diff --git a/CHANGELOG.md b/CHANGELOG.md index 032d844..436ee03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,20 +3,42 @@ Changelog -**0.40.0 (TBA)** +**0.41.1 (2024-01-02)** -This release includes a major rewrite to use -[Click](https://click.palletsprojects.com/) for creating the command line -interface. This allows for some new features like nested commands, setting -parameters via environment variables, and shell completion. See docs for -details. Backward compatibility should be mostly preserved, except for cases -noted below please report any issues. +* Fix a crash in settings parsing code + +**0.41.0 (2024-01-02)** + +* Honour user's default visibility set in Mastodon preferences instead of always + defaulting to public visibility (thanks Lexi Winter) +* TUI: Add editing toots (thanks Lexi Winter) +* TUI: Fix a bug which made pallette config in settings not work +* TUI: Show edit datetime in status detail (thanks Lexi Winter) + +**0.40.2 (2023-12-28)** + +* Reinstate `toot post --using` option. +* Add shell completion for instances. + +**0.40.1 (2023-12-28)** + +* Add `toot --as` option to replace `toot post --using`. This now works for all + commands. + +**0.40.0 (2023-12-27)** + +This release includes a rather extensive change to use the Click library +(https://click.palletsprojects.com/) for creating the command line interface. +This allows for some new features like nested commands, setting parameters via +environment variables, and shell completion. Backward compatibility should be +mostly preserved, except for cases noted below. Please report any issues. * BREAKING: Remove deprecated `--disable-https` option for `login` and `login_cli`, pass the base URL instead -* BREAKING: Options `--debug`, `--color`, `--quiet` must be specified after - `toot` but before the command -* Enable passing params via environment variables, see: +* BREAKING: Options `--debug` and `--color` must be specified after `toot` but + before the command +* BREAKING: Option `--quiet` has been removed. Redirect output instead. +* Add passing parameters via environment variables, see: https://toot.bezdomni.net/environment_variables.html * Add shell completion, see: https://toot.bezdomni.net/shell_completion.html * Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature` @@ -26,11 +48,11 @@ noted below please report any issues. * Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`, `lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands. -* Add `--json` option to tags commands -* Add `--json` option to lists commands +* Add `--json` option to tags and lists commands * Add `toot --width` option for setting your prefered terminal width * Add `--media-viewer` and `--colors` options to `toot tui`. These were previously accessible only via settings. +* TUI: Fix issue where UI did not render until first input (thanks Urwid devs) **0.39.0 (2023-11-23)** diff --git a/changelog.yaml b/changelog.yaml index 2d48e53..8204466 100644 --- a/changelog.yaml +++ b/changelog.yaml @@ -1,23 +1,49 @@ +0.41.1: + date: 2024-01-02 + changes: + - "Fix a crash in settings parsing code" + +0.41.0: + date: 2024-01-02 + changes: + - "Honour user's default visibility set in Mastodon preferences instead of always defaulting to public visibility (thanks Lexi Winter)" + - "TUI: Add editing toots (thanks Lexi Winter)" + - "TUI: Fix a bug which made pallette config in settings not work" + - "TUI: Show edit datetime in status detail (thanks Lexi Winter)" + +0.40.2: + date: 2023-12-28 + changes: + - "Reinstate `toot post --using` option." + - "Add shell completion for instances." + +0.40.1: + date: 2023-12-28 + changes: + - "Add `toot --as` option to replace `toot post --using`. This now works for all commands." + 0.40.0: - date: TBA + date: 2023-12-27 description: | - This release includes a major rewrite to use [Click](https://click.palletsprojects.com/) for - creating the command line interface. This allows for some new features like nested commands, - setting parameters via environment variables, and shell completion. See docs for details. - Backward compatibility should be mostly preserved, except for cases noted below please report - any issues. + This release includes a rather extensive change to use the Click library + (https://click.palletsprojects.com/) for creating the command line + interface. This allows for some new features like nested commands, setting + parameters via environment variables, and shell completion. Backward + compatibility should be mostly preserved, except for cases noted below. + Please report any issues. changes: - "BREAKING: Remove deprecated `--disable-https` option for `login` and `login_cli`, pass the base URL instead" - - "BREAKING: Options `--debug`, `--color`, `--quiet` must be specified after `toot` but before the command" - - "Enable passing params via environment variables, see: https://toot.bezdomni.net/environment_variables.html" + - "BREAKING: Options `--debug` and `--color` must be specified after `toot` but before the command" + - "BREAKING: Option `--quiet` has been removed. Redirect output instead." + - "Add passing parameters via environment variables, see: https://toot.bezdomni.net/environment_variables.html" - "Add shell completion, see: https://toot.bezdomni.net/shell_completion.html" - "Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature` commands" - "Add `tags followed`, `tags follow`, and `tags unfollow` sub-commands, deprecate `tags_followed`, `tags_follow`, and `tags tags_unfollow`" - "Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`, `lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands." - - "Add `--json` option to tags commands" - - "Add `--json` option to lists commands" + - "Add `--json` option to tags and lists commands" - "Add `toot --width` option for setting your prefered terminal width" - "Add `--media-viewer` and `--colors` options to `toot tui`. These were previously accessible only via settings." + - "TUI: Fix issue where UI did not render until first input (thanks Urwid devs)" 0.39.0: date: 2023-11-23 diff --git a/docs/changelog.md b/docs/changelog.md index 032d844..436ee03 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,20 +3,42 @@ Changelog -**0.40.0 (TBA)** +**0.41.1 (2024-01-02)** -This release includes a major rewrite to use -[Click](https://click.palletsprojects.com/) for creating the command line -interface. This allows for some new features like nested commands, setting -parameters via environment variables, and shell completion. See docs for -details. Backward compatibility should be mostly preserved, except for cases -noted below please report any issues. +* Fix a crash in settings parsing code + +**0.41.0 (2024-01-02)** + +* Honour user's default visibility set in Mastodon preferences instead of always + defaulting to public visibility (thanks Lexi Winter) +* TUI: Add editing toots (thanks Lexi Winter) +* TUI: Fix a bug which made pallette config in settings not work +* TUI: Show edit datetime in status detail (thanks Lexi Winter) + +**0.40.2 (2023-12-28)** + +* Reinstate `toot post --using` option. +* Add shell completion for instances. + +**0.40.1 (2023-12-28)** + +* Add `toot --as` option to replace `toot post --using`. This now works for all + commands. + +**0.40.0 (2023-12-27)** + +This release includes a rather extensive change to use the Click library +(https://click.palletsprojects.com/) for creating the command line interface. +This allows for some new features like nested commands, setting parameters via +environment variables, and shell completion. Backward compatibility should be +mostly preserved, except for cases noted below. Please report any issues. * BREAKING: Remove deprecated `--disable-https` option for `login` and `login_cli`, pass the base URL instead -* BREAKING: Options `--debug`, `--color`, `--quiet` must be specified after - `toot` but before the command -* Enable passing params via environment variables, see: +* BREAKING: Options `--debug` and `--color` must be specified after `toot` but + before the command +* BREAKING: Option `--quiet` has been removed. Redirect output instead. +* Add passing parameters via environment variables, see: https://toot.bezdomni.net/environment_variables.html * Add shell completion, see: https://toot.bezdomni.net/shell_completion.html * Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature` @@ -26,11 +48,11 @@ noted below please report any issues. * Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`, `lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands. -* Add `--json` option to tags commands -* Add `--json` option to lists commands +* Add `--json` option to tags and lists commands * Add `toot --width` option for setting your prefered terminal width * Add `--media-viewer` and `--colors` options to `toot tui`. These were previously accessible only via settings. +* TUI: Fix issue where UI did not render until first input (thanks Urwid devs) **0.39.0 (2023-11-23)** diff --git a/scripts/tag_version b/scripts/tag_version index 56efe8d..6f919fe 100755 --- a/scripts/tag_version +++ b/scripts/tag_version @@ -43,6 +43,7 @@ if dist_version != version: sys.exit(1) release_date = changelog_item["date"] +description = changelog_item.get("description") changes = changelog_item["changes"] if not isinstance(release_date, date): @@ -50,6 +51,11 @@ if not isinstance(release_date, date): sys.exit(1) commit_message = f"toot {version}\n\n" + +if description: + lines = textwrap.wrap(description.strip(), 72) + commit_message += "\n".join(lines) + "\n\n" + for c in changes: lines = textwrap.wrap(c, 70) initial = True diff --git a/setup.py b/setup.py index c587639..decc45f 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ and blocking accounts and other actions. setup( name='toot', - version='0.40.0', + version='0.41.1', description='Mastodon CLI client', long_description=long_description.strip(), author='Ivan Habunek', diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index fea6477..fbb04f5 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -9,15 +9,14 @@ your test server and database: ``` export TOOT_TEST_BASE_URL="localhost:3000" -export TOOT_TEST_DATABASE_DSN="dbname=mastodon_development" ``` """ import json -import re import os -import psycopg2 import pytest +import re +import typing as t import uuid from click.testing import CliRunner, Result @@ -31,8 +30,10 @@ def pytest_configure(config): toot.settings.DISABLE_SETTINGS = True +# Type alias for run commands +Run = t.Callable[..., Result] + # Mastodon database name, used to confirm user registration without having to click the link -DATABASE_DSN = os.getenv("TOOT_TEST_DATABASE_DSN") TOOT_TEST_BASE_URL = os.getenv("TOOT_TEST_BASE_URL") # Toot logo used for testing image upload @@ -52,17 +53,9 @@ def register_account(app: App): email = f"{username}@example.com" response = api.register_account(app, username, email, "password", "en") - confirm_user(email) return User(app.instance, username, response["access_token"]) -def confirm_user(email): - conn = psycopg2.connect(DATABASE_DSN) - cursor = conn.cursor() - cursor.execute("UPDATE users SET confirmed_at = now() WHERE email = %s;", (email,)) - conn.commit() - - # ------------------------------------------------------------------------------ # Fixtures # ------------------------------------------------------------------------------ diff --git a/tests/integration/test_auth.py b/tests/integration/test_auth.py index 858d6fb..74db83b 100644 --- a/tests/integration/test_auth.py +++ b/tests/integration/test_auth.py @@ -3,7 +3,7 @@ from unittest import mock from unittest.mock import MagicMock from toot import User, cli -from toot.cli import Run +from tests.integration.conftest import Run # TODO: figure out how to test login diff --git a/tests/integration/test_status.py b/tests/integration/test_status.py index 6f9a2a4..8e609a6 100644 --- a/tests/integration/test_status.py +++ b/tests/integration/test_status.py @@ -1,7 +1,7 @@ import json -import time import pytest +from tests.utils import run_with_retries from toot import api, cli from toot.exceptions import NotFoundError @@ -46,11 +46,11 @@ def test_favourite(app, user, run): assert result.exit_code == 0 assert result.stdout.strip() == "✓ Status unfavourited" - # A short delay is required before the server returns new data - time.sleep(0.2) - - status = api.fetch_status(app, user, status["id"]).json() - assert not status["favourited"] + def test_favourited(): + nonlocal status + status = api.fetch_status(app, user, status["id"]).json() + assert not status["favourited"] + run_with_retries(test_favourited) def test_favourite_json(app, user, run): diff --git a/tests/integration/test_timelines.py b/tests/integration/test_timelines.py index d4cebbd..818ba3d 100644 --- a/tests/integration/test_timelines.py +++ b/tests/integration/test_timelines.py @@ -1,7 +1,7 @@ import pytest -from time import sleep from uuid import uuid4 +from tests.utils import run_with_retries from toot import api, cli from toot.entities import from_dict, Status @@ -40,16 +40,14 @@ def test_timelines(app, user, other_user, friend_user, friend_list, run): status2 = _post_status(app, other_user, "#bar") status3 = _post_status(app, friend_user, "#foo #bar") - # Give mastodon time to process things :/ - # Tests fail if this is removed, required delay depends on server speed - sleep(1) - # Home timeline - result = run(cli.timelines.timeline) - assert result.exit_code == 0 - assert status1.id in result.stdout - assert status2.id not in result.stdout - assert status3.id in result.stdout + def test_home(): + result = run(cli.timelines.timeline) + assert result.exit_code == 0 + assert status1.id in result.stdout + assert status2.id not in result.stdout + assert status3.id in result.stdout + run_with_retries(test_home) # Public timeline result = run(cli.timelines.timeline, "--public") @@ -166,13 +164,14 @@ def test_notifications(app, user, other_user, run): text = f"Paging doctor @{user.username}" status = _post_status(app, other_user, text) - sleep(0.5) # grr - result = run(cli.timelines.notifications) - assert result.exit_code == 0 - assert f"@{other_user.username} mentioned you" in result.stdout - assert status.id in result.stdout - assert text in result.stdout + def test_notifications(): + result = run(cli.timelines.notifications) + assert result.exit_code == 0 + assert f"@{other_user.username} mentioned you" in result.stdout + assert status.id in result.stdout + assert text in result.stdout + run_with_retries(test_notifications) result = run(cli.timelines.notifications, "--mentions") assert result.exit_code == 0 @@ -186,7 +185,6 @@ def test_notifications_follow(app, user, friend_user, run_as): assert result.exit_code == 0 assert f"@{user.username} now follows you" in result.stdout - result = run_as(friend_user, cli.timelines.notifications, "--mentions") assert result.exit_code == 0 assert "now follows you" not in result.stdout diff --git a/tests/utils.py b/tests/utils.py index cdae09c..817bdb9 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -2,6 +2,9 @@ Helpers for testing. """ +import time +from typing import Any, Callable + class MockResponse: def __init__(self, response_data={}, ok=True, is_redirect=False): @@ -19,3 +22,23 @@ class MockResponse: def retval(val): return lambda *args, **kwargs: val + + +def run_with_retries(fn: Callable[..., Any]): + """ + Run the the given function repeatedly until it finishes without raising an + AssertionError. Sleep a bit between attempts. If the function doesn't + succeed in the given number of tries raises the AssertionError. Used for + tests which should eventually succeed. + """ + + # Wait upto 6 seconds with incrementally longer sleeps + delays = [0.1, 0.2, 0.3, 0.4, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] + + for delay in delays: + try: + return fn() + except AssertionError: + time.sleep(delay) + + fn() diff --git a/toot/__init__.py b/toot/__init__.py index 010b17a..2a3f4ab 100644 --- a/toot/__init__.py +++ b/toot/__init__.py @@ -4,7 +4,7 @@ import sys from os.path import join, expanduser from typing import NamedTuple -__version__ = '0.40.0' +__version__ = '0.41.1' class App(NamedTuple): diff --git a/toot/api.py b/toot/api.py index 5071df4..01dcc74 100644 --- a/toot/api.py +++ b/toot/api.py @@ -183,7 +183,7 @@ def post_status( app, user, status, - visibility='public', + visibility=None, media_ids=None, sensitive=False, spoiler_text=None, @@ -230,6 +230,52 @@ def post_status( return http.post(app, user, '/api/v1/statuses', json=data, headers=headers) +def edit_status( + app, + user, + id, + status, + visibility='public', + media_ids=None, + sensitive=False, + spoiler_text=None, + in_reply_to_id=None, + language=None, + content_type=None, + poll_options=None, + poll_expires_in=None, + poll_multiple=None, + poll_hide_totals=None, +) -> Response: + """ + Edit an existing status + https://docs.joinmastodon.org/methods/statuses/#edit + """ + + # Strip keys for which value is None + # Sending null values doesn't bother Mastodon, but it breaks Pleroma + data = drop_empty_values({ + 'status': status, + 'media_ids': media_ids, + 'visibility': visibility, + 'sensitive': sensitive, + 'in_reply_to_id': in_reply_to_id, + 'language': language, + 'content_type': content_type, + 'spoiler_text': spoiler_text, + }) + + if poll_options: + data["poll"] = { + "options": poll_options, + "expires_in": poll_expires_in, + "multiple": poll_multiple, + "hide_totals": poll_hide_totals, + } + + return http.put(app, user, f"/api/v1/statuses/{id}", json=data) + + def fetch_status(app, user, id): """ Fetch a single status @@ -238,6 +284,15 @@ def fetch_status(app, user, id): return http.get(app, user, f"/api/v1/statuses/{id}") +def fetch_status_source(app, user, id): + """ + Fetch the source (original text) for a single status. + This only works on local toots. + https://docs.joinmastodon.org/methods/statuses/#source + """ + return http.get(app, user, f"/api/v1/statuses/{id}/source") + + def scheduled_statuses(app, user): """ List scheduled statuses @@ -618,6 +673,10 @@ def get_instance(base_url: str) -> Response: return http.anon_get(url) +def get_preferences(app, user) -> Response: + return http.get(app, user, '/api/v1/preferences') + + def get_lists(app, user): return http.get(app, user, "/api/v1/lists").json() diff --git a/toot/cli/__init__.py b/toot/cli/__init__.py index 6e888a6..a6af85a 100644 --- a/toot/cli/__init__.py +++ b/toot/cli/__init__.py @@ -4,9 +4,12 @@ import os import sys import typing as t -from click.testing import Result +from click.shell_completion import CompletionItem +from click.types import StringParamType from functools import wraps + from toot import App, User, config, __version__ +from toot.output import print_warning from toot.settings import get_settings if t.TYPE_CHECKING: @@ -35,10 +38,6 @@ DURATION_EXAMPLES = """e.g. "1 day", "2 hours 30 minutes", "5 minutes 30 seconds" or any combination of above. Shorthand: "1d", "2h30m", "5m30s\"""" -# Type alias for run commands -Run = t.Callable[..., Result] - - def get_default_visibility() -> str: return os.getenv("TOOT_POST_VISIBILITY", "public") @@ -47,6 +46,17 @@ def get_default_map(): settings = get_settings() common = settings.get("common", {}) commands = settings.get("commands", {}) + + # TODO: remove in version 1.0 + tui_old = settings.get("tui", {}).copy() + if "palette" in tui_old: + del tui_old["palette"] + if tui_old: + # TODO: don't show the warning for [toot.palette] + print_warning("Settings section [tui] has been deprecated in favour of [commands.tui].") + tui_new = commands.get("tui", {}) + commands["tui"] = {**tui_old, **tui_new} + return {**common, **commands} @@ -69,18 +79,44 @@ class Context(t.NamedTuple): user: t.Optional[User] = None color: bool = False debug: bool = False - quiet: bool = False class TootObj(t.NamedTuple): """Data to add to Click context""" color: bool = True debug: bool = False - quiet: bool = False + as_user: t.Optional[str] = None # Pass a context for testing purposes test_ctx: t.Optional[Context] = None +class AccountParamType(StringParamType): + """Custom type to add shell completion for account names""" + name = "account" + + def shell_complete(self, ctx, param, incomplete: str): + users = config.load_config()["users"].keys() + return [ + CompletionItem(u) + for u in users + if u.lower().startswith(incomplete.lower()) + ] + + +class InstanceParamType(StringParamType): + """Custom type to add shell completion for instance domains""" + name = "instance" + + def shell_complete(self, ctx, param, incomplete: str): + apps = config.load_config()["apps"] + + return [ + CompletionItem(i) + for i in apps.keys() + if i.lower().startswith(incomplete.lower()) + ] + + def pass_context(f: "t.Callable[te.Concatenate[Context, P], R]") -> "t.Callable[P, R]": """Pass the toot Context as first argument.""" @wraps(f) @@ -98,11 +134,16 @@ def get_context() -> Context: if obj.test_ctx: return obj.test_ctx - user, app = config.get_active_user_app() - if not user or not app: - raise click.ClickException("This command requires you to be logged in.") + if obj.as_user: + user, app = config.get_user_app(obj.as_user) + if not user or not app: + raise click.ClickException(f"Account '{obj.as_user}' not found. Run `toot auth` to see available accounts.") + else: + user, app = config.get_active_user_app() + if not user or not app: + raise click.ClickException("This command requires you to be logged in.") - return Context(app, user, obj.color, obj.debug, obj.quiet) + return Context(app, user, obj.color, obj.debug) json_option = click.option( @@ -117,12 +158,12 @@ json_option = click.option( @click.option("-w", "--max-width", type=int, default=80, help="Maximum width for content rendered by toot") @click.option("--debug/--no-debug", default=False, help="Log debug info to stderr") @click.option("--color/--no-color", default=sys.stdout.isatty(), help="Use ANSI color in output") -@click.option("--quiet/--no-quiet", default=False, help="Don't print anything to stdout") +@click.option("--as", "as_user", type=AccountParamType(), help="The account to use, overrides the active account.") @click.version_option(__version__, message="%(prog)s v%(version)s") @click.pass_context -def cli(ctx: click.Context, max_width: int, color: bool, debug: bool, quiet: bool): +def cli(ctx: click.Context, max_width: int, color: bool, debug: bool, as_user: str): """Toot is a Mastodon CLI""" - ctx.obj = TootObj(color, debug, quiet) + ctx.obj = TootObj(color, debug, as_user) ctx.color = color ctx.max_content_width = max_width diff --git a/toot/cli/auth.py b/toot/cli/auth.py index c72f0c4..b7d32e6 100644 --- a/toot/cli/auth.py +++ b/toot/cli/auth.py @@ -2,13 +2,10 @@ import click import platform import sys import webbrowser -from click.shell_completion import CompletionItem - -from click.types import StringParamType from toot import api, config, __version__ from toot.auth import get_or_create_app, login_auth_code, login_username_password -from toot.cli import cli +from toot.cli import AccountParamType, cli from toot.cli.validators import validate_instance @@ -22,18 +19,6 @@ instance_option = click.option( ) -class AccountParamType(StringParamType): - """Custom type to add shell completion for account names""" - - def shell_complete(self, ctx, param, incomplete: str): - accounts = config.load_config()["users"].keys() - return [ - CompletionItem(a) - for a in accounts - if a.lower().startswith(incomplete.lower()) - ] - - @cli.command() def auth(): """Show logged in accounts and instances""" diff --git a/toot/cli/lists.py b/toot/cli/lists.py index 8bf39fb..f0d4ae6 100644 --- a/toot/cli/lists.py +++ b/toot/cli/lists.py @@ -11,7 +11,8 @@ from toot.output import print_list_accounts, print_lists, print_warning def lists(ctx: click.Context): """Display and manage lists""" if ctx.invoked_subcommand is None: - print_warning("`toot lists` is deprecated in favour of `toot lists list`") + print_warning("`toot lists` is deprecated in favour of `toot lists list`.\n" + + "Run `toot lists -h` to see other list-related commands.") user, app = config.get_active_user_app() if not user or not app: diff --git a/toot/cli/post.py b/toot/cli/post.py index af0fa60..8d54dd1 100644 --- a/toot/cli/post.py +++ b/toot/cli/post.py @@ -6,8 +6,8 @@ from datetime import datetime, timedelta, timezone from time import sleep, time from typing import BinaryIO, Optional, Tuple -from toot import api -from toot.cli import cli, json_option, pass_context, Context +from toot import api, config +from toot.cli import AccountParamType, cli, json_option, pass_context, Context from toot.cli import DURATION_EXAMPLES, VISIBILITY_CHOICES from toot.cli.validators import validate_duration, validate_language from toot.entities import MediaAttachment, from_dict @@ -40,7 +40,6 @@ from toot.utils.datetime import parse_datetime "--visibility", "-v", help="Post visibility", type=click.Choice(VISIBILITY_CHOICES), - default="public", ) @click.option( "--sensitive", "-s", @@ -106,6 +105,11 @@ from toot.utils.datetime import parse_datetime is_flag=True, default=False, ) +@click.option( + "-u", "--using", + type=AccountParamType(), + help="The account to use, overrides the active account.", +) @json_option @pass_context def post( @@ -114,7 +118,7 @@ def post( media: Tuple[str], descriptions: Tuple[str], thumbnails: Tuple[str], - visibility: str, + visibility: Optional[str], sensitive: bool, spoiler_text: Optional[str], reply_to: Optional[str], @@ -127,12 +131,20 @@ def post( poll_expires_in: int, poll_multiple: bool, poll_hide_totals: bool, - json: bool + json: bool, + using: str ): """Post a new status""" if len(media) > 4: raise click.ClickException("Cannot attach more than 4 files.") + if using: + user, app = config.get_user_app(using) + if not user or not app: + raise click.ClickException(f"Account '{using}' not found. Run `toot auth` to see available accounts.") + else: + user, app = ctx.user, ctx.app + media_ids = _upload_media(ctx.app, ctx.user, media, descriptions, thumbnails) status_text = _get_status_text(text, editor, media) scheduled_at = _get_scheduled_at(scheduled_at, scheduled_in) @@ -141,8 +153,8 @@ def post( raise click.ClickException("You must specify either text or media to post.") response = api.post_status( - ctx.app, - ctx.user, + app, + user, status_text, visibility=visibility, media_ids=media_ids, diff --git a/toot/cli/read.py b/toot/cli/read.py index cfbcea8..32ce49a 100644 --- a/toot/cli/read.py +++ b/toot/cli/read.py @@ -9,7 +9,7 @@ from toot.cli.validators import validate_instance from toot.entities import Instance, Status, from_dict, Account from toot.exceptions import ApiError, ConsoleError from toot.output import print_account, print_instance, print_search_results, print_status, print_timeline -from toot.cli import cli, get_context, json_option, pass_context, Context +from toot.cli import InstanceParamType, cli, get_context, json_option, pass_context, Context @cli.command() @@ -43,7 +43,7 @@ def whois(ctx: Context, account: str, json: bool): @cli.command() -@click.argument("instance", callback=validate_instance, required=False) +@click.argument("instance", type=InstanceParamType(), callback=validate_instance, required=False) @json_option def instance(instance: Optional[str], json: bool): """Display instance details diff --git a/toot/cli/timelines.py b/toot/cli/timelines.py index 86d0ceb..aac2c45 100644 --- a/toot/cli/timelines.py +++ b/toot/cli/timelines.py @@ -2,7 +2,7 @@ import sys import click from toot import api -from toot.cli import cli, get_context, pass_context, Context +from toot.cli import InstanceParamType, cli, get_context, pass_context, Context from typing import Optional from toot.cli.validators import validate_instance @@ -13,6 +13,7 @@ from toot.output import print_notifications, print_timeline @cli.command() @click.option( "--instance", "-i", + type=InstanceParamType(), callback=validate_instance, help="""Domain or base URL of the instance from which to read, e.g. 'mastodon.social' or 'https://mastodon.social'""", diff --git a/toot/cli/tui.py b/toot/cli/tui.py index c333fb3..e5456a1 100644 --- a/toot/cli/tui.py +++ b/toot/cli/tui.py @@ -1,7 +1,7 @@ import click from typing import Optional -from toot.cli import TUI_COLORS, Context, cli, pass_context +from toot.cli import TUI_COLORS, VISIBILITY_CHOICES, Context, cli, pass_context from toot.cli.validators import validate_tui_colors, validate_cache_size from toot.tui.app import TUI, TuiOptions @@ -30,13 +30,25 @@ COLOR_OPTIONS = ", ".join(TUI_COLORS.keys()) help="""Specify the image cache maximum size in megabytes. Default: 10MB. Minimum: 1MB.""" ) +@click.option( + "-v", "--default-visibility", + type=click.Choice(VISIBILITY_CHOICES), + help="Default visibility when posting new toots; overrides the server-side preference" +) +@click.option( + "-S", "--always-show-sensitive", + is_flag=True, + help="Expand toots with content warnings automatically" +) @pass_context def tui( ctx: Context, colors: Optional[int], media_viewer: Optional[str], + always_show_sensitive: bool, relative_datetimes: bool, cache_size: Optional[int], + default_visibility: Optional[str] ): """Launches the toot terminal user interface""" if colors is None: @@ -47,6 +59,8 @@ def tui( media_viewer=media_viewer, relative_datetimes=relative_datetimes, cache_size=cache_size, + default_visibility=default_visibility, + always_show_sensitive=always_show_sensitive, ) tui = TUI.create(ctx.app, ctx.user, options) tui.run() diff --git a/toot/http.py b/toot/http.py index 14acdd0..ec4b62a 100644 --- a/toot/http.py +++ b/toot/http.py @@ -38,7 +38,7 @@ def _get_error_message(response): except Exception: pass - return "Unknown error" + return f"Unknown error: {response.status_code} {response.reason}" def process_response(response): @@ -81,6 +81,22 @@ def post(app, user, path, headers=None, files=None, data=None, json=None, allow_ return anon_post(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects) +def anon_put(url, headers=None, files=None, data=None, json=None, allow_redirects=True): + request = Request(method="PUT", url=url, headers=headers, files=files, data=data, json=json) + response = send_request(request, allow_redirects) + + return process_response(response) + + +def put(app, user, path, headers=None, files=None, data=None, json=None, allow_redirects=True): + url = app.base_url + path + + headers = headers or {} + headers["Authorization"] = f"Bearer {user.access_token}" + + return anon_put(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects) + + def patch(app, user, path, headers=None, files=None, data=None, json=None): url = app.base_url + path diff --git a/toot/output.py b/toot/output.py index 266f467..bc85c0d 100644 --- a/toot/output.py +++ b/toot/output.py @@ -274,8 +274,9 @@ def print_notification(notification: Notification): def print_notifications(notifications: List[Notification]): for notification in notifications: - print_divider() - print_notification(notification) + if notification.type not in ['pleroma:emoji_reaction']: + print_divider() + print_notification(notification) print_divider() diff --git a/toot/tui/app.py b/toot/tui/app.py index bc2de45..a6a27e1 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -6,11 +6,13 @@ import warnings from concurrent.futures import ThreadPoolExecutor from typing import NamedTuple, Optional +from datetime import datetime, timezone from toot import api, config, __version__, settings from toot import App, User from toot.cli import get_default_visibility from toot.exceptions import ApiError +from toot.utils.datetime import parse_datetime from .compose import StatusComposer from .constants import PALETTE @@ -22,7 +24,8 @@ from .poll import Poll from .timeline import Timeline from .utils import get_max_toot_chars, parse_content_links, copy_to_clipboard, ImageCache from PIL import Image - +from .widgets import ModalBox +>>>>>>> master logger = logging.getLogger(__name__) @@ -35,9 +38,10 @@ DEFAULT_MAX_TOOT_CHARS = 500 class TuiOptions(NamedTuple): colors: int media_viewer: Optional[str] + always_show_sensitive: bool relative_datetimes: bool cache_size: int - + default_visibility: Optional[bool] class Header(urwid.WidgetWrap): def __init__(self, app, user): @@ -143,6 +147,7 @@ class TUI(urwid.Frame): self.can_translate = False self.account = None self.followed_accounts = [] + self.preferences = {} if self.options.cache_size: self.cache_max = 1024 * 1024 * self.options.cache_size @@ -153,6 +158,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_preferences()) self.loop.set_alarm_in(0, lambda *args: self.async_load_timeline( is_initial=True, timeline_name="home")) self.loop.set_alarm_in(0, lambda *args: self.async_load_followed_accounts()) @@ -337,6 +343,19 @@ class TUI(urwid.Frame): return self.run_in_thread(_load_instance, done_callback=_done) + def async_load_preferences(self): + """ + Attempt to update user preferences from instance. + https://docs.joinmastodon.org/methods/preferences/ + """ + def _load_preferences(): + return api.get_preferences(self.app, self.user).json() + + def _done(preferences): + self.preferences = preferences + + return self.run_in_thread(_load_preferences, done_callback=_done) + def async_load_followed_accounts(self): def _load_accounts(): try: @@ -411,11 +430,45 @@ class TUI(urwid.Frame): def _post(timeline, *args): self.post_status(*args) - composer = StatusComposer(self.max_toot_chars, self.user.username, in_reply_to) + # If the user specified --default-visibility, use that; otherwise, + # try to use the server-side default visibility. If that fails, fall + # back to get_default_visibility(). + visibility = (self.options.default_visibility or + self.preferences.get('posting:default:visibility', + get_default_visibility())) + + composer = StatusComposer(self.max_toot_chars, self.user.username, + visibility, in_reply_to) urwid.connect_signal(composer, "close", _close) urwid.connect_signal(composer, "post", _post) self.open_overlay(composer, title="Compose status") + def async_edit(self, status): + def _fetch_source(): + return api.fetch_status_source(self.app, self.user, status.id).json() + + def _done(source): + self.close_overlay() + self.show_edit(status, source) + + please_wait = ModalBox("Loading status...") + self.open_overlay(please_wait) + + self.run_in_thread(_fetch_source, done_callback=_done) + + def show_edit(self, status, source): + def _close(*args): + self.close_overlay() + + def _edit(timeline, *args): + self.edit_status(status, *args) + + composer = StatusComposer(self.max_toot_chars, self.user.username, + visibility=None, edit=status, source=source) + urwid.connect_signal(composer, "close", _close) + urwid.connect_signal(composer, "post", _edit) + self.open_overlay(composer, title="Edit status") + def show_goto_menu(self): user_timelines = self.config.get("timelines", {}) user_lists = api.get_lists(self.app, self.user) or [] @@ -563,6 +616,42 @@ class TUI(urwid.Frame): self.footer.set_message("Status posted {} \\o/".format(status.id)) self.close_overlay() + def edit_status(self, status, content, warning, visibility, in_reply_to_id): + # We don't support editing polls (yet), so to avoid losing the poll + # data from the original toot, copy it to the edit request. + poll_args = {} + poll = status.original.data.get('poll', None) + + if poll is not None: + poll_args['poll_options'] = [o['title'] for o in poll['options']] + poll_args['poll_multiple'] = poll['multiple'] + + # Convert absolute expiry time into seconds from now. + expires_at = parse_datetime(poll['expires_at']) + expires_in = int((expires_at - datetime.now(timezone.utc)).total_seconds()) + poll_args['poll_expires_in'] = expires_in + + if 'hide_totals' in poll: + poll_args['poll_hide_totals'] = poll['hide_totals'] + + data = api.edit_status( + self.app, + self.user, + status.id, + content, + spoiler_text=warning, + visibility=visibility, + **poll_args + ).json() + + new_status = self.make_status(data) + + self.footer.set_message("Status edited {} \\o/".format(status.id)) + self.close_overlay() + + if self.timeline is not None: + self.timeline.update_status(new_status) + def show_account(self, account_id): account = api.whois(self.app, self.user, account_id) relationship = api.get_relationship(self.app, self.user, account_id) diff --git a/toot/tui/compose.py b/toot/tui/compose.py index c4a038a..a931fa3 100644 --- a/toot/tui/compose.py +++ b/toot/tui/compose.py @@ -1,8 +1,6 @@ import urwid import logging -from toot.cli import get_default_visibility - from .constants import VISIBILITY_OPTIONS from .widgets import Button, EditBox @@ -11,21 +9,22 @@ logger = logging.getLogger(__name__) class StatusComposer(urwid.Frame): """ - UI for compose and posting a status message. + UI for composing or editing a status message. + + To edit a status, provide the original status in 'edit', and optionally + provide the status source (from the /status/:id/source API endpoint) in + 'source'; this should have at least a 'text' member, and optionally + 'spoiler_text'. If source is not provided, the formatted HTML will be + presented to the user for editing. """ signals = ["close", "post"] - def __init__(self, max_chars, username, in_reply_to=None): + def __init__(self, max_chars, username, visibility, in_reply_to=None, + edit=None, source=None): self.in_reply_to = in_reply_to self.max_chars = max_chars self.username = username - - text = self.get_initial_text(in_reply_to) - self.content_edit = EditBox( - edit_text=text, edit_pos=len(text), multiline=True, allow_tab=True) - urwid.connect_signal(self.content_edit.edit, "change", self.text_changed) - - self.char_count = urwid.Text(["0/{}".format(max_chars)]) + self.edit = edit self.cw_edit = None self.cw_add_button = Button("Add content warning", @@ -33,13 +32,34 @@ class StatusComposer(urwid.Frame): self.cw_remove_button = Button("Remove content warning", on_press=self.remove_content_warning) - self.visibility = ( - in_reply_to.visibility if in_reply_to else get_default_visibility() - ) + if edit: + if source is None: + text = edit.data["content"] + else: + text = source.get("text", edit.data["content"]) + + if 'spoiler_text' in source: + self.cw_edit = EditBox(multiline=True, allow_tab=True, + edit_text=source['spoiler_text']) + + self.visibility = edit.data["visibility"] + + else: # not edit + text = self.get_initial_text(in_reply_to) + self.visibility = ( + in_reply_to.visibility if in_reply_to else visibility + ) + + self.content_edit = EditBox( + edit_text=text, edit_pos=len(text), multiline=True, allow_tab=True) + urwid.connect_signal(self.content_edit.edit, "change", self.text_changed) + + self.char_count = urwid.Text(["0/{}".format(max_chars)]) + self.visibility_button = Button("Visibility: {}".format(self.visibility), on_press=self.choose_visibility) - self.post_button = Button("Post", on_press=self.post) + self.post_button = Button("Edit" if edit else "Post", on_press=self.post) self.cancel_button = Button("Cancel", on_press=self.close) contents = list(self.generate_list_items()) diff --git a/toot/tui/entities.py b/toot/tui/entities.py index 165ca77..642e953 100644 --- a/toot/tui/entities.py +++ b/toot/tui/entities.py @@ -53,6 +53,10 @@ class Status: self.id = self.data["id"] self.account = self._get_account() self.created_at = parse_datetime(data["created_at"]) + if data["edited_at"]: + self.edited_at = parse_datetime(data["edited_at"]) + else: + self.edited_at = None self.author = self._get_author() self.favourited = data.get("favourited", False) self.reblogged = data.get("reblogged", False) diff --git a/toot/tui/overlays.py b/toot/tui/overlays.py index e5327c8..95072ba 100644 --- a/toot/tui/overlays.py +++ b/toot/tui/overlays.py @@ -231,6 +231,7 @@ class Help(urwid.Padding): 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")) + yield urwid.Text(h(" [M] - Show status media")) yield urwid.Text(h(" [T] - Show status thread (replies)")) yield urwid.Text(h(" [L] - Show the status links")) yield urwid.Text(h(" [U] - Show the status data in JSON as received from the server")) diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index 43fd16e..23dc164 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -109,6 +109,7 @@ class Timeline(urwid.Columns): "[A]ccount" if not status.is_mine else "", "[B]oost", "[D]elete" if status.is_mine else "", + "[E]dit" if status.is_mine else "", "B[o]okmark", "[F]avourite", "[V]iew", @@ -198,6 +199,11 @@ class Timeline(urwid.Columns): self.tui.show_delete_confirmation(status) return + if key in ("e", "E"): + if status.is_mine: + self.tui.async_edit(status) + return + if key in ("f", "F"): self.tui.async_toggle_favourite(self, status) return @@ -349,6 +355,7 @@ class StatusDetails(urwid.Pile): if self.status: self.status.placeholders = [] self.followed_accounts = timeline.tui.followed_accounts + self.options = timeline.tui.options reblogged_by = status.author if status and status.reblog else None widget_list = list(self.content_generator(status.original, reblogged_by) @@ -447,9 +454,12 @@ class StatusDetails(urwid.Pile): yield ("pack", urwid.Divider()) # Show content warning - if status.data["spoiler_text"] and not status.show_sensitive: + if status.data["spoiler_text"] and not status.show_sensitive and not self.options.always_show_sensitive: yield ("pack", urwid.Text(("content_warning", "Marked as sensitive. Press S to view."))) else: + if status.data["spoiler_text"]: + yield ("pack", urwid.Text(("content_warning", "Marked as sensitive."))) + content = status.original.translation if status.original.show_translation else status.data["content"] widgetlist = html_to_widgets(content) @@ -516,6 +526,8 @@ class StatusDetails(urwid.Pile): yield ("pack", urwid.Text([ ("status_detail_timestamp", f"{status.created_at.strftime('%Y-%m-%d %H:%M')} "), + ("status_detail_timestamp", + f"(edited {status.edited_at.strftime('%Y-%m-%d %H:%M')}) " if status.edited_at else ""), ("status_detail_bookmarked" if status.bookmarked else "dim", "b "), ("dim", f"⤶ {status.data['replies_count']} "), ("highlight" if status.reblogged else "dim", f"♺ {status.data['reblogs_count']} "), @@ -579,7 +591,7 @@ class StatusDetails(urwid.Pile): class StatusListItem(SelectableColumns): def __init__(self, status, relative_datetimes): - edited_at = status.data.get("edited_at") + edited_at = status.original.edited_at # TODO: hacky implementation to avoid creating conflicts for existing # pull reuqests, refactor when merged. @@ -593,7 +605,7 @@ class StatusListItem(SelectableColumns): favourited = ("highlight", "★") if status.original.favourited else " " reblogged = ("highlight", "♺") if status.original.reblogged else " " is_reblog = ("dim", "♺") if status.reblog else " " - is_reply = ("dim", "⤶") if status.original.in_reply_to else " " + is_reply = ("dim", "⤶ ") if status.original.in_reply_to else " " return super().__init__([ ("pack", SelectableText(("status_list_timestamp", created_at), wrap="clip")), diff --git a/toot/tui/widgets.py b/toot/tui/widgets.py index f9fa411..dc05aad 100644 --- a/toot/tui/widgets.py +++ b/toot/tui/widgets.py @@ -159,3 +159,10 @@ class EmojiText(urwid.Padding): columns.append(("weight", 9999, urwid.Text(""))) column_widget = urwid.Columns(columns, dividechars=1, min_width=2) super().__init__(column_widget) + +class ModalBox(urwid.Frame): + def __init__(self, message): + text = urwid.Text(message) + filler = urwid.Filler(text, valign='top', top=1, bottom=1) + padding = urwid.Padding(filler, left=1, right=1) + return super().__init__(padding)