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(dir:*)",
"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.
## 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"]`.

View File

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

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):
"""
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):

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)