~ihabunek/toot-discuss

This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch
1

[PATCH tooi v2] Add notification tab (#6)

Lexi Winter <lexi@le-Fay.ORG>
Details
Message ID
<20240109121940.80845-1-lexi@le-Fay.ORG>
DKIM signature
missing
Download raw message
Patch: +640 -328
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
Details
Message ID
<b660bac7-27f5-45f7-a52d-c0dbe42be311@app.fastmail.com>
In-Reply-To
<20240109121940.80845-1-lexi@le-Fay.ORG> (view parent)
DKIM signature
missing
Download raw message
Thanks!

-- Ivan
Reply to thread Export thread (mbox)