Chats, plantillas, movidas varias.

This commit is contained in:
Jesús Pavón Abián
2026-02-03 13:28:12 +01:00
parent 5f9cf2c25b
commit 7754cccc2e
10 changed files with 596 additions and 133 deletions

View File

@@ -358,7 +358,7 @@ class Handler:
templates_cfg = buffer.session.settings.get("templates", {})
template_state = {
"post": templates_cfg.get("post", "$display_name, $safe_text $date."),
"person": templates_cfg.get("person", "$display_name (@$screen_name). $followers followers, $following following, $posts posts."),
"person": templates_cfg.get("person", "$display_name (@$screen_name). $followers followers, $following following, $posts posts. Joined $created_at."),
"notification": templates_cfg.get("notification", "$display_name $text, $date"),
}
dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"])

View File

@@ -108,6 +108,9 @@ class BaseBuffer(base.Buffer):
index = self.buffer.list.get_selected()
if index < 0:
return
# Only update if the list has at least 3 columns (Author, Text, Date)
if self.buffer.list.list.GetColumnCount() < 3:
return
def g(obj, key, default=None):
if isinstance(obj, dict):
@@ -474,41 +477,46 @@ class BaseBuffer(base.Buffer):
self.send_message()
def send_message(self, *args, **kwargs):
# Global shortcut for DM
item = self.get_item()
if not item:
output.speak(_("No user selected to message."), True)
# Global shortcut for DM - similar to Mastodon's implementation
# Use the robust author extraction method
author_details = self.get_selected_item_author_details()
if not author_details:
output.speak(_("No item selected."), True)
return
author = getattr(item, "author", None) or (item.get("post", {}).get("author") if isinstance(item, dict) else None)
if not author:
# Try item itself if it's a user object (UserBuffer)
author = item
did = getattr(author, "did", None) or author.get("did")
handle = getattr(author, "handle", "unknown") or (author.get("handle") if isinstance(author, dict) else "unknown")
did = author_details.get("did")
handle = author_details.get("handle") or "unknown"
if not did:
output.speak(_("Could not identify user to message."), True)
return
# Show simple text entry dialog for sending DM
dlg = wx.TextEntryDialog(None, _("Message to {0}:").format(handle), _("Send Message"))
if dlg.ShowModal() == wx.ID_OK:
text = dlg.GetValue()
# Use full post dialog like Mastodon
title = _("Conversation with {0}").format(handle)
caption = _("Write your message here")
initial_text = "@{} ".format(handle)
post = blueski_messages.post(session=self.session, title=title, caption=caption, text=initial_text)
if post.message.ShowModal() == wx.ID_OK:
text, files, cw_text, langs = post.get_data()
if text:
try:
api = self.session._ensure_client()
dm_client = api.with_bsky_chat_proxy()
# Get or create conversation
res = dm_client.chat.bsky.convo.get_convo_for_members({"members": [did]})
convo_id = res.convo.id
self.session.send_chat_message(convo_id, text)
self.session.sound.play("dm_sent.ogg")
output.speak(_("Message sent."), True)
except Exception as e:
log.error("Error sending Bluesky DM: %s", e)
output.speak(_("Failed to send message."), True)
dlg.Destroy()
def do_send():
try:
api = self.session._ensure_client()
dm_client = api.with_bsky_chat_proxy()
# Get or create conversation
res = dm_client.chat.bsky.convo.get_convo_for_members({"members": [did]})
convo_id = res.convo.id
self.session.send_chat_message(convo_id, text)
wx.CallAfter(self.session.sound.play, "dm_sent.ogg")
wx.CallAfter(output.speak, _("Message sent."))
except Exception as e:
log.error("Error sending Bluesky DM: %s", e)
wx.CallAfter(output.speak, _("Failed to send message."), True)
call_threaded(do_send)
if hasattr(post.message, "Destroy"):
post.message.Destroy()
def view(self, *args, **kwargs):
self.view_item()
@@ -800,7 +808,7 @@ class BaseBuffer(base.Buffer):
post_template = template_settings.get("post", "$display_name, $safe_text $date.")
return templates.render_notification(item, template, post_template, self.session.settings, relative_times, offset_hours)
if self.type in ("user", "post_user_list"):
template = template_settings.get("person", "$display_name (@$screen_name). $followers followers, $following following, $posts posts.")
template = template_settings.get("person", "$display_name (@$screen_name). $followers followers, $following following, $posts posts. Joined $created_at.")
return templates.render_user(item, template, self.session.settings, relative_times, offset_hours)
template = template_settings.get("post", "$display_name, $safe_text $date.")
return templates.render_post(item, template, self.session.settings, relative_times, offset_hours)
@@ -888,20 +896,43 @@ class BaseBuffer(base.Buffer):
return getattr(obj, key, default)
author = None
# Check if item itself is a user object (UserBuffer, FollowersBuffer, etc.)
if g(item, "did") or g(item, "handle"):
author = item
else:
author = g(item, "author")
# Use the same pattern as compose_post: get actual_post first
# This handles FeedViewPost (item.post) and PostView (item itself)
actual_post = g(item, "post", item)
author = g(actual_post, "author")
# If still no author, try other nested structures
if not author:
post = g(item, "post") or g(item, "record")
author = g(post, "author") if post else None
# Try record.author
record = g(item, "record")
if record:
author = g(record, "author")
# Try subject (for notifications)
if not author:
subject = g(item, "subject")
if subject:
actual_subject = g(subject, "post", subject)
author = g(actual_subject, "author")
if not author:
return None
did = g(author, "did")
handle = g(author, "handle")
# Only return if we have at least did or handle
if not did and not handle:
return None
return {
"did": g(author, "did"),
"handle": g(author, "handle"),
"did": did,
"handle": handle,
}
def process_items(self, items, play_sound=True, avoid_autoreading=False):

View File

@@ -1,8 +1,10 @@
# -*- coding: utf-8 -*-
import logging
import wx
import widgetUtils
import output
from .base import BaseBuffer
from controller.blueski import messages as blueski_messages
from wxUI.buffers.blueski import panels as BlueskiPanels
from sessions.blueski import compose
from mysc.thread_utils import call_threaded
@@ -10,16 +12,107 @@ from mysc.thread_utils import call_threaded
log = logging.getLogger("controller.buffers.blueski.chat")
class ConversationListBuffer(BaseBuffer):
"""Buffer for listing conversations, similar to Mastodon's ConversationListBuffer."""
def __init__(self, *args, **kwargs):
kwargs["compose_func"] = "compose_convo"
super(ConversationListBuffer, self).__init__(*args, **kwargs)
self.type = "chat"
self.sound = "dm_received.ogg"
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.ChatPanel(parent, name)
self.buffer.session = self.session
def bind_events(self):
"""Bind events like Mastodon's ConversationListBuffer."""
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"):
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.on_post, self.buffer.post)
if hasattr(self.buffer, "reply"):
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.reply, self.buffer.reply)
if hasattr(self.buffer, "new_chat"):
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.on_new_chat, self.buffer.new_chat)
def get_item(self):
"""Get the last message from the selected conversation (like Mastodon)."""
index = self.buffer.list.get_selected()
if index > -1 and self.session.db.get(self.name) is not None and len(self.session.db[self.name]) > index:
convo = self.session.db[self.name][index]
# Return lastMessage for compatibility with item-based operations
last_msg = getattr(convo, "lastMessage", None) or (convo.get("lastMessage") if isinstance(convo, dict) else None)
return last_msg
return None
def get_conversation(self):
"""Get the full conversation object."""
index = self.buffer.list.get_selected()
if index > -1 and self.session.db.get(self.name) is not None and len(self.session.db[self.name]) > index:
return self.session.db[self.name][index]
return None
def get_convo_id(self, conversation):
"""Extract conversation ID from a conversation object, handling different field names."""
if not conversation:
return None
# Try different possible field names for the conversation ID
for attr in ("id", "convo_id", "convoId"):
val = getattr(conversation, attr, None)
if val:
return val
if isinstance(conversation, dict) and attr in conversation:
return conversation[attr]
return None
def on_new_chat(self, *args, **kwargs):
"""Start a new conversation by entering a handle."""
dlg = wx.TextEntryDialog(None, _("Enter the handle of the user (e.g., user.bsky.social):"), _("New Chat"))
if dlg.ShowModal() == wx.ID_OK:
handle = dlg.GetValue().strip()
if handle:
if handle.startswith("@"):
handle = handle[1:]
def do_create():
try:
# Resolve handle to DID
profile = self.session.get_profile(handle)
if not profile:
wx.CallAfter(output.speak, _("User not found."), True)
return
did = getattr(profile, "did", None) or (profile.get("did") if isinstance(profile, dict) else None)
if not did:
wx.CallAfter(output.speak, _("Could not get user ID."), True)
return
# Get or create conversation
convo = self.session.get_or_create_convo([did])
if not convo:
wx.CallAfter(output.speak, _("Could not create conversation."), True)
return
convo_id = self.get_convo_id(convo)
user_handle = getattr(profile, "handle", None) or (profile.get("handle") if isinstance(profile, dict) else None) or handle
title = _("Chat: {0}").format(user_handle)
# Create the buffer
import application
wx.CallAfter(
application.app.controller.create_buffer,
buffer_type="chat_messages",
session_type="blueski",
buffer_title=title,
kwargs={"session": self.session, "convo_id": convo_id, "name": title},
start=True
)
# Refresh conversation list
wx.CallAfter(self.start_stream, True, False)
except Exception:
log.exception("Error creating new conversation")
wx.CallAfter(output.speak, _("Error creating conversation."), True)
call_threaded(do_create)
dlg.Destroy()
def start_stream(self, mandatory=False, play_sound=True):
count = self.get_max_items()
try:
@@ -28,33 +121,98 @@ class ConversationListBuffer(BaseBuffer):
self.session.db[self.name] = []
self.buffer.list.clear()
return self.process_items(items, play_sound)
except Exception as e:
log.error("Error fetching conversations: %s", e)
except Exception:
log.exception("Error fetching conversations")
output.speak(_("Error loading conversations."), True)
return 0
def fav(self):
pass
def unfav(self):
pass
def can_share(self):
return False
def url(self, *args, **kwargs):
# In chat list, Enter (URL) should open the chat conversation buffer
"""Enter key opens the chat conversation buffer."""
self.view_chat()
def send_message(self, *args, **kwargs):
# Global shortcut for DM
self.view_chat()
"""Global DM shortcut - reply to conversation."""
return self.reply()
def reply(self, *args, **kwargs):
"""Reply to the selected conversation (like Mastodon)."""
conversation = self.get_conversation()
if not conversation:
output.speak(_("No conversation selected."), True)
return
convo_id = self.get_convo_id(conversation)
if not convo_id:
log.error("Could not get conversation ID from conversation object")
output.speak(_("Could not identify conversation."), True)
return
# Get participants for title
members = getattr(conversation, "members", []) or (conversation.get("members", []) if isinstance(conversation, dict) else [])
user_did = self.session.db.get("user_id")
others = [m for m in members if (getattr(m, "did", None) or (m.get("did") if isinstance(m, dict) else None)) != user_did]
if not others:
others = members
if others:
first_user = others[0]
username = getattr(first_user, "handle", None) or (first_user.get("handle") if isinstance(first_user, dict) else None) or "unknown"
else:
username = "unknown"
title = _("Conversation with {0}").format(username)
caption = _("Write your message here")
initial_text = "@{} ".format(username)
post = blueski_messages.post(session=self.session, title=title, caption=caption, text=initial_text)
if post.message.ShowModal() == wx.ID_OK:
text, files, cw_text, langs = post.get_data()
if text:
def do_send():
try:
self.session.send_chat_message(convo_id, text)
wx.CallAfter(self.session.sound.play, "dm_sent.ogg")
wx.CallAfter(output.speak, _("Message sent."))
wx.CallAfter(self.start_stream, True, False)
except Exception:
log.exception("Error sending message")
wx.CallAfter(output.speak, _("Failed to send message."), True)
call_threaded(do_send)
if hasattr(post.message, "Destroy"):
post.message.Destroy()
def view_chat(self):
item = self.get_item()
if not item: return
convo_id = getattr(item, "id", None) or item.get("id")
if not convo_id: return
"""Open the conversation in a separate buffer."""
conversation = self.get_conversation()
if not conversation:
output.speak(_("No conversation selected."), True)
return
convo_id = self.get_convo_id(conversation)
if not convo_id:
log.error("Could not get conversation ID from conversation object: %r", conversation)
output.speak(_("Could not identify conversation."), True)
return
# Determine participants names for title
members = getattr(item, "members", []) or item.get("members", [])
others = [m for m in members if (getattr(m, "did", None) or m.get("did")) != self.session.db["user_id"]]
if not others: others = members
names = ", ".join([getattr(m, "handle", "unknown") or m.get("handle") for m in others])
members = getattr(conversation, "members", []) or (conversation.get("members", []) if isinstance(conversation, dict) else [])
user_did = self.session.db.get("user_id")
others = [m for m in members if (getattr(m, "did", None) or (m.get("did") if isinstance(m, dict) else None)) != user_did]
if not others:
others = members
names = ", ".join([getattr(m, "handle", None) or (m.get("handle") if isinstance(m, dict) else None) or "unknown" for m in others])
title = _("Chat: {0}").format(names)
import application
application.app.controller.create_buffer(
buffer_type="chat_messages",
@@ -64,14 +222,19 @@ class ConversationListBuffer(BaseBuffer):
start=True
)
def destroy_status(self):
pass
class ChatBuffer(BaseBuffer):
"""Buffer for displaying messages in a conversation, similar to Mastodon's ConversationBuffer."""
def __init__(self, *args, **kwargs):
kwargs["compose_func"] = "compose_chat_message"
super(ChatBuffer, self).__init__(*args, **kwargs)
self.type = "chat_messages"
self.convo_id = kwargs.get("convo_id")
self.sound = "dm_received.ogg"
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.ChatMessagePanel(parent, name)
self.buffer.session = self.session
@@ -87,27 +250,76 @@ class ChatBuffer(BaseBuffer):
self.buffer.list.clear()
items = list(reversed(items))
return self.process_items(items, play_sound)
except Exception as e:
log.error("Error fetching chat messages: %s", e)
except Exception:
log.exception("Error fetching chat messages")
return 0
def get_more_items(self):
output.speak(_("This action is not supported for this buffer"), True)
def fav(self):
pass
def unfav(self):
pass
def can_share(self):
return False
def destroy_status(self):
pass
def url(self, *args, **kwargs):
"""Enter key opens reply dialog in chat."""
self.on_reply(None)
def on_reply(self, evt):
# Open a text entry chat box
dlg = wx.TextEntryDialog(None, _("Message:"), _("Send Message"), style=wx.TE_MULTILINE | wx.OK | wx.CANCEL)
if dlg.ShowModal() == wx.ID_OK:
text = dlg.GetValue()
"""Open dialog to send a message in this conversation."""
if not self.convo_id:
output.speak(_("Cannot send message: no conversation selected."), True)
return
# Get conversation title from buffer name or use generic
title = _("Send Message")
if self.name and self.name.startswith(_("Chat: ")):
title = self.name
caption = _("Write your message here")
post = blueski_messages.post(session=self.session, title=title, caption=caption, text="")
if post.message.ShowModal() == wx.ID_OK:
text, files, cw_text, langs = post.get_data()
if text:
def do_send():
try:
self.session.send_chat_message(self.convo_id, text)
wx.CallAfter(self.session.sound.play, "dm_sent.ogg")
wx.CallAfter(output.speak, _("Message sent."))
wx.CallAfter(self.start_stream, True, False)
except Exception:
wx.CallAfter(output.speak, _("Failed to send message."), True)
call_threaded(do_send)
dlg.Destroy()
def do_send():
try:
self.session.send_chat_message(self.convo_id, text)
wx.CallAfter(self.session.sound.play, "dm_sent.ogg")
wx.CallAfter(output.speak, _("Message sent."))
wx.CallAfter(self.start_stream, True, False)
except Exception:
log.exception("Error sending chat message")
wx.CallAfter(output.speak, _("Failed to send message."), True)
call_threaded(do_send)
if hasattr(post.message, "Destroy"):
post.message.Destroy()
def reply(self, *args, **kwargs):
"""Handle reply action (from menu or keyboard shortcut)."""
self.on_reply(None)
def send_message(self, *args, **kwargs):
# Global shortcut for DM
self.on_reply(None)
"""Global shortcut for DM."""
self.on_reply(None)
def remove_buffer(self, force=False):
"""Allow removing this buffer."""
from wxUI import commonMessageDialogs
if force == False:
dlg = commonMessageDialogs.remove_buffer()
else:
dlg = widgetUtils.YES
if dlg == widgetUtils.YES:
if self.name in self.session.db:
self.session.db.pop(self.name)
return True
elif dlg == widgetUtils.NO:
return False

View File

@@ -128,6 +128,67 @@ class NotificationBuffer(BaseBuffer):
self.buffer = BlueskiPanels.NotificationPanel(parent, name)
self.buffer.session = self.session
def _hydrate_notifications(self, notifications):
"""Fetch subject post text for like/repost notifications."""
if not notifications:
return notifications
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
# Collect URIs for likes/reposts that need subject post text
uris_to_fetch = []
for notif in notifications:
reason = g(notif, "reason", "")
if reason in ("like", "repost"):
reason_subject = g(notif, "reasonSubject") or g(notif, "reason_subject")
if reason_subject and isinstance(reason_subject, str):
uris_to_fetch.append(reason_subject)
if not uris_to_fetch:
return notifications
# Fetch posts in batch
posts_map = {}
try:
api = self.session._ensure_client()
if api and uris_to_fetch:
# getPosts accepts up to 25 URIs at a time
for i in range(0, len(uris_to_fetch), 25):
batch = uris_to_fetch[i:i+25]
res = api.app.bsky.feed.get_posts({"uris": batch})
for post in getattr(res, "posts", []):
uri = g(post, "uri")
if uri:
record = g(post, "record", {})
text = g(record, "text", "")
posts_map[uri] = text
except Exception as e:
log.error("Error fetching subject posts for notifications: %s", e)
# Attach subject post text to notifications
enriched = []
for notif in notifications:
reason = g(notif, "reason", "")
if reason in ("like", "repost"):
reason_subject = g(notif, "reasonSubject") or g(notif, "reason_subject")
if reason_subject and reason_subject in posts_map:
# Create a modified notification with subject post text
if isinstance(notif, dict):
notif = dict(notif)
notif["_subject_text"] = posts_map[reason_subject]
else:
# For ATProto model objects, add as attribute
try:
notif._subject_text = posts_map[reason_subject]
except AttributeError:
pass
enriched.append(notif)
return enriched
def start_stream(self, mandatory=False, play_sound=True):
count = self.get_max_items()
api = self.session._ensure_client()
@@ -139,6 +200,7 @@ class NotificationBuffer(BaseBuffer):
self.next_cursor = getattr(res, "cursor", None)
if not notifications:
return 0
notifications = self._hydrate_notifications(notifications)
return self.process_items(notifications, play_sound)
except Exception as e:
log.error("Error fetching notifications: %s", e)
@@ -155,6 +217,7 @@ class NotificationBuffer(BaseBuffer):
res = api.app.bsky.notification.list_notifications({"limit": count, "cursor": self.next_cursor})
notifications = list(getattr(res, "notifications", []))
self.next_cursor = getattr(res, "cursor", None)
notifications = self._hydrate_notifications(notifications)
added = self.process_items(notifications, play_sound=False)
if added:
output.speak(_(u"%s items retrieved") % added, True)
@@ -162,7 +225,8 @@ class NotificationBuffer(BaseBuffer):
log.error("Error fetching more notifications: %s", e)
def add_new_item(self, notification):
return self.process_items([notification], play_sound=True)
notifications = self._hydrate_notifications([notification])
return self.process_items(notifications, play_sound=True)
class Conversation(BaseBuffer):

View File

@@ -290,6 +290,7 @@ class Controller(object):
self.started = True
if len(self.accounts) > 0:
b = self.get_first_buffer(self.accounts[0])
self.menubar_current_handler = b.session.type
self.update_menus(handler=self.get_handler(b.session.type))
def _start_session_buffers(self, session):
@@ -698,8 +699,13 @@ class Controller(object):
def send_dm(self, *args, **kwargs):
buffer = self.get_current_buffer()
if buffer is None:
output.speak(_("No buffer selected."), True)
return
if hasattr(buffer, "send_message"):
buffer.send_message()
else:
output.speak(_("Cannot send messages from this buffer."), True)
def post_retweet(self, *args, **kwargs):
buffer = self.get_current_buffer()
@@ -959,21 +965,81 @@ class Controller(object):
self.view.check_menuitem("autoread", autoread)
def update_menus(self, handler):
# Initialize storage for hidden menu items if not present
if not hasattr(self, "_hidden_menu_items"):
self._hidden_menu_items = {}
if not hasattr(self, "_original_menu_labels"):
self._original_menu_labels = {}
if hasattr(handler, "menus"):
for m in list(handler.menus.keys()):
if hasattr(self.view, m):
menu_item = getattr(self.view, m)
# Store original label on first encounter
if m not in self._original_menu_labels:
self._original_menu_labels[m] = menu_item.GetItemLabel()
if handler.menus[m] == "HIDE":
menu_item.Enable(False)
menu_item.SetItemLabel("")
# Actually hide the menu item by removing it from parent menu
if m not in self._hidden_menu_items:
try:
parent_menu = menu_item.GetMenu()
if parent_menu:
# Store menu item info for restoration
position = self._find_menu_item_position(parent_menu, menu_item)
item_id = menu_item.GetId()
# Remove returns the removed item - store that reference
removed_item = parent_menu.Remove(item_id)
if removed_item:
self._hidden_menu_items[m] = {
"menu": parent_menu,
"item": removed_item,
"position": position
}
except Exception as e:
log.error(f"Error hiding menu item {m}: {e}")
elif handler.menus[m] == None:
# Restore if hidden, then disable
self._restore_menu_item(m)
menu_item.Enable(False)
else:
# Restore if hidden, then enable with new label
self._restore_menu_item(m)
menu_item.Enable(True)
menu_item.SetItemLabel(handler.menus[m])
if hasattr(handler, "item_menu"):
self.view.menubar.SetMenuLabel(1, handler.item_menu)
def _find_menu_item_position(self, menu, item):
"""Find the position of a menu item within its parent menu."""
for i, menu_item in enumerate(menu.GetMenuItems()):
if menu_item.GetId() == item.GetId():
return i
return -1
def _restore_menu_item(self, name):
"""Restore a previously hidden menu item."""
if not hasattr(self, "_hidden_menu_items"):
return
if name not in self._hidden_menu_items:
return
info = self._hidden_menu_items[name]
parent_menu = info["menu"]
item = info["item"]
position = info["position"]
try:
# Re-insert at original position
if position >= 0 and position < parent_menu.GetMenuItemCount():
parent_menu.Insert(position, item)
else:
parent_menu.Append(item)
# Restore original label if available
if hasattr(self, "_original_menu_labels") and name in self._original_menu_labels:
item.SetItemLabel(self._original_menu_labels[name])
except Exception as e:
log.error(f"Error restoring menu item {name}: {e}")
del self._hidden_menu_items[name]
def fix_wrong_buffer(self):
buf = self.get_best_buffer()
if buf == None: