mirror of
https://github.com/MCV-Software/TWBlue.git
synced 2026-03-06 01:17:32 +01:00
Terminando de refactorizar
This commit is contained in:
@@ -4,7 +4,8 @@
|
|||||||
"Bash(ls:*)",
|
"Bash(ls:*)",
|
||||||
"Bash(dir:*)",
|
"Bash(dir:*)",
|
||||||
"Bash(findstr:*)",
|
"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.
|
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"]`.
|
||||||
|
|||||||
@@ -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,8 +50,12 @@ 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"):
|
||||||
self.buffer.post.Bind(wx.EVT_BUTTON, self.on_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)
|
self.buffer.dm.Bind(wx.EVT_BUTTON, self.on_dm)
|
||||||
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.
|
||||||
@@ -679,9 +800,74 @@ class BaseBuffer(base.Buffer):
|
|||||||
# Play sound
|
# Play sound
|
||||||
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()
|
||||||
|
|||||||
@@ -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", "")
|
||||||
@@ -154,39 +159,15 @@ 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.)
|
|
||||||
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):
|
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:
|
||||||
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
|
# 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):
|
||||||
|
|||||||
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