diff --git a/toot/api.py b/toot/api.py index 0033eb4..3509f47 100644 --- a/toot/api.py +++ b/toot/api.py @@ -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 @@ -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 diff --git a/toot/cli/__init__.py b/toot/cli/__init__.py index 5ebd196..21f4002 100644 --- a/toot/cli/__init__.py +++ b/toot/cli/__init__.py @@ -5,7 +5,6 @@ import sys import typing as t from click.shell_completion import CompletionItem -from click.testing import Result from click.types import StringParamType from functools import wraps diff --git a/toot/http.py b/toot/http.py index 14acdd0..ec4b62a 100644 --- a/toot/http.py +++ b/toot/http.py @@ -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): @@ -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 diff --git a/toot/tui/app.py b/toot/tui/app.py index 5d9982b..5790d2c 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -4,11 +4,13 @@ import urwid 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 @@ -18,6 +20,7 @@ from .overlays import StatusDeleteConfirmation, Account 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__) @@ -429,6 +432,32 @@ class TUI(urwid.Frame): 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 [] @@ -576,6 +605,42 @@ class TUI(urwid.Frame): 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) diff --git a/toot/tui/compose.py b/toot/tui/compose.py index ea60fa6..a931fa3 100644 --- a/toot/tui/compose.py +++ b/toot/tui/compose.py @@ -9,21 +9,22 @@ logger = logging.getLogger(__name__) 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", @@ -31,13 +32,34 @@ class StatusComposer(urwid.Frame): 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()) diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index 0cc7756..679c6b7 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -101,6 +101,7 @@ class Timeline(urwid.Columns): "[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", @@ -189,6 +190,11 @@ class Timeline(urwid.Columns): 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 diff --git a/toot/tui/widgets.py b/toot/tui/widgets.py index f2ae4b8..2c8f907 100644 --- a/toot/tui/widgets.py +++ b/toot/tui/widgets.py @@ -67,3 +67,11 @@ class RadioButton(urwid.AttrWrap): 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)