mirror of
https://github.com/MCV-Software/TWBlue.git
synced 2026-03-06 09:27:33 +01:00
Terminando de refactorizar
This commit is contained in:
@@ -4,7 +4,8 @@
|
||||
"Bash(ls:*)",
|
||||
"Bash(dir:*)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(find:*)"
|
||||
"Bash(find:*)",
|
||||
"Bash(python:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
32
context.md
32
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"]`.
|
||||
|
||||
@@ -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,7 +50,11 @@ 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"):
|
||||
@@ -63,6 +70,120 @@ class BaseBuffer(base.Buffer):
|
||||
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.
|
||||
@@ -680,8 +801,73 @@ class BaseBuffer(base.Buffer):
|
||||
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()
|
||||
|
||||
@@ -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", "")
|
||||
@@ -155,38 +160,14 @@ 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):
|
||||
|
||||
189
src/sessions/blueski/utils.py
Normal file
189
src/sessions/blueski/utils.py
Normal 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
|
||||
97
src/wxUI/dialogs/blueski/menus.py
Normal file
97
src/wxUI/dialogs/blueski/menus.py
Normal 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)
|
||||
Reference in New Issue
Block a user