diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 92b51475..8b6fcc0c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,8 @@ "Bash(ls:*)", "Bash(dir:*)", "Bash(findstr:*)", - "Bash(find:*)" + "Bash(find:*)", + "Bash(python:*)" ] } } diff --git a/context.md b/context.md index da05a7f7..f4a3b40f 100644 --- a/context.md +++ b/context.md @@ -4,17 +4,34 @@ Igualar la experiencia de Bluesky con Mastodon en la interfaz (menús, diálogos, buffers y accesos), manteniendo las diferencias sólo cuando el protocolo lo exige. Mastodon es la referencia. ## Estado actual -Se está siguiendo `falta.md` por orden. Los puntos 1 a 8 y 10-11 están marcados como "Hecho". Punto 9 parcialmente completado. +Se completaron todos los puntos de falta.md. Ahora se está trabajando en igualar características de accesibilidad comparando con el código original de Mastodon en srcantiguo/. ## Cambios recientes (sesión actual) +- **Accesibilidad mejorada en Bluesky:** + - Implementado método `onFocus()` para: actualizar tiempos relativos, leer posts largos en GUI, reproducir sonidos indicadores de audio/imagen. + - Implementado método `auto_read()` para lectura automática de nuevos items. + - Implementado menú contextual (`show_menu()`, `show_menu_by_key()`). + - Añadido método `open_in_browser()` para abrir posts en navegador. + - Añadido método `add_new_item()` para streaming. + - Añadido método `update_item()` para actualizar items existentes. + - Añadido método `get_buffer_name()` para nombres de buffer legibles. + - Añadido método `copy()` para copiar al portapapeles. + +- **Nuevos archivos creados:** + - `src/sessions/blueski/utils.py` - Funciones utilitarias (is_audio_or_video, is_image, get_media_urls, find_urls). + - `src/wxUI/dialogs/blueski/menus.py` - Menús contextuales (baseMenu, notificationMenu, userMenu, chatMenu). + +- **Correcciones de sonido:** + - Arreglado bug en base.py donde `self.sound = sound` usaba variable indefinida. + - Añadido `self.sound` a todos los buffers (Conversation, chat, user, etc.). + +## Cambios anteriores - Perfil de usuario mejorado: imágenes de avatar/banner, botones de timeline (posts, followers, following). - Acciones de usuario en perfil: follow, unfollow, mute, unmute, block, unblock. - 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. - Reposts/Likes ahora abren buffers con paginación bajo "Timelines". - Restauración de followers/following propios sin duplicar. @@ -22,9 +39,16 @@ 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 -Ninguno. Todos los puntos de falta.md están completados. +- Verificar funcionamiento completo de onFocus con la aplicación en ejecución. +- Implementar soporte de templates para usuarios y notificaciones (como Mastodon). +- Considerar OCR para imágenes si es necesario. ## Notas técnicas +- `onFocus()` se conecta via `self.buffer.set_focus_function(self.onFocus)` en bind_events(). +- `auto_read()` se llama desde `process_items()` automáticamente si hay nuevos items. +- Menú contextual aparece con clic derecho o tecla de menú (WXK_WINDOWS_MENU). +- `utils.is_audio_or_video()` y `utils.is_image()` detectan multimedia en posts de Bluesky. +- Los sonidos indicadores (`indicate_audio`, `indicate_img`) ya están en blueski.defaults. - `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"]`. diff --git a/src/controller/buffers/blueski/base.py b/src/controller/buffers/blueski/base.py index 24b934bd..a7cdd075 100644 --- a/src/controller/buffers/blueski/base.py +++ b/src/controller/buffers/blueski/base.py @@ -1,16 +1,19 @@ # -*- coding: utf-8 -*- import logging import wx +import arrow import output import sound import config import widgetUtils +import languageHandler from pubsub import pub from controller.buffers.base import base from controller.blueski import messages as blueski_messages -from sessions.blueski import compose +from sessions.blueski import compose, utils from wxUI.buffers.blueski import panels as BlueskiPanels from wxUI import commonMessageDialogs +from wxUI.dialogs.blueski import menus log = logging.getLogger("controller.buffers.blueski.base") @@ -47,8 +50,12 @@ class BaseBuffer(base.Buffer): def bind_events(self): # Bind essential events + log.debug("Binding events for buffer %s" % self.name) + self.buffer.set_focus_function(self.onFocus) widgetUtils.connect_event(self.buffer.list.list, widgetUtils.KEYPRESS, self.get_event) - + widgetUtils.connect_event(self.buffer.list.list, wx.EVT_LIST_ITEM_RIGHT_CLICK, self.show_menu) + widgetUtils.connect_event(self.buffer.list.list, wx.EVT_LIST_KEY_DOWN, self.show_menu_by_key) + # Buttons if hasattr(self.buffer, "post"): self.buffer.post.Bind(wx.EVT_BUTTON, self.on_post) @@ -62,7 +69,121 @@ class BaseBuffer(base.Buffer): self.buffer.dm.Bind(wx.EVT_BUTTON, self.on_dm) if hasattr(self.buffer, "actions"): self.buffer.actions.Bind(wx.EVT_BUTTON, self.user_actions) - + + def get_buffer_name(self): + """Get human-readable buffer name.""" + basic_buffers = dict( + home_timeline=_("Home"), + notifications=_("Notifications"), + mentions=_("Mentions"), + sent=_("Sent"), + likes=_("Likes"), + chats=_("Chats"), + ) + if self.name in basic_buffers: + return basic_buffers[self.name] + if hasattr(self, "username"): + if "timeline" in self.name.lower(): + return _("{username}'s timeline").format(username=self.username) + if "followers" in self.name.lower(): + return _("{username}'s followers").format(username=self.username) + if "following" in self.name.lower(): + return _("{username}'s following").format(username=self.username) + return self.name + + def onFocus(self, *args, **kwargs): + """Handle focus event for accessibility features.""" + post = self.get_item() + if not post: + return + + # Update relative time display + if self.session.settings["general"].get("relative_times", False): + try: + index = self.buffer.list.get_selected() + if index < 0: + return + + def g(obj, key, default=None): + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + actual_post = g(post, "post", post) + indexed_at = g(actual_post, "indexed_at", "") or g(actual_post, "indexedAt", "") + if indexed_at: + original_date = arrow.get(indexed_at) + ts = original_date.humanize(locale=languageHandler.curLang[:2]) + self.buffer.list.list.SetItem(index, 2, ts) + except Exception: + log.exception("Error updating relative time on focus") + + # Read long posts in GUI + if config.app["app-settings"].get("read_long_posts_in_gui", False) and self.buffer.list.list.HasFocus(): + wx.CallLater(40, output.speak, self.get_message(), interrupt=True) + + # Audio/video indicator sound + if self.session.settings["sound"].get("indicate_audio", False) and utils.is_audio_or_video(post): + self.session.sound.play("audio.ogg") + + # Image indicator sound + if self.session.settings["sound"].get("indicate_img", False) and utils.is_image(post): + self.session.sound.play("image.ogg") + + def auto_read(self, number_of_items): + """Automatically read new items for accessibility.""" + if number_of_items == 0: + return + if self.name in self.session.settings["other_buffers"].get("muted_buffers", []): + return + if self.session.settings["sound"].get("session_mute", False): + return + if self.name not in self.session.settings["other_buffers"].get("autoread_buffers", []): + return + + safe = True + relative_times = self.session.settings["general"].get("relative_times", False) + show_screen_names = self.session.settings["general"].get("show_screen_names", False) + + if number_of_items == 1: + if self.session.settings["general"].get("reverse_timelines", False): + post = self.session.db[self.name][0] + else: + post = self.session.db[self.name][-1] + output.speak(_("New post in {0}").format(self.get_buffer_name())) + output.speak(" ".join(self.compose_function(post, self.session.db, self.session.settings, relative_times, show_screen_names, safe=safe))) + elif number_of_items > 1: + output.speak(_("{0} new posts in {1}.").format(number_of_items, self.get_buffer_name())) + + def show_menu(self, ev, pos=0, *args, **kwargs): + """Show context menu for current item.""" + if self.buffer.list.get_count() == 0: + return + menu = menus.baseMenu() + widgetUtils.connect_event(menu, widgetUtils.MENU, self.reply, menuitem=menu.reply) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.repost) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.add_to_favorites, menuitem=menu.like) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.user_actions, menuitem=menu.userActions) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.url_, menuitem=menu.openUrl) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.view, menuitem=menu.view) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.copy, menuitem=menu.copy) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.destroy_status, menuitem=menu.remove) + if pos != 0: + self.buffer.PopupMenu(menu, pos) + else: + self.buffer.PopupMenu(menu, self.buffer.list.list.GetPosition()) + + def show_menu_by_key(self, ev): + """Show context menu when pressing menu key.""" + if self.buffer.list.get_count() == 0: + return + if ev.GetKeyCode() == wx.WXK_WINDOWS_MENU: + self.show_menu(widgetUtils.MENU, pos=self.buffer.list.list.GetPosition()) + + def copy(self, *args, **kwargs): + """Copy post to clipboard.""" + pub.sendMessage("execute-action", action="copy_to_clipboard") + def on_post(self, evt): from wxUI.dialogs.blueski import postDialogs dlg = postDialogs.Post(caption=_("New Post")) @@ -197,7 +318,7 @@ class BaseBuffer(base.Buffer): # Update the item in place (only 3 columns: Author, Post, Date) self.buffer.list.list.SetItem(index, 0, post_data[0]) # Author - self.buffer.list.list.SetItem(index, 1, post_data[1]) # Text (with ♥ indicator) + self.buffer.list.list.SetItem(index, 1, post_data[1]) # Text self.buffer.list.list.SetItem(index, 2, post_data[2]) # Date # Note: compose_post returns 4 items but list only has 3 columns except Exception: @@ -584,7 +705,7 @@ class BaseBuffer(base.Buffer): "handle": g(author, "handle"), } - def process_items(self, items, play_sound=True): + def process_items(self, items, play_sound=True, avoid_autoreading=False): """ Process list of items (FeedViewPost objects), update DB, and update UI. Returns number of new items. @@ -679,9 +800,74 @@ class BaseBuffer(base.Buffer): # Play sound if play_sound and self.sound and not self.session.settings["sound"]["session_mute"]: self.session.sound.play(self.sound) - + + # Auto-read for accessibility + if not avoid_autoreading and len(new_items) > 0: + self.auto_read(len(new_items)) + return len(new_items) + def add_new_item(self, item): + """Add a single new item from streaming.""" + safe = True + relative_times = self.session.settings["general"].get("relative_times", False) + show_screen_names = self.session.settings["general"].get("show_screen_names", False) + post = self.compose_function(item, self.session.db, self.session.settings, relative_times, show_screen_names, safe=safe) + + if self.session.settings["general"].get("reverse_timelines", False): + self.buffer.list.insert_item(True, *post) + self.session.db[self.name].insert(0, item) + else: + self.buffer.list.insert_item(False, *post) + self.session.db[self.name].append(item) + + # Auto-read single item + if self.name in self.session.settings["other_buffers"].get("autoread_buffers", []) and \ + self.name not in self.session.settings["other_buffers"].get("muted_buffers", []) and \ + not self.session.settings["sound"].get("session_mute", False): + output.speak(" ".join(post[:2]), + speech=self.session.settings["reporting"].get("speech_reporting", True), + braille=self.session.settings["reporting"].get("braille_reporting", True)) + + def update_item(self, item, position): + """Update an existing item at the specified position.""" + safe = True + relative_times = self.session.settings["general"].get("relative_times", False) + show_screen_names = self.session.settings["general"].get("show_screen_names", False) + post = self.compose_function(item, self.session.db, self.session.settings, relative_times, show_screen_names, safe=safe) + self.buffer.list.list.SetItem(position, 1, post[1]) + + def open_in_browser(self, *args, **kwargs): + """Open the current post in web browser.""" + item = self.get_item() + if not item: + return + + import webbrowser + + def g(obj, key, default=None): + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + uri = g(item, "uri") or g(g(item, "post"), "uri") + author = g(item, "author") or g(g(item, "post"), "author") + handle = g(author, "handle") + + if uri and handle: + if "app.bsky.feed.post" in uri: + rkey = uri.split("/")[-1] + url = f"https://bsky.app/profile/{handle}/post/{rkey}" + output.speak(_("Opening in browser...")) + webbrowser.open(url) + return + + # Fallback to profile + if handle: + url = f"https://bsky.app/profile/{handle}" + output.speak(_("Opening profile in browser...")) + webbrowser.open(url) + def save_positions(self): try: self.session.db[self.name+"_pos"] = self.buffer.list.get_selected() diff --git a/src/sessions/blueski/compose.py b/src/sessions/blueski/compose.py index ae0035ea..6f6697b3 100644 --- a/src/sessions/blueski/compose.py +++ b/src/sessions/blueski/compose.py @@ -18,17 +18,7 @@ log = logging.getLogger("sessions.blueski.compose") def compose_post(post, db, settings, relative_times, show_screen_names=False, safe=True): """ Compose a Bluesky post into a list of strings for display. - - Args: - post: dict or ATProto model object (FeedViewPost or PostView) - db: Session database dict - settings: Session settings - relative_times: If True, use relative time formatting - show_screen_names: If True, show only @handle instead of display name - safe: If True, handle exceptions gracefully - - Returns: - List of strings: [User, Text, Date, Source] + Format matches Mastodon: [user+", ", text, date+", ", source] """ def g(obj, key, default=None): """Helper to get attribute from dict or object.""" @@ -37,41 +27,58 @@ def compose_post(post, db, settings, relative_times, show_screen_names=False, sa return getattr(obj, key, default) # Resolve Post View or Feed View structure - # Feed items have .post field, direct post objects don't actual_post = g(post, "post", post) - record = g(actual_post, "record", {}) author = g(actual_post, "author", {}) - # Author - handle = g(author, "handle", "") - display_name = g(author, "displayName") or g(author, "display_name") or handle or "Unknown" + # Original author info + original_handle = g(author, "handle", "") + original_display_name = g(author, "displayName") or g(author, "display_name") or original_handle or "Unknown" - if show_screen_names: - user_str = f"@{handle}" - else: - if handle and display_name != handle: - user_str = f"{display_name} (@{handle})" - else: - user_str = f"@{handle}" - - # Text - text = g(record, "text", "") - - # Repost reason + # Check if this is a repost reason = g(post, "reason", None) + is_repost = False + reposter_handle = "" + reposter_display_name = "" + if reason: rtype = g(reason, "$type") or g(reason, "py_type") if rtype and "reasonRepost" in rtype: + is_repost = True by = g(reason, "by", {}) - by_handle = g(by, "handle", "") - reason_line = _("Reposted by @{handle}").format(handle=by_handle) if by_handle else _("Reposted") - text = f"{reason_line}\n{text}" if text else reason_line + reposter_handle = g(by, "handle", "") + reposter_display_name = g(by, "displayName") or g(by, "display_name") or reposter_handle + + # User column: show reposter if repost, otherwise original author (like Mastodon) + if is_repost and reposter_handle: + if show_screen_names: + user_str = f"@{reposter_handle}" + else: + if reposter_display_name and reposter_display_name != reposter_handle: + user_str = f"{reposter_display_name} (@{reposter_handle})" + else: + user_str = f"@{reposter_handle}" + else: + if show_screen_names: + user_str = f"@{original_handle}" + else: + if original_display_name and original_display_name != original_handle: + user_str = f"{original_display_name} (@{original_handle})" + else: + user_str = f"@{original_handle}" + + # Text + original_text = g(record, "text", "") + + # Build text - if repost, format like Mastodon: "Reposted from @original: text" + if is_repost: + text = _("Reposted from @{}: {}").format(original_handle, original_text) + else: + text = original_text # Labels / Content Warning labels = g(actual_post, "labels", []) cw_text = "" - for label in labels: val = g(label, "val", "") if val in ["!warn", "porn", "sexual", "nudity", "gore", "graphic-media", "corpse", "self-harm", "hate", "spam", "impersonation"]: @@ -92,7 +99,7 @@ def compose_post(post, db, settings, relative_times, show_screen_names=False, sa if etype and ("images" in etype): images = g(embed, "images", []) if images: - text += f"\n[{len(images)} {_('Images')}]" + text += f" [{len(images)} {_('images')}]" # Quote posts quote_rec = None @@ -100,13 +107,12 @@ def compose_post(post, db, settings, relative_times, show_screen_names=False, sa rec_embed = g(embed, "record", {}) if rec_embed: quote_rec = g(rec_embed, "record", None) or rec_embed - # Media in wrapper media = g(embed, "media", {}) mtype = g(media, "$type") or g(media, "py_type") if mtype and "images" in mtype: images = g(media, "images", []) if images: - text += f"\n[{len(images)} {_('Images')}]" + text += f" [{len(images)} {_('images')}]" elif etype and ("record" in etype): quote_rec = g(embed, "record", {}) @@ -115,29 +121,28 @@ def compose_post(post, db, settings, relative_times, show_screen_names=False, sa if quote_rec: qtype = g(quote_rec, "$type") or g(quote_rec, "py_type") - if qtype and "viewNotFound" in qtype: - text += f"\n[{_('Quoted post not found')}]" + text += f" [{_('Quoted post not found')}]" elif qtype and "viewBlocked" in qtype: - text += f"\n[{_('Quoted post blocked')}]" + text += f" [{_('Quoted post blocked')}]" elif qtype and "generatorView" in qtype: gen = g(quote_rec, "displayName", "Feed") - text += f"\n[{_('Quoting Feed')}: {gen}]" + text += f" [{_('Quoting Feed')}: {gen}]" else: q_author = g(quote_rec, "author", {}) q_handle = g(q_author, "handle", "unknown") q_val = g(quote_rec, "value", {}) q_text = g(q_val, "text", "") - if q_text: - text += f"\n[{_('Quoting')} @{q_handle}: {q_text}]" + text += " " + _("Quoting @{}: {}").format(q_handle, q_text) else: - text += f"\n[{_('Quoting')} @{q_handle}]" + text += " " + _("Quoting @{}").format(q_handle) elif etype and ("external" in etype): ext = g(embed, "external", {}) title = g(ext, "title", "") - text += f"\n[{_('Link')}: {title}]" + if title: + text += f" [{_('Link')}: {title}]" # Date indexed_at = g(actual_post, "indexed_at", "") or g(actual_post, "indexedAt", "") @@ -154,39 +159,15 @@ def compose_post(post, db, settings, relative_times, show_screen_names=False, sa # Source / Client source = "Bluesky" - - # Viewer state (liked, reposted, etc.) - viewer_indicators = [] - viewer = g(actual_post, "viewer") or g(post, "viewer") - if viewer: - if g(viewer, "like"): - viewer_indicators.append("♥") # Liked - if g(viewer, "repost"): - viewer_indicators.append("🔁") # Reposted - - # Add viewer indicators to the source column or create a prefix for text - if viewer_indicators: - indicator_str = " ".join(viewer_indicators) - # Add to beginning of text for visibility - text = f"{indicator_str} {text}" - return [user_str, text, ts_str, source] + # Format like Mastodon: add ", " after user and date + return [user_str + ", ", text, ts_str + ", ", source] def compose_notification(notification, db, settings, relative_times, show_screen_names=False, safe=True): """ Compose a Bluesky notification into a list of strings for display. - - Args: - notification: ATProto notification object - db: Session database dict - settings: Session settings - relative_times: If True, use relative time formatting - show_screen_names: If True, show only @handle - safe: If True, handle exceptions gracefully - - Returns: - List of strings: [User, Action/Text, Date] + Format matches Mastodon: [user, text, date] """ def g(obj, key, default=None): if isinstance(obj, dict): @@ -201,30 +182,50 @@ def compose_notification(notification, db, settings, relative_times, show_screen if show_screen_names: user_str = f"@{handle}" else: - user_str = f"{display_name} (@{handle})" + if display_name and display_name != handle: + user_str = f"{display_name} (@{handle})" + else: + user_str = f"@{handle}" # Notification reason/type reason = g(notification, "reason", "unknown") - # Map reason to user-readable text - reason_text_map = { - "like": _("liked your post"), - "repost": _("reposted your post"), - "follow": _("followed you"), - "mention": _("mentioned you"), - "reply": _("replied to you"), - "quote": _("quoted your post"), - "starterpack-joined": _("joined your starter pack"), - } - - action_text = reason_text_map.get(reason, reason) - - # For mentions/replies/quotes, include snippet of the text + # Get post text if available record = g(notification, "record", {}) post_text = g(record, "text", "") - if post_text and reason in ["mention", "reply", "quote"]: - snippet = post_text[:100] + "..." if len(post_text) > 100 else post_text - action_text = f"{action_text}: {snippet}" + + # Format like Mastodon: "{username} has action: {status}" + if reason == "like": + if post_text: + text = _("{username} has added to favorites: {status}").format(username=user_str, status=post_text) + else: + text = _("{username} has added to favorites").format(username=user_str) + elif reason == "repost": + if post_text: + text = _("{username} has reposted: {status}").format(username=user_str, status=post_text) + else: + text = _("{username} has reposted").format(username=user_str) + elif reason == "follow": + text = _("{username} has followed you.").format(username=user_str) + elif reason == "mention": + if post_text: + text = _("{username} has mentioned you: {status}").format(username=user_str, status=post_text) + else: + text = _("{username} has mentioned you").format(username=user_str) + elif reason == "reply": + if post_text: + text = _("{username} has replied: {status}").format(username=user_str, status=post_text) + else: + text = _("{username} has replied").format(username=user_str) + elif reason == "quote": + if post_text: + text = _("{username} has quoted your post: {status}").format(username=user_str, status=post_text) + else: + text = _("{username} has quoted your post").format(username=user_str) + elif reason == "starterpack-joined": + text = _("{username} has joined your starter pack.").format(username=user_str) + else: + text = f"{user_str}: {reason}" # Date indexed_at = g(notification, "indexedAt", "") or g(notification, "indexed_at", "") @@ -239,23 +240,13 @@ def compose_notification(notification, db, settings, relative_times, show_screen except Exception: ts_str = str(indexed_at)[:16].replace("T", " ") - return [user_str, action_text, ts_str] + return [user_str, text, ts_str] def compose_user(user, db, settings, relative_times, show_screen_names=False, safe=True): """ Compose a Bluesky user profile for list display. - - Args: - user: User profile dict or ATProto model - db: Session database dict - settings: Session settings - relative_times: If True, use relative time formatting - show_screen_names: If True, show only @handle - safe: If True, handle exceptions gracefully - - Returns: - List of strings: [User summary] + Format matches Mastodon: single string with all info. """ def g(obj, key, default=None): if isinstance(obj, dict): @@ -264,9 +255,9 @@ def compose_user(user, db, settings, relative_times, show_screen_names=False, sa handle = g(user, "handle", "unknown") display_name = g(user, "displayName") or g(user, "display_name") or handle - followers = g(user, "followersCount", None) - following = g(user, "followsCount", None) - posts = g(user, "postsCount", None) + followers = g(user, "followersCount", 0) or 0 + following = g(user, "followsCount", 0) or 0 + posts = g(user, "postsCount", 0) or 0 created_at = g(user, "createdAt", None) ts = "" @@ -279,17 +270,13 @@ def compose_user(user, db, settings, relative_times, show_screen_names=False, sa offset = db.get("utc_offset", 0) if isinstance(db, dict) else 0 ts = original_date.shift(hours=offset).format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2]) except Exception: - ts = str(created_at) + ts = "" - parts = [f"{display_name} (@{handle})."] - if followers is not None and following is not None and posts is not None: - parts.append(_("{followers} followers, {following} following, {posts} posts.").format( - followers=followers, following=following, posts=posts - )) + # Format like Mastodon: "Name (@handle). X followers, Y following, Z posts. Joined date" if ts: - parts.append(_("Joined {date}").format(date=ts)) - - return [" ".join(parts).strip()] + return [_("%s (@%s). %s followers, %s following, %s posts. Joined %s") % (display_name, handle, followers, following, posts, ts)] + else: + return [_("%s (@%s). %s followers, %s following, %s posts.") % (display_name, handle, followers, following, posts)] def compose_convo(convo, db, settings, relative_times, show_screen_names=False, safe=True): diff --git a/src/sessions/blueski/utils.py b/src/sessions/blueski/utils.py new file mode 100644 index 00000000..616d4738 --- /dev/null +++ b/src/sessions/blueski/utils.py @@ -0,0 +1,189 @@ +# -*- coding: utf-8 -*- +""" +Utility functions for Bluesky session. +""" + +import logging + +log = logging.getLogger("sessions.blueski.utils") + + +def g(obj, key, default=None): + """Helper to get attribute from dict or object.""" + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + +def is_audio_or_video(post): + """ + Check if post contains audio or video content. + + Args: + post: Bluesky post object (FeedViewPost or PostView) + + Returns: + bool: True if post has audio/video media + """ + actual_post = g(post, "post", post) + embed = g(actual_post, "embed", None) + if not embed: + return False + + etype = g(embed, "$type") or g(embed, "py_type") + + # Check for video embed + if etype and "video" in etype.lower(): + return True + + # Check for external link that might be video (YouTube, etc.) + if etype and "external" in etype: + ext = g(embed, "external", {}) + uri = g(ext, "uri", "") + # Common video hosting sites + video_hosts = ["youtube.com", "youtu.be", "vimeo.com", "twitch.tv", "dailymotion.com"] + for host in video_hosts: + if host in uri.lower(): + return True + + # Check in recordWithMedia wrapper + if etype and "recordWithMedia" in etype: + media = g(embed, "media", {}) + mtype = g(media, "$type") or g(media, "py_type") + if mtype and "video" in mtype.lower(): + return True + + return False + + +def is_image(post): + """ + Check if post contains image content. + + Args: + post: Bluesky post object (FeedViewPost or PostView) + + Returns: + bool: True if post has image media + """ + actual_post = g(post, "post", post) + embed = g(actual_post, "embed", None) + if not embed: + return False + + etype = g(embed, "$type") or g(embed, "py_type") + + # Direct images embed + if etype and "images" in etype: + images = g(embed, "images", []) + return len(images) > 0 + + # Check in recordWithMedia wrapper + if etype and "recordWithMedia" in etype: + media = g(embed, "media", {}) + mtype = g(media, "$type") or g(media, "py_type") + if mtype and "images" in mtype: + images = g(media, "images", []) + return len(images) > 0 + + return False + + +def get_media_urls(post): + """ + Get URLs for media attachments (video/audio) from post. + + Args: + post: Bluesky post object + + Returns: + list: List of media URLs + """ + urls = [] + actual_post = g(post, "post", post) + embed = g(actual_post, "embed", None) + if not embed: + return urls + + etype = g(embed, "$type") or g(embed, "py_type") + + # Video embed + if etype and "video" in etype.lower(): + playlist = g(embed, "playlist", None) + if playlist: + urls.append(playlist) + # Alternative URL fields + for key in ["url", "uri", "thumb"]: + val = g(embed, key) + if val and val not in urls: + urls.append(val) + + # External links (YouTube, etc.) + if etype and "external" in etype: + ext = g(embed, "external", {}) + uri = g(ext, "uri", "") + if uri: + urls.append(uri) + + return urls + + +def find_urls(post): + """ + Find all URLs in post content. + + Args: + post: Bluesky post object + + Returns: + list: List of URLs found + """ + urls = [] + actual_post = g(post, "post", post) + record = g(actual_post, "record", {}) + + # Check facets for link annotations + facets = g(record, "facets", []) + for facet in facets: + features = g(facet, "features", []) + for feature in features: + ftype = g(feature, "$type") or g(feature, "py_type") + if ftype and "link" in ftype: + uri = g(feature, "uri", "") + if uri and uri not in urls: + urls.append(uri) + + # Check embed for external links + embed = g(actual_post, "embed", None) + if embed: + etype = g(embed, "$type") or g(embed, "py_type") + if etype and "external" in etype: + ext = g(embed, "external", {}) + uri = g(ext, "uri", "") + if uri and uri not in urls: + urls.append(uri) + + return urls + + +def find_item(item, items_list): + """ + Find item index in list by URI. + + Args: + item: Item to find + items_list: List to search + + Returns: + int or None: Index if found, None otherwise + """ + item_uri = g(item, "uri") or g(g(item, "post"), "uri") + if not item_uri: + return None + + for i, existing in enumerate(items_list): + existing_uri = g(existing, "uri") or g(g(existing, "post"), "uri") + if existing_uri == item_uri: + return i + + return None diff --git a/src/wxUI/dialogs/blueski/menus.py b/src/wxUI/dialogs/blueski/menus.py new file mode 100644 index 00000000..109adbda --- /dev/null +++ b/src/wxUI/dialogs/blueski/menus.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +"""Context menus for Bluesky buffers.""" + +import wx + + +class baseMenu(wx.Menu): + """Base context menu for Bluesky posts.""" + + def __init__(self): + super(baseMenu, self).__init__() + self.repost = wx.MenuItem(self, wx.ID_ANY, _("&Repost")) + self.Append(self.repost) + self.quote = wx.MenuItem(self, wx.ID_ANY, _("&Quote")) + self.Append(self.quote) + self.reply = wx.MenuItem(self, wx.ID_ANY, _("Re&ply")) + self.Append(self.reply) + self.like = wx.MenuItem(self, wx.ID_ANY, _("&Like")) + self.Append(self.like) + self.unlike = wx.MenuItem(self, wx.ID_ANY, _("&Unlike")) + self.Append(self.unlike) + self.openUrl = wx.MenuItem(self, wx.ID_ANY, _("&Open URL")) + self.Append(self.openUrl) + self.openInBrowser = wx.MenuItem(self, wx.ID_ANY, _("Open in &browser")) + self.Append(self.openInBrowser) + self.view = wx.MenuItem(self, wx.ID_ANY, _("&Show post")) + self.Append(self.view) + self.copy = wx.MenuItem(self, wx.ID_ANY, _("&Copy to clipboard")) + self.Append(self.copy) + self.remove = wx.MenuItem(self, wx.ID_ANY, _("&Delete")) + self.Append(self.remove) + self.userActions = wx.MenuItem(self, wx.ID_ANY, _("&User actions...")) + self.Append(self.userActions) + + +class notificationMenu(wx.Menu): + """Context menu for Bluesky notifications.""" + + def __init__(self, notification_type="like"): + super(notificationMenu, self).__init__() + # Notification types that have associated posts + post_types = ["like", "repost", "mention", "reply", "quote"] + + if notification_type in post_types: + self.repost = wx.MenuItem(self, wx.ID_ANY, _("&Repost")) + self.Append(self.repost) + self.reply = wx.MenuItem(self, wx.ID_ANY, _("Re&ply")) + self.Append(self.reply) + self.like = wx.MenuItem(self, wx.ID_ANY, _("&Like")) + self.Append(self.like) + self.openUrl = wx.MenuItem(self, wx.ID_ANY, _("&Open URL")) + self.Append(self.openUrl) + + self.openInBrowser = wx.MenuItem(self, wx.ID_ANY, _("Open in &browser")) + self.Append(self.openInBrowser) + self.view = wx.MenuItem(self, wx.ID_ANY, _("&Show post")) + self.Append(self.view) + self.copy = wx.MenuItem(self, wx.ID_ANY, _("&Copy to clipboard")) + self.Append(self.copy) + self.userActions = wx.MenuItem(self, wx.ID_ANY, _("&User actions...")) + self.Append(self.userActions) + + +class userMenu(wx.Menu): + """Context menu for Bluesky user lists.""" + + def __init__(self): + super(userMenu, self).__init__() + self.timeline = wx.MenuItem(self, wx.ID_ANY, _("View &timeline")) + self.Append(self.timeline) + self.followers = wx.MenuItem(self, wx.ID_ANY, _("View f&ollowers")) + self.Append(self.followers) + self.following = wx.MenuItem(self, wx.ID_ANY, _("View &following")) + self.Append(self.following) + self.dm = wx.MenuItem(self, wx.ID_ANY, _("Send &message")) + self.Append(self.dm) + self.view = wx.MenuItem(self, wx.ID_ANY, _("View &profile")) + self.Append(self.view) + self.copy = wx.MenuItem(self, wx.ID_ANY, _("&Copy to clipboard")) + self.Append(self.copy) + self.userActions = wx.MenuItem(self, wx.ID_ANY, _("&User actions...")) + self.Append(self.userActions) + + +class chatMenu(wx.Menu): + """Context menu for Bluesky chat messages.""" + + def __init__(self): + super(chatMenu, self).__init__() + self.reply = wx.MenuItem(self, wx.ID_ANY, _("&Reply")) + self.Append(self.reply) + self.copy = wx.MenuItem(self, wx.ID_ANY, _("&Copy to clipboard")) + self.Append(self.copy) + self.view = wx.MenuItem(self, wx.ID_ANY, _("&Show message")) + self.Append(self.view) + self.userActions = wx.MenuItem(self, wx.ID_ANY, _("&User actions...")) + self.Append(self.userActions)