This commit is contained in:
Jesús Pavón Abián
2026-01-11 20:13:56 +01:00
parent 9d9d86160d
commit 932e44a9c9
391 changed files with 120828 additions and 1090 deletions

View File

@@ -1,6 +1,9 @@
from __future__ import annotations
import logging
import wx
import output
from wxUI.dialogs.blueski.showUserProfile import ShowUserProfileDialog
from typing import Any
import languageHandler # Ensure _() injection
@@ -19,10 +22,16 @@ class Handler:
def create_buffers(self, session, createAccounts=True, controller=None):
name = session.get_name()
controller.accounts.append(name)
if createAccounts:
from pubsub import pub
pub.sendMessage("core.create_account", name=name, session_id=session.session_id, logged=True)
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)
# Discover/home timeline
from pubsub import pub
@@ -45,6 +54,66 @@ class Handler:
start=False,
kwargs=dict(parent=controller.view.nb, name="following_timeline", session=session)
)
# 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)
)
# 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)
)
# 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)
)
# Following (Users)
pub.sendMessage(
"createBuffer",
buffer_type="FollowingBuffer",
session_type="blueski",
buffer_title=_("Following (Users)"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="following", session=session)
)
# 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)
)
# 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)
)
def start_buffer(self, controller, buffer):
"""Start a newly created Bluesky buffer."""
@@ -86,6 +155,45 @@ class Handler:
except Exception:
logger.exception("Error opening Bluesky account settings dialog")
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
@@ -97,3 +205,156 @@ class Handler:
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"""
session = getattr(buffer, "session", None)
if not session:
output.speak(_("No active session."), 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
item = buffer.get_item() if hasattr(buffer, "get_item") else None
if item:
if g(item, "handle") or g(item, "did"):
user_ident = g(item, "handle") or g(item, "did")
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, "handle") or g(author, "did")
users = [user_ident] if user_ident else []
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"""
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
author = getattr(item, "author", None) or (item.get("post", {}).get("author") if isinstance(item, dict) else None)
handle = getattr(author, "handle", "unknown") if author else "unknown"
title = _("Conversation with {0}").format(handle)
from pubsub import pub
pub.sendMessage(
"createBuffer",
buffer_type="conversation",
session_type="blueski",
buffer_title=title,
parent_tab=controller.view.search(buffer.session.get_name(), buffer.session.get_name()) if hasattr(buffer.session, "get_name") else None,
start=True,
kwargs=dict(parent=controller.view.nb, name=title, session=buffer.session, uri=uri)
)
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 _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 not actor:
actor = session.db.get("user_id") or session.db.get("user_name")
handle = session.db.get("user_name") or actor
return actor, handle
def _open_user_list(self, main_controller, session, actor, handle, list_type):
account_name = session.get_name()
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):
name = "followers" if list_type == "followers" else "following"
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
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(account_name, account_name),
start=True,
kwargs=dict(parent=main_controller.view.nb, name=list_name, session=session, actor=actor)
)
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."))

View File

@@ -1,75 +1,98 @@
from __future__ import annotations
# -*- coding: utf-8 -*-
import logging
from typing import TYPE_CHECKING, Any
import widgetUtils
import output
from wxUI.dialogs.blueski import userActions as userActionsDialog
import languageHandler
fromapprove.translation import translate as _
# fromapprove.controller.mastodon import userActions as mastodon_user_actions # If adapting
if TYPE_CHECKING:
fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted
logger = logging.getLogger(__name__)
# This file defines user-specific actions that can be performed on Blueski entities,
# typically represented as buttons or links in the UI, often on user profiles or posts.
# For Blueski, actions might include:
# - Viewing a user's profile on Bluesky/Blueski instance.
# - Following/Unfollowing a user.
# - Muting/Blocking a user.
# - Reporting a user.
# - Fetching a user's latest posts.
# These actions are often presented in a context menu or as direct buttons.
# The `get_user_actions` method in the BlueskiSession class would define these.
# This file would contain the implementation or further handling logic if needed,
# or if actions are too complex for simple lambda/method calls in the session class.
# Example structure for defining an action:
# (This might be more detailed if actions require forms or multi-step processes)
# def view_profile_action(session: BlueskiSession, user_id: str) -> dict[str, Any]:
# """
# Generates data for a "View Profile on Blueski" action.
# user_id here would be the Blueski DID or handle.
# """
# # profile_url = f"https://bsky.app/profile/{user_id}" # Example, construct from handle or DID
# # This might involve resolving DID to handle or vice-versa if only one is known.
# # handle = await session.util.get_username_from_user_id(user_id) or user_id
# # profile_url = f"https://bsky.app/profile/{handle}"
# return {
# "id": "blueski_view_profile",
# "label": _("View Profile on Bluesky"),
# "icon": "external-link-alt", # FontAwesome icon name
# "action_type": "link", # "link", "modal", "api_call"
# "url": profile_url, # For "link" type
# # "api_endpoint": "/api/blueski/user_action", # For "api_call"
# # "payload": {"action": "view_profile", "target_user_id": user_id},
# "confirmation_required": False,
# }
log = logging.getLogger("controller.blueski.userActions")
# async def follow_user_action_handler(session: BlueskiSession, target_user_id: str) -> dict[str, Any]:
# """
# Handles the 'follow_user' action for Blueski.
# target_user_id should be the DID of the user to follow.
# """
# # success = await session.util.follow_user(target_user_id)
# # if success:
# # return {"status": "success", "message": _("User {target_user_id} followed.").format(target_user_id=target_user_id)}
# # else:
# # return {"status": "error", "message": _("Failed to follow user {target_user_id}.").format(target_user_id=target_user_id)}
# return {"status": "pending", "message": "Follow action not implemented yet."}
class BasicUserSelector(object):
def __init__(self, session, users=None):
super(BasicUserSelector, self).__init__()
self.session = session
self.create_dialog(users=users or [])
def create_dialog(self, users):
pass
def resolve_profile(self, actor):
try:
return self.session.get_profile(actor)
except Exception:
log.exception("Error resolving Bluesky profile for %s.", actor)
return None
# The list of available actions is typically defined in the Session class,
# e.g., BlueskiSession.get_user_actions(). That method would return a list
# of dictionaries, and this file might provide handlers for more complex actions
# if they aren't simple API calls defined directly in the session's util.
class userActions(BasicUserSelector):
def __init__(self, *args, **kwargs):
super(userActions, self).__init__(*args, **kwargs)
if self.dialog.get_response() == widgetUtils.OK:
self.process_action()
# For now, this file can be a placeholder if most actions are simple enough
# to be handled directly by the session.util methods or basic handler routes.
def create_dialog(self, users):
self.dialog = userActionsDialog.UserActionsDialog(users)
logger.info("Blueski userActions module loaded (placeholders).")
def process_action(self):
action = self.dialog.get_action()
actor = self.dialog.get_user().strip()
if not actor:
output.speak(_("No user specified."), True)
return
profile = self.resolve_profile(actor)
if not profile:
output.speak(_("User not found."), True)
return
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
did = g(profile, "did")
viewer = g(profile, "viewer") or {}
if not did:
output.speak(_("User identifier not available."), True)
return
if action == "follow":
if self.session.follow_user(did):
output.speak(_("Followed."))
else:
output.speak(_("Failed to follow user."), True)
elif action == "unfollow":
follow_uri = g(viewer, "following")
if not follow_uri:
output.speak(_("Follow information not available."), True)
return
if self.session.unfollow_user(follow_uri):
output.speak(_("Unfollowed."))
else:
output.speak(_("Failed to unfollow user."), True)
elif action == "mute":
if self.session.mute_user(did):
output.speak(_("Muted."))
else:
output.speak(_("Failed to mute user."), True)
elif action == "unmute":
if self.session.unmute_user(did):
output.speak(_("Unmuted."))
else:
output.speak(_("Failed to unmute user."), True)
elif action == "block":
if self.session.block_user(did):
output.speak(_("Blocked."))
else:
output.speak(_("Failed to block user."), True)
elif action == "unblock":
block_uri = g(viewer, "blocking")
if not block_uri:
output.speak(_("Block information not available."), True)
return
if self.session.unblock_user(block_uri):
output.speak(_("Unblocked."))
else:
output.speak(_("Failed to unblock user."), True)

View File

@@ -10,7 +10,7 @@ from . import base
log = logging.getLogger("controller.buffers.base.account")
class AccountBuffer(base.Buffer):
def __init__(self, parent, name, account, account_id):
def __init__(self, parent, name, account, account_id, session=None):
super(AccountBuffer, self).__init__(parent, None, name)
log.debug("Initializing buffer %s, account %s" % (name, account,))
self.buffer = buffers.accountPanel(parent, name)
@@ -53,4 +53,4 @@ class AccountBuffer(base.Buffer):
else:
self.buffer.change_autostart(False)
config.app["sessions"]["ignored_sessions"].append(self.account_id)
config.app.write()
config.app.write()

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from .timeline import HomeTimeline, FollowingTimeline, NotificationBuffer, Conversation
from .user import FollowersBuffer, FollowingBuffer, BlocksBuffer
from .chat import ConversationListBuffer, ChatBuffer as ChatMessageBuffer

View File

@@ -0,0 +1,579 @@
# -*- coding: utf-8 -*-
import logging
import wx
import output
import sound
import config
import widgetUtils
from pubsub import pub
from controller.buffers.base import base
from sessions.blueski import compose
from wxUI.buffers.blueski import panels as BlueskiPanels
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 = sound
# Initialize DB list if needed
if self.name not in self.session.db:
self.session.db[self.name] = []
self.bind_events()
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
widgetUtils.connect_event(self.buffer.list.list, widgetUtils.KEYPRESS, self.get_event)
# 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 on_post(self, evt):
from wxUI.dialogs.blueski import postDialogs
dlg = postDialogs.Post(caption=_("New Post"))
if dlg.ShowModal() == wx.ID_OK:
text, files, cw, langs = dlg.get_payload()
self.session.send_message(message=text, files=files, cw_text=cw, langs=langs)
output.speak(_("Sending..."))
dlg.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)
# 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 "")
from wxUI.dialogs.blueski import postDialogs
dlg = postDialogs.Post(caption=_("Reply"), text=initial_text)
if dlg.ShowModal() == wx.ID_OK:
text, files, cw, langs = dlg.get_payload()
self.session.send_message(message=text, files=files, reply_to=uri, cw_text=cw, langs=langs)
output.speak(_("Sending reply..."))
dlg.Destroy()
def on_repost(self, evt):
self.share_item(confirm=True)
def share_item(self, confirm=False, *args, **kwargs):
item = self.get_item()
if not item: return
uri = item.get("uri") if isinstance(item, dict) else getattr(item, "uri", None)
if confirm:
if wx.MessageBox(_("Repost this?"), _("Confirm"), wx.YES_NO | wx.ICON_QUESTION) != wx.YES:
return
self.session.repost(uri)
output.speak(_("Reposted."))
def on_like(self, evt):
self.toggle_favorite(confirm=True)
def toggle_favorite(self, confirm=False, *args, **kwargs):
item = self.get_item()
if not item: return
uri = item.get("uri") if isinstance(item, dict) else getattr(item, "uri", None)
if confirm:
if wx.MessageBox(_("Like this post?"), _("Confirm"), wx.YES_NO | wx.ICON_QUESTION) != wx.YES:
return
self.session.like(uri)
output.speak(_("Liked."))
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
item = self.get_item()
if not item:
output.speak(_("No user selected to message."), 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")
if not did:
return
if self.showing == False:
dlg = wx.TextEntryDialog(None, _("Message to {0}:").format(handle), _("Send Message"))
if dlg.ShowModal() == wx.ID_OK:
text = dlg.GetValue()
if text:
try:
api = self.session._ensure_client()
# Get or create conversation
res = api.chat.bsky.convo.get_convo_for_members({"members": [did]})
convo_id = res.convo.id
self.session.send_chat_message(convo_id, text)
output.speak(_("Message sent."), True)
except:
log.exception("Error sending Bluesky DM (invisible)")
output.speak(_("Failed to send message."), True)
dlg.Destroy()
return
# If showing, we'll just open the chat buffer for now as it's more structured
self.view_chat_with_user(did, handle)
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
import application
title = _("Chat: {0}").format(handle)
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
)
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 share_item(self, *args, **kwargs):
self.on_repost(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:
log.exception("Error deleting Bluesky post")
output.speak(_("Could not delete."), True)
def url(self, *args, **kwargs):
item = self.get_item()
if not item: return
uri = item.get("uri") if isinstance(item, dict) else getattr(item, "uri", None)
# Convert at:// uri to https://bsky.app link
if uri and "at://" in uri and "app.bsky.feed.post" in uri:
parts = uri.split("/")
# at://did:plc:xxx/app.bsky.feed.post/rkey
did = parts[2]
rkey = parts[-1]
# Need handle for prettier url, but did works? bluesky web supports profile/did/post/rkey?
# Let's try to find handle if possible
handle = None
if isinstance(item, dict):
handle = item.get("handle")
else:
handle = getattr(getattr(item, "author", None), "handle", None)
target = handle if handle else did
link = f"https://bsky.app/profile/{target}/post/{rkey}"
import webbrowser
webbrowser.open(link)
def audio(self, *args, **kwargs):
output.speak(_("Audio playback not supported for Bluesky yet."))
# Helper to map standard keys if they don't invoke the methods above via get_event
# But usually get_event is enough.
# 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
# Use the compose function to get the full formatted text
# Bluesky compose returns [user, text, date, source]
composed = self.compose_function(item, self.session.db, self.session.settings, self.session.settings["general"].get("relative_times", False), self.session.settings["general"].get("show_screen_names", False))
# Join them for a full readout similar to Mastodon's template render
return " ".join(composed)
def view_item(self, *args, **kwargs):
self.view_conversation()
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
import application
controller = application.app.controller
handle = "Unknown"
if isinstance(item, dict):
handle = item.get("author", {}).get("handle", "Unknown")
else:
handle = getattr(getattr(item, "author", None), "handle", "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_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
if g(item, "did") or g(item, "handle"):
author = item
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 not author:
return None
return {
"did": g(author, "did"),
"handle": g(author, "handle"),
}
def process_items(self, items, play_sound=True):
"""
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")
if it.get("id"):
return it.get("id")
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")
return None
post = getattr(it, "post", None)
if post is not None:
return getattr(post, "uri", None)
for attr in ("uri", "id", "did", "handle"):
val = getattr(it, 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)
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
# 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)
return len(new_items)
def save_positions(self):
try:
self.session.db[self.name+"_pos"] = self.buffer.list.get_selected()
except: pass
def remove_buffer(self, force=False):
if self.type in ("conversation", "chat_messages") or self.name.lower().startswith("conversation"):
try:
self.session.db.pop(self.name, None)
except Exception:
pass
return True
return False

View File

@@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
import logging
import wx
import output
from .base import BaseBuffer
from wxUI.buffers.blueski import panels as BlueskiPanels
from sessions.blueski import compose
log = logging.getLogger("controller.buffers.blueski.chat")
class ConversationListBuffer(BaseBuffer):
def __init__(self, *args, **kwargs):
kwargs["compose_func"] = "compose_convo"
super(ConversationListBuffer, self).__init__(*args, **kwargs)
self.type = "chat"
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.ChatPanel(parent, name)
self.buffer.session = self.session
def start_stream(self, mandatory=False, play_sound=True):
count = self.session.settings["general"].get("max_posts_per_call", 50)
try:
res = self.session.list_convos(limit=count)
items = res.get("items", [])
# Clear to avoid list weirdness on refreshes?
# Chat list usually replaces content on fetch
self.session.db[self.name] = []
self.buffer.list.clear()
return self.process_items(items, play_sound)
except Exception:
log.exception("Error fetching conversations")
return 0
def url(self, *args, **kwargs):
# In chat list, Enter (URL) should open the chat conversation buffer
self.view_chat()
def send_message(self, *args, **kwargs):
# Global shortcut for DM
self.view_chat()
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
# 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])
title = _("Chat: {0}").format(names)
import application
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
)
class ChatBuffer(BaseBuffer):
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")
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.ChatMessagePanel(parent, name)
self.buffer.session = self.session
def start_stream(self, mandatory=False, play_sound=True):
if not self.convo_id: return 0
count = self.session.settings["general"].get("max_posts_per_call", 50)
try:
res = self.session.get_convo_messages(self.convo_id, limit=count)
items = res.get("items", [])
# Message order in API is often Oldest...Newest or vice versa.
# We want them in order and only new ones.
# For chat, let's just clear and show last N messages for simplicity now.
self.session.db[self.name] = []
self.buffer.list.clear()
# API usually returns newest first. We want newest at bottom.
items = list(reversed(items))
return self.process_items(items, play_sound)
except Exception:
log.exception("Error fetching chat messages")
return 0
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()
if text:
try:
self.session.send_chat_message(self.convo_id, text)
output.speak(_("Message sent."))
# Refresh
self.start_stream(mandatory=True, play_sound=False)
except:
output.speak(_("Failed to send message."))
dlg.Destroy()
def send_message(self, *args, **kwargs):
# Global shortcut for DM
self.on_reply(None)

View File

@@ -0,0 +1,209 @@
# -*- coding: utf-8 -*-
import logging
from .base import BaseBuffer
from wxUI.buffers.blueski import panels as BlueskiPanels
from pubsub import pub
log = logging.getLogger("controller.buffers.blueski.timeline")
class HomeTimeline(BaseBuffer):
def __init__(self, *args, **kwargs):
super(HomeTimeline, self).__init__(*args, **kwargs)
self.type = "home_timeline"
self.feed_uri = None
def create_buffer(self, parent, name):
# Override to use HomePanel
self.buffer = BlueskiPanels.HomePanel(parent, name)
self.buffer.session = self.session
def start_stream(self, mandatory=False, play_sound=True):
count = 50
try:
count = self.session.settings["general"].get("max_posts_per_call", 50)
except: pass
api = self.session._ensure_client()
# Discover Logic
if not self.feed_uri:
self.feed_uri = self._resolve_discover_feed(api)
items = []
try:
res = None
if self.feed_uri:
# Fetch feed
res = api.app.bsky.feed.get_feed({"feed": self.feed_uri, "limit": count})
else:
# Fallback to standard timeline
res = api.app.bsky.feed.get_timeline({"limit": count})
feed = getattr(res, "feed", [])
items = list(feed)
except Exception:
log.exception("Failed to fetch home timeline")
return 0
return self.process_items(items, play_sound)
def _resolve_discover_feed(self, api):
# Reuse logic from panels.py
try:
cached = self.session.db.get("discover_feed_uri")
if cached: return cached
# Simple fallback: Suggested feeds
try:
res = api.app.bsky.feed.get_suggested_feeds({"limit": 50})
feeds = getattr(res, "feeds", [])
for feed in feeds:
dn = getattr(feed, "displayName", "") or getattr(feed, "display_name", "")
if "discover" in dn.lower():
uri = getattr(feed, "uri", "")
self.session.db["discover_feed_uri"] = uri
try: self.session.save_persistent_data()
except: pass
return uri
except: pass
return None
except:
return None
class FollowingTimeline(BaseBuffer):
def __init__(self, *args, **kwargs):
super(FollowingTimeline, self).__init__(*args, **kwargs)
self.type = "following_timeline"
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.HomePanel(parent, name) # Reuse HomePanel layout
self.buffer.session = self.session
def start_stream(self, mandatory=False, play_sound=True):
count = 50
try: count = self.session.settings["general"].get("max_posts_per_call", 50)
except: pass
api = self.session._ensure_client()
try:
# Force reverse-chronological
res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": "reverse-chronological"})
feed = getattr(res, "feed", [])
items = list(feed)
except Exception:
log.exception("Error fetching following timeline")
return 0
return self.process_items(items, play_sound)
class NotificationBuffer(BaseBuffer):
def __init__(self, *args, **kwargs):
super(NotificationBuffer, self).__init__(*args, **kwargs)
self.type = "notifications"
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.NotificationPanel(parent, name)
self.buffer.session = self.session
def start_stream(self, mandatory=False, play_sound=True):
count = 50
api = self.session._ensure_client()
try:
res = api.app.bsky.notification.list_notifications({"limit": count})
notifs = getattr(res, "notifications", [])
items = []
# Notifications are not FeedViewPost. They have different structure.
# self.compose_function expects FeedViewPost-like structure (post, author, etc).
# We need to map them or have a different compose function.
# For now, let's skip items to avoid crash
# Or attempt to map.
except:
return 0
return 0
class Conversation(BaseBuffer):
def __init__(self, *args, **kwargs):
super(Conversation, self).__init__(*args, **kwargs)
self.type = "conversation"
# We need the root URI or the URI of the post to show thread for
self.root_uri = kwargs.get("uri")
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.HomePanel(parent, name)
self.buffer.session = self.session
def start_stream(self, mandatory=False, play_sound=True):
if not self.root_uri: return 0
api = self.session._ensure_client()
try:
params = {"uri": self.root_uri, "depth": 100, "parentHeight": 100}
try:
res = api.app.bsky.feed.get_post_thread(params)
except Exception:
res = api.app.bsky.feed.get_post_thread({"uri": self.root_uri})
thread = getattr(res, "thread", None)
if not thread:
return 0
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
# Find the root of the thread tree
curr = thread
while g(curr, "parent"):
curr = g(curr, "parent")
final_items = []
def traverse(node):
if not node:
return
post = g(node, "post")
if post:
final_items.append(post)
replies = g(node, "replies") or []
for r in replies:
traverse(r)
traverse(curr)
# Clear existing items to avoid duplication when refreshing a thread view (which changes structure little)
self.session.db[self.name] = []
self.buffer.list.clear() # Clear UI too
return self.process_items(final_items, play_sound)
except Exception:
log.exception("Error fetching thread")
return 0
class LikesBuffer(BaseBuffer):
def __init__(self, *args, **kwargs):
super(LikesBuffer, self).__init__(*args, **kwargs)
self.type = "likes"
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.HomePanel(parent, name)
self.buffer.session = self.session
def start_stream(self, mandatory=False, play_sound=True):
count = 50
try:
count = self.session.settings["general"].get("max_posts_per_call", 50)
except Exception:
pass
api = self.session._ensure_client()
try:
res = api.app.bsky.feed.get_actor_likes({"actor": api.me.did, "limit": count})
items = getattr(res, "feed", None) or getattr(res, "items", None) or []
except Exception:
log.exception("Error fetching likes")
return 0
return self.process_items(list(items), play_sound)

View File

@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
import logging
from .base import BaseBuffer
from wxUI.buffers.blueski import panels as BlueskiPanels
from sessions.blueski import compose
log = logging.getLogger("controller.buffers.blueski.user")
class UserBuffer(BaseBuffer):
def __init__(self, *args, **kwargs):
# We need compose_user for this buffer
kwargs["compose_func"] = "compose_user"
super(UserBuffer, self).__init__(*args, **kwargs)
self.type = "user"
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.UserPanel(parent, name)
self.buffer.session = self.session
def start_stream(self, mandatory=False, play_sound=True):
api_method = self.kwargs.get("api_method")
if not api_method: return 0
count = self.session.settings["general"].get("max_posts_per_call", 50)
actor = (
self.kwargs.get("actor")
or self.kwargs.get("did")
or self.kwargs.get("handle")
or self.kwargs.get("id")
)
try:
# We call the method in session. API methods return {"items": [...], "cursor": ...}
if api_method in ("get_followers", "get_follows"):
res = getattr(self.session, api_method)(actor=actor, limit=count)
else:
res = getattr(self.session, api_method)(limit=count)
items = res.get("items", [])
# Clear existing items for these lists to start fresh?
# Or append? Standard lists in TWBlue usually append.
# But followers/blocks are often full-sync or large jumps.
# For now, append like timelines.
return self.process_items(items, play_sound)
except Exception:
log.exception(f"Error fetching user list for {self.name}")
return 0
class FollowersBuffer(UserBuffer):
def __init__(self, *args, **kwargs):
kwargs["api_method"] = "get_followers"
super(FollowersBuffer, self).__init__(*args, **kwargs)
class FollowingBuffer(UserBuffer):
def __init__(self, *args, **kwargs):
kwargs["api_method"] = "get_follows"
super(FollowingBuffer, self).__init__(*args, **kwargs)
class BlocksBuffer(UserBuffer):
def __init__(self, *args, **kwargs):
kwargs["api_method"] = "get_blocks"
super(BlocksBuffer, self).__init__(*args, **kwargs)

View File

@@ -5,6 +5,7 @@ import logging
import webbrowser
import wx
import requests
import asyncio
import keystrokeEditor
import sessions
import widgetUtils
@@ -293,9 +294,52 @@ class Controller(object):
pub.sendMessage("core.create_account", name=session.get_name(), session_id=session.session_id)
def login_account(self, session_id):
session = None
for i in sessions.sessions:
if sessions.sessions[i].session_id == session_id: session = sessions.sessions[i]
session.login()
if sessions.sessions[i].session_id == session_id:
session = sessions.sessions[i]
break
if not session:
return
old_name = session.get_name()
try:
session.login()
except Exception as e:
log.exception("Login failed for session %s", session_id)
output.speak(_("Login failed for {0}: {1}").format(old_name, str(e)), True)
return
if not session.logged:
output.speak(_("Login failed for {0}. Please check your credentials.").format(old_name), True)
return
new_name = session.get_name()
if old_name != new_name:
log.info(f"Account name changed from {old_name} to {new_name} after login")
if self.current_account == old_name:
self.current_account = new_name
if old_name in self.accounts:
idx = self.accounts.index(old_name)
self.accounts[idx] = new_name
else:
self.accounts.append(new_name)
# Update root buffer name and account
for b in self.buffers:
if b.account == old_name:
b.account = new_name
if hasattr(b, "buffer"):
b.buffer.account = new_name
# If this is the root node, its name matches old_name (e.g. "Bluesky")
if b.name == old_name:
b.name = new_name
if hasattr(b, "buffer"):
b.buffer.name = new_name
# Update tree node label
self.change_buffer_title(old_name, old_name, new_name)
handler = self.get_handler(type=session.type)
if handler != None and hasattr(handler, "create_buffers"):
try:
@@ -329,60 +373,35 @@ class Controller(object):
try:
buffer_panel_class = None
if session_type == "blueski":
from wxUI.buffers.blueski import panels as BlueskiPanels # Import new panels
if buffer_type == "home_timeline":
buffer_panel_class = BlueskiPanels.BlueskiHomeTimelinePanel
# kwargs for HomeTimelinePanel: parent, name, session
# 'name' is buffer_title, 'parent' is self.view.nb
# 'session' needs to be fetched based on user_id in kwargs
if "user_id" in kwargs and "session" not in kwargs: # Ensure session is passed
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
# Clean unsupported kwarg for panel ctor
if "user_id" in kwargs:
kwargs.pop("user_id", None)
if "name" not in kwargs: kwargs["name"] = buffer_title
from controller.buffers.blueski import timeline as BlueskiTimelines
from controller.buffers.blueski import user as BlueskiUsers
from controller.buffers.blueski import chat as BlueskiChats
if "user_id" in kwargs and "session" not in kwargs:
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
if "name" not in kwargs: kwargs["name"] = buffer_title
elif buffer_type == "user_timeline":
buffer_panel_class = BlueskiPanels.BlueskiUserTimelinePanel
# kwargs for UserTimelinePanel: parent, name, session, target_user_did, target_user_handle
if "user_id" in kwargs and "session" not in kwargs:
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
kwargs.pop("user_id", None)
if "name" not in kwargs: kwargs["name"] = buffer_title
# target_user_did and target_user_handle must be in kwargs from blueski.Handler
buffer_map = {
"home_timeline": BlueskiTimelines.HomeTimeline,
"following_timeline": BlueskiTimelines.FollowingTimeline,
"notifications": BlueskiTimelines.NotificationBuffer,
"conversation": BlueskiTimelines.Conversation,
"likes": BlueskiTimelines.LikesBuffer,
"UserBuffer": BlueskiUsers.UserBuffer,
"FollowersBuffer": BlueskiUsers.FollowersBuffer,
"FollowingBuffer": BlueskiUsers.FollowingBuffer,
"BlocksBuffer": BlueskiUsers.BlocksBuffer,
"ConversationListBuffer": BlueskiChats.ConversationListBuffer,
"ChatMessageBuffer": BlueskiChats.ChatBuffer,
"chat_messages": BlueskiChats.ChatBuffer,
}
elif buffer_type == "notifications":
buffer_panel_class = BlueskiPanels.BlueskiNotificationPanel
if "user_id" in kwargs and "session" not in kwargs:
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
kwargs.pop("user_id", None)
if "name" not in kwargs: kwargs["name"] = buffer_title
# target_user_did and target_user_handle must be in kwargs from blueski.Handler
elif buffer_type == "notifications":
buffer_panel_class = BlueskiPanels.BlueskiNotificationPanel
if "user_id" in kwargs and "session" not in kwargs:
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
kwargs.pop("user_id", None)
if "name" not in kwargs: kwargs["name"] = buffer_title
elif buffer_type == "user_list_followers" or buffer_type == "user_list_following":
buffer_panel_class = BlueskiPanels.BlueskiUserListPanel
elif buffer_type == "following_timeline":
buffer_panel_class = BlueskiPanels.BlueskiFollowingTimelinePanel
# Clean stray keys that this panel doesn't accept
kwargs.pop("user_id", None)
kwargs.pop("list_type", None)
if "name" not in kwargs: kwargs["name"] = buffer_title
else:
log.warning(f"Unsupported Blueski buffer type: {buffer_type}. Falling back to generic.")
# Fallback to trying to find it in generic buffers or error
available_buffers = getattr(buffers, "base", None) # Or some generic panel module
if available_buffers and hasattr(available_buffers, buffer_type):
buffer_panel_class = getattr(available_buffers, buffer_type)
elif available_buffers and hasattr(available_buffers, "TimelinePanel"): # Example generic
buffer_panel_class = getattr(available_buffers, "TimelinePanel")
else:
raise AttributeError(f"Blueski buffer type {buffer_type} not found in blueski.panels or base panels.")
buffer_panel_class = buffer_map.get(buffer_type)
if buffer_panel_class is None:
# Fallback for others including user_timeline to HomeTimeline for now
log.warning(f"Unsupported Blueski buffer type: {buffer_type}. Falling back to HomeTimeline.")
buffer_panel_class = BlueskiTimelines.HomeTimeline
else: # Existing logic for other session types
available_buffers = getattr(buffers, session_type)
if not hasattr(available_buffers, buffer_type):
@@ -722,6 +741,12 @@ class Controller(object):
session = buffer.session
if getattr(session, "type", "") == "blueski":
author_handle = ""
if hasattr(buffer, "get_selected_item_author_details"):
details = buffer.get_selected_item_author_details()
if details:
author_handle = details.get("handle", "") or details.get("did", "")
initial_text = f"@{author_handle} " if author_handle and not author_handle.startswith("@") else (f"{author_handle} " if author_handle else "")
if self.showing == False:
dlg = wx.TextEntryDialog(None, _("Write your reply:"), _("Reply"))
if dlg.ShowModal() == wx.ID_OK:
@@ -742,7 +767,7 @@ class Controller(object):
dlg.Destroy()
return
from wxUI.dialogs.blueski.postDialogs import Post as ATPostDialog
dlg = ATPostDialog(caption=_("Reply"))
dlg = ATPostDialog(caption=_("Reply"), text=initial_text)
if dlg.ShowModal() == wx.ID_OK:
text, files, cw_text, langs = dlg.get_payload()
dlg.Destroy()
@@ -1432,13 +1457,10 @@ class Controller(object):
def update_buffers(self):
for i in self.buffers[:]:
if i.session != None and i.session.is_logged == True:
# For Blueski, initial load is in session.start() or manual.
# Periodic updates would need a separate timer or manual refresh via update_buffer.
if i.session.KIND != "blueski":
try:
i.start_stream(mandatory=True) # This is likely for streaming connections or timed polling within buffer
except Exception as err:
log.exception("Error %s starting buffer %s on account %s, with args %r and kwargs %r." % (str(err), i.name, i.account, i.args, i.kwargs))
try:
i.start_stream(mandatory=True)
except Exception as err:
log.exception("Error %s starting buffer %s on account %s, with args %r and kwargs %r." % (str(err), i.name, i.account, i.args, i.kwargs))
def update_buffer(self, *args, **kwargs):
"""Handles the 'Update buffer' menu command to fetch newest items."""
@@ -1454,50 +1476,27 @@ class Controller(object):
new_ids = []
try:
if session.KIND == "blueski":
if bf.name == f"{session.label} Home": # Assuming buffer name indicates type
# Its panel's load_initial_posts calls session.fetch_home_timeline
if hasattr(bf, "load_initial_posts"): # Generic for timeline panels
await bf.load_initial_posts(limit=config.app["app-settings"].get("items_per_request", 20))
new_ids = getattr(bf, "item_uris", [])
else: # Should not happen if panel is correctly typed
logger.warning(f"Home timeline panel for {session.KIND} missing load_initial_posts")
elif bf.type == "notifications" and hasattr(bf, "refresh_notifications"):
await bf.refresh_notifications(limit=config.app["app-settings"].get("items_per_request", 20))
new_ids = []
elif bf.type == "user_timeline" and hasattr(bf, "load_initial_posts"):
await bf.load_initial_posts(limit=config.app["app-settings"].get("items_per_request", 20))
new_ids = getattr(bf, "item_uris", [])
elif bf.type in ["user_list_followers", "user_list_following"] and hasattr(bf, "load_initial_users"):
await bf.load_initial_users(limit=config.app["app-settings"].get("items_per_request", 30))
new_ids = [u.get("did") for u in getattr(bf, "user_list_data", []) if isinstance(u,dict)]
if hasattr(bf, "start_stream"):
count = bf.start_stream(mandatory=True)
if count: new_ids = [str(x) for x in range(count)]
else:
if hasattr(bf, "start_stream"): # Fallback for non-Blueski panels or unhandled types
count = bf.start_stream(mandatory=True, avoid_autoreading=True)
if count is not None: new_ids = [str(x) for x in range(count)] # Dummy IDs for count
else:
output.speak(_(u"This buffer type cannot be updated in this way."), True)
return
else: # For other session types (e.g. Mastodon)
output.speak(_(u"This buffer type cannot be updated."), True)
return
else: # Generic fallback for other sessions
if hasattr(bf, "start_stream"):
count = bf.start_stream(mandatory=True, avoid_autoreading=True)
if count is not None: new_ids = [str(x) for x in range(count)] # Dummy IDs for count
if count: new_ids = [str(x) for x in range(count)]
else:
output.speak(_(u"Unable to update this buffer."), True)
return
# Generic feedback based on new_ids for timelines or user lists
# Generic feedback
if bf.type in ["home_timeline", "user_timeline"]:
output.speak(_("{0} posts retrieved").format(len(new_ids)), True)
elif bf.type in ["user_list_followers", "user_list_following"]:
output.speak(_("{0} users retrieved").format(len(new_ids)), True)
elif bf.type == "notifications":
output.speak(_("Notifications updated."), True)
# else, original start_stream might have given feedback
except NotificationError as e:
output.speak(str(e), True) # Ensure output.speak is on main thread if called from here
except Exception as e_general:
logger.error(f"Error updating buffer {bf.name}: {e_general}", exc_info=True)
except Exception as e:
log.exception("Error updating buffer %s", bf.name)
output.speak(_("An error occurred while updating the buffer."), True)
wx.CallAfter(asyncio.create_task, do_update())
@@ -1674,10 +1673,9 @@ class Controller(object):
# The handler's user_details method is responsible for extracting context
# (e.g., selected user) from the buffer and displaying the profile.
# For Blueski, handler.user_details calls the ShowUserProfileDialog.
# It's an async method, so needs to be called appropriately.
async def _show_details():
await handler.user_details(buffer)
wx.CallAfter(asyncio.create_task, _show_details())
result = handler.user_details(buffer)
if asyncio.iscoroutine(result):
call_threaded(asyncio.run, result)
else:
output.speak(_("This session type does not support viewing user details in this way."), True)
@@ -1737,9 +1735,9 @@ class Controller(object):
if author_details: user = author_details
if handler and hasattr(handler, 'open_followers_timeline'):
async def _open_followers():
await handler.open_followers_timeline(main_controller=self, session=session_to_use, user_payload=user)
wx.CallAfter(asyncio.create_task, _open_followers())
result = handler.open_followers_timeline(main_controller=self, session=session_to_use, user_payload=user)
if asyncio.iscoroutine(result):
call_threaded(asyncio.run, result)
elif handler and hasattr(handler, 'openFollowersTimeline'): # Fallback
handler.openFollowersTimeline(self, current_buffer, user)
else:
@@ -1768,9 +1766,9 @@ class Controller(object):
if author_details: user = author_details
if handler and hasattr(handler, 'open_following_timeline'):
async def _open_following():
await handler.open_following_timeline(main_controller=self, session=session_to_use, user_payload=user)
wx.CallAfter(asyncio.create_task, _open_following())
result = handler.open_following_timeline(main_controller=self, session=session_to_use, user_payload=user)
if asyncio.iscoroutine(result):
call_threaded(asyncio.run, result)
elif handler and hasattr(handler, 'openFollowingTimeline'): # Fallback
handler.openFollowingTimeline(self, current_buffer, user)
else: