diff --git a/src/blueski.defaults b/src/blueski.defaults index b6860f10..cbcff1ac 100644 --- a/src/blueski.defaults +++ b/src/blueski.defaults @@ -45,7 +45,7 @@ speech_reporting = boolean(default=True) [templates] post = string(default="$display_name, $safe_text $date.") -person = string(default="$display_name (@$screen_name). $followers followers, $following following, $posts posts.") +person = string(default="$display_name (@$screen_name). $followers followers, $following following, $posts posts. Joined $created_at.") notification = string(default="$display_name $text, $date") [filters] diff --git a/src/controller/blueski/handler.py b/src/controller/blueski/handler.py index 334a159a..3601df53 100644 --- a/src/controller/blueski/handler.py +++ b/src/controller/blueski/handler.py @@ -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"]) diff --git a/src/controller/buffers/blueski/base.py b/src/controller/buffers/blueski/base.py index 71beb9ac..1e82d75a 100644 --- a/src/controller/buffers/blueski/base.py +++ b/src/controller/buffers/blueski/base.py @@ -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): diff --git a/src/controller/buffers/blueski/chat.py b/src/controller/buffers/blueski/chat.py index 64a58132..607f6257 100644 --- a/src/controller/buffers/blueski/chat.py +++ b/src/controller/buffers/blueski/chat.py @@ -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 diff --git a/src/controller/buffers/blueski/timeline.py b/src/controller/buffers/blueski/timeline.py index 23549982..1dc87579 100644 --- a/src/controller/buffers/blueski/timeline.py +++ b/src/controller/buffers/blueski/timeline.py @@ -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): diff --git a/src/controller/mainController.py b/src/controller/mainController.py index 10ea6756..d1d8e95c 100644 --- a/src/controller/mainController.py +++ b/src/controller/mainController.py @@ -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: diff --git a/src/sessions/blueski/compose.py b/src/sessions/blueski/compose.py index a6fe36b2..73e686a8 100644 --- a/src/sessions/blueski/compose.py +++ b/src/sessions/blueski/compose.py @@ -222,42 +222,65 @@ def compose_notification(notification, db, settings, relative_times, show_screen # Notification reason/type reason = g(notification, "reason", "unknown") - # Get post text if available + # Get post text - try multiple locations depending on notification type record = g(notification, "record", {}) + post_text = "" + + # For mentions, replies, quotes: text is in the record itself post_text = g(record, "text", "") - # Format like Mastodon: "{username} has action: {status}" + # For likes and reposts: try to get the subject post text + if not post_text and reason in ("like", "repost"): + # First check for hydrated subject text (added by NotificationBuffer) + post_text = g(notification, "_subject_text", "") + + # Check if there's a reasonSubject with embedded post data + if not post_text: + reason_subject = g(notification, "reasonSubject") or g(notification, "reason_subject") + if reason_subject: + # Sometimes the subject post is embedded + subject_record = g(reason_subject, "record", {}) + post_text = g(subject_record, "text", "") + + # Check if there's subject post data in other locations + if not post_text: + subject = g(record, "subject", {}) + subject_text = g(subject, "text", "") + if subject_text: + post_text = subject_text + + # Format: action text without username (username is already in column 0) if reason == "like": if post_text: - text = _("{username} has added to favorites: {status}").format(username=user_str, status=post_text) + text = _("has added to favorites: {status}").format(status=post_text) else: - text = _("{username} has added to favorites").format(username=user_str) + text = _("has added to favorites") elif reason == "repost": if post_text: - text = _("{username} has reposted: {status}").format(username=user_str, status=post_text) + text = _("has reposted: {status}").format(status=post_text) else: - text = _("{username} has reposted").format(username=user_str) + text = _("has reposted") elif reason == "follow": - text = _("{username} has followed you.").format(username=user_str) + text = _("has followed you.") elif reason == "mention": if post_text: - text = _("{username} has mentioned you: {status}").format(username=user_str, status=post_text) + text = _("has mentioned you: {status}").format(status=post_text) else: - text = _("{username} has mentioned you").format(username=user_str) + text = _("has mentioned you") elif reason == "reply": if post_text: - text = _("{username} has replied: {status}").format(username=user_str, status=post_text) + text = _("has replied: {status}").format(status=post_text) else: - text = _("{username} has replied").format(username=user_str) + text = _("has replied") elif reason == "quote": if post_text: - text = _("{username} has quoted your post: {status}").format(username=user_str, status=post_text) + text = _("has quoted your post: {status}").format(status=post_text) else: - text = _("{username} has quoted your post").format(username=user_str) + text = _("has quoted your post") elif reason == "starterpack-joined": - text = _("{username} has joined your starter pack.").format(username=user_str) + text = _("has joined your starter pack.") else: - text = f"{user_str}: {reason}" + text = reason # Date indexed_at = g(notification, "indexedAt", "") or g(notification, "indexed_at", "") @@ -315,10 +338,10 @@ def compose_user(user, db, settings, relative_times, show_screen_names=False, sa ts = "" # Format like Mastodon: "Name (@handle). X followers, Y following, Z posts. Joined date" - if ts: - return [_("%s (@%s). %s followers, %s following, %s posts. Joined %s") % (display_name, handle, followers, following, posts, ts)] - else: - return [_("%s (@%s). %s followers, %s following, %s posts.") % (display_name, handle, followers, following, posts)] + # Use the exact same translatable string as Mastodon (sessions/mastodon/compose.py) + if not ts: + ts = _("unknown") + return [_("%s (@%s). %s followers, %s following, %s posts. Joined %s") % (display_name, handle, followers, following, posts, ts)] def compose_convo(convo, db, settings, relative_times, show_screen_names=False, safe=True): diff --git a/src/sessions/blueski/session.py b/src/sessions/blueski/session.py index b2e77a44..a23e82e6 100644 --- a/src/sessions/blueski/session.py +++ b/src/sessions/blueski/session.py @@ -528,12 +528,18 @@ class Session(base.baseSession): api = self._ensure_client() if not actors: return {"items": []} - try: - res = api.app.bsky.actor.get_profiles({"actors": actors}) - return {"items": getattr(res, "profiles", []) or []} - except Exception: - log.exception("Error fetching Bluesky profiles batch") - return {"items": []} + # API limit is 25 actors per request, batch if needed + all_profiles = [] + batch_size = 25 + for i in range(0, len(actors), batch_size): + batch = actors[i:i + batch_size] + try: + res = api.app.bsky.actor.get_profiles({"actors": batch}) + profiles = getattr(res, "profiles", []) or [] + all_profiles.extend(profiles) + except Exception: + log.exception("Error fetching Bluesky profiles batch") + return {"items": all_profiles} def get_post_likes(self, uri: str, limit: int = 50, cursor: str | None = None) -> dict[str, Any]: api = self._ensure_client() @@ -734,24 +740,57 @@ class Session(base.baseSession): api = self._ensure_client() # Chat API requires using the chat proxy dm_client = api.with_bsky_chat_proxy() - res = dm_client.chat.bsky.convo.list_convos({"limit": limit, "cursor": cursor}) - return {"items": res.convos, "cursor": res.cursor} + dm = dm_client.chat.bsky.convo + params = {"limit": limit} + if cursor: + params["cursor"] = cursor + try: + res = dm.list_convos(params) + return {"items": res.convos, "cursor": getattr(res, "cursor", None)} + except Exception: + log.exception("Error listing conversations") + return {"items": [], "cursor": None} def get_convo_messages(self, convo_id: str, limit: int = 50, cursor: str | None = None) -> dict[str, Any]: api = self._ensure_client() dm_client = api.with_bsky_chat_proxy() - res = dm_client.chat.bsky.convo.get_messages({"convoId": convo_id, "limit": limit, "cursor": cursor}) - return {"items": res.messages, "cursor": res.cursor} + dm = dm_client.chat.bsky.convo + params = {"convoId": convo_id, "limit": limit} + if cursor: + params["cursor"] = cursor + try: + res = dm.get_messages(params) + return {"items": res.messages, "cursor": getattr(res, "cursor", None)} + except Exception: + log.exception("Error getting conversation messages") + return {"items": [], "cursor": None} def send_chat_message(self, convo_id: str, text: str) -> Any: api = self._ensure_client() dm_client = api.with_bsky_chat_proxy() - return dm_client.chat.bsky.convo.send_message({ - "convoId": convo_id, - "message": { - "text": text - } - }) + dm = dm_client.chat.bsky.convo + try: + return dm.send_message({ + "convoId": convo_id, + "message": { + "text": text + } + }) + except Exception: + log.exception("Error sending chat message") + raise + + def get_or_create_convo(self, members: list[str]) -> dict[str, Any] | None: + """Get or create a conversation with the given members (DIDs).""" + api = self._ensure_client() + dm_client = api.with_bsky_chat_proxy() + dm = dm_client.chat.bsky.convo + try: + res = dm.get_convo_for_members({"members": members}) + return res.convo + except Exception: + log.exception("Error getting/creating conversation") + return None # Streaming/Polling methods diff --git a/src/sessions/blueski/templates.py b/src/sessions/blueski/templates.py index a7b234bf..ebb5b61d 100644 --- a/src/sessions/blueski/templates.py +++ b/src/sessions/blueski/templates.py @@ -227,32 +227,42 @@ def render_notification(notification, template, post_template, settings, relativ screen_name = _g(author, "handle", "") reason = _g(notification, "reason", "unknown") record = _g(notification, "record") or {} + + # Get post text - try multiple locations depending on notification type post_text = _g(record, "text", "") or "" + # For likes and reposts: try to get the subject post text + if not post_text and reason in ("like", "repost"): + # First check for hydrated subject text (added by NotificationBuffer) + post_text = _g(notification, "_subject_text", "") or "" + + # Check if there's a reasonSubject with embedded post data + if not post_text: + reason_subject = _g(notification, "reasonSubject") or _g(notification, "reason_subject") + if reason_subject: + subject_record = _g(reason_subject, "record", {}) + post_text = _g(subject_record, "text", "") or "" + + # Check subject in record + if not post_text: + subject = _g(record, "subject", {}) + post_text = _g(subject, "text", "") or "" + + # Format: action text without username (username is already in display_name for template) if reason == "like": - text = _("{username} has added to favorites: {status}").format( - username=display_name, status=post_text - ) if post_text else _("{username} has added to favorites").format(username=display_name) + text = _("has added to favorites: {status}").format(status=post_text) if post_text else _("has added to favorites") elif reason == "repost": - text = _("{username} has reposted: {status}").format( - username=display_name, status=post_text - ) if post_text else _("{username} has reposted").format(username=display_name) + text = _("has reposted: {status}").format(status=post_text) if post_text else _("has reposted") elif reason == "follow": - text = _("{username} has followed you.").format(username=display_name) + text = _("has followed you.") elif reason == "mention": - text = _("{username} has mentioned you: {status}").format( - username=display_name, status=post_text - ) if post_text else _("{username} has mentioned you").format(username=display_name) + text = _("has mentioned you: {status}").format(status=post_text) if post_text else _("has mentioned you") elif reason == "reply": - text = _("{username} has replied: {status}").format( - username=display_name, status=post_text - ) if post_text else _("{username} has replied").format(username=display_name) + text = _("has replied: {status}").format(status=post_text) if post_text else _("has replied") elif reason == "quote": - text = _("{username} has quoted your post: {status}").format( - username=display_name, status=post_text - ) if post_text else _("{username} has quoted your post").format(username=display_name) + text = _("has quoted your post: {status}").format(status=post_text) if post_text else _("has quoted your post") else: - text = "{user}: {reason}".format(user=display_name or screen_name, reason=reason) + text = reason indexed_at = _g(notification, "indexedAt") or _g(notification, "indexed_at") date = process_date(indexed_at, relative_times, offset_hours) if indexed_at else "" diff --git a/src/wxUI/buffers/blueski/panels.py b/src/wxUI/buffers/blueski/panels.py index 8177ab3b..950ef910 100644 --- a/src/wxUI/buffers/blueski/panels.py +++ b/src/wxUI/buffers/blueski/panels.py @@ -105,27 +105,44 @@ class UserPanel(wx.Panel): self.list.list.SetFocus() class ChatPanel(wx.Panel): + """Panel for conversation list, similar to Mastodon's conversationListPanel.""" def __init__(self, parent, name, account="Unknown"): super().__init__(parent, name=name) self.name = name self.account = account self.type = "chat" - + self.sizer = wx.BoxSizer(wx.VERTICAL) - # List: Participants, Last Message, Date - self.list = widgets.list(self, _("Participants"), _("Last Message"), _("Date"), style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_VRULES) + # List: User, Text, Date (like Mastodon) + self.list = widgets.list(self, _("User"), _("Text"), _("Date"), style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_VRULES) self.list.set_windows_size(0, 200) self.list.set_windows_size(1, 600) self.list.set_windows_size(2, 200) self.list.set_size() - + + # Buttons (like Mastodon: Post, Reply) + self.post = wx.Button(self, -1, _("Post")) + self.reply = wx.Button(self, -1, _("Reply")) + self.new_chat = wx.Button(self, -1, _("New Chat")) + btnSizer = wx.BoxSizer(wx.HORIZONTAL) + btnSizer.Add(self.post, 0, wx.ALL, 5) + btnSizer.Add(self.reply, 0, wx.ALL, 5) + btnSizer.Add(self.new_chat, 0, wx.ALL, 5) + self.sizer.Add(btnSizer, 0, wx.ALL, 5) + self.sizer.Add(self.list.list, 1, wx.EXPAND | wx.ALL, 5) self.SetSizer(self.sizer) def set_focus_function(self, func): self.list.list.Bind(wx.EVT_LIST_ITEM_FOCUSED, func) + def set_position(self, reversed=False): + if reversed == False: + self.list.select_item(self.list.get_count()-1) + else: + self.list.select_item(0) + def set_focus_in_list(self): self.list.list.SetFocus() @@ -136,6 +153,7 @@ class ChatMessagePanel(HomePanel): # Adjust buttons for chat self.repost.Hide() self.like.Hide() + self.dm.Hide() # Hide Chat button since we're already in a chat self.reply.SetLabel(_("Send Message")) # Refresh columns