1
0
mirror of https://github.com/ihabunek/toot.git synced 2024-06-30 06:35:24 +00:00

Merge pull request #312 from danschwarz/poll3

UI to vote in polls
This commit is contained in:
Ivan Habunek 2023-02-20 09:06:51 +01:00 committed by GitHub
commit a633f757b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 174 additions and 19 deletions

View File

@ -1,4 +1,5 @@
import re import re
from typing import List
import uuid import uuid
from urllib.parse import urlparse, urlencode, quote 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() 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): def mute(app, user, account):
return _account_action(app, user, account, 'mute') return _account_action(app, user, account, 'mute')

View File

@ -12,6 +12,7 @@ from .constants import PALETTE
from .entities import Status from .entities import Status
from .overlays import ExceptionStackTrace, GotoMenu, Help, StatusSource, StatusLinks, StatusZoom from .overlays import ExceptionStackTrace, GotoMenu, Help, StatusSource, StatusLinks, StatusZoom
from .overlays import StatusDeleteConfirmation, Account from .overlays import StatusDeleteConfirmation, Account
from .poll import Poll
from .timeline import Timeline from .timeline import Timeline
from .utils import parse_content_links, show_media from .utils import parse_content_links, show_media
@ -155,7 +156,7 @@ class TUI(urwid.Frame):
def _default_error_callback(ex): def _default_error_callback(ex):
self.exception = 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 _error_callback = error_callback or _default_error_callback
@ -200,6 +201,9 @@ class TUI(urwid.Frame):
def _menu(timeline, status): def _menu(timeline, status):
self.show_context_menu(status) self.show_context_menu(status)
def _poll(timeline, status):
self.show_poll(status)
def _zoom(timeline, status_details): def _zoom(timeline, status_details):
self.show_status_zoom(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, "focus", self.refresh_footer)
urwid.connect_signal(timeline, "media", _media) urwid.connect_signal(timeline, "media", _media)
urwid.connect_signal(timeline, "menu", _menu) 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, "reblog", self.async_toggle_reblog)
urwid.connect_signal(timeline, "reply", _reply) urwid.connect_signal(timeline, "reply", _reply)
urwid.connect_signal(timeline, "source", _source) urwid.connect_signal(timeline, "source", _source)
@ -445,6 +450,12 @@ class TUI(urwid.Frame):
def show_help(self): def show_help(self):
self.open_overlay(Help(), title="Help") 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): def goto_home_timeline(self):
self.timeline_generator = api.home_timeline_generator( self.timeline_generator = api.home_timeline_generator(
self.app, self.user, limit=40) self.app, self.user, limit=40)
@ -651,12 +662,14 @@ class TUI(urwid.Frame):
def close_overlay(self): def close_overlay(self):
self.body = self.overlay.bottom_w self.body = self.overlay.bottom_w
self.overlay = None self.overlay = None
if self.timeline:
self.timeline.refresh_status_details()
# --- Keys ----------------------------------------------------------------- # --- Keys -----------------------------------------------------------------
def unhandled_input(self, key): def unhandled_input(self, key):
# TODO: this should not be in unhandled input # TODO: this should not be in unhandled input
if key in ('e', 'E'): if key in ('x', 'X'):
if self.exception: if self.exception:
self.show_exception(self.exception) self.show_exception(self.exception)

View File

@ -211,7 +211,7 @@ class Account(urwid.ListBox):
super().__init__(walker) super().__init__(walker)
def generate_contents(self, account): 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"]: if account["note"]:
yield urwid.Divider() yield urwid.Divider()
@ -219,8 +219,8 @@ class Account(urwid.ListBox):
yield urwid.Text(highlight_hashtags(line, followed_tags=set())) yield urwid.Text(highlight_hashtags(line, followed_tags=set()))
yield urwid.Divider() yield urwid.Divider()
yield urwid.Text([("ID: "), ("green", f"{account['id']}")]) yield urwid.Text(["ID: ", ("green", f"{account['id']}")])
yield urwid.Text([("Since: "), ("green", f"{account['created_at'][:10]}")]) yield urwid.Text(["Since: ", ("green", f"{account['created_at'][:10]}")])
yield urwid.Divider() yield urwid.Divider()
if account["bot"]: if account["bot"]:
@ -233,15 +233,15 @@ class Account(urwid.ListBox):
yield urwid.Text([("warning", "Suspended \N{cross mark}")]) yield urwid.Text([("warning", "Suspended \N{cross mark}")])
yield urwid.Divider() yield urwid.Divider()
yield urwid.Text([("Followers: "), ("yellow", f"{account['followers_count']}")]) yield urwid.Text(["Followers: ", ("yellow", f"{account['followers_count']}")])
yield urwid.Text([("Following: "), ("yellow", f"{account['following_count']}")]) yield urwid.Text(["Following: ", ("yellow", f"{account['following_count']}")])
yield urwid.Text([("Statuses: "), ("yellow", f"{account['statuses_count']}")]) yield urwid.Text(["Statuses: ", ("yellow", f"{account['statuses_count']}")])
if account["fields"]: if account["fields"]:
for field in account["fields"]: for field in account["fields"]:
name = field["name"].title() name = field["name"].title()
yield urwid.Divider() yield urwid.Divider()
yield urwid.Text([("yellow", f"{name.rstrip(':')}"), (":")]) yield urwid.Text([("yellow", f"{name.rstrip(':')}"), ":"])
for line in format_content(field["value"]): for line in format_content(field["value"]):
yield urwid.Text(highlight_hashtags(line, followed_tags=set())) yield urwid.Text(highlight_hashtags(line, followed_tags=set()))
if field["verified_at"]: if field["verified_at"]:

102
toot/tui/poll.py Normal file
View File

@ -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))

View File

@ -31,6 +31,7 @@ class Timeline(urwid.Columns):
"media", # Display media attachments "media", # Display media attachments
"menu", # Show a context menu "menu", # Show a context menu
"next", # Fetch more statuses "next", # Fetch more statuses
"poll", # Vote in a poll
"reblog", # Reblog status "reblog", # Reblog status
"reply", # Compose a reply to a status "reply", # Compose a reply to a status
"source", # Show status source "source", # Show status source
@ -65,11 +66,12 @@ class Timeline(urwid.Columns):
]) ])
def wrap_status_details(self, status_details: "StatusDetails") -> urwid.Widget: 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( return urwid.Padding(
urwid.Frame( urwid.Frame(
body=ScrollBar( body=ScrollBar(
Scrollable(urwid.Padding(status_details, right=1)), self.status_detail_scrollable,
thumb_char="\u2588", thumb_char="\u2588",
trough_char="\u2591", trough_char="\u2591",
), ),
@ -102,6 +104,8 @@ class Timeline(urwid.Columns):
if not status: if not status:
return None return None
poll = status.original.data.get("poll")
options = [ options = [
"[A]ccount" if not status.is_mine else "", "[A]ccount" if not status.is_mine else "",
"[B]oost", "[B]oost",
@ -112,6 +116,7 @@ class Timeline(urwid.Columns):
"[T]hread" if not self.is_thread else "", "[T]hread" if not self.is_thread else "",
"[L]inks", "[L]inks",
"[R]eply", "[R]eply",
"[P]oll" if poll and not poll["expired"] else "",
"So[u]rce", "So[u]rce",
"[Z]oom", "[Z]oom",
"Tra[n]slate" if self.can_translate else "", "Tra[n]slate" if self.can_translate else "",
@ -148,7 +153,9 @@ class Timeline(urwid.Columns):
def refresh_status_details(self): def refresh_status_details(self):
"""Redraws the details of the focused status.""" """Redraws the details of the focused status."""
status = self.get_focused_status() status = self.get_focused_status()
pos = self.status_detail_scrollable.get_scrollpos()
self.draw_status_details(status) self.draw_status_details(status)
self.status_detail_scrollable.set_scrollpos(pos)
def draw_status_details(self, status): def draw_status_details(self, status):
self.status_details = StatusDetails(self, status) self.status_details = StatusDetails(self, status)
@ -240,7 +247,7 @@ class Timeline(urwid.Columns):
self._emit("clear-screen") self._emit("clear-screen")
return return
if key in ("p", "P"): if key in ("e", "E"):
self._emit("save", status) self._emit("save", status)
return return
@ -248,6 +255,12 @@ class Timeline(urwid.Columns):
self._emit("zoom", self.status_details) self._emit("zoom", self.status_details)
return 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) return super().keypress(size, key)
def append_status(self, status): def append_status(self, status):
@ -340,7 +353,7 @@ class StatusDetails(urwid.Pile):
yield ("pack", urwid.Text(m["description"])) yield ("pack", urwid.Text(m["description"]))
yield ("pack", urwid.Text(("link", m["url"]))) yield ("pack", urwid.Text(("link", m["url"])))
poll = status.data.get("poll") poll = status.original.data.get("poll")
if poll: if poll:
yield ("pack", urwid.Divider()) yield ("pack", urwid.Divider())
yield ("pack", self.build_linebox(self.poll_generator(poll))) yield ("pack", self.build_linebox(self.poll_generator(poll)))

View File

@ -37,19 +37,19 @@ def time_ago(value: datetime) -> datetime:
now = datetime.now().astimezone() now = datetime.now().astimezone()
delta = now.timestamp() - value.timestamp() delta = now.timestamp() - value.timestamp()
if (delta < 1): if delta < 1:
return "now" return "now"
if (delta < 8 * DAY): if delta < 8 * DAY:
if (delta < MINUTE): if delta < MINUTE:
return f"{math.floor(delta / SECOND)}".rjust(2, " ") + "s" return f"{math.floor(delta / SECOND)}".rjust(2, " ") + "s"
if (delta < HOUR): if delta < HOUR:
return f"{math.floor(delta / MINUTE)}".rjust(2, " ") + "m" 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 / HOUR)}".rjust(2, " ") + "h"
return f"{math.floor(delta / DAY)}".rjust(2, " ") + "d" 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 f"{math.floor(delta / WEEK)}".rjust(2, " ") + "w"
return ">1y" return ">1y"

View File

@ -46,3 +46,23 @@ class Button(urwid.AttrWrap):
def set_label(self, *args, **kwargs): def set_label(self, *args, **kwargs):
self.original_widget.original_widget.set_label(*args, **kwargs) self.original_widget.original_widget.set_label(*args, **kwargs)
self.original_widget.width = len(args[0]) + 4 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")