mirror of
https://github.com/ihabunek/toot
synced 2025-01-24 16:31:49 +01:00
tui: allow editing toots
Add new [E]dit command to the timeline: opens an existing toot to allow editing it. Since this is more or less the same operation as posting a new toot, extend the StatusComposer view to support this rather than implementing a new view. Add a new api method, fetch_status_source(), to implement the /api/v1/statuses/{id}/source endpoint used to fetch the original post text.
This commit is contained in:
parent
301c8d21df
commit
ec48e8eed8
55
toot/api.py
55
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
|
||||
|
@ -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
|
||||
|
||||
|
18
toot/http.py
18
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
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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())
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user