From 29ff62946461afe1d6bfa27a2a5338134412da5f Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sun, 11 Dec 2022 23:46:54 +0100 Subject: [PATCH 01/54] Add toot env command --- toot/commands.py | 9 ++++++++- toot/console.py | 6 ++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/toot/commands.py b/toot/commands.py index 7319e7e..5cbde45 100644 --- a/toot/commands.py +++ b/toot/commands.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- import sys +import platform from datetime import datetime, timedelta, timezone -from toot import api, config +from toot import api, config, __version__ from toot.auth import login_interactive, login_browser_interactive, create_app_interactive from toot.exceptions import ApiError, ConsoleError from toot.output import (print_out, print_instance, print_account, print_acct_list, @@ -220,6 +221,12 @@ def auth(app, user, args): print_out("\nAuth tokens are stored in: {}".format(path)) +def env(app, user, args): + print_out(f"toot {__version__}") + print_out(f"Python {sys.version}") + print_out(platform.platform()) + + def login_cli(app, user, args): app = create_app_interactive(instance=args.instance, scheme=args.scheme) login_interactive(app, args.email) diff --git a/toot/console.py b/toot/console.py index 71ced98..dbb681a 100644 --- a/toot/console.py +++ b/toot/console.py @@ -223,6 +223,12 @@ AUTH_COMMANDS = [ arguments=[], require_auth=False, ), + Command( + name="env", + description="Print environment information for inclusion in bug reports.", + arguments=[], + require_auth=False, + ), ] TUI_COMMANDS = [ From 5c98d4ac803dbd8e5abf2156ae3f2a449d313b46 Mon Sep 17 00:00:00 2001 From: Lim Ding Wen Date: Thu, 22 Dec 2022 04:16:03 +0800 Subject: [PATCH 02/54] Show error if trying to boost unboostables in TUI --- toot/tui/app.py | 7 +++++++ toot/tui/entities.py | 1 + 2 files changed, 8 insertions(+) diff --git a/toot/tui/app.py b/toot/tui/app.py index 037506e..fe12a87 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -497,6 +497,13 @@ class TUI(urwid.Frame): new_status = self.make_status(new_data) timeline.update_status(new_status) + # Check if status is rebloggable + no_reblog_because_private = status.visibility == "private" and not status.is_mine + no_reblog_because_direct = status.visibility == "direct" + if no_reblog_because_private or no_reblog_because_direct: + self.footer.set_error_message("You may not reblog this {} status".format(status.visibility)) + return + self.run_in_thread( _unreblog if status.reblogged else _reblog, done_callback=_done diff --git a/toot/tui/entities.py b/toot/tui/entities.py index 5722b64..082e664 100644 --- a/toot/tui/entities.py +++ b/toot/tui/entities.py @@ -60,6 +60,7 @@ class Status: self.url = data.get("url") self.mentions = data.get("mentions") self.reblog = self._get_reblog() + self.visibility = data.get("visibility") @property def original(self): From 8595e39f4ce06d93bce2e72dedbde064d3d559cb Mon Sep 17 00:00:00 2001 From: Daniel Schwarz Date: Tue, 27 Dec 2022 04:53:58 -0500 Subject: [PATCH 03/54] Hide polls and media for sensitive toots --- toot/tui/timeline.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index f69640c..e912fe1 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -277,24 +277,24 @@ class StatusDetails(urwid.Pile): for line in format_content(content): yield ("pack", urwid.Text(highlight_hashtags(line))) - media = status.data["media_attachments"] - if media: - for m in media: - yield ("pack", urwid.AttrMap(urwid.Divider("-"), "gray")) - yield ("pack", urwid.Text([("bold", "Media attachment"), " (", m["type"], ")"])) - if m["description"]: - yield ("pack", urwid.Text(m["description"])) - yield ("pack", urwid.Text(("link", m["url"]))) + media = status.data["media_attachments"] + if media: + for m in media: + yield ("pack", urwid.AttrMap(urwid.Divider("-"), "gray")) + yield ("pack", urwid.Text([("bold", "Media attachment"), " (", m["type"], ")"])) + if m["description"]: + yield ("pack", urwid.Text(m["description"])) + yield ("pack", urwid.Text(("link", m["url"]))) - poll = status.data.get("poll") - if poll: - yield ("pack", urwid.Divider()) - yield ("pack", self.build_linebox(self.poll_generator(poll))) + poll = status.data.get("poll") + if poll: + yield ("pack", urwid.Divider()) + yield ("pack", self.build_linebox(self.poll_generator(poll))) - card = status.data.get("card") - if card: - yield ("pack", urwid.Divider()) - yield ("pack", self.build_linebox(self.card_generator(card))) + card = status.data.get("card") + if card: + yield ("pack", urwid.Divider()) + yield ("pack", self.build_linebox(self.card_generator(card))) application = status.data.get("application") or {} application = application.get("name") From ded7a0c50d244b10e0a59f7d7accad868c1f208a Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 27 Dec 2022 10:41:06 +0100 Subject: [PATCH 04/54] Fix flake8 errors --- .flake8 | 3 ++- toot/tui/app.py | 2 +- toot/utils/__init__.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.flake8 b/.flake8 index 8e61f20..634f10a 100644 --- a/.flake8 +++ b/.flake8 @@ -1,3 +1,4 @@ [flake8] -max-line-length=100 +exclude=build,tests ignore=E128 +max-line-length=120 diff --git a/toot/tui/app.py b/toot/tui/app.py index fe12a87..62ea2c3 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -521,7 +521,7 @@ class TUI(urwid.Frame): else: self.footer.set_error_message("Server returned empty translation") response = None - except: + except Exception: response = None self.footer.set_error_message("Translate server error") diff --git a/toot/utils/__init__.py b/toot/utils/__init__.py index f1b0736..40e0daf 100644 --- a/toot/utils/__init__.py +++ b/toot/utils/__init__.py @@ -41,7 +41,7 @@ def parse_html(html): paragraphs = [re.split("
", p) for p in paragraphs if p] # Convert each line in each paragraph to plain text: - return [[get_text(l) for l in p] for p in paragraphs] + return [[get_text(line) for line in p] for p in paragraphs] def format_content(content): From fed5574939480613d15fe5440ee27f743a57402e Mon Sep 17 00:00:00 2001 From: Daniel Schwarz Date: Tue, 27 Dec 2022 11:39:50 +0100 Subject: [PATCH 05/54] Add bookmark timeline and bookmarking to tui --- toot/api.py | 6 ++++++ toot/tui/app.py | 33 ++++++++++++++++++++++++++++++++- toot/tui/constants.py | 1 + toot/tui/entities.py | 1 + toot/tui/overlays.py | 8 +++++++- toot/tui/timeline.py | 8 ++++++++ 6 files changed, 55 insertions(+), 2 deletions(-) diff --git a/toot/api.py b/toot/api.py index 1ce1ec0..17458e7 100644 --- a/toot/api.py +++ b/toot/api.py @@ -260,6 +260,12 @@ def tag_timeline_generator(app, user, hashtag, local=False, limit=20): return _timeline_generator(app, user, path, params) +def bookmark_timeline_generator(app, user, limit=20): + path = '/api/v1/bookmarks' + params = {'limit': limit} + return _timeline_generator(app, user, path, params) + + def timeline_list_generator(app, user, list_id, limit=20): path = '/api/v1/timelines/list/{}'.format(list_id) return _timeline_generator(app, user, path, {'limit': limit}) diff --git a/toot/tui/app.py b/toot/tui/app.py index 62ea2c3..2dd53dc 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -196,6 +196,7 @@ class TUI(urwid.Frame): def _zoom(timeline, status_details): self.show_status_zoom(status_details) + urwid.connect_signal(timeline, "bookmark", self.async_toggle_bookmark) urwid.connect_signal(timeline, "compose", _compose) urwid.connect_signal(timeline, "delete", _delete) urwid.connect_signal(timeline, "favourite", self.async_toggle_favourite) @@ -390,12 +391,15 @@ class TUI(urwid.Frame): lambda x: self.goto_home_timeline()) urwid.connect_signal(menu, "public_timeline", lambda x, local: self.goto_public_timeline(local)) + urwid.connect_signal(menu, "bookmark_timeline", + lambda x, local: self.goto_bookmarks()) + urwid.connect_signal(menu, "hashtag_timeline", lambda x, tag, local: self.goto_tag_timeline(tag, local=local)) self.open_overlay(menu, title="Go to", options=dict( align="center", width=("relative", 60), - valign="middle", height=9 + len(user_timelines), + valign="middle", height=10 + len(user_timelines), )) def show_help(self): @@ -413,6 +417,12 @@ class TUI(urwid.Frame): promise = self.async_load_timeline(is_initial=True, timeline_name="public") promise.add_done_callback(lambda *args: self.close_overlay()) + def goto_bookmarks(self): + self.timeline_generator = api.bookmark_timeline_generator( + self.app, self.user, limit=40) + promise = self.async_load_timeline(is_initial=True, timeline_name="bookmarks") + promise.add_done_callback(lambda *args: self.close_overlay()) + def goto_tag_timeline(self, tag, local): self.timeline_generator = api.tag_timeline_generator( self.app, self.user, tag, local=local, limit=40) @@ -542,6 +552,27 @@ class TUI(urwid.Frame): else: self.run_in_thread(_translate, done_callback=_done) + def async_toggle_bookmark(self, timeline, status): + def _bookmark(): + logger.info("Bookmarking {}".format(status)) + api.bookmark(self.app, self.user, status.id) + + def _unbookmark(): + logger.info("Unbookmarking {}".format(status)) + api.unbookmark(self.app, self.user, status.id) + + def _done(loop): + # Create a new Status with flipped bookmarked flag + new_data = status.data + new_data["bookmarked"] = not status.bookmarked + new_status = self.make_status(new_data) + timeline.update_status(new_status) + + self.run_in_thread( + _unbookmark if status.bookmarked else _bookmark, + done_callback=_done + ) + def async_delete_status(self, timeline, status): def _delete(): api.delete_status(self.app, self.user, status.id) diff --git a/toot/tui/constants.py b/toot/tui/constants.py index e2a2a5c..563eb8e 100644 --- a/toot/tui/constants.py +++ b/toot/tui/constants.py @@ -34,6 +34,7 @@ PALETTE = [ ('green_selected', 'white,bold', 'dark green'), ('yellow', 'yellow', ''), ('yellow_bold', 'yellow,bold', ''), + ('red', 'dark red', ''), ('warning', 'light red', ''), ] diff --git a/toot/tui/entities.py b/toot/tui/entities.py index 082e664..a30bcb6 100644 --- a/toot/tui/entities.py +++ b/toot/tui/entities.py @@ -56,6 +56,7 @@ class Status: self.author = self._get_author() self.favourited = data.get("favourited", False) self.reblogged = data.get("reblogged", False) + self.bookmarked = data.get("bookmarked", False) self.in_reply_to = data.get("in_reply_to_id") self.url = data.get("url") self.mentions = data.get("mentions") diff --git a/toot/tui/overlays.py b/toot/tui/overlays.py index 51ad22f..9af13ce 100644 --- a/toot/tui/overlays.py +++ b/toot/tui/overlays.py @@ -45,7 +45,7 @@ class StatusLinks(urwid.ListBox): class ExceptionStackTrace(urwid.ListBox): """Shows an exception stack trace.""" def __init__(self, ex): - lines = traceback.format_exception(etype=type(ex), value=ex, tb=ex.__traceback__) + lines = traceback.format_exception(type(ex), value=ex, tb=ex.__traceback__) walker = urwid.SimpleFocusListWalker([ urwid.Text(line) for line in lines ]) @@ -74,6 +74,7 @@ class GotoMenu(urwid.ListBox): "home_timeline", "public_timeline", "hashtag_timeline", + "bookmark_timeline", ] def __init__(self, user_timelines): @@ -96,6 +97,9 @@ class GotoMenu(urwid.ListBox): def _global_public(button): self._emit("public_timeline", False) + def _bookmarks(button): + self._emit("bookmark_timeline", False) + def _hashtag(local): hashtag = self.get_hashtag() if hashtag: @@ -117,6 +121,7 @@ class GotoMenu(urwid.ListBox): yield Button("Local public timeline", on_press=_local_public) yield Button("Global public timeline", on_press=_global_public) + yield Button("Bookmarks", on_press=_bookmarks) yield urwid.Divider() yield self.hash_edit yield Button("Local hashtag timeline", on_press=lambda x: _hashtag(True)) @@ -164,6 +169,7 @@ class Help(urwid.Padding): yield urwid.Text(h(" [B] - Boost/unboost status")) yield urwid.Text(h(" [C] - Compose new status")) yield urwid.Text(h(" [F] - Favourite/unfavourite status")) + yield urwid.Text(h(" [K] - Bookmark/unbookmark status")) yield urwid.Text(h(" [N] - Translate status if possible (toggle)")) yield urwid.Text(h(" [R] - Reply to current status")) yield urwid.Text(h(" [S] - Show text marked as sensitive")) diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index e912fe1..2521c7a 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -21,6 +21,7 @@ class Timeline(urwid.Columns): "delete", # Delete own status "favourite", # Favourite status "focus", # Focus changed + "bookmark", # Bookmark status "media", # Display media attachments "menu", # Show a context menu "next", # Fetch more statuses @@ -67,6 +68,7 @@ class Timeline(urwid.Columns): "green": "green_selected", "yellow": "green_selected", "cyan": "green_selected", + "red": "green_selected", None: "green_selected", }) @@ -156,6 +158,10 @@ class Timeline(urwid.Columns): self.refresh_status_details() return + if key in ("k", "K"): + self._emit("bookmark", status) + return + if key in ("l", "L"): self._emit("links", status) return @@ -308,6 +314,7 @@ class StatusDetails(urwid.Pile): ) yield ("pack", urwid.Text([ + ("red" if status.bookmarked else "gray", "🠷" if status.bookmarked else " "), ("gray", f"⤶ {status.data['replies_count']} "), ("yellow" if status.reblogged else "gray", f"♺ {status.data['reblogs_count']} "), ("yellow" if status.favourited else "gray", f"★ {status.data['favourites_count']}"), @@ -322,6 +329,7 @@ class StatusDetails(urwid.Pile): "[B]oost", "[D]elete" if status.is_mine else "", "[F]avourite", + "Boo[k]mark", "[V]iew", "[T]hread" if not self.in_thread else "", "[L]inks", From adf3f713a6a692928ae86775d4562904d30d5c93 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 27 Dec 2022 11:46:29 +0100 Subject: [PATCH 06/54] Change bookmark key binding to avoid conflict K is used to scroll up vim-style. --- toot/tui/timeline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index 2521c7a..70134b7 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -158,7 +158,7 @@ class Timeline(urwid.Columns): self.refresh_status_details() return - if key in ("k", "K"): + if key in ("o", "O"): self._emit("bookmark", status) return @@ -329,7 +329,7 @@ class StatusDetails(urwid.Pile): "[B]oost", "[D]elete" if status.is_mine else "", "[F]avourite", - "Boo[k]mark", + "B[o]okmark", "[V]iew", "[T]hread" if not self.in_thread else "", "[L]inks", From 69b9ab31642bf51be03f56796f4743bef587bd83 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 27 Dec 2022 12:02:23 +0100 Subject: [PATCH 07/54] Truncate long log lines unless --verbose given --- toot/logging.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/toot/logging.py b/toot/logging.py index e612020..7634a87 100644 --- a/toot/logging.py +++ b/toot/logging.py @@ -1,9 +1,12 @@ import json +import sys from logging import getLogger logger = getLogger('toot') +VERBOSE = "--verbose" in sys.argv + def censor_secrets(headers): def _censor(k, v): @@ -14,6 +17,13 @@ def censor_secrets(headers): return {_censor(k, v) for k, v in headers.items()} +def truncate(line): + if not VERBOSE and len(line) > 100: + return line[:100] + "…" + + return line + + def log_request(request): logger.debug(">>> \033[32m{} {}\033[0m".format(request.method, request.url)) @@ -22,10 +32,12 @@ def log_request(request): logger.debug(">>> HEADERS: \033[33m{}\033[0m".format(headers)) if request.data: - logger.debug(">>> DATA: \033[33m{}\033[0m".format(request.data)) + data = truncate(request.data) + logger.debug(">>> DATA: \033[33m{}\033[0m".format(data)) if request.json: - logger.debug(">>> JSON: \033[33m{}\033[0m".format(json.dumps(request.json))) + data = truncate(json.dumps(request.json)) + logger.debug(">>> JSON: \033[33m{}\033[0m".format(data)) if request.files: logger.debug(">>> FILES: \033[33m{}\033[0m".format(request.files)) @@ -35,12 +47,14 @@ def log_request(request): def log_response(response): + content = truncate(response.content.decode()) + if response.ok: logger.debug("<<< \033[32m{}\033[0m".format(response)) - logger.debug("<<< \033[33m{}\033[0m".format(response.content.decode())) + logger.debug("<<< \033[33m{}\033[0m".format(content)) else: logger.debug("<<< \033[31m{}\033[0m".format(response)) - logger.debug("<<< \033[31m{}\033[0m".format(response.content.decode())) + logger.debug("<<< \033[31m{}\033[0m".format(content)) def log_debug(*msgs): From f534d295c0365c1f7242636d3f9aac5bf102f822 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 27 Dec 2022 12:11:34 +0100 Subject: [PATCH 08/54] Make line more readable --- toot/tui/timeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index 70134b7..26b67ab 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -314,7 +314,7 @@ class StatusDetails(urwid.Pile): ) yield ("pack", urwid.Text([ - ("red" if status.bookmarked else "gray", "🠷" if status.bookmarked else " "), + ("red", "🠷 ") if status.bookmarked else "", ("gray", f"⤶ {status.data['replies_count']} "), ("yellow" if status.reblogged else "gray", f"♺ {status.data['reblogs_count']} "), ("yellow" if status.favourited else "gray", f"★ {status.data['favourites_count']}"), From 5d4bb3c464f03f4cac2e568aef14aba171ae16c1 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 27 Dec 2022 12:16:18 +0100 Subject: [PATCH 09/54] Run flake8 in CI --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 244b008..fcc77c9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,3 +28,6 @@ jobs: - name: Validate minimum required version run: | vermin --target=3.6 --no-tips . + - name: Check style + run: | + flake8 From da0df926154f6a1e39587490dd90b3e0a1b02c04 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 27 Dec 2022 12:17:31 +0100 Subject: [PATCH 10/54] Add flake8 to test dependencies --- requirements-test.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-test.txt b/requirements-test.txt index 35d9578..3a35c72 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,3 +1,4 @@ +flake8 psycopg2-binary pytest pytest-xdist[psutil] From 8ffe8d281fcbfc0c9542408d4cb1a88582179a17 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 27 Dec 2022 12:31:55 +0100 Subject: [PATCH 11/54] Fix style issues --- toot/commands.py | 2 +- toot/tui/timeline.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/toot/commands.py b/toot/commands.py index 5cbde45..e334b30 100644 --- a/toot/commands.py +++ b/toot/commands.py @@ -43,7 +43,7 @@ def get_timeline_generator(app, user, args): def timeline(app, user, args): generator = get_timeline_generator(app, user, args) - while(True): + while True: try: items = next(generator) except StopIteration: diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index 26b67ab..120feba 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -236,8 +236,8 @@ class Timeline(urwid.Columns): index = self.get_status_index(status.id) assert self.statuses[index].id == status.id # Sanity check - del(self.statuses[index]) - del(self.status_list.body[index]) + del self.statuses[index] + del self.status_list.body[index] self.refresh_status_details() From 064cab1988a3d1144ace4fc9674c9f7b0277ef96 Mon Sep 17 00:00:00 2001 From: Lim Ding Wen Date: Thu, 22 Dec 2022 07:30:29 +0800 Subject: [PATCH 12/54] Show visibility in TUI --- toot/tui/timeline.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index 120feba..25559b8 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -313,11 +313,19 @@ class StatusDetails(urwid.Pile): else None ) + visibility_colors = { + "public": "gray", + "unlisted": "white", + "private": "cyan", + "direct": "yellow" + } + yield ("pack", urwid.Text([ ("red", "🠷 ") if status.bookmarked else "", ("gray", f"⤶ {status.data['replies_count']} "), ("yellow" if status.reblogged else "gray", f"♺ {status.data['reblogs_count']} "), ("yellow" if status.favourited else "gray", f"★ {status.data['favourites_count']}"), + (visibility_colors[status.visibility], f" · {status.visibility}"), ("yellow", f" · Translated from {translated_from} ") if translated_from else "", ("gray", f" · {application}" if application else ""), ])) From fa6b90a115d966f410bb839d34689576f27aeaea Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Wed, 28 Dec 2022 07:48:53 +0100 Subject: [PATCH 13/54] Tweak visibility display --- toot/tui/timeline.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index 25559b8..ac95469 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -320,12 +320,15 @@ class StatusDetails(urwid.Pile): "direct": "yellow" } + visibility = status.visibility.title() + visibility_color = visibility_colors.get(status.visibility, "gray") + yield ("pack", urwid.Text([ ("red", "🠷 ") if status.bookmarked else "", ("gray", f"⤶ {status.data['replies_count']} "), ("yellow" if status.reblogged else "gray", f"♺ {status.data['reblogs_count']} "), ("yellow" if status.favourited else "gray", f"★ {status.data['favourites_count']}"), - (visibility_colors[status.visibility], f" · {status.visibility}"), + (visibility_color, f" · {visibility}"), ("yellow", f" · Translated from {translated_from} ") if translated_from else "", ("gray", f" · {application}" if application else ""), ])) From 69718f41f6f32d431cb88b041e5db951137d8dc2 Mon Sep 17 00:00:00 2001 From: Lim Ding Wen Date: Thu, 22 Dec 2022 06:17:19 +0800 Subject: [PATCH 14/54] Reply to original account instead of boosting account Affects the "replying to" TUI label, and the mention auto-generated. This brings it more in-line with Mastodon web behaviour. --- toot/tui/compose.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/toot/tui/compose.py b/toot/tui/compose.py index 68228b1..913ca3e 100644 --- a/toot/tui/compose.py +++ b/toot/tui/compose.py @@ -46,7 +46,7 @@ class StatusComposer(urwid.Frame): if not in_reply_to: return "" - text = '@{} '.format(in_reply_to.account) + text = '@{} '.format(in_reply_to.original.account) mentions = ['@{}'.format(m["acct"]) for m in in_reply_to.mentions] if mentions: text += '\n\n{}'.format(' '.join(mentions)) @@ -61,7 +61,7 @@ class StatusComposer(urwid.Frame): def generate_list_items(self): if self.in_reply_to: - yield urwid.Text(("gray", "Replying to {}".format(self.in_reply_to.account))) + yield urwid.Text(("gray", "Replying to {}".format(self.in_reply_to.original.account))) yield urwid.AttrWrap(urwid.Divider("-"), "gray") yield urwid.Text("Status message") From f91bfa0c62e7724a5e4d9cec4c7fbc68915771c9 Mon Sep 17 00:00:00 2001 From: Lim Ding Wen Date: Wed, 28 Dec 2022 08:53:44 +0100 Subject: [PATCH 15/54] TUI no longer mentions self when replying This brings it more in-line with Mastodon v4's web UI. --- toot/tui/app.py | 2 +- toot/tui/compose.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/toot/tui/app.py b/toot/tui/app.py index 2dd53dc..5f5a2c3 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -379,7 +379,7 @@ class TUI(urwid.Frame): def _post(timeline, *args): self.post_status(*args) - composer = StatusComposer(self.max_toot_chars, in_reply_to) + composer = StatusComposer(self.max_toot_chars, self.user.username, in_reply_to) urwid.connect_signal(composer, "close", _close) urwid.connect_signal(composer, "post", _post) self.open_overlay(composer, title="Compose status") diff --git a/toot/tui/compose.py b/toot/tui/compose.py index 913ca3e..44b7e47 100644 --- a/toot/tui/compose.py +++ b/toot/tui/compose.py @@ -13,9 +13,10 @@ class StatusComposer(urwid.Frame): """ signals = ["close", "post"] - def __init__(self, max_chars, in_reply_to=None): + def __init__(self, max_chars, username, in_reply_to=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( @@ -46,8 +47,8 @@ class StatusComposer(urwid.Frame): if not in_reply_to: return "" - text = '@{} '.format(in_reply_to.original.account) - mentions = ['@{}'.format(m["acct"]) for m in in_reply_to.mentions] + text = '' if in_reply_to.is_mine else '@{} '.format(in_reply_to.original.account) + mentions = ['@{}'.format(m["acct"]) for m in in_reply_to.mentions if m["acct"] != self.username] if mentions: text += '\n\n{}'.format(' '.join(mentions)) From 8582c8ed62ced4aba763456de795570e919438ec Mon Sep 17 00:00:00 2001 From: Lim Ding Wen Date: Thu, 22 Dec 2022 05:12:35 +0800 Subject: [PATCH 16/54] TOOT_VISIBILITY controls default visibility --- toot/console.py | 2 +- toot/tui/compose.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/toot/console.py b/toot/console.py index dbb681a..663aab5 100644 --- a/toot/console.py +++ b/toot/console.py @@ -348,7 +348,7 @@ POST_COMMANDS = [ }), (["-v", "--visibility"], { "type": visibility, - "default": "public", + "default": os.getenv("TOOT_VISIBILITY", "public"), "help": 'post visibility, one of: %s' % ", ".join(VISIBILITY_CHOICES), }), (["-s", "--sensitive"], { diff --git a/toot/tui/compose.py b/toot/tui/compose.py index 44b7e47..9eb742e 100644 --- a/toot/tui/compose.py +++ b/toot/tui/compose.py @@ -1,5 +1,6 @@ import urwid import logging +import os from .constants import VISIBILITY_OPTIONS from .widgets import Button, EditBox @@ -31,7 +32,7 @@ class StatusComposer(urwid.Frame): self.cw_remove_button = Button("Remove content warning", on_press=self.remove_content_warning) - self.visibility = "public" + self.visibility = os.getenv("TOOT_VISIBILITY", "public") self.visibility_button = Button("Visibility: {}".format(self.visibility), on_press=self.choose_visibility) From 08a28bfb26dae4ca6f13d91f62220e6221245311 Mon Sep 17 00:00:00 2001 From: Lim Ding Wen Date: Thu, 22 Dec 2022 05:12:36 +0800 Subject: [PATCH 17/54] TOOT_VISIBILITY controls boost visibility TOOT_VISIBILITY controls default boost visibility from CLI, and the boost visibility from TUI (no option to change in TUI yet) --- toot/api.py | 8 ++++---- toot/commands.py | 2 +- toot/console.py | 7 ++++++- toot/tui/app.py | 3 ++- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/toot/api.py b/toot/api.py index 17458e7..2d84ceb 100644 --- a/toot/api.py +++ b/toot/api.py @@ -18,10 +18,10 @@ def _account_action(app, user, account, action): return http.post(app, user, url).json() -def _status_action(app, user, status_id, action): +def _status_action(app, user, status_id, action, data=None): url = '/api/v1/statuses/{}/{}'.format(status_id, action) - return http.post(app, user, url).json() + return http.post(app, user, url, data=data).json() def create_app(domain, scheme='https'): @@ -187,8 +187,8 @@ def unfavourite(app, user, status_id): return _status_action(app, user, status_id, 'unfavourite') -def reblog(app, user, status_id): - return _status_action(app, user, status_id, 'reblog') +def reblog(app, user, status_id, visibility="public"): + return _status_action(app, user, status_id, 'reblog', data={"visibility": visibility}) def unreblog(app, user, status_id): diff --git a/toot/commands.py b/toot/commands.py index e334b30..daa155f 100644 --- a/toot/commands.py +++ b/toot/commands.py @@ -169,7 +169,7 @@ def unfavourite(app, user, args): def reblog(app, user, args): - api.reblog(app, user, args.status_id) + api.reblog(app, user, args.status_id, visibility=args.visibility) print_out("✓ Status reblogged") diff --git a/toot/console.py b/toot/console.py index 663aab5..51c1d5e 100644 --- a/toot/console.py +++ b/toot/console.py @@ -434,7 +434,12 @@ STATUS_COMMANDS = [ Command( name="reblog", description="Reblog a status", - arguments=[status_id_arg], + arguments=[status_id_arg, + (["-v", "--visibility"], { + "type": visibility, + "default": os.getenv("TOOT_VISIBILITY", "public"), + "help": 'boost visibility, one of: %s' % ", ".join(VISIBILITY_CHOICES), + })], require_auth=True, ), Command( diff --git a/toot/tui/app.py b/toot/tui/app.py index 5f5a2c3..39b8529 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -1,5 +1,6 @@ import logging import urwid +import os from concurrent.futures import ThreadPoolExecutor @@ -494,7 +495,7 @@ class TUI(urwid.Frame): def async_toggle_reblog(self, timeline, status): def _reblog(): logger.info("Reblogging {}".format(status)) - api.reblog(self.app, self.user, status.id) + api.reblog(self.app, self.user, status.id, visibility=os.getenv("TOOT_VISIBILITY", "public")) def _unreblog(): logger.info("Unreblogging {}".format(status)) From e07be634f6591dc038d3f6ad8346deca06acaffc Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Wed, 28 Dec 2022 09:19:25 +0100 Subject: [PATCH 18/54] Extract code for getting default visibility --- toot/console.py | 8 ++++++-- toot/tui/app.py | 4 ++-- toot/tui/compose.py | 5 +++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/toot/console.py b/toot/console.py index 51c1d5e..82124aa 100644 --- a/toot/console.py +++ b/toot/console.py @@ -15,6 +15,10 @@ from toot.output import print_out, print_err VISIBILITY_CHOICES = ['public', 'unlisted', 'private', 'direct'] +def get_default_visibility(): + return os.getenv("TOOT_VISIBILITY", "public") + + def language(value): """Validates the language parameter""" if len(value) != 2: @@ -348,7 +352,7 @@ POST_COMMANDS = [ }), (["-v", "--visibility"], { "type": visibility, - "default": os.getenv("TOOT_VISIBILITY", "public"), + "default": get_default_visibility(), "help": 'post visibility, one of: %s' % ", ".join(VISIBILITY_CHOICES), }), (["-s", "--sensitive"], { @@ -437,7 +441,7 @@ STATUS_COMMANDS = [ arguments=[status_id_arg, (["-v", "--visibility"], { "type": visibility, - "default": os.getenv("TOOT_VISIBILITY", "public"), + "default": get_default_visibility(), "help": 'boost visibility, one of: %s' % ", ".join(VISIBILITY_CHOICES), })], require_auth=True, diff --git a/toot/tui/app.py b/toot/tui/app.py index 39b8529..9f917ec 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -1,10 +1,10 @@ import logging import urwid -import os from concurrent.futures import ThreadPoolExecutor from toot import api, config, __version__ +from toot.console import get_default_visibility from .compose import StatusComposer from .constants import PALETTE @@ -495,7 +495,7 @@ class TUI(urwid.Frame): def async_toggle_reblog(self, timeline, status): def _reblog(): logger.info("Reblogging {}".format(status)) - api.reblog(self.app, self.user, status.id, visibility=os.getenv("TOOT_VISIBILITY", "public")) + api.reblog(self.app, self.user, status.id, visibility=get_default_visibility()) def _unreblog(): logger.info("Unreblogging {}".format(status)) diff --git a/toot/tui/compose.py b/toot/tui/compose.py index 9eb742e..4b31c0f 100644 --- a/toot/tui/compose.py +++ b/toot/tui/compose.py @@ -1,6 +1,7 @@ import urwid import logging -import os + +from toot.console import get_default_visibility from .constants import VISIBILITY_OPTIONS from .widgets import Button, EditBox @@ -32,7 +33,7 @@ class StatusComposer(urwid.Frame): self.cw_remove_button = Button("Remove content warning", on_press=self.remove_content_warning) - self.visibility = os.getenv("TOOT_VISIBILITY", "public") + self.visibility = get_default_visibility() self.visibility_button = Button("Visibility: {}".format(self.visibility), on_press=self.choose_visibility) From aa75cacbff07875c139a50397d0074fac90f9d45 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Wed, 28 Dec 2022 09:21:22 +0100 Subject: [PATCH 19/54] Rename TOOT_VISIBILITY to TOOT_POST_VISIBILITY This makes it more in line with what's planned for environemnt variables in the future. --- toot/console.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toot/console.py b/toot/console.py index 82124aa..c3e2a06 100644 --- a/toot/console.py +++ b/toot/console.py @@ -16,7 +16,7 @@ VISIBILITY_CHOICES = ['public', 'unlisted', 'private', 'direct'] def get_default_visibility(): - return os.getenv("TOOT_VISIBILITY", "public") + return os.getenv("TOOT_POST_VISIBILITY", "public") def language(value): From a3fa7e1e3afcc239836cea5bcdd7a936adf10422 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Wed, 28 Dec 2022 09:48:44 +0100 Subject: [PATCH 20/54] Improve visibility help string --- toot/console.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/toot/console.py b/toot/console.py index c3e2a06..b11eaee 100644 --- a/toot/console.py +++ b/toot/console.py @@ -13,6 +13,7 @@ from toot.exceptions import ApiError, ConsoleError from toot.output import print_out, print_err VISIBILITY_CHOICES = ['public', 'unlisted', 'private', 'direct'] +VISIBILITY_CHOICES_STR = ", ".join(f"'{v}'" for v in VISIBILITY_CHOICES) def get_default_visibility(): @@ -152,6 +153,15 @@ status_id_arg = (["status_id"], { "type": str, }) +visibility_arg = (["-v", "--visibility"], { + "type": visibility, + "default": get_default_visibility(), + "help": f"Post visibility. One of: {VISIBILITY_CHOICES_STR}. Defaults to " + f"'{get_default_visibility()}' which can be overridden by setting " + "the TOOT_POST_VISIBILITY environment variable", +}) + + # Arguments for selecting a timeline (see `toot.commands.get_timeline_generator`) common_timeline_args = [ (["-p", "--public"], { @@ -350,11 +360,7 @@ POST_COMMANDS = [ "help": "plain-text description of the media for accessibility " "purposes, one per attached media" }), - (["-v", "--visibility"], { - "type": visibility, - "default": get_default_visibility(), - "help": 'post visibility, one of: %s' % ", ".join(VISIBILITY_CHOICES), - }), + visibility_arg, (["-s", "--sensitive"], { "action": 'store_true', "default": False, @@ -438,12 +444,7 @@ STATUS_COMMANDS = [ Command( name="reblog", description="Reblog a status", - arguments=[status_id_arg, - (["-v", "--visibility"], { - "type": visibility, - "default": get_default_visibility(), - "help": 'boost visibility, one of: %s' % ", ".join(VISIBILITY_CHOICES), - })], + arguments=[status_id_arg, visibility_arg], require_auth=True, ), Command( From 67b52757a4a22ebd488d3949f400b9bbeb28b3af Mon Sep 17 00:00:00 2001 From: Daniel Schwarz Date: Tue, 20 Dec 2022 12:55:32 -0500 Subject: [PATCH 21/54] Command line support for following hashtags (Mastodon 4+) --- toot/api.py | 31 +++++++++++++++++++++++++------ toot/commands.py | 20 +++++++++++++++++++- toot/console.py | 28 +++++++++++++++++++++++++++- toot/output.py | 5 +++++ 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/toot/api.py b/toot/api.py index 2d84ceb..9e34c83 100644 --- a/toot/api.py +++ b/toot/api.py @@ -24,6 +24,12 @@ def _status_action(app, user, status_id, action, data=None): return http.post(app, user, url, data=data).json() +def _tag_action(app, user, tag_name, action): + url = '/api/v1/tags/{}/{}'.format(tag_name, action) + + return http.post(app, user, url).json() + + def create_app(domain, scheme='https'): url = '{}://{}/api/v1/apps'.format(scheme, domain) @@ -318,23 +324,36 @@ def unfollow(app, user, account): return _account_action(app, user, account, 'unfollow') -def _get_account_list(app, user, path): - accounts = [] +def follow_tag(app, user, tag_name): + return _tag_action(app, user, tag_name, 'follow') + + +def unfollow_tag(app, user, tag_name): + return _tag_action(app, user, tag_name, 'unfollow') + + +def _get_response_list(app, user, path): + items = [] while path: response = http.get(app, user, path) - accounts += response.json() + items += response.json() path = _get_next_path(response.headers) - return accounts + return items def following(app, user, account): path = '/api/v1/accounts/{}/{}'.format(account, 'following') - return _get_account_list(app, user, path) + return _get_response_list(app, user, path) def followers(app, user, account): path = '/api/v1/accounts/{}/{}'.format(account, 'followers') - return _get_account_list(app, user, path) + return _get_response_list(app, user, path) + + +def followed_tags(app, user): + path = '/api/v1/followed_tags' + return _get_response_list(app, user, path) def mute(app, user, account): diff --git a/toot/commands.py b/toot/commands.py index daa155f..b9819dd 100644 --- a/toot/commands.py +++ b/toot/commands.py @@ -8,7 +8,8 @@ from toot import api, config, __version__ from toot.auth import login_interactive, login_browser_interactive, create_app_interactive from toot.exceptions import ApiError, ConsoleError from toot.output import (print_out, print_instance, print_account, print_acct_list, - print_search_results, print_timeline, print_notifications) + print_search_results, print_timeline, print_notifications, + print_tag_list) from toot.tui.utils import parse_datetime from toot.utils import editor_input, multiline_input, EOF_KEY @@ -323,6 +324,23 @@ def followers(app, user, args): print_acct_list(response) +def follow_tag(app, user, args): + tn = args.tag_name if not args.tag_name.startswith("#") else args.tag_name[1:] + api.follow_tag(app, user, tn) + print_out("✓ You are now following #{}".format(tn)) + + +def unfollow_tag(app, user, args): + tn = args.tag_name if not args.tag_name.startswith("#") else args.tag_name[1:] + api.unfollow_tag(app, user, tn) + print_out("✓ You are no longer following #{}".format(tn)) + + +def followed_tags(app, user, args): + response = api.followed_tags(app, user) + print_tag_list(response) + + def mute(app, user, args): account = _find_account(app, user, args.account) api.mute(app, user, account['id']) diff --git a/toot/console.py b/toot/console.py index b11eaee..49df221 100644 --- a/toot/console.py +++ b/toot/console.py @@ -161,6 +161,10 @@ visibility_arg = (["-v", "--visibility"], { "the TOOT_POST_VISIBILITY environment variable", }) +tag_arg = (["tag_name"], { + "type": str, + "help": "tag name, e.g. Caturday, or \"#Caturday\"", +}) # Arguments for selecting a timeline (see `toot.commands.get_timeline_generator`) common_timeline_args = [ @@ -552,7 +556,28 @@ ACCOUNTS_COMMANDS = [ ), ] -COMMANDS = AUTH_COMMANDS + READ_COMMANDS + TUI_COMMANDS + POST_COMMANDS + STATUS_COMMANDS + ACCOUNTS_COMMANDS +TAG_COMMANDS = [ + Command( + name="follow_tag", + description="Follow a hashtag", + arguments=[tag_arg], + require_auth=True, + ), + Command( + name="unfollow_tag", + description="Unfollow a hashtag", + arguments=[tag_arg], + require_auth=True, + ), + Command( + name="followed_tags", + description="List hashtags you follow", + arguments=[], + require_auth=True, + ), +] + +COMMANDS = AUTH_COMMANDS + READ_COMMANDS + TUI_COMMANDS + POST_COMMANDS + STATUS_COMMANDS + ACCOUNTS_COMMANDS + TAG_COMMANDS def print_usage(): @@ -565,6 +590,7 @@ def print_usage(): ("Post", POST_COMMANDS), ("Status", STATUS_COMMANDS), ("Accounts", ACCOUNTS_COMMANDS), + ("Hashtags", TAG_COMMANDS), ] print_out("{}".format(CLIENT_NAME)) diff --git a/toot/output.py b/toot/output.py index 4927e58..bacda1a 100644 --- a/toot/output.py +++ b/toot/output.py @@ -198,6 +198,11 @@ def print_acct_list(accounts): print_out(f"* @{account['acct']} {account['display_name']}") +def print_tag_list(tags): + for tag in tags: + print_out(f"* #{tag['name']}\t {tag['url']}") + + def print_search_results(results): accounts = results['accounts'] hashtags = results['hashtags'] From ce560eacc70c88cfc98f1b0de06407bb8fbb98de Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sat, 31 Dec 2022 09:11:05 +0100 Subject: [PATCH 22/54] Make commands code a bit nicer --- toot/console.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/toot/console.py b/toot/console.py index 49df221..1e8ba63 100644 --- a/toot/console.py +++ b/toot/console.py @@ -8,6 +8,7 @@ import sys from argparse import ArgumentParser, FileType, ArgumentTypeError from collections import namedtuple +from itertools import chain from toot import config, commands, CLIENT_NAME, CLIENT_WEBSITE, __version__ from toot.exceptions import ApiError, ConsoleError from toot.output import print_out, print_err @@ -577,26 +578,26 @@ TAG_COMMANDS = [ ), ] -COMMANDS = AUTH_COMMANDS + READ_COMMANDS + TUI_COMMANDS + POST_COMMANDS + STATUS_COMMANDS + ACCOUNTS_COMMANDS + TAG_COMMANDS +COMMAND_GROUPS = [ + ("Authentication", AUTH_COMMANDS), + ("TUI", TUI_COMMANDS), + ("Read", READ_COMMANDS), + ("Post", POST_COMMANDS), + ("Status", STATUS_COMMANDS), + ("Accounts", ACCOUNTS_COMMANDS), + ("Hashtags", TAG_COMMANDS), +] + +COMMANDS = list(chain(*[commands for _, commands in COMMAND_GROUPS])) def print_usage(): - max_name_len = max(len(command.name) for command in COMMANDS) - - groups = [ - ("Authentication", AUTH_COMMANDS), - ("TUI", TUI_COMMANDS), - ("Read", READ_COMMANDS), - ("Post", POST_COMMANDS), - ("Status", STATUS_COMMANDS), - ("Accounts", ACCOUNTS_COMMANDS), - ("Hashtags", TAG_COMMANDS), - ] + max_name_len = max(len(name) for name, _ in COMMAND_GROUPS) print_out("{}".format(CLIENT_NAME)) print_out("v{}".format(__version__)) - for name, cmds in groups: + for name, cmds in COMMAND_GROUPS: print_out("") print_out(name + ":") From 6f9ef6927743fee9c7f6fbd0ede2895baa9c05e4 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sat, 31 Dec 2022 09:13:25 +0100 Subject: [PATCH 23/54] Rename tag commands to start with tags_ --- toot/commands.py | 6 +++--- toot/console.py | 16 ++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/toot/commands.py b/toot/commands.py index b9819dd..5287946 100644 --- a/toot/commands.py +++ b/toot/commands.py @@ -324,19 +324,19 @@ def followers(app, user, args): print_acct_list(response) -def follow_tag(app, user, args): +def tags_follow(app, user, args): tn = args.tag_name if not args.tag_name.startswith("#") else args.tag_name[1:] api.follow_tag(app, user, tn) print_out("✓ You are now following #{}".format(tn)) -def unfollow_tag(app, user, args): +def tags_unfollow(app, user, args): tn = args.tag_name if not args.tag_name.startswith("#") else args.tag_name[1:] api.unfollow_tag(app, user, tn) print_out("✓ You are no longer following #{}".format(tn)) -def followed_tags(app, user, args): +def tags_followed(app, user, args): response = api.followed_tags(app, user) print_tag_list(response) diff --git a/toot/console.py b/toot/console.py index 1e8ba63..b0d007e 100644 --- a/toot/console.py +++ b/toot/console.py @@ -559,23 +559,23 @@ ACCOUNTS_COMMANDS = [ TAG_COMMANDS = [ Command( - name="follow_tag", + name="tags_followed", + description="List hashtags you follow", + arguments=[], + require_auth=True, + ), + Command( + name="tags_follow", description="Follow a hashtag", arguments=[tag_arg], require_auth=True, ), Command( - name="unfollow_tag", + name="tags_unfollow", description="Unfollow a hashtag", arguments=[tag_arg], require_auth=True, ), - Command( - name="followed_tags", - description="List hashtags you follow", - arguments=[], - require_auth=True, - ), ] COMMAND_GROUPS = [ From 7be74f924053ff9ecda3723267dd6b3ee8c51ee8 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sat, 31 Dec 2022 09:14:28 +0100 Subject: [PATCH 24/54] Print if no tags are followed --- toot/output.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/toot/output.py b/toot/output.py index bacda1a..b037492 100644 --- a/toot/output.py +++ b/toot/output.py @@ -199,8 +199,11 @@ def print_acct_list(accounts): def print_tag_list(tags): - for tag in tags: - print_out(f"* #{tag['name']}\t {tag['url']}") + if tags: + for tag in tags: + print_out(f"* #{tag['name']}\t {tag['url']}") + else: + print_out("You're not following any hashtags.") def print_search_results(results): From 2d8791e629bc59b9206c82c0704fcc0ce9ac5c6f Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sat, 31 Dec 2022 09:24:08 +0100 Subject: [PATCH 25/54] Remove coding directives, no longer needed --- docs/conf.py | 2 -- tests/test_api.py | 1 - tests/test_auth.py | 2 -- tests/test_console.py | 1 - toot/__init__.py | 2 -- toot/api.py | 2 -- toot/auth.py | 2 -- toot/commands.py | 2 -- toot/console.py | 2 -- toot/output.py | 2 -- toot/utils/__init__.py | 2 -- 11 files changed, 20 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index f610945..174d568 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from datetime import datetime # -- Project information ----------------------------------------------------- diff --git a/tests/test_api.py b/tests/test_api.py index de3da73..65f815a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import pytest from unittest import mock diff --git a/tests/test_auth.py b/tests/test_auth.py index ef16204..e8e3301 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from toot import App, User, api, config, auth from tests.utils import retval diff --git a/tests/test_console.py b/tests/test_console.py index ae61ada..f59cf5f 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import io import pytest import re diff --git a/toot/__init__.py b/toot/__init__.py index 2dbddfd..daa953b 100644 --- a/toot/__init__.py +++ b/toot/__init__.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from collections import namedtuple __version__ = '0.32.1' diff --git a/toot/api.py b/toot/api.py index 9e34c83..478949e 100644 --- a/toot/api.py +++ b/toot/api.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import re import uuid diff --git a/toot/auth.py b/toot/auth.py index 1b4a327..05b61b6 100644 --- a/toot/auth.py +++ b/toot/auth.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import sys import webbrowser diff --git a/toot/commands.py b/toot/commands.py index 5287946..c34cd83 100644 --- a/toot/commands.py +++ b/toot/commands.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import sys import platform diff --git a/toot/console.py b/toot/console.py index b0d007e..ac81224 100644 --- a/toot/console.py +++ b/toot/console.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import logging import os import re diff --git a/toot/output.py b/toot/output.py index b037492..73877a4 100644 --- a/toot/output.py +++ b/toot/output.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import os import re import sys diff --git a/toot/utils/__init__.py b/toot/utils/__init__.py index 40e0daf..c76e65f 100644 --- a/toot/utils/__init__.py +++ b/toot/utils/__init__.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import os import re import socket From c969848e7a5592458f11165c782f8365a78dd69d Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sat, 31 Dec 2022 11:40:11 +0100 Subject: [PATCH 26/54] Add a contribution guide --- CONTRIBUTING.md | 135 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f0ec0ff --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,135 @@ +Toot contribution guide +======================= + +Firstly, thank you for contributing to toot! + +Relevant links which will be referenced below: + +* [toot documentation](https://toot.readthedocs.io/) +* [toot-discuss mailing list](https://lists.sr.ht/~ihabunek/toot-discuss) + used for discussion as well as accepting patches +* [toot project on github](https://github.com/ihabunek/toot) + here you can report issues and submit pull requests +* #toot IRC channel on [libera.chat](https://libera.chat) + +## Code of conduct + +Please be kind and patient. Toot is governed by one human with a full time job. + +## I have a question + +First, check if your question is addressed in the documentation or the mailing +list. If not, feel free to send an email to the mailing list. You may want to +subscribe to the mailing list to receive replies. + +Alternatively, you can ask your question on the IRC channel and ping me +(ihabunek). You may have to wait for a response, please be patient. + +Please don't open Github issues for questions. + +## I want to contribute + +### Reporting a bug + +First check you're using the +[latest version](https://github.com/ihabunek/toot/releases/) of toot and verify +the bug is present in this version. + +Search Github issues to check the bug hasn't already been reported. + +To report a bug open an +[issue on Github](https://github.com/ihabunek/toot/issues) or send an +email to the [mailing list](https://lists.sr.ht/~ihabunek/toot-discuss). + +* Run `toot env` and include its contents in the bug report. +* Explain the behavior you would expect and the actual behavior. +* Please provide as much context as possible and describe the reproduction steps + that someone else can follow to recreate the issue on their own. + +### Suggesting enhancements + +This includes suggesting new features or changes to existing ones. + +Search Github issues to check the enhancement has not already been requested. If +it hasn't, [open a new issue](https://github.com/ihabunek/toot/issues). + +Your request will be reviewed to see if it's a good fit for toot. Implementing +requested features depends on the available time and energy of the maintainer +and other contributors. Be patient. + +### Contributing code + +When contributing to toot, please only submit code that you have authored or +code whose license allows it to be included in toot. You agree that the code +you submit will be published under the [toot license](LICENSE). + +#### Setting up a dev environment + +Check out toot (or a fork) and install it into a virtual environment. + +``` +git clone git@github.com:ihabunek/toot.git +cd toot +python3 -m venv _env +source _env/bin/activate +pip install --editable . +pip install -r requirements-dev.txt +pip install -r requirements-test.txt +``` + +While the virtual env is active, running `toot` will execute the one you checked +out. This allows you to make changes and test them. + +Run tests: + +``` +pytest +``` + +Check code style: + +``` +flake8 +``` + +#### Crafting good commits + +Please put some effort into breaking your contribution up into a series of well +formed commits. If you're unsure what this means, there is a good guide +available at https://cbea.ms/git-commit/. + +Rules for commits: + +* each commit should ideally contain only one change +* don't bundle multiple unrelated changes into a single commit +* write descriptive and well formatted commit messages + +Rules for commit messages: + +* separate subject from body with a blank line +* limit the subject line to 50 characters +* capitalize the subject line +* do not end the subject line with a period +* use the imperative mood in the subject line +* wrap the body at 72 characters +* use the body to explain what and why vs. how + +If you use vim to write your commit messages, it will already enforce these +rules for you. + +#### Submitting patches + +To submit your code either open +[a pull request](https://github.com/ihabunek/toot/pulls) on Github, or send +patch(es) to [the mailing list](https://lists.sr.ht/~ihabunek/toot-discuss). + +If sending to the mailing list, patches should be sent using `git send-email`. +If you're unsure how to do this, there is a good guide at +https://git-send-email.io/. + +--- + +Parts of this guide were taken from the following sources: + +* https://contributing.md/ +* https://cbea.ms/git-commit/ From 4ef866dcbe7a247c0c4b827025851382f32609c0 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sat, 31 Dec 2022 11:50:22 +0100 Subject: [PATCH 27/54] Don't print usage on unknown command Usage has grown pretty long and it obscures the error message. --- toot/console.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/toot/console.py b/toot/console.py index ac81224..55b5b7b 100644 --- a/toot/console.py +++ b/toot/console.py @@ -605,7 +605,7 @@ def print_usage(): print_out("") print_out("To get help for each command run:") - print_out(" toot --help") + print_out(" toot \\ --help") print_out("") print_out("{}".format(CLIENT_WEBSITE)) @@ -630,8 +630,8 @@ def run_command(app, user, name, args): command = next((c for c in COMMANDS if c.name == name), None) if not command: - print_err("Unknown command '{}'\n".format(name)) - print_usage() + print_err(f"Unknown command '{name}'") + print_out("Run toot --help to show a list of available commands.") return parser = get_argument_parser(name, command) From ecb9c75f2e3d88a064571a69f2d2b5e01f8a9e5c Mon Sep 17 00:00:00 2001 From: Daniel Schwarz Date: Tue, 13 Dec 2022 12:45:07 -0500 Subject: [PATCH 28/54] React properly to 422: Validation Failed. Status has already been taken errors --- toot/api.py | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/toot/api.py b/toot/api.py index 478949e..f3c3a21 100644 --- a/toot/api.py +++ b/toot/api.py @@ -4,7 +4,7 @@ import uuid from urllib.parse import urlparse, urlencode, quote from toot import http, CLIENT_NAME, CLIENT_WEBSITE -from toot.exceptions import AuthenticationError +from toot.exceptions import AuthenticationError, ApiError from toot.utils import str_bool SCOPES = 'read write follow' @@ -27,6 +27,23 @@ def _tag_action(app, user, tag_name, action): return http.post(app, user, url).json() +def _status_toggle_action(app, user, status_id, action, data=None): + url = '/api/v1/statuses/{}/{}'.format(status_id, action) + + try: + response = http.post(app, user, url, data=data).json() + except ApiError as e: + # For "toggle" operations, Mastodon returns unhelpful + # 422: "Validation failed:" + # responses when you try to bookmark a status already + # bookmarked, or favourite a status already favourited + # so we just swallow those errors here + if str(e).startswith("Validation failed:"): + return None # FIXME: return mock OK Response object? + else: + # not the error we expected; re-raise the exception + raise e + return response def create_app(domain, scheme='https'): url = '{}://{}/api/v1/apps'.format(scheme, domain) @@ -184,38 +201,40 @@ def delete_status(app, user, status_id): def favourite(app, user, status_id): - return _status_action(app, user, status_id, 'favourite') + return _status_toggle_action(app, user, status_id, 'favourite') def unfavourite(app, user, status_id): - return _status_action(app, user, status_id, 'unfavourite') + return _status_toggle_action(app, user, status_id, 'unfavourite') def reblog(app, user, status_id, visibility="public"): - return _status_action(app, user, status_id, 'reblog', data={"visibility": visibility}) + return _status_toggle_action(app, user, status_id, 'reblog', data={"visibility": visibility}) def unreblog(app, user, status_id): - return _status_action(app, user, status_id, 'unreblog') + return _status_toggle_action(app, user, status_id, 'unreblog') def pin(app, user, status_id): - return _status_action(app, user, status_id, 'pin') + return _status_toggle_action(app, user, status_id, 'pin') def unpin(app, user, status_id): - return _status_action(app, user, status_id, 'unpin') + return _status_toggle_action(app, user, status_id, 'unpin') def bookmark(app, user, status_id): - return _status_action(app, user, status_id, 'bookmark') + return _status_toggle_action(app, user, status_id, 'bookmark') def unbookmark(app, user, status_id): - return _status_action(app, user, status_id, 'unbookmark') + return _status_toggle_action(app, user, status_id, 'unbookmark') def translate(app, user, status_id): + # don't use status_toggle_action for translate as this is + # not toggling anything server-side; it's a read only operation. return _status_action(app, user, status_id, 'translate') From 6633b758bcf64318348e7604cb4e139ac5066c59 Mon Sep 17 00:00:00 2001 From: Dan Schwarz Date: Sat, 31 Dec 2022 19:59:18 -0500 Subject: [PATCH 29/54] Make the status detail key commands more visible Some terminal color schemes completely eliminate the difference between cyan and cyan-bold colors (all the base16 themes, for instance). This change makes the key letters stand out clearly in bold white. --- toot/tui/constants.py | 1 + toot/tui/timeline.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/toot/tui/constants.py b/toot/tui/constants.py index 563eb8e..cbf257e 100644 --- a/toot/tui/constants.py +++ b/toot/tui/constants.py @@ -36,6 +36,7 @@ PALETTE = [ ('yellow_bold', 'yellow,bold', ''), ('red', 'dark red', ''), ('warning', 'light red', ''), + ('white_bold', 'white,bold', '') ] VISIBILITY_OPTIONS = [ diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index ac95469..86d3e78 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -352,7 +352,7 @@ class StatusDetails(urwid.Pile): ] options = " ".join(o for o in options if o) - options = highlight_keys(options, "cyan_bold", "cyan") + options = highlight_keys(options, "white_bold", "cyan") yield ("pack", urwid.Text(options)) def build_linebox(self, contents): From 13fffd9fc18778ccefafa233000b7e41ffe6e0d7 Mon Sep 17 00:00:00 2001 From: Daniel Schwarz Date: Thu, 22 Dec 2022 13:40:22 -0500 Subject: [PATCH 30/54] Screen refresh after web browser invocation and exit --- toot/tui/app.py | 8 +++++++- toot/tui/overlays.py | 8 +++++++- toot/tui/timeline.py | 3 +++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/toot/tui/app.py b/toot/tui/app.py index 9f917ec..3ab44e7 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -210,6 +210,7 @@ class TUI(urwid.Frame): urwid.connect_signal(timeline, "links", _links) urwid.connect_signal(timeline, "zoom", _zoom) urwid.connect_signal(timeline, "translate", self.async_translate) + urwid.connect_signal(timeline, "clear-screen", self.loop.screen.clear) def build_timeline(self, name, statuses, local): def _close(*args): @@ -347,6 +348,9 @@ class TUI(urwid.Frame): title="Status source", ) + def _clear_screen(self, widget): + self.loop.screen.clear() + def show_links(self, status): links = parse_content_links(status.data["content"]) if status else [] post_attachments = status.data["media_attachments"] or [] @@ -355,8 +359,10 @@ class TUI(urwid.Frame): url = a["remote_url"] or a["url"] links.append((url, a["description"] if a["description"] else url)) if links: + sl_widget=StatusLinks(links) + urwid.connect_signal(sl_widget, "clear-screen", self._clear_screen) self.open_overlay( - widget=StatusLinks(links), + widget=sl_widget, title="Status links", options={"height": len(links) + 2}, ) diff --git a/toot/tui/overlays.py b/toot/tui/overlays.py index 9af13ce..1fa09da 100644 --- a/toot/tui/overlays.py +++ b/toot/tui/overlays.py @@ -30,17 +30,23 @@ class StatusZoom(urwid.ListBox): class StatusLinks(urwid.ListBox): """Shows status links.""" + signals = ["clear-screen"] def __init__(self, links): def widget(url, title): - return Button(title or url, on_press=lambda btn: webbrowser.open(url)) + return Button(title or url, on_press=lambda btn: self.browse(url)) walker = urwid.SimpleFocusListWalker( [widget(url, title) for url, title in links] ) super().__init__(walker) + def browse(self, url): + webbrowser.open(url) + # force a screen refresh; necessary with console browsers + self._emit("clear-screen") + class ExceptionStackTrace(urwid.ListBox): """Shows an exception stack trace.""" diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index 86d3e78..05c5f1f 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -33,6 +33,7 @@ class Timeline(urwid.Columns): "translate", # Translate status "save", # Save current timeline "zoom", # Open status in scrollable popup window + "clear-screen", # clear the screen (used internally) ] def __init__(self, name, statuses, can_translate, focus=0, is_thread=False): @@ -182,6 +183,8 @@ class Timeline(urwid.Columns): if key in ("v", "V"): if status.original.url: webbrowser.open(status.original.url) + # force a screen refresh; necessary with console browsers + self._emit("clear-screen") return if key in ("p", "P"): From 253eea12a60b7ec8c607f30d5320f27490395328 Mon Sep 17 00:00:00 2001 From: Giuseppe Bilotta Date: Sat, 31 Dec 2022 17:36:25 +0100 Subject: [PATCH 31/54] Command to browse bookmarks from the CLI --- toot/commands.py | 9 +++++++-- toot/console.py | 10 +++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/toot/commands.py b/toot/commands.py index c34cd83..7853179 100644 --- a/toot/commands.py +++ b/toot/commands.py @@ -39,8 +39,9 @@ def get_timeline_generator(app, user, args): return api.home_timeline_generator(app, user, limit=args.count) -def timeline(app, user, args): - generator = get_timeline_generator(app, user, args) +def timeline(app, user, args, generator=None): + if not generator: + generator = get_timeline_generator(app, user, args) while True: try: @@ -197,6 +198,10 @@ def unbookmark(app, user, args): print_out("✓ Status unbookmarked") +def bookmarks(app, user, args): + timeline(app, user, args, api.bookmark_timeline_generator(app, user, limit=args.count)) + + def reblogged_by(app, user, args): for account in api.reblogged_by(app, user, args.status_id): print_out("{}\n @{}".format(account['display_name'], account['acct'])) diff --git a/toot/console.py b/toot/console.py index 55b5b7b..0b3f96c 100644 --- a/toot/console.py +++ b/toot/console.py @@ -191,7 +191,7 @@ common_timeline_args = [ }), ] -timeline_args = common_timeline_args + [ +timeline_and_bookmark_args = [ (["-c", "--count"], { "type": timeline_count, "help": "number of toots to show per page (1-20, default 10).", @@ -209,6 +209,8 @@ timeline_args = common_timeline_args + [ }), ] +timeline_args = common_timeline_args + timeline_and_bookmark_args + AUTH_COMMANDS = [ Command( name="login", @@ -340,6 +342,12 @@ READ_COMMANDS = [ arguments=timeline_args, require_auth=True, ), + Command( + name="bookmarks", + description="Show bookmarked posts", + arguments=timeline_and_bookmark_args, + require_auth=True, + ), ] POST_COMMANDS = [ From 64dd1094a979ef2199948b63a1b9d4c643639f87 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sun, 1 Jan 2023 11:11:10 +0100 Subject: [PATCH 32/54] Run tests on pull requests --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fcc77c9..e93c9bc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,9 +1,9 @@ name: Run tests -on: [push] +on: [push, pull_request] jobs: - build: + test: # Older Ubuntu required for testing on Python 3.6 which is not available in # later versions. Remove once support for 3.6 is dropped. runs-on: ubuntu-20.04 From 1e18f1f6d967f6e174c91281dbeb999c3f355df3 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sun, 1 Jan 2023 11:13:21 +0100 Subject: [PATCH 33/54] Make flake8 happy --- toot/tui/app.py | 6 +++--- toot/tui/timeline.py | 36 ++++++++++++++++++------------------ 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/toot/tui/app.py b/toot/tui/app.py index 3ab44e7..650f771 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -348,8 +348,8 @@ class TUI(urwid.Frame): title="Status source", ) - def _clear_screen(self, widget): - self.loop.screen.clear() + def _clear_screen(self, widget): + self.loop.screen.clear() def show_links(self, status): links = parse_content_links(status.data["content"]) if status else [] @@ -359,7 +359,7 @@ class TUI(urwid.Frame): url = a["remote_url"] or a["url"] links.append((url, a["description"] if a["description"] else url)) if links: - sl_widget=StatusLinks(links) + sl_widget = StatusLinks(links) urwid.connect_signal(sl_widget, "clear-screen", self._clear_screen) self.open_overlay( widget=sl_widget, diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index 05c5f1f..43f927a 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -16,24 +16,24 @@ class Timeline(urwid.Columns): Displays a list of statuses to the left, and status details on the right. """ signals = [ - "close", # Close thread - "compose", # Compose a new toot - "delete", # Delete own status - "favourite", # Favourite status - "focus", # Focus changed - "bookmark", # Bookmark status - "media", # Display media attachments - "menu", # Show a context menu - "next", # Fetch more statuses - "reblog", # Reblog status - "reply", # Compose a reply to a status - "source", # Show status source - "links", # Show status links - "thread", # Show thread for status - "translate", # Translate status - "save", # Save current timeline - "zoom", # Open status in scrollable popup window - "clear-screen", # clear the screen (used internally) + "close", # Close thread + "compose", # Compose a new toot + "delete", # Delete own status + "favourite", # Favourite status + "focus", # Focus changed + "bookmark", # Bookmark status + "media", # Display media attachments + "menu", # Show a context menu + "next", # Fetch more statuses + "reblog", # Reblog status + "reply", # Compose a reply to a status + "source", # Show status source + "links", # Show status links + "thread", # Show thread for status + "translate", # Translate status + "save", # Save current timeline + "zoom", # Open status in scrollable popup window + "clear-screen", # Clear the screen (used internally) ] def __init__(self, name, statuses, can_translate, focus=0, is_thread=False): From 4c1f3b65fd4b1527623f7c78e48426dff0968312 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sun, 1 Jan 2023 12:15:51 +0100 Subject: [PATCH 34/54] Add flake8 and vermin to tests make command --- .flake8 | 2 +- Makefile | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.flake8 b/.flake8 index 634f10a..d39e12b 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,4 @@ [flake8] -exclude=build,tests +exclude=build,tests,tmp ignore=E128 max-line-length=120 diff --git a/Makefile b/Makefile index 82b14c5..bdef9e1 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,8 @@ publish : test: pytest -v + flake8 + vermin --target=3.6 --no-tips --violations . coverage: coverage erase From 02b6023a61b61fc8be46116fa90b1eb15cc6ded0 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sun, 1 Jan 2023 12:24:32 +0100 Subject: [PATCH 35/54] Improve contribution guidelines --- CONTRIBUTING.md | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f0ec0ff..0d58901 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,18 +80,6 @@ pip install -r requirements-test.txt While the virtual env is active, running `toot` will execute the one you checked out. This allows you to make changes and test them. -Run tests: - -``` -pytest -``` - -Check code style: - -``` -flake8 -``` - #### Crafting good commits Please put some effort into breaking your contribution up into a series of well @@ -114,8 +102,27 @@ Rules for commit messages: * wrap the body at 72 characters * use the body to explain what and why vs. how -If you use vim to write your commit messages, it will already enforce these -rules for you. +For a more detailed explanation with examples see the guide at +https://cbea.ms/git-commit/ + +If you use vim to write your commit messages, it will already enforce some of +these rules for you. + +#### Run tests before submitting + +You can run code and sytle tests by running: + +``` +make test +``` + +This runs three tools: + +* `pytest` runs the test suite +* `flake8` checks code formatting +* `vermin` checks that minimum python version + +Please ensure all three commands succeed before submitting your patches. #### Submitting patches From a83c3520aec1c93d88f98be9130615c7c60897bd Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Mon, 2 Jan 2023 10:12:42 +0100 Subject: [PATCH 36/54] Use fstrings instead of format --- toot/api.py | 38 ++++++++++++++++---------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/toot/api.py b/toot/api.py index 478949e..e5d6fed 100644 --- a/toot/api.py +++ b/toot/api.py @@ -11,25 +11,22 @@ SCOPES = 'read write follow' def _account_action(app, user, account, action): - url = '/api/v1/accounts/{}/{}'.format(account, action) - + url = f"/api/v1/accounts/{account}/{action}" return http.post(app, user, url).json() def _status_action(app, user, status_id, action, data=None): - url = '/api/v1/statuses/{}/{}'.format(status_id, action) - + url = f"/api/v1/statuses/{status_id}/{action}" return http.post(app, user, url, data=data).json() def _tag_action(app, user, tag_name, action): - url = '/api/v1/tags/{}/{}'.format(tag_name, action) - + url = f"/api/v1/tags/{tag_name}/{action}" return http.post(app, user, url).json() def create_app(domain, scheme='https'): - url = '{}://{}/api/v1/apps'.format(scheme, domain) + url = f"{scheme}://{domain}/api/v1/apps" json = { 'client_name': CLIENT_NAME, @@ -180,7 +177,7 @@ def delete_status(app, user, status_id): Deletes a status with given ID. https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#deleting-a-status """ - return http.delete(app, user, '/api/v1/statuses/{}'.format(status_id)) + return http.delete(app, user, f"/api/v1/statuses/{status_id}") def favourite(app, user, status_id): @@ -220,14 +217,12 @@ def translate(app, user, status_id): def context(app, user, status_id): - url = '/api/v1/statuses/{}/context'.format(status_id) - + url = f"/api/v1/statuses/{status_id}/context" return http.get(app, user, url).json() def reblogged_by(app, user, status_id): - url = '/api/v1/statuses/{}/reblogged_by'.format(status_id) - + url = f"/api/v1/statuses/{status_id}/reblogged_by" return http.get(app, user, url).json() @@ -248,7 +243,7 @@ def _timeline_generator(app, user, path, params=None): def home_timeline_generator(app, user, limit=20): - path = '/api/v1/timelines/home?limit={}'.format(limit) + path = f"/api/v1/timelines/home?limit={limit}" return _timeline_generator(app, user, path) @@ -259,7 +254,7 @@ def public_timeline_generator(app, user, local=False, limit=20): def tag_timeline_generator(app, user, hashtag, local=False, limit=20): - path = '/api/v1/timelines/tag/{}'.format(quote(hashtag)) + path = f"/api/v1/timelines/tag/{quote(hashtag)}" params = {'local': str_bool(local), 'limit': limit} return _timeline_generator(app, user, path, params) @@ -271,13 +266,13 @@ def bookmark_timeline_generator(app, user, limit=20): def timeline_list_generator(app, user, list_id, limit=20): - path = '/api/v1/timelines/list/{}'.format(list_id) + path = f"/api/v1/timelines/list/{list_id}" return _timeline_generator(app, user, path, {'limit': limit}) def _anon_timeline_generator(instance, path, params=None): while path: - url = "https://{}{}".format(instance, path) + url = f"https://{instance}{path}" response = http.anon_get(url, params) yield response.json() path = _get_next_path(response.headers) @@ -290,7 +285,7 @@ def anon_public_timeline_generator(instance, local=False, limit=20): def anon_tag_timeline_generator(instance, hashtag, local=False, limit=20): - path = '/api/v1/timelines/tag/{}'.format(quote(hashtag)) + path = f"/api/v1/timelines/tag/{quote(hashtag)}" params = {'local': str_bool(local), 'limit': limit} return _anon_timeline_generator(instance, path, params) @@ -340,12 +335,12 @@ def _get_response_list(app, user, path): def following(app, user, account): - path = '/api/v1/accounts/{}/{}'.format(account, 'following') + path = f"/api/v1/accounts/{account}/following" return _get_response_list(app, user, path) def followers(app, user, account): - path = '/api/v1/accounts/{}/{}'.format(account, 'followers') + path = f"/api/v1/accounts/{account}/followers" return _get_response_list(app, user, path) @@ -375,8 +370,7 @@ def verify_credentials(app, user): def single_status(app, user, status_id): - url = '/api/v1/statuses/{}'.format(status_id) - + url = f"/api/v1/statuses/{status_id}" return http.get(app, user, url).json() @@ -390,5 +384,5 @@ def clear_notifications(app, user): def get_instance(domain, scheme="https"): - url = "{}://{}/api/v1/instance".format(scheme, domain) + url = f"{scheme}://{domain}/api/v1/instance" return http.anon_get(url).json() From 15d377e8890e92e837c1cb9ff14586a151c4ee7b Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Mon, 2 Jan 2023 10:11:19 +0100 Subject: [PATCH 37/54] Expand tests --- Makefile | 1 + tests/test_integration.py | 65 +++++++++++++++++++++++++++++++++++++++ toot/api.py | 8 +++++ toot/output.py | 2 +- 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index bdef9e1..6b7ddf9 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,7 @@ test: coverage: coverage erase coverage run + coverage html coverage report clean : diff --git a/tests/test_integration.py b/tests/test_integration.py index 9c51ba2..49a7a78 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -431,6 +431,71 @@ def test_follow_not_found(run): assert str(ex_info.value) == "Account not found" +def test_mute(app, user, friend, run): + out = run("mute", friend.username) + assert out == f"✓ You have muted {friend.username}" + + [muted_account] = api.get_muted_accounts(app, user) + assert muted_account["acct"] == friend.username + + out = run("unmute", friend.username) + assert out == f"✓ {friend.username} is no longer muted" + + assert api.get_muted_accounts(app, user) == [] + + +def test_block(app, user, friend, run): + out = run("block", friend.username) + assert out == f"✓ You are now blocking {friend.username}" + + [blockd_account] = api.get_blocked_accounts(app, user) + assert blockd_account["acct"] == friend.username + + out = run("unblock", friend.username) + assert out == f"✓ {friend.username} is no longer blocked" + + assert api.get_blocked_accounts(app, user) == [] + + +def test_following_followers(user, friend, run): + out = run("following", user.username) + assert out == "" + + run("follow", friend.username) + + out = run("following", user.username) + assert out == f"* @{friend.username}" + + out = run("followers", friend.username) + assert out == f"* @{user.username}" + + +def test_tags(run): + out = run("tags_followed") + assert out == "You're not following any hashtags." + + out = run("tags_follow", "foo") + assert out == "✓ You are now following #foo" + + out = run("tags_followed") + assert out == "* #foo\thttp://localhost:3000/tags/foo" + + out = run("tags_follow", "bar") + assert out == "✓ You are now following #bar" + + out = run("tags_followed") + assert out == "\n".join([ + "* #bar\thttp://localhost:3000/tags/bar", + "* #foo\thttp://localhost:3000/tags/foo", + ]) + + out = run("tags_unfollow", "foo") + assert out == "✓ You are no longer following #foo" + + out = run("tags_followed") + assert out == "* #bar\thttp://localhost:3000/tags/bar" + + # ------------------------------------------------------------------------------ # Utils # ------------------------------------------------------------------------------ diff --git a/toot/api.py b/toot/api.py index e5d6fed..1140955 100644 --- a/toot/api.py +++ b/toot/api.py @@ -38,6 +38,14 @@ def create_app(domain, scheme='https'): return http.anon_post(url, json=json).json() +def get_muted_accounts(app, user): + return http.get(app, user, "/api/v1/mutes").json() + + +def get_blocked_accounts(app, user): + return http.get(app, user, "/api/v1/blocks").json() + + def register_account(app, username, email, password, locale="en", agreement=True): """ Register an account diff --git a/toot/output.py b/toot/output.py index 73877a4..a87c1f4 100644 --- a/toot/output.py +++ b/toot/output.py @@ -199,7 +199,7 @@ def print_acct_list(accounts): def print_tag_list(tags): if tags: for tag in tags: - print_out(f"* #{tag['name']}\t {tag['url']}") + print_out(f"* #{tag['name']}\t{tag['url']}") else: print_out("You're not following any hashtags.") From 88c444c411c583ea15dd89e73b8daa027c639656 Mon Sep 17 00:00:00 2001 From: Daniel Schwarz Date: Tue, 20 Dec 2022 16:28:24 -0500 Subject: [PATCH 38/54] Highlight followed tags --- toot/tui/app.py | 30 +++++++++++++++++++++++++++--- toot/tui/constants.py | 1 + toot/tui/timeline.py | 14 ++++++++------ toot/tui/utils.py | 17 ++++++++++++----- 4 files changed, 48 insertions(+), 14 deletions(-) diff --git a/toot/tui/app.py b/toot/tui/app.py index 650f771..590aef8 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -113,6 +113,7 @@ class TUI(urwid.Frame): def run(self): self.loop.set_alarm_in(0, lambda *args: self.async_load_instance()) + self.loop.set_alarm_in(0, lambda *args: self.async_load_followed_tags()) self.loop.set_alarm_in(0, lambda *args: self.async_load_timeline( is_initial=True, timeline_name="home")) self.loop.run() @@ -237,7 +238,7 @@ class TUI(urwid.Frame): self.loop.set_alarm_in(5, lambda *args: self.footer.clear_message()) config.save_config(self.config) - timeline = Timeline(name, statuses, self.can_translate) + timeline = Timeline(name, statuses, self.can_translate, self.followed_tags) self.connect_default_timeline_signals(timeline) urwid.connect_signal(timeline, "next", _next) @@ -266,8 +267,9 @@ class TUI(urwid.Frame): statuses = ancestors + [status] + descendants focus = len(ancestors) - timeline = Timeline("thread", statuses, self.can_translate, focus, - is_thread=True) + timeline = Timeline("thread", statuses, self.can_translate, + self.followed_tags, focus, is_thread=True) + self.connect_default_timeline_signals(timeline) urwid.connect_signal(timeline, "close", _close) @@ -334,6 +336,28 @@ class TUI(urwid.Frame): return self.run_in_thread(_load_instance, done_callback=_done) + def async_load_followed_tags(self): + def _load_tag_list(): + logger.info("Loading tags") + try: + return api.followed_tags(self.app, self.user) + except: + # not supported by all Mastodon servers so fail silently if necessary + return [] + + def _done_tag_list(tags): + if len(tags) > 0: + self.followed_tags = [t["name"] for t in tags] + else: + self.followed_tags = [] + logger.info("Loaded tags. Followed tags = {}".format(self.followed_tags)) + + self.run_in_thread( + _load_tag_list, done_callback=_done_tag_list + ) + + + def refresh_footer(self, timeline): """Show status details in footer.""" status, index, count = timeline.get_focused_status_with_counts() diff --git a/toot/tui/constants.py b/toot/tui/constants.py index cbf257e..e866e34 100644 --- a/toot/tui/constants.py +++ b/toot/tui/constants.py @@ -19,6 +19,7 @@ PALETTE = [ # Functional ('hashtag', 'light cyan,bold', ''), + ('followed_hashtag', 'yellow,bold', ''), ('link', ',italics', ''), ('link_focused', ',italics', 'dark magenta'), diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index 43f927a..7d1e601 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -36,16 +36,17 @@ class Timeline(urwid.Columns): "clear-screen", # Clear the screen (used internally) ] - def __init__(self, name, statuses, can_translate, focus=0, is_thread=False): + def __init__(self, name, statuses, can_translate, followed_tags=[], focus=0, is_thread=False): self.name = name self.is_thread = is_thread self.statuses = statuses self.can_translate = can_translate self.status_list = self.build_status_list(statuses, focus=focus) + self.followed_tags = followed_tags try: - self.status_details = StatusDetails(statuses[focus], is_thread, can_translate) + self.status_details = StatusDetails(statuses[focus], is_thread, can_translate, followed_tags) except IndexError: - self.status_details = StatusDetails(None, is_thread, can_translate) + self.status_details = StatusDetails(None, is_thread, can_translate, followed_tags) super().__init__([ ("weight", 40, self.status_list), @@ -103,7 +104,7 @@ class Timeline(urwid.Columns): self.draw_status_details(status) def draw_status_details(self, status): - self.status_details = StatusDetails(status, self.is_thread, self.can_translate) + self.status_details = StatusDetails(status, self.is_thread, self.can_translate, self.followed_tags) self.contents[2] = urwid.Padding(self.status_details, left=1), ("weight", 60, False) def keypress(self, size, key): @@ -245,7 +246,7 @@ class Timeline(urwid.Columns): class StatusDetails(urwid.Pile): - def __init__(self, status, in_thread, can_translate=False): + def __init__(self, status, in_thread, can_translate=False, followed_tags=[]): """ Parameters ---------- @@ -257,6 +258,7 @@ class StatusDetails(urwid.Pile): """ self.in_thread = in_thread self.can_translate = can_translate + self.followed_tags = followed_tags reblogged_by = status.author if status and status.reblog else None widget_list = list(self.content_generator(status.original, reblogged_by) if status else ()) @@ -284,7 +286,7 @@ class StatusDetails(urwid.Pile): else: content = status.translation if status.show_translation else status.data["content"] for line in format_content(content): - yield ("pack", urwid.Text(highlight_hashtags(line))) + yield ("pack", urwid.Text(highlight_hashtags(line, self.followed_tags))) media = status.data["media_attachments"] if media: diff --git a/toot/tui/utils.py b/toot/tui/utils.py index a9ab122..727788e 100644 --- a/toot/tui/utils.py +++ b/toot/tui/utils.py @@ -51,11 +51,18 @@ def highlight_keys(text, high_attr, low_attr=""): return list(_gen()) -def highlight_hashtags(line, attr="hashtag"): - return [ - (attr, p) if p.startswith("#") else p - for p in re.split(HASHTAG_PATTERN, line) - ] +def highlight_hashtags(line, followed_tags, attr="hashtag",\ + followed_attr="followed_hashtag"): + hline = [] + for p in re.split(HASHTAG_PATTERN, line): + if p.startswith("#"): + if p[1:].lower() in (t.lower() for t in followed_tags): + hline.append((followed_attr,p)) + else: + hline.append((attr,p)) + else: + hline.append(p) + return hline def show_media(paths): From ff1374a95c5507716f886b2c3c8cba0a59edc9eb Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Mon, 2 Jan 2023 14:24:39 +0100 Subject: [PATCH 39/54] Improve formatting, remove logging --- toot/tui/app.py | 11 +++-------- toot/tui/utils.py | 9 +++++---- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/toot/tui/app.py b/toot/tui/app.py index 590aef8..31202b9 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -5,6 +5,7 @@ from concurrent.futures import ThreadPoolExecutor from toot import api, config, __version__ from toot.console import get_default_visibility +from toot.exceptions import ApiError from .compose import StatusComposer from .constants import PALETTE @@ -338,10 +339,9 @@ class TUI(urwid.Frame): def async_load_followed_tags(self): def _load_tag_list(): - logger.info("Loading tags") try: return api.followed_tags(self.app, self.user) - except: + except ApiError: # not supported by all Mastodon servers so fail silently if necessary return [] @@ -350,13 +350,8 @@ class TUI(urwid.Frame): self.followed_tags = [t["name"] for t in tags] else: self.followed_tags = [] - logger.info("Loaded tags. Followed tags = {}".format(self.followed_tags)) - - self.run_in_thread( - _load_tag_list, done_callback=_done_tag_list - ) - + self.run_in_thread(_load_tag_list, done_callback=_done_tag_list) def refresh_footer(self, timeline): """Show status details in footer.""" diff --git a/toot/tui/utils.py b/toot/tui/utils.py index 727788e..441c4a8 100644 --- a/toot/tui/utils.py +++ b/toot/tui/utils.py @@ -51,17 +51,18 @@ def highlight_keys(text, high_attr, low_attr=""): return list(_gen()) -def highlight_hashtags(line, followed_tags, attr="hashtag",\ - followed_attr="followed_hashtag"): +def highlight_hashtags(line, followed_tags, attr="hashtag", followed_attr="followed_hashtag"): hline = [] + for p in re.split(HASHTAG_PATTERN, line): if p.startswith("#"): if p[1:].lower() in (t.lower() for t in followed_tags): - hline.append((followed_attr,p)) + hline.append((followed_attr, p)) else: - hline.append((attr,p)) + hline.append((attr, p)) else: hline.append(p) + return hline From 9e800996f18226a276185ac68ffa922b00c7f352 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Mon, 2 Jan 2023 14:45:01 +0100 Subject: [PATCH 40/54] Bump version --- CHANGELOG.md | 21 +++++++++++++++++++++ changelog.yaml | 16 ++++++++++++++++ setup.py | 2 +- toot/__init__.py | 2 +- 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 141e04b..a171f25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,27 @@ Changelog +**0.33.0 (2023-01-02)** + +* Add CONTRIBUTING.md containing a contribution guide +* Add `env` command which prints local env to include in issues +* Add TOOT_POST_VISIBILITY environment to control default post visibility + (thanks Lim Ding Wen) +* Add `tags_followed`, `tags_follow`, and `tags_unfollow` commands (thanks + Daniel Schwarz) +* Add `tags_bookmarks` command (thanks Giuseppe Bilotta) +* TUI: Show an error if attemptint to boost a private status (thanks Lim Ding + Wen) +* TUI: Hide polls, cards and media attachments for sensitive posts (thanks + Daniel Schwarz) +* TUI: Add bookmarking and bookmark timeline (thanks Daniel Schwarz) +* TUI: Show status visiblity (thanks Lim Ding Wen) +* TUI: Reply to original account instead of boosting account (thanks Lim Ding + Wen) +* TUI: Refresh screen after exiting browser, required for text browsers (thanks + Daniel Schwarz) +* TUI: Highlight followed tags (thanks Daniel Schwarz) + **0.32.1 (2022-12-12)** * Fix packaging issue, missing toot.utils module diff --git a/changelog.yaml b/changelog.yaml index e26d5ba..91de2cf 100644 --- a/changelog.yaml +++ b/changelog.yaml @@ -1,3 +1,19 @@ +0.33.0: + date: 2023-01-02 + changes: + - "Add CONTRIBUTING.md containing a contribution guide" + - "Add `env` command which prints local env to include in issues" + - "Add TOOT_POST_VISIBILITY environment to control default post visibility (thanks Lim Ding Wen)" + - "Add `tags_followed`, `tags_follow`, and `tags_unfollow` commands (thanks Daniel Schwarz)" + - "Add `tags_bookmarks` command (thanks Giuseppe Bilotta)" + - "TUI: Show an error if attemptint to boost a private status (thanks Lim Ding Wen)" + - "TUI: Hide polls, cards and media attachments for sensitive posts (thanks Daniel Schwarz)" + - "TUI: Add bookmarking and bookmark timeline (thanks Daniel Schwarz)" + - "TUI: Show status visiblity (thanks Lim Ding Wen)" + - "TUI: Reply to original account instead of boosting account (thanks Lim Ding Wen)" + - "TUI: Refresh screen after exiting browser, required for text browsers (thanks Daniel Schwarz)" + - "TUI: Highlight followed tags (thanks Daniel Schwarz)" + 0.32.1: date: 2022-12-12 changes: diff --git a/setup.py b/setup.py index ea4bc8b..4d95344 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ and blocking accounts and other actions. setup( name='toot', - version='0.32.1', + version='0.33.0', description='Mastodon CLI client', long_description=long_description.strip(), author='Ivan Habunek', diff --git a/toot/__init__.py b/toot/__init__.py index daa953b..d58dfa4 100644 --- a/toot/__init__.py +++ b/toot/__init__.py @@ -1,6 +1,6 @@ from collections import namedtuple -__version__ = '0.32.1' +__version__ = '0.33.0' App = namedtuple('App', ['instance', 'base_url', 'client_id', 'client_secret']) User = namedtuple('User', ['instance', 'username', 'access_token']) From 82383cd163f07a6ffb2572bedfc70e321043dab4 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 3 Jan 2023 11:58:43 +0100 Subject: [PATCH 41/54] Fix clear screen It was passed more arguments than expected. --- toot/tui/app.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/toot/tui/app.py b/toot/tui/app.py index 31202b9..2471b92 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -199,6 +199,9 @@ class TUI(urwid.Frame): def _zoom(timeline, status_details): self.show_status_zoom(status_details) + def _clear(*args): + self.clear_screen() + urwid.connect_signal(timeline, "bookmark", self.async_toggle_bookmark) urwid.connect_signal(timeline, "compose", _compose) urwid.connect_signal(timeline, "delete", _delete) @@ -212,7 +215,7 @@ class TUI(urwid.Frame): urwid.connect_signal(timeline, "links", _links) urwid.connect_signal(timeline, "zoom", _zoom) urwid.connect_signal(timeline, "translate", self.async_translate) - urwid.connect_signal(timeline, "clear-screen", self.loop.screen.clear) + urwid.connect_signal(timeline, "clear-screen", _clear) def build_timeline(self, name, statuses, local): def _close(*args): @@ -367,19 +370,24 @@ class TUI(urwid.Frame): title="Status source", ) - def _clear_screen(self, widget): + def clear_screen(self): self.loop.screen.clear() def show_links(self, status): links = parse_content_links(status.data["content"]) if status else [] post_attachments = status.data["media_attachments"] or [] reblog_attachments = (status.data["reblog"]["media_attachments"] if status.data["reblog"] else None) or [] + for a in post_attachments + reblog_attachments: url = a["remote_url"] or a["url"] links.append((url, a["description"] if a["description"] else url)) + + def _clear(*args): + self.clear_screen() + if links: sl_widget = StatusLinks(links) - urwid.connect_signal(sl_widget, "clear-screen", self._clear_screen) + urwid.connect_signal(sl_widget, "clear-screen", _clear) self.open_overlay( widget=sl_widget, title="Status links", From 08dd02d9890f4ca240987e7944ab61463f213024 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 3 Jan 2023 12:05:26 +0100 Subject: [PATCH 42/54] Bump version --- CHANGELOG.md | 4 ++++ changelog.yaml | 5 +++++ setup.py | 2 +- toot/__init__.py | 2 +- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a171f25..9b4c601 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ Changelog +**0.33.1 (2023-01-03)** + +* TUI: Fix crash when viewing toot in browser + **0.33.0 (2023-01-02)** * Add CONTRIBUTING.md containing a contribution guide diff --git a/changelog.yaml b/changelog.yaml index 91de2cf..fe79326 100644 --- a/changelog.yaml +++ b/changelog.yaml @@ -1,3 +1,8 @@ +0.33.1: + date: 2023-01-03 + changes: + - "TUI: Fix crash when viewing toot in browser" + 0.33.0: date: 2023-01-02 changes: diff --git a/setup.py b/setup.py index 4d95344..079bcc6 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ and blocking accounts and other actions. setup( name='toot', - version='0.33.0', + version='0.33.1', description='Mastodon CLI client', long_description=long_description.strip(), author='Ivan Habunek', diff --git a/toot/__init__.py b/toot/__init__.py index d58dfa4..459cafc 100644 --- a/toot/__init__.py +++ b/toot/__init__.py @@ -1,6 +1,6 @@ from collections import namedtuple -__version__ = '0.33.0' +__version__ = '0.33.1' App = namedtuple('App', ['instance', 'base_url', 'client_id', 'client_secret']) User = namedtuple('User', ['instance', 'username', 'access_token']) From c5b3724015ec1107fe48e6f273a753e05ba87d4d Mon Sep 17 00:00:00 2001 From: Daniel Schwarz Date: Thu, 19 Jan 2023 02:44:16 -0500 Subject: [PATCH 43/54] Don't focus newly posted toot This breaks the reading flow. fixes #188 --- toot/tui/app.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/toot/tui/app.py b/toot/tui/app.py index 2471b92..ffab49d 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -497,9 +497,7 @@ class TUI(urwid.Frame): in_reply_to_id=in_reply_to_id) status = self.make_status(data) - # TODO: instead of this, fetch new items from the timeline? - self.timeline.prepend_status(status) - self.timeline.focus_status(status) + # TODO: fetch new items from the timeline? self.footer.set_message("Status posted {} \\o/".format(status.id)) self.close_overlay() From 91c1b792bed23c8ce5255f2020f2a17055e582b7 Mon Sep 17 00:00:00 2001 From: Dan Schwarz Date: Sun, 8 Jan 2023 23:20:33 -0500 Subject: [PATCH 44/54] Status detail scrollbar feature Uses scroll.py from https://github.com/rndusr/stig --- .flake8 | 2 +- toot/tui/scroll.py | 426 +++++++++++++++++++++++++++++++++++++++++++ toot/tui/timeline.py | 86 ++++++--- 3 files changed, 489 insertions(+), 25 deletions(-) create mode 100644 toot/tui/scroll.py diff --git a/.flake8 b/.flake8 index d39e12b..603b785 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,4 @@ [flake8] -exclude=build,tests,tmp +exclude=build,tests,tmp,toot/tui/scroll.py ignore=E128 max-line-length=120 diff --git a/toot/tui/scroll.py b/toot/tui/scroll.py new file mode 100644 index 0000000..fa2c3bb --- /dev/null +++ b/toot/tui/scroll.py @@ -0,0 +1,426 @@ +# scroll.py +# +# Copied from the stig project by rndusr@github +# https://github.com/rndusr/stig +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details +# http://www.gnu.org/licenses/gpl-3.0.txt + +import urwid +from urwid.widget import BOX, FIXED, FLOW + +# Scroll actions +SCROLL_LINE_UP = 'line up' +SCROLL_LINE_DOWN = 'line down' +SCROLL_PAGE_UP = 'page up' +SCROLL_PAGE_DOWN = 'page down' +SCROLL_TO_TOP = 'to top' +SCROLL_TO_END = 'to end' + +# Scrollbar positions +SCROLLBAR_LEFT = 'left' +SCROLLBAR_RIGHT = 'right' + +class Scrollable(urwid.WidgetDecoration): + def sizing(self): + return frozenset([BOX,]) + + def selectable(self): + return True + + def __init__(self, widget): + """Box widget that makes a fixed or flow widget vertically scrollable + + TODO: Focusable widgets are handled, including switching focus, but + possibly not intuitively, depending on the arrangement of widgets. When + switching focus to a widget that is outside of the visible part of the + original widget, the canvas scrolls up/down to the focused widget. It + would be better to scroll until the next focusable widget is in sight + first. But for that to work we must somehow obtain a list of focusable + rows in the original canvas. + """ + if not any(s in widget.sizing() for s in (FIXED, FLOW)): + raise ValueError('Not a fixed or flow widget: %r' % widget) + self._trim_top = 0 + self._scroll_action = None + self._forward_keypress = None + self._old_cursor_coords = None + self._rows_max_cached = 0 + self.__super.__init__(widget) + + def render(self, size, focus=False): + maxcol, maxrow = size + + # Render complete original widget + ow = self._original_widget + ow_size = self._get_original_widget_size(size) + canv_full = ow.render(ow_size, focus) + + # Make full canvas editable + canv = urwid.CompositeCanvas(canv_full) + canv_cols, canv_rows = canv.cols(), canv.rows() + + if canv_cols <= maxcol: + pad_width = maxcol - canv_cols + if pad_width > 0: + # Canvas is narrower than available horizontal space + canv.pad_trim_left_right(0, pad_width) + + if canv_rows <= maxrow: + fill_height = maxrow - canv_rows + if fill_height > 0: + # Canvas is lower than available vertical space + canv.pad_trim_top_bottom(0, fill_height) + + if canv_cols <= maxcol and canv_rows <= maxrow: + # Canvas is small enough to fit without trimming + return canv + + self._adjust_trim_top(canv, size) + + # Trim canvas if necessary + trim_top = self._trim_top + trim_end = canv_rows - maxrow - trim_top + trim_right = canv_cols - maxcol + if trim_top > 0: + canv.trim(trim_top) + if trim_end > 0: + canv.trim_end(trim_end) + if trim_right > 0: + canv.pad_trim_left_right(0, -trim_right) + + # Disable cursor display if cursor is outside of visible canvas parts + if canv.cursor is not None: + curscol, cursrow = canv.cursor + if cursrow >= maxrow or cursrow < 0: + canv.cursor = None + + # Figure out whether we should forward keypresses to original widget + if canv.cursor is not None: + # Trimmed canvas contains the cursor, e.g. in an Edit widget + self._forward_keypress = True + else: + if canv_full.cursor is not None: + # Full canvas contains the cursor, but scrolled out of view + self._forward_keypress = False + else: + # Original widget does not have a cursor, but may be selectable + + # FIXME: Using ow.selectable() is bad because the original + # widget may be selectable because it's a container widget with + # a key-grabbing widget that is scrolled out of view. + # ow.selectable() returns True anyway because it doesn't know + # how we trimmed our canvas. + # + # To fix this, we need to resolve ow.focus and somehow + # ask canv whether it contains bits of the focused widget. I + # can't see a way to do that. + if ow.selectable(): + self._forward_keypress = True + else: + self._forward_keypress = False + + return canv + + def keypress(self, size, key): + # Maybe offer key to original widget + if self._forward_keypress: + ow = self._original_widget + ow_size = self._get_original_widget_size(size) + + # Remember previous cursor position if possible + if hasattr(ow, 'get_cursor_coords'): + self._old_cursor_coords = ow.get_cursor_coords(ow_size) + + key = ow.keypress(ow_size, key) + if key is None: + return None + + # Handle up/down, page up/down, etc + command_map = self._command_map + if command_map[key] == urwid.CURSOR_UP: + self._scroll_action = SCROLL_LINE_UP + elif command_map[key] == urwid.CURSOR_DOWN: + self._scroll_action = SCROLL_LINE_DOWN + + elif command_map[key] == urwid.CURSOR_PAGE_UP: + self._scroll_action = SCROLL_PAGE_UP + elif command_map[key] == urwid.CURSOR_PAGE_DOWN: + self._scroll_action = SCROLL_PAGE_DOWN + + elif command_map[key] == urwid.CURSOR_MAX_LEFT: # 'home' + self._scroll_action = SCROLL_TO_TOP + elif command_map[key] == urwid.CURSOR_MAX_RIGHT: # 'end' + self._scroll_action = SCROLL_TO_END + + else: + return key + + self._invalidate() + + def mouse_event(self, size, event, button, col, row, focus): + ow = self._original_widget + if hasattr(ow, 'mouse_event'): + ow_size = self._get_original_widget_size(size) + row += self._trim_top + return ow.mouse_event(ow_size, event, button, col, row, focus) + else: + return False + + def _adjust_trim_top(self, canv, size): + """Adjust self._trim_top according to self._scroll_action""" + action = self._scroll_action + self._scroll_action = None + + maxcol, maxrow = size + trim_top = self._trim_top + canv_rows = canv.rows() + + if trim_top < 0: + # Negative trim_top values use bottom of canvas as reference + trim_top = canv_rows - maxrow + trim_top + 1 + + if canv_rows <= maxrow: + self._trim_top = 0 # Reset scroll position + return + + def ensure_bounds(new_trim_top): + return max(0, min(canv_rows - maxrow, new_trim_top)) + + if action == SCROLL_LINE_UP: + self._trim_top = ensure_bounds(trim_top - 1) + elif action == SCROLL_LINE_DOWN: + self._trim_top = ensure_bounds(trim_top + 1) + + elif action == SCROLL_PAGE_UP: + self._trim_top = ensure_bounds(trim_top - maxrow + 1) + elif action == SCROLL_PAGE_DOWN: + self._trim_top = ensure_bounds(trim_top + maxrow - 1) + + elif action == SCROLL_TO_TOP: + self._trim_top = 0 + elif action == SCROLL_TO_END: + self._trim_top = canv_rows - maxrow + + else: + self._trim_top = ensure_bounds(trim_top) + + # If the cursor was moved by the most recent keypress, adjust trim_top + # so that the new cursor position is within the displayed canvas part. + # But don't do this if the cursor is at the top/bottom edge so we can still scroll out + if self._old_cursor_coords is not None and self._old_cursor_coords != canv.cursor: + self._old_cursor_coords = None + curscol, cursrow = canv.cursor + if cursrow < self._trim_top: + self._trim_top = cursrow + elif cursrow >= self._trim_top + maxrow: + self._trim_top = max(0, cursrow - maxrow + 1) + + def _get_original_widget_size(self, size): + ow = self._original_widget + sizing = ow.sizing() + if FIXED in sizing: + return () + elif FLOW in sizing: + return (size[0],) + + def get_scrollpos(self, size=None, focus=False): + """Current scrolling position + + Lower limit is 0, upper limit is the maximum number of rows with the + given maxcol minus maxrow. + + NOTE: The returned value may be too low or too high if the position has + changed but the widget wasn't rendered yet. + """ + return self._trim_top + + def set_scrollpos(self, position): + """Set scrolling position + + If `position` is positive it is interpreted as lines from the top. + If `position` is negative it is interpreted as lines from the bottom. + + Values that are too high or too low values are automatically adjusted + during rendering. + """ + self._trim_top = int(position) + self._invalidate() + + def rows_max(self, size=None, focus=False): + """Return the number of rows for `size` + + If `size` is not given, the currently rendered number of rows is returned. + """ + if size is not None: + ow = self._original_widget + ow_size = self._get_original_widget_size(size) + sizing = ow.sizing() + if FIXED in sizing: + self._rows_max_cached = ow.pack(ow_size, focus)[1] + elif FLOW in sizing: + self._rows_max_cached = ow.rows(ow_size, focus) + else: + raise RuntimeError('Not a flow/box widget: %r' % self._original_widget) + return self._rows_max_cached + + +class ScrollBar(urwid.WidgetDecoration): + def sizing(self): + return frozenset((BOX,)) + + def selectable(self): + return True + + def __init__(self, widget, thumb_char=u'\u2588', trough_char=' ', + side=SCROLLBAR_RIGHT, width=1): + """Box widget that adds a scrollbar to `widget` + + `widget` must be a box widget with the following methods: + - `get_scrollpos` takes the arguments `size` and `focus` and returns + the index of the first visible row. + - `set_scrollpos` (optional; needed for mouse click support) takes the + index of the first visible row. + - `rows_max` takes `size` and `focus` and returns the total number of + rows `widget` can render. + + `thumb_char` is the character used for the scrollbar handle. + `trough_char` is used for the space above and below the handle. + `side` must be 'left' or 'right'. + `width` specifies the number of columns the scrollbar uses. + """ + if BOX not in widget.sizing(): + raise ValueError('Not a box widget: %r' % widget) + self.__super.__init__(widget) + self._thumb_char = thumb_char + self._trough_char = trough_char + self.scrollbar_side = side + self.scrollbar_width = max(1, width) + self._original_widget_size = (0, 0) + + def render(self, size, focus=False): + maxcol, maxrow = size + + sb_width = self._scrollbar_width + ow_size = (max(0, maxcol - sb_width), maxrow) + sb_width = maxcol - ow_size[0] + + ow = self._original_widget + ow_base = self.scrolling_base_widget + ow_rows_max = ow_base.rows_max(size, focus) + if ow_rows_max <= maxrow: + # Canvas fits without scrolling - no scrollbar needed + self._original_widget_size = size + return ow.render(size, focus) + ow_rows_max = ow_base.rows_max(ow_size, focus) + + ow_canv = ow.render(ow_size, focus) + self._original_widget_size = ow_size + + pos = ow_base.get_scrollpos(ow_size, focus) + posmax = ow_rows_max - maxrow + + # Thumb shrinks/grows according to the ratio of + # / + thumb_weight = min(1, maxrow / max(1, ow_rows_max)) + thumb_height = max(1, round(thumb_weight * maxrow)) + + # Thumb may only touch top/bottom if the first/last row is visible + top_weight = float(pos) / max(1, posmax) + top_height = int((maxrow - thumb_height) * top_weight) + if top_height == 0 and top_weight > 0: + top_height = 1 + + # Bottom part is remaining space + bottom_height = maxrow - thumb_height - top_height + assert thumb_height + top_height + bottom_height == maxrow + + # Create scrollbar canvas + # Creating SolidCanvases of correct height may result in "cviews do not + # fill gaps in shard_tail!" or "cviews overflow gaps in shard_tail!" + # exceptions. Stacking the same SolidCanvas is a workaround. + # https://github.com/urwid/urwid/issues/226#issuecomment-437176837 + top = urwid.SolidCanvas(self._trough_char, sb_width, 1) + thumb = urwid.SolidCanvas(self._thumb_char, sb_width, 1) + bottom = urwid.SolidCanvas(self._trough_char, sb_width, 1) + sb_canv = urwid.CanvasCombine( + [(top, None, False)] * top_height + + [(thumb, None, False)] * thumb_height + + [(bottom, None, False)] * bottom_height, + ) + + combinelist = [(ow_canv, None, True, ow_size[0]), + (sb_canv, None, False, sb_width)] + if self._scrollbar_side != SCROLLBAR_LEFT: + return urwid.CanvasJoin(combinelist) + else: + return urwid.CanvasJoin(reversed(combinelist)) + + @property + def scrollbar_width(self): + """Columns the scrollbar uses""" + return max(1, self._scrollbar_width) + + @scrollbar_width.setter + def scrollbar_width(self, width): + self._scrollbar_width = max(1, int(width)) + self._invalidate() + + @property + def scrollbar_side(self): + """Where to display the scrollbar; must be 'left' or 'right'""" + return self._scrollbar_side + + @scrollbar_side.setter + def scrollbar_side(self, side): + if side not in (SCROLLBAR_LEFT, SCROLLBAR_RIGHT): + raise ValueError('scrollbar_side must be "left" or "right", not %r' % side) + self._scrollbar_side = side + self._invalidate() + + @property + def scrolling_base_widget(self): + """Nearest `original_widget` that is compatible with the scrolling API""" + def orig_iter(w): + while hasattr(w, 'original_widget'): + w = w.original_widget + yield w + yield w + + def is_scrolling_widget(w): + return hasattr(w, 'get_scrollpos') and hasattr(w, 'rows_max') + + for w in orig_iter(self): + if is_scrolling_widget(w): + return w + raise ValueError('Not compatible to be wrapped by ScrollBar: %r' % w) + + def keypress(self, size, key): + return self._original_widget.keypress(self._original_widget_size, key) + + def mouse_event(self, size, event, button, col, row, focus): + ow = self._original_widget + ow_size = self._original_widget_size + handled = False + if hasattr(ow, 'mouse_event'): + handled = ow.mouse_event(ow_size, event, button, col, row, focus) + + if not handled and hasattr(ow, 'set_scrollpos'): + if button == 4: # scroll wheel up + pos = ow.get_scrollpos(ow_size) + ow.set_scrollpos(pos - 1) + return True + elif button == 5: # scroll wheel down + pos = ow.get_scrollpos(ow_size) + ow.set_scrollpos(pos + 1) + return True + + return False \ No newline at end of file diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index 7d1e601..99a41d6 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -7,6 +7,7 @@ from toot.utils.language import language_name from .utils import highlight_hashtags, parse_datetime, highlight_keys from .widgets import SelectableText, SelectableColumns +from toot.tui.scroll import Scrollable, ScrollBar logger = logging.getLogger("toot") @@ -43,10 +44,32 @@ class Timeline(urwid.Columns): self.can_translate = can_translate self.status_list = self.build_status_list(statuses, focus=focus) self.followed_tags = followed_tags + opts_footer = urwid.Text(self.get_option_text(statuses[focus])) try: - self.status_details = StatusDetails(statuses[focus], is_thread, can_translate, followed_tags) + self.status_details = urwid.Frame( + body=ScrollBar( + Scrollable( + urwid.Padding( + StatusDetails( + statuses[focus], + is_thread, + can_translate, + followed_tags, + ), + right=1, + ) + ), + thumb_char="\u2588", + trough_char="\u2591", + ), + footer=opts_footer, + ) + except IndexError: - self.status_details = StatusDetails(None, is_thread, can_translate, followed_tags) + # we have no statuses to display + self.status_details = StatusDetails( + None, is_thread, can_translate, followed_tags + ) super().__init__([ ("weight", 40, self.status_list), @@ -74,6 +97,25 @@ class Timeline(urwid.Columns): None: "green_selected", }) + def get_option_text(self, status): + options = [ + "[B]oost", + "[D]elete" if status.is_mine else "", + "B[o]okmark", + "[F]avourite", + "[V]iew", + "[T]hread" if not self.is_thread else "", + "[L]inks", + "[R]eply", + "So[u]rce", + "[Z]oom", + "Tra[n]slate" if self.can_translate else "", + "[H]elp", + ] + options = "\n" + " ".join(o for o in options if o) + options = highlight_keys(options, "white_bold", "cyan") + return options + def get_focused_status(self): try: return self.statuses[self.status_list.body.focus] @@ -104,8 +146,23 @@ class Timeline(urwid.Columns): self.draw_status_details(status) def draw_status_details(self, status): - self.status_details = StatusDetails(status, self.is_thread, self.can_translate, self.followed_tags) - self.contents[2] = urwid.Padding(self.status_details, left=1), ("weight", 60, False) + opts_footer = urwid.Text(self.get_option_text(status)) + self.status_details = StatusDetails( + status, self.is_thread, self.can_translate, self.followed_tags + ) + self.contents[2] = ( + urwid.Padding( + urwid.Frame( + body=ScrollBar( + Scrollable(urwid.Padding(self.status_details, right=1)), + thumb_char="\u2588", + trough_char="\u2591", + ), + footer=opts_footer, + ), + left=1, + ) + ), ("weight", 60, False) def keypress(self, size, key): status = self.get_focused_status() @@ -339,26 +396,7 @@ class StatusDetails(urwid.Pile): ])) # Push things to bottom - yield ("weight", 1, urwid.SolidFill(" ")) - - options = [ - "[B]oost", - "[D]elete" if status.is_mine else "", - "[F]avourite", - "B[o]okmark", - "[V]iew", - "[T]hread" if not self.in_thread else "", - "[L]inks", - "[R]eply", - "So[u]rce", - "[Z]oom", - "Tra[n]slate" if self.can_translate else "", - "[H]elp", - ] - options = " ".join(o for o in options if o) - - options = highlight_keys(options, "white_bold", "cyan") - yield ("pack", urwid.Text(options)) + yield ("weight", 1, urwid.BoxAdapter(urwid.SolidFill(" "), 1)) def build_linebox(self, contents): contents = urwid.Pile(list(contents)) From 0a6543d3556521d25936cade49f5456d30f6f092 Mon Sep 17 00:00:00 2001 From: Daniel Schwarz Date: Wed, 18 Jan 2023 21:07:27 -0500 Subject: [PATCH 45/54] Ignore venv folder for flake8 and vermin tests --- .flake8 | 2 +- Makefile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.flake8 b/.flake8 index 603b785..21fd7bd 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,4 @@ [flake8] -exclude=build,tests,tmp,toot/tui/scroll.py +exclude=build,tests,tmp,venv,toot/tui/scroll.py ignore=E128 max-line-length=120 diff --git a/Makefile b/Makefile index 6b7ddf9..c1aaa5f 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ publish : test: pytest -v flake8 - vermin --target=3.6 --no-tips --violations . + vermin --target=3.6 --no-tips --violations --exclude-regex venv/.* . coverage: coverage erase From 4f9391f015a61b08f29c221ceaf5055703be2e7e Mon Sep 17 00:00:00 2001 From: K Date: Thu, 19 Jan 2023 09:32:45 +0100 Subject: [PATCH 46/54] Fix version detection Version check failed when the server sent something other than a number as a version as happened on development version of the gotosocial server. --- toot/tui/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/toot/tui/app.py b/toot/tui/app.py index ffab49d..7429a73 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -336,7 +336,8 @@ class TUI(urwid.Frame): # this works for Mastodon and Pleroma version strings # Mastodon versions < 4 do not have translation service # Revisit this logic if Pleroma implements translation - self.can_translate = int(instance["version"][0]) > 3 + ch = instance["version"][0] + self.can_translate = int(ch) > 3 if ch.isnumeric() else False return self.run_in_thread(_load_instance, done_callback=_done) From b8f49ef2126b59f0418bde1ea57ba2a2d9d50a05 Mon Sep 17 00:00:00 2001 From: Norman Walsh Date: Fri, 6 Jan 2023 14:30:03 +0000 Subject: [PATCH 47/54] Support --help as the only command-line argument --- toot/console.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toot/console.py b/toot/console.py index 0b3f96c..38e0656 100644 --- a/toot/console.py +++ b/toot/console.py @@ -673,7 +673,7 @@ def main(): command_name = sys.argv[1] if len(sys.argv) > 1 else None args = sys.argv[2:] - if not command_name: + if not command_name or command_name == "--help": return print_usage() user, app = config.get_active_user_app() From b0319c43f049b99d426a2badca9470b3babdeb3d Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Thu, 19 Jan 2023 09:53:38 +0100 Subject: [PATCH 48/54] Pass timeline as first argument to status Instead of passing various attributes of timeline. --- toot/tui/timeline.py | 39 +++++++++++---------------------------- 1 file changed, 11 insertions(+), 28 deletions(-) diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index 99a41d6..99ed89d 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -2,12 +2,14 @@ import logging import urwid import webbrowser -from toot.utils import format_content -from toot.utils.language import language_name +from typing import Optional +from .entities import Status +from .scroll import Scrollable, ScrollBar from .utils import highlight_hashtags, parse_datetime, highlight_keys from .widgets import SelectableText, SelectableColumns -from toot.tui.scroll import Scrollable, ScrollBar +from toot.utils import format_content +from toot.utils.language import language_name logger = logging.getLogger("toot") @@ -50,12 +52,7 @@ class Timeline(urwid.Columns): body=ScrollBar( Scrollable( urwid.Padding( - StatusDetails( - statuses[focus], - is_thread, - can_translate, - followed_tags, - ), + StatusDetails(self, statuses[focus]), right=1, ) ), @@ -67,9 +64,7 @@ class Timeline(urwid.Columns): except IndexError: # we have no statuses to display - self.status_details = StatusDetails( - None, is_thread, can_translate, followed_tags - ) + self.status_details = StatusDetails(self, None) super().__init__([ ("weight", 40, self.status_list), @@ -147,9 +142,8 @@ class Timeline(urwid.Columns): def draw_status_details(self, status): opts_footer = urwid.Text(self.get_option_text(status)) - self.status_details = StatusDetails( - status, self.is_thread, self.can_translate, self.followed_tags - ) + self.status_details = StatusDetails(self, status) + self.contents[2] = ( urwid.Padding( urwid.Frame( @@ -303,19 +297,8 @@ class Timeline(urwid.Columns): class StatusDetails(urwid.Pile): - def __init__(self, status, in_thread, can_translate=False, followed_tags=[]): - """ - Parameters - ---------- - status : Status - The status to render. - - in_thread : bool - Whether the status is rendered from a thread status list. - """ - self.in_thread = in_thread - self.can_translate = can_translate - self.followed_tags = followed_tags + def __init__(self, timeline: Timeline, status: Optional[Status]): + self.followed_tags = timeline.followed_tags reblogged_by = status.author if status and status.reblog else None widget_list = list(self.content_generator(status.original, reblogged_by) if status else ()) From 7cada43e2f94baf1d920d6df04409e2d053c9b6e Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Thu, 19 Jan 2023 11:10:36 +0100 Subject: [PATCH 49/54] Deduplicate code for wrapping the status details --- toot/tui/timeline.py | 65 ++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index 99ed89d..b3bbe85 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -46,32 +46,35 @@ class Timeline(urwid.Columns): self.can_translate = can_translate self.status_list = self.build_status_list(statuses, focus=focus) self.followed_tags = followed_tags - opts_footer = urwid.Text(self.get_option_text(statuses[focus])) - try: - self.status_details = urwid.Frame( - body=ScrollBar( - Scrollable( - urwid.Padding( - StatusDetails(self, statuses[focus]), - right=1, - ) - ), - thumb_char="\u2588", - trough_char="\u2591", - ), - footer=opts_footer, - ) + try: + focused_status = statuses[focus] except IndexError: - # we have no statuses to display - self.status_details = StatusDetails(self, None) + focused_status = None + + self.status_details = StatusDetails(self, focused_status) + status_widget = self.wrap_status_details(self.status_details) super().__init__([ ("weight", 40, self.status_list), ("weight", 0, urwid.AttrWrap(urwid.SolidFill("│"), "blue_selected")), - ("weight", 60, urwid.Padding(self.status_details, left=1)), + ("weight", 60, status_widget), ]) + def wrap_status_details(self, status_details: "StatusDetails") -> urwid.Widget: + """Wrap StatusDetails widget with a scollbar and footer.""" + return urwid.Padding( + urwid.Frame( + body=ScrollBar( + Scrollable(urwid.Padding(status_details, right=1)), + thumb_char="\u2588", + trough_char="\u2591", + ), + footer=self.get_option_text(status_details.status), + ), + left=1 + ) + def build_status_list(self, statuses, focus): items = [self.build_list_item(status) for status in statuses] walker = urwid.SimpleFocusListWalker(items) @@ -92,7 +95,10 @@ class Timeline(urwid.Columns): None: "green_selected", }) - def get_option_text(self, status): + def get_option_text(self, status: Optional[Status]) -> Optional[urwid.Text]: + if not status: + return None + options = [ "[B]oost", "[D]elete" if status.is_mine else "", @@ -109,7 +115,7 @@ class Timeline(urwid.Columns): ] options = "\n" + " ".join(o for o in options if o) options = highlight_keys(options, "white_bold", "cyan") - return options + return urwid.Text(options) def get_focused_status(self): try: @@ -141,22 +147,9 @@ class Timeline(urwid.Columns): self.draw_status_details(status) def draw_status_details(self, status): - opts_footer = urwid.Text(self.get_option_text(status)) self.status_details = StatusDetails(self, status) - - self.contents[2] = ( - urwid.Padding( - urwid.Frame( - body=ScrollBar( - Scrollable(urwid.Padding(self.status_details, right=1)), - thumb_char="\u2588", - trough_char="\u2591", - ), - footer=opts_footer, - ), - left=1, - ) - ), ("weight", 60, False) + widget = self.wrap_status_details(self.status_details) + self.contents[2] = widget, ("weight", 60, False) def keypress(self, size, key): status = self.get_focused_status() @@ -298,7 +291,9 @@ class Timeline(urwid.Columns): class StatusDetails(urwid.Pile): def __init__(self, timeline: Timeline, status: Optional[Status]): + self.status = status self.followed_tags = timeline.followed_tags + reblogged_by = status.author if status and status.reblog else None widget_list = list(self.content_generator(status.original, reblogged_by) if status else ()) From deebdf7141343c0fa92c562f48cdd54910624564 Mon Sep 17 00:00:00 2001 From: Daniel Schwarz Date: Fri, 20 Jan 2023 15:51:05 -0500 Subject: [PATCH 50/54] Show relative datetimes in status list Status detail pane now shows the full created_at timestamp. --- toot/tui/timeline.py | 11 ++++++++--- toot/tui/utils.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index b3bbe85..14e0342 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -10,6 +10,7 @@ from .utils import highlight_hashtags, parse_datetime, highlight_keys from .widgets import SelectableText, SelectableColumns from toot.utils import format_content from toot.utils.language import language_name +from toot.tui.utils import time_ago logger = logging.getLogger("toot") @@ -364,12 +365,13 @@ class StatusDetails(urwid.Pile): visibility_color = visibility_colors.get(status.visibility, "gray") yield ("pack", urwid.Text([ - ("red", "🠷 ") if status.bookmarked else "", + ("blue", f"{status.created_at.strftime('%Y-%m-%d %H:%M')} "), + ("red" if status.bookmarked else "gray", "🠷 "), ("gray", f"⤶ {status.data['replies_count']} "), ("yellow" if status.reblogged else "gray", f"♺ {status.data['reblogs_count']} "), ("yellow" if status.favourited else "gray", f"★ {status.data['favourites_count']}"), (visibility_color, f" · {visibility}"), - ("yellow", f" · Translated from {translated_from} ") if translated_from else "", + ("yellow", f" · Translated from {translated_from} " if translated_from else ""), ("gray", f" · {application}" if application else ""), ])) @@ -418,7 +420,9 @@ class StatusDetails(urwid.Pile): class StatusListItem(SelectableColumns): def __init__(self, status): - created_at = status.created_at.strftime("%Y-%m-%d %H:%M") + edited = status.data["edited_at"] + created_at = time_ago(status.created_at).ljust(3, " ") + edited_flag = "*" if edited else " " favourited = ("yellow", "★") if status.original.favourited else " " reblogged = ("yellow", "♺") if status.original.reblogged else " " is_reblog = ("cyan", "♺") if status.reblog else " " @@ -426,6 +430,7 @@ class StatusListItem(SelectableColumns): return super().__init__([ ("pack", SelectableText(("blue", created_at), wrap="clip")), + ("pack", urwid.Text(("blue", edited_flag))), ("pack", urwid.Text(" ")), ("pack", urwid.Text(favourited)), ("pack", urwid.Text(" ")), diff --git a/toot/tui/utils.py b/toot/tui/utils.py index 441c4a8..e2855c4 100644 --- a/toot/tui/utils.py +++ b/toot/tui/utils.py @@ -1,4 +1,5 @@ from html.parser import HTMLParser +import math import os import re import shutil @@ -7,6 +8,11 @@ import subprocess from datetime import datetime, timezone HASHTAG_PATTERN = re.compile(r'(? datetime: + now = datetime.now().astimezone() + delta = now.timestamp() - value.timestamp() + + if (delta < 1): + return "now" + + if (delta < 8 * DAY): + if (delta < MINUTE): + return f"{math.floor(delta / SECOND)}".rjust(2, " ") + "s" + if (delta < HOUR): + return f"{math.floor(delta / MINUTE)}".rjust(2, " ") + "m" + if (delta < DAY): + return f"{math.floor(delta / HOUR)}".rjust(2, " ") + "h" + return f"{math.floor(delta / DAY)}".rjust(2, " ") + "d" + + if (delta < 53 * WEEK): # not exactly correct but good enough as a boundary + return f"{math.floor(delta / WEEK)}".rjust(2, " ") + "w" + + return ">1y" + + def highlight_keys(text, high_attr, low_attr=""): """ Takes a string and adds high_attr attribute to parts in square brackets, From f3b90c947efb229407c759c7ec95681ad55d33d1 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sun, 29 Jan 2023 09:23:57 +0100 Subject: [PATCH 51/54] Add option to display relative datetimes --- toot/commands.py | 2 +- toot/console.py | 8 +++++++- toot/tui/app.py | 7 ++++--- toot/tui/timeline.py | 11 ++++++++++- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/toot/commands.py b/toot/commands.py index 7853179..69c9c58 100644 --- a/toot/commands.py +++ b/toot/commands.py @@ -417,4 +417,4 @@ def notifications(app, user, args): def tui(app, user, args): from .tui.app import TUI - TUI.create(app, user).run() + TUI.create(app, user, args).run() diff --git a/toot/console.py b/toot/console.py index 38e0656..f29856c 100644 --- a/toot/console.py +++ b/toot/console.py @@ -254,7 +254,13 @@ TUI_COMMANDS = [ Command( name="tui", description="Launches the toot terminal user interface", - arguments=[], + arguments=[ + (["--relative-datetimes"], { + "action": "store_true", + "default": False, + "help": "Show relative datetimes in status list.", + }), + ], require_auth=True, ), ] diff --git a/toot/tui/app.py b/toot/tui/app.py index 7429a73..16c284f 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -73,10 +73,10 @@ class TUI(urwid.Frame): """Main TUI frame.""" @classmethod - def create(cls, app, user): + def create(cls, app, user, args): """Factory method, sets up TUI and an event loop.""" - tui = cls(app, user) + tui = cls(app, user, args) loop = urwid.MainLoop( tui, palette=PALETTE, @@ -87,9 +87,10 @@ class TUI(urwid.Frame): return tui - def __init__(self, app, user): + def __init__(self, app, user, args): self.app = app self.user = user + self.args = args self.config = config.load_config() self.loop = None # set in `create` diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index 14e0342..d4b9b74 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -1,4 +1,5 @@ import logging +import sys import urwid import webbrowser @@ -421,7 +422,15 @@ class StatusDetails(urwid.Pile): class StatusListItem(SelectableColumns): def __init__(self, status): edited = status.data["edited_at"] - created_at = time_ago(status.created_at).ljust(3, " ") + + # TODO: hacky implementation to avoid creating conflicts for existing + # pull reuqests, refactor when merged. + created_at = ( + time_ago(status.created_at).ljust(3, " ") + if "--relative-datetimes" in sys.argv + else status.created_at.strftime("%Y-%m-%d %H:%M") + ) + edited_flag = "*" if edited else " " favourited = ("yellow", "★") if status.original.favourited else " " reblogged = ("yellow", "♺") if status.original.reblogged else " " From 459937f196b36ab0c66cd1ff20a0dbea6a1ef572 Mon Sep 17 00:00:00 2001 From: Daniel Schwarz Date: Wed, 18 Jan 2023 20:15:37 -0500 Subject: [PATCH 52/54] --verbose and --no-color options now work with --debug logging --- toot/console.py | 5 +++++ toot/logging.py | 34 ++++++++++++++++++++++++---------- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/toot/console.py b/toot/console.py index f29856c..72743fe 100644 --- a/toot/console.py +++ b/toot/console.py @@ -116,6 +116,11 @@ common_args = [ "action": 'store_true', "default": False, }), + (["--verbose"], { + "help": "show extra detail in debug log; used with --debug", + "action": 'store_true', + "default": False, + }), ] # Arguments added to commands which require authentication diff --git a/toot/logging.py b/toot/logging.py index 7634a87..4c6e382 100644 --- a/toot/logging.py +++ b/toot/logging.py @@ -6,6 +6,18 @@ from logging import getLogger logger = getLogger('toot') VERBOSE = "--verbose" in sys.argv +COLOR = "--no-color" not in sys.argv + +if COLOR: + ANSI_RED = "\033[31m" + ANSI_GREEN = "\033[32m" + ANSI_YELLOW = "\033[33m" + ANSI_END_COLOR = "\033[0m" +else: + ANSI_RED = "" + ANSI_GREEN = "" + ANSI_YELLOW = "" + ANSI_END_COLOR = "" def censor_secrets(headers): @@ -25,36 +37,38 @@ def truncate(line): def log_request(request): - logger.debug(">>> \033[32m{} {}\033[0m".format(request.method, request.url)) + + logger.debug(f">>> {ANSI_GREEN}{request.method} {request.url}{ANSI_END_COLOR}") if request.headers: headers = censor_secrets(request.headers) - logger.debug(">>> HEADERS: \033[33m{}\033[0m".format(headers)) + logger.debug(f">>> HEADERS: {ANSI_GREEN}{headers}{ANSI_END_COLOR}") if request.data: data = truncate(request.data) - logger.debug(">>> DATA: \033[33m{}\033[0m".format(data)) + logger.debug(f">>> DATA: {ANSI_GREEN}{data}{ANSI_END_COLOR}") if request.json: data = truncate(json.dumps(request.json)) - logger.debug(">>> JSON: \033[33m{}\033[0m".format(data)) + logger.debug(f">>> JSON: {ANSI_GREEN}{data}{ANSI_END_COLOR}") if request.files: - logger.debug(">>> FILES: \033[33m{}\033[0m".format(request.files)) + logger.debug(f">>> FILES: {ANSI_GREEN}{request.files}{ANSI_END_COLOR}") if request.params: - logger.debug(">>> PARAMS: \033[33m{}\033[0m".format(request.params)) + logger.debug(f">>> PARAMS: {ANSI_GREEN}{request.params}{ANSI_END_COLOR}") def log_response(response): + content = truncate(response.content.decode()) if response.ok: - logger.debug("<<< \033[32m{}\033[0m".format(response)) - logger.debug("<<< \033[33m{}\033[0m".format(content)) + logger.debug(f"<<< {ANSI_GREEN}{response}{ANSI_END_COLOR}") + logger.debug(f"<<< {ANSI_YELLOW}{content}{ANSI_END_COLOR}") else: - logger.debug("<<< \033[31m{}\033[0m".format(response)) - logger.debug("<<< \033[31m{}\033[0m".format(content)) + logger.debug(f"<<< {ANSI_RED}{response}{ANSI_END_COLOR}") + logger.debug(f"<<< {ANSI_RED}{content}{ANSI_END_COLOR}") def log_debug(*msgs): From baa5a371256b0f759244dc57f5e4dd484b973f48 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Fri, 16 Dec 2022 12:56:51 +0100 Subject: [PATCH 53/54] Add custom fields to status output --- toot/output.py | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/toot/output.py b/toot/output.py index a87c1f4..3ec753a 100644 --- a/toot/output.py +++ b/toot/output.py @@ -3,7 +3,6 @@ import re import sys import textwrap -from textwrap import wrap from toot.tui.utils import parse_datetime from wcwidth import wcswidth @@ -167,11 +166,9 @@ def print_instance(instance): def print_account(account): print_out(f"@{account['acct']} {account['display_name']}") - note = get_text(account['note']) - - if note: + if account["note"]: print_out("") - print_out("\n".join(wrap(note))) + print_html(account["note"]) print_out("") print_out(f"ID: {account['id']}") @@ -180,6 +177,13 @@ def print_account(account): print_out(f"Followers: {account['followers_count']}") print_out(f"Following: {account['following_count']}") print_out(f"Statuses: {account['statuses_count']}") + + if account["fields"]: + for field in account["fields"]: + name = field["name"].title() + print_out(f'\n{name}:') + print_html(field["value"]) + print_out("") print_out(account["url"]) @@ -244,11 +248,8 @@ def print_status(status, width): f"{time}", ) - for paragraph in parse_html(content): - print_out("") - for line in paragraph: - for subline in wc_wrap(line, width): - print_out(highlight_hashtags(subline)) + print_out("") + print_html(content, width) if media_attachments: print_out("\nMedia:") @@ -268,6 +269,17 @@ def print_status(status, width): ) +def print_html(text, width=80): + first = True + for paragraph in parse_html(text): + if not first: + print_out("") + for line in paragraph: + for subline in wc_wrap(line, width): + print_out(highlight_hashtags(subline)) + first = False + + def print_poll(poll): print_out() for idx, option in enumerate(poll["options"]): From 40076ab0c420da5811a97561d6509911a747b069 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Mon, 30 Jan 2023 17:09:41 +0100 Subject: [PATCH 54/54] Print verified flag --- toot/output.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/toot/output.py b/toot/output.py index 3ec753a..5be6a92 100644 --- a/toot/output.py +++ b/toot/output.py @@ -183,6 +183,8 @@ def print_account(account): name = field["name"].title() print_out(f'\n{name}:') print_html(field["value"]) + if field["verified_at"]: + print_out("✓ Verified") print_out("") print_out(account["url"])