Files
twblue/src/controller/blueski/handler.py
T
Jesús Pavón AbiánandClaude Opus 5 0e5a75048a Fix duplicated posts in Bluesky threads and bogus list position in DMs
Thread buffers rebuilt themselves from scratch on every refresh with no
deduplication and no guard against overlapping runs, and the list control was
mutated from the update timer's background thread. Threads opened from the same
account also shared a single session.db entry, because the buffer name was
derived from the author instead of the post.

- Name conversation buffers after the post URI, and navigate to an already open
  thread instead of opening a second buffer for it.
- Deduplicate thread items by URI, and refuse a refresh while one is in flight.
- Move the clear-and-fill step onto the GUI thread via wx.CallAfter.
- Stop start_buffers() from starting a buffer that create_buffer() already
  started, since needs_init was never cleared.

The direct messages list never focused an item after being rebuilt, so its
position was announced as "0 of 1", which cannot exist. The conversation list
now focuses an item whenever it cannot restore the previous selection, and the
buffer navigation announcements no longer report a position while nothing is
focused.

Verified against the live Bluesky API that app.bsky.feed.getPostThread itself
returns no repeated posts, so the duplication was entirely on our side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:29:04 +02:00

986 lines
41 KiB
Python

from __future__ import annotations
import logging
import wx
import asyncio
import output
from mysc.thread_utils import call_threaded
import widgetUtils
from extra.autocompletionUsers import completion
from wxUI.dialogs.blueski.showUserProfile import ShowUserProfileDialog
from typing import Any
import languageHandler # Ensure _() injection
logger = logging.getLogger(__name__)
class Handler:
"""Handler for Bluesky integration: creates minimal buffers."""
def __init__(self):
super().__init__()
self.menus = dict(
# Application menu
updateProfile="HIDE",
menuitem_search=_("&Search"),
lists="HIDE",
manageAliases="HIDE",
# Item menu
compose=_("&Post"),
reply=_("Re&ply"),
share=_("&Boost"),
fav=_("&Add to favorites"),
unfav="HIDE",
view=_("&Show post"),
view_conversation=_("View conversa&tion"),
ocr=_("&OCR"),
delete=_("&Delete"),
# User menu
follow=_("&Actions..."),
timeline=_("&View timeline..."),
dm=_("Direct me&ssage"),
addAlias="HIDE",
addToList="HIDE",
removeFromList="HIDE",
details=_("S&how user profile"),
favs="HIDE",
# Buffer menu
community_timeline="HIDE",
filter="HIDE",
manage_filters="HIDE",
)
self.item_menu = _("&Post")
def create_buffers(self, session, createAccounts=True, controller=None):
name = session.get_name()
if createAccounts:
from pubsub import pub
pub.sendMessage("core.create_account", name=name, session_id=session.session_id, logged=session.logged)
if not session.logged:
logger.debug(f"Session {session.session_id} is not logged in, skipping timeline buffer creation.")
return
if name not in controller.accounts:
controller.accounts.append(name)
root_position = controller.view.search(name, name)
from pubsub import pub
# Home (Following-only timeline - reverse-chronological)
pub.sendMessage(
"createBuffer",
buffer_type="following_timeline",
session_type="blueski",
buffer_title=_("Home"),
parent_tab=root_position,
start=True,
kwargs=dict(parent=controller.view.nb, name="following_timeline", session=session, sound="tweet_received.ogg")
)
# Discover timeline
pub.sendMessage(
"createBuffer",
buffer_type="home_timeline",
session_type="blueski",
buffer_title=_("Discover"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="home_timeline", session=session, sound="tweet_received.ogg")
)
# Mentions (replies, mentions, quotes)
pub.sendMessage(
"createBuffer",
buffer_type="MentionsBuffer",
session_type="blueski",
buffer_title=_("Mentions"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="mentions", session=session, sound="mention_received.ogg")
)
# Chats
pub.sendMessage(
"createBuffer",
buffer_type="ConversationListBuffer",
session_type="blueski",
buffer_title=_("Chats"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="direct_messages", session=session, sound="dm_received.ogg")
)
# Notifications
pub.sendMessage(
"createBuffer",
buffer_type="notifications",
session_type="blueski",
buffer_title=_("Notifications"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="notifications", session=session, sound="new_event.ogg")
)
# Sent posts
pub.sendMessage(
"createBuffer",
buffer_type="SentBuffer",
session_type="blueski",
buffer_title=_("Sent"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="sent", session=session, sound="tweet_received.ogg")
)
# Likes
pub.sendMessage(
"createBuffer",
buffer_type="likes",
session_type="blueski",
buffer_title=_("Likes"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="likes", session=session, sound="favourite.ogg")
)
# Followers
pub.sendMessage(
"createBuffer",
buffer_type="FollowersBuffer",
session_type="blueski",
buffer_title=_("Followers"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="followers", session=session, sound="update_followers.ogg")
)
# Followings (Users you follow)
pub.sendMessage(
"createBuffer",
buffer_type="FollowingBuffer",
session_type="blueski",
buffer_title=_("Following"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="following", session=session, sound="update_followers.ogg")
)
# Blocks
pub.sendMessage(
"createBuffer",
buffer_type="BlocksBuffer",
session_type="blueski",
buffer_title=_("Blocked Users"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="blocked", session=session)
)
# Timelines container
pub.sendMessage(
"createBuffer",
buffer_type="EmptyBuffer",
session_type="base",
buffer_title=_("Timelines"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="timelines", account=name)
)
timelines_position = controller.view.search("timelines", name)
# Searches container (Bluesky supports search buffers)
pub.sendMessage(
"createBuffer",
buffer_type="EmptyBuffer",
session_type="base",
buffer_title=_("Searches"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="searches", account=name)
)
searches_position = controller.view.search("searches", name)
# Saved searches
try:
searches = session.settings["other_buffers"].get("searches")
if searches is None:
searches = []
if isinstance(searches, str):
searches = [s for s in searches.split(",") if s]
for query in searches:
buffer_name = f"search_{query[:20]}"
title = _("Search: {query}").format(query=query)
pub.sendMessage(
"createBuffer",
buffer_type="SearchBuffer",
session_type="blueski",
buffer_title=title,
parent_tab=searches_position,
start=False,
kwargs=dict(parent=controller.view.nb, name=buffer_name, session=session, query=query, sound="search_updated.ogg")
)
except Exception as e:
logger.error("Failed to restore Bluesky search buffers: %s", e)
# Saved user timelines
try:
timelines = session.settings["other_buffers"].get("timelines")
if timelines is None:
timelines = []
if isinstance(timelines, str):
timelines = [t for t in timelines.split(",") if t]
for actor in timelines:
handle = actor
try:
if isinstance(actor, str) and actor.startswith("did:"):
profile = session.get_profile(actor)
if profile:
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
handle = g(profile, "handle") or actor
except Exception:
handle = actor
title = _("Timeline for {user}").format(user=handle)
pub.sendMessage(
"createBuffer",
buffer_type="UserTimeline",
session_type="blueski",
buffer_title=title,
parent_tab=timelines_position,
start=False,
kwargs=dict(parent=controller.view.nb, name=f"{handle}-timeline", session=session, actor=actor, handle=handle, sound="tweet_timeline.ogg")
)
except Exception as e:
logger.error("Failed to restore Bluesky timeline buffers: %s", e)
# Saved followers/following timelines
try:
followers = session.settings["other_buffers"].get("followers_timelines")
if followers is None:
followers = []
if isinstance(followers, str):
followers = [t for t in followers.split(",") if t]
for actor in followers:
handle = actor
try:
if isinstance(actor, str) and actor.startswith("did:"):
profile = session.get_profile(actor)
if profile:
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
handle = g(profile, "handle") or actor
except Exception:
handle = actor
own_actor = session.db.get("user_id") or session.db.get("user_name")
own_handle = session.db.get("user_name")
if actor == own_actor or (own_handle and actor == own_handle) or (handle and own_handle and handle == own_handle):
continue
title = _("Followers for {user}").format(user=handle)
pub.sendMessage(
"createBuffer",
buffer_type="FollowersBuffer",
session_type="blueski",
buffer_title=title,
parent_tab=timelines_position,
start=False,
kwargs=dict(parent=controller.view.nb, name=f"{handle}-followers", session=session, actor=actor, handle=handle, sound="new_event.ogg")
)
except Exception as e:
logger.error("Failed to restore Bluesky followers buffers: %s", e)
try:
following = session.settings["other_buffers"].get("following_timelines")
if following is None:
following = []
if isinstance(following, str):
following = [t for t in following.split(",") if t]
for actor in following:
handle = actor
try:
if isinstance(actor, str) and actor.startswith("did:"):
profile = session.get_profile(actor)
if profile:
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
handle = g(profile, "handle") or actor
except Exception:
handle = actor
own_actor = session.db.get("user_id") or session.db.get("user_name")
own_handle = session.db.get("user_name")
if actor == own_actor or (own_handle and actor == own_handle) or (handle and own_handle and handle == own_handle):
continue
title = _("Following for {user}").format(user=handle)
pub.sendMessage(
"createBuffer",
buffer_type="FollowingBuffer",
session_type="blueski",
buffer_title=title,
parent_tab=timelines_position,
start=False,
kwargs=dict(parent=controller.view.nb, name=f"{handle}-following", session=session, actor=actor, handle=handle, sound="new_event.ogg")
)
except Exception as e:
logger.error("Failed to restore Bluesky following buffers: %s", e)
# Start the background poller for real-time-like updates
try:
session.start_streaming()
except Exception as e:
logger.error("Failed to start Bluesky streaming for session %s: %s", name, e)
def start_buffer(self, controller, buffer):
"""Start a newly created Bluesky buffer."""
try:
if hasattr(buffer, "start_stream"):
buffer.start_stream(mandatory=True, play_sound=False)
# Enable periodic auto-refresh to simulate real-time updates
if hasattr(buffer, "enable_auto_refresh"):
buffer.enable_auto_refresh()
finally:
# Ensure we won't try to start it again
try:
buffer.needs_init = False
except Exception:
pass
def account_settings(self, buffer, controller):
"""Open a minimal account settings dialog for Bluesky."""
try:
current_mode = None
try:
current_mode = buffer.session.settings["general"].get("boost_mode")
except Exception:
current_mode = None
ask_default = True if current_mode in (None, "ask") else False
from wxUI.dialogs.blueski.configuration import AccountSettingsDialog
from .templateEditor import EditTemplate
dlg = AccountSettingsDialog(controller.view, ask_before_boost=ask_default)
try:
if buffer.session.settings.get("templates") is None:
buffer.session.settings["templates"] = {}
templates_cfg = buffer.session.settings.get("templates", {})
template_state = {
"post": templates_cfg.get("post", "$display_name, $reply_to$safe_text $image_descriptions $date."),
"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"])
def edit_post_template(*args, **kwargs):
control = EditTemplate(template=template_state["post"], type="post")
result = control.run_dialog()
if result:
buffer.session.settings["templates"]["post"] = result
buffer.session.settings.write()
template_state["post"] = result
dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"])
def edit_person_template(*args, **kwargs):
control = EditTemplate(template=template_state["person"], type="person")
result = control.run_dialog()
if result:
buffer.session.settings["templates"]["person"] = result
buffer.session.settings.write()
template_state["person"] = result
dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"])
def edit_notification_template(*args, **kwargs):
control = EditTemplate(template=template_state["notification"], type="notification")
result = control.run_dialog()
if result:
buffer.session.settings["templates"]["notification"] = result
buffer.session.settings.write()
template_state["notification"] = result
dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"])
widgetUtils.connect_event(dlg.template_post, widgetUtils.BUTTON_PRESSED, edit_post_template)
widgetUtils.connect_event(dlg.template_person, widgetUtils.BUTTON_PRESSED, edit_person_template)
widgetUtils.connect_event(dlg.template_notification, widgetUtils.BUTTON_PRESSED, edit_notification_template)
except Exception as e:
logger.error("Failed to init Bluesky templates editor: %s", e)
resp = dlg.ShowModal()
if resp == wx.ID_OK:
vals = dlg.get_values()
boost_mode = "ask" if vals.get("ask_before_boost") else "direct"
try:
buffer.session.settings["general"]["boost_mode"] = boost_mode
buffer.session.settings.write()
except Exception as e:
logger.error("Failed to persist Bluesky boost_mode setting: %s", e)
dlg.Destroy()
except Exception as e:
logger.error("Error opening Bluesky account settings dialog: %s", e)
def user_details(self, buffer):
"""Show user profile dialog for the selected user/post."""
session = getattr(buffer, "session", None)
if not session:
output.speak(_("No active session to view user details."), True)
return
item = buffer.get_item() if hasattr(buffer, "get_item") else None
if not item:
output.speak(_("No user selected or identified to view details."), True)
return
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
user_ident = None
# If we're in a user list, the item itself is the user profile dict/model.
if g(item, "did") or g(item, "handle"):
user_ident = g(item, "did") or g(item, "handle")
else:
author = g(item, "author")
if not author:
post = g(item, "post") or g(item, "record")
author = g(post, "author") if post else None
if author:
user_ident = g(author, "did") or g(author, "handle")
if not user_ident:
output.speak(_("No user selected or identified to view details."), True)
return
parent = getattr(buffer, "buffer", None) or wx.GetApp().GetTopWindow()
dialog = ShowUserProfileDialog(parent, session, user_ident)
dialog.ShowModal()
dialog.Destroy()
async def handle_action(self, action_name: str, user_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
logger.debug("handle_action stub: %s %s %s", action_name, user_id, payload)
return None
async def handle_message_command(self, command: str, user_id: str, message_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
logger.debug("handle_message_command stub: %s %s %s %s", command, user_id, message_id, payload)
return None
async def handle_user_command(self, command: str, user_id: str, target_user_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
logger.debug("handle_user_command stub: %s %s %s %s", command, user_id, target_user_id, payload)
return None
def add_to_favourites(self, buffer):
"""Standard action for Alt+Win+F"""
if hasattr(buffer, "add_to_favorites"):
buffer.add_to_favorites()
elif hasattr(buffer, "on_like"):
# Fallback
buffer.on_like(None)
def remove_from_favourites(self, buffer):
"""Standard action for Alt+Shift+Win+F"""
if hasattr(buffer, "remove_from_favorites"):
buffer.remove_from_favorites()
elif hasattr(buffer, "on_like"):
buffer.on_like(None)
def follow(self, buffer):
"""Standard action for Ctrl+Win+S - Opens user actions dialog"""
if not hasattr(buffer, "get_item"):
return
session = getattr(buffer, "session", None)
if not session:
output.speak(_("No active session."), True)
return
item = buffer.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)
users = []
buffer_type = getattr(buffer, "type", "")
if buffer_type in ("user", "post_user_list"):
# User buffer - item is a user object
handle = g(item, "handle")
if handle:
users = [handle]
elif buffer_type == "notifications":
# Notification buffer
author = g(item, "author")
if author:
handle = g(author, "handle")
if handle:
users.append(handle)
# Also check for post author in the notification subject
record = g(item, "record")
if record:
subject = g(record, "subject")
if subject:
subject_author = g(subject, "author")
if subject_author:
subject_handle = g(subject_author, "handle")
if subject_handle and subject_handle not in users:
users.append(subject_handle)
else:
# Post buffer - extract author and mentioned users
# Get the actual post (could be nested in "post" key)
actual_post = g(item, "post", item)
record = g(actual_post, "record") or {}
# Extract mentions from facets
facets = g(record, "facets") or []
for facet in facets:
features = g(facet, "features") or []
for feature in features:
ftype = g(feature, "$type") or g(feature, "py_type") or ""
if "mention" in ftype.lower():
mention_did = g(feature, "did")
# We'd need to resolve DID to handle, but for simplicity just skip
# The main author will be added below
# Get the post author
author = g(actual_post, "author") or g(item, "author")
if author:
handle = g(author, "handle")
if handle and handle not in users:
users.insert(0, handle)
# Ensure we have at least the author if no users found
if not users:
author = g(item, "author") or g(g(item, "post"), "author")
if author:
handle = g(author, "handle")
if handle:
users = [handle]
from controller.blueski import userActions as user_actions_controller
user_actions_controller.userActions(session, users)
def open_conversation(self, controller, buffer):
"""Standard action for Control+Win+C"""
# If this is a chat conversation list, open the selected chat
if buffer.type == "chat" and hasattr(buffer, "view_chat"):
buffer.view_chat()
return
item = buffer.get_item()
if not item:
return
uri = None
if hasattr(buffer, "get_selected_item_id"):
uri = buffer.get_selected_item_id()
if not uri:
uri = getattr(item, "uri", None) or (item.get("post", {}).get("uri") if isinstance(item, dict) else None)
if not uri: return
# Buffer Title
handle = None
display_name = None
if hasattr(buffer, "get_selected_item_author_details"):
details = buffer.get_selected_item_author_details()
if details:
handle = details.get("handle")
if not handle:
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
author = g(item, "author") or g(g(item, "post"), "author")
if author:
handle = g(author, "handle")
display_name = g(author, "displayName") or g(author, "display_name")
label = handle or display_name or _("Unknown")
title = _("Conversation with {0}").format(label)
# The buffer name identifies the buffer and keys its entry in session.db, so
# it has to be derived from the post instead of the author. Two threads from
# the same account would otherwise share a single database entry.
buffer_name = "%s-conversation" % (uri,)
account_name = buffer.session.get_name() if hasattr(buffer.session, "get_name") else None
# If this thread is already open, go to it rather than opening it twice.
if account_name != None and controller.search_buffer(buffer_name, account_name):
index = controller.view.search(buffer_name, account_name)
if index is not None:
controller.view.change_buffer(index)
return
from pubsub import pub
pub.sendMessage(
"createBuffer",
buffer_type="conversation",
session_type="blueski",
buffer_title=title,
parent_tab=controller.view.search(account_name, account_name) if account_name != None else None,
start=True,
kwargs=dict(parent=controller.view.nb, name=buffer_name, session=buffer.session, uri=uri, sound="search_updated.ogg")
)
def open_timeline(self, controller, buffer, default="posts"):
if not hasattr(buffer, "get_item"):
return
item = buffer.get_item()
if not item:
output.speak(_("No user selected."), True)
return
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
users = []
handle = None
if hasattr(buffer, "get_selected_item_author_details"):
details = buffer.get_selected_item_author_details()
if details:
handle = details.get("handle") or details.get("did")
if not handle:
if g(item, "handle") or g(item, "did"):
handle = g(item, "handle") or g(item, "did")
else:
author = g(item, "author") or g(g(item, "post"), "author")
if author:
handle = g(author, "handle") or g(author, "did")
if not handle:
output.speak(_("No user selected."), True)
return
users.append(handle)
# Add mentioned users if available (facets)
record = g(g(item, "post"), "record") or g(item, "record")
facets = g(record, "facets", []) if record else []
handle_cache = {}
def resolve_handle(did):
if did in handle_cache:
return handle_cache[did]
try:
profile = buffer.session.get_profile(did)
if profile:
h = g(profile, "handle")
if h:
handle_cache[did] = h
return h
except Exception:
pass
return None
self_did = buffer.session.db.get("user_id")
for facet in facets or []:
features = g(facet, "features", []) or []
for feat in features:
ftype = g(feat, "$type") or g(feat, "py_type") or ""
if "facet#mention" in ftype:
did = g(feat, "did")
if not did or did == self_did:
continue
h = resolve_handle(did)
if h and h not in users:
users.append(h)
from wxUI.dialogs.mastodon import userTimeline as userTimelineDialog
dlg = userTimelineDialog.UserTimeline(users=users, default=default)
try:
widgetUtils.connect_event(
dlg.autocompletion,
widgetUtils.BUTTON_PRESSED,
lambda *args, **kwargs: completion.autocompletionUsers(dlg, buffer.session.session_id).show_menu("free"),
)
except Exception:
pass
try:
if hasattr(dlg, "autocompletion"):
dlg.autocompletion.Enable(True)
except Exception:
pass
if dlg.ShowModal() != wx.ID_OK:
dlg.Destroy()
return
action = dlg.get_action()
user = dlg.get_user().strip() or handle
dlg.Destroy()
if user.startswith("@"):
user = user[1:]
try:
profile = buffer.session.get_profile(user)
if profile is None:
output.speak(_("User not found."), True)
return
except Exception:
pass
user_payload = {"handle": user}
if action == "posts":
result = self.open_user_timeline(main_controller=controller, session=buffer.session, user_payload=user_payload)
elif action == "followers":
result = self.open_followers_timeline(main_controller=controller, session=buffer.session, user_payload=user_payload)
elif action == "following":
result = self.open_following_timeline(main_controller=controller, session=buffer.session, user_payload=user_payload)
else:
return
if asyncio.iscoroutine(result):
call_threaded(asyncio.run, result)
def open_followers_timeline(self, main_controller, session, user_payload=None):
actor, handle = self._resolve_actor(session, user_payload)
if not actor:
output.speak(_("No user selected."), True)
return
self._open_user_list(main_controller, session, actor, handle, list_type="followers")
def open_following_timeline(self, main_controller, session, user_payload=None):
actor, handle = self._resolve_actor(session, user_payload)
if not actor:
output.speak(_("No user selected."), True)
return
self._open_user_list(main_controller, session, actor, handle, list_type="following")
def open_user_timeline(self, main_controller, session, user_payload=None):
"""Open posts timeline for a user (Alt+Win+I)."""
actor, handle = self._resolve_actor(session, user_payload)
if not actor:
output.speak(_("No user selected."), True)
return
actor, handle = self._resolve_actor(session, {"did": actor, "handle": handle})
if not handle:
handle = actor
account_name = session.get_name()
list_name = f"{handle}-timeline"
if main_controller.search_buffer(list_name, account_name):
index = main_controller.view.search(list_name, account_name)
if index is not None:
main_controller.view.change_buffer(index)
return
title = _("Timeline for {user}").format(user=handle)
from pubsub import pub
pub.sendMessage(
"createBuffer",
buffer_type="UserTimeline",
session_type="blueski",
buffer_title=title,
parent_tab=main_controller.view.search("timelines", account_name),
start=True,
kwargs=dict(parent=main_controller.view.nb, name=list_name, session=session, actor=actor, handle=handle, sound="tweet_timeline.ogg")
)
try:
timelines = session.settings["other_buffers"].get("timelines")
if timelines is None:
timelines = []
if isinstance(timelines, str):
timelines = [t for t in timelines.split(",") if t]
key = actor or handle
if key in timelines:
from wxUI import commonMessageDialogs
commonMessageDialogs.timeline_exist()
return
if key:
timelines.append(key)
session.settings["other_buffers"]["timelines"] = timelines
session.settings.write()
except Exception as e:
logger.error("Failed to persist Bluesky timeline buffer: %s", e)
def _resolve_actor(self, session, user_payload):
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
actor = None
handle = None
if user_payload:
actor = g(user_payload, "did") or g(user_payload, "handle")
handle = g(user_payload, "handle") or g(user_payload, "did")
if isinstance(actor, str):
actor = actor.strip()
if actor.startswith("@"):
actor = actor[1:]
if isinstance(handle, str):
handle = handle.strip()
if handle.startswith("@"):
handle = handle[1:]
# Resolve handle -> DID when possible, and keep handle for titles
try:
if isinstance(actor, str) and not actor.startswith("did:"):
profile = session.get_profile(actor)
if profile:
did = g(profile, "did")
if did:
actor = did
if not handle:
handle = g(profile, "handle")
except Exception:
pass
if not actor:
actor = session.db.get("user_id") or session.db.get("user_name")
handle = session.db.get("user_name") or actor
if not handle and isinstance(actor, str):
try:
if actor.startswith("did:"):
profile = session.get_profile(actor)
if profile:
handle = g(profile, "handle")
except Exception:
pass
return actor, handle
def _open_user_list(self, main_controller, session, actor, handle, list_type):
account_name = session.get_name()
if not handle:
handle = actor
own_actor = session.db.get("user_id") or session.db.get("user_name")
own_handle = session.db.get("user_name")
if actor == own_actor or (own_handle and actor == own_handle) or (handle and own_handle and handle == own_handle):
name = "followers" if list_type == "followers" else "following"
try:
stored = session.settings["other_buffers"].get("followers_timelines" if list_type == "followers" else "following_timelines") or []
if isinstance(stored, str):
stored = [t for t in stored.split(",") if t]
if actor in stored:
stored.remove(actor)
session.settings["other_buffers"]["followers_timelines" if list_type == "followers" else "following_timelines"] = stored
session.settings.write()
except Exception:
pass
index = main_controller.view.search(name, account_name)
if index is not None:
main_controller.view.change_buffer(index)
return
list_name = f"{handle}-{list_type}"
if main_controller.search_buffer(list_name, account_name):
index = main_controller.view.search(list_name, account_name)
if index is not None:
main_controller.view.change_buffer(index)
return
settings_key = "followers_timelines" if list_type == "followers" else "following_timelines"
try:
stored = session.settings["other_buffers"].get(settings_key)
if stored is None:
stored = []
if isinstance(stored, str):
stored = [t for t in stored.split(",") if t]
key = actor or handle
if key in stored:
from wxUI import commonMessageDialogs
commonMessageDialogs.timeline_exist()
return
except Exception:
stored = None
title = _("Followers for {user}").format(user=handle) if list_type == "followers" else _("Following for {user}").format(user=handle)
from pubsub import pub
pub.sendMessage(
"createBuffer",
buffer_type="FollowersBuffer" if list_type == "followers" else "FollowingBuffer",
session_type="blueski",
buffer_title=title,
parent_tab=main_controller.view.search("timelines", account_name),
start=True,
kwargs=dict(parent=main_controller.view.nb, name=list_name, session=session, actor=actor, handle=handle, sound="new_event.ogg")
)
try:
if stored is None:
stored = session.settings["other_buffers"].get(settings_key) or []
if isinstance(stored, str):
stored = [t for t in stored.split(",") if t]
key = actor or handle
if key:
stored.append(key)
session.settings["other_buffers"][settings_key] = stored
session.settings.write()
except Exception as e:
logger.error("Failed to persist Bluesky %s buffer: %s", list_type, e)
def delete(self, buffer, controller):
"""Standard action for delete key / menu item"""
item = buffer.get_item()
if not item: return
uri = getattr(item, "uri", None) or (item.get("post", {}).get("uri") if isinstance(item, dict) else None)
if not uri: return
import wx
if wx.MessageBox(_("Are you sure you want to delete this post?"), _("Delete post"), wx.YES_NO | wx.ICON_QUESTION) == wx.YES:
if buffer.session.delete_post(uri):
import output
output.speak(_("Post deleted."))
# Refresh buffer
if hasattr(buffer, "start_stream"):
buffer.start_stream(mandatory=True, play_sound=False)
else:
import output
output.speak(_("Failed to delete post."))
def search(self, controller, session, value=""):
"""Open search dialog and create search buffer for results."""
dlg = wx.TextEntryDialog(
controller.view,
_("Enter search term:"),
_("Search Bluesky"),
value
)
if dlg.ShowModal() != wx.ID_OK:
dlg.Destroy()
return
query = dlg.GetValue().strip()
dlg.Destroy()
if not query:
return
# Create unique buffer name for this search
buffer_name = f"search_{query[:20]}"
account_name = session.get_name()
# Check if buffer already exists
existing = controller.search_buffer(buffer_name, account_name)
if existing:
# Navigate to existing buffer
index = controller.view.search(buffer_name, account_name)
if index is not None:
controller.view.change_buffer(index)
# Refresh search
existing.search_query = query
existing.start_stream(mandatory=True, play_sound=False)
return
# Create new search buffer
title = _("Search: {query}").format(query=query)
from pubsub import pub
pub.sendMessage(
"createBuffer",
buffer_type="SearchBuffer",
session_type="blueski",
buffer_title=title,
parent_tab=controller.view.search("searches", account_name),
start=True,
kwargs=dict(
parent=controller.view.nb,
name=buffer_name,
session=session,
query=query,
sound="search_updated.ogg"
)
)
# Save search to settings for persistence
try:
searches = session.settings["other_buffers"].get("searches")
if searches is None:
searches = []
if isinstance(searches, str):
searches = [s for s in searches.split(",") if s]
if query not in searches:
searches.append(query)
session.settings["other_buffers"]["searches"] = searches
session.settings.write()
except Exception as e:
logger.error("Failed to save search to settings: %s", e)