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

View File

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

View File

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

View File

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

View File

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