Merge pull request #312 from danschwarz/poll3

UI to vote in polls
pull/299/head^2
Ivan Habunek 2023-02-20 09:06:51 +01:00 zatwierdzone przez GitHub
commit a633f757b5
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
7 zmienionych plików z 174 dodań i 19 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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"]:

102
toot/tui/poll.py 100644
Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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