Files
twblue/src/controller/buffers/blueski/chat.py
T
Jesús Pavón Abián e115464cc8 Fix unknown sender names in Bluesky chats, reduce false notifications, and reorder Chats buffer
Resolve sender DIDs to display names by building member maps from conversation
data. Fix compose functions to prefer snake_case attributes (ATProto SDK convention).
Ensure stable key comparison in dedup logic by converting ATProto objects to strings.
Move Chats buffer to appear after Mentions and before Notifications.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 08:46:17 +01:00

560 lines
23 KiB
Python

# -*- 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
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 _get_members_key(self, conversation):
"""Fallback key when convo id is missing: stable member DID list."""
members = getattr(conversation, "members", None) or (conversation.get("members") if isinstance(conversation, dict) else None) or []
dids = []
for m in members:
did = getattr(m, "did", None) or (m.get("did") if isinstance(m, dict) else None)
if did:
dids.append(did)
if not dids:
return None
dids.sort()
return tuple(dids)
def _get_last_message_key(self, conversation):
"""Key for detecting conversation updates based on last message."""
last_msg = getattr(conversation, "lastMessage", None) or (conversation.get("lastMessage") if isinstance(conversation, dict) else None)
if not last_msg:
return None
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
for attr in ("id", "messageId", "message_id", "msgId", "msg_id", "cid", "rev"):
val = g(last_msg, attr)
if val:
return str(val)
nested = g(last_msg, "message") or g(last_msg, "record")
if nested:
for attr in ("id", "messageId", "message_id", "msgId", "msg_id", "cid", "rev"):
val = g(nested, attr)
if val:
return str(val)
sent_at = g(last_msg, "sentAt") or g(last_msg, "sent_at") or g(last_msg, "createdAt") or g(last_msg, "created_at")
sender = g(last_msg, "sender") or (g(nested, "sender") if nested else {}) or {}
sender_did = g(sender, "did")
text = g(last_msg, "text") or (g(nested, "text") if nested else None)
if sent_at or sender_did or text:
return (str(sent_at) if sent_at else None, str(sender_did) if sender_did else None, str(text) if text else None)
return None
def get_formatted_message(self):
"""Return last message text for current conversation."""
conversation = self.get_conversation()
if not conversation:
return None
return self.compose_function(
conversation,
self.session.db,
self.session.settings,
self.session.settings["general"].get("relative_times", False),
self.session.settings["general"].get("show_screen_names", False),
)[1]
def get_message(self):
"""Return a readable summary for the selected conversation."""
conversation = self.get_conversation()
if not conversation:
return None
composed = self.compose_function(
conversation,
self.session.db,
self.session.settings,
self.session.settings["general"].get("relative_times", False),
self.session.settings["general"].get("show_screen_names", False),
)
return " ".join(composed)
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 under direct_messages node
wx.CallAfter(self._create_chat_buffer, self.controller, title, convo_id)
# 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, avoid_autoreading=False):
count = self.get_max_items()
try:
res = self.session.list_convos(limit=count)
items = res.get("items", [])
self._build_member_maps(items)
return self._merge_conversations(items, play_sound, avoid_autoreading=avoid_autoreading)
except Exception:
log.exception("Error fetching conversations")
output.speak(_("Error loading conversations."), True)
return 0
def _build_member_maps(self, convos):
"""Build DID→name maps from conversation members and store in db for chat buffers."""
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
for convo in convos:
convo_id = self.get_convo_id(convo)
if not convo_id:
continue
members = g(convo, "members", []) or []
member_map = {}
for m in members:
did = g(m, "did", None)
if did:
name = g(m, "display_name") or g(m, "displayName") or g(m, "handle", "unknown")
member_map[did] = name
if member_map:
self.session.db["convo_" + str(convo_id) + "_members"] = member_map
def _merge_conversations(self, items, play_sound=True, avoid_autoreading=False):
"""Merge conversation list, updating items without duplicating or re-alerting."""
if self.session.db.get(self.name) is None:
self.session.db[self.name] = []
# Track current selection to restore after refresh
selected_key = None
current_convo = self.get_conversation()
if current_convo:
selected_key = self.get_convo_id(current_convo) or self._get_members_key(current_convo)
existing = {}
existing_last = {}
for convo in self.session.db[self.name]:
key = self.get_convo_id(convo) or self._get_members_key(convo)
if key is None:
continue
existing[key] = convo
existing_last[key] = self._get_last_message_key(convo)
new_db = []
new_count = 0
first_load = len(self.session.db[self.name]) == 0
for convo in items:
key = self.get_convo_id(convo) or self._get_members_key(convo)
new_db.append(convo)
if key is None:
if first_load:
new_count += 1
continue
if key not in existing:
new_count += 1
continue
if self._get_last_message_key(convo) != existing_last.get(key):
new_count += 1
# Replace DB with latest ordered list from API
self.session.db[self.name] = new_db
# Rebuild list UI to keep ordering consistent with API
self.buffer.list.clear()
safe = True
relative_times = self.session.settings["general"].get("relative_times", False)
show_screen_names = self.session.settings["general"].get("show_screen_names", False)
for convo in new_db:
row = self.compose_function(convo, self.session.db, self.session.settings, relative_times, show_screen_names, safe=safe)
self.buffer.list.insert_item(False, *row)
# Restore selection if possible
if selected_key is not None:
for idx, convo in enumerate(new_db):
key = self.get_convo_id(convo) or self._get_members_key(convo)
if key == selected_key:
self.buffer.list.select_item(idx)
break
# Sound and auto-read only when something actually changed
if new_count > 0:
if play_sound and self.sound and not self.session.settings["sound"].get("session_mute", False):
self.session.sound.play(self.sound)
if not avoid_autoreading:
self.auto_read(new_count)
return new_count
def fav(self):
pass
def unfav(self):
pass
def can_share(self):
return False
def url(self, *args, **kwargs):
"""Enter key opens the chat conversation buffer."""
self.view_chat()
def send_message(self, *args, **kwargs):
"""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):
"""Open the conversation in a separate buffer (nested under Chats node)."""
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(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)
self._create_chat_buffer(self.controller, title, convo_id)
def _create_chat_buffer(self, controller, title, convo_id):
"""Create a chat buffer under the direct_messages node, avoiding duplicates."""
account_name = self.session.get_name()
# Avoid duplicates: if buffer already exists, navigate to it
existing = controller.search_buffer(title, account_name)
if existing:
index = controller.view.search(title, account_name)
if index is not None:
controller.view.change_buffer(index)
return
# Insert under direct_messages node (like Mastodon's ConversationBuffer)
chats_position = controller.view.search("direct_messages", account_name)
if chats_position is None:
chats_position = controller.view.search(account_name, account_name)
controller.create_buffer(
buffer_type="chat_messages",
session_type="blueski",
buffer_title=title,
parent_tab=chats_position,
kwargs={"session": self.session, "convo_id": convo_id, "name": title},
start=True
)
# Navigate to the newly created buffer and announce it
new_index = controller.view.search(title, account_name)
if new_index is not None:
controller.view.change_buffer(new_index)
buffer_obj = controller.search_buffer(title, account_name)
if buffer_obj and hasattr(buffer_obj.buffer, "list"):
try:
count = buffer_obj.buffer.list.get_count()
if count > 0:
msg = _("{0}, {1} of {2}").format(title, buffer_obj.buffer.list.get_selected()+1, count)
else:
msg = _("{0}. Empty").format(title)
except Exception:
msg = _("{0}. Empty").format(title)
output.speak(msg, 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"
self._member_map_loaded = False
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.ChatMessagePanel(parent, name)
self.buffer.session = self.session
def _update_member_map(self):
"""Fetch conversation members to build a DID-to-name map for sender resolution."""
try:
convo = self.session.get_convo(self.convo_id)
if not convo:
return
member_map = {}
for m in getattr(convo, "members", []) or []:
did = getattr(m, "did", None)
if did:
name = getattr(m, "display_name", None) or getattr(m, "handle", None) or "unknown"
member_map[did] = name
self.session.db[self.name + "_members"] = member_map
except Exception:
log.exception("Error fetching conversation members for DID resolution")
def start_stream(self, mandatory=False, play_sound=True):
if not self.convo_id:
return 0
if not self._member_map_loaded:
self._update_member_map()
self._member_map_loaded = True
count = self.get_max_items()
try:
res = self.session.get_convo_messages(self.convo_id, limit=count)
items = res.get("items", [])
items = list(reversed(items))
return self.process_items(items, play_sound)
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 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:
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)
def get_message(self):
"""Return a readable summary for the selected chat message."""
item = self.get_item()
if item is None:
return None
composed = self.compose_function(
item, self.session.db, self.session.settings,
self.session.settings["general"].get("relative_times", False),
self.session.settings["general"].get("show_screen_names", False),
)
return " ".join(composed)
def get_formatted_message(self):
"""Return the text content of the selected chat message."""
item = self.get_item()
if item is None:
return None
composed = self.compose_function(
item, self.session.db, self.session.settings,
self.session.settings["general"].get("relative_times", False),
self.session.settings["general"].get("show_screen_names", False),
)
return composed[1]
def view_item(self, item=None):
"""View the selected chat message in a dialog."""
msg = self.get_formatted_message()
if not msg:
output.speak(_("No message selected."), True)
return
viewer = blueski_messages.text(title=_("Chat message"), text=msg)
viewer.message.ShowModal()
viewer.message.Destroy()
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