The notification tab shows account notifications, and is opened from
GotoScreen.
Rather than adding a new tab type (like NotificationTab), teach
TimelineTab to display things other than statuses. This allows us to
implement things like a merged home+notifications tab in the future.
Add a new Event class, which represents an event on the timeline. This
has various subclasses like StatusEvent and NotificationEvent to
represent the specific type of event. Timeline (and its subclasses) now
generates Events instead of Statuses.
Teach TimelineTab to display Events instead of Statuses.
Rename StatusList to EventList, which can display any kind of event.
Add a new make_event_detail() function which can return a *Detail widget
for any kind of event; EventList uses this to display the event detail.
Add new event detail widgets: MentionEvent, FavouriteEvent, ReblogEvent,
NewFollowerEvent. These are very basic for now; for example
NewFollowerEvent just displays "{acct} followed you".
---
cleaned up based on review:
- removed several properties (including a couple of existing ones in
api.timeline since we're touching that code anyway)
- moved Event from api to data
- merged the small event details into a single file
- replaced some type dicts with match
- replaced list with set in StatusDetail.
- less nesting of types in NotificationTimeline
- rebased on current main
tooi/__init__.py | 5 +-
tooi/api/timeline.py | 239 +++++++++++++++++++---------------
tooi/app.py | 39 +++---
tooi/data/events.py | 96 ++++++++++++++
tooi/messages.py | 15 ++-
tooi/screens/goto.py | 5 +-
tooi/screens/main.py | 28 ++--
tooi/tabs/timeline.py | 104 +++++++++------
tooi/widgets/event_detail.py | 106 +++++++++++++++
tooi/widgets/event_list.py | 165 +++++++++++++++++++++++
tooi/widgets/status_detail.py | 45 ++++---
tooi/widgets/status_list.py | 121 -----------------
12 files changed, 640 insertions(+), 328 deletions(-)
create mode 100644 tooi/data/events.py
create mode 100644 tooi/widgets/event_detail.py
create mode 100644 tooi/widgets/event_list.py
delete mode 100644 tooi/widgets/status_list.py
diff --git a/tooi/__init__.py b/tooi/__init__.py
index 1b5df54..4f393d6 100644
--- a/tooi/__init__.py
+++ b/tooi/__init__.py
@@ -1,3 +1,6 @@
from importlib import metadata
-__version__ = metadata.version("toot-tooi")
+try:
+ __version__ = metadata.version("toot-tooi")
+except metadata.PackageNotFoundError:
+ __version__ = "0.0.0"
diff --git a/tooi/api/timeline.py b/tooi/api/timeline.py
index 05e2b4b..5db03f3 100644
--- a/tooi/api/timeline.py
+++ b/tooi/api/timeline.py
@@ -4,19 +4,22 @@ https://docs.joinmastodon.org/methods/timelines/
"""
import re
+from abc import ABC, abstractmethod
from typing import AsyncGenerator, List, Optional
-from abc import ABC, abstractmethod, abstractproperty
from urllib.parse import quote, urlparse
from httpx import Headers
from httpx._types import QueryParamTypes
from tooi.api import request, statuses
-from tooi.entities import Status, from_dict
+from tooi.data.events import Event, StatusEvent, MentionEvent, NewFollowerEvent, ReblogEvent
+from tooi.data.events import FavouriteEvent
+from tooi.data.instance import InstanceInfo
+from tooi.entities import Status, Notification, from_dict
from tooi.utils.string import str_bool
Params = Optional[QueryParamTypes]
-StatusListGenerator = AsyncGenerator[List[Status], None]
+EventGenerator = AsyncGenerator[List[Event], None]
# def anon_public_timeline_generator(instance, local: bool = False, limit: int = 40):
# path = "/api/v1/timelines/public"
@@ -34,128 +37,171 @@ class Timeline(ABC):
"""
Base class for a timeline.
"""
+
+ def __init__(self, name, instance: InstanceInfo):
+ self.instance = instance
+ self.name = name
+
@abstractmethod
- def create_generator(self, limit: int = 40) -> StatusListGenerator:
+ def create_generator(self, limit: int = 40) -> EventGenerator:
...
- @abstractproperty
- def name(self) -> str:
- ...
+
+async def _status_generator(
+ instance: InstanceInfo,
+ path: str, params: Params = None) -> EventGenerator:
+ next_path = path
+ while next_path:
+ response = await request("GET", next_path, params=params)
+ yield [StatusEvent(instance, from_dict(Status, s)) for s in response.json()]
+ next_path = _get_next_path(response.headers)
class HomeTimeline(Timeline):
- def init(self):
- super().__init__()
+ """
+ HomeTimeline loads events from the user's home timeline.
+ This timeline only ever returns events of type StatusEvent.
+ """
- def create_generator(self, limit: int = 40):
- return home_timeline_generator(limit)
+ def __init__(self, instance: InstanceInfo):
+ super().__init__("Home", instance)
- @property
- def name(self):
- return "Home"
+ def create_generator(self, limit: int = 40):
+ return _status_generator(self.instance, "/api/v1/timelines/home", {"limit": limit})
class LocalTimeline(Timeline):
- def init(self):
- super().__init__()
+ """
+ LocalTimeline loads events from the user's local instance timeline.
+ This timeline only ever returns events of type StatusEvent.
+ """
+ def __init__(self, instance: InstanceInfo):
+ super().__init__("Local", instance)
def create_generator(self, limit: int = 40):
- return public_timeline_generator(local=True, limit=limit)
-
- @property
- def name(self):
- return "Local"
+ return public_timeline_generator(self.instance, local=True, limit=limit)
class FederatedTimeline(Timeline):
- def init(self):
- super().__init__()
+ """
+ FederatedTimeline loads events from the user's federated timeline.
+ This timeline only ever returns events of type StatusEvent.
+ """
+ def __init__(self, instance: InstanceInfo):
+ super().__init__("Federated", instance)
def create_generator(self, limit: int = 40):
- return public_timeline_generator(local=False, limit=limit)
+ return public_timeline_generator(self.instance, local=False, limit=limit)
- @property
- def name(self):
- return "Federated"
+class NotificationTimeline(Timeline):
+ """
+ NotificationTimeline loads events from the user's notifications.
+ https://docs.joinmastodon.org/methods/notifications/
+ """
+ def __init__(self, instance: InstanceInfo):
+ super().__init__("Notifications", instance)
+
+ def make_notification_event(self, response: dict) -> Event | None:
+ notification = from_dict(Notification, response)
+
+ match notification.type:
+ case "mention":
+ return MentionEvent(self.instance, notification)
+ case "follow":
+ return NewFollowerEvent(self.instance, notification)
+ case "favourite":
+ return FavouriteEvent(self.instance, notification)
+ case "reblog":
+ return ReblogEvent(self.instance, notification)
+ case _:
+ return None
+
+ async def _notification_generator(self, params: Params = None) -> EventGenerator:
+ path = "/api/v1/notifications"
+ next_path = path
+
+ while next_path:
+ response = await request("GET", next_path, params=params)
+ yield [e for e in map(self.make_notification_event, response.json()) if e is not None]
+ next_path = _get_next_path(response.headers)
-class TagTimeline(Timeline):
- def __init__(self, hashtag: str, local: bool = False):
- super().__init__()
- self._local = local
+ def create_generator(self, limit: int = 40):
+ # TODO: not included: follow_request, poll, update, admin.sign_up, admin.report
+ types = ["mention", "status", "reblog", "favourite", "follow"]
+ params = {"types[]": types, "limit": limit}
+ return self._notification_generator(params)
- if len(hashtag) == 0:
- raise (ValueError("TagTimeline: tag is empty"))
+
+class TagTimeline(Timeline):
+ """
+ TagTimeline loads events from the given hashtag.
+ This timeline only ever returns events of type StatusEvent.
+ """
+ def __init__(self, instance: InstanceInfo, hashtag: str, local: bool = False):
+ self.local = local
# Normalise the hashtag to not begin with a hash
if hashtag[0] == '#':
hashtag = hashtag[1:]
- self._hashtag = hashtag
-
- @property
- def hashtag(self) -> str:
- return '#' + self._hashtag
-
- @property
- def local(self) -> bool:
- return self._local
+ if len(hashtag) == 0:
+ raise (ValueError("TagTimeline: tag is empty"))
- @property
- def name(self) -> str:
- return self.hashtag
+ self.hashtag = hashtag
+ super().__init__(f"#{self.hashtag}", instance)
def create_generator(self, limit: int = 40):
- return tag_timeline_generator(self.hashtag, self.local, limit)
+ return tag_timeline_generator(self.instance, self.hashtag, self.local, limit)
class ContextTimeline(Timeline):
- def __init__(self, status: Status):
+ """
+ TagTimeline loads events from the thread the given status is part of.
+ This timeline only ever returns events of type StatusEvent.
+ """
+ def __init__(self, instance: InstanceInfo, status: Status):
+ super().__init__("Thread", instance)
self._status = status
def create_generator(self, limit: int = 40):
- return context_timeline_generator(self._status, limit)
+ async def _context_timeline_generator(status: Status, limit: int = 40):
+ response = await statuses.context(status.original.id)
+ data = response.json()
+ ancestors = [from_dict(Status, s) for s in data["ancestors"]]
+ descendants = [from_dict(Status, s) for s in data["descendants"]]
+ all_statuses = ancestors + [status] + descendants
+ yield [StatusEvent(self.instance, s) for s in all_statuses]
- @property
- def name(self):
- return "Thread"
-
-
-def home_timeline_generator(limit: int = 40):
- path = "/api/v1/timelines/home"
- params = {"limit": limit}
- return _timeline_generator(path, params)
+ return _context_timeline_generator(self._status, limit)
-def public_timeline_generator(local: bool = False, limit: int = 40):
+def public_timeline_generator(instance: InstanceInfo, local: bool = False, limit: int = 40):
path = "/api/v1/timelines/public"
params = {"local": str_bool(local), "limit": limit}
- return _timeline_generator(path, params)
+ return _status_generator(instance, path, params)
-def tag_timeline_generator(hashtag: str, local: bool = False, limit: int = 40):
+def tag_timeline_generator(
+ instance: InstanceInfo,
+ hashtag: str,
+ local: bool = False,
+ limit: int = 40):
path = f"/api/v1/timelines/tag/{quote(hashtag)}"
params = {"local": str_bool(local), "limit": limit}
- return _timeline_generator(path, params)
+ return _status_generator(instance, path, params)
-def bookmark_timeline_generator(limit: int = 40):
+def bookmark_timeline_generator(instance: InstanceInfo, limit: int = 40):
path = "/api/v1/bookmarks"
params = {"limit": limit}
- return _timeline_generator(path, params)
+ return _status_generator(instance, path, params)
-def notification_timeline_generator(limit: int = 40):
- # exclude all but mentions and statuses
- exclude_types = ["follow", "favourite", "reblog", "poll", "follow_request"]
- params = {"exclude_types[]": exclude_types, "limit": limit}
- return _notification_timeline_generator("/api/v1/notifications", params)
-
-
-def conversation_timeline_generator(limit: int = 40):
- path = "/api/v1/conversations"
- params = {"limit": limit}
- return _conversation_timeline_generator(path, params)
+# def conversation_timeline_generator(limit: int = 40):
+# path = "/api/v1/conversations"
+# params = {"limit": limit}
+# return _conversation_timeline_generator(path, params)
# def account_timeline_generator(account_name: str, replies=False, reblogs=False, limit: int = 40):
@@ -165,9 +211,9 @@ def conversation_timeline_generator(limit: int = 40):
# return _timeline_generator(path, params)
-def list_timeline_generator(list_id: str, limit: int = 20):
- path = f"/api/v1/timelines/list/{list_id}"
- return _timeline_generator(path, {"limit": limit})
+# def list_timeline_generator(list_id: str, limit: int = 20):
+# path = f"/api/v1/timelines/list/{list_id}"
+# return _timeline_generator(path, {"limit": limit})
# async def _anon_timeline_generator(instance: str, path: Optional[str], params=None):
@@ -180,37 +226,14 @@ def list_timeline_generator(list_id: str, limit: int = 20):
# path = _get_next_path(response.headers)
-async def context_timeline_generator(status: Status, limit: int = 40):
- response = await statuses.context(status.original.id)
- data = response.json()
- ancestors = [from_dict(Status, s) for s in data["ancestors"]]
- descendants = [from_dict(Status, s) for s in data["descendants"]]
- all_statuses = ancestors + [status] + descendants
- yield all_statuses
-
-
-async def _timeline_generator(path: str, params: Params = None) -> StatusListGenerator:
- next_path = path
- while next_path:
- response = await request("GET", next_path, params=params)
- yield [from_dict(Status, s) for s in response.json()]
- next_path = _get_next_path(response.headers)
-
-
-async def _notification_timeline_generator(path: str, params: Params = None) -> StatusListGenerator:
- next_path = path
- while next_path:
- response = await request("GET", next_path, params=params)
- yield [n["status"] for n in response.json() if n["status"]]
- next_path = _get_next_path(response.headers)
-
-
-async def _conversation_timeline_generator(path: str, params: Params = None) -> StatusListGenerator:
- next_path = path
- while next_path:
- response = await request("GET", next_path, params=params)
- yield [c["last_status"] for c in response.json() if c["last_status"]]
- next_path = _get_next_path(response.headers)
+# async def _conversation_timeline_generator(
+# path: str,
+# params: Params = None) -> StatusListGenerator:
+# next_path = path
+# while next_path:
+# response = await request("GET", next_path, params=params)
+# yield [c["last_status"] for c in response.json() if c["last_status"]]
+# next_path = _get_next_path(response.headers)
def _get_next_path(headers: Headers) -> str | None:
diff --git a/tooi/app.py b/tooi/app.py
index afc3dd1..8a8995d 100644
--- a/tooi/app.py
+++ b/tooi/app.py
@@ -6,11 +6,11 @@ from textual.screen import ModalScreen
from urllib.parse import urlparse
from tooi.api.timeline import Timeline, HomeTimeline, LocalTimeline, TagTimeline
-from tooi.api.timeline import FederatedTimeline, ContextTimeline
+from tooi.api.timeline import FederatedTimeline, ContextTimeline, NotificationTimeline
from tooi.context import get_context
-from tooi.data.instance import InstanceInfo, get_instance_info
+from tooi.data.instance import get_instance_info
from tooi.messages import GotoHashtagTimeline, GotoHomeTimeline, GotoLocalTimeline
-from tooi.messages import ShowAccount, ShowSource, ShowStatusMenu, ShowThread
+from tooi.messages import ShowAccount, ShowSource, ShowStatusMenu, ShowThread, ShowNotifications
from tooi.messages import ShowHashtagPicker, StatusReply, GotoFederatedTimeline
from tooi.screens.account import AccountScreen
from tooi.screens.compose import ComposeScreen
@@ -41,15 +41,12 @@ class TooiApp(App[None]):
async def on_mount(self):
self.push_screen("loading")
self.context = get_context()
-
- instance_info = await get_instance_info()
-
- self.tabs = MainScreen(instance_info)
+ self.instance = await get_instance_info()
+ self.tabs = MainScreen(self.instance)
self.switch_screen(self.tabs)
- self.instance_info: InstanceInfo = instance_info
def action_compose(self):
- self.push_screen(ComposeScreen(self.instance_info))
+ self.push_screen(ComposeScreen(self.instance))
def action_goto(self):
def _goto_done(action):
@@ -59,7 +56,7 @@ class TooiApp(App[None]):
self.push_screen(GotoScreen(), _goto_done)
async def action_show_instance(self):
- screen = InstanceScreen(self.instance_info)
+ screen = InstanceScreen(self.instance)
self.push_screen(screen)
def action_pop_or_quit(self):
@@ -86,7 +83,7 @@ class TooiApp(App[None]):
self.push_screen(StatusMenuScreen(message.status))
def on_status_reply(self, message: StatusReply):
- self.push_screen(ComposeScreen(self.instance_info, message.status))
+ self.push_screen(ComposeScreen(self.instance, message.status))
async def on_show_hashtag_picker(self, message: ShowHashtagPicker):
def _show_hashtag(hashtag: str):
@@ -97,21 +94,27 @@ class TooiApp(App[None]):
async def on_show_thread(self, message: ShowThread):
# TODO: add footer message while loading statuses
- timeline = ContextTimeline(message.status.original)
- await self.tabs.open_timeline_tab(timeline, initial_focus=message.status.original.id)
+ timeline = ContextTimeline(self.instance, message.status.original)
+ # TODO: composing a status: event id by hand is probably not ideal.
+ await self.tabs.open_timeline_tab(
+ timeline,
+ initial_focus=f"status:{message.status.original.id}")
async def on_goto_home_timeline(self, message: GotoHomeTimeline):
# TODO: add footer message while loading statuses
- await self._open_timeline(HomeTimeline())
+ await self._open_timeline(HomeTimeline(self.instance))
async def on_goto_local_timeline(self, message: GotoLocalTimeline):
- await self._open_timeline(LocalTimeline())
+ await self._open_timeline(LocalTimeline(self.instance))
async def on_goto_federated_timeline(self, message: GotoFederatedTimeline):
- await self._open_timeline(FederatedTimeline())
+ await self._open_timeline(FederatedTimeline(self.instance))
async def on_goto_hashtag_timeline(self, message: GotoHashtagTimeline):
- await self._open_timeline(TagTimeline(hashtag=message.hashtag))
+ await self._open_timeline(TagTimeline(self.instance, hashtag=message.hashtag))
+
+ async def on_show_notifications(self, message: ShowNotifications):
+ await self.tabs.open_timeline_tab(NotificationTimeline(self.instance))
async def _open_timeline(self, timeline: Timeline):
await self.tabs.open_timeline_tab(timeline)
@@ -122,7 +125,7 @@ class TooiApp(App[None]):
# Hashtag
if m := re.match(r"/tags/(\w+)", parsed.path):
hashtag = m.group(1)
- await self._open_timeline(TagTimeline(hashtag))
+ await self._open_timeline(TagTimeline(self.instance, hashtag))
else:
# TODO: improve link handling
webbrowser.open(message.url)
diff --git a/tooi/data/events.py b/tooi/data/events.py
new file mode 100644
index 0000000..6a87934
--- /dev/null
+++ b/tooi/data/events.py
@@ -0,0 +1,96 @@
+from abc import ABC, abstractproperty
+from datetime import datetime
+
+from tooi.data.instance import InstanceInfo
+from tooi.entities import Account, Status, Notification
+
+
+class Event(ABC):
+ """
+ An Event is something that happens on a timeline.
+ """
+ def __init__(self, id: str, instance: InstanceInfo):
+ self.id = id
+ self.instance = instance
+
+ @abstractproperty
+ def created_at(self) -> datetime:
+ ...
+
+
+class StatusEvent(Event):
+ """
+ Represents a new status being posted on a timeline.
+ """
+ def __init__(self, instance: InstanceInfo, status: Status):
+ self.status = status
+ super().__init__(f"status:{status.id}", instance)
+
+ @property
+ def created_at(self) -> datetime:
+ return self.status.created_at
+
+
+class NotificationEvent(Event):
+ """
+ Represents an event from the notification timeline.
+ """
+ def __init__(self, instance: InstanceInfo, notification: Notification):
+ self.notification = notification
+ super().__init__(f"notification:{notification.id}", instance)
+
+ @property
+ def created_at(self) -> datetime:
+ return self.notification.created_at
+
+ @property
+ def account(self) -> Account:
+ return self.notification.account
+
+
+class MentionEvent(NotificationEvent):
+ """
+ Represents a notification that we were mentioned in a status.
+ """
+ def __init__(self, instance: InstanceInfo, notification: Notification):
+ super().__init__(instance, notification)
+
+ @property
+ def status(self) -> Status:
+ return self.notification.status
+
+
+class ReblogEvent(NotificationEvent):
+ """
+ Represents a notification that our status was reblogged.
+ """
+ def __init__(self, instance: InstanceInfo, notification: Notification):
+ super().__init__(instance, notification)
+
+ @property
+ def status(self) -> Status:
+ return self.notification.status
+
+
+class FavouriteEvent(NotificationEvent):
+ """
+ Represents a notification that our status was favourited.
+ """
+ def __init__(self, instance: InstanceInfo, notification: Notification):
+ super().__init__(instance, notification)
+
+ @property
+ def status(self) -> Status:
+ return self.notification.status
+
+
+class NewFollowerEvent(NotificationEvent):
+ """
+ Represents a notification that we were followed by a new account.
+ """
+ def __init__(self, instance: InstanceInfo, notification: Notification):
+ super().__init__(instance, notification)
+
+ @property
+ def status(self) -> Status:
+ return self.notification.status
diff --git a/tooi/messages.py b/tooi/messages.py
index 5b237e6..4dc4690 100644
--- a/tooi/messages.py
+++ b/tooi/messages.py
@@ -1,4 +1,5 @@
from textual.message import Message
+from tooi.data.events import Event
from tooi.entities import Account, Status
# Common message types
@@ -10,6 +11,12 @@ class AccountMessage(Message, bubble=True):
self.account = account
+class EventMessage(Message, bubble=True):
+ def __init__(self, event: Event) -> None:
+ super().__init__()
+ self.event = event
+
+
class StatusMessage(Message, bubble=True):
def __init__(self, status: Status) -> None:
super().__init__()
@@ -18,11 +25,11 @@ class StatusMessage(Message, bubble=True):
# Custom messages
-class StatusSelected(StatusMessage):
+class EventSelected(EventMessage):
pass
-class StatusHighlighted(StatusMessage):
+class EventHighlighted(EventMessage):
pass
@@ -38,6 +45,10 @@ class GotoFederatedTimeline(Message):
pass
+class ShowNotifications(Message):
+ pass
+
+
class ShowHashtagPicker(Message):
pass
diff --git a/tooi/screens/goto.py b/tooi/screens/goto.py
index 7a3574c..869ab1a 100644
--- a/tooi/screens/goto.py
+++ b/tooi/screens/goto.py
@@ -3,7 +3,7 @@ from textual.binding import Binding
from textual.message import Message
from textual.widgets import Input, ListItem, Static
-from tooi.messages import GotoHomeTimeline, GotoLocalTimeline
+from tooi.messages import GotoHomeTimeline, GotoLocalTimeline, ShowNotifications
from tooi.messages import GotoFederatedTimeline, ShowHashtagPicker
from tooi.screens.modal import ModalScreen
from tooi.widgets.list_view import ListView
@@ -21,6 +21,7 @@ class GotoScreen(ModalScreen[Message | None]):
ListItem(Static("< Home timeline >"), id="goto_home"),
ListItem(Static("< Local timeline >"), id="goto_local"),
ListItem(Static("< Federated timeline >"), id="goto_federated"),
+ ListItem(Static("< Notifications >"), id="goto_notifications"),
ListItem(Static("< Hashtag timeline >"), id="goto_hashtag"),
)
self.status = Static("")
@@ -44,6 +45,8 @@ class GotoScreen(ModalScreen[Message | None]):
self.dismiss(GotoFederatedTimeline())
case "goto_hashtag":
self.dismiss(ShowHashtagPicker())
+ case "goto_notifications":
+ self.dismiss(ShowNotifications())
case _:
log.error("Unknown selection")
self.dismiss(None)
diff --git a/tooi/screens/main.py b/tooi/screens/main.py
index e224ee4..21b4bb1 100644
--- a/tooi/screens/main.py
+++ b/tooi/screens/main.py
@@ -37,32 +37,32 @@ class MainScreen(Screen[None]):
BINDINGS = [
Binding("ctrl+d", "close_current_tab"),
Binding(".", "refresh_timeline", "Refresh"),
- Binding("1", "select_tab(1)"),
- Binding("2", "select_tab(2)"),
- Binding("3", "select_tab(3)"),
- Binding("4", "select_tab(4)"),
- Binding("5", "select_tab(5)"),
- Binding("6", "select_tab(6)"),
- Binding("7", "select_tab(7)"),
- Binding("8", "select_tab(8)"),
- Binding("9", "select_tab(9)"),
- Binding("0", "select_tab(10)"),
Binding("/", "open_search_tab", "Search"),
+ Binding("1", "select_tab(1)", show=False),
+ Binding("2", "select_tab(2)", show=False),
+ Binding("3", "select_tab(3)", show=False),
+ Binding("4", "select_tab(4)", show=False),
+ Binding("5", "select_tab(5)", show=False),
+ Binding("6", "select_tab(6)", show=False),
+ Binding("7", "select_tab(7)", show=False),
+ Binding("8", "select_tab(8)", show=False),
+ Binding("9", "select_tab(9)", show=False),
+ Binding("0", "select_tab(10)", show=False),
]
- def __init__(self, instance_info: InstanceInfo):
- self.instance_info = instance_info
+ def __init__(self, instance: InstanceInfo):
+ self.instance = instance
super().__init__()
def compose(self) -> ComposeResult:
yield Header("toot")
# Start with the home timeline
with TabbedContent():
- yield TimelineTab(self.instance_info, HomeTimeline())
+ yield TimelineTab(self.instance, HomeTimeline(self.instance))
yield Footer()
async def open_timeline_tab(self, timeline: Timeline, initial_focus: str | None = None):
- tab = TimelineTab(self.instance_info, timeline, initial_focus=initial_focus)
+ tab = TimelineTab(self.instance, timeline, initial_focus=initial_focus)
tc = self.query_one(TabbedContent)
await tc.add_pane(tab)
tc.active = tab.id
diff --git a/tooi/tabs/timeline.py b/tooi/tabs/timeline.py
index e29d571..9146d20 100644
--- a/tooi/tabs/timeline.py
+++ b/tooi/tabs/timeline.py
@@ -4,18 +4,23 @@ from textual.binding import Binding
from textual.containers import Horizontal
from textual.widgets import TabPane
+from tooi.data.events import Event, StatusEvent, MentionEvent
from tooi.api.timeline import Timeline
from tooi.context import get_context
from tooi.data.instance import InstanceInfo
-from tooi.entities import Status
from tooi.messages import ShowAccount, ShowSource, ShowStatusMenu, ShowThread
-from tooi.messages import StatusHighlighted, StatusSelected, StatusReply
+from tooi.messages import EventHighlighted, EventSelected, StatusReply
from tooi.widgets.status_bar import StatusBar
-from tooi.widgets.status_detail import StatusDetail, StatusDetailPlaceholder
-from tooi.widgets.status_list import StatusList
+from tooi.widgets.status_detail import StatusDetail
+from tooi.widgets.event_detail import make_event_detail, EventDetailPlaceholder
+from tooi.widgets.event_list import EventList
class TimelineTab(TabPane):
+ """
+ A tab that shows events from a timeline.
+ """
+
BINDINGS = [
Binding("a", "show_account", "Account"),
Binding("u", "show_source", "Source"),
@@ -41,7 +46,6 @@ class TimelineTab(TabPane):
self.timeline = timeline
self.generator = None
self.fetching = False
- self.revealed_ids: set[str] = set()
self.initial_focus = initial_focus
if self.context.config.always_show_sensitive is not None:
@@ -52,80 +56,96 @@ class TimelineTab(TabPane):
False))
# Start with an empty status list while we wait to load statuses.
- self.status_list = StatusList([])
- self.status_detail = StatusDetailPlaceholder()
+ self.event_list = EventList([])
+ self.event_detail = EventDetailPlaceholder()
self.status_bar = StatusBar()
def on_show(self, message):
- self.status_list.focus()
+ self.event_list.focus()
async def on_mount(self, message):
- self.status_detail.focus()
+ self.event_detail.focus()
await self.refresh_timeline()
if self.initial_focus:
- self.status_list.focus_status(self.initial_focus)
+ self.event_list.focus_event(self.initial_focus)
def compose(self):
yield Horizontal(
- self.status_list,
- self.status_detail,
+ self.event_list,
+ self.event_detail,
id="main_window"
)
yield self.status_bar
- def make_status_detail(self, status: Status):
- revealed = (self.always_show_sensitive or
- status.original.id in self.revealed_ids)
- return StatusDetail(status, revealed=revealed)
+ def make_event_detail(self, event: Event):
+ return make_event_detail(event)
def focus_status(self, status_id: str):
- self.status_list.focus_status(status_id)
+ self.event_list.focus_status(status_id)
async def refresh_timeline(self):
self.generator = self.timeline.create_generator()
- statuses = await anext(self.generator)
- self.status_list.replace(statuses)
- self.query_one("#main_window").mount(self.status_detail)
+ events = await anext(self.generator)
+ self.event_list.replace(events)
+ self.query_one("#main_window").mount(self.event_detail)
- def on_status_highlighted(self, message: StatusHighlighted):
+ def on_event_highlighted(self, message: EventHighlighted):
# TODO: This is slow, try updating the existing StatusDetail instead of
# creating a new one. This requires some fiddling since compose() is
# called only once, so updating needs to be implemented manually.
# See: https://github.com/Textualize/textual/discussions/1683
- self.status_detail.remove()
- self.status_detail = self.make_status_detail(message.status)
- self.query_one("#main_window").mount(self.status_detail)
+ self.event_detail.remove()
+ self.event_detail = self.make_event_detail(message.event)
+ self.query_one("#main_window").mount(self.event_detail)
asyncio.create_task(self.maybe_fetch_next_batch())
- def on_status_selected(self, message: StatusSelected):
+ def on_status_selected(self, message: EventSelected):
self.post_message(ShowStatusMenu(message.status))
def action_show_sensitive(self):
- if isinstance(self.status_detail, StatusDetail) and self.status_detail.sensitive:
- self.revealed_ids.add(self.status_detail.status.original.id)
- self.status_detail.reveal()
+ if isinstance(self.event_detail, StatusDetail) and self.event_detail.sensitive:
+ self.event_detail.reveal()
def action_show_account(self):
- if status := self.status_list.current:
- self.post_message(ShowAccount(status.original.account))
+ if event := self.event_list.current:
+ match event:
+ case MentionEvent():
+ self.post_message(ShowAccount(event.status.original.account))
+ case StatusEvent():
+ self.post_message(ShowThread(event.status.original.account))
+ case _:
+ pass
def action_show_source(self):
- if status := self.status_list.current:
- self.post_message(ShowSource(status))
+ if event := self.event_list.current:
+ if isinstance(event, StatusEvent):
+ self.post_message(ShowSource(event.status))
def action_show_thread(self):
- if status := self.status_list.current:
- self.post_message(ShowThread(status))
+ if event := self.event_list.current:
+ match event:
+ case MentionEvent():
+ self.post_message(ShowThread(event.status))
+ case StatusEvent():
+ self.post_message(ShowThread(event.status))
+ case _:
+ pass
def action_status_reply(self):
- if status := self.status_list.current:
- self.post_message(StatusReply(status))
+ if event := self.event_list.current:
+ match event:
+ case MentionEvent():
+ self.post_message(StatusReply(event.status))
+ case StatusEvent():
+ self.post_message(StatusReply(event.status))
+ case _:
+ pass
def action_scroll_left(self):
- self.query_one("StatusList").focus()
+ self.event_list.focus()
def action_scroll_right(self):
- self.query_one("StatusDetail").focus()
+ self.event_detail.focus()
async def maybe_fetch_next_batch(self):
if self.generator and self.should_fetch():
@@ -133,13 +153,13 @@ class TimelineTab(TabPane):
self.status_bar.update("[green]Loading statuses...[/]")
# TODO: handle exceptions
try:
- next_statuses = await anext(self.generator)
- self.status_list.update(next_statuses)
+ next_events = await anext(self.generator)
+ self.event_list.update(next_events)
finally:
self.fetching = False
self.status_bar.update()
def should_fetch(self):
- if not self.fetching and self.status_list.index is not None:
- diff = self.status_list.count - self.status_list.index
+ if not self.fetching and self.event_list.index is not None:
+ diff = self.event_list.count - self.event_list.index
return diff < 10
diff --git a/tooi/widgets/event_detail.py b/tooi/widgets/event_detail.py
new file mode 100644
index 0000000..6b0b487
--- /dev/null
+++ b/tooi/widgets/event_detail.py
@@ -0,0 +1,106 @@
+from textual.containers import VerticalScroll
+from textual.widget import Widget
+from textual.widgets import Static
+
+from tooi.context import get_context
+from tooi.data.events import Event, StatusEvent, MentionEvent, FavouriteEvent, NewFollowerEvent
+from tooi.data.events import ReblogEvent
+from tooi.widgets.status_detail import StatusDetail
+
+
+class MentionDetail(StatusDetail):
+ def __init__(self, event: Event, revealed: bool = False):
+ super().__init__(event)
+
+ # TODO: Perhaps display a "You were mentioned by..." header.
+ def compose(self):
+ yield from StatusDetail.compose(self)
+
+
+class ReblogDetail(StatusDetail):
+ def __init__(self, event: Event, revealed: bool = False):
+ super().__init__(event)
+
+ def compose(self):
+ yield from StatusDetail.compose(self)
+
+
+class FavouriteDetail(StatusDetail):
+ def __init__(self, event: Event, revealed: bool = False):
+ super().__init__(event)
+
+ def compose(self):
+ yield from StatusDetail.compose(self)
+
+
+class NewFollowerDetail(VerticalScroll):
+ DEFAULT_CSS = """
+ NewFollowerDetail {
+ width: 2fr;
+ padding: 0 1;
+ }
+ NewFollowerDetail:focus {
+ background: $panel;
+ }
+ """
+
+ def __init__(self, event: NewFollowerEvent, revealed: bool = False):
+ self.event = event
+ super().__init__()
+
+ def compose(self):
+ ctx = get_context()
+ acct = self.event.account.acct
+ acct = acct if "@" in acct else f"{acct}@{ctx.auth.domain}"
+ yield Static(f"{acct} followed you.")
+
+
+class UnknownEventDetail(Static, can_focus=True):
+ DEFAULT_CSS = """
+ UnknownEventDetail {
+ width: 2fr;
+ padding: 0 1;
+ color: gray;
+ height: 100%;
+ }
+ UnknownEventDetail:focus {
+ background: $panel;
+ }
+ """
+
+ def __init__(self, event):
+ self.event = event
+ super().__init__("<unknown event>")
+
+
+class EventDetailPlaceholder(Static, can_focus=True):
+ DEFAULT_CSS = """
+ EventDetailPlaceholder {
+ width: 2fr;
+ padding: 0 1;
+ color: gray;
+ height: 100%;
+ }
+ EventDetailPlaceholder:focus {
+ background: $panel;
+ }
+ """
+
+ def __init__(self):
+ super().__init__("Nothing selected")
+
+
+def make_event_detail(event: Event) -> Widget:
+ match event:
+ case NewFollowerEvent():
+ return NewFollowerDetail(event)
+ case MentionEvent():
+ return MentionDetail(event)
+ case FavouriteEvent():
+ return FavouriteDetail(event)
+ case ReblogEvent():
+ return ReblogDetail(event)
+ case StatusEvent():
+ return StatusDetail(event)
+ case _:
+ return UnknownEventDetail(event)
diff --git a/tooi/widgets/event_list.py b/tooi/widgets/event_list.py
new file mode 100644
index 0000000..e6bf8e6
--- /dev/null
+++ b/tooi/widgets/event_list.py
@@ -0,0 +1,165 @@
+from textual.widgets import ListItem, Label
+
+from tooi.data.events import Event, StatusEvent, MentionEvent, NewFollowerEvent, ReblogEvent
+from tooi.data.events import FavouriteEvent
+from tooi.context import get_context
+from tooi.messages import EventHighlighted, EventSelected
+from tooi.utils.datetime import format_datetime
+from tooi.widgets.list_view import ListView
+
+
+class EventList(ListView):
+ """
+ A ListView that shows a list of events.
+ """
+
+ current: Event | None
+ events: list[Event]
+
+ DEFAULT_CSS = """
+ EventList {
+ width: 1fr;
+ min-width: 20;
+ border-right: solid $accent;
+ }
+ EventList:focus-within {
+ background: $panel;
+ }
+ """
+
+ def __init__(self, events: list[Event]):
+ super().__init__()
+ self.events = []
+ self.current = None
+ self.update(events)
+
+ def replace(self, next_events: list[Event]):
+ self.events = []
+ self.clear()
+ self.current = None
+ self.update(next_events)
+
+ def update(self, next_events: list[Event]):
+ self.events += next_events
+
+ for event in next_events:
+ self.mount(EventListItem(event))
+
+ if self.current is None and len(self.events) > 0:
+ self.index = 0
+ self.current = self.events[0]
+
+ if self.current is not None:
+ self.post_message(EventHighlighted(self.current))
+
+ def focus_event(self, event_id: str):
+ for i, event in enumerate(self.events):
+ if event.id == event_id:
+ self.index = i
+ self.current = event
+
+ @property
+ def count(self):
+ return len(self.events)
+
+ def on_list_view_highlighted(self, message: ListView.Highlighted):
+ if message.item and hasattr(message.item, "event"):
+ event = message.item.event
+ if event != self.current:
+ self.current = event
+ self.post_message(EventHighlighted(event))
+
+ def on_list_view_selected(self, message: ListView.Highlighted):
+ if self.current:
+ self.post_message(EventSelected(self.current))
+
+
+class EventListItem(ListItem, can_focus=True):
+ event: Event
+
+ DEFAULT_CSS = """
+ EventListItem {
+ layout: horizontal;
+ width: auto;
+ }
+
+ Label {
+ width: 1fr;
+ align: left middle;
+ }
+
+ .event_list_timestamp {
+ width: auto;
+ }
+
+ .event_list_acct {
+ color: green;
+ width: auto;
+ padding-left: 1;
+ }
+
+ .event_list_flags {
+ width: 2;
+ padding-left: 1;
+ }
+ """
+
+ def __init__(self, event: Event):
+ super().__init__(classes="event_list_item")
+ self.event = event
+
+ def compose(self):
+ timestamp = format_datetime(self.event.created_at)
+ yield Label(timestamp, classes="event_list_timestamp")
+
+ # TODO: These should probably be implemented in a way that doesn't
+ # require hard-coding the list of event types.
+ match self.event:
+ case NewFollowerEvent():
+ yield from self.compose_new_follower()
+ case ReblogEvent():
+ yield from self.compose_reblog()
+ case FavouriteEvent():
+ yield from self.compose_favourite()
+ case MentionEvent():
+ yield from self.compose_mention()
+ case StatusEvent():
+ yield from self.compose_status()
+ case _:
+ yield from self.compose_unknown()
+
+ def _format_account_name(self, account):
+ ctx = get_context()
+ acct = account.acct
+ return acct if "@" in acct else f"{acct}@{ctx.auth.domain}"
+
+ def compose_status(self):
+ flags = "B" if self.event.status.reblog else " "
+ acct = self.event.status.original.account
+ yield Label(flags, classes="event_list_flags")
+ yield Label(self._format_account_name(acct), classes="event_list_acct")
+
+ def compose_mention(self):
+ acct = self.event.account
+ yield Label("@", classes="event_list_flags")
+ yield Label(self._format_account_name(acct), classes="event_list_acct")
+
+ def compose_reblog(self):
+ acct = self.event.account
+ yield Label("B", classes="event_list_flags")
+ yield Label(self._format_account_name(acct), classes="event_list_acct")
+
+ def compose_favourite(self):
+ acct = self.event.account
+ yield Label("*", classes="event_list_flags")
+ yield Label(self._format_account_name(acct), classes="event_list_acct")
+
+ def compose_new_follower(self):
+ acct = self.event.account
+ yield Label(">", classes="event_list_flags")
+ yield Label(self._format_account_name(acct), classes="event_list_acct")
+
+ def compose_unknown(self):
+ acct = self.event.account
+ yield Label("?", classes="event_list_flags")
+ yield Label(self._format_account_name(acct), classes="event_list_acct")
diff --git a/tooi/widgets/status_detail.py b/tooi/widgets/status_detail.py
index e22c69a..35a0453 100644
--- a/tooi/widgets/status_detail.py
+++ b/tooi/widgets/status_detail.py
@@ -4,6 +4,8 @@ from textual.containers import Vertical, VerticalScroll
from textual.widget import Widget
from textual.widgets import Static
+from tooi.data.events import Event
+from tooi.context import get_context
from tooi.entities import MediaAttachment, Status
from tooi.utils.datetime import format_datetime
from tooi.widgets.account import AccountHeader
@@ -14,6 +16,8 @@ from tooi.widgets.poll import Poll
class StatusDetail(VerticalScroll):
+ _revealed: set[str] = set()
+
DEFAULT_CSS = """
StatusDetail {
width: 2fr;
@@ -50,11 +54,27 @@ class StatusDetail(VerticalScroll):
Binding("pagedown", "page_down", "Page Down", show=False),
]
- def __init__(self, status: Status, revealed: bool = False):
+ def __init__(self, event: Event):
super().__init__()
- self.status = status
- self.sensitive = status.original.sensitive
- self.revealed = revealed
+ self.context = get_context()
+ self.event = event
+ self.status = event.status
+ self.sensitive = self.status.original.sensitive
+
+ @property
+ def revealed(self) -> bool:
+ return (self.context.config.always_show_sensitive
+ or (self.status.original.id in self._revealed))
+
+ @revealed.setter
+ def revealed(self, b: bool):
+ if b:
+ self._revealed.add(self.status.id)
+ else:
+ try:
+ self._revealed.remove(self.status.id)
+ except KeyError:
+ pass
def compose(self):
status = self.status.original
@@ -206,23 +226,6 @@ class StatusMeta(Static):
return " ยท ".join(parts)
-class StatusDetailPlaceholder(Static, can_focus=True):
- DEFAULT_CSS = """
- StatusDetailPlaceholder {
- width: 2fr;
- padding: 0 1;
- color: gray;
- height: 100%;
- }
- StatusDetailPlaceholder:focus {
- background: $panel;
- }
- """
-
- def __init__(self):
- super().__init__("No status selected")
-
-
class StatusSensitiveNotice(Static):
DEFAULT_CSS = """
StatusSensitiveNotice {
diff --git a/tooi/widgets/status_list.py b/tooi/widgets/status_list.py
deleted file mode 100644
index b6bd0f3..0000000
--- a/tooi/widgets/status_list.py
@@ -1,121 +0,0 @@
-from textual.widgets import ListItem, Label
-
-from tooi.context import get_context
-from tooi.entities import Status
-from tooi.messages import StatusHighlighted, StatusSelected
-from tooi.utils.datetime import format_datetime
-from tooi.widgets.list_view import ListView
-
-
-class StatusList(ListView):
- current: Status | None
- statuses: list[Status]
-
- DEFAULT_CSS = """
- StatusList {
- width: 1fr;
- min-width: 20;
- border-right: solid $accent;
- }
- StatusList:focus-within {
- background: $panel;
- }
- """
-
- def __init__(self, statuses: list[Status]):
- super().__init__()
- self.statuses = []
- self.current = None
- self.update(statuses)
-
- def replace(self, next_statuses: list[Status]):
- self.statuses = []
- self.clear()
- self.current = None
- self.update(next_statuses)
-
- def update(self, next_statuses: list[Status]):
- self.statuses += next_statuses
-
- for status in next_statuses:
- self.mount(StatusListItem(status))
-
- if self.current is None and len(self.statuses) > 0:
- self.index = 0
- self.current = self.statuses[0]
-
- if self.current is not None:
- self.post_message(StatusHighlighted(self.current))
-
- def focus_status(self, status_id: str):
- for i, status in enumerate(self.statuses):
- if status.id == status_id:
- self.index = i
- self.current = status
-
- @property
- def count(self):
- return len(self.statuses)
-
- def on_list_view_highlighted(self, message: ListView.Highlighted):
- if message.item and hasattr(message.item, "status"):
- status = message.item.status
- if status != self.current:
- self.current = status
- self.post_message(StatusHighlighted(status))
-
- def on_list_view_selected(self, message: ListView.Highlighted):
- if self.current:
- self.post_message(StatusSelected(self.current))
-
-
-class StatusListItem(ListItem, can_focus=True):
- status: Status
-
- DEFAULT_CSS = """
- StatusListItem {
- layout: horizontal;
- width: auto;
- }
-
- Label {
- width: 1fr;
- align: left middle;
- }
-
- .status_list_timestamp {
- width: auto;
- }
-
- .status_list_acct {
- color: green;
- width: auto;
- padding-left: 1;
- }
-
- .status_list_flags {
- width: 2;
- padding-left: 1;
- }
- """
-
- def __init__(self, status: Status):
- super().__init__(classes="status_list_item")
- self.status = status
-
- def compose(self):
- ctx = get_context()
- status = self.status
- original = status.original
-
- flags = " "
- if status.reblog:
- flags = "R"
-
- timestamp = format_datetime(status.created_at)
- acct = original.account.acct
- acct = acct if "@" in acct else f"{acct}@{ctx.auth.domain}"
-
- yield Label(timestamp, classes="status_list_timestamp")
- yield Label(flags, classes="status_list_flags")
- yield Label(acct, classes="status_list_acct")
--
2.43.0