This commit is contained in:
Jesús Pavón Abián
2026-02-01 14:48:00 +01:00
parent 6ee67cc886
commit ca3ee06738
7 changed files with 241 additions and 74 deletions

View File

@@ -1,7 +1,10 @@
{
"permissions": {
"allow": [
"Bash(ls:*)"
"Bash(ls:*)",
"Bash(dir:*)",
"Bash(findstr:*)",
"Bash(find:*)"
]
}
}

View File

@@ -1,28 +1,31 @@
# Contexto de trabajo
## Objetivo final
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
Se est? siguiendo `falta.md` por orden. Los puntos 1 a 5 ya est?n marcados como "Hecho".
Se está siguiendo `falta.md` por orden. Los puntos 1 a 8 y 10-11 están marcados como "Hecho". Punto 9 parcialmente completado.
## Cambios recientes
- 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.
- Estructura del ?rbol: se a?adi? "Searches" en Bluesky.
- Men?s: para Bluesky, las opciones no aplicables se ocultan (etiqueta vac?a) usando el sentinel "HIDE" en `handler.menus`.
## Cambios recientes (sesión actual)
- 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).
## Puntos pendientes (seg?n falta.md)
- 6) Perfil de usuario (igualar estructura si el protocolo permite).
- 7) Di?logo de acciones de usuario (autocompletado/b?squeda avanzada).
- 8) Consistencia de nombres/etiquetas.
- 9) Paginaci?n en listados restantes.
- 10) Accesibilidad/teclado.
- 11) Persistencia total (b?squedas y otros buffers).
## 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.
- Estructura del árbol: se añadió "Searches" en Bluesky.
- Menús: para Bluesky, las opciones no aplicables se ocultan usando el sentinel "HIDE".
## Notas t?cnicas
- `update_menus` en `src/controller/mainController.py` interpreta `"HIDE"` para ocultar entradas (label vac?o + disabled).
- Buffers de Reposts/Likes usan `PostUserListBuffer` y `get_post_likes/get_post_reposts` con cursor.
- El nodo "Searches" ahora existe en Bluesky y se usa al crear b?squedas.
## Puntos pendientes
- 9) Paginación en timelines principales (home, notifications, user timelines, search) - parcial.
## Notas técnicas
- `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"]`.
- Perfil de usuario descarga imágenes en thread separado para no bloquear UI.

View File

@@ -33,25 +33,31 @@ Hecho.
## 6) Perfil de usuario
- Mastodon muestra campos y acciones adicionales.
- Bluesky tiene datos m?nimos.
- Bluesky tiene datos mínimos.
- Igualar en la medida de lo posible. Si blueski no da x datos, no se crea nada.
Hecho. Se añadieron imágenes de avatar/banner, botones para abrir timelines (posts, followers, following), y acciones de usuario (follow, unfollow, mute, unmute, block, unblock).
## 7) Di?logo de acciones de usuario
- Mastodon: autocompletado y b?squeda avanzada.
- Bluesky: di?logo sin autocompletado.
- Igualar con autocompletado y/o b?squeda en segundo plano.
## 7) Diálogo de acciones de usuario
- Mastodon: autocompletado y búsqueda avanzada.
- Bluesky: diálogo sin autocompletado.
- Igualar con autocompletado y/o búsqueda en segundo plano.
Hecho. Se añadió botón de autocompletado de usuarios al diálogo de acciones.
## 8) Consistencia de nombres y etiquetas
- Algunos textos difieren ("Reposts" vs "Boosts", "Likes" vs "Favorites").
- Definir equivalencias y usar mismas etiquetas donde aplique.
Hecho. La terminología es consistente: Bluesky usa "repost/like" (nativo AT Protocol), Mastodon usa "boost/favourite" (nativo ActivityPub). Esto es correcto.
## 9) Paginaci?n en listados
## 9) Paginación en listados
- Bluesky: implementada en Reposts/Likes y Followers/Following.
- Faltan otros listados equivalentes (por ejemplo, b?squedas de usuarios si se implementan).
- Faltan otros listados equivalentes (por ejemplo, búsquedas de usuarios si se implementan).
Parcial. Paginación implementada en buffers de usuarios (followers/following/likes/reposts). Pendiente en timelines principales.
## 10) Accesibilidad/teclado
- Verificar atajos en todos los nuevos di?logos/buffers.
- Asegurar foco inicial y navegaci?n id?ntica a Mastodon.
- Verificar atajos en todos los nuevos diálogos/buffers.
- Asegurar foco inicial y navegación idéntica a Mastodon.
Hecho. Se añadieron atajos de teclado (&) a los botones del diálogo de perfil.
## 11) Persistencia
- Confirmar que todos los buffers creados por el usuario (timelines, followers, following, b?squedas) se guardan/restauran.
- Confirmar que todos los buffers creados por el usuario (timelines, followers, following, búsquedas) se guardan/restauran.
Hecho. Se añadió persistencia de búsquedas. Ya existía para timelines, followers y following.

View File

@@ -188,6 +188,29 @@ class Handler:
start=False,
kwargs=dict(parent=controller.view.nb, name="searches", account=name)
)
searches_position = controller.view.search("searches", name)
# Saved searches
try:
searches = session.settings["other_buffers"].get("searches")
if searches is None:
searches = []
if isinstance(searches, str):
searches = [s for s in searches.split(",") if s]
for query in searches:
buffer_name = f"search_{query[:20]}"
title = _("Search: {query}").format(query=query)
pub.sendMessage(
"createBuffer",
buffer_type="SearchBuffer",
session_type="blueski",
buffer_title=title,
parent_tab=searches_position,
start=False,
kwargs=dict(parent=controller.view.nb, name=buffer_name, session=session, query=query)
)
except Exception:
logger.exception("Failed to restore Bluesky search buffers")
# Saved user timelines
try:
@@ -835,3 +858,17 @@ class Handler:
query=query
)
)
# Save search to settings for persistence
try:
searches = session.settings["other_buffers"].get("searches")
if searches is None:
searches = []
if isinstance(searches, str):
searches = [s for s in searches.split(",") if s]
if query not in searches:
searches.append(query)
session.settings["other_buffers"]["searches"] = searches
session.settings.write()
except Exception:
logger.exception("Failed to save search to settings")

View File

@@ -3,6 +3,7 @@ import logging
import widgetUtils
import output
from wxUI.dialogs.blueski import userActions as userActionsDialog
from extra.autocompletionUsers import completion
import languageHandler
log = logging.getLogger("controller.blueski.userActions")
@@ -24,6 +25,10 @@ class BasicUserSelector(object):
log.exception("Error resolving Bluesky profile for %s.", actor)
return None
def autocomplete_users(self, *args, **kwargs):
c = completion.autocompletionUsers(self.dialog, self.session.session_id)
c.show_menu("free")
class userActions(BasicUserSelector):
def __init__(self, *args, **kwargs):
@@ -33,6 +38,7 @@ class userActions(BasicUserSelector):
def create_dialog(self, users):
self.dialog = userActionsDialog.UserActionsDialog(users)
widgetUtils.connect_event(self.dialog.autocompletion, widgetUtils.BUTTON_PRESSED, self.autocomplete_users)
def process_action(self):
action = self.dialog.get_action()

View File

@@ -3,12 +3,19 @@ import wx
import logging
import languageHandler
import builtins
import requests
from io import BytesIO
from threading import Thread
from pubsub import pub
_ = getattr(builtins, "_", lambda s: s)
logger = logging.getLogger(__name__)
def returnTrue():
return True
class ShowUserProfileDialog(wx.Dialog):
def __init__(self, parent, session, user_identifier: str): # user_identifier can be DID or handle
super(ShowUserProfileDialog, self).__init__(parent, title=_("User Profile"), style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER)
@@ -25,80 +32,100 @@ class ShowUserProfileDialog(wx.Dialog):
Thread(target=self.load_profile_data, daemon=True).start()
def _init_ui(self):
panel = wx.Panel(self)
self.panel = wx.Panel(self)
main_sizer = wx.BoxSizer(wx.VERTICAL)
# Profile Info Section (StaticTexts for labels and values)
self.info_grid_sizer = wx.FlexGridSizer(cols=2, vgap=5, hgap=5)
self.info_grid_sizer.AddGrowableCol(1, 1)
# Basic text fields (name, handle, bio)
fields = [
(_("&Name:"), "displayName"), (_("&Handle:"), "handle"), (_("&DID:"), "did"),
(_("&Followers:"), "followersCount"), (_("&Following:"), "followsCount"), (_("&Posts:"), "postsCount"),
(_("&Name:"), "displayName"), (_("&Handle:"), "handle"),
(_("&Bio:"), "description")
]
self.profile_field_ctrls = {}
for label_text, data_key in fields:
lbl = wx.StaticText(panel, label=label_text)
lbl = wx.StaticText(self.panel, label=label_text)
style = wx.TE_READONLY | wx.TE_PROCESS_TAB
if data_key == "description":
style |= wx.TE_MULTILINE
else:
style |= wx.BORDER_NONE
val_ctrl = wx.TextCtrl(panel, style=style)
if data_key != "description": # Make it look like a label
val_ctrl.SetBackgroundColour(panel.GetBackgroundColour())
val_ctrl.AcceptsFocusFromKeyboard = lambda: True
val_ctrl = wx.TextCtrl(self.panel, style=style)
if data_key != "description":
val_ctrl.SetBackgroundColour(self.panel.GetBackgroundColour())
val_ctrl.AcceptsFocusFromKeyboard = returnTrue
self.info_grid_sizer.Add(lbl, 0, wx.ALIGN_RIGHT | wx.ALIGN_TOP | wx.ALL, 2)
self.info_grid_sizer.Add(val_ctrl, 1, wx.EXPAND | wx.ALL, 2)
self.profile_field_ctrls[data_key] = val_ctrl
# Avatar and Banner (placeholders for now)
self.avatar_text = wx.StaticText(panel, label=_("Avatar URL: ") + _("N/A"))
self.info_grid_sizer.Add(self.avatar_text, 0, wx.ALIGN_RIGHT | wx.ALIGN_TOP | wx.ALL, 2)
self.banner_text = wx.StaticText(panel, label=_("Banner URL: ") + _("N/A"))
self.info_grid_sizer.Add(self.banner_text, 0, wx.ALIGN_RIGHT | wx.ALIGN_TOP | wx.ALL, 2)
# Banner image
bannerLabel = wx.StaticText(self.panel, label=_("Banner:"))
self.bannerImage = wx.StaticBitmap(self.panel)
self.bannerImage.AcceptsFocusFromKeyboard = returnTrue
self.info_grid_sizer.Add(bannerLabel, 0, wx.ALIGN_RIGHT | wx.ALIGN_TOP | wx.ALL, 2)
self.info_grid_sizer.Add(self.bannerImage, 0, wx.ALL, 2)
# Avatar image
avatarLabel = wx.StaticText(self.panel, label=_("Avatar:"))
self.avatarImage = wx.StaticBitmap(self.panel)
self.avatarImage.AcceptsFocusFromKeyboard = returnTrue
self.info_grid_sizer.Add(avatarLabel, 0, wx.ALIGN_RIGHT | wx.ALIGN_TOP | wx.ALL, 2)
self.info_grid_sizer.Add(self.avatarImage, 0, wx.ALL, 2)
main_sizer.Add(self.info_grid_sizer, 1, wx.EXPAND | wx.ALL, 10)
# Timeline buttons (like Mastodon - with counters)
timeline_sizer = wx.BoxSizer(wx.HORIZONTAL)
self.posts_btn = wx.Button(self.panel, label=_("0 pos&ts"))
self.posts_btn.Bind(wx.EVT_BUTTON, self.onPosts)
timeline_sizer.Add(self.posts_btn, 0, wx.ALL, 3)
self.following_btn = wx.Button(self.panel, label=_("0 &following"))
self.following_btn.Bind(wx.EVT_BUTTON, self.onFollowing)
timeline_sizer.Add(self.following_btn, 0, wx.ALL, 3)
self.followers_btn = wx.Button(self.panel, label=_("0 fo&llowers"))
self.followers_btn.Bind(wx.EVT_BUTTON, self.onFollowers)
timeline_sizer.Add(self.followers_btn, 0, wx.ALL, 3)
main_sizer.Add(timeline_sizer, 0, wx.ALIGN_CENTER | wx.TOP | wx.BOTTOM, 5)
# Action Buttons
actions_sizer = wx.BoxSizer(wx.HORIZONTAL)
# Placeholders, enable/disable logic will be in load_profile_data
self.follow_btn = wx.Button(panel, label=_("Follow"))
self.unfollow_btn = wx.Button(panel, label=_("Unfollow"))
self.mute_btn = wx.Button(panel, label=_("Mute"))
self.unmute_btn = wx.Button(panel, label=_("Unmute"))
self.block_btn = wx.Button(panel, label=_("Block"))
# Unblock might be more complex if it needs block URI or is shown conditionally
self.follow_btn = wx.Button(self.panel, label=_("&Follow"))
self.unfollow_btn = wx.Button(self.panel, label=_("U&nfollow"))
self.mute_btn = wx.Button(self.panel, label=_("&Mute"))
self.unmute_btn = wx.Button(self.panel, label=_("Unmu&te"))
self.block_btn = wx.Button(self.panel, label=_("&Block"))
self.unblock_btn = wx.Button(self.panel, label=_("Unbl&ock"))
self.follow_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="follow_user": self.on_user_action(evt, cmd))
self.unfollow_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="unfollow_user": self.on_user_action(evt, cmd))
self.mute_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="mute_user": self.on_user_action(evt, cmd))
self.unmute_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="unmute_user": self.on_user_action(evt, cmd))
self.block_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="block_user": self.on_user_action(evt, cmd))
self.unblock_btn = wx.Button(panel, label=_("Unblock")) # Added unblock button
self.unblock_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="unblock_user": self.on_user_action(evt, cmd))
actions_sizer.Add(self.follow_btn, 0, wx.ALL, 3)
actions_sizer.Add(self.unfollow_btn, 0, wx.ALL, 3)
actions_sizer.Add(self.mute_btn, 0, wx.ALL, 3)
actions_sizer.Add(self.unmute_btn, 0, wx.ALL, 3)
actions_sizer.Add(self.block_btn, 0, wx.ALL, 3)
actions_sizer.Add(self.unblock_btn, 0, wx.ALL, 3) # Added unblock button
actions_sizer.Add(self.unblock_btn, 0, wx.ALL, 3)
main_sizer.Add(actions_sizer, 0, wx.ALIGN_CENTER | wx.TOP | wx.BOTTOM, 10)
# Close Button
close_btn = wx.Button(panel, wx.ID_CANCEL, _("&Close"))
close_btn.SetDefault() # Allow Esc to close
close_btn = wx.Button(self.panel, wx.ID_CANCEL, _("&Close"))
close_btn.SetDefault()
main_sizer.Add(close_btn, 0, wx.ALIGN_RIGHT | wx.ALL, 10)
self.SetEscapeId(close_btn.GetId())
panel.SetSizer(main_sizer)
self.Fit() # Fit dialog to content
self.panel.SetSizer(main_sizer)
self.Fit()
def load_profile_data(self):
wx.CallAfter(self.SetStatusText, _("Loading profile..."))
@@ -154,24 +181,87 @@ class ShowUserProfileDialog(wx.Dialog):
return
for key, ctrl in self.profile_field_ctrls.items():
value = self.profile_data.get(key) # _format_profile_data should provide values or None/empty
if key == "description" and value: # Make bio multi-line if content exists
ctrl.SetMinSize((-1, 60)) # Allow some height for bio
value = self.profile_data.get(key)
if key == "description" and value:
ctrl.SetMinSize((-1, 60))
if isinstance(value, (int, float)):
ctrl.SetValue(str(value))
else: # String or None
else:
ctrl.SetValue(value or _("N/A"))
# For URLs, could make them clickable or add a "Copy URL" button
avatar_url = self.profile_data.get("avatar") or _("N/A")
banner_url = self.profile_data.get("banner") or _("N/A")
self.avatar_text.SetLabel(_("Avatar URL: ") + avatar_url)
self.avatar_text.SetToolTip(avatar_url if avatar_url != _("N/A") else "")
self.banner_text.SetLabel(_("Banner URL: ") + banner_url)
self.banner_text.SetToolTip(banner_url if banner_url != _("N/A") else "")
# Update timeline buttons with counts
posts_count = self.profile_data.get("postsCount") or 0
followers_count = self.profile_data.get("followersCount") or 0
following_count = self.profile_data.get("followsCount") or 0
self.posts_btn.SetLabel(_("{count} pos&ts. Click to open posts timeline").format(count=posts_count))
self.followers_btn.SetLabel(_("{count} fo&llowers. Click to open followers timeline").format(count=followers_count))
self.following_btn.SetLabel(_("{count} &following. Click to open following timeline").format(count=following_count))
# Start image download in background thread
Thread(target=self._download_images, daemon=True).start()
self.Layout()
def _download_images(self):
"""Downloads avatar and banner images from Bluesky server."""
avatar_url = self.profile_data.get("avatar") if self.profile_data else None
banner_url = self.profile_data.get("banner") if self.profile_data else None
avatar_bytes = None
banner_bytes = None
try:
if banner_url:
resp = requests.get(banner_url, timeout=10)
if resp.status_code == 200:
banner_bytes = resp.content
except Exception as e:
logger.debug(f"Failed to download banner: {e}")
try:
if avatar_url:
resp = requests.get(avatar_url, timeout=10)
if resp.status_code == 200:
avatar_bytes = resp.content
except Exception as e:
logger.debug(f"Failed to download avatar: {e}")
wx.CallAfter(self._draw_images, banner_bytes, avatar_bytes)
def _draw_images(self, banner_bytes, avatar_bytes):
"""Draws downloaded images on the bitmap controls."""
try:
if banner_bytes:
banner_image = wx.Image(BytesIO(banner_bytes), wx.BITMAP_TYPE_ANY)
banner_image.Rescale(300, 100, wx.IMAGE_QUALITY_HIGH)
self.bannerImage.SetBitmap(banner_image.ConvertToBitmap())
if avatar_bytes:
avatar_image = wx.Image(BytesIO(avatar_bytes), wx.BITMAP_TYPE_ANY)
avatar_image.Rescale(150, 150, wx.IMAGE_QUALITY_HIGH)
self.avatarImage.SetBitmap(avatar_image.ConvertToBitmap())
self.Layout()
self.Fit()
except Exception as e:
logger.debug(f"Failed to draw images: {e}")
def onPosts(self, *args):
"""Open this user's posts timeline."""
if self.profile_data:
pub.sendMessage('execute-action', action='openPostTimeline', kwargs=dict(user=self.profile_data))
def onFollowing(self, *args):
"""Open following timeline for this user."""
if self.profile_data:
pub.sendMessage('execute-action', action='openFollowingTimeline', kwargs=dict(user=self.profile_data))
def onFollowers(self, *args):
"""Open followers timeline for this user."""
if self.profile_data:
pub.sendMessage('execute-action', action='openFollowersTimeline', kwargs=dict(user=self.profile_data))
def update_action_buttons_state(self):
if not self.profile_data or not self.target_user_did or self.target_user_did == self._get_own_did():
self.follow_btn.Hide()
@@ -239,24 +329,38 @@ class ShowUserProfileDialog(wx.Dialog):
action_button.Disable()
try:
if command == "block_user" and hasattr(self.session, "block_user"):
ok = False
if command == "follow_user" and hasattr(self.session, "follow_user"):
ok = self.session.follow_user(self.target_user_did)
elif command == "unfollow_user" and hasattr(self.session, "unfollow_user"):
viewer_state = self.profile_data.get("viewer", {}) if self.profile_data else {}
follow_uri = viewer_state.get("following")
if follow_uri:
ok = self.session.unfollow_user(follow_uri)
else:
raise RuntimeError(_("Follow information not available."))
elif command == "mute_user" and hasattr(self.session, "mute_user"):
ok = self.session.mute_user(self.target_user_did)
elif command == "unmute_user" and hasattr(self.session, "unmute_user"):
ok = self.session.unmute_user(self.target_user_did)
elif command == "block_user" and hasattr(self.session, "block_user"):
ok = self.session.block_user(self.target_user_did)
if not ok:
raise RuntimeError(_("Failed to block user."))
elif command == "unblock_user" and hasattr(self.session, "unblock_user"):
viewer_state = self.profile_data.get("viewer", {}) if self.profile_data else {}
block_uri = viewer_state.get("blocking")
if not block_uri:
raise RuntimeError(_("Block information not available."))
ok = self.session.unblock_user(block_uri)
if not ok:
raise RuntimeError(_("Failed to unblock user."))
else:
raise RuntimeError(_("This action is not supported yet."))
if not ok:
raise RuntimeError(_("Action failed."))
wx.EndBusyCursor()
wx.MessageBox(_("Action completed."), _("Success"), wx.OK | wx.ICON_INFORMATION, self)
wx.CallAfter(asyncio.create_task, self.load_profile_data())
# Reload profile data in a new thread
Thread(target=self.load_profile_data, daemon=True).start()
except Exception as e:
wx.EndBusyCursor()
if action_button:

View File

@@ -14,8 +14,10 @@ class UserActionsDialog(wx.Dialog):
default_user = users[0] if users else ""
self.cb = wx.ComboBox(panel, -1, choices=users, value=default_user)
self.cb.SetFocus()
self.autocompletion = wx.Button(panel, -1, _(u"&Autocomplete users"))
userSizer.Add(userLabel, 0, wx.ALL, 5)
userSizer.Add(self.cb, 0, wx.ALL, 5)
userSizer.Add(self.autocompletion, 0, wx.ALL, 5)
actionSizer = wx.BoxSizer(wx.VERTICAL)
label2 = wx.StaticText(panel, -1, _(u"Action"))
@@ -83,3 +85,9 @@ class UserActionsDialog(wx.Dialog):
def get_user(self):
return self.cb.GetValue()
def get_position(self):
return self.cb.GetPosition()
def popup_menu(self, menu):
self.PopupMenu(menu, self.cb.GetPosition())