Files
twblue/src/controller/buffers/blueski/base.py
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

1230 lines
50 KiB
Python

# -*- coding: utf-8 -*-
import logging
import wx
import arrow
import output
import sound
import config
import widgetUtils
import languageHandler
from pubsub import pub
from controller.buffers.base import base
from controller.blueski import messages as blueski_messages
from sessions.blueski import compose, utils, templates
from mysc.thread_utils import call_threaded
from wxUI.buffers.blueski import panels as BlueskiPanels
from wxUI import commonMessageDialogs
from wxUI.dialogs.blueski import menus
log = logging.getLogger("controller.buffers.blueski.base")
class BaseBuffer(base.Buffer):
def __init__(self, parent=None, name=None, session=None, *args, **kwargs):
# Adapt params to BaseBuffer
# BaseBuffer expects (parent, function, name, sessionObject, account)
function = "timeline" # Dummy
sessionObject = session
account = session.get_name() if session else "Unknown"
super(BaseBuffer, self).__init__(parent, function, name=name, sessionObject=sessionObject, account=account, *args, **kwargs)
self.session = sessionObject
self.account = account
self.name = name
self.create_buffer(parent, name)
self.buffer.account = account
self.invisible = True
compose_func = kwargs.get("compose_func", "compose_post")
self.compose_function = getattr(compose, compose_func)
self.sound = kwargs.get("sound", None)
# Initialize DB list if needed
if self.name not in self.session.db:
self.session.db[self.name] = []
self.bind_events()
def get_max_items(self):
"""Get max items per call from settings."""
return self.session.settings["general"]["max_posts_per_call"]
def create_buffer(self, parent, name):
# Default to HomePanel, can be overridden
self.buffer = BlueskiPanels.HomePanel(parent, name, account=self.account)
self.buffer.session = self.session
def bind_events(self):
# Bind essential events
log.debug("Binding events for buffer %s" % self.name)
self.buffer.set_focus_function(self.onFocus)
widgetUtils.connect_event(self.buffer.list.list, widgetUtils.KEYPRESS, self.get_event)
widgetUtils.connect_event(self.buffer.list.list, 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"):
self.buffer.post.Bind(wx.EVT_BUTTON, self.on_post)
if hasattr(self.buffer, "reply"):
self.buffer.reply.Bind(wx.EVT_BUTTON, self.on_reply)
if hasattr(self.buffer, "repost"):
self.buffer.repost.Bind(wx.EVT_BUTTON, self.on_repost)
if hasattr(self.buffer, "like"):
self.buffer.like.Bind(wx.EVT_BUTTON, self.on_like)
if hasattr(self.buffer, "dm"):
self.buffer.dm.Bind(wx.EVT_BUTTON, self.on_dm)
if hasattr(self.buffer, "actions"):
self.buffer.actions.Bind(wx.EVT_BUTTON, self.user_actions)
def get_buffer_name(self):
"""Get human-readable buffer name."""
basic_buffers = dict(
home_timeline=_("Home"),
notifications=_("Notifications"),
mentions=_("Mentions"),
sent=_("Sent"),
likes=_("Likes"),
chats=_("Chats"),
)
if self.name in basic_buffers:
return basic_buffers[self.name]
if hasattr(self, "username"):
if "timeline" in self.name.lower():
return _("{username}'s timeline").format(username=self.username)
if "followers" in self.name.lower():
return _("{username}'s followers").format(username=self.username)
if "following" in self.name.lower():
return _("{username}'s following").format(username=self.username)
return self.name
def onFocus(self, *args, **kwargs):
"""Handle focus event for accessibility features."""
post = self.get_item()
if not post:
return
# Update relative time display
if self.session.settings["general"].get("relative_times", False):
try:
index = self.buffer.list.get_selected()
if index < 0:
return
# 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):
return obj.get(key, default)
return getattr(obj, key, default)
actual_post = g(post, "post", post)
indexed_at = g(actual_post, "indexed_at", "") or g(actual_post, "indexedAt", "")
if indexed_at:
original_date = arrow.get(indexed_at)
ts = original_date.humanize(locale=languageHandler.curLang[:2])
self.buffer.list.list.SetItem(index, 2, ts)
except Exception as e:
log.error("Error updating relative time on focus: %s", e)
# Read long posts in GUI
if config.app["app-settings"].get("read_long_posts_in_gui", False) and self.buffer.list.list.HasFocus():
wx.CallLater(40, output.speak, self.get_message(), interrupt=True)
# Audio/video indicator sound
if self.session.settings["sound"].get("indicate_audio", False) and utils.is_audio_or_video(post):
self.session.sound.play("audio.ogg")
# Image indicator sound
if self.session.settings["sound"].get("indicate_img", False) and utils.is_image(post):
self.session.sound.play("image.ogg")
def auto_read(self, number_of_items):
"""Automatically read new items for accessibility."""
if number_of_items == 0:
return
if self.name in self.session.settings["other_buffers"].get("muted_buffers", []):
return
if self.session.settings["sound"].get("session_mute", False):
return
if self.name not in self.session.settings["other_buffers"].get("autoread_buffers", []):
return
safe = True
relative_times = self.session.settings["general"].get("relative_times", False)
show_screen_names = self.session.settings["general"].get("show_screen_names", False)
if number_of_items == 1:
if self.session.settings["general"].get("reverse_timelines", False):
post = self.session.db[self.name][0]
else:
post = self.session.db[self.name][-1]
output.speak(_("New post in {0}").format(self.get_buffer_name()))
output.speak(" ".join(self.compose_function(post, self.session.db, self.session.settings, relative_times, show_screen_names, safe=safe)))
elif number_of_items > 1:
output.speak(_("{0} new posts in {1}.").format(number_of_items, self.get_buffer_name()))
def show_menu(self, ev, pos=0, *args, **kwargs):
"""Show context menu for current item."""
if self.buffer.list.get_count() == 0:
return
menu = menus.baseMenu()
widgetUtils.connect_event(menu, widgetUtils.MENU, self.reply, menuitem=menu.reply)
widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.repost)
if hasattr(menu, "quote"):
widgetUtils.connect_event(menu, widgetUtils.MENU, self.quote, menuitem=menu.quote)
widgetUtils.connect_event(menu, widgetUtils.MENU, self.add_to_favorites, menuitem=menu.like)
widgetUtils.connect_event(menu, widgetUtils.MENU, self.user_actions, menuitem=menu.userActions)
widgetUtils.connect_event(menu, widgetUtils.MENU, self.url_, menuitem=menu.openUrl)
if hasattr(menu, "openInBrowser"):
widgetUtils.connect_event(menu, widgetUtils.MENU, self.open_in_browser, menuitem=menu.openInBrowser)
widgetUtils.connect_event(menu, widgetUtils.MENU, self.view, menuitem=menu.view)
widgetUtils.connect_event(menu, widgetUtils.MENU, self.copy, menuitem=menu.copy)
widgetUtils.connect_event(menu, widgetUtils.MENU, self.destroy_status, menuitem=menu.remove)
if pos != 0:
self.buffer.PopupMenu(menu, pos)
else:
self.buffer.PopupMenu(menu, self.buffer.list.list.GetPosition())
def show_menu_by_key(self, ev):
"""Show context menu when pressing menu key."""
if self.buffer.list.get_count() == 0:
return
if ev.GetKeyCode() == wx.WXK_WINDOWS_MENU:
self.show_menu(widgetUtils.MENU, pos=self.buffer.list.list.GetPosition())
def copy(self, *args, **kwargs):
"""Copy post to clipboard."""
pub.sendMessage("execute-action", action="copy_to_clipboard")
def on_post(self, evt):
dlg = blueski_messages.post(session=self.session, title=_("New Post"), caption=_("New Post"))
if dlg.message.ShowModal() == wx.ID_OK:
text, files, cw, langs = dlg.get_data()
self._send_post_async(
text=text,
files=files,
cw_text=cw,
langs=langs,
success_message=_("Sent."),
error_message=_("An error occurred while posting."),
sound="tweet_send.ogg",
refresh_args=(False, False),
)
dlg.message.Destroy()
def on_reply(self, evt):
item = self.get_item()
if not item: return
# item is a feed object or dict.
# We need its URI.
uri = self.get_selected_item_id()
if not uri:
uri = item.get("uri") if isinstance(item, dict) else getattr(item, "uri", None)
reply_cid = self.get_selected_item_cid()
# Attempt to get CID if present for consistency, though send_message handles it
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
author = g(item, "author")
if not author:
post = g(item, "post") or g(item, "record")
author = g(post, "author") if post else None
handle = g(author, "handle", "")
initial_text = f"@{handle} " if handle and not handle.startswith("@") else (f"{handle} " if handle else "")
dlg = blueski_messages.post(session=self.session, title=_("Reply"), caption=_("Reply"), text=initial_text)
if dlg.message.ShowModal() == wx.ID_OK:
text, files, cw, langs = dlg.get_data()
refresh_args = (True, False) if getattr(self, "type", "") == "conversation" else None
self._send_post_async(
text=text,
files=files,
cw_text=cw,
langs=langs,
reply_to=uri,
reply_to_cid=reply_cid,
success_message=_("Reply sent."),
error_message=_("An error occurred while replying."),
sound="reply_send.ogg",
refresh_args=refresh_args,
)
dlg.message.Destroy()
def _send_post_async(
self,
*,
text,
files,
cw_text,
langs,
reply_to=None,
reply_to_cid=None,
success_message="",
error_message="",
sound=None,
refresh_args=None,
):
if not text and not files:
return
def do_send():
try:
uri_resp = self.session.send_message(
message=text,
files=files,
reply_to=reply_to,
reply_to_cid=reply_to_cid,
cw_text=cw_text,
langs=langs,
)
if uri_resp:
if sound:
wx.CallAfter(self.session.sound.play, sound)
if success_message:
wx.CallAfter(output.speak, success_message)
if refresh_args and hasattr(self, "start_stream"):
try:
wx.CallAfter(self.start_stream, *refresh_args)
except Exception:
pass
else:
wx.CallAfter(output.speak, _("Failed to send post."), True)
except Exception:
log.exception("Error sending Bluesky post")
if error_message:
wx.CallAfter(output.speak, error_message, True)
else:
wx.CallAfter(output.speak, _("An error occurred while posting."), True)
call_threaded(do_send)
def on_repost(self, evt):
self.share_item()
def share_item(self, event=None, item=None, *args, **kwargs):
if item is None:
item = self.get_item()
if not item:
return
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
# Get the URI for reposting
uri = g(item, "uri") or g(g(item, "post"), "uri")
cid = g(item, "cid") or g(g(item, "post"), "cid")
if not uri:
output.speak(_("Could not find post to repost."), True)
return
# Check boost_mode setting
boost_mode = self.session.settings["general"].get("boost_mode", "ask")
if boost_mode == "ask":
from wxUI.dialogs.blueski.postDialogs import repost_question
answer = repost_question()
if answer == 1:
self._direct_repost(uri)
elif answer == 2:
self.quote(item=item)
else:
self._direct_repost(uri)
def _direct_repost(self, uri):
try:
self.session.repost(uri)
self.session.sound.play("retweet_send.ogg")
output.speak(_("Reposted."))
except Exception as e:
log.error("Error reposting: %s", e)
output.speak(_("Failed to repost."), True)
def quote(self, event=None, item=None, *args, **kwargs):
if item is None:
item = self.get_item()
if not item:
return
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
uri = g(item, "uri") or g(g(item, "post"), "uri")
if not uri:
output.speak(_("Could not find post to quote."), True)
return
title = _("Quote post")
caption = _("Write your comment here")
dlg = blueski_messages.post(session=self.session, title=title, caption=caption)
if dlg.message.ShowModal() == wx.ID_OK:
text, files, cw, langs = dlg.get_data()
if text or files:
def do_quote():
try:
result = self.session.send_message(
message=text,
files=files,
cw_text=cw,
langs=langs,
quote_uri=uri,
)
if result:
wx.CallAfter(self.session.sound.play, "retweet_send.ogg")
wx.CallAfter(output.speak, _("Quote posted."))
else:
wx.CallAfter(output.speak, _("Failed to post quote."), True)
except Exception as e:
log.error("Error posting quote: %s", e)
wx.CallAfter(output.speak, _("Failed to post quote."), True)
call_threaded(do_quote)
dlg.message.Destroy()
def on_like(self, evt):
self.toggle_favorite(confirm=False)
def toggle_favorite(self, confirm=False, *args, **kwargs):
item = self.get_item()
if not item:
output.speak(_("No item to like."), True)
return
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
uri = g(item, "uri")
if not uri:
post = g(item, "post") or g(item, "record")
uri = g(post, "uri") if post else None
if not uri:
output.speak(_("Could not find post identifier."), True)
return
if confirm:
if wx.MessageBox(_("Like this post?"), _("Confirm"), wx.YES_NO | wx.ICON_QUESTION) != wx.YES:
return
# Check if already liked
viewer = g(item, "viewer")
already_liked = g(viewer, "like") if viewer else None
if already_liked:
output.speak(_("Already liked."), True)
return
# Perform the like
like_uri = self.session.like(uri)
if not like_uri:
output.speak(_("Failed to like post."), True)
return
self.session.sound.play("favourite.ogg")
output.speak(_("Liked."))
# Update the viewer state in the item
if isinstance(item, dict):
if "viewer" not in item:
item["viewer"] = {}
item["viewer"]["like"] = like_uri
else:
# For SDK models, create or update viewer
if not hasattr(item, "viewer") or item.viewer is None:
# Create a simple object to hold the like state
class Viewer:
def __init__(self):
self.like = None
item.viewer = Viewer()
item.viewer.like = like_uri
# Refresh the displayed item in the list
try:
index = self.buffer.list.get_selected()
if index > -1:
# Recompose and update the list item
safe = True
relative_times = self.session.settings["general"].get("relative_times", False)
show_screen_names = self.session.settings["general"].get("show_screen_names", False)
post_data = self.compose_function(item, self.session.db, self.session.settings,
relative_times=relative_times,
show_screen_names=show_screen_names,
safe=safe)
# Update the item in place (only 3 columns: Author, Post, Date)
self.buffer.list.list.SetItem(index, 0, post_data[0]) # Author
self.buffer.list.list.SetItem(index, 1, post_data[1]) # Text
self.buffer.list.list.SetItem(index, 2, post_data[2]) # Date
# Note: compose_post returns 4 items but list only has 3 columns
except Exception as e:
log.error("Error refreshing list item after like: %s", e)
def add_to_favorites(self, *args, **kwargs):
self.toggle_favorite(confirm=False)
def remove_from_favorites(self, *args, **kwargs):
# We need unlike support in session
pass
def on_dm(self, evt):
self.send_message()
def send_message(self, *args, **kwargs):
# 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
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
# 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:
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()
def view_item(self, item=None):
if item is None:
item = self.get_item()
if not item:
return
if not blueski_messages.has_post_data(item):
pub.sendMessage("execute-action", action="user_details")
return
try:
blueski_messages.viewPost(self.session, item, controller=getattr(self, "controller", None))
except Exception as e:
log.error("Error opening Bluesky post viewer: %s", e)
def url_(self, *args, **kwargs):
self.url()
def url(self, announce=True, item=None, *args, **kwargs):
"""Open URLs found in the post content."""
if item is None:
item = self.get_item()
if not item:
return
import webbrowser
from wxUI.dialogs import urlList
urls = utils.find_urls(item)
url = ""
if len(urls) == 1:
url = urls[0]
elif len(urls) > 1:
urls_list = urlList.urlList()
urls_list.populate_list(urls)
if urls_list.get_response() == widgetUtils.OK:
url = urls_list.get_string()
if hasattr(urls_list, "destroy"):
urls_list.destroy()
if url != '':
if announce:
output.speak(_(u"Opening URL..."), True)
webbrowser.open_new_tab(url)
def user_actions(self, *args, **kwargs):
pub.sendMessage("execute-action", action="follow")
def view_chat_with_user(self, did, handle):
try:
api = self.session._ensure_client()
res = api.chat.bsky.convo.get_convo_for_members({"members": [did]})
convo_id = res.convo.id
title = _("Chat: {0}").format(handle)
self.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
)
except:
output.speak(_("Could not open chat."), True)
def block_user(self, *args, **kwargs):
item = self.get_item()
if not item: return
author = getattr(item, "author", None) or (item.get("post", {}).get("author") if isinstance(item, dict) else item)
did = getattr(author, "did", None) or (author.get("did") if isinstance(author, dict) else None)
handle = getattr(author, "handle", "unknown") or (author.get("handle") if isinstance(author, dict) else "unknown")
if wx.MessageBox(_("Are you sure you want to block {0}?").format(handle), _("Block"), wx.YES_NO | wx.ICON_WARNING) == wx.YES:
if self.session.block_user(did):
output.speak(_("User blocked."))
else:
output.speak(_("Failed to block user."))
def unblock_user(self, *args, **kwargs):
# Unblocking usually needs the block record URI.
# In a UserBuffer (Blocks), it might be present.
item = self.get_item()
if not item: return
# Check if item itself is a block record or user object with viewer.blocking
block_uri = None
if isinstance(item, dict):
block_uri = item.get("viewer", {}).get("blocking")
else:
viewer = getattr(item, "viewer", None)
block_uri = getattr(viewer, "blocking", None) if viewer else None
if not block_uri:
output.speak(_("Could not find block information for this user."), True)
return
if self.session.unblock_user(block_uri):
output.speak(_("User unblocked."))
else:
output.speak(_("Failed to unblock user."))
def put_items_on_list(self, number_of_items):
list_to_use = self.session.db[self.name]
count = self.buffer.list.get_count()
reverse = False
try:
reverse = self.session.settings["general"].get("reverse_timelines", False)
except: pass
if number_of_items == 0:
return
safe = True
relative_times = self.session.settings["general"].get("relative_times", False)
show_screen_names = self.session.settings["general"].get("show_screen_names", False)
if count == 0:
for i in list_to_use:
post = self.compose_function(i, self.session.db, self.session.settings, relative_times=relative_times, show_screen_names=show_screen_names, safe=safe)
self.buffer.list.insert_item(False, *post)
# Set selection
total = self.buffer.list.get_count()
if total > 0:
if not reverse:
self.buffer.list.select_item(total - 1) # Bottom
else:
self.buffer.list.select_item(0) # Top
elif count > 0 and number_of_items > 0:
if not reverse:
items = list_to_use[:number_of_items] # If we prepended items for normal (oldest first) timeline... wait.
# Standard flow: "New items" come from API.
# If standard timeline (oldest at top, newest at bottom): new items appended to DB.
# UI: append to bottom.
items = list_to_use[len(list_to_use)-number_of_items:]
for i in items:
post = self.compose_function(i, self.session.db, self.session.settings, relative_times=relative_times, show_screen_names=show_screen_names, safe=safe)
self.buffer.list.insert_item(False, *post)
else:
# Reverse timeline (Newest at top).
# New items appended to DB? Or inserted at 0?
# Mastodon BaseBuffer:
# if reverse_timelines == False: items_db.insert(0, i) (Wait, insert at 0?)
# Actually let's look at `get_more_items` in Mastodon BaseBuffer again.
# "if self.session.settings["general"]["reverse_timelines"] == False: items_db.insert(0, i)"
# This means for standard timeline, new items (newer time) go to index 0?
# No, standard timeline usually has oldest at top. Retrieve "more items" usually means "newer items" or "older items" depending on context (streaming vs styling).
# Let's trust that we just need to insert based on how we updated DB in start_stream.
# For now, simplistic approach:
items = list_to_use[0:number_of_items] # Assuming we inserted at 0 in DB
# items.reverse() if needed?
for i in items:
post = self.compose_function(i, self.session.db, self.session.settings, relative_times=relative_times, show_screen_names=show_screen_names, safe=safe)
self.buffer.list.insert_item(True, *post) # Insert at 0 (True)
def reply(self, *args, **kwargs):
self.on_reply(None)
def post_status(self, *args, **kwargs):
self.on_post(None)
def destroy_status(self, *args, **kwargs):
# Delete post
item = self.get_item()
if not item: return
uri = self.get_selected_item_id()
if not uri:
if isinstance(item, dict):
uri = item.get("uri") or item.get("post", {}).get("uri")
else:
post = getattr(item, "post", None)
uri = getattr(item, "uri", None) or getattr(post, "uri", None)
if not uri:
output.speak(_("Could not find the post identifier."), True)
return
# Check if author is self
# Implementation depends on parsing URI or checking active user DID vs author DID
# For now, just try and handle error
if wx.MessageBox(_("Delete this post?"), _("Confirm"), wx.YES_NO | wx.ICON_QUESTION) == wx.YES:
try:
ok = self.session.delete_post(uri)
if not ok:
output.speak(_("Could not delete."), True)
return
index = self.buffer.list.get_selected()
if index > -1 and self.session.db.get(self.name):
try:
self.session.db[self.name].pop(index)
except Exception:
pass
try:
self.buffer.list.remove_item(index)
except Exception:
pass
output.speak(_("Deleted."))
except Exception as e:
log.error("Error deleting Bluesky post: %s", e)
output.speak(_("Could not delete."), True)
def audio(self, event=None, item=None, *args, **kwargs):
"""Play audio/video from the current post."""
if sound.URLPlayer.player.is_playing():
return sound.URLPlayer.stop_audio()
if item is None:
item = self.get_item()
if not item:
return
urls = utils.get_media_urls(item)
if not urls:
output.speak(_("This post has no playable media."), True)
return
url = ""
if len(urls) == 1:
url = urls[0]
elif len(urls) > 1:
from wxUI.dialogs import urlList
urls_list = urlList.urlList()
urls_list.populate_list(urls)
if urls_list.get_response() == widgetUtils.OK:
url = urls_list.get_string()
if hasattr(urls_list, "destroy"):
urls_list.destroy()
if url:
sound.URLPlayer.play(url, self.session.settings["sound"]["volume"])
def ocr_image(self, *args, **kwargs):
"""Perform OCR on images in the current post."""
post = self.get_item()
if not post:
return
image_list = utils.get_image_urls(post)
if not image_list:
return
if len(image_list) > 1:
from wxUI.dialogs import urlList
labels = [_("Picture {0}").format(i + 1) for i in range(len(image_list))]
dialog = urlList.urlList(title=_("Select the picture"))
dialog.populate_list(labels)
if dialog.get_response() != widgetUtils.OK:
return
img = image_list[dialog.get_item()]
else:
img = image_list[0]
url = img.get("url")
if not url:
return
from extra import ocr as ocr_module
api = ocr_module.OCRSpace.OCRSpaceAPI()
try:
text = api.OCR_URL(url)
except ocr_module.OCRSpace.APIError:
output.speak(_("Unable to extract text"), True)
return
except Exception as e:
log.error("OCR error: %s", e)
output.speak(_("Unable to extract text"), True)
return
viewer = blueski_messages.text(title=_("OCR Result"), text=text["ParsedText"])
viewer.message.ShowModal()
viewer.message.Destroy()
# Also implement "view_item" if standard keymap uses it
def get_formatted_message(self):
return self.compose_function(self.get_item(), 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):
item = self.get_item()
if item is None:
return
relative_times = self.session.settings["general"].get("relative_times", False)
offset_hours = 0
if isinstance(self.session.db, dict):
offset_hours = self.session.db.get("utc_offset", 0) or 0
template_settings = self.session.settings.get("templates", {})
try:
if self.type == "notifications":
template = template_settings.get("notification", "$display_name $text, $date")
post_template = template_settings.get("post", "$display_name, $reply_to$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. Joined $created_at.")
return templates.render_user(item, template, self.session.settings, relative_times, offset_hours)
template = template_settings.get("post", "$display_name, $reply_to$safe_text $date.")
return templates.render_post(item, template, self.session.settings, relative_times, offset_hours)
except Exception:
# Fallback to compose if any template render fails.
composed = self.compose_function(
item,
self.session.db,
self.session.settings,
relative_times,
self.session.settings["general"].get("show_screen_names", False),
)
return " ".join(composed)
def view_conversation(self, *args, **kwargs):
item = self.get_item()
if not item: return
uri = item.get("uri") if isinstance(item, dict) else getattr(item, "uri", None)
if not uri: return
controller = self.controller
details = self.get_selected_item_author_details()
handle = "Unknown"
if details:
handle = details.get("handle") or "Unknown"
title = _("Conversation: {0}").format(handle)
controller.create_buffer(
buffer_type="conversation",
session_type="blueski",
buffer_title=title,
kwargs={"session": self.session, "uri": uri, "name": title},
start=True
)
def get_item(self):
index = self.buffer.list.get_selected()
if index > -1 and self.session.db.get(self.name) is not None:
# Logic implies DB order matches UI order
return self.session.db[self.name][index]
def get_selected_item_id(self):
item = self.get_item()
if not item:
return None
if isinstance(item, dict):
uri = item.get("uri")
if uri:
return uri
post = item.get("post") or item.get("record")
if isinstance(post, dict):
return post.get("uri")
return getattr(post, "uri", None)
return getattr(item, "uri", None) or getattr(getattr(item, "post", None), "uri", None)
def get_selected_item_cid(self):
item = self.get_item()
if not item:
return None
if isinstance(item, dict):
cid = item.get("cid")
if cid:
return cid
post = item.get("post") or item.get("record")
if isinstance(post, dict):
return post.get("cid")
return getattr(post, "cid", None)
return getattr(item, "cid", None) or getattr(getattr(item, "post", None), "cid", None)
def get_selected_item_author_details(self):
item = self.get_item()
if not item:
return None
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
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:
# 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:
# 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": did,
"handle": handle,
}
def _hydrate_reply_handles(self, items):
"""Populate _reply_to_handle on items when reply metadata lacks hydrated author."""
if not items:
return
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
def set_handle(obj, handle):
if not obj or not handle:
return
if isinstance(obj, dict):
obj["_reply_to_handle"] = handle
else:
try:
setattr(obj, "_reply_to_handle", handle)
except Exception:
pass
def get_parent_uri(item):
actual_post = g(item, "post", item)
record = g(actual_post, "record", {}) or {}
reply = g(record, "reply", None)
if not reply:
return None
parent = g(reply, "parent", None) or reply
return g(parent, "uri", None)
unresolved_by_parent_uri = {}
for item in items:
if utils.extract_reply_to_handle(item):
continue
parent_uri = get_parent_uri(item)
if not parent_uri:
continue
unresolved_by_parent_uri.setdefault(parent_uri, []).append(item)
if not unresolved_by_parent_uri:
return
api = self.session._ensure_client()
if not api:
return
uris = list(unresolved_by_parent_uri.keys())
uri_to_handle = {}
chunk_size = 25
for start in range(0, len(uris), chunk_size):
chunk = uris[start:start + chunk_size]
try:
try:
res = api.app.bsky.feed.get_posts({"uris": chunk})
except Exception:
res = api.app.bsky.feed.get_posts(uris=chunk)
posts = list(getattr(res, "posts", None) or [])
for parent_post in posts:
uri = g(parent_post, "uri", None)
author = g(parent_post, "author", None) or {}
handle = g(author, "handle", None)
if uri and handle:
uri_to_handle[uri] = handle
except Exception as e:
log.debug("Could not hydrate reply handles for chunk: %s", e)
if not uri_to_handle:
return
for parent_uri, item_list in unresolved_by_parent_uri.items():
handle = uri_to_handle.get(parent_uri)
if not handle:
continue
for item in item_list:
set_handle(item, handle)
actual_post = g(item, "post", item)
set_handle(actual_post, handle)
def process_items(self, items, play_sound=True, avoid_autoreading=False):
"""
Process list of items (FeedViewPost objects), update DB, and update UI.
Returns number of new items.
"""
if not items:
return 0
# Identify new items
new_items = []
current_uris = set()
# Create a set of keys from existing db to check duplicates
def get_key(it):
if isinstance(it, dict):
post = it.get("post")
if isinstance(post, dict) and post.get("uri"):
return post.get("uri")
if it.get("uri"):
return it.get("uri")
for key in ("id", "cid", "rev", "convoId", "convo_id", "messageId", "message_id", "msgId", "msg_id"):
if it.get(key):
return it.get(key)
nested = it.get("message") or it.get("record")
if isinstance(nested, dict):
for key in ("id", "cid", "rev", "messageId", "message_id", "msgId", "msg_id"):
if nested.get(key):
return nested.get(key)
if it.get("did"):
return it.get("did")
if it.get("handle"):
return it.get("handle")
author = it.get("author")
if isinstance(author, dict):
return author.get("did") or author.get("handle")
# Chat message fallback — use str() to ensure stable hash/comparison
sent_at = it.get("sentAt") or it.get("sent_at") or it.get("createdAt") or it.get("created_at")
sender = it.get("sender") or (nested.get("sender") if isinstance(nested, dict) else {}) or {}
sender_did = sender.get("did") if isinstance(sender, dict) else None
text = it.get("text") or (nested.get("text") if isinstance(nested, dict) 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
post = getattr(it, "post", None)
if post is not None:
return getattr(post, "uri", None)
for attr in ("uri", "id", "cid", "rev", "convoId", "convo_id", "messageId", "message_id", "msgId", "msg_id", "did", "handle"):
val = getattr(it, attr, None)
if val:
return val
nested = getattr(it, "message", None) or getattr(it, "record", None)
if nested is not None:
for attr in ("id", "cid", "rev", "messageId", "message_id", "msgId", "msg_id"):
val = getattr(nested, attr, None)
if val:
return val
author = getattr(it, "author", None)
if author is not None:
return getattr(author, "did", None) or getattr(author, "handle", None)
sent_at = getattr(it, "sentAt", None) or getattr(it, "sent_at", None) or getattr(it, "createdAt", None) or getattr(it, "created_at", None)
sender = getattr(it, "sender", None) or (getattr(nested, "sender", None) if nested is not None else None)
sender_did = getattr(sender, "did", None) if sender is not None else None
text = getattr(it, "text", None) or (getattr(nested, "text", None) if nested is not None 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
for item in self.session.db[self.name]:
key = get_key(item)
if key:
current_uris.add(key)
for item in items:
key = get_key(item)
if key:
if key in current_uris:
continue
current_uris.add(key)
new_items.append(item)
if not new_items:
return 0
self._hydrate_reply_handles(new_items)
# Add to DB
# Reverse timeline setting
reverse = False
try: reverse = self.session.settings["general"].get("reverse_timelines", False)
except: pass
# If reverse (newest at top), we insert new items at index 0?
# Typically API returns newest first.
# If DB is [Newest ... Oldest] (Reverse order)
# Then we insert new items at 0.
# If DB is [Oldest ... Newest] (Normal order)
# Then we append new items at end.
# But traditionally APIs return [Newest ... Oldest].
# So 'items' list is [Newest ... Oldest].
if reverse: # Newest at top
# DB: [Newest (Index 0) ... Oldest]
# We want to insert 'new_items' at 0.
# But 'new_items' are also [Newest...Oldest]
# So duplicates check handled.
# We insert the whole block at 0?
for it in reversed(new_items): # Insert oldest of new first, so newest ends up at 0
self.session.db[self.name].insert(0, it)
else: # Oldest at top
# DB: [Oldest ... Newest]
# APIs return [Newest ... Oldest]
# We want to append them.
# So we append reversed(new_items)?
for it in reversed(new_items):
self.session.db[self.name].append(it)
# Update UI
self.put_items_on_list(len(new_items))
# Play sound
if play_sound and self.sound and not self.session.settings["sound"]["session_mute"]:
self.session.sound.play(self.sound)
# Auto-read for accessibility
if not avoid_autoreading and len(new_items) > 0:
self.auto_read(len(new_items))
return len(new_items)
def add_new_item(self, item):
"""Add a single new item from streaming."""
self._hydrate_reply_handles([item])
safe = True
relative_times = self.session.settings["general"].get("relative_times", False)
show_screen_names = self.session.settings["general"].get("show_screen_names", False)
post = self.compose_function(item, self.session.db, self.session.settings, relative_times, show_screen_names, safe=safe)
if self.session.settings["general"].get("reverse_timelines", False):
self.buffer.list.insert_item(True, *post)
self.session.db[self.name].insert(0, item)
else:
self.buffer.list.insert_item(False, *post)
self.session.db[self.name].append(item)
# Auto-read single item
if self.name in self.session.settings["other_buffers"].get("autoread_buffers", []) and \
self.name not in self.session.settings["other_buffers"].get("muted_buffers", []) and \
not self.session.settings["sound"].get("session_mute", False):
output.speak(" ".join(post[:2]),
speech=self.session.settings["reporting"].get("speech_reporting", True),
braille=self.session.settings["reporting"].get("braille_reporting", True))
def update_item(self, item, position):
"""Update an existing item at the specified position."""
safe = True
relative_times = self.session.settings["general"].get("relative_times", False)
show_screen_names = self.session.settings["general"].get("show_screen_names", False)
post = self.compose_function(item, self.session.db, self.session.settings, relative_times, show_screen_names, safe=safe)
self.buffer.list.list.SetItem(position, 1, post[1])
def open_in_browser(self, *args, **kwargs):
"""Open the current post in web browser."""
item = self.get_item()
if not item:
return
import webbrowser
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
uri = g(item, "uri") or g(g(item, "post"), "uri")
author = g(item, "author") or g(g(item, "post"), "author")
handle = g(author, "handle")
if uri and handle:
if "app.bsky.feed.post" in uri:
rkey = uri.split("/")[-1]
url = f"https://bsky.app/profile/{handle}/post/{rkey}"
output.speak(_("Opening item in web browser..."))
webbrowser.open(url)
return
# Fallback to profile
if handle:
url = f"https://bsky.app/profile/{handle}"
output.speak(_("Opening item in web browser..."))
webbrowser.open(url)
def save_positions(self):
try:
self.session.db[self.name+"_pos"] = self.buffer.list.get_selected()
except: pass
def clear_list(self):
dlg = commonMessageDialogs.clear_list()
if dlg == widgetUtils.YES:
self.session.db[self.name] = []
self.buffer.list.clear()
def remove_buffer(self, force=False):
if self.type in ("conversation", "chat_messages") or self.name.lower().startswith("conversation"):
if not force:
dlg = commonMessageDialogs.remove_buffer()
if dlg != widgetUtils.YES:
return False
try:
self.session.db.pop(self.name, None)
except Exception:
pass
return True
return False