mirror of
https://github.com/ihabunek/toot.git
synced 2024-09-29 04:35:54 -04:00
Merge pull request #451 from lexiwinter/edit-toot
tui: allow editing toots
This commit is contained in:
commit
7f0692891e
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)
|
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):
|
def fetch_status(app, user, id):
|
||||||
"""
|
"""
|
||||||
Fetch a single status
|
Fetch a single status
|
||||||
@ -238,6 +284,15 @@ def fetch_status(app, user, id):
|
|||||||
return http.get(app, user, f"/api/v1/statuses/{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):
|
def scheduled_statuses(app, user):
|
||||||
"""
|
"""
|
||||||
List scheduled statuses
|
List scheduled statuses
|
||||||
|
18
toot/http.py
18
toot/http.py
@ -38,7 +38,7 @@ def _get_error_message(response):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return "Unknown error"
|
return f"Unknown error: {response.status_code} {response.reason}"
|
||||||
|
|
||||||
|
|
||||||
def process_response(response):
|
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)
|
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):
|
def patch(app, user, path, headers=None, files=None, data=None, json=None):
|
||||||
url = app.base_url + path
|
url = app.base_url + path
|
||||||
|
|
||||||
|
@ -4,11 +4,13 @@ import urwid
|
|||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from typing import NamedTuple, Optional
|
from typing import NamedTuple, Optional
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from toot import api, config, __version__, settings
|
from toot import api, config, __version__, settings
|
||||||
from toot import App, User
|
from toot import App, User
|
||||||
from toot.cli import get_default_visibility
|
from toot.cli import get_default_visibility
|
||||||
from toot.exceptions import ApiError
|
from toot.exceptions import ApiError
|
||||||
|
from toot.utils.datetime import parse_datetime
|
||||||
|
|
||||||
from .compose import StatusComposer
|
from .compose import StatusComposer
|
||||||
from .constants import PALETTE
|
from .constants import PALETTE
|
||||||
@ -18,6 +20,7 @@ from .overlays import StatusDeleteConfirmation, Account
|
|||||||
from .poll import Poll
|
from .poll import Poll
|
||||||
from .timeline import Timeline
|
from .timeline import Timeline
|
||||||
from .utils import get_max_toot_chars, parse_content_links, copy_to_clipboard
|
from .utils import get_max_toot_chars, parse_content_links, copy_to_clipboard
|
||||||
|
from .widgets import ModalBox
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -429,6 +432,32 @@ class TUI(urwid.Frame):
|
|||||||
urwid.connect_signal(composer, "post", _post)
|
urwid.connect_signal(composer, "post", _post)
|
||||||
self.open_overlay(composer, title="Compose status")
|
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):
|
def show_goto_menu(self):
|
||||||
user_timelines = self.config.get("timelines", {})
|
user_timelines = self.config.get("timelines", {})
|
||||||
user_lists = api.get_lists(self.app, self.user) or []
|
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.footer.set_message("Status posted {} \\o/".format(status.id))
|
||||||
self.close_overlay()
|
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):
|
def show_account(self, account_id):
|
||||||
account = api.whois(self.app, self.user, account_id)
|
account = api.whois(self.app, self.user, account_id)
|
||||||
relationship = api.get_relationship(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):
|
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"]
|
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.in_reply_to = in_reply_to
|
||||||
self.max_chars = max_chars
|
self.max_chars = max_chars
|
||||||
self.username = username
|
self.username = username
|
||||||
|
self.edit = edit
|
||||||
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.cw_edit = None
|
self.cw_edit = None
|
||||||
self.cw_add_button = Button("Add content warning",
|
self.cw_add_button = Button("Add content warning",
|
||||||
@ -31,13 +32,34 @@ class StatusComposer(urwid.Frame):
|
|||||||
self.cw_remove_button = Button("Remove content warning",
|
self.cw_remove_button = Button("Remove content warning",
|
||||||
on_press=self.remove_content_warning)
|
on_press=self.remove_content_warning)
|
||||||
|
|
||||||
self.visibility = (
|
if edit:
|
||||||
in_reply_to.visibility if in_reply_to else visibility
|
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),
|
self.visibility_button = Button("Visibility: {}".format(self.visibility),
|
||||||
on_press=self.choose_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)
|
self.cancel_button = Button("Cancel", on_press=self.close)
|
||||||
|
|
||||||
contents = list(self.generate_list_items())
|
contents = list(self.generate_list_items())
|
||||||
|
@ -101,6 +101,7 @@ class Timeline(urwid.Columns):
|
|||||||
"[A]ccount" if not status.is_mine else "",
|
"[A]ccount" if not status.is_mine else "",
|
||||||
"[B]oost",
|
"[B]oost",
|
||||||
"[D]elete" if status.is_mine else "",
|
"[D]elete" if status.is_mine else "",
|
||||||
|
"[E]dit" if status.is_mine else "",
|
||||||
"B[o]okmark",
|
"B[o]okmark",
|
||||||
"[F]avourite",
|
"[F]avourite",
|
||||||
"[V]iew",
|
"[V]iew",
|
||||||
@ -189,6 +190,11 @@ class Timeline(urwid.Columns):
|
|||||||
self.tui.show_delete_confirmation(status)
|
self.tui.show_delete_confirmation(status)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if key in ("e", "E"):
|
||||||
|
if status.is_mine:
|
||||||
|
self.tui.async_edit(status)
|
||||||
|
return
|
||||||
|
|
||||||
if key in ("f", "F"):
|
if key in ("f", "F"):
|
||||||
self.tui.async_toggle_favourite(self, status)
|
self.tui.async_toggle_favourite(self, status)
|
||||||
return
|
return
|
||||||
|
@ -67,3 +67,11 @@ class RadioButton(urwid.AttrWrap):
|
|||||||
button = urwid.RadioButton(*args, **kwargs)
|
button = urwid.RadioButton(*args, **kwargs)
|
||||||
padding = urwid.Padding(button, width=len(args[1]) + 4)
|
padding = urwid.Padding(button, width=len(args[1]) + 4)
|
||||||
return super().__init__(padding, "button", "button_focused")
|
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