diff --git a/context.md b/context.md index 02fd97f7..da05a7f7 100644 --- a/context.md +++ b/context.md @@ -12,6 +12,7 @@ Se está siguiendo `falta.md` por orden. Los puntos 1 a 8 y 10-11 están marcado - Autocompletado añadido al diálogo de acciones de usuario. - Atajos de teclado (&) añadidos a botones del perfil. - Persistencia de búsquedas implementada (se guardan y restauran al reiniciar). +- Paginación completa en todos los buffers: HomeTimeline, FollowingTimeline, NotificationBuffer, LikesBuffer, MentionsBuffer, SentBuffer, UserTimeline, SearchBuffer. ## Cambios anteriores - Activado autocompletado en el diálogo "Ver timeline..." y validación de usuario. @@ -21,11 +22,13 @@ Se está siguiendo `falta.md` por orden. Los puntos 1 a 8 y 10-11 están marcado - Menús: para Bluesky, las opciones no aplicables se ocultan usando el sentinel "HIDE". ## Puntos pendientes -- 9) Paginación en timelines principales (home, notifications, user timelines, search) - parcial. +Ninguno. Todos los puntos de falta.md están completados. ## Notas técnicas - `update_menus` en `src/controller/mainController.py` interpreta `"HIDE"` para ocultar entradas. - Buffers de Reposts/Likes usan `PostUserListBuffer` con cursor para paginación. - Las búsquedas ahora se guardan en `session.settings["other_buffers"]["searches"]`. - Perfil de usuario descarga imágenes en thread separado para no bloquear UI. +- Paginación usa patrón: `self.next_cursor` guardado en `start_stream()`, usado en `get_more_items()`. +- El menú "load_previous_items" activa `get_more_items()` en el buffer actual. diff --git a/falta.md b/falta.md index f5fc353a..fe352994 100644 --- a/falta.md +++ b/falta.md @@ -51,7 +51,7 @@ Hecho. La terminología es consistente: Bluesky usa "repost/like" (nativo AT Pro ## 9) Paginación en listados - Bluesky: implementada en Reposts/Likes y Followers/Following. - Faltan otros listados equivalentes (por ejemplo, búsquedas de usuarios si se implementan). -Parcial. Paginación implementada en buffers de usuarios (followers/following/likes/reposts). Pendiente en timelines principales. +Hecho. Paginación implementada en todos los buffers: HomeTimeline, FollowingTimeline, NotificationBuffer, LikesBuffer, MentionsBuffer, SentBuffer, UserTimeline, SearchBuffer, FollowersBuffer, FollowingBuffer, PostUserListBuffer. ## 10) Accesibilidad/teclado - Verificar atajos en todos los nuevos diálogos/buffers. diff --git a/src/controller/buffers/blueski/timeline.py b/src/controller/buffers/blueski/timeline.py index 53bae56e..7b88667a 100644 --- a/src/controller/buffers/blueski/timeline.py +++ b/src/controller/buffers/blueski/timeline.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import logging +import output from .base import BaseBuffer from wxUI.buffers.blueski import panels as BlueskiPanels from pubsub import pub @@ -11,7 +12,8 @@ class HomeTimeline(BaseBuffer): super(HomeTimeline, self).__init__(*args, **kwargs) self.type = "home_timeline" self.feed_uri = None - + self.next_cursor = None + def create_buffer(self, parent, name): # Override to use HomePanel self.buffer = BlueskiPanels.HomePanel(parent, name) @@ -22,13 +24,13 @@ class HomeTimeline(BaseBuffer): try: count = self.session.settings["general"].get("max_posts_per_call", 50) except: pass - + api = self.session._ensure_client() - + # Discover Logic if not self.feed_uri: self.feed_uri = self._resolve_discover_feed(api) - + items = [] try: res = None @@ -38,16 +40,40 @@ class HomeTimeline(BaseBuffer): else: # Fallback to standard timeline res = api.app.bsky.feed.get_timeline({"limit": count}) - + feed = getattr(res, "feed", []) items = list(feed) - + self.next_cursor = getattr(res, "cursor", None) + except Exception: log.exception("Failed to fetch home timeline") return 0 - + return self.process_items(items, play_sound) + def get_more_items(self): + if not self.next_cursor: + return + count = 50 + try: + count = self.session.settings["general"].get("max_posts_per_call", 50) + except: + pass + api = self.session._ensure_client() + try: + if self.feed_uri: + res = api.app.bsky.feed.get_feed({"feed": self.feed_uri, "limit": count, "cursor": self.next_cursor}) + else: + res = api.app.bsky.feed.get_timeline({"limit": count, "cursor": self.next_cursor}) + feed = getattr(res, "feed", []) + items = list(feed) + self.next_cursor = getattr(res, "cursor", None) + added = self.process_items(items, play_sound=False) + if added: + output.speak(_(u"%s items retrieved") % (str(added)), True) + except Exception: + log.exception("Error fetching more home timeline items") + def _resolve_discover_feed(self, api): # Reuse logic from panels.py try: @@ -76,7 +102,8 @@ class FollowingTimeline(BaseBuffer): def __init__(self, *args, **kwargs): super(FollowingTimeline, self).__init__(*args, **kwargs) self.type = "following_timeline" - + self.next_cursor = None + def create_buffer(self, parent, name): self.buffer = BlueskiPanels.HomePanel(parent, name) # Reuse HomePanel layout self.buffer.session = self.session @@ -85,19 +112,40 @@ class FollowingTimeline(BaseBuffer): count = 50 try: count = self.session.settings["general"].get("max_posts_per_call", 50) except: pass - + api = self.session._ensure_client() try: # Force reverse-chronological res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": "reverse-chronological"}) feed = getattr(res, "feed", []) items = list(feed) + self.next_cursor = getattr(res, "cursor", None) except Exception: log.exception("Error fetching following timeline") return 0 - + return self.process_items(items, play_sound) + def get_more_items(self): + if not self.next_cursor: + return + count = 50 + try: + count = self.session.settings["general"].get("max_posts_per_call", 50) + except: + pass + api = self.session._ensure_client() + try: + res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": "reverse-chronological", "cursor": self.next_cursor}) + feed = getattr(res, "feed", []) + items = list(feed) + self.next_cursor = getattr(res, "cursor", None) + added = self.process_items(items, play_sound=False) + if added: + output.speak(_(u"%s items retrieved") % (str(added)), True) + except Exception: + log.exception("Error fetching more following timeline items") + class NotificationBuffer(BaseBuffer): def __init__(self, *args, **kwargs): # Override compose_func before calling super().__init__ @@ -105,6 +153,7 @@ class NotificationBuffer(BaseBuffer): super(NotificationBuffer, self).__init__(*args, **kwargs) self.type = "notifications" self.sound = "notification_received.ogg" + self.next_cursor = None def create_buffer(self, parent, name): self.buffer = BlueskiPanels.NotificationPanel(parent, name) @@ -124,6 +173,7 @@ class NotificationBuffer(BaseBuffer): try: res = api.app.bsky.notification.list_notifications({"limit": count}) notifications = getattr(res, "notifications", []) + self.next_cursor = getattr(res, "cursor", None) if not notifications: return 0 @@ -134,6 +184,27 @@ class NotificationBuffer(BaseBuffer): log.exception("Error fetching Bluesky notifications") return 0 + def get_more_items(self): + if not self.next_cursor: + return + count = 50 + try: + count = self.session.settings["general"].get("max_posts_per_call", 50) + except: + pass + api = self.session._ensure_client() + if not api: + return + try: + res = api.app.bsky.notification.list_notifications({"limit": count, "cursor": self.next_cursor}) + notifications = getattr(res, "notifications", []) + self.next_cursor = getattr(res, "cursor", None) + added = self.process_items(list(notifications), play_sound=False) + if added: + output.speak(_(u"%s items retrieved") % (str(added)), True) + except Exception: + log.exception("Error fetching more notifications") + def add_new_item(self, notification): """Add a single new notification from streaming/polling.""" return self.process_items([notification], play_sound=True) @@ -207,6 +278,7 @@ class LikesBuffer(BaseBuffer): def __init__(self, *args, **kwargs): super(LikesBuffer, self).__init__(*args, **kwargs) self.type = "likes" + self.next_cursor = None def create_buffer(self, parent, name): self.buffer = BlueskiPanels.HomePanel(parent, name) @@ -223,12 +295,34 @@ class LikesBuffer(BaseBuffer): try: res = api.app.bsky.feed.get_actor_likes({"actor": api.me.did, "limit": count}) items = getattr(res, "feed", None) or getattr(res, "items", None) or [] + self.next_cursor = getattr(res, "cursor", None) except Exception: log.exception("Error fetching likes") return 0 return self.process_items(list(items), play_sound) + def get_more_items(self): + if not self.next_cursor: + return + count = 50 + try: + count = self.session.settings["general"].get("max_posts_per_call", 50) + except: + pass + api = self.session._ensure_client() + if not api: + return + try: + res = api.app.bsky.feed.get_actor_likes({"actor": api.me.did, "limit": count, "cursor": self.next_cursor}) + items = getattr(res, "feed", None) or getattr(res, "items", None) or [] + self.next_cursor = getattr(res, "cursor", None) + added = self.process_items(list(items), play_sound=False) + if added: + output.speak(_(u"%s items retrieved") % (str(added)), True) + except Exception: + log.exception("Error fetching more likes") + class MentionsBuffer(BaseBuffer): """Buffer for mentions and replies to the current user.""" @@ -239,6 +333,7 @@ class MentionsBuffer(BaseBuffer): super(MentionsBuffer, self).__init__(*args, **kwargs) self.type = "mentions" self.sound = "mention_received.ogg" + self.next_cursor = None def create_buffer(self, parent, name): self.buffer = BlueskiPanels.NotificationPanel(parent, name) @@ -258,6 +353,7 @@ class MentionsBuffer(BaseBuffer): try: res = api.app.bsky.notification.list_notifications({"limit": count}) notifications = getattr(res, "notifications", []) + self.next_cursor = getattr(res, "cursor", None) if not notifications: return 0 @@ -276,6 +372,33 @@ class MentionsBuffer(BaseBuffer): log.exception("Error fetching Bluesky mentions") return 0 + def get_more_items(self): + if not self.next_cursor: + return + count = 50 + try: + count = self.session.settings["general"].get("max_posts_per_call", 50) + except: + pass + api = self.session._ensure_client() + if not api: + return + try: + res = api.app.bsky.notification.list_notifications({"limit": count, "cursor": self.next_cursor}) + notifications = getattr(res, "notifications", []) + self.next_cursor = getattr(res, "cursor", None) + # Filter only mentions and replies + mentions = [ + n for n in notifications + if getattr(n, "reason", "") in ("mention", "reply", "quote") + ] + if mentions: + added = self.process_items(mentions, play_sound=False) + if added: + output.speak(_(u"%s items retrieved") % (str(added)), True) + except Exception: + log.exception("Error fetching more mentions") + def add_new_item(self, notification): """Add a single new mention from streaming/polling.""" reason = getattr(notification, "reason", "") @@ -290,6 +413,7 @@ class SentBuffer(BaseBuffer): def __init__(self, *args, **kwargs): super(SentBuffer, self).__init__(*args, **kwargs) self.type = "sent" + self.next_cursor = None def create_buffer(self, parent, name): self.buffer = BlueskiPanels.HomePanel(parent, name) @@ -314,6 +438,7 @@ class SentBuffer(BaseBuffer): "filter": "posts_no_replies" }) items = getattr(res, "feed", []) + self.next_cursor = getattr(res, "cursor", None) if not items: return 0 @@ -324,6 +449,32 @@ class SentBuffer(BaseBuffer): log.exception("Error fetching sent posts") return 0 + def get_more_items(self): + if not self.next_cursor: + return + count = 50 + try: + count = self.session.settings["general"].get("max_posts_per_call", 50) + except: + pass + api = self.session._ensure_client() + if not api or not api.me: + return + try: + res = api.app.bsky.feed.get_author_feed({ + "actor": api.me.did, + "limit": count, + "filter": "posts_no_replies", + "cursor": self.next_cursor + }) + items = getattr(res, "feed", []) + self.next_cursor = getattr(res, "cursor", None) + added = self.process_items(list(items), play_sound=False) + if added: + output.speak(_(u"%s items retrieved") % (str(added)), True) + except Exception: + log.exception("Error fetching more sent posts") + class UserTimeline(BaseBuffer): """Buffer for posts by a specific user.""" @@ -333,6 +484,8 @@ class UserTimeline(BaseBuffer): self.handle = kwargs.get("handle") super(UserTimeline, self).__init__(*args, **kwargs) self.type = "user_timeline" + self.next_cursor = None + self._resolved_actor = None def create_buffer(self, parent, name): self.buffer = BlueskiPanels.HomePanel(parent, name) @@ -372,17 +525,44 @@ class UserTimeline(BaseBuffer): actor = did except Exception: pass + self._resolved_actor = actor res = api.app.bsky.feed.get_author_feed({ "actor": actor, "limit": count, }) items = getattr(res, "feed", []) or [] + self.next_cursor = getattr(res, "cursor", None) except Exception: log.exception("Error fetching user timeline") return 0 return self.process_items(list(items), play_sound) + def get_more_items(self): + if not self.next_cursor or not self._resolved_actor: + return + count = 50 + try: + count = self.session.settings["general"].get("max_posts_per_call", 50) + except: + pass + api = self.session._ensure_client() + if not api: + return + try: + res = api.app.bsky.feed.get_author_feed({ + "actor": self._resolved_actor, + "limit": count, + "cursor": self.next_cursor + }) + items = getattr(res, "feed", []) or [] + self.next_cursor = getattr(res, "cursor", None) + added = self.process_items(list(items), play_sound=False) + if added: + output.speak(_(u"%s items retrieved") % (str(added)), True) + except Exception: + log.exception("Error fetching more user timeline items") + def remove_buffer(self, force=False): if not force: from wxUI import commonMessageDialogs @@ -419,6 +599,7 @@ class SearchBuffer(BaseBuffer): self.search_query = kwargs.pop("query", "") super(SearchBuffer, self).__init__(*args, **kwargs) self.type = "search" + self.next_cursor = None def create_buffer(self, parent, name): self.buffer = BlueskiPanels.HomePanel(parent, name) @@ -445,6 +626,7 @@ class SearchBuffer(BaseBuffer): "limit": count }) posts = getattr(res, "posts", []) + self.next_cursor = getattr(res, "cursor", None) if not posts: return 0 @@ -459,6 +641,31 @@ class SearchBuffer(BaseBuffer): log.exception("Error searching Bluesky posts") return 0 + def get_more_items(self): + if not self.next_cursor or not self.search_query: + return + count = 50 + try: + count = self.session.settings["general"].get("max_posts_per_call", 50) + except: + pass + api = self.session._ensure_client() + if not api: + return + try: + res = api.app.bsky.feed.search_posts({ + "q": self.search_query, + "limit": count, + "cursor": self.next_cursor + }) + posts = getattr(res, "posts", []) + self.next_cursor = getattr(res, "cursor", None) + added = self.process_items(list(posts), play_sound=False) + if added: + output.speak(_(u"%s items retrieved") % (str(added)), True) + except Exception: + log.exception("Error fetching more search results") + def remove_buffer(self, force=False): """Search buffers can always be removed.""" if not force: @@ -471,4 +678,16 @@ class SearchBuffer(BaseBuffer): self.session.db.pop(self.name, None) except Exception: pass + # Also remove from saved searches + try: + searches = self.session.settings["other_buffers"].get("searches") + if searches: + if isinstance(searches, str): + searches = [s for s in searches.split(",") if s] + if self.search_query in searches: + searches.remove(self.search_query) + self.session.settings["other_buffers"]["searches"] = searches + self.session.settings.write() + except Exception: + log.exception("Error updating saved searches") return True