toot/toot/tui/timeline.py

474 wiersze
16 KiB
Python
Czysty Zwykły widok Historia

import logging
import sys
import urwid
2019-08-24 11:13:22 +00:00
import webbrowser
2023-02-16 09:41:38 +00:00
from typing import List, Optional
2019-08-24 10:53:55 +00:00
2023-02-15 07:54:45 +00:00
from .entities import Account, Poll, PreviewCard, Status
from .scroll import Scrollable, ScrollBar
2019-08-28 13:32:57 +00:00
from .utils import highlight_hashtags, parse_datetime, highlight_keys
from .widgets import SelectableText, SelectableColumns
from toot.utils import format_content
from toot.utils.language import language_name
from toot.tui.utils import time_ago
logger = logging.getLogger("toot")
class Timeline(urwid.Columns):
"""
Displays a list of statuses to the left, and status details on the right.
"""
2019-08-24 10:53:55 +00:00
signals = [
"account", # Display account info and actions
2023-01-01 10:13:21 +00:00
"close", # Close thread
"compose", # Compose a new toot
"delete", # Delete own status
"favourite", # Favourite status
"focus", # Focus changed
"bookmark", # Bookmark status
"media", # Display media attachments
"menu", # Show a context menu
"next", # Fetch more statuses
"reblog", # Reblog status
"reply", # Compose a reply to a status
"source", # Show status source
"links", # Show status links
"thread", # Show thread for status
"translate", # Translate status
"save", # Save current timeline
"zoom", # Open status in scrollable popup window
"clear-screen", # Clear the screen (used internally)
2019-08-24 10:53:55 +00:00
]
2022-12-20 21:28:24 +00:00
def __init__(self, name, statuses, can_translate, followed_tags=[], focus=0, is_thread=False):
2019-08-28 13:32:57 +00:00
self.name = name
self.is_thread = is_thread
self.statuses = statuses
self.can_translate = can_translate
2019-08-28 13:32:57 +00:00
self.status_list = self.build_status_list(statuses, focus=focus)
2022-12-20 21:28:24 +00:00
self.followed_tags = followed_tags
try:
focused_status = statuses[focus]
2020-05-19 22:50:21 +00:00
except IndexError:
focused_status = None
self.status_details = StatusDetails(self, focused_status)
status_widget = self.wrap_status_details(self.status_details)
super().__init__([
("weight", 40, self.status_list),
("weight", 0, urwid.AttrWrap(urwid.SolidFill(""), "blue_selected")),
("weight", 60, status_widget),
2019-08-30 10:28:03 +00:00
])
def wrap_status_details(self, status_details: "StatusDetails") -> urwid.Widget:
"""Wrap StatusDetails widget with a scollbar and footer."""
return urwid.Padding(
urwid.Frame(
body=ScrollBar(
Scrollable(urwid.Padding(status_details, right=1)),
thumb_char="\u2588",
trough_char="\u2591",
),
footer=self.get_option_text(status_details.status),
),
left=1
)
2019-08-28 13:32:57 +00:00
def build_status_list(self, statuses, focus):
2019-08-24 10:53:55 +00:00
items = [self.build_list_item(status) for status in statuses]
walker = urwid.SimpleFocusListWalker(items)
2019-08-28 13:32:57 +00:00
walker.set_focus(focus)
urwid.connect_signal(walker, "modified", self.modified)
return urwid.ListBox(walker)
2019-08-24 10:53:55 +00:00
def build_list_item(self, status):
item = StatusListItem(status)
2019-08-30 10:28:03 +00:00
urwid.connect_signal(item, "click", lambda *args:
self._emit("menu", status))
2019-08-24 10:53:55 +00:00
return urwid.AttrMap(item, None, focus_map={
"blue": "green_selected",
"green": "green_selected",
"yellow": "green_selected",
"cyan": "green_selected",
"red": "green_selected",
2019-08-24 10:53:55 +00:00
None: "green_selected",
})
def get_option_text(self, status: Optional[Status]) -> Optional[urwid.Text]:
if not status:
return None
2023-02-17 08:29:07 +00:00
assert status._meta
is_mine = status._meta.is_mine
options = [
2023-02-17 08:29:07 +00:00
"[A]ccount" if not is_mine else "",
"[B]oost",
2023-02-17 08:29:07 +00:00
"[D]elete" if is_mine else "",
"B[o]okmark",
"[F]avourite",
"[V]iew",
"[T]hread" if not self.is_thread else "",
"[L]inks",
"[R]eply",
"So[u]rce",
"[Z]oom",
"Tra[n]slate" if self.can_translate else "",
"[H]elp",
]
options = "\n" + " ".join(o for o in options if o)
options = highlight_keys(options, "white_bold", "cyan")
return urwid.Text(options)
2023-02-16 09:41:38 +00:00
def get_focused_status(self) -> Optional[Status]:
2020-05-19 22:50:21 +00:00
try:
return self.statuses[self.status_list.body.focus]
except TypeError:
return None
2019-08-28 13:32:57 +00:00
def get_focused_status_with_counts(self):
2019-08-29 08:43:56 +00:00
"""Returns a tuple of:
* focused status
* focused status' index in the status list
* length of the status list
"""
2019-08-28 13:32:57 +00:00
return (
self.get_focused_status(),
self.status_list.body.focus,
len(self.statuses),
)
def modified(self):
"""Called when the list focus switches to a new status"""
2019-08-28 13:32:57 +00:00
status, index, count = self.get_focused_status_with_counts()
2019-08-27 08:02:13 +00:00
self.draw_status_details(status)
2019-08-28 13:32:57 +00:00
self._emit("focus")
2019-08-28 13:32:57 +00:00
def refresh_status_details(self):
"""Redraws the details of the focused status."""
status = self.get_focused_status()
self.draw_status_details(status)
2019-08-27 08:02:13 +00:00
def draw_status_details(self, status):
self.status_details = StatusDetails(self, status)
widget = self.wrap_status_details(self.status_details)
self.contents[2] = widget, ("weight", 60, False)
2019-08-27 08:02:13 +00:00
2019-08-24 11:13:22 +00:00
def keypress(self, size, key):
2019-08-29 09:01:49 +00:00
status = self.get_focused_status()
2019-08-29 08:43:56 +00:00
command = self._command_map[key]
2020-05-19 22:50:21 +00:00
if not status:
return super().keypress(size, key)
2019-08-24 11:13:22 +00:00
# If down is pressed on last status in list emit a signal to load more.
# TODO: Consider pre-loading statuses earlier
2020-05-19 22:50:21 +00:00
if command in [urwid.CURSOR_DOWN, urwid.CURSOR_PAGE_DOWN] \
and self.status_list.body.focus:
2019-08-24 11:13:22 +00:00
index = self.status_list.body.focus + 1
count = len(self.statuses)
if index >= count:
self._emit("next")
if key in ("a", "A"):
2023-02-16 09:41:38 +00:00
self._emit("account", status.original.account.id)
return
if key in ("b", "B"):
self._emit("reblog", status)
return
if key in ("c", "C"):
self._emit("compose")
2019-08-27 08:02:13 +00:00
return
if key in ("d", "D"):
self._emit("delete", status)
return
if key in ("f", "F"):
self._emit("favourite", status)
return
2019-08-29 09:47:44 +00:00
if key in ("m", "M"):
self._emit("media", status)
return
if key in ("q", "Q"):
2019-08-28 13:32:57 +00:00
self._emit("close")
return
if key == "esc" and self.is_thread:
self._emit("close")
return
2019-08-29 09:01:49 +00:00
if key in ("r", "R"):
self._emit("reply", status)
return
2019-08-29 12:01:26 +00:00
if key in ("s", "S"):
2023-02-16 09:41:38 +00:00
status._meta.show_sensitive = True
2019-08-29 12:01:26 +00:00
self.refresh_status_details()
return
if key in ("o", "O"):
self._emit("bookmark", status)
return
if key in ("l", "L"):
self._emit("links", status)
return
if key in ("n", "N"):
if self.can_translate:
self._emit("translate", status)
return
if key in ("t", "T"):
2019-08-28 13:32:57 +00:00
self._emit("thread", status)
return
2019-08-29 09:47:44 +00:00
if key in ("u", "U"):
self._emit("source", status)
return
2019-08-24 11:13:22 +00:00
if key in ("v", "V"):
if status.original.url:
webbrowser.open(status.original.url)
# force a screen refresh; necessary with console browsers
self._emit("clear-screen")
2019-08-29 08:43:56 +00:00
return
2019-08-24 11:13:22 +00:00
if key in ("p", "P"):
self._emit("save", status)
return
if key in ("z", "Z"):
self._emit("zoom", self.status_details)
return
2019-08-24 11:13:22 +00:00
return super().keypress(size, key)
2023-02-16 09:41:38 +00:00
def append_status(self, status: Status):
self.statuses.append(status)
self.status_list.body.append(self.build_list_item(status))
2023-02-16 09:41:38 +00:00
def prepend_status(self, status: Status):
2019-08-27 08:02:13 +00:00
self.statuses.insert(0, status)
self.status_list.body.insert(0, self.build_list_item(status))
2023-02-16 09:41:38 +00:00
def append_statuses(self, statuses: List[Status]):
for status in statuses:
self.append_status(status)
2023-02-16 09:41:38 +00:00
def get_status_index(self, id: str) -> int:
# TODO: This is suboptimal, consider a better way
for n, status in enumerate(self.statuses):
if status.id == id:
return n
raise ValueError("Status with ID {} not found".format(id))
2023-02-16 09:41:38 +00:00
def focus_status(self, status: Status):
2019-08-28 15:04:45 +00:00
index = self.get_status_index(status.id)
self.status_list.body.set_focus(index)
2023-02-14 10:47:23 +00:00
def update_status(self, status: Status):
"""Overwrite status in list with the new instance and redraw."""
index = self.get_status_index(status.id)
assert self.statuses[index].id == status.id # Sanity check
# Update internal status list
self.statuses[index] = status
# Redraw list item
self.status_list.body[index] = self.build_list_item(status)
2019-08-24 11:13:22 +00:00
# Redraw status details if status is focused
if index == self.status_list.body.focus:
2019-08-27 08:02:13 +00:00
self.draw_status_details(status)
2023-03-01 05:59:07 +00:00
# TODO: dedupe code with update_status
def redraw_status(self, status: Status):
index = self.get_status_index(status.id)
# Redraw list item
self.status_list.body[index] = self.build_list_item(status)
# Redraw status details if status is focused
if index == self.status_list.body.focus:
self.draw_status_details(status)
def remove_status(self, status):
index = self.get_status_index(status.id)
assert self.statuses[index].id == status.id # Sanity check
2022-12-27 11:31:55 +00:00
del self.statuses[index]
del self.status_list.body[index]
self.refresh_status_details()
class StatusDetails(urwid.Pile):
def __init__(self, timeline: Timeline, status: Optional[Status]):
self.status = status
self.followed_tags = timeline.followed_tags
2023-02-15 07:54:45 +00:00
reblogged_by = status.account if status and status.reblog else None
2023-02-17 08:29:07 +00:00
widget_list = list(self.content_generator(status, reblogged_by)
2020-05-19 22:50:21 +00:00
if status else ())
2019-08-24 10:53:55 +00:00
return super().__init__(widget_list)
2023-02-16 09:41:38 +00:00
def content_generator(self, status: Status, reblogged_by: Optional[Account]):
2023-02-17 08:29:07 +00:00
assert status._meta
meta = status._meta
status = status.original
if reblogged_by:
text = "{} boosted".format(reblogged_by.display_name or reblogged_by.username)
yield ("pack", urwid.Text(("gray", text)))
2019-08-25 15:58:46 +00:00
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "gray"))
2019-08-24 11:43:41 +00:00
2023-02-15 07:54:45 +00:00
if status.account.display_name:
yield ("pack", urwid.Text(("green", status.account.display_name)))
2019-08-25 08:00:48 +00:00
2023-02-15 07:54:45 +00:00
yield ("pack", urwid.Text(("yellow", status.account.acct)))
2019-08-25 15:58:46 +00:00
yield ("pack", urwid.Divider())
2019-08-24 10:53:55 +00:00
2023-02-15 07:54:45 +00:00
if status.spoiler_text:
yield ("pack", urwid.Text(status.spoiler_text))
2019-08-29 12:01:26 +00:00
yield ("pack", urwid.Divider())
# Show content warning
2023-02-15 07:54:45 +00:00
if status.spoiler_text and not meta.show_sensitive:
2019-08-29 12:01:26 +00:00
yield ("pack", urwid.Text(("content_warning", "Marked as sensitive. Press S to view.")))
else:
2023-02-15 07:54:45 +00:00
content = meta.translation if meta.show_translation else status.content
2022-12-11 21:51:32 +00:00
for line in format_content(content):
2022-12-20 21:28:24 +00:00
yield ("pack", urwid.Text(highlight_hashtags(line, self.followed_tags)))
2023-02-17 08:29:07 +00:00
for m in status.media_attachments:
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "gray"))
yield ("pack", urwid.Text([("bold", "Media attachment"), f" ({m.type})"]))
if m.description:
yield ("pack", urwid.Text(m.description))
yield ("pack", urwid.Text(("link", m.url)))
2023-02-15 07:54:45 +00:00
if status.poll:
yield ("pack", urwid.Divider())
2023-02-15 07:54:45 +00:00
yield ("pack", self.build_linebox(self.poll_generator(status.poll)))
2023-02-15 07:54:45 +00:00
if status.card:
yield ("pack", urwid.Divider())
2023-02-15 07:54:45 +00:00
yield ("pack", self.build_linebox(self.card_generator(status.card)))
2019-08-25 15:58:46 +00:00
2023-02-15 07:54:45 +00:00
application_name = status.application.name if status.application else None
2019-08-31 13:05:50 +00:00
yield ("pack", urwid.AttrWrap(urwid.Divider("-"), "gray"))
2022-12-11 22:26:15 +00:00
translated_from = (
2023-02-15 07:54:45 +00:00
language_name(meta.translated_from)
if meta.show_translation and meta.translated_from
2022-12-11 22:26:15 +00:00
else None
)
2022-12-21 23:30:29 +00:00
visibility_colors = {
"public": "gray",
"unlisted": "white",
"private": "cyan",
"direct": "yellow"
}
2022-12-28 06:48:53 +00:00
visibility = status.visibility.title()
visibility_color = visibility_colors.get(status.visibility, "gray")
yield ("pack", urwid.Text([
("blue", f"{status.created_at.strftime('%Y-%m-%d %H:%M')} "),
("red" if status.bookmarked else "gray", "🠷 "),
2023-02-16 09:41:38 +00:00
("gray", f"{status.replies_count} "),
("yellow" if status.reblogged else "gray", f"{status.reblogs_count} "),
("yellow" if status.favourited else "gray", f"{status.favourites_count}"),
2022-12-28 06:48:53 +00:00
(visibility_color, f" · {visibility}"),
("yellow", f" · Translated from {translated_from} " if translated_from else ""),
2023-02-15 07:54:45 +00:00
("gray", f" · {application_name}" if application_name else ""),
]))
2019-08-25 15:58:46 +00:00
# Push things to bottom
yield ("weight", 1, urwid.BoxAdapter(urwid.SolidFill(" "), 1))
2019-08-24 10:53:55 +00:00
def build_linebox(self, contents):
contents = urwid.Pile(list(contents))
contents = urwid.Padding(contents, left=1, right=1)
return urwid.LineBox(contents)
2023-02-15 07:54:45 +00:00
def card_generator(self, card: PreviewCard):
yield urwid.Text(("green", card.title.strip()))
if card.author_name:
yield urwid.Text(["by ", ("yellow", card.author_name.strip())])
2019-08-24 12:14:46 +00:00
yield urwid.Text("")
2023-02-15 07:54:45 +00:00
if card.description:
yield urwid.Text(card.description.strip())
2019-08-24 12:14:46 +00:00
yield urwid.Text("")
2023-02-15 07:54:45 +00:00
yield urwid.Text(("link", card.url))
2019-08-24 10:53:55 +00:00
2023-02-15 07:54:45 +00:00
def poll_generator(self, poll: Poll):
for idx, option in enumerate(poll.options):
option_votes_count = option.votes_count or 0
perc = (round(100 * option_votes_count / poll.votes_count)
if poll.votes_count else 0)
2023-02-15 07:54:45 +00:00
if poll.voted and poll.own_votes and idx in poll.own_votes:
voted_for = ""
else:
voted_for = ""
2023-02-15 07:54:45 +00:00
yield urwid.Text(option.title + voted_for)
2019-08-31 13:06:17 +00:00
yield urwid.ProgressBar("", "poll_bar", perc)
2019-08-27 12:34:51 +00:00
2023-02-15 07:54:45 +00:00
status = "Poll · {} votes".format(poll.votes_count)
2023-02-15 07:54:45 +00:00
if poll.expired:
status += " · Closed"
2023-02-15 07:54:45 +00:00
if poll.expires_at:
expires_at = poll.expires_at.strftime("%Y-%m-%d %H:%M")
status += " · Closes on {}".format(expires_at)
2019-08-27 12:34:51 +00:00
yield urwid.Text(("gray", status))
class StatusListItem(SelectableColumns):
2023-02-15 07:54:45 +00:00
def __init__(self, status: Status):
# TODO: hacky implementation to avoid creating conflicts for existing
# pull reuqests, refactor when merged.
created_at = (
time_ago(status.created_at).ljust(3, " ")
if "--relative-datetimes" in sys.argv
else status.created_at.strftime("%Y-%m-%d %H:%M")
)
2023-02-15 07:54:45 +00:00
edited_flag = "*" if status.edited_at else " "
favourited = ("yellow", "") if status.original.favourited else " "
reblogged = ("yellow", "") if status.original.reblogged else " "
is_reblog = ("cyan", "") if status.reblog else " "
2023-02-15 07:54:45 +00:00
is_reply = ("cyan", "") if status.original.in_reply_to_id else " "
# TODO: Add server name for home accounts?
account = status.original.account.acct
return super().__init__([
("pack", SelectableText(("blue", created_at), wrap="clip")),
("pack", urwid.Text(("blue", edited_flag))),
("pack", urwid.Text(" ")),
("pack", urwid.Text(favourited)),
2019-08-24 10:53:55 +00:00
("pack", urwid.Text(" ")),
("pack", urwid.Text(reblogged)),
("pack", urwid.Text(" ")),
2023-02-15 07:54:45 +00:00
urwid.Text(("green", account), wrap="clip"),
("pack", urwid.Text(is_reply)),
("pack", urwid.Text(is_reblog)),
("pack", urwid.Text(" ")),
])