Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

tui: allow editing toots #451

Merged
merged 1 commit into from
Jan 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions toot/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,52 @@ def post_status(
return http.post(app, user, '/api/v1/statuses', json=data, headers=headers)


def edit_status(
app,
user,
id,
status,
visibility='public',
media_ids=None,
sensitive=False,
spoiler_text=None,
in_reply_to_id=None,
language=None,
content_type=None,
poll_options=None,
poll_expires_in=None,
poll_multiple=None,
poll_hide_totals=None,
) -> Response:
"""
Edit an existing status
https://docs.joinmastodon.org/methods/statuses/#edit
"""

# Strip keys for which value is None
# Sending null values doesn't bother Mastodon, but it breaks Pleroma
data = drop_empty_values({
'status': status,
'media_ids': media_ids,
'visibility': visibility,
'sensitive': sensitive,
'in_reply_to_id': in_reply_to_id,
'language': language,
'content_type': content_type,
'spoiler_text': spoiler_text,
})

if poll_options:
data["poll"] = {
"options": poll_options,
"expires_in": poll_expires_in,
"multiple": poll_multiple,
"hide_totals": poll_hide_totals,
}

return http.put(app, user, f"/api/v1/statuses/{id}", json=data)


def fetch_status(app, user, id):
"""
Fetch a single status
Expand All @@ -238,6 +284,15 @@ def fetch_status(app, user, id):
return http.get(app, user, f"/api/v1/statuses/{id}")


def fetch_status_source(app, user, id):
"""
Fetch the source (original text) for a single status.
This only works on local toots.
https://docs.joinmastodon.org/methods/statuses/#source
"""
return http.get(app, user, f"/api/v1/statuses/{id}/source")


def scheduled_statuses(app, user):
"""
List scheduled statuses
Expand Down
1 change: 0 additions & 1 deletion toot/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import typing as t

from click.shell_completion import CompletionItem
from click.testing import Result
from click.types import StringParamType
from functools import wraps

Expand Down
18 changes: 17 additions & 1 deletion toot/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def _get_error_message(response):
except Exception:
pass

return "Unknown error"
return f"Unknown error: {response.status_code} {response.reason}"


def process_response(response):
Expand Down Expand Up @@ -81,6 +81,22 @@ def post(app, user, path, headers=None, files=None, data=None, json=None, allow_
return anon_post(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects)


def anon_put(url, headers=None, files=None, data=None, json=None, allow_redirects=True):
request = Request(method="PUT", url=url, headers=headers, files=files, data=data, json=json)
response = send_request(request, allow_redirects)

return process_response(response)


def put(app, user, path, headers=None, files=None, data=None, json=None, allow_redirects=True):
url = app.base_url + path

headers = headers or {}
headers["Authorization"] = f"Bearer {user.access_token}"

return anon_put(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects)


def patch(app, user, path, headers=None, files=None, data=None, json=None):
url = app.base_url + path

Expand Down
65 changes: 65 additions & 0 deletions toot/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@

from concurrent.futures import ThreadPoolExecutor
from typing import NamedTuple, Optional
from datetime import datetime, timezone

from toot import api, config, __version__, settings
from toot import App, User
from toot.cli import get_default_visibility
from toot.exceptions import ApiError
from toot.utils.datetime import parse_datetime

from .compose import StatusComposer
from .constants import PALETTE
Expand All @@ -18,6 +20,7 @@
from .poll import Poll
from .timeline import Timeline
from .utils import get_max_toot_chars, parse_content_links, copy_to_clipboard
from .widgets import ModalBox

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -429,6 +432,32 @@ def _post(timeline, *args):
urwid.connect_signal(composer, "post", _post)
self.open_overlay(composer, title="Compose status")

def async_edit(self, status):
def _fetch_source():
return api.fetch_status_source(self.app, self.user, status.id).json()

def _done(source):
self.close_overlay()
self.show_edit(status, source)

please_wait = ModalBox("Loading status...")
self.open_overlay(please_wait)

self.run_in_thread(_fetch_source, done_callback=_done)

def show_edit(self, status, source):
def _close(*args):
self.close_overlay()

def _edit(timeline, *args):
self.edit_status(status, *args)

composer = StatusComposer(self.max_toot_chars, self.user.username,
visibility=None, edit=status, source=source)
urwid.connect_signal(composer, "close", _close)
urwid.connect_signal(composer, "post", _edit)
self.open_overlay(composer, title="Edit status")

def show_goto_menu(self):
user_timelines = self.config.get("timelines", {})
user_lists = api.get_lists(self.app, self.user) or []
Expand Down Expand Up @@ -576,6 +605,42 @@ def post_status(self, content, warning, visibility, in_reply_to_id):
self.footer.set_message("Status posted {} \\o/".format(status.id))
self.close_overlay()

def edit_status(self, status, content, warning, visibility, in_reply_to_id):
# We don't support editing polls (yet), so to avoid losing the poll
# data from the original toot, copy it to the edit request.
poll_args = {}
poll = status.original.data.get('poll', None)

if poll is not None:
poll_args['poll_options'] = [o['title'] for o in poll['options']]
poll_args['poll_multiple'] = poll['multiple']

# Convert absolute expiry time into seconds from now.
expires_at = parse_datetime(poll['expires_at'])
expires_in = int((expires_at - datetime.now(timezone.utc)).total_seconds())
poll_args['poll_expires_in'] = expires_in

if 'hide_totals' in poll:
poll_args['poll_hide_totals'] = poll['hide_totals']

data = api.edit_status(
self.app,
self.user,
status.id,
content,
spoiler_text=warning,
visibility=visibility,
**poll_args
).json()

new_status = self.make_status(data)

self.footer.set_message("Status edited {} \\o/".format(status.id))
self.close_overlay()

if self.timeline is not None:
self.timeline.update_status(new_status)

def show_account(self, account_id):
account = api.whois(self.app, self.user, account_id)
relationship = api.get_relationship(self.app, self.user, account_id)
Expand Down
48 changes: 35 additions & 13 deletions toot/tui/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,57 @@

class StatusComposer(urwid.Frame):
"""
UI for compose and posting a status message.
UI for composing or editing a status message.

To edit a status, provide the original status in 'edit', and optionally
provide the status source (from the /status/:id/source API endpoint) in
'source'; this should have at least a 'text' member, and optionally
'spoiler_text'. If source is not provided, the formatted HTML will be
presented to the user for editing.
"""
signals = ["close", "post"]

def __init__(self, max_chars, username, visibility, in_reply_to=None):
def __init__(self, max_chars, username, visibility, in_reply_to=None,
edit=None, source=None):
self.in_reply_to = in_reply_to
self.max_chars = max_chars
self.username = username

text = self.get_initial_text(in_reply_to)
self.content_edit = EditBox(
edit_text=text, edit_pos=len(text), multiline=True, allow_tab=True)
urwid.connect_signal(self.content_edit.edit, "change", self.text_changed)

self.char_count = urwid.Text(["0/{}".format(max_chars)])
self.edit = edit

self.cw_edit = None
self.cw_add_button = Button("Add content warning",
on_press=self.add_content_warning)
self.cw_remove_button = Button("Remove content warning",
on_press=self.remove_content_warning)

self.visibility = (
in_reply_to.visibility if in_reply_to else visibility
)
if edit:
if source is None:
text = edit.data["content"]
else:
text = source.get("text", edit.data["content"])

if 'spoiler_text' in source:
self.cw_edit = EditBox(multiline=True, allow_tab=True,
edit_text=source['spoiler_text'])

self.visibility = edit.data["visibility"]

else: # not edit
text = self.get_initial_text(in_reply_to)
self.visibility = (
in_reply_to.visibility if in_reply_to else visibility
)

self.content_edit = EditBox(
edit_text=text, edit_pos=len(text), multiline=True, allow_tab=True)
urwid.connect_signal(self.content_edit.edit, "change", self.text_changed)

self.char_count = urwid.Text(["0/{}".format(max_chars)])

self.visibility_button = Button("Visibility: {}".format(self.visibility),
on_press=self.choose_visibility)

self.post_button = Button("Post", on_press=self.post)
self.post_button = Button("Edit" if edit else "Post", on_press=self.post)
self.cancel_button = Button("Cancel", on_press=self.close)

contents = list(self.generate_list_items())
Expand Down
6 changes: 6 additions & 0 deletions toot/tui/timeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ def get_option_text(self, status: Optional[Status]) -> Optional[urwid.Text]:
"[A]ccount" if not status.is_mine else "",
"[B]oost",
"[D]elete" if status.is_mine else "",
"[E]dit" if status.is_mine else "",
"B[o]okmark",
"[F]avourite",
"[V]iew",
Expand Down Expand Up @@ -189,6 +190,11 @@ def keypress(self, size, key):
self.tui.show_delete_confirmation(status)
return

if key in ("e", "E"):
if status.is_mine:
self.tui.async_edit(status)
return

if key in ("f", "F"):
self.tui.async_toggle_favourite(self, status)
return
Expand Down
8 changes: 8 additions & 0 deletions toot/tui/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,11 @@ def __init__(self, *args, **kwargs):
button = urwid.RadioButton(*args, **kwargs)
padding = urwid.Padding(button, width=len(args[1]) + 4)
return super().__init__(padding, "button", "button_focused")


class ModalBox(urwid.Frame):
def __init__(self, message):
text = urwid.Text(message)
filler = urwid.Filler(text, valign='top', top=1, bottom=1)
padding = urwid.Padding(filler, left=1, right=1)
return super().__init__(padding)