diff --git a/src/archive_reader/app.py b/src/archive_reader/app.py index 11874ef..f5cda75 100644 --- a/src/archive_reader/app.py +++ b/src/archive_reader/app.py @@ -5,30 +5,21 @@ from textual import log, work from textual._node_list import DuplicateIds from textual.app import App, ComposeResult -from textual.containers import Horizontal, Vertical +from textual.containers import Vertical from textual.css.query import NoMatches - from textual.reactive import var -from textual.screen import Screen from textual.widgets import ( Footer, - Input, - ListItem, ListView, LoadingIndicator, - SelectionList, - Static, ) - from .models import initialize_database from .widgets import ( Threads, MailingListItem, - MailingListChoose, MailingLists, ThreadItem, Header, - EmailItem, ) from .core import ListManager, ThreadsManager from .screens import ThreadReadScreen, MailingListAddScreen @@ -46,6 +37,7 @@ class ArchiveApp(App): ('s', 'app.screenshot()', 'Screenshot'), ('q', 'quit', 'Quit'), ('u', 'load_new_threads', 'Update threads'), + ('m', 'more_threads', 'More threads'), ] TITLE = 'Archive Reader' SUB_TITLE = 'An app to reach Hyperkitty archives in Terminal!' @@ -57,6 +49,7 @@ def __init__(self, *args, **kw): self._existing_threads = defaultdict(dict) self.list_manager = ListManager() self.thread_mgrs = {} + self.thread_page = defaultdict(int) def watch_show_tree(self, show_tree: bool) -> None: """Called when show_tree is modified.""" @@ -150,7 +143,7 @@ def thread_mgr(self): async def update_threads(self, ml): header = self.query_one('#header', Header) header.text = '{} ({})'.format(ml.display_name, ml.name) - await self._clear_threads() + # await self._clear_threads() self._show_loading() self.current_mailinglist = ml mgr = self.thread_mgr() @@ -162,11 +155,12 @@ async def update_threads(self, ml): self._notify_update_complete() @work() - async def action_load_new_threads(self): + async def action_load_new_threads(self, offset=0): ml = self.current_mailinglist mgr = self.thread_mgr() - await mgr.update_threads() - self.update_threads(ml) + new_threads = await mgr.update_threads(offset=offset) + for thread in new_threads: + await self._set_thread(thread) async def on_list_view_selected(self, item): # Handle the list item selected for MailingList. @@ -188,6 +182,17 @@ async def on_list_view_selected(self, item): ) ) + async def action_more_threads(self): + """Load more threads for the Current Mailinglist.""" + self.thread_page[self.current_mailinglist.name] += 1 + threads_container = self.query_one('#threads', ListView) + self.action_load_new_threads( + offset=len(threads_container.children), + ) + self.notify( + f'Loading more threads for {self.current_mailinglist.name}.' + ) + # @work # async def on_thread_updated(self, item): # self._existing_threads[self.current_mailinglist.name][ diff --git a/src/archive_reader/archiver.css b/src/archive_reader/archiver.css index ed862ce..601980d 100644 --- a/src/archive_reader/archiver.css +++ b/src/archive_reader/archiver.css @@ -51,4 +51,36 @@ Screen { } .sender { padding: 0 1; +} + +ThreadItem { + height: 3; + width: 1fr; + layout: grid; + grid-size: 3; + grid-columns: 14fr 1fr 2fr; + content-align: left middle; + padding: 1 1; +} + +.read { + background: gray; +} + +EmailItem { + width: 1fr; + margin: 1 1; + height: auto; + padding: 2; +} +Label { + padding: 1 2; +} + +ListView > ListItem.--highlight { + background: $secondary-background-lighten-3 20%; +} + +ListView:focus > ListItem.--highlight { + background: $secondary-background-lighten-3 50%; } \ No newline at end of file diff --git a/src/archive_reader/core.py b/src/archive_reader/core.py index 17dfe16..c185736 100644 --- a/src/archive_reader/core.py +++ b/src/archive_reader/core.py @@ -34,8 +34,8 @@ async def threads(self): """ return await self._load_threads_from_db() - async def update_threads(self): - return await self._fetch_threads() + async def update_threads(self, offset): + return await self._fetch_threads(offset) async def emails(self, thread): """Return all the Emails for a give Thread.""" @@ -75,12 +75,14 @@ async def _load_threads_from_db(self): """Load all the existing threads from the db.""" return await Thread.objects.filter(mailinglist=self.ml.url).all() - async def _fetch_threads(self, page: int = 1): + async def _fetch_threads(self, offset: int = 0): """Fetch threads from the remote server. :param page: The page no. to fetch. """ - threads = await hyperktty_client.threads(self.ml.threads) + threads = await hyperktty_client.threads( + self.ml.threads, offset=offset + ) thread_objs = [] for thread in threads.get('results'): obj = await Thread.objects.update_or_create( diff --git a/src/archive_reader/hyperkitty.py b/src/archive_reader/hyperkitty.py index 38a98fa..9b062e1 100644 --- a/src/archive_reader/hyperkitty.py +++ b/src/archive_reader/hyperkitty.py @@ -10,6 +10,9 @@ ] +DEFAULT_PAGINATION_COUNT = 25 + + class HyperkittyAPI: """Hyperkitty is a client for Hyperkitty. It returns objects that can be used in the UI elements. @@ -28,12 +31,18 @@ async def lists(self, base_url): url = f'{base_url}/api/lists?format=json' return await self._call(url, MailingListPage) - async def threads(self, threads_url): + async def threads( + self, threads_url, offset=1, limit=DEFAULT_PAGINATION_COUNT + ): """Given a ML object, return the threads for that Mailinglist.""" - return await self._call(threads_url, ThreadsPage) + return await self._call( + f'{threads_url}&limit={limit}&offset={offset}', ThreadsPage + ) - async def emails(self, thread): - return await self._call(thread.get('emails'), EmailsPage) + async def emails(self, thread, page=1, count=DEFAULT_PAGINATION_COUNT): + return await self._call( + f'{thread.emails}&page={page}&count={count}', EmailsPage + ) async def fetch_urls(urls, logger=None): diff --git a/src/archive_reader/widgets.py b/src/archive_reader/widgets.py index 2ff3139..ec5fa67 100644 --- a/src/archive_reader/widgets.py +++ b/src/archive_reader/widgets.py @@ -1,11 +1,5 @@ -from collections import defaultdict -from datetime import datetime -from zoneinfo import ZoneInfo -from textual.app import ComposeResult - -import timeago from rich.console import RenderableType -from textual import events, log +from textual import events from textual.containers import ScrollableContainer from textual.message import Message from textual.reactive import reactive @@ -83,6 +77,12 @@ def compose(self): ) def on_button_pressed(self, event: Button.Pressed): + """Handle MailingList selected event. + + This will simply post a MailingListChoose.Selected message, which will + be then handled by the handler in the ArchiveApp() to subscribe the + lists chosen by the user. + """ if event.button.id == 'select_mailinglist': self.post_message( self.Selected(self.query_one(SelectionList).selected) @@ -91,37 +91,26 @@ def on_button_pressed(self, event: Button.Pressed): class ThreadReplies(Widget): + """Represents total no. of messages in the Threads.""" + #: Represents if this thread has new messages since it was + #: last opened. Currently this is un-used. has_new = reactive(0) + #: Total no of replies in the thread. count = reactive(0) - def __init__(self, count, has_new, *args, **kw): + def __init__(self, count, has_new=0, *args, **kw): super().__init__(*args, **kw) self.count = count self.has_new = has_new def render(self): - return f':speech_balloon: {self.count} ({self.has_new})' + return f':speech_balloon: {self.count}' class ThreadItem(ListItem): """Represents a thread on the Main screen.""" - DEFAULT_CSS = """ - ThreadItem { - height: 3; - width: 1fr; - layout: grid; - grid-size: 3; - grid-columns: 14fr 1fr 2fr; - content-align: left middle; - padding: 1 1; - } - - .read { - background: gray; - } - """ #: Represents whether this thread has been opened in the current #: reader before. This is computed locally and turned to 'read' #: as soon as the thread is opened. @@ -145,6 +134,10 @@ def __init__(self, thread): super().__init__() class Updated(Message): + """Represents an updated Event for the thread. This is sent + out so that any handlers that exist can refresh the view. + """ + def __init__(self, thread_data): self.data = thread_data super().__init__() @@ -152,14 +145,12 @@ def __init__(self, thread_data): def __init__(self, *args, thread=None, mailinglist=None, **kw) -> None: super().__init__(*args, **kw) self.mailinglist = mailinglist - # self.is_new = thread_data.get('is_new', False) - # self.has_new = thread_data.get('has_new', 0) - # self.read = thread_data.get('read', False) self.thread = thread if self.read: self.add_class('read') def get(self, attr): + """Get the attribute of the owned Thread object.""" return getattr(self.thread, attr) @property @@ -167,50 +158,16 @@ def subject(self): return self.thread.subject def time_format(self): + """Return thread's active_time formatted properly to show in UI.""" return self.thread.date_active - # def watch_read(self, old, new): - # if old is False and new is True: - # self.add_class('read') - # # Regardless of the current value, just turn these two off since - # # they are not required anymore. - # self.is_new = False - # self.has_new = 0 - # self.data['is_new'] = False - # self.data['has_new'] = 0 - # self.data['read'] = True - # self.read = True - # log(f'Sending ThreadUpdated for {self}') - # self._notify_updated() - # self._save_read_status(new) - def _notify_updated(self): + """Sends out Message for thread's updated event.""" self.post_message(self.Updated(self.thread)) - def _save_read_status(self, new): - # Update the thread.read() status in the storage. - # XXX(abraj): This is a relatively complex operation. The reason - # for which is the fact that we are using a caching solution as a - # trivial json database in a way that doesn't provide tons of data - # access patterns that we want to have. - # This can be solved easily with a local Sqlite database in future, - # infact, the current caching solution utilizes sqlite underneath. - pass - - # def watch_has_new(self, _, new): - # # It is possible that this is b - # try: - # self.query_one(ThreadReplies).has_new = new - # except Exception: - # log(f'Failed to Find & Update thread replies') - def compose(self): yield Static(self.subject) - yield ThreadReplies( - count=self.thread.replies_count, has_new=self.has_new - ) - # now = datetime.now(tz=ZoneInfo('Asia/Kolkata')) - thread_date = self.time_format() + yield ThreadReplies(count=self.thread.replies_count) yield Static(':two-thirty: {}'.format(self.get('date_active'))) async def _on_click(self, _: events.Click) -> None: @@ -261,24 +218,6 @@ class EmailItem(ListItem): of the instance. You can get the values of those using the `.get()` method. """ - DEFAULT_CSS = """ - Email { - width: 1fr; - margin: 1 1; - height: auto; - padding: 2; - } - Label { - padding: 1 2; - } - ListView > ListItem.--highlight { - background: $secondary-background-lighten-3 20%; - } - ListView:focus > ListItem.--highlight { - background: $secondary-background-lighten-3 50%; - } - """ - def __init__(self, *args, email=None, **kw): super().__init__(*args, **kw) self.email = email