Merge branch 'master' into images

This commit is contained in:
Daniel Schwarz 2024-01-06 13:04:59 -05:00
commit b94c500c9c
27 changed files with 492 additions and 139 deletions

View File

@ -3,20 +3,42 @@ Changelog
<!-- Do not edit. This file is automatically generated from changelog.yaml.--> <!-- Do not edit. This file is automatically generated from changelog.yaml.-->
**0.40.0 (TBA)** **0.41.1 (2024-01-02)**
This release includes a major rewrite to use * Fix a crash in settings parsing code
[Click](https://click.palletsprojects.com/) for creating the command line
interface. This allows for some new features like nested commands, setting **0.41.0 (2024-01-02)**
parameters via environment variables, and shell completion. See docs for
details. Backward compatibility should be mostly preserved, except for cases * Honour user's default visibility set in Mastodon preferences instead of always
noted below please report any issues. 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 * BREAKING: Remove deprecated `--disable-https` option for `login` and
`login_cli`, pass the base URL instead `login_cli`, pass the base URL instead
* BREAKING: Options `--debug`, `--color`, `--quiet` must be specified after * BREAKING: Options `--debug` and `--color` must be specified after `toot` but
`toot` but before the command before the command
* Enable passing params via environment variables, see: * BREAKING: Option `--quiet` has been removed. Redirect output instead.
* Add passing parameters via environment variables, see:
https://toot.bezdomni.net/environment_variables.html https://toot.bezdomni.net/environment_variables.html
* Add shell completion, see: https://toot.bezdomni.net/shell_completion.html * Add shell completion, see: https://toot.bezdomni.net/shell_completion.html
* Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature` * 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 * Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists
list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`, list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`,
`lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands. `lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands.
* Add `--json` option to tags commands * Add `--json` option to tags and lists commands
* Add `--json` option to lists commands
* Add `toot --width` option for setting your prefered terminal width * Add `toot --width` option for setting your prefered terminal width
* Add `--media-viewer` and `--colors` options to `toot tui`. These were * Add `--media-viewer` and `--colors` options to `toot tui`. These were
previously accessible only via settings. 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)** **0.39.0 (2023-11-23)**

View File

@ -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: 0.40.0:
date: TBA date: 2023-12-27
description: | description: |
This release includes a major rewrite to use [Click](https://click.palletsprojects.com/) for This release includes a rather extensive change to use the Click library
creating the command line interface. This allows for some new features like nested commands, (https://click.palletsprojects.com/) for creating the command line
setting parameters via environment variables, and shell completion. See docs for details. interface. This allows for some new features like nested commands, setting
Backward compatibility should be mostly preserved, except for cases noted below please report parameters via environment variables, and shell completion. Backward
any issues. compatibility should be mostly preserved, except for cases noted below.
Please report any issues.
changes: changes:
- "BREAKING: Remove deprecated `--disable-https` option for `login` and `login_cli`, pass the base URL instead" - "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" - "BREAKING: Options `--debug` and `--color` must be specified after `toot` but before the command"
- "Enable passing params via environment variables, see: https://toot.bezdomni.net/environment_variables.html" - "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 shell completion, see: https://toot.bezdomni.net/shell_completion.html"
- "Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature` commands" - "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 `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 `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 tags and lists commands"
- "Add `--json` option to lists commands"
- "Add `toot --width` option for setting your prefered terminal width" - "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." - "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: 0.39.0:
date: 2023-11-23 date: 2023-11-23

View File

@ -3,20 +3,42 @@ Changelog
<!-- Do not edit. This file is automatically generated from changelog.yaml.--> <!-- Do not edit. This file is automatically generated from changelog.yaml.-->
**0.40.0 (TBA)** **0.41.1 (2024-01-02)**
This release includes a major rewrite to use * Fix a crash in settings parsing code
[Click](https://click.palletsprojects.com/) for creating the command line
interface. This allows for some new features like nested commands, setting **0.41.0 (2024-01-02)**
parameters via environment variables, and shell completion. See docs for
details. Backward compatibility should be mostly preserved, except for cases * Honour user's default visibility set in Mastodon preferences instead of always
noted below please report any issues. 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 * BREAKING: Remove deprecated `--disable-https` option for `login` and
`login_cli`, pass the base URL instead `login_cli`, pass the base URL instead
* BREAKING: Options `--debug`, `--color`, `--quiet` must be specified after * BREAKING: Options `--debug` and `--color` must be specified after `toot` but
`toot` but before the command before the command
* Enable passing params via environment variables, see: * BREAKING: Option `--quiet` has been removed. Redirect output instead.
* Add passing parameters via environment variables, see:
https://toot.bezdomni.net/environment_variables.html https://toot.bezdomni.net/environment_variables.html
* Add shell completion, see: https://toot.bezdomni.net/shell_completion.html * Add shell completion, see: https://toot.bezdomni.net/shell_completion.html
* Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature` * 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 * Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists
list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`, list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`,
`lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands. `lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands.
* Add `--json` option to tags commands * Add `--json` option to tags and lists commands
* Add `--json` option to lists commands
* Add `toot --width` option for setting your prefered terminal width * Add `toot --width` option for setting your prefered terminal width
* Add `--media-viewer` and `--colors` options to `toot tui`. These were * Add `--media-viewer` and `--colors` options to `toot tui`. These were
previously accessible only via settings. 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)** **0.39.0 (2023-11-23)**

View File

@ -43,6 +43,7 @@ if dist_version != version:
sys.exit(1) sys.exit(1)
release_date = changelog_item["date"] release_date = changelog_item["date"]
description = changelog_item.get("description")
changes = changelog_item["changes"] changes = changelog_item["changes"]
if not isinstance(release_date, date): if not isinstance(release_date, date):
@ -50,6 +51,11 @@ if not isinstance(release_date, date):
sys.exit(1) sys.exit(1)
commit_message = f"toot {version}\n\n" 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: for c in changes:
lines = textwrap.wrap(c, 70) lines = textwrap.wrap(c, 70)
initial = True initial = True

View File

@ -12,7 +12,7 @@ and blocking accounts and other actions.
setup( setup(
name='toot', name='toot',
version='0.40.0', version='0.41.1',
description='Mastodon CLI client', description='Mastodon CLI client',
long_description=long_description.strip(), long_description=long_description.strip(),
author='Ivan Habunek', author='Ivan Habunek',

View File

@ -9,15 +9,14 @@ your test server and database:
``` ```
export TOOT_TEST_BASE_URL="localhost:3000" export TOOT_TEST_BASE_URL="localhost:3000"
export TOOT_TEST_DATABASE_DSN="dbname=mastodon_development"
``` ```
""" """
import json import json
import re
import os import os
import psycopg2
import pytest import pytest
import re
import typing as t
import uuid import uuid
from click.testing import CliRunner, Result from click.testing import CliRunner, Result
@ -31,8 +30,10 @@ def pytest_configure(config):
toot.settings.DISABLE_SETTINGS = True 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 # 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_TEST_BASE_URL = os.getenv("TOOT_TEST_BASE_URL")
# Toot logo used for testing image upload # Toot logo used for testing image upload
@ -52,17 +53,9 @@ def register_account(app: App):
email = f"{username}@example.com" email = f"{username}@example.com"
response = api.register_account(app, username, email, "password", "en") response = api.register_account(app, username, email, "password", "en")
confirm_user(email)
return User(app.instance, username, response["access_token"]) 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 # Fixtures
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------

View File

@ -3,7 +3,7 @@ from unittest import mock
from unittest.mock import MagicMock from unittest.mock import MagicMock
from toot import User, cli from toot import User, cli
from toot.cli import Run from tests.integration.conftest import Run
# TODO: figure out how to test login # TODO: figure out how to test login

View File

@ -1,7 +1,7 @@
import json import json
import time
import pytest import pytest
from tests.utils import run_with_retries
from toot import api, cli from toot import api, cli
from toot.exceptions import NotFoundError from toot.exceptions import NotFoundError
@ -46,11 +46,11 @@ def test_favourite(app, user, run):
assert result.exit_code == 0 assert result.exit_code == 0
assert result.stdout.strip() == "✓ Status unfavourited" assert result.stdout.strip() == "✓ Status unfavourited"
# A short delay is required before the server returns new data def test_favourited():
time.sleep(0.2) nonlocal status
status = api.fetch_status(app, user, status["id"]).json()
status = api.fetch_status(app, user, status["id"]).json() assert not status["favourited"]
assert not status["favourited"] run_with_retries(test_favourited)
def test_favourite_json(app, user, run): def test_favourite_json(app, user, run):

View File

@ -1,7 +1,7 @@
import pytest import pytest
from time import sleep
from uuid import uuid4 from uuid import uuid4
from tests.utils import run_with_retries
from toot import api, cli from toot import api, cli
from toot.entities import from_dict, Status 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") status2 = _post_status(app, other_user, "#bar")
status3 = _post_status(app, friend_user, "#foo #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 # Home timeline
result = run(cli.timelines.timeline) def test_home():
assert result.exit_code == 0 result = run(cli.timelines.timeline)
assert status1.id in result.stdout assert result.exit_code == 0
assert status2.id not in result.stdout assert status1.id in result.stdout
assert status3.id in result.stdout assert status2.id not in result.stdout
assert status3.id in result.stdout
run_with_retries(test_home)
# Public timeline # Public timeline
result = run(cli.timelines.timeline, "--public") result = run(cli.timelines.timeline, "--public")
@ -166,13 +164,14 @@ def test_notifications(app, user, other_user, run):
text = f"Paging doctor @{user.username}" text = f"Paging doctor @{user.username}"
status = _post_status(app, other_user, text) status = _post_status(app, other_user, text)
sleep(0.5) # grr
result = run(cli.timelines.notifications) def test_notifications():
assert result.exit_code == 0 result = run(cli.timelines.notifications)
assert f"@{other_user.username} mentioned you" in result.stdout assert result.exit_code == 0
assert status.id in result.stdout assert f"@{other_user.username} mentioned you" in result.stdout
assert text 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") result = run(cli.timelines.notifications, "--mentions")
assert result.exit_code == 0 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 result.exit_code == 0
assert f"@{user.username} now follows you" in result.stdout assert f"@{user.username} now follows you" in result.stdout
result = run_as(friend_user, cli.timelines.notifications, "--mentions") result = run_as(friend_user, cli.timelines.notifications, "--mentions")
assert result.exit_code == 0 assert result.exit_code == 0
assert "now follows you" not in result.stdout assert "now follows you" not in result.stdout

View File

@ -2,6 +2,9 @@
Helpers for testing. Helpers for testing.
""" """
import time
from typing import Any, Callable
class MockResponse: class MockResponse:
def __init__(self, response_data={}, ok=True, is_redirect=False): def __init__(self, response_data={}, ok=True, is_redirect=False):
@ -19,3 +22,23 @@ class MockResponse:
def retval(val): def retval(val):
return lambda *args, **kwargs: 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()

View File

@ -4,7 +4,7 @@ import sys
from os.path import join, expanduser from os.path import join, expanduser
from typing import NamedTuple from typing import NamedTuple
__version__ = '0.40.0' __version__ = '0.41.1'
class App(NamedTuple): class App(NamedTuple):

View File

@ -183,7 +183,7 @@ def post_status(
app, app,
user, user,
status, status,
visibility='public', visibility=None,
media_ids=None, media_ids=None,
sensitive=False, sensitive=False,
spoiler_text=None, spoiler_text=None,
@ -230,6 +230,52 @@ def post_status(
return http.post(app, user, '/api/v1/statuses', json=data, headers=headers) return http.post(app, user, '/api/v1/statuses', json=data, headers=headers)
def edit_status(
app,
user,
id,
status,
visibility='public',
media_ids=None,
sensitive=False,
spoiler_text=None,
in_reply_to_id=None,
language=None,
content_type=None,
poll_options=None,
poll_expires_in=None,
poll_multiple=None,
poll_hide_totals=None,
) -> Response:
"""
Edit an existing status
https://docs.joinmastodon.org/methods/statuses/#edit
"""
# Strip keys for which value is None
# Sending null values doesn't bother Mastodon, but it breaks Pleroma
data = drop_empty_values({
'status': status,
'media_ids': media_ids,
'visibility': visibility,
'sensitive': sensitive,
'in_reply_to_id': in_reply_to_id,
'language': language,
'content_type': content_type,
'spoiler_text': spoiler_text,
})
if poll_options:
data["poll"] = {
"options": poll_options,
"expires_in": poll_expires_in,
"multiple": poll_multiple,
"hide_totals": poll_hide_totals,
}
return http.put(app, user, f"/api/v1/statuses/{id}", json=data)
def fetch_status(app, user, id): def fetch_status(app, user, id):
""" """
Fetch a single status Fetch a single status
@ -238,6 +284,15 @@ def fetch_status(app, user, id):
return http.get(app, user, f"/api/v1/statuses/{id}") return http.get(app, user, f"/api/v1/statuses/{id}")
def fetch_status_source(app, user, id):
"""
Fetch the source (original text) for a single status.
This only works on local toots.
https://docs.joinmastodon.org/methods/statuses/#source
"""
return http.get(app, user, f"/api/v1/statuses/{id}/source")
def scheduled_statuses(app, user): def scheduled_statuses(app, user):
""" """
List scheduled statuses List scheduled statuses
@ -618,6 +673,10 @@ def get_instance(base_url: str) -> Response:
return http.anon_get(url) return http.anon_get(url)
def get_preferences(app, user) -> Response:
return http.get(app, user, '/api/v1/preferences')
def get_lists(app, user): def get_lists(app, user):
return http.get(app, user, "/api/v1/lists").json() return http.get(app, user, "/api/v1/lists").json()

View File

@ -4,9 +4,12 @@ import os
import sys import sys
import typing as t 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 functools import wraps
from toot import App, User, config, __version__ from toot import App, User, config, __version__
from toot.output import print_warning
from toot.settings import get_settings from toot.settings import get_settings
if t.TYPE_CHECKING: 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\"""" seconds" or any combination of above. Shorthand: "1d", "2h30m", "5m30s\""""
# Type alias for run commands
Run = t.Callable[..., Result]
def get_default_visibility() -> str: def get_default_visibility() -> str:
return os.getenv("TOOT_POST_VISIBILITY", "public") return os.getenv("TOOT_POST_VISIBILITY", "public")
@ -47,6 +46,17 @@ def get_default_map():
settings = get_settings() settings = get_settings()
common = settings.get("common", {}) common = settings.get("common", {})
commands = settings.get("commands", {}) 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} return {**common, **commands}
@ -69,18 +79,44 @@ class Context(t.NamedTuple):
user: t.Optional[User] = None user: t.Optional[User] = None
color: bool = False color: bool = False
debug: bool = False debug: bool = False
quiet: bool = False
class TootObj(t.NamedTuple): class TootObj(t.NamedTuple):
"""Data to add to Click context""" """Data to add to Click context"""
color: bool = True color: bool = True
debug: bool = False debug: bool = False
quiet: bool = False as_user: t.Optional[str] = None
# Pass a context for testing purposes # Pass a context for testing purposes
test_ctx: t.Optional[Context] = None 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]": def pass_context(f: "t.Callable[te.Concatenate[Context, P], R]") -> "t.Callable[P, R]":
"""Pass the toot Context as first argument.""" """Pass the toot Context as first argument."""
@wraps(f) @wraps(f)
@ -98,11 +134,16 @@ def get_context() -> Context:
if obj.test_ctx: if obj.test_ctx:
return obj.test_ctx return obj.test_ctx
user, app = config.get_active_user_app() if obj.as_user:
if not user or not app: user, app = config.get_user_app(obj.as_user)
raise click.ClickException("This command requires you to be logged in.") 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( 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("-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("--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("--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.version_option(__version__, message="%(prog)s v%(version)s")
@click.pass_context @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""" """Toot is a Mastodon CLI"""
ctx.obj = TootObj(color, debug, quiet) ctx.obj = TootObj(color, debug, as_user)
ctx.color = color ctx.color = color
ctx.max_content_width = max_width ctx.max_content_width = max_width

View File

@ -2,13 +2,10 @@ import click
import platform import platform
import sys import sys
import webbrowser import webbrowser
from click.shell_completion import CompletionItem
from click.types import StringParamType
from toot import api, config, __version__ from toot import api, config, __version__
from toot.auth import get_or_create_app, login_auth_code, login_username_password 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 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() @cli.command()
def auth(): def auth():
"""Show logged in accounts and instances""" """Show logged in accounts and instances"""

View File

@ -11,7 +11,8 @@ from toot.output import print_list_accounts, print_lists, print_warning
def lists(ctx: click.Context): def lists(ctx: click.Context):
"""Display and manage lists""" """Display and manage lists"""
if ctx.invoked_subcommand is None: 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() user, app = config.get_active_user_app()
if not user or not app: if not user or not app:

View File

@ -6,8 +6,8 @@ from datetime import datetime, timedelta, timezone
from time import sleep, time from time import sleep, time
from typing import BinaryIO, Optional, Tuple from typing import BinaryIO, Optional, Tuple
from toot import api from toot import api, config
from toot.cli import cli, json_option, pass_context, Context from toot.cli import AccountParamType, cli, json_option, pass_context, Context
from toot.cli import DURATION_EXAMPLES, VISIBILITY_CHOICES from toot.cli import DURATION_EXAMPLES, VISIBILITY_CHOICES
from toot.cli.validators import validate_duration, validate_language from toot.cli.validators import validate_duration, validate_language
from toot.entities import MediaAttachment, from_dict from toot.entities import MediaAttachment, from_dict
@ -40,7 +40,6 @@ from toot.utils.datetime import parse_datetime
"--visibility", "-v", "--visibility", "-v",
help="Post visibility", help="Post visibility",
type=click.Choice(VISIBILITY_CHOICES), type=click.Choice(VISIBILITY_CHOICES),
default="public",
) )
@click.option( @click.option(
"--sensitive", "-s", "--sensitive", "-s",
@ -106,6 +105,11 @@ from toot.utils.datetime import parse_datetime
is_flag=True, is_flag=True,
default=False, default=False,
) )
@click.option(
"-u", "--using",
type=AccountParamType(),
help="The account to use, overrides the active account.",
)
@json_option @json_option
@pass_context @pass_context
def post( def post(
@ -114,7 +118,7 @@ def post(
media: Tuple[str], media: Tuple[str],
descriptions: Tuple[str], descriptions: Tuple[str],
thumbnails: Tuple[str], thumbnails: Tuple[str],
visibility: str, visibility: Optional[str],
sensitive: bool, sensitive: bool,
spoiler_text: Optional[str], spoiler_text: Optional[str],
reply_to: Optional[str], reply_to: Optional[str],
@ -127,12 +131,20 @@ def post(
poll_expires_in: int, poll_expires_in: int,
poll_multiple: bool, poll_multiple: bool,
poll_hide_totals: bool, poll_hide_totals: bool,
json: bool json: bool,
using: str
): ):
"""Post a new status""" """Post a new status"""
if len(media) > 4: if len(media) > 4:
raise click.ClickException("Cannot attach more than 4 files.") 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) media_ids = _upload_media(ctx.app, ctx.user, media, descriptions, thumbnails)
status_text = _get_status_text(text, editor, media) status_text = _get_status_text(text, editor, media)
scheduled_at = _get_scheduled_at(scheduled_at, scheduled_in) 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.") raise click.ClickException("You must specify either text or media to post.")
response = api.post_status( response = api.post_status(
ctx.app, app,
ctx.user, user,
status_text, status_text,
visibility=visibility, visibility=visibility,
media_ids=media_ids, media_ids=media_ids,

View File

@ -9,7 +9,7 @@ from toot.cli.validators import validate_instance
from toot.entities import Instance, Status, from_dict, Account from toot.entities import Instance, Status, from_dict, Account
from toot.exceptions import ApiError, ConsoleError from toot.exceptions import ApiError, ConsoleError
from toot.output import print_account, print_instance, print_search_results, print_status, print_timeline 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() @cli.command()
@ -43,7 +43,7 @@ def whois(ctx: Context, account: str, json: bool):
@cli.command() @cli.command()
@click.argument("instance", callback=validate_instance, required=False) @click.argument("instance", type=InstanceParamType(), callback=validate_instance, required=False)
@json_option @json_option
def instance(instance: Optional[str], json: bool): def instance(instance: Optional[str], json: bool):
"""Display instance details """Display instance details

View File

@ -2,7 +2,7 @@ import sys
import click import click
from toot import api 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 typing import Optional
from toot.cli.validators import validate_instance from toot.cli.validators import validate_instance
@ -13,6 +13,7 @@ from toot.output import print_notifications, print_timeline
@cli.command() @cli.command()
@click.option( @click.option(
"--instance", "-i", "--instance", "-i",
type=InstanceParamType(),
callback=validate_instance, callback=validate_instance,
help="""Domain or base URL of the instance from which to read, help="""Domain or base URL of the instance from which to read,
e.g. 'mastodon.social' or 'https://mastodon.social'""", e.g. 'mastodon.social' or 'https://mastodon.social'""",

View File

@ -1,7 +1,7 @@
import click import click
from typing import Optional 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.cli.validators import validate_tui_colors, validate_cache_size
from toot.tui.app import TUI, TuiOptions 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. help="""Specify the image cache maximum size in megabytes. Default: 10MB.
Minimum: 1MB.""" 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 @pass_context
def tui( def tui(
ctx: Context, ctx: Context,
colors: Optional[int], colors: Optional[int],
media_viewer: Optional[str], media_viewer: Optional[str],
always_show_sensitive: bool,
relative_datetimes: bool, relative_datetimes: bool,
cache_size: Optional[int], cache_size: Optional[int],
default_visibility: Optional[str]
): ):
"""Launches the toot terminal user interface""" """Launches the toot terminal user interface"""
if colors is None: if colors is None:
@ -47,6 +59,8 @@ def tui(
media_viewer=media_viewer, media_viewer=media_viewer,
relative_datetimes=relative_datetimes, relative_datetimes=relative_datetimes,
cache_size=cache_size, cache_size=cache_size,
default_visibility=default_visibility,
always_show_sensitive=always_show_sensitive,
) )
tui = TUI.create(ctx.app, ctx.user, options) tui = TUI.create(ctx.app, ctx.user, options)
tui.run() tui.run()

View File

@ -38,7 +38,7 @@ def _get_error_message(response):
except Exception: except Exception:
pass pass
return "Unknown error" return f"Unknown error: {response.status_code} {response.reason}"
def process_response(response): def process_response(response):
@ -81,6 +81,22 @@ def post(app, user, path, headers=None, files=None, data=None, json=None, allow_
return anon_post(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects) return anon_post(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects)
def anon_put(url, headers=None, files=None, data=None, json=None, allow_redirects=True):
request = Request(method="PUT", url=url, headers=headers, files=files, data=data, json=json)
response = send_request(request, allow_redirects)
return process_response(response)
def put(app, user, path, headers=None, files=None, data=None, json=None, allow_redirects=True):
url = app.base_url + path
headers = headers or {}
headers["Authorization"] = f"Bearer {user.access_token}"
return anon_put(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects)
def patch(app, user, path, headers=None, files=None, data=None, json=None): def patch(app, user, path, headers=None, files=None, data=None, json=None):
url = app.base_url + path url = app.base_url + path

View File

@ -274,8 +274,9 @@ def print_notification(notification: Notification):
def print_notifications(notifications: List[Notification]): def print_notifications(notifications: List[Notification]):
for notification in notifications: for notification in notifications:
print_divider() if notification.type not in ['pleroma:emoji_reaction']:
print_notification(notification) print_divider()
print_notification(notification)
print_divider() print_divider()

View File

@ -6,11 +6,13 @@ import warnings
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import NamedTuple, Optional from typing import NamedTuple, Optional
from datetime import datetime, timezone
from toot import api, config, __version__, settings from toot import api, config, __version__, settings
from toot import App, User from toot import App, User
from toot.cli import get_default_visibility from toot.cli import get_default_visibility
from toot.exceptions import ApiError from toot.exceptions import ApiError
from toot.utils.datetime import parse_datetime
from .compose import StatusComposer from .compose import StatusComposer
from .constants import PALETTE from .constants import PALETTE
@ -22,7 +24,8 @@ from .poll import Poll
from .timeline import Timeline from .timeline import Timeline
from .utils import get_max_toot_chars, parse_content_links, copy_to_clipboard, ImageCache from .utils import get_max_toot_chars, parse_content_links, copy_to_clipboard, ImageCache
from PIL import Image from PIL import Image
from .widgets import ModalBox
>>>>>>> master
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -35,9 +38,10 @@ DEFAULT_MAX_TOOT_CHARS = 500
class TuiOptions(NamedTuple): class TuiOptions(NamedTuple):
colors: int colors: int
media_viewer: Optional[str] media_viewer: Optional[str]
always_show_sensitive: bool
relative_datetimes: bool relative_datetimes: bool
cache_size: int cache_size: int
default_visibility: Optional[bool]
class Header(urwid.WidgetWrap): class Header(urwid.WidgetWrap):
def __init__(self, app, user): def __init__(self, app, user):
@ -143,6 +147,7 @@ class TUI(urwid.Frame):
self.can_translate = False self.can_translate = False
self.account = None self.account = None
self.followed_accounts = [] self.followed_accounts = []
self.preferences = {}
if self.options.cache_size: if self.options.cache_size:
self.cache_max = 1024 * 1024 * self.options.cache_size self.cache_max = 1024 * 1024 * self.options.cache_size
@ -153,6 +158,7 @@ class TUI(urwid.Frame):
def run(self): 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_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( self.loop.set_alarm_in(0, lambda *args: self.async_load_timeline(
is_initial=True, timeline_name="home")) is_initial=True, timeline_name="home"))
self.loop.set_alarm_in(0, lambda *args: self.async_load_followed_accounts()) 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) 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 async_load_followed_accounts(self):
def _load_accounts(): def _load_accounts():
try: try:
@ -411,11 +430,45 @@ class TUI(urwid.Frame):
def _post(timeline, *args): def _post(timeline, *args):
self.post_status(*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, "close", _close)
urwid.connect_signal(composer, "post", _post) urwid.connect_signal(composer, "post", _post)
self.open_overlay(composer, title="Compose status") self.open_overlay(composer, title="Compose status")
def async_edit(self, status):
def _fetch_source():
return api.fetch_status_source(self.app, self.user, status.id).json()
def _done(source):
self.close_overlay()
self.show_edit(status, source)
please_wait = ModalBox("Loading status...")
self.open_overlay(please_wait)
self.run_in_thread(_fetch_source, done_callback=_done)
def show_edit(self, status, source):
def _close(*args):
self.close_overlay()
def _edit(timeline, *args):
self.edit_status(status, *args)
composer = StatusComposer(self.max_toot_chars, self.user.username,
visibility=None, edit=status, source=source)
urwid.connect_signal(composer, "close", _close)
urwid.connect_signal(composer, "post", _edit)
self.open_overlay(composer, title="Edit status")
def show_goto_menu(self): def show_goto_menu(self):
user_timelines = self.config.get("timelines", {}) user_timelines = self.config.get("timelines", {})
user_lists = api.get_lists(self.app, self.user) or [] user_lists = api.get_lists(self.app, self.user) or []
@ -563,6 +616,42 @@ class TUI(urwid.Frame):
self.footer.set_message("Status posted {} \\o/".format(status.id)) self.footer.set_message("Status posted {} \\o/".format(status.id))
self.close_overlay() self.close_overlay()
def edit_status(self, status, content, warning, visibility, in_reply_to_id):
# We don't support editing polls (yet), so to avoid losing the poll
# data from the original toot, copy it to the edit request.
poll_args = {}
poll = status.original.data.get('poll', None)
if poll is not None:
poll_args['poll_options'] = [o['title'] for o in poll['options']]
poll_args['poll_multiple'] = poll['multiple']
# Convert absolute expiry time into seconds from now.
expires_at = parse_datetime(poll['expires_at'])
expires_in = int((expires_at - datetime.now(timezone.utc)).total_seconds())
poll_args['poll_expires_in'] = expires_in
if 'hide_totals' in poll:
poll_args['poll_hide_totals'] = poll['hide_totals']
data = api.edit_status(
self.app,
self.user,
status.id,
content,
spoiler_text=warning,
visibility=visibility,
**poll_args
).json()
new_status = self.make_status(data)
self.footer.set_message("Status edited {} \\o/".format(status.id))
self.close_overlay()
if self.timeline is not None:
self.timeline.update_status(new_status)
def show_account(self, account_id): def show_account(self, account_id):
account = api.whois(self.app, self.user, account_id) account = api.whois(self.app, self.user, account_id)
relationship = api.get_relationship(self.app, self.user, account_id) relationship = api.get_relationship(self.app, self.user, account_id)

View File

@ -1,8 +1,6 @@
import urwid import urwid
import logging import logging
from toot.cli import get_default_visibility
from .constants import VISIBILITY_OPTIONS from .constants import VISIBILITY_OPTIONS
from .widgets import Button, EditBox from .widgets import Button, EditBox
@ -11,21 +9,22 @@ logger = logging.getLogger(__name__)
class StatusComposer(urwid.Frame): class StatusComposer(urwid.Frame):
""" """
UI for compose and posting a status message. UI for composing or editing a status message.
To edit a status, provide the original status in 'edit', and optionally
provide the status source (from the /status/:id/source API endpoint) in
'source'; this should have at least a 'text' member, and optionally
'spoiler_text'. If source is not provided, the formatted HTML will be
presented to the user for editing.
""" """
signals = ["close", "post"] signals = ["close", "post"]
def __init__(self, max_chars, username, in_reply_to=None): def __init__(self, max_chars, username, visibility, in_reply_to=None,
edit=None, source=None):
self.in_reply_to = in_reply_to self.in_reply_to = in_reply_to
self.max_chars = max_chars self.max_chars = max_chars
self.username = username self.username = username
self.edit = edit
text = self.get_initial_text(in_reply_to)
self.content_edit = EditBox(
edit_text=text, edit_pos=len(text), multiline=True, allow_tab=True)
urwid.connect_signal(self.content_edit.edit, "change", self.text_changed)
self.char_count = urwid.Text(["0/{}".format(max_chars)])
self.cw_edit = None self.cw_edit = None
self.cw_add_button = Button("Add content warning", self.cw_add_button = Button("Add content warning",
@ -33,13 +32,34 @@ class StatusComposer(urwid.Frame):
self.cw_remove_button = Button("Remove content warning", self.cw_remove_button = Button("Remove content warning",
on_press=self.remove_content_warning) on_press=self.remove_content_warning)
self.visibility = ( if edit:
in_reply_to.visibility if in_reply_to else get_default_visibility() if source is None:
) text = edit.data["content"]
else:
text = source.get("text", edit.data["content"])
if 'spoiler_text' in source:
self.cw_edit = EditBox(multiline=True, allow_tab=True,
edit_text=source['spoiler_text'])
self.visibility = edit.data["visibility"]
else: # not edit
text = self.get_initial_text(in_reply_to)
self.visibility = (
in_reply_to.visibility if in_reply_to else visibility
)
self.content_edit = EditBox(
edit_text=text, edit_pos=len(text), multiline=True, allow_tab=True)
urwid.connect_signal(self.content_edit.edit, "change", self.text_changed)
self.char_count = urwid.Text(["0/{}".format(max_chars)])
self.visibility_button = Button("Visibility: {}".format(self.visibility), self.visibility_button = Button("Visibility: {}".format(self.visibility),
on_press=self.choose_visibility) on_press=self.choose_visibility)
self.post_button = Button("Post", on_press=self.post) self.post_button = Button("Edit" if edit else "Post", on_press=self.post)
self.cancel_button = Button("Cancel", on_press=self.close) self.cancel_button = Button("Cancel", on_press=self.close)
contents = list(self.generate_list_items()) contents = list(self.generate_list_items())

View File

@ -53,6 +53,10 @@ class Status:
self.id = self.data["id"] self.id = self.data["id"]
self.account = self._get_account() self.account = self._get_account()
self.created_at = parse_datetime(data["created_at"]) 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.author = self._get_author()
self.favourited = data.get("favourited", False) self.favourited = data.get("favourited", False)
self.reblogged = data.get("reblogged", False) self.reblogged = data.get("reblogged", False)

View File

@ -231,6 +231,7 @@ class Help(urwid.Padding):
yield urwid.Text(h(" [N] - Translate status if possible (toggle)")) yield urwid.Text(h(" [N] - Translate status if possible (toggle)"))
yield urwid.Text(h(" [R] - Reply to current status")) yield urwid.Text(h(" [R] - Reply to current status"))
yield urwid.Text(h(" [S] - Show text marked as sensitive")) 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(" [T] - Show status thread (replies)"))
yield urwid.Text(h(" [L] - Show the status links")) 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")) yield urwid.Text(h(" [U] - Show the status data in JSON as received from the server"))

View File

@ -109,6 +109,7 @@ class Timeline(urwid.Columns):
"[A]ccount" if not status.is_mine else "", "[A]ccount" if not status.is_mine else "",
"[B]oost", "[B]oost",
"[D]elete" if status.is_mine else "", "[D]elete" if status.is_mine else "",
"[E]dit" if status.is_mine else "",
"B[o]okmark", "B[o]okmark",
"[F]avourite", "[F]avourite",
"[V]iew", "[V]iew",
@ -198,6 +199,11 @@ class Timeline(urwid.Columns):
self.tui.show_delete_confirmation(status) self.tui.show_delete_confirmation(status)
return return
if key in ("e", "E"):
if status.is_mine:
self.tui.async_edit(status)
return
if key in ("f", "F"): if key in ("f", "F"):
self.tui.async_toggle_favourite(self, status) self.tui.async_toggle_favourite(self, status)
return return
@ -349,6 +355,7 @@ class StatusDetails(urwid.Pile):
if self.status: if self.status:
self.status.placeholders = [] self.status.placeholders = []
self.followed_accounts = timeline.tui.followed_accounts self.followed_accounts = timeline.tui.followed_accounts
self.options = timeline.tui.options
reblogged_by = status.author if status and status.reblog else None reblogged_by = status.author if status and status.reblog else None
widget_list = list(self.content_generator(status.original, reblogged_by) widget_list = list(self.content_generator(status.original, reblogged_by)
@ -447,9 +454,12 @@ class StatusDetails(urwid.Pile):
yield ("pack", urwid.Divider()) yield ("pack", urwid.Divider())
# Show content warning # 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."))) yield ("pack", urwid.Text(("content_warning", "Marked as sensitive. Press S to view.")))
else: 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"] content = status.original.translation if status.original.show_translation else status.data["content"]
widgetlist = html_to_widgets(content) widgetlist = html_to_widgets(content)
@ -516,6 +526,8 @@ class StatusDetails(urwid.Pile):
yield ("pack", urwid.Text([ yield ("pack", urwid.Text([
("status_detail_timestamp", f"{status.created_at.strftime('%Y-%m-%d %H:%M')} "), ("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 "), ("status_detail_bookmarked" if status.bookmarked else "dim", "b "),
("dim", f"{status.data['replies_count']} "), ("dim", f"{status.data['replies_count']} "),
("highlight" if status.reblogged else "dim", f"{status.data['reblogs_count']} "), ("highlight" if status.reblogged else "dim", f"{status.data['reblogs_count']} "),
@ -579,7 +591,7 @@ class StatusDetails(urwid.Pile):
class StatusListItem(SelectableColumns): class StatusListItem(SelectableColumns):
def __init__(self, status, relative_datetimes): 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 # TODO: hacky implementation to avoid creating conflicts for existing
# pull reuqests, refactor when merged. # pull reuqests, refactor when merged.
@ -593,7 +605,7 @@ class StatusListItem(SelectableColumns):
favourited = ("highlight", "") if status.original.favourited else " " favourited = ("highlight", "") if status.original.favourited else " "
reblogged = ("highlight", "") if status.original.reblogged else " " reblogged = ("highlight", "") if status.original.reblogged else " "
is_reblog = ("dim", "") if status.reblog 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__([ return super().__init__([
("pack", SelectableText(("status_list_timestamp", created_at), wrap="clip")), ("pack", SelectableText(("status_list_timestamp", created_at), wrap="clip")),

View File

@ -159,3 +159,10 @@ class EmojiText(urwid.Padding):
columns.append(("weight", 9999, urwid.Text(""))) columns.append(("weight", 9999, urwid.Text("")))
column_widget = urwid.Columns(columns, dividechars=1, min_width=2) column_widget = urwid.Columns(columns, dividechars=1, min_width=2)
super().__init__(column_widget) 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)