diff --git a/toot/api.py b/toot/api.py index 7022823..564746f 100644 --- a/toot/api.py +++ b/toot/api.py @@ -1,4 +1,5 @@ import re +from typing import List import uuid from urllib.parse import urlparse, urlencode, quote @@ -362,6 +363,12 @@ def whois(app, user, account): return http.get(app, user, f'/api/v1/accounts/{account}').json() +def vote(app, user, poll_id, choices: List[int]): + url = f"/api/v1/polls/{poll_id}/votes" + json = {'choices': choices} + return http.post(app, user, url, json=json).json() + + def mute(app, user, account): return _account_action(app, user, account, 'mute') diff --git a/toot/tui/app.py b/toot/tui/app.py index fbbf72a..64c50c7 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -12,6 +12,7 @@ from .constants import PALETTE from .entities import Status from .overlays import ExceptionStackTrace, GotoMenu, Help, StatusSource, StatusLinks, StatusZoom from .overlays import StatusDeleteConfirmation, Account +from .poll import Poll from .timeline import Timeline from .utils import parse_content_links, show_media @@ -155,7 +156,7 @@ class TUI(urwid.Frame): def _default_error_callback(ex): self.exception = ex - self.footer.set_error_message("An exception occurred, press E to view") + self.footer.set_error_message("An exception occurred, press X to view") _error_callback = error_callback or _default_error_callback @@ -200,6 +201,9 @@ class TUI(urwid.Frame): def _menu(timeline, status): self.show_context_menu(status) + def _poll(timeline, status): + self.show_poll(status) + def _zoom(timeline, status_details): self.show_status_zoom(status_details) @@ -214,6 +218,7 @@ class TUI(urwid.Frame): urwid.connect_signal(timeline, "focus", self.refresh_footer) urwid.connect_signal(timeline, "media", _media) urwid.connect_signal(timeline, "menu", _menu) + urwid.connect_signal(timeline, "poll", _poll) urwid.connect_signal(timeline, "reblog", self.async_toggle_reblog) urwid.connect_signal(timeline, "reply", _reply) urwid.connect_signal(timeline, "source", _source) @@ -445,6 +450,12 @@ class TUI(urwid.Frame): def show_help(self): self.open_overlay(Help(), title="Help") + def show_poll(self, status): + self.open_overlay( + widget=Poll(self.app, self.user, status), + title="Poll", + ) + def goto_home_timeline(self): self.timeline_generator = api.home_timeline_generator( self.app, self.user, limit=40) @@ -651,12 +662,14 @@ class TUI(urwid.Frame): def close_overlay(self): self.body = self.overlay.bottom_w self.overlay = None + if self.timeline: + self.timeline.refresh_status_details() # --- Keys ----------------------------------------------------------------- def unhandled_input(self, key): # TODO: this should not be in unhandled input - if key in ('e', 'E'): + if key in ('x', 'X'): if self.exception: self.show_exception(self.exception) diff --git a/toot/tui/overlays.py b/toot/tui/overlays.py index 96680aa..de9dbe6 100644 --- a/toot/tui/overlays.py +++ b/toot/tui/overlays.py @@ -211,7 +211,7 @@ class Account(urwid.ListBox): super().__init__(walker) def generate_contents(self, account): - yield urwid.Text([('green', f"@{account['acct']}"), (f" {account['display_name']}")]) + yield urwid.Text([('green', f"@{account['acct']}"), f" {account['display_name']}"]) if account["note"]: yield urwid.Divider() @@ -219,8 +219,8 @@ class Account(urwid.ListBox): yield urwid.Text(highlight_hashtags(line, followed_tags=set())) yield urwid.Divider() - yield urwid.Text([("ID: "), ("green", f"{account['id']}")]) - yield urwid.Text([("Since: "), ("green", f"{account['created_at'][:10]}")]) + yield urwid.Text(["ID: ", ("green", f"{account['id']}")]) + yield urwid.Text(["Since: ", ("green", f"{account['created_at'][:10]}")]) yield urwid.Divider() if account["bot"]: @@ -233,15 +233,15 @@ class Account(urwid.ListBox): yield urwid.Text([("warning", "Suspended \N{cross mark}")]) yield urwid.Divider() - yield urwid.Text([("Followers: "), ("yellow", f"{account['followers_count']}")]) - yield urwid.Text([("Following: "), ("yellow", f"{account['following_count']}")]) - yield urwid.Text([("Statuses: "), ("yellow", f"{account['statuses_count']}")]) + yield urwid.Text(["Followers: ", ("yellow", f"{account['followers_count']}")]) + yield urwid.Text(["Following: ", ("yellow", f"{account['following_count']}")]) + yield urwid.Text(["Statuses: ", ("yellow", f"{account['statuses_count']}")]) if account["fields"]: for field in account["fields"]: name = field["name"].title() yield urwid.Divider() - yield urwid.Text([("yellow", f"{name.rstrip(':')}"), (":")]) + yield urwid.Text([("yellow", f"{name.rstrip(':')}"), ":"]) for line in format_content(field["value"]): yield urwid.Text(highlight_hashtags(line, followed_tags=set())) if field["verified_at"]: diff --git a/toot/tui/poll.py b/toot/tui/poll.py new file mode 100644 index 0000000..81756af --- /dev/null +++ b/toot/tui/poll.py @@ -0,0 +1,102 @@ +import urwid + +from toot import api +from toot.exceptions import ApiError +from toot.utils import format_content +from .utils import highlight_hashtags, parse_datetime +from .widgets import Button, CheckBox, RadioButton + + +class Poll(urwid.ListBox): + """View and vote on a poll""" + + def __init__(self, app, user, status): + self.status = status + self.app = app + self.user = user + self.poll = status.original.data.get("poll") + self.button_group = [] + self.api_exception = None + self.setup_listbox() + + def setup_listbox(self): + actions = list(self.generate_contents(self.status)) + walker = urwid.SimpleListWalker(actions) + super().__init__(walker) + + def build_linebox(self, contents): + contents = urwid.Pile(list(contents)) + contents = urwid.Padding(contents, left=1, right=1) + return urwid.LineBox(contents) + + def vote(self, button_widget): + poll = self.status.original.data.get("poll") + choices = [] + for idx, button in enumerate(self.button_group): + if button.get_state(): + choices.append(idx) + + if len(choices): + try: + response = api.vote(self.app, self.user, poll["id"], choices=choices) + self.status.original.data["poll"] = response + self.api_exception = None + self.poll["voted"] = True + self.poll["own_votes"] = choices + except ApiError as exception: + self.api_exception = exception + finally: + self.setup_listbox() + + def generate_poll_detail(self): + poll = self.poll + + self.button_group = [] # button group + for idx, option in enumerate(poll["options"]): + voted_for = ( + poll["voted"] and poll["own_votes"] and idx in poll["own_votes"] + ) + + if poll["voted"] or poll["expired"]: + prefix = " ✓ " if voted_for else " " + yield urwid.Text(("gray", prefix + f'{option["title"]}')) + else: + if poll["multiple"]: + checkbox = CheckBox(f'{option["title"]}') + self.button_group.append(checkbox) + yield checkbox + else: + yield RadioButton(self.button_group, f'{option["title"]}') + + yield urwid.Divider() + + poll_detail = "Poll · {} votes".format(poll["votes_count"]) + + if poll["expired"]: + poll_detail += " · Closed" + + if poll["expires_at"]: + expires_at = parse_datetime(poll["expires_at"]).strftime( + "%Y-%m-%d %H:%M" + ) + poll_detail += " · Closes on {}".format(expires_at) + + yield urwid.Text(("gray", poll_detail)) + + def generate_contents(self, status): + yield urwid.Divider() + for line in format_content(status.data["content"]): + yield urwid.Text(highlight_hashtags(line, set())) + + yield urwid.Divider() + yield self.build_linebox(self.generate_poll_detail()) + yield urwid.Divider() + + if self.poll["voted"]: + yield urwid.Text(("grey", "< Already Voted >")) + elif not self.poll["expired"]: + yield Button("Vote", on_press=self.vote) + + if self.api_exception: + yield urwid.Divider() + yield urwid.Text("warning", str(self.api_exception)) diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index cd12f6a..695662d 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -31,6 +31,7 @@ class Timeline(urwid.Columns): "media", # Display media attachments "menu", # Show a context menu "next", # Fetch more statuses + "poll", # Vote in a poll "reblog", # Reblog status "reply", # Compose a reply to a status "source", # Show status source @@ -65,11 +66,12 @@ class Timeline(urwid.Columns): ]) def wrap_status_details(self, status_details: "StatusDetails") -> urwid.Widget: - """Wrap StatusDetails widget with a scollbar and footer.""" + """Wrap StatusDetails widget with a scrollbar and footer.""" + self.status_detail_scrollable = Scrollable(urwid.Padding(status_details, right=1)) return urwid.Padding( urwid.Frame( body=ScrollBar( - Scrollable(urwid.Padding(status_details, right=1)), + self.status_detail_scrollable, thumb_char="\u2588", trough_char="\u2591", ), @@ -102,6 +104,8 @@ class Timeline(urwid.Columns): if not status: return None + poll = status.original.data.get("poll") + options = [ "[A]ccount" if not status.is_mine else "", "[B]oost", @@ -112,6 +116,7 @@ class Timeline(urwid.Columns): "[T]hread" if not self.is_thread else "", "[L]inks", "[R]eply", + "[P]oll" if poll and not poll["expired"] else "", "So[u]rce", "[Z]oom", "Tra[n]slate" if self.can_translate else "", @@ -148,7 +153,9 @@ class Timeline(urwid.Columns): def refresh_status_details(self): """Redraws the details of the focused status.""" status = self.get_focused_status() + pos = self.status_detail_scrollable.get_scrollpos() self.draw_status_details(status) + self.status_detail_scrollable.set_scrollpos(pos) def draw_status_details(self, status): self.status_details = StatusDetails(self, status) @@ -240,7 +247,7 @@ class Timeline(urwid.Columns): self._emit("clear-screen") return - if key in ("p", "P"): + if key in ("e", "E"): self._emit("save", status) return @@ -248,6 +255,12 @@ class Timeline(urwid.Columns): self._emit("zoom", self.status_details) return + if key in ("p", "P"): + poll = status.original.data.get("poll") + if poll and not poll["expired"]: + self._emit("poll", status) + return + return super().keypress(size, key) def append_status(self, status): @@ -340,7 +353,7 @@ class StatusDetails(urwid.Pile): yield ("pack", urwid.Text(m["description"])) yield ("pack", urwid.Text(("link", m["url"]))) - poll = status.data.get("poll") + poll = status.original.data.get("poll") if poll: yield ("pack", urwid.Divider()) yield ("pack", self.build_linebox(self.poll_generator(poll))) diff --git a/toot/tui/utils.py b/toot/tui/utils.py index e2855c4..cfc0778 100644 --- a/toot/tui/utils.py +++ b/toot/tui/utils.py @@ -37,19 +37,19 @@ def time_ago(value: datetime) -> datetime: now = datetime.now().astimezone() delta = now.timestamp() - value.timestamp() - if (delta < 1): + if delta < 1: return "now" - if (delta < 8 * DAY): - if (delta < MINUTE): + if delta < 8 * DAY: + if delta < MINUTE: return f"{math.floor(delta / SECOND)}".rjust(2, " ") + "s" - if (delta < HOUR): + if delta < HOUR: return f"{math.floor(delta / MINUTE)}".rjust(2, " ") + "m" - if (delta < DAY): + if delta < DAY: return f"{math.floor(delta / HOUR)}".rjust(2, " ") + "h" return f"{math.floor(delta / DAY)}".rjust(2, " ") + "d" - if (delta < 53 * WEEK): # not exactly correct but good enough as a boundary + if delta < 53 * WEEK: # not exactly correct but good enough as a boundary return f"{math.floor(delta / WEEK)}".rjust(2, " ") + "w" return ">1y" diff --git a/toot/tui/widgets.py b/toot/tui/widgets.py index a311d52..6f46fb3 100644 --- a/toot/tui/widgets.py +++ b/toot/tui/widgets.py @@ -46,3 +46,23 @@ class Button(urwid.AttrWrap): def set_label(self, *args, **kwargs): self.original_widget.original_widget.set_label(*args, **kwargs) self.original_widget.width = len(args[0]) + 4 + + +class CheckBox(urwid.AttrWrap): + """Styled checkbox.""" + def __init__(self, *args, **kwargs): + self.button = urwid.CheckBox(*args, **kwargs) + padding = urwid.Padding(self.button, width=len(args[0]) + 4) + return super().__init__(padding, "button", "button_focused") + + def get_state(self): + """Return the state of the checkbox.""" + return self.button._state + + +class RadioButton(urwid.AttrWrap): + """Styled radiobutton.""" + def __init__(self, *args, **kwargs): + button = urwid.RadioButton(*args, **kwargs) + padding = urwid.Padding(button, width=len(args[1]) + 4) + return super().__init__(padding, "button", "button_focused")