Terminando de refactorizar

This commit is contained in:
Jesús Pavón Abián
2026-02-01 18:58:38 +01:00
parent 5d4ac82c4d
commit 25ecd8b5fd
6 changed files with 607 additions and 123 deletions

View File

@@ -4,7 +4,8 @@
"Bash(ls:*)", "Bash(ls:*)",
"Bash(dir:*)", "Bash(dir:*)",
"Bash(findstr:*)", "Bash(findstr:*)",
"Bash(find:*)" "Bash(find:*)",
"Bash(python:*)"
] ]
} }
} }

View File

@@ -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. 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 ## 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) ## 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). - 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. - Acciones de usuario en perfil: follow, unfollow, mute, unmute, block, unblock.
- Autocompletado añadido al diálogo de acciones de usuario. - Autocompletado añadido al diálogo de acciones de usuario.
- Atajos de teclado (&) añadidos a botones del perfil. - Atajos de teclado (&) añadidos a botones del perfil.
- Persistencia de búsquedas implementada (se guardan y restauran al reiniciar). - 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. - 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. - Activado autocompletado en el diálogo "Ver timeline..." y validación de usuario.
- Reposts/Likes ahora abren buffers con paginación bajo "Timelines". - Reposts/Likes ahora abren buffers con paginación bajo "Timelines".
- Restauración de followers/following propios sin duplicar. - 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". - Menús: para Bluesky, las opciones no aplicables se ocultan usando el sentinel "HIDE".
## Puntos pendientes ## 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 ## 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. - `update_menus` en `src/controller/mainController.py` interpreta `"HIDE"` para ocultar entradas.
- Buffers de Reposts/Likes usan `PostUserListBuffer` con cursor para paginación. - Buffers de Reposts/Likes usan `PostUserListBuffer` con cursor para paginación.
- Las búsquedas ahora se guardan en `session.settings["other_buffers"]["searches"]`. - Las búsquedas ahora se guardan en `session.settings["other_buffers"]["searches"]`.

View File

@@ -1,16 +1,19 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import logging import logging
import wx import wx
import arrow
import output import output
import sound import sound
import config import config
import widgetUtils import widgetUtils
import languageHandler
from pubsub import pub from pubsub import pub
from controller.buffers.base import base from controller.buffers.base import base
from controller.blueski import messages as blueski_messages 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.buffers.blueski import panels as BlueskiPanels
from wxUI import commonMessageDialogs from wxUI import commonMessageDialogs
from wxUI.dialogs.blueski import menus
log = logging.getLogger("controller.buffers.blueski.base") log = logging.getLogger("controller.buffers.blueski.base")
@@ -47,7 +50,11 @@ class BaseBuffer(base.Buffer):
def bind_events(self): def bind_events(self):
# Bind essential events # 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, 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 # Buttons
if hasattr(self.buffer, "post"): if hasattr(self.buffer, "post"):
@@ -63,6 +70,120 @@ class BaseBuffer(base.Buffer):
if hasattr(self.buffer, "actions"): if hasattr(self.buffer, "actions"):
self.buffer.actions.Bind(wx.EVT_BUTTON, self.user_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): def on_post(self, evt):
from wxUI.dialogs.blueski import postDialogs from wxUI.dialogs.blueski import postDialogs
dlg = postDialogs.Post(caption=_("New Post")) 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) # 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, 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 self.buffer.list.list.SetItem(index, 2, post_data[2]) # Date
# Note: compose_post returns 4 items but list only has 3 columns # Note: compose_post returns 4 items but list only has 3 columns
except Exception: except Exception:
@@ -584,7 +705,7 @@ class BaseBuffer(base.Buffer):
"handle": g(author, "handle"), "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. Process list of items (FeedViewPost objects), update DB, and update UI.
Returns number of new items. Returns number of new items.
@@ -680,8 +801,73 @@ class BaseBuffer(base.Buffer):
if play_sound and self.sound and not self.session.settings["sound"]["session_mute"]: if play_sound and self.sound and not self.session.settings["sound"]["session_mute"]:
self.session.sound.play(self.sound) 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) 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): def save_positions(self):
try: try:
self.session.db[self.name+"_pos"] = self.buffer.list.get_selected() self.session.db[self.name+"_pos"] = self.buffer.list.get_selected()

View File

@@ -18,17 +18,7 @@ log = logging.getLogger("sessions.blueski.compose")
def compose_post(post, db, settings, relative_times, show_screen_names=False, safe=True): 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. Compose a Bluesky post into a list of strings for display.
Format matches Mastodon: [user+", ", text, date+", ", source]
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]
""" """
def g(obj, key, default=None): def g(obj, key, default=None):
"""Helper to get attribute from dict or object.""" """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) return getattr(obj, key, default)
# Resolve Post View or Feed View structure # Resolve Post View or Feed View structure
# Feed items have .post field, direct post objects don't
actual_post = g(post, "post", post) actual_post = g(post, "post", post)
record = g(actual_post, "record", {}) record = g(actual_post, "record", {})
author = g(actual_post, "author", {}) author = g(actual_post, "author", {})
# Author # Original author info
handle = g(author, "handle", "") original_handle = g(author, "handle", "")
display_name = g(author, "displayName") or g(author, "display_name") or handle or "Unknown" original_display_name = g(author, "displayName") or g(author, "display_name") or original_handle or "Unknown"
if show_screen_names: # Check if this is a repost
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
reason = g(post, "reason", None) reason = g(post, "reason", None)
is_repost = False
reposter_handle = ""
reposter_display_name = ""
if reason: if reason:
rtype = g(reason, "$type") or g(reason, "py_type") rtype = g(reason, "$type") or g(reason, "py_type")
if rtype and "reasonRepost" in rtype: if rtype and "reasonRepost" in rtype:
is_repost = True
by = g(reason, "by", {}) by = g(reason, "by", {})
by_handle = g(by, "handle", "") reposter_handle = g(by, "handle", "")
reason_line = _("Reposted by @{handle}").format(handle=by_handle) if by_handle else _("Reposted") reposter_display_name = g(by, "displayName") or g(by, "display_name") or reposter_handle
text = f"{reason_line}\n{text}" if text else reason_line
# 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 / Content Warning
labels = g(actual_post, "labels", []) labels = g(actual_post, "labels", [])
cw_text = "" cw_text = ""
for label in labels: for label in labels:
val = g(label, "val", "") val = g(label, "val", "")
if val in ["!warn", "porn", "sexual", "nudity", "gore", "graphic-media", "corpse", "self-harm", "hate", "spam", "impersonation"]: 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): if etype and ("images" in etype):
images = g(embed, "images", []) images = g(embed, "images", [])
if images: if images:
text += f"\n[{len(images)} {_('Images')}]" text += f" [{len(images)} {_('images')}]"
# Quote posts # Quote posts
quote_rec = None 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", {}) rec_embed = g(embed, "record", {})
if rec_embed: if rec_embed:
quote_rec = g(rec_embed, "record", None) or rec_embed quote_rec = g(rec_embed, "record", None) or rec_embed
# Media in wrapper
media = g(embed, "media", {}) media = g(embed, "media", {})
mtype = g(media, "$type") or g(media, "py_type") mtype = g(media, "$type") or g(media, "py_type")
if mtype and "images" in mtype: if mtype and "images" in mtype:
images = g(media, "images", []) images = g(media, "images", [])
if images: if images:
text += f"\n[{len(images)} {_('Images')}]" text += f" [{len(images)} {_('images')}]"
elif etype and ("record" in etype): elif etype and ("record" in etype):
quote_rec = g(embed, "record", {}) 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: if quote_rec:
qtype = g(quote_rec, "$type") or g(quote_rec, "py_type") qtype = g(quote_rec, "$type") or g(quote_rec, "py_type")
if qtype and "viewNotFound" in qtype: 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: elif qtype and "viewBlocked" in qtype:
text += f"\n[{_('Quoted post blocked')}]" text += f" [{_('Quoted post blocked')}]"
elif qtype and "generatorView" in qtype: elif qtype and "generatorView" in qtype:
gen = g(quote_rec, "displayName", "Feed") gen = g(quote_rec, "displayName", "Feed")
text += f"\n[{_('Quoting Feed')}: {gen}]" text += f" [{_('Quoting Feed')}: {gen}]"
else: else:
q_author = g(quote_rec, "author", {}) q_author = g(quote_rec, "author", {})
q_handle = g(q_author, "handle", "unknown") q_handle = g(q_author, "handle", "unknown")
q_val = g(quote_rec, "value", {}) q_val = g(quote_rec, "value", {})
q_text = g(q_val, "text", "") q_text = g(q_val, "text", "")
if q_text: if q_text:
text += f"\n[{_('Quoting')} @{q_handle}: {q_text}]" text += " " + _("Quoting @{}: {}").format(q_handle, q_text)
else: else:
text += f"\n[{_('Quoting')} @{q_handle}]" text += " " + _("Quoting @{}").format(q_handle)
elif etype and ("external" in etype): elif etype and ("external" in etype):
ext = g(embed, "external", {}) ext = g(embed, "external", {})
title = g(ext, "title", "") title = g(ext, "title", "")
text += f"\n[{_('Link')}: {title}]" if title:
text += f" [{_('Link')}: {title}]"
# Date # Date
indexed_at = g(actual_post, "indexed_at", "") or g(actual_post, "indexedAt", "") indexed_at = g(actual_post, "indexed_at", "") or g(actual_post, "indexedAt", "")
@@ -155,38 +160,14 @@ def compose_post(post, db, settings, relative_times, show_screen_names=False, sa
# Source / Client # Source / Client
source = "Bluesky" source = "Bluesky"
# Viewer state (liked, reposted, etc.) # Format like Mastodon: add ", " after user and date
viewer_indicators = [] return [user_str + ", ", text, ts_str + ", ", source]
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]
def compose_notification(notification, db, settings, relative_times, show_screen_names=False, safe=True): 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. Compose a Bluesky notification into a list of strings for display.
Format matches Mastodon: [user, text, date]
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]
""" """
def g(obj, key, default=None): def g(obj, key, default=None):
if isinstance(obj, dict): if isinstance(obj, dict):
@@ -201,30 +182,50 @@ def compose_notification(notification, db, settings, relative_times, show_screen
if show_screen_names: if show_screen_names:
user_str = f"@{handle}" user_str = f"@{handle}"
else: else:
if display_name and display_name != handle:
user_str = f"{display_name} (@{handle})" user_str = f"{display_name} (@{handle})"
else:
user_str = f"@{handle}"
# Notification reason/type # Notification reason/type
reason = g(notification, "reason", "unknown") reason = g(notification, "reason", "unknown")
# Map reason to user-readable text # Get post text if available
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
record = g(notification, "record", {}) record = g(notification, "record", {})
post_text = g(record, "text", "") 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 # Format like Mastodon: "{username} has action: {status}"
action_text = f"{action_text}: {snippet}" 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 # Date
indexed_at = g(notification, "indexedAt", "") or g(notification, "indexed_at", "") 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: except Exception:
ts_str = str(indexed_at)[:16].replace("T", " ") 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): def compose_user(user, db, settings, relative_times, show_screen_names=False, safe=True):
""" """
Compose a Bluesky user profile for list display. Compose a Bluesky user profile for list display.
Format matches Mastodon: single string with all info.
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]
""" """
def g(obj, key, default=None): def g(obj, key, default=None):
if isinstance(obj, dict): 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") handle = g(user, "handle", "unknown")
display_name = g(user, "displayName") or g(user, "display_name") or handle display_name = g(user, "displayName") or g(user, "display_name") or handle
followers = g(user, "followersCount", None) followers = g(user, "followersCount", 0) or 0
following = g(user, "followsCount", None) following = g(user, "followsCount", 0) or 0
posts = g(user, "postsCount", None) posts = g(user, "postsCount", 0) or 0
created_at = g(user, "createdAt", None) created_at = g(user, "createdAt", None)
ts = "" 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 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]) ts = original_date.shift(hours=offset).format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
except Exception: except Exception:
ts = str(created_at) ts = ""
parts = [f"{display_name} (@{handle})."] # Format like Mastodon: "Name (@handle). X followers, Y following, Z posts. Joined date"
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
))
if ts: if ts:
parts.append(_("Joined {date}").format(date=ts)) return [_("%s (@%s). %s followers, %s following, %s posts. Joined %s") % (display_name, handle, followers, following, posts, ts)]
else:
return [" ".join(parts).strip()] 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): def compose_convo(convo, db, settings, relative_times, show_screen_names=False, safe=True):

View File

@@ -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

View File

@@ -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)