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:

View File

@@ -205,6 +205,10 @@ class sessionManagerController(object):
# But for immediate use if not restarting, it might need to be added to sessions.sessions
sessions.sessions[location] = s # Make it globally available immediately
self.new_sessions[location] = s
# Sync with global config
if location not in config.app["sessions"]["sessions"]:
config.app["sessions"]["sessions"].append(location)
config.app.write()
else: # Authorise returned False or None
@@ -232,6 +236,9 @@ class sessionManagerController(object):
self.view.remove_session(index)
self.removed_sessions.append(selected_account.get("id"))
self.sessions.remove(selected_account)
if selected_account.get("id") in config.app["sessions"]["sessions"]:
config.app["sessions"]["sessions"].remove(selected_account.get("id"))
config.app.write()
shutil.rmtree(path=os.path.join(paths.config_path(), selected_account.get("id")), ignore_errors=True)
def configuration(self):

View File

@@ -59,7 +59,9 @@ class baseSession(object):
if not os.path.exists(path):
log.debug("Creating %s path" % (os.path.join(paths.config_path(), path),))
os.mkdir(path)
config.app["sessions"]["sessions"].append(id)
if self.session_id not in config.app["sessions"]["sessions"]:
config.app["sessions"]["sessions"].append(self.session_id)
config.app.write()
def get_configuration(self):
""" Get settings for a session."""

View File

@@ -4,12 +4,11 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from datetime import datetime
from approve.translation import translate as _
from approve.util import parse_iso_datetime # For parsing ISO timestamps
import arrow
import languageHandler
if TYPE_CHECKING:
from approve.sessions.blueski.session import Session as BlueskiSession
from sessions.blueski.session import Session as BlueskiSession
from atproto.xrpc_client import models # For type hinting ATProto models
logger = logging.getLogger(__name__)
@@ -94,12 +93,23 @@ class BlueskiCompose:
post_text = getattr(record, 'text', '') if not isinstance(record, dict) else record.get('text', '')
reason = post_data.get("reason")
if reason:
rtype = getattr(reason, "$type", "") if not isinstance(reason, dict) else reason.get("$type", "")
if not rtype and not isinstance(reason, dict):
rtype = getattr(reason, "py_type", "")
if rtype and "reasonRepost" in rtype:
by = getattr(reason, "by", None) if not isinstance(reason, dict) else reason.get("by")
by_handle = getattr(by, "handle", "") if by and not isinstance(by, dict) else (by.get("handle", "") if by else "")
reason_line = _("Reposted by @{handle}").format(handle=by_handle) if by_handle else _("Reposted")
post_text = f"{reason_line}\n{post_text}" if post_text else reason_line
created_at_str = getattr(record, 'createdAt', '') if not isinstance(record, dict) else record.get('createdAt', '')
timestamp_str = ""
if created_at_str:
try:
dt_obj = parse_iso_datetime(created_at_str)
timestamp_str = dt_obj.strftime("%I:%M %p - %b %d, %Y") if dt_obj else created_at_str
ts = arrow.get(created_at_str)
timestamp_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
except Exception as e:
logger.debug(f"Could not parse timestamp {created_at_str}: {e}")
timestamp_str = created_at_str
@@ -143,8 +153,10 @@ class BlueskiCompose:
if alt_texts_present: embed_display += _(" (Alt text available)")
embed_display += "]"
elif embed_type in ['app.bsky.embed.record#view', 'app.bsky.embed.record']:
elif embed_type in ['app.bsky.embed.record#view', 'app.bsky.embed.record', 'app.bsky.embed.recordWithMedia#view', 'app.bsky.embed.recordWithMedia']:
record_embed_data = getattr(embed_data, 'record', None) if hasattr(embed_data, 'record') else embed_data.get('record', None)
if record_embed_data and isinstance(record_embed_data, dict):
record_embed_data = record_embed_data.get("record") or record_embed_data
record_embed_type = getattr(record_embed_data, '$type', '')
if not record_embed_type and isinstance(record_embed_data, dict): record_embed_type = record_embed_data.get('$type', '')
@@ -243,3 +255,275 @@ class BlueskiCompose:
display_parts.append(f"\"{body_snippet}\"")
return " ".join(display_parts).strip()
def compose_post(post, db, settings, relative_times, show_screen_names=False, safe=True):
"""
Compose a Bluesky post into a list of strings [User, Text, Date, Source].
post: dict or ATProto model object.
"""
# Extract data using getattr for models or .get for dicts
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
# Resolve Post View or Feed View structure
# Feed items often have .post field. Direct post objects don't.
actual_post = g(post, "post", post)
record = g(actual_post, "record", {})
author = g(actual_post, "author", {})
# Author
handle = g(author, "handle", "")
display_name = g(author, "displayName") or g(author, "display_name") or handle or "Unknown"
if show_screen_names:
user_str = f"@{handle}"
else:
# "Display Name (@handle)"
if handle and display_name != handle:
user_str = f"{display_name} (@{handle})"
else:
user_str = f"@{handle}"
# Text
text = g(record, "text", "")
# Repost reason (so users know why they see an unfamiliar post)
reason = g(post, "reason", None)
if reason:
rtype = g(reason, "$type") or g(reason, "py_type")
if rtype and "reasonRepost" in rtype:
by = g(reason, "by", {})
by_handle = g(by, "handle", "")
reason_line = _("Reposted by @{handle}").format(handle=by_handle) if by_handle else _("Reposted")
text = f"{reason_line}\n{text}" if text else reason_line
# Labels / Content Warning
labels = g(actual_post, "labels", [])
cw_text = ""
is_sensitive = False
for label in labels:
val = g(label, "val", "")
if val in ["!warn", "porn", "sexual", "nudity", "gore", "graphic-media", "corpse", "self-harm", "hate", "spam", "impersonation"]:
is_sensitive = True
if not cw_text: cw_text = _("Sensitive Content")
elif val.startswith("warn:"):
is_sensitive = True
cw_text = val.split("warn:", 1)[-1].strip()
if cw_text:
text = f"CW: {cw_text}\n\n{text}"
# Embeds (Images, Quotes)
embed = g(actual_post, "embed", None)
if embed:
etype = g(embed, "$type") or g(embed, "py_type")
if etype and ("images" in etype):
images = g(embed, "images", [])
if images:
text += f"\n[{len(images)} {_('Images')}]"
# Handle Record (Quote) or RecordWithMedia (Quote + Media)
quote_rec = None
if etype and ("recordWithMedia" in etype):
# Extract the nested record
rec_embed = g(embed, "record", {})
if rec_embed:
quote_rec = g(rec_embed, "record", None) or rec_embed
# Also check for media in the wrapper
media = g(embed, "media", {})
mtype = g(media, "$type") or g(media, "py_type")
if mtype and "images" in mtype:
images = g(media, "images", [])
if images: text += f"\n[{len(images)} {_('Images')}]"
elif etype and ("record" in etype):
# Direct quote
quote_rec = g(embed, "record", {})
if isinstance(quote_rec, dict):
quote_rec = quote_rec.get("record") or quote_rec
if quote_rec:
# It is likely a ViewRecord
# Check type (ViewRecord, ViewNotFound, ViewBlocked, etc)
qtype = g(quote_rec, "$type") or g(quote_rec, "py_type")
if qtype and "viewNotFound" in qtype:
text += f"\n[{_('Quoted post not found')}]"
elif qtype and "viewBlocked" in qtype:
text += f"\n[{_('Quoted post blocked')}]"
elif qtype and "generatorView" in qtype:
# Feed generator
gen = g(quote_rec, "displayName", "Feed")
text += f"\n[{_('Quoting Feed')}: {gen}]"
else:
# Assume ViewRecord
q_author = g(quote_rec, "author", {})
q_handle = g(q_author, "handle", "unknown")
q_val = g(quote_rec, "value", {})
q_text = g(q_val, "text", "")
if q_text:
text += f"\n[{_('Quoting')} @{q_handle}: {q_text}]"
else:
text += f"\n[{_('Quoting')} @{q_handle}]"
elif etype and ("external" in etype):
ext = g(embed, "external", {})
uri = g(ext, "uri", "")
title = g(ext, "title", "")
text += f"\n[{_('Link')}: {title}]"
# Date
indexed_at = g(actual_post, "indexed_at", "")
ts_str = ""
if indexed_at:
try:
# Try arrow parsing
import arrow
ts = arrow.get(indexed_at)
if relative_times:
ts_str = ts.humanize(locale=languageHandler.curLang[:2])
else:
ts_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
except Exception:
ts_str = str(indexed_at)[:16].replace("T", " ")
# Source (not always available in Bsky view, often just client)
# We'll leave it empty or mock it if needed
source = "Bluesky"
return [user_str, text, ts_str, source]
def compose_user(user, db, settings, relative_times, show_screen_names=False, safe=True):
"""
Compose a Bluesky user for list display.
Returns: [User summary string]
"""
# Extract data using getattr for models or .get for dicts
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
handle = g(user, "handle", "unknown")
display_name = g(user, "displayName") or g(user, "display_name") or handle
followers = g(user, "followersCount", None)
following = g(user, "followsCount", None)
posts = g(user, "postsCount", None)
created_at = g(user, "createdAt", None)
ts = ""
if created_at:
try:
import arrow
original_date = arrow.get(created_at)
if relative_times:
ts = original_date.humanize(locale=languageHandler.curLang[:2])
else:
offset = db.get("utc_offset", 0) if isinstance(db, dict) else 0
ts = original_date.shift(hours=offset).format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
except Exception:
ts = str(created_at)
parts = [f"{display_name} (@{handle})."]
if followers is not None and following is not None and posts is not None:
parts.append(_("{followers} followers, {following} following, {posts} posts.").format(
followers=followers, following=following, posts=posts
))
if ts:
parts.append(_("Joined {date}").format(date=ts))
return [" ".join(parts).strip()]
def compose_convo(convo, db, settings, relative_times, show_screen_names=False, safe=True):
"""
Compose a Bluesky chat conversation for list display.
Returns: [Participants, Last Message, Date]
"""
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
members = g(convo, "members", [])
self_did = db.get("user_id") if isinstance(db, dict) else None
others = []
for m in members:
did = g(m, "did", None)
if self_did and did == self_did:
continue
label = g(m, "displayName") or g(m, "display_name") or g(m, "handle", "unknown")
others.append(label)
if not others:
others = [g(m, "displayName") or g(m, "display_name") or g(m, "handle", "unknown") for m in members]
participants = ", ".join(others)
last_msg_obj = g(convo, "lastMessage") or g(convo, "last_message")
last_text = ""
last_sender = ""
if last_msg_obj:
last_text = g(last_msg_obj, "text", "")
sender = g(last_msg_obj, "sender", None)
if sender:
last_sender = g(sender, "displayName") or g(sender, "display_name") or g(sender, "handle", "")
# Date (using lastMessage.sentAt)
date_str = ""
sent_at = None
if last_msg_obj:
sent_at = g(last_msg_obj, "sentAt") or g(last_msg_obj, "sent_at")
if sent_at:
try:
import arrow
ts = arrow.get(sent_at)
if relative_times:
date_str = ts.humanize(locale=languageHandler.curLang[:2])
else:
date_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
except:
date_str = str(sent_at)[:16]
if last_sender and last_text:
last_text = _("Last message from {user}: {text}").format(user=last_sender, text=last_text)
elif last_text:
last_text = _("Last message: {text}").format(text=last_text)
return [participants, last_text, date_str]
def compose_chat_message(msg, db, settings, relative_times, show_screen_names=False, safe=True):
"""
Compose an individual chat message for display in a thread.
Returns: [Sender, Text, Date]
"""
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
sender = g(msg, "sender", {})
handle = g(sender, "displayName") or g(sender, "display_name") or g(sender, "handle", "unknown")
text = g(msg, "text", "")
sent_at = g(msg, "sentAt") or g(msg, "sent_at")
date_str = ""
if sent_at:
try:
import arrow
ts = arrow.get(sent_at)
if relative_times:
date_str = ts.humanize(locale=languageHandler.curLang[:2])
else:
date_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
except:
date_str = str(sent_at)[:16]
return [handle, text, date_str]

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
import re
from typing import Any
import wx
@@ -66,6 +67,7 @@ class Session(base.baseSession):
handle = (
self.db.get("user_name")
or (self.settings and self.settings.get("blueski", {}).get("handle"))
or (self.settings and self.settings.get("atprotosocial", {}).get("handle"))
or (getattr(getattr(self, "api", None), "me", None) and self.api.me.handle)
)
if handle:
@@ -129,9 +131,10 @@ class Session(base.baseSession):
self.settings.write()
self.logged = True
log.debug("Logged in to Bluesky as %s", api.me.handle)
except Exception:
except Exception as e:
log.exception("Bluesky login failed")
self.logged = False
raise e
def authorise(self):
self._ensure_settings_namespace()
@@ -175,7 +178,7 @@ class Session(base.baseSession):
_("We could not log in to Bluesky. Please verify your handle and app password."),
_("Login error"), wx.ICON_ERROR
)
return
return False
return True
def get_message_url(self, message_id, context=None):
@@ -207,6 +210,22 @@ class Session(base.baseSession):
"$type": "app.bsky.feed.post",
"text": text,
}
# Facets (Links and Mentions)
try:
facets = self._get_facets(text, api)
if facets:
record["facets"] = facets
except:
pass
# Labels (CW)
if cw_text:
record["labels"] = {
"$type": "com.atproto.label.defs#selfLabels",
"values": [{"val": "warn"}]
}
# createdAt
try:
record["createdAt"] = api.get_current_time_iso()
@@ -360,16 +379,164 @@ class Session(base.baseSession):
uri = None
if not uri:
raise RuntimeError("Post did not return a URI")
# Store last post id if useful
self.db.setdefault("sent", [])
self.db["sent"].append(dict(id=uri, text=message))
self.save_persistent_data()
return uri
except Exception:
log.exception("Error sending Bluesky post")
output.speak(_("An error occurred while posting to Bluesky."), True)
return None
def _get_facets(self, text, api):
facets = []
# Mentions
for m in re.finditer(r'@([a-zA-Z0-9.-]+)', text):
handle = m.group(1)
try:
# We should probably cache this identity lookup
res = api.com.atproto.identity.resolve_handle({'handle': handle})
did = res.did
facets.append({
'index': {
'byteStart': len(text[:m.start()].encode('utf-8')),
'byteEnd': len(text[:m.end()].encode('utf-8'))
},
'features': [{'$type': 'app.bsky.richtext.facet#mention', 'did': did}]
})
except:
continue
# Links
for m in re.finditer(r'(https?://[^\s]+)', text):
url = m.group(1)
facets.append({
'index': {
'byteStart': len(text[:m.start()].encode('utf-8')),
'byteEnd': len(text[:m.end()].encode('utf-8'))
},
'features': [{'$type': 'app.bsky.richtext.facet#link', 'uri': url}]
})
return facets
def delete_post(self, uri: str) -> bool:
"""Delete a post by its AT URI."""
api = self._ensure_client()
try:
# at://did:plc:xxx/app.bsky.feed.post/rkey
parts = uri.split("/")
rkey = parts[-1]
api.com.atproto.repo.delete_record({
"repo": api.me.did,
"collection": "app.bsky.feed.post",
"rkey": rkey
})
return True
except:
log.exception("Error deleting Bluesky post")
return False
def block_user(self, did: str) -> bool:
"""Block a user by their DID."""
api = self._ensure_client()
try:
api.com.atproto.repo.create_record({
"repo": api.me.did,
"collection": "app.bsky.graph.block",
"record": {
"$type": "app.bsky.graph.block",
"subject": did,
"createdAt": api.get_current_time_iso()
}
})
return True
except:
log.exception("Error blocking Bluesky user")
return False
def unblock_user(self, block_uri: str) -> bool:
"""Unblock a user by the URI of the block record."""
api = self._ensure_client()
try:
parts = block_uri.split("/")
rkey = parts[-1]
api.com.atproto.repo.delete_record({
"repo": api.me.did,
"collection": "app.bsky.graph.block",
"rkey": rkey
})
return True
except:
log.exception("Error unblocking Bluesky user")
return False
def get_profile(self, actor: str) -> Any:
api = self._ensure_client()
try:
return api.app.bsky.actor.get_profile({"actor": actor})
except Exception:
log.exception("Error fetching Bluesky profile for %s", actor)
return None
def follow_user(self, did: str) -> bool:
api = self._ensure_client()
try:
api.com.atproto.repo.create_record({
"repo": api.me.did,
"collection": "app.bsky.graph.follow",
"record": {
"$type": "app.bsky.graph.follow",
"subject": did,
"createdAt": api.get_current_time_iso()
}
})
return True
except Exception:
log.exception("Error following Bluesky user")
return False
def unfollow_user(self, follow_uri: str) -> bool:
api = self._ensure_client()
try:
parts = follow_uri.split("/")
rkey = parts[-1]
api.com.atproto.repo.delete_record({
"repo": api.me.did,
"collection": "app.bsky.graph.follow",
"rkey": rkey
})
return True
except Exception:
log.exception("Error unfollowing Bluesky user")
return False
def mute_user(self, did: str) -> bool:
api = self._ensure_client()
try:
graph = api.app.bsky.graph
if hasattr(graph, "mute_actor"):
graph.mute_actor({"actor": did})
elif hasattr(graph, "muteActor"):
graph.muteActor({"actor": did})
else:
return False
return True
except Exception:
log.exception("Error muting Bluesky user")
return False
def unmute_user(self, did: str) -> bool:
api = self._ensure_client()
try:
graph = api.app.bsky.graph
if hasattr(graph, "unmute_actor"):
graph.unmute_actor({"actor": did})
elif hasattr(graph, "unmuteActor"):
graph.unmuteActor({"actor": did})
else:
return False
return True
except Exception:
log.exception("Error unmuting Bluesky user")
return False
def repost(self, post_uri: str, post_cid: str | None = None) -> str | None:
"""Create a simple repost of a given post. Returns URI of the repost record or None."""
if not self.logged:
@@ -415,3 +582,80 @@ class Session(base.baseSession):
except Exception:
log.exception("Error creating Bluesky repost record")
return None
def like(self, post_uri: str, post_cid: str | None = None) -> str | None:
"""Create a like for a given post."""
if not self.logged:
raise Exceptions.NotLoggedSessionError("You are not logged in yet.")
try:
api = self._ensure_client()
# Resolve strong ref if needed
def _get_strong_ref(uri: str):
try:
posts_res = api.app.bsky.feed.get_posts({"uris": [uri]})
posts = getattr(posts_res, "posts", None) or []
except Exception:
try: posts_res = api.app.bsky.feed.get_posts(uris=[uri])
except: posts_res = None
posts = getattr(posts_res, "posts", None) or []
if posts:
p = posts[0]
return {"uri": getattr(p, "uri", uri), "cid": getattr(p, "cid", None)}
return None
if not post_cid:
strong = _get_strong_ref(post_uri)
if not strong: return None
post_uri = strong["uri"]
post_cid = strong["cid"]
out = api.com.atproto.repo.create_record({
"repo": api.me.did,
"collection": "app.bsky.feed.like",
"record": {
"$type": "app.bsky.feed.like",
"subject": {"uri": post_uri, "cid": post_cid},
"createdAt": getattr(api, "get_current_time_iso", lambda: None)() or None,
},
})
return getattr(out, "uri", None)
except Exception:
log.exception("Error creating Bluesky like")
return None
def get_followers(self, actor: str | None = None, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
api = self._ensure_client()
actor = actor or api.me.did
res = api.app.bsky.graph.get_followers({"actor": actor, "limit": limit, "cursor": cursor})
return {"items": res.followers, "cursor": res.cursor}
def get_follows(self, actor: str | None = None, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
api = self._ensure_client()
actor = actor or api.me.did
res = api.app.bsky.graph.get_follows({"actor": actor, "limit": limit, "cursor": cursor})
return {"items": res.follows, "cursor": res.cursor}
def get_blocks(self, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
api = self._ensure_client()
res = api.app.bsky.graph.get_blocks({"limit": limit, "cursor": cursor})
return {"items": res.blocks, "cursor": res.cursor}
def list_convos(self, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
api = self._ensure_client()
res = api.chat.bsky.convo.list_convos({"limit": limit, "cursor": cursor})
return {"items": res.convos, "cursor": res.cursor}
def get_convo_messages(self, convo_id: str, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
api = self._ensure_client()
res = api.chat.bsky.convo.get_messages({"convoId": convo_id, "limit": limit, "cursor": cursor})
return {"items": res.messages, "cursor": res.cursor}
def send_chat_message(self, convo_id: str, text: str) -> Any:
api = self._ensure_client()
return api.chat.bsky.convo.send_message({
"convoId": convo_id,
"message": {
"text": text
}
})

View File

@@ -1,393 +1,149 @@
# -*- coding: utf-8 -*-
import wx
import languageHandler # Ensure _() is available
import logging
import wx
import config
from mysc.repeating_timer import RepeatingTimer
import arrow
import arrow
from datetime import datetime
import languageHandler
from multiplatform_widgets import widgets
log = logging.getLogger("wxUI.buffers.blueski.panels")
class BlueskiHomeTimelinePanel(object):
"""Minimal Home timeline buffer for Bluesky.
Exposes a .buffer wx.Panel with a List control and provides
start_stream()/get_more_items() to fetch items from atproto.
"""
def __init__(self, parent, name: str, session):
super().__init__()
self.session = session
self.account = session.get_name()
self.name = name
self.type = "home_timeline"
self.timeline_algorithm = None
self.invisible = True
self.needs_init = True
self.buffer = _HomePanel(parent, name)
self.buffer.session = session
self.buffer.name = name
# Ensure controller can resolve current account from the GUI panel
self.buffer.account = self.account
self.items = [] # list of dicts: {uri, author, handle, display_name, text, indexed_at}
self.cursor = None
self._auto_timer = None
def start_stream(self, mandatory=False, play_sound=True):
"""Fetch newest items and render them."""
try:
count = self.session.settings["general"]["max_posts_per_call"] or 40
except Exception:
count = 40
try:
api = self.session._ensure_client()
# The atproto SDK expects params, not raw kwargs
try:
from atproto import models as at_models # type: ignore
params = at_models.AppBskyFeedGetTimeline.Params(
limit=count,
algorithm=self.timeline_algorithm
)
res = api.app.bsky.feed.get_timeline(params)
except Exception:
payload = {"limit": count}
if self.timeline_algorithm:
payload["algorithm"] = self.timeline_algorithm
res = api.app.bsky.feed.get_timeline(payload)
feed = getattr(res, "feed", [])
self.cursor = getattr(res, "cursor", None)
self.items = []
for it in feed:
post = getattr(it, "post", None)
if not post:
continue
# No additional client-side filtering; server distinguishes timelines correctly
record = getattr(post, "record", None)
author = getattr(post, "author", None)
text = getattr(record, "text", "") if record else ""
handle = getattr(author, "handle", "") if author else ""
display_name = (
getattr(author, "display_name", None)
or getattr(author, "displayName", None)
or ""
) if author else ""
indexed_at = getattr(post, "indexed_at", None)
item = {
"uri": getattr(post, "uri", ""),
"author": display_name or handle,
"handle": handle,
"display_name": display_name,
"text": text,
"indexed_at": indexed_at,
}
self._append_item(item, to_top=self._reverse())
# Full rerender to ensure column widths and selection
self._render_list(replace=True)
return len(self.items)
except Exception:
log.exception("Failed to load Bluesky home timeline")
self.buffer.list.clear()
self.buffer.list.insert_item(False, _("Error"), _("Could not load timeline."), "")
return 0
def get_more_items(self):
if not self.cursor:
return 0
try:
api = self.session._ensure_client()
try:
from atproto import models as at_models # type: ignore
params = at_models.AppBskyFeedGetTimeline.Params(
limit=40,
cursor=self.cursor,
algorithm=self.timeline_algorithm
)
res = api.app.bsky.feed.get_timeline(params)
except Exception:
payload = {"limit": 40, "cursor": self.cursor}
if self.timeline_algorithm:
payload["algorithm"] = self.timeline_algorithm
res = api.app.bsky.feed.get_timeline(payload)
feed = getattr(res, "feed", [])
self.cursor = getattr(res, "cursor", None)
new_items = []
for it in feed:
post = getattr(it, "post", None)
if not post:
continue
# No additional client-side filtering
record = getattr(post, "record", None)
author = getattr(post, "author", None)
text = getattr(record, "text", "") if record else ""
handle = getattr(author, "handle", "") if author else ""
display_name = (
getattr(author, "display_name", None)
or getattr(author, "displayName", None)
or ""
) if author else ""
indexed_at = getattr(post, "indexed_at", None)
new_items.append({
"uri": getattr(post, "uri", ""),
"author": display_name or handle,
"handle": handle,
"display_name": display_name,
"text": text,
"indexed_at": indexed_at,
})
if not new_items:
return 0
for it in new_items:
self._append_item(it, to_top=self._reverse())
# Render only the newly added slice
self._render_list(replace=False, start=len(self.items) - len(new_items))
return len(new_items)
except Exception:
log.exception("Failed to load more Bluesky timeline items")
return 0
# Alias to integrate with mainController expectations for Blueski
def load_more_posts(self, *args, **kwargs):
return self.get_more_items()
def _reverse(self) -> bool:
try:
return bool(self.session.settings["general"].get("reverse_timelines", False))
except Exception:
return False
def _append_item(self, item: dict, to_top: bool = False):
if to_top:
self.items.insert(0, item)
else:
self.items.append(item)
def _render_list(self, replace: bool, start: int = 0):
if replace:
self.buffer.list.clear()
for i in range(start, len(self.items)):
it = self.items[i]
dt = ""
if it.get("indexed_at"):
try:
# Mastodon-like date formatting: relative or full date
rel = False
try:
rel = bool(self.session.settings["general"].get("relative_times", False))
except Exception:
rel = False
ts = arrow.get(str(it["indexed_at"]))
if rel:
dt = ts.humanize(locale=languageHandler.curLang[:2])
else:
dt = ts.format(_("dddd, MMMM D, YYYY H:m:s"), locale=languageHandler.curLang[:2])
except Exception:
try:
dt = str(it["indexed_at"])[:16].replace("T", " ")
except Exception:
dt = ""
text = it.get("text", "").replace("\n", " ")
if len(text) > 200:
text = text[:197] + "..."
# Display name and handle like Mastodon: "Display (@handle)"
author_col = it.get("author", "")
handle = it.get("handle", "")
if handle and it.get("display_name"):
author_col = f"{it.get('display_name')} (@{handle})"
elif handle and not author_col:
author_col = f"@{handle}"
self.buffer.list.insert_item(False, author_col, text, dt)
# For compatibility with controller expectations
def save_positions(self):
try:
pos = self.buffer.list.get_selected()
self.session.db[self.name + "_pos"] = pos
except Exception:
pass
# Support actions that need a selected item identifier (e.g., reply)
def get_selected_item_id(self):
try:
idx = self.buffer.list.get_selected()
if idx is None or idx < 0:
return None
return self.items[idx].get("uri")
except Exception:
return None
def get_message(self):
try:
idx = self.buffer.list.get_selected()
if idx is None or idx < 0:
return ""
it = self.items[idx]
author = it.get("display_name") or it.get("author") or ""
handle = it.get("handle")
if handle:
author = f"{author} (@{handle})" if author else f"@{handle}"
text = it.get("text", "").replace("\n", " ")
dt = ""
if it.get("indexed_at"):
try:
dt = str(it["indexed_at"])[:16].replace("T", " ")
except Exception:
dt = ""
parts = [p for p in [author, text, dt] if p]
return ", ".join(parts)
except Exception:
return ""
# Auto-refresh support (polling) to simulate near real-time updates
def _periodic_refresh(self):
try:
# Ensure UI updates happen on the main thread
wx.CallAfter(self.start_stream, False, False)
except Exception:
pass
def enable_auto_refresh(self, seconds: int | None = None):
try:
if self._auto_timer:
return
if seconds is None:
# Use global update_period (minutes) → seconds; minimum 15s
minutes = config.app["app-settings"].get("update_period", 2)
seconds = max(15, int(minutes * 60))
self._auto_timer = RepeatingTimer(seconds, self._periodic_refresh)
self._auto_timer.start()
except Exception:
log.exception("Failed to enable auto refresh for Bluesky panel %s", self.name)
def disable_auto_refresh(self):
try:
if self._auto_timer:
self._auto_timer.stop()
self._auto_timer = None
except Exception:
pass
class _HomePanel(wx.Panel):
def __init__(self, parent, name):
class HomePanel(wx.Panel):
def __init__(self, parent, name, account="Unknown"):
super().__init__(parent, name=name)
self.name = name
self.account = account
self.type = "home_timeline"
sizer = wx.BoxSizer(wx.VERTICAL)
self.list = widgets.list(self, _("Author"), _("Post"), _("Date"), style=wx.LC_REPORT | wx.LC_SINGLE_SEL)
self.sizer = wx.BoxSizer(wx.VERTICAL)
# List
self.list = widgets.list(self, _("Author"), _("Post"), _("Date"), style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_VRULES)
self.list.set_windows_size(0, 120)
self.list.set_windows_size(1, 360)
self.list.set_windows_size(2, 150)
self.list.set_windows_size(1, 400)
self.list.set_windows_size(2, 120)
self.list.set_size()
sizer.Add(self.list.list, 1, wx.EXPAND | wx.ALL, 0)
self.SetSizer(sizer)
# Buttons
self.post = wx.Button(self, -1, _("Post"))
self.repost = wx.Button(self, -1, _("Repost"))
self.reply = wx.Button(self, -1, _("Reply"))
self.like = wx.Button(self, wx.ID_ANY, _("Like"))
# self.bookmark = wx.Button(self, wx.ID_ANY, _("Bookmark")) # Not yet common in Bsky API usage here
self.dm = wx.Button(self, -1, _("Chat"))
btnSizer = wx.BoxSizer(wx.HORIZONTAL)
btnSizer.Add(self.post, 0, wx.ALL, 5)
btnSizer.Add(self.repost, 0, wx.ALL, 5)
btnSizer.Add(self.reply, 0, wx.ALL, 5)
btnSizer.Add(self.like, 0, wx.ALL, 5)
# btnSizer.Add(self.bookmark, 0, wx.ALL, 5)
btnSizer.Add(self.dm, 0, wx.ALL, 5)
self.sizer.Add(btnSizer, 0, wx.ALL, 5)
self.sizer.Add(self.list.list, 1, wx.EXPAND | wx.ALL, 5)
self.SetSizer(self.sizer)
class BlueskiFollowingTimelinePanel(BlueskiHomeTimelinePanel):
"""Following-only timeline (reverse-chronological)."""
# Some helper methods expected by controller might be needed?
# Controller accesses self.buffer.list directly.
# Some older code expected .set_position, .post, .message, .actions attributes or buttons on the panel?
# Mastodon panels usually have bottom buttons (Post, Reply, etc).
# I should add them if I want to "reuse Mastodon".
# But for now, simple list is what the previous code had.
def set_focus_function(self, func):
self.list.list.Bind(wx.EVT_SET_FOCUS, func)
def set_position(self, reverse):
if reverse:
self.list.select_item(0)
else:
self.list.select_item(self.list.get_count() - 1)
def __init__(self, parent, name: str, session):
super().__init__(parent, name, session)
self.type = "following_timeline"
self.timeline_algorithm = "reverse-chronological"
# Make sure the underlying wx panel also reflects this type
try:
self.buffer.type = "following_timeline"
except Exception:
pass
def set_focus_in_list(self):
self.list.list.SetFocus()
def start_stream(self, mandatory=False, play_sound=True):
try:
count = self.session.settings["general"]["max_posts_per_call"] or 40
except Exception:
count = 40
try:
api = self.session._ensure_client()
# Following timeline via reverse-chronological algorithm on get_timeline
# Use plain dict to avoid typed-model mismatches across SDK versions
res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": self.timeline_algorithm})
feed = getattr(res, "feed", [])
self.cursor = getattr(res, "cursor", None)
self.items = []
for it in feed:
post = getattr(it, "post", None)
if not post:
continue
record = getattr(post, "record", None)
author = getattr(post, "author", None)
text = getattr(record, "text", "") if record else ""
handle = getattr(author, "handle", "") if author else ""
display_name = (
getattr(author, "display_name", None)
or getattr(author, "displayName", None)
or ""
) if author else ""
indexed_at = getattr(post, "indexed_at", None)
item = {
"uri": getattr(post, "uri", ""),
"author": display_name or handle,
"handle": handle,
"display_name": display_name,
"text": text,
"indexed_at": indexed_at,
}
self._append_item(item, to_top=self._reverse())
self._render_list(replace=True)
return len(self.items)
except Exception:
log.exception("Failed to load Bluesky following timeline")
self.buffer.list.clear()
self.buffer.list.insert_item(False, _("Error"), _("Could not load timeline."), "")
return 0
class NotificationPanel(HomePanel):
pass
def get_more_items(self):
if not self.cursor:
return 0
try:
api = self.session._ensure_client()
# Pagination via reverse-chronological algorithm on get_timeline
res = api.app.bsky.feed.get_timeline({
"limit": 40,
"cursor": self.cursor,
"algorithm": self.timeline_algorithm
})
feed = getattr(res, "feed", [])
self.cursor = getattr(res, "cursor", None)
new_items = []
for it in feed:
post = getattr(it, "post", None)
if not post:
continue
record = getattr(post, "record", None)
author = getattr(post, "author", None)
text = getattr(record, "text", "") if record else ""
handle = getattr(author, "handle", "") if author else ""
display_name = (
getattr(author, "display_name", None)
or getattr(author, "displayName", None)
or ""
) if author else ""
indexed_at = getattr(post, "indexed_at", None)
new_items.append({
"uri": getattr(post, "uri", ""),
"author": display_name or handle,
"handle": handle,
"display_name": display_name,
"text": text,
"indexed_at": indexed_at,
})
if not new_items:
return 0
for it in new_items:
self._append_item(it, to_top=self._reverse())
self._render_list(replace=False, start=len(self.items) - len(new_items))
return len(new_items)
except Exception:
log.exception("Failed to load more items for following timeline")
return 0
class UserPanel(wx.Panel):
def __init__(self, parent, name, account="Unknown"):
super().__init__(parent, name=name)
self.name = name
self.account = account
self.type = "user"
self.sizer = wx.BoxSizer(wx.VERTICAL)
# List: User
self.list = widgets.list(self, _("User"), style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_VRULES)
self.list.set_windows_size(0, 600)
self.list.set_size()
# Buttons
self.post = wx.Button(self, -1, _("Post"))
self.actions = wx.Button(self, -1, _("Actions"))
self.message = wx.Button(self, -1, _("Message"))
btnSizer = wx.BoxSizer(wx.HORIZONTAL)
btnSizer.Add(self.post, 0, wx.ALL, 5)
btnSizer.Add(self.actions, 0, wx.ALL, 5)
btnSizer.Add(self.message, 0, wx.ALL, 5)
self.sizer.Add(btnSizer, 0, wx.ALL, 5)
self.sizer.Add(self.list.list, 1, wx.EXPAND | wx.ALL, 5)
self.SetSizer(self.sizer)
def set_focus_function(self, func):
self.list.list.Bind(wx.EVT_SET_FOCUS, func)
def set_position(self, reverse):
if reverse:
self.list.select_item(0)
else:
self.list.select_item(self.list.get_count() - 1)
def set_focus_in_list(self):
self.list.list.SetFocus()
class ChatPanel(wx.Panel):
def __init__(self, parent, name, account="Unknown"):
super().__init__(parent, name=name)
self.name = name
self.account = account
self.type = "chat"
self.sizer = wx.BoxSizer(wx.VERTICAL)
# List: Participants, Last Message, Date
self.list = widgets.list(self, _("Participants"), _("Last Message"), _("Date"), style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_VRULES)
self.list.set_windows_size(0, 200)
self.list.set_windows_size(1, 600)
self.list.set_windows_size(2, 200)
self.list.set_size()
self.sizer.Add(self.list.list, 1, wx.EXPAND | wx.ALL, 5)
self.SetSizer(self.sizer)
def set_focus_function(self, func):
self.list.list.Bind(wx.EVT_SET_FOCUS, func)
def set_focus_in_list(self):
self.list.list.SetFocus()
class ChatMessagePanel(HomePanel):
def __init__(self, parent, name, account="Unknown"):
super().__init__(parent, name, account)
self.type = "chat_messages"
# Adjust buttons for chat
self.repost.Hide()
self.like.Hide()
self.reply.SetLabel(_("Send Message"))
# Refresh columns
self.list.list.ClearAll()
self.list.list.InsertColumn(0, _("Sender"))
self.list.list.InsertColumn(1, _("Message"))
self.list.list.InsertColumn(2, _("Date"))
self.list.set_windows_size(0, 100)
self.list.set_windows_size(1, 400)
self.list.set_windows_size(2, 100)
self.list.set_size()

View File

@@ -9,10 +9,10 @@ class basePanel(wx.Panel):
def create_list(self):
self.list = widgets.list(self, _(u"User"), _(u"Text"), _(u"Date"), _(u"Client"), style=wx.LC_REPORT|wx.LC_SINGLE_SEL|wx.LC_VRULES)
self.list.set_windows_size(0, 60)
self.list.set_windows_size(1, 320)
self.list.set_windows_size(2, 110)
self.list.set_windows_size(3, 84)
self.list.set_windows_size(0, 200)
self.list.set_windows_size(1, 600)
self.list.set_windows_size(2, 200)
self.list.set_windows_size(3, 200)
self.list.set_size()
def __init__(self, parent, name):
@@ -35,7 +35,7 @@ class basePanel(wx.Panel):
btnSizer.Add(self.bookmark, 0, wx.ALL, 5)
btnSizer.Add(self.dm, 0, wx.ALL, 5)
self.sizer.Add(btnSizer, 0, wx.ALL, 5)
self.sizer.Add(self.list.list, 0, wx.ALL|wx.EXPAND, 5)
self.sizer.Add(self.list.list, 1, wx.ALL|wx.EXPAND, 5)
self.SetSizer(self.sizer)
self.SetClientSize(self.sizer.CalcMin())

View File

@@ -9,10 +9,10 @@ class conversationListPanel(wx.Panel):
def create_list(self):
self.list = widgets.list(self, _(u"User"), _(u"Text"), _(u"Date"), _(u"Client"), style=wx.LC_REPORT|wx.LC_SINGLE_SEL|wx.LC_VRULES)
self.list.set_windows_size(0, 60)
self.list.set_windows_size(1, 320)
self.list.set_windows_size(2, 110)
self.list.set_windows_size(3, 84)
self.list.set_windows_size(0, 200)
self.list.set_windows_size(1, 600)
self.list.set_windows_size(2, 200)
self.list.set_windows_size(3, 200)
self.list.set_size()
def __init__(self, parent, name):
@@ -27,7 +27,7 @@ class conversationListPanel(wx.Panel):
btnSizer.Add(self.post, 0, wx.ALL, 5)
btnSizer.Add(self.reply, 0, wx.ALL, 5)
self.sizer.Add(btnSizer, 0, wx.ALL, 5)
self.sizer.Add(self.list.list, 0, wx.ALL|wx.EXPAND, 5)
self.sizer.Add(self.list.list, 1, wx.ALL|wx.EXPAND, 5)
self.SetSizer(self.sizer)
self.SetClientSize(self.sizer.CalcMin())

View File

@@ -9,8 +9,8 @@ class notificationsPanel(wx.Panel):
def create_list(self):
self.list = widgets.list(self, _("Text"), _("Date"), style=wx.LC_REPORT|wx.LC_SINGLE_SEL|wx.LC_VRULES)
self.list.set_windows_size(0, 320)
self.list.set_windows_size(2, 110)
self.list.set_windows_size(0, 600)
self.list.set_windows_size(1, 200)
self.list.set_size()
def __init__(self, parent, name):
@@ -25,7 +25,7 @@ class notificationsPanel(wx.Panel):
btnSizer.Add(self.post, 0, wx.ALL, 5)
btnSizer.Add(self.dismiss, 0, wx.ALL, 5)
self.sizer.Add(btnSizer, 0, wx.ALL, 5)
self.sizer.Add(self.list.list, 0, wx.ALL|wx.EXPAND, 5)
self.sizer.Add(self.list.list, 1, wx.ALL|wx.EXPAND, 5)
self.SetSizer(self.sizer)
self.SetClientSize(self.sizer.CalcMin())

View File

@@ -6,7 +6,7 @@ class userPanel(wx.Panel):
def create_list(self):
self.list = widgets.list(self, _("User"), style=wx.LC_REPORT|wx.LC_SINGLE_SEL|wx.LC_VRULES)
self.list.set_windows_size(0, 320)
self.list.set_windows_size(0, 600)
self.list.set_size()
def __init__(self, parent, name):
@@ -23,7 +23,7 @@ class userPanel(wx.Panel):
btnSizer.Add(self.actions, 0, wx.ALL, 5)
btnSizer.Add(self.message, 0, wx.ALL, 5)
self.sizer.Add(btnSizer, 0, wx.ALL, 5)
self.sizer.Add(self.list.list, 0, wx.ALL|wx.EXPAND, 5)
self.sizer.Add(self.list.list, 1, wx.ALL|wx.EXPAND, 5)
self.SetSizer(self.sizer)
self.SetClientSize(self.sizer.CalcMin())

View File

@@ -9,7 +9,10 @@ class Post(wx.Dialog):
main_sizer = wx.BoxSizer(wx.VERTICAL)
# Text
post_label = wx.StaticText(self, wx.ID_ANY, caption)
main_sizer.Add(post_label, 0, wx.ALL, 6)
self.text = wx.TextCtrl(self, wx.ID_ANY, text, style=wx.TE_MULTILINE)
self.Bind(wx.EVT_CHAR_HOOK, self.handle_keys, self.text)
self.text.SetMinSize((400, 160))
main_sizer.Add(self.text, 1, wx.EXPAND | wx.ALL, 6)
@@ -58,6 +61,7 @@ class Post(wx.Dialog):
self.SetSizer(main_sizer)
main_sizer.Fit(self)
self.SetEscapeId(cancel.GetId())
self.Layout()
# Bindings
@@ -66,6 +70,13 @@ class Post(wx.Dialog):
self.attach_list.Bind(wx.EVT_LIST_ITEM_SELECTED, lambda evt: self.btn_remove.Enable(True))
self.attach_list.Bind(wx.EVT_LIST_ITEM_DESELECTED, lambda evt: self.btn_remove.Enable(False))
def handle_keys(self, event):
shift = event.ShiftDown()
if event.GetKeyCode() == wx.WXK_RETURN and not shift and hasattr(self, "send"):
self.EndModal(wx.ID_OK)
else:
event.Skip()
def on_add(self, evt):
if self.attach_list.GetItemCount() >= 4:
wx.MessageBox(_("You can attach up to 4 images."), _("Attachment limit"), wx.ICON_INFORMATION)

View File

@@ -1,14 +1,11 @@
# -*- coding: utf-8 -*-
import wx
import asyncio
import logging
from pubsub import pub
import languageHandler
import builtins
from threading import Thread
from approve.translation import translate as _
from approve.notifications import NotificationError
# Assuming controller.blueski.userList.get_user_profile_details and session.util._format_profile_data exist
# For direct call to util:
# from sessions.blueski import utils as BlueskiUtils
_ = getattr(builtins, "_", lambda s: s)
logger = logging.getLogger(__name__)
@@ -25,7 +22,7 @@ class ShowUserProfileDialog(wx.Dialog):
self.SetMinSize((400, 300))
self.CentreOnParent()
wx.CallAfter(asyncio.create_task, self.load_profile_data())
Thread(target=self.load_profile_data, daemon=True).start()
def _init_ui(self):
panel = wx.Panel(self)
@@ -36,17 +33,23 @@ class ShowUserProfileDialog(wx.Dialog):
self.info_grid_sizer.AddGrowableCol(1, 1)
fields = [
(_("Display Name:"), "displayName"), (_("Handle:"), "handle"), (_("DID:"), "did"),
(_("Followers:"), "followersCount"), (_("Following:"), "followsCount"), (_("Posts:"), "postsCount"),
(_("Bio:"), "description")
(_("&Name:"), "displayName"), (_("&Handle:"), "handle"), (_("&DID:"), "did"),
(_("&Followers:"), "followersCount"), (_("&Following:"), "followsCount"), (_("&Posts:"), "postsCount"),
(_("&Bio:"), "description")
]
self.profile_field_ctrls = {}
for label_text, data_key in fields:
lbl = wx.StaticText(panel, label=label_text)
val_ctrl = wx.TextCtrl(panel, style=wx.TE_READONLY | wx.TE_MULTILINE if data_key == "description" else wx.TE_READONLY | wx.BORDER_NONE)
style = wx.TE_READONLY | wx.TE_PROCESS_TAB
if data_key == "description":
style |= wx.TE_MULTILINE
else:
style |= wx.BORDER_NONE
val_ctrl = wx.TextCtrl(panel, style=style)
if data_key != "description": # Make it look like a label
val_ctrl.SetBackgroundColour(panel.GetBackgroundColour())
val_ctrl.AcceptsFocusFromKeyboard = lambda: True
self.info_grid_sizer.Add(lbl, 0, wx.ALIGN_RIGHT | wx.ALIGN_TOP | wx.ALL, 2)
self.info_grid_sizer.Add(val_ctrl, 1, wx.EXPAND | wx.ALL, 2)
@@ -89,51 +92,62 @@ class ShowUserProfileDialog(wx.Dialog):
main_sizer.Add(actions_sizer, 0, wx.ALIGN_CENTER | wx.TOP | wx.BOTTOM, 10)
# Close Button
close_btn = wx.Button(panel, wx.ID_CANCEL, _("Close"))
close_btn = wx.Button(panel, wx.ID_CANCEL, _("&Close"))
close_btn.SetDefault() # Allow Esc to close
main_sizer.Add(close_btn, 0, wx.ALIGN_RIGHT | wx.ALL, 10)
self.SetEscapeId(close_btn.GetId())
panel.SetSizer(main_sizer)
self.Fit() # Fit dialog to content
async def load_profile_data(self):
self.SetStatusText(_("Loading profile..."))
def load_profile_data(self):
wx.CallAfter(self.SetStatusText, _("Loading profile..."))
for ctrl in self.profile_field_ctrls.values():
ctrl.SetValue(_("Loading..."))
wx.CallAfter(ctrl.SetValue, _("Loading..."))
# Initially hide all action buttons until state is known
self.follow_btn.Hide()
self.unfollow_btn.Hide()
self.mute_btn.Hide()
self.unmute_btn.Hide()
self.block_btn.Hide()
self.unblock_btn.Hide()
wx.CallAfter(self.follow_btn.Hide)
wx.CallAfter(self.unfollow_btn.Hide)
wx.CallAfter(self.mute_btn.Hide)
wx.CallAfter(self.unmute_btn.Hide)
wx.CallAfter(self.block_btn.Hide)
wx.CallAfter(self.unblock_btn.Hide)
try:
raw_profile = await self.session.util.get_user_profile(self.user_identifier)
if raw_profile:
self.profile_data = self.session.util._format_profile_data(raw_profile) # This should return a dict
self.target_user_did = self.profile_data.get("did") # Store the canonical DID
self.user_identifier = self.target_user_did # Update identifier to resolved DID for consistency
self.update_ui_fields()
self.update_action_buttons_state()
self.SetTitle(_("Profile: {handle}").format(handle=self.profile_data.get("handle", "")))
self.SetStatusText(_("Profile loaded."))
else:
for ctrl in self.profile_field_ctrls.values():
ctrl.SetValue(_("Not found."))
self.SetStatusText(_("Profile not found for '{ident}'.").format(ident=self.user_identifier))
wx.MessageBox(_("User profile for '{ident}' not found.").format(ident=self.user_identifier), _("Error"), wx.OK | wx.ICON_ERROR, self)
api = self.session._ensure_client()
try:
raw_profile = api.app.bsky.actor.get_profile({"actor": self.user_identifier})
except Exception:
raw_profile = None
wx.CallAfter(self._apply_profile_data, raw_profile)
except Exception as e:
logger.error(f"Error loading profile for {self.user_identifier}: {e}", exc_info=True)
wx.CallAfter(self._apply_profile_error, e)
def _apply_profile_data(self, raw_profile):
if raw_profile:
self.profile_data = self._format_profile_data(raw_profile)
self.target_user_did = self.profile_data.get("did")
self.user_identifier = self.target_user_did or self.user_identifier
self.update_ui_fields()
self.update_action_buttons_state()
self.SetTitle(_("Profile: {handle}").format(handle=self.profile_data.get("handle", "")))
self.SetStatusText(_("Profile loaded."))
else:
for ctrl in self.profile_field_ctrls.values():
ctrl.SetValue(_("Error loading."))
self.SetStatusText(_("Error loading profile."))
wx.MessageBox(_("Error loading profile: {error}").format(error=str(e)), _("Error"), wx.OK | wx.ICON_ERROR, self)
finally:
self.Layout() # Refresh layout after hiding/showing buttons
ctrl.SetValue(_("Not found."))
self.SetStatusText(_("Profile not found for '{ident}'.").format(ident=self.user_identifier))
wx.MessageBox(_("User profile for '{ident}' not found.").format(ident=self.user_identifier), _("Error"), wx.OK | wx.ICON_ERROR, self)
self.Layout()
def _apply_profile_error(self, err):
for ctrl in self.profile_field_ctrls.values():
ctrl.SetValue(_("Error loading."))
self.SetStatusText(_("Error loading profile."))
wx.MessageBox(_("Error loading profile: {error}").format(error=str(err)), _("Error"), wx.OK | wx.ICON_ERROR, self)
self.Layout()
def update_ui_fields(self):
if not self.profile_data:
@@ -159,7 +173,7 @@ class ShowUserProfileDialog(wx.Dialog):
self.Layout()
def update_action_buttons_state(self):
if not self.profile_data or not self.target_user_did or self.target_user_did == self.session.util.get_own_did():
if not self.profile_data or not self.target_user_did or self.target_user_did == self._get_own_did():
self.follow_btn.Hide()
self.unfollow_btn.Hide()
self.mute_btn.Hide()
@@ -218,80 +232,70 @@ class ShowUserProfileDialog(wx.Dialog):
return
dlg.Destroy()
async def do_action():
wx.BeginBusyCursor()
self.SetStatusText(_("Performing action: {action}...").format(action=command))
action_button = event.GetEventObject()
if action_button: action_button.Disable() # Disable the clicked button
wx.BeginBusyCursor()
self.SetStatusText(_("Performing action: {action}...").format(action=command))
action_button = event.GetEventObject()
if action_button:
action_button.Disable()
try:
# Ensure controller_handler is available on the session
if not hasattr(self.session, 'controller_handler') or not self.session.controller_handler:
app = wx.GetApp()
if hasattr(app, 'mainController'):
self.session.controller_handler = app.mainController.get_handler(self.session.KIND)
if not self.session.controller_handler: # Still not found
raise RuntimeError("Controller handler not found for session.")
try:
if command == "block_user" and hasattr(self.session, "block_user"):
ok = self.session.block_user(self.target_user_did)
if not ok:
raise RuntimeError(_("Failed to block user."))
elif command == "unblock_user" and hasattr(self.session, "unblock_user"):
viewer_state = self.profile_data.get("viewer", {}) if self.profile_data else {}
block_uri = viewer_state.get("blocking")
if not block_uri:
raise RuntimeError(_("Block information not available."))
ok = self.session.unblock_user(block_uri)
if not ok:
raise RuntimeError(_("Failed to unblock user."))
else:
raise RuntimeError(_("This action is not supported yet."))
result = await self.session.controller_handler.handle_user_command(
command=command,
user_id=self.session.uid,
target_user_id=self.target_user_did,
payload={}
)
wx.EndBusyCursor()
# Use CallAfter for UI updates from async task
wx.CallAfter(wx.MessageBox, result.get("message", _("Action completed.")),
_("Success") if result.get("status") == "success" else _("Error"),
wx.OK | (wx.ICON_INFORMATION if result.get("status") == "success" else wx.ICON_ERROR),
self)
wx.EndBusyCursor()
wx.MessageBox(_("Action completed."), _("Success"), wx.OK | wx.ICON_INFORMATION, self)
wx.CallAfter(asyncio.create_task, self.load_profile_data())
except Exception as e:
wx.EndBusyCursor()
if action_button:
action_button.Enable()
self.SetStatusText(_("Action failed."))
wx.MessageBox(str(e), _("Error"), wx.OK | wx.ICON_ERROR, self)
if result.get("status") == "success":
# Re-fetch profile data to update UI (especially button states)
wx.CallAfter(asyncio.create_task, self.load_profile_data())
else: # Re-enable button if action failed
if action_button: wx.CallAfter(action_button.Enable, True)
self.SetStatusText(_("Action failed."))
def _get_own_did(self):
if isinstance(self.session.db, dict):
did = self.session.db.get("user_id")
if did:
return did
try:
api = self.session._ensure_client()
if getattr(api, "me", None):
return api.me.did
except Exception:
pass
return None
def _format_profile_data(self, profile_model):
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
except NotificationError as e:
wx.EndBusyCursor()
if action_button: wx.CallAfter(action_button.Enable, True)
self.SetStatusText(_("Action failed."))
wx.CallAfter(wx.MessageBox, str(e), _("Action Error"), wx.OK | wx.ICON_ERROR, self)
except Exception as e:
wx.EndBusyCursor()
if action_button: wx.CallAfter(action_button.Enable, True)
self.SetStatusText(_("Action failed."))
logger.error(f"Error performing user action '{command}' on {self.target_user_did}: {e}", exc_info=True)
wx.CallAfter(wx.MessageBox, _("An unexpected error occurred: {error}").format(error=str(e)), _("Error"), wx.OK | wx.ICON_ERROR, self)
asyncio.create_task(do_action()) # No wx.CallAfter needed for starting the task itself
return {
"did": g(profile_model, "did"),
"handle": g(profile_model, "handle"),
"displayName": g(profile_model, "displayName") or g(profile_model, "display_name") or g(profile_model, "handle"),
"description": g(profile_model, "description"),
"avatar": g(profile_model, "avatar"),
"banner": g(profile_model, "banner"),
"followersCount": g(profile_model, "followersCount"),
"followsCount": g(profile_model, "followsCount"),
"postsCount": g(profile_model, "postsCount"),
"viewer": g(profile_model, "viewer") or {},
}
def SetStatusText(self, text): # Simple status text for dialog title
self.SetTitle(f"{_('User Profile')} - {text}")
```python
# Example of how this dialog might be called from blueski.Handler.user_details:
# (This is conceptual, actual integration in handler.py will use the dialog)
#
# async def user_details(self, buffer_panel_or_user_ident):
# session = self._get_session(self.current_user_id_from_context) # Get current session
# user_identifier_to_show = None
# if isinstance(buffer_panel_or_user_ident, str): # It's a DID or handle
# user_identifier_to_show = buffer_panel_or_user_ident
# elif hasattr(buffer_panel_or_user_ident, 'get_selected_item_author_details'): # It's a panel
# author_details = buffer_panel_or_user_ident.get_selected_item_author_details()
# if author_details:
# user_identifier_to_show = author_details.get("did") or author_details.get("handle")
#
# if not user_identifier_to_show:
# # Optionally prompt for user_identifier if not found
# output.speak(_("No user selected or identified to view details."), True)
# return
#
# dialog = ShowUserProfileDialog(self.main_controller.view, session, user_identifier_to_show)
# dialog.ShowModal()
# dialog.Destroy()
```

View File

@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
import wx
class UserActionsDialog(wx.Dialog):
def __init__(self, users=None, default="follow", *args, **kwargs):
super(UserActionsDialog, self).__init__(parent=None, *args, **kwargs)
users = users or []
panel = wx.Panel(self)
self.SetTitle(_(u"Action"))
userSizer = wx.BoxSizer()
userLabel = wx.StaticText(panel, -1, _(u"&User"))
default_user = users[0] if users else ""
self.cb = wx.ComboBox(panel, -1, choices=users, value=default_user)
self.cb.SetFocus()
userSizer.Add(userLabel, 0, wx.ALL, 5)
userSizer.Add(self.cb, 0, wx.ALL, 5)
actionSizer = wx.BoxSizer(wx.VERTICAL)
label2 = wx.StaticText(panel, -1, _(u"Action"))
self.follow = wx.RadioButton(panel, -1, _(u"&Follow"), name=_(u"Action"), style=wx.RB_GROUP)
self.unfollow = wx.RadioButton(panel, -1, _(u"U&nfollow"))
self.mute = wx.RadioButton(panel, -1, _(u"&Mute"))
self.unmute = wx.RadioButton(panel, -1, _(u"Unmu&te"))
self.block = wx.RadioButton(panel, -1, _(u"&Block"))
self.unblock = wx.RadioButton(panel, -1, _(u"Unbl&ock"))
self.setup_default(default)
hSizer = wx.BoxSizer(wx.HORIZONTAL)
hSizer.Add(label2, 0, wx.ALL, 5)
actionSizer.Add(self.follow, 0, wx.ALL, 5)
actionSizer.Add(self.unfollow, 0, wx.ALL, 5)
actionSizer.Add(self.mute, 0, wx.ALL, 5)
actionSizer.Add(self.unmute, 0, wx.ALL, 5)
actionSizer.Add(self.block, 0, wx.ALL, 5)
actionSizer.Add(self.unblock, 0, wx.ALL, 5)
hSizer.Add(actionSizer, 0, wx.ALL, 5)
sizer = wx.BoxSizer(wx.VERTICAL)
ok = wx.Button(panel, wx.ID_OK, _(u"&OK"))
ok.SetDefault()
cancel = wx.Button(panel, wx.ID_CANCEL, _(u"&Close"))
btnsizer = wx.BoxSizer()
btnsizer.Add(ok)
btnsizer.Add(cancel)
sizer.Add(userSizer)
sizer.Add(hSizer, 0, wx.ALL, 5)
sizer.Add(btnsizer)
panel.SetSizer(sizer)
def get_action(self):
if self.follow.GetValue() == True:
return "follow"
elif self.unfollow.GetValue() == True:
return "unfollow"
elif self.mute.GetValue() == True:
return "mute"
elif self.unmute.GetValue() == True:
return "unmute"
elif self.block.GetValue() == True:
return "block"
elif self.unblock.GetValue() == True:
return "unblock"
def setup_default(self, default):
if default == "follow":
self.follow.SetValue(True)
elif default == "unfollow":
self.unfollow.SetValue(True)
elif default == "mute":
self.mute.SetValue(True)
elif default == "unmute":
self.unmute.SetValue(True)
elif default == "block":
self.block.SetValue(True)
elif default == "unblock":
self.unblock.SetValue(True)
def get_response(self):
return self.ShowModal()
def get_user(self):
return self.cb.GetValue()

View File

@@ -134,9 +134,9 @@ class mainFrame(wx.Frame):
self.buffers[name] = buffer.GetId()
def prepare(self):
self.sizer.Add(self.nb, 0, wx.ALL, 5)
self.sizer.Add(self.nb, 1, wx.ALL | wx.EXPAND, 5)
self.panel.SetSizer(self.sizer)
# self.Maximize()
self.Maximize()
self.sizer.Layout()
self.SetClientSize(self.sizer.CalcMin())
# print self.GetSize()