Feat: Atproto integration. You can see home

This commit is contained in:
Jesús Pavón Abián
2025-08-30 22:48:00 +02:00
parent 8e999e67d4
commit 9124476ce0
9 changed files with 986 additions and 2587 deletions

View File

@@ -1,484 +1,74 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
fromapprove.controller.base import BaseHandler
fromapprove.sessions import SessionStoreInterface
if TYPE_CHECKING:
fromapprove.config import Config
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
from typing import Any
import languageHandler # Ensure _() injection
logger = logging.getLogger(__name__)
class Handler(BaseHandler):
SESSION_KIND = "atprotosocial"
class Handler:
"""Handler for Bluesky integration: creates minimal buffers."""
def __init__(self, session_store: SessionStoreInterface, config: Config) -> None:
super().__init__(session_store, config)
self.main_controller = wx.GetApp().mainController # Get a reference to the mainController
# Define menu labels specific to ATProtoSocial
self.menus = {
"menubar_item": _("&Post"), # Top-level menu for posting actions
"compose": _("&New Post"), # New post/skeet
"share": _("&Repost"), # Equivalent of Boost/Retweet
"fav": _("&Like"), # Equivalent of Favorite
"unfav": _("&Unlike"),
# "dm": None, # Disable Direct Message if not applicable
# Add other menu items that need relabeling or enabling/disabling
}
# self.item_menu is another attribute used in mainController.update_menus
# It seems to be the label for the second main menu (originally "&Tweet")
self.item_menu = _("&Post") # Changes the top-level "Tweet" menu label to "Post"
def __init__(self):
super().__init__()
self.menus = dict(
compose="&Post",
)
self.item_menu = "&Post"
def _get_session(self, user_id: str) -> ATProtoSocialSession:
session = self.session_store.get_session_by_user_id(user_id, self.SESSION_KIND)
if not session:
# It's possible the session is still being created, especially during initial setup.
# Try to get it from the global sessions store if it was just added.
from sessions import sessions as global_sessions
if user_id in global_sessions and global_sessions[user_id].KIND == self.SESSION_KIND:
return global_sessions[user_id] # type: ignore[return-value]
raise ValueError(f"No ATProtoSocial session found for user {user_id}")
return session # type: ignore[return-value] # We are checking kind
def create_buffers(self, user_id: str) -> None:
"""Creates the default set of buffers for an ATProtoSocial session."""
session = self._get_session(user_id)
if not session:
logger.error(f"Cannot create buffers for ATProtoSocial user {user_id}: session not found.")
return
logger.info(f"Creating default buffers for ATProtoSocial user {user_id} ({session.label})")
# Home Timeline Buffer
self.main_controller.add_buffer(
buffer_type="home_timeline", # Generic type, panel will adapt based on session kind
user_id=user_id,
name=_("{label} Home").format(label=session.label),
session_kind=self.SESSION_KIND
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)
root_position = controller.view.search(name, name)
# Home timeline only for now
from pubsub import pub
pub.sendMessage(
"createBuffer",
buffer_type="home_timeline",
session_type="atprotosocial",
buffer_title=_("Home"),
parent_tab=root_position,
start=True,
kwargs=dict(parent=controller.view.nb, name="home_timeline", session=session)
)
# Following-only timeline (reverse-chronological)
pub.sendMessage(
"createBuffer",
buffer_type="following_timeline",
session_type="atprotosocial",
buffer_title=_("Following"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="following_timeline", session=session)
)
# Notifications Buffer
self.main_controller.add_buffer(
buffer_type="notifications", # Generic type
user_id=user_id,
name=_("{label} Notifications").format(label=session.label),
session_kind=self.SESSION_KIND
)
# Own Posts (Profile) Buffer - using "user_posts" which is often generic
# self.main_controller.add_buffer(
# buffer_type="user_posts",
# user_id=user_id, # User whose posts to show (self in this case)
# target_user_id=session.util.get_own_did(), # Pass own DID as target
# name=_("{label} My Posts").format(label=session.label),
# session_kind=self.SESSION_KIND
# )
# Mentions buffer might be part of notifications or a separate stream/filter later.
# Ensure these buffers are shown if it's a new setup
# This part might be handled by mainController.add_buffer or session startup logic
# For now, just creating them. The UI should make them visible.
# --- Action Handlers (called by mainController based on menu interactions) ---
async def repost_item(self, session: ATProtoSocialSession, item_uri: str) -> dict[str, Any]:
"""Handles reposting an item."""
if not session.is_ready():
return {"status": "error", "message": _("Session not ready.")}
def start_buffer(self, controller, buffer):
"""Start a newly created Bluesky buffer."""
try:
success = await session.util.repost_post(item_uri) # Assuming repost_post in utils
if success:
return {"status": "success", "message": _("Post reposted successfully.")}
else:
return {"status": "error", "message": _("Failed to repost post.")}
except NotificationError as e:
return {"status": "error", "message": e.message}
except Exception as e:
logger.error(f"Error reposting item {item_uri}: {e}", exc_info=True)
return {"status": "error", "message": _("An unexpected error occurred while reposting.")}
async def like_item(self, session: ATProtoSocialSession, item_uri: str) -> dict[str, Any]:
"""Handles liking an item."""
if not session.is_ready():
return {"status": "error", "message": _("Session not ready.")}
try:
like_uri = await session.util.like_post(item_uri) # Assuming like_post in utils
if like_uri:
return {"status": "success", "message": _("Post liked successfully."), "like_uri": like_uri}
else:
return {"status": "error", "message": _("Failed to like post.")}
except NotificationError as e:
return {"status": "error", "message": e.message}
except Exception as e:
logger.error(f"Error liking item {item_uri}: {e}", exc_info=True)
return {"status": "error", "message": _("An unexpected error occurred while liking the post.")}
async def unlike_item(self, session: ATProtoSocialSession, like_uri: str) -> dict[str, Any]: # like_uri is the URI of the like record
"""Handles unliking an item."""
if not session.is_ready():
return {"status": "error", "message": _("Session not ready.")}
try:
# Unlike typically requires the URI of the like record itself, not the post.
# The UI or calling context needs to store this (e.g. from viewer_state of the post).
success = await session.util.delete_like(like_uri) # Assuming delete_like in utils
if success:
return {"status": "success", "message": _("Like removed successfully.")}
else:
return {"status": "error", "message": _("Failed to remove like.")}
except NotificationError as e:
return {"status": "error", "message": e.message}
except Exception as e:
logger.error(f"Error unliking item with like URI {like_uri}: {e}", exc_info=True)
return {"status": "error", "message": _("An unexpected error occurred while unliking.")}
if hasattr(buffer, "start_stream"):
buffer.start_stream(mandatory=True, play_sound=False)
# Enable periodic auto-refresh to simulate real-time updates
if hasattr(buffer, "enable_auto_refresh"):
buffer.enable_auto_refresh()
finally:
# Ensure we won't try to start it again
try:
buffer.needs_init = False
except Exception:
pass
async def handle_action(self, action_name: str, user_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
"""Handles actions specific to ATProtoSocial integration."""
logger.debug(
f"Handling ATProtoSocial action '{action_name}' for user {user_id} with payload: {payload}"
)
# session = self._get_session(user_id)
logger.debug("handle_action stub: %s %s %s", action_name, user_id, payload)
return None
# TODO: Implement action handlers based on ATProtoSocial's capabilities
# Example:
# if action_name == "get_profile_info":
# # profile_data = await session.util.get_profile_info(payload.get("handle"))
# # return {"profile": profile_data}
# elif action_name == "follow_user":
# # await session.util.follow_user(payload.get("user_id_to_follow"))
# # return {"status": "success", "message": "User followed"}
# else:
# logger.warning(f"Unknown ATProtoSocial action: {action_name}")
# return {"error": f"Unknown action: {action_name}"}
return None # Placeholder
async def handle_message_command(self, command: str, user_id: str, message_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
logger.debug("handle_message_command stub: %s %s %s %s", command, user_id, message_id, payload)
return None
async def handle_message_command(
self, command: str, user_id: str, message_id: str, payload: dict[str, Any]
) -> dict[str, Any] | None:
"""Handles commands related to specific messages for ATProtoSocial."""
logger.debug(
f"Handling ATProtoSocial message command '{command}' for user {user_id}, message {message_id} with payload: {payload}"
)
# session = self._get_session(user_id)
# TODO: Implement message command handlers
# Example:
# if command == "get_post_details":
# # post_details = await session.util.get_post_by_id(message_id)
# # return {"details": post_details}
# elif command == "like_post":
# # await session.util.like_post(message_id)
# # return {"status": "success", "message": "Post liked"}
# else:
# logger.warning(f"Unknown ATProtoSocial message command: {command}")
# return {"error": f"Unknown message command: {command}"}
return None # Placeholder
fromapprove.translation import translate as _ # For user-facing messages
async def handle_user_command(
self, command: str, user_id: str, target_user_id: str, payload: dict[str, Any]
) -> dict[str, Any] | None:
"""Handles commands related to specific users for ATProtoSocial."""
logger.debug(
f"Handling ATProtoSocial user command '{command}' for user {user_id}, target user {target_user_id} with payload: {payload}"
)
session = self._get_session(user_id)
if not session.is_ready():
return {"status": "error", "message": _("ATProtoSocial session is not active or authenticated.")}
# target_user_id is expected to be the DID of the user to act upon.
if not target_user_id:
return {"status": "error", "message": _("Target user DID not provided.")}
success = False
message = _("Action could not be completed.") # Default error message
try:
if command == "follow_user":
success = await session.util.follow_user(target_user_id)
message = _("User followed successfully.") if success else _("Failed to follow user.")
elif command == "unfollow_user":
success = await session.util.unfollow_user(target_user_id)
message = _("User unfollowed successfully.") if success else _("Failed to unfollow user.")
elif command == "mute_user":
success = await session.util.mute_user(target_user_id)
message = _("User muted successfully.") if success else _("Failed to mute user.")
elif command == "unmute_user":
success = await session.util.unmute_user(target_user_id)
message = _("User unmuted successfully.") if success else _("Failed to unmute user.")
elif command == "block_user":
block_uri = await session.util.block_user(target_user_id) # Returns URI or None
success = bool(block_uri)
message = _("User blocked successfully.") if success else _("Failed to block user.")
elif command == "unblock_user":
success = await session.util.unblock_user(target_user_id)
message = _("User unblocked successfully.") if success else _("Failed to unblock user, or user was not blocked.")
else:
logger.warning(f"Unknown ATProtoSocial user command: {command}")
return {"status": "error", "message": _("Unknown action: {command}").format(command=command)}
return {"status": "success" if success else "error", "message": message}
except NotificationError as e: # Catch specific errors raised from utils
logger.error(f"ATProtoSocial user command '{command}' failed for target {target_user_id}: {e.message}")
return {"status": "error", "message": e.message}
except Exception as e:
logger.error(f"Unexpected error during ATProtoSocial user command '{command}' for target {target_user_id}: {e}", exc_info=True)
return {"status": "error", "message": _("An unexpected server error occurred.")}
# --- UI Related Action Handlers (called by mainController) ---
async def user_details(self, buffer: Any) -> None: # buffer is typically a timeline panel
"""Shows user profile details for the selected user in the buffer."""
session = buffer.session
if not session or session.KIND != self.SESSION_KIND or not session.is_ready():
output.speak(_("Active ATProtoSocial session not found or not ready."), True)
return
user_ident = None
if hasattr(buffer, "get_selected_item_author_details"): # Method in panel to get author of selected post
author_details = buffer.get_selected_item_author_details()
if author_details and isinstance(author_details, dict):
user_ident = author_details.get("did") or author_details.get("handle")
if not user_ident:
# Fallback or if no item selected, prompt for user
# For now, just inform user if no selection. A dialog prompt could be added.
output.speak(_("Please select an item or user to view details."), True)
# TODO: Add wx.TextEntryDialog to ask for user handle/DID if none selected
return
try:
profile_data = await session.util.get_user_profile(user_ident)
if profile_data:
# Example: from src.wxUI.dialogs.mastodon.showUserProfile import UserProfileDialog
# For ATProtoSocial, we use the new dialog:
from wxUI.dialogs.atprotosocial.showUserProfile import ShowUserProfileDialog
# Ensure main_controller.view is the correct parent (main frame)
dialog = ShowUserProfileDialog(parent=self.main_controller.view, session=session, user_identifier=user_ident)
dialog.ShowModal() # Show as modal dialog
dialog.Destroy()
else:
output.speak(_("Could not fetch profile for {user_ident}.").format(user_ident=user_ident), True)
except Exception as e:
logger.error(f"Error fetching/displaying profile for {user_ident}: {e}", exc_info=True)
output.speak(_("Error displaying profile: {error}").format(error=str(e)), True)
async def open_user_timeline(self, main_controller: Any, session: ATProtoSocialSession, user_payload: Any | None) -> None:
"""Opens a new buffer for a specific user's posts."""
user_ident = None
if isinstance(user_payload, dict): # Assuming user_payload is a dict from get_selected_item_author_details
user_ident = user_payload.get("did") or user_payload.get("handle")
elif isinstance(user_payload, str): # Direct DID or Handle string
user_ident = user_payload
if not user_ident: # Prompt if not found
dialog = wx.TextEntryDialog(main_controller.view, _("Enter user DID or handle:"), _("View User Timeline"))
if dialog.ShowModal() == wx.ID_OK:
user_ident = dialog.GetValue()
dialog.Destroy()
if not user_ident:
return
# Fetch profile to get canonical handle/DID for buffer name, and to ensure user exists
try:
profile = await session.util.get_user_profile(user_ident)
if not profile:
output.speak(_("User {user_ident} not found.").format(user_ident=user_ident), True)
return
buffer_name = _("{user_handle}'s Posts").format(user_handle=profile.handle)
buffer_id = f"atp_user_feed_{profile.did}" # Unique ID for the buffer
# Check if buffer already exists
# existing_buffer = main_controller.search_buffer_by_id_or_properties(id=buffer_id) # Hypothetical method
# For now, assume it might create duplicates if not handled by add_buffer logic
main_controller.add_buffer(
buffer_type="user_timeline", # This type will need a corresponding panel
user_id=session.uid, # The session user_id
name=buffer_name,
session_kind=self.SESSION_KIND,
target_user_did=profile.did, # Store target DID for the panel to use
target_user_handle=profile.handle
)
except Exception as e:
logger.error(f"Error opening user timeline for {user_ident}: {e}", exc_info=True)
output.speak(_("Failed to open user timeline: {error}").format(error=str(e)), True)
async def open_followers_timeline(self, main_controller: Any, session: ATProtoSocialSession, user_payload: Any | None) -> None:
"""Opens a new buffer for a user's followers."""
user_ident = None
if isinstance(user_payload, dict):
user_ident = user_payload.get("did") or user_payload.get("handle")
elif isinstance(user_payload, str):
user_ident = user_payload
if not user_ident:
dialog = wx.TextEntryDialog(main_controller.view, _("Enter user DID or handle to view followers:"), _("View Followers"))
if dialog.ShowModal() == wx.ID_OK:
user_ident = dialog.GetValue()
dialog.Destroy()
if not user_ident:
return
try:
profile = await session.util.get_user_profile(user_ident) # Ensure user exists, get DID
if not profile:
output.speak(_("User {user_ident} not found.").format(user_ident=user_ident), True)
return
buffer_name = _("Followers of {user_handle}").format(user_handle=profile.handle)
main_controller.add_buffer(
buffer_type="user_list_followers", # Needs specific panel type
user_id=session.uid,
name=buffer_name,
session_kind=self.SESSION_KIND,
target_user_did=profile.did
)
except Exception as e:
logger.error(f"Error opening followers list for {user_ident}: {e}", exc_info=True)
output.speak(_("Failed to open followers list: {error}").format(error=str(e)), True)
async def open_following_timeline(self, main_controller: Any, session: ATProtoSocialSession, user_payload: Any | None) -> None:
"""Opens a new buffer for users a user is following."""
user_ident = None
if isinstance(user_payload, dict):
user_ident = user_payload.get("did") or user_payload.get("handle")
elif isinstance(user_payload, str):
user_ident = user_payload
if not user_ident:
dialog = wx.TextEntryDialog(main_controller.view, _("Enter user DID or handle to view their following list:"), _("View Following"))
if dialog.ShowModal() == wx.ID_OK:
user_ident = dialog.GetValue()
dialog.Destroy()
if not user_ident:
return
try:
profile = await session.util.get_user_profile(user_ident) # Ensure user exists, get DID
if not profile:
output.speak(_("User {user_ident} not found.").format(user_ident=user_ident), True)
return
buffer_name = _("Following by {user_handle}").format(user_handle=profile.handle)
main_controller.add_buffer(
buffer_type="user_list_following", # Needs specific panel type
user_id=session.uid,
name=buffer_name,
session_kind=self.SESSION_KIND,
target_user_did=profile.did
)
except Exception as e:
logger.error(f"Error opening following list for {user_ident}: {e}", exc_info=True)
output.speak(_("Failed to open following list: {error}").format(error=str(e)), True)
async def get_settings_inputs(self, user_id: str | None = None) -> list[dict[str, Any]]:
"""Returns settings inputs for ATProtoSocial, potentially user-specific."""
# This typically delegates to the Session class's method
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
current_config = {}
if user_id:
# Fetch existing config for the user if available to pre-fill values
# This part depends on how config is stored and accessed.
# For example, if Session class has a method to get its current config:
try:
session = self._get_session(user_id)
current_config = session.get_configurable_values_for_user(user_id)
except ValueError: # No session means no specific config yet for this user
pass
except Exception as e:
logger.error(f"Error fetching current ATProtoSocial config for user {user_id}: {e}")
return ATProtoSocialSession.get_settings_inputs(user_id=user_id, current_config=current_config)
async def update_settings(self, user_id: str, settings_data: dict[str, Any]) -> dict[str, Any]:
"""Updates settings for ATProtoSocial for a given user."""
logger.info(f"Updating ATProtoSocial settings for user {user_id}")
# This is a simplified example. In a real scenario, you'd validate `settings_data`
# and then update the configuration, possibly re-initializing the session or
# informing it of the changes.
# config_manager = self.config.sessions.atprotosocial[user_id]
# for key, value in settings_data.items():
# if hasattr(config_manager, key):
# await config_manager[key].set(value)
# else:
# logger.warning(f"Attempted to set unknown ATProtoSocial setting '{key}' for user {user_id}")
# # Optionally, re-initialize or notify the session if it's active
# try:
# session = self._get_session(user_id)
# await session.stop() # Stop if it might be using old settings
# # Re-fetch config for the session or update it directly
# # session.api_base_url = settings_data.get("api_base_url", session.api_base_url)
# # session.access_token = settings_data.get("access_token", session.access_token)
# if session.active: # Or based on some logic if it should auto-restart
# await session.start()
# logger.info(f"Successfully updated and re-initialized ATProtoSocial session for user {user_id}")
# except ValueError:
# logger.info(f"ATProtoSocial session for user {user_id} not found or not active, settings saved but session not restarted.")
# except Exception as e:
# logger.error(f"Error re-initializing ATProtoSocial session for user {user_id} after settings update: {e}")
# return {"status": "error", "message": f"Settings saved, but failed to restart session: {e}"}
# For now, just a placeholder response
return {"status": "success", "message": "ATProtoSocial settings updated (implementation pending)."}
@classmethod
def get_auth_inputs(cls, user_id: str | None = None) -> list[dict[str, Any]]:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
# current_config = {} # fetch if needed
return ATProtoSocialSession.get_auth_inputs(user_id=user_id) # current_config=current_config
@classmethod
async def test_connection(cls, settings: dict[str, Any]) -> tuple[bool, str]:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
return await ATProtoSocialSession.test_connection(settings)
@classmethod
def get_user_actions(cls) -> list[dict[str, Any]]:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
return ATProtoSocialSession.get_user_actions()
@classmethod
def get_user_list_actions(cls) -> list[dict[str, Any]]:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
return ATProtoSocialSession.get_user_list_actions()
@classmethod
def get_config_description(cls) -> str | None:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
return ATProtoSocialSession.get_config_description()
@classmethod
def get_auth_type(cls) -> str:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
return ATProtoSocialSession.get_auth_type()
@classmethod
def get_logo_path(cls) -> str | None:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
return ATProtoSocialSession.get_logo_path()
@classmethod
def get_session_kind(cls) -> str:
return cls.SESSION_KIND
@classmethod
def get_dependencies(cls) -> list[str]:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
return ATProtoSocialSession.get_dependencies()
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

View File

@@ -1,13 +1,9 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from typing import Any
# fromapprove.controller.mastodon import messages as mastodon_messages # Example, if adapting
fromapprove.translation import translate as _
if TYPE_CHECKING:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession # Adjusted import
# Translation function is provided globally by TWBlue's language handler (_)
logger = logging.getLogger(__name__)
@@ -19,14 +15,17 @@ logger = logging.getLogger(__name__)
# Example: If ATProtoSocial develops a standard for "cards" or interactive messages,
# functions to create those would go here. For now, we can imagine placeholders.
def format_welcome_message(session: ATProtoSocialSession) -> dict[str, Any]:
def format_welcome_message(session: Any) -> dict[str, Any]:
"""
Generates a welcome message for a new ATProtoSocial session.
This is just a placeholder and example.
"""
# user_profile = session.util.get_own_profile_info() # Assuming this method exists and is async or cached
# handle = user_profile.get("handle", _("your ATProtoSocial account")) if user_profile else _("your ATProtoSocial account")
handle = session.util.get_own_username() or _("your ATProtoSocial account")
# Expect session to expose username via db/settings
handle = (getattr(session, "db", {}).get("user_name")
or getattr(getattr(session, "settings", {}), "get", lambda *_: {})("atprotosocial").get("handle")
or _("your Bluesky account"))
return {

View File

@@ -94,6 +94,17 @@ class Controller(object):
[results.append(self.search_buffer(i.name, i.account)) for i in buffers if i.account == account and (i.type != "account")]
return results
def get_handler(self, type):
"""Return the controller handler for a given session type."""
try:
if type == "mastodon":
return MastodonHandler.Handler()
if type == "atprotosocial":
return ATProtoSocialHandler.Handler()
except Exception:
log.exception("Error creating handler for type %s", type)
return None
def bind_other_events(self):
""" Binds the local application events with their functions."""
log.debug("Binding other application events...")
@@ -193,29 +204,12 @@ class Controller(object):
def get_handler(self, type):
handler = self.handlers.get(type)
if handler == None:
if handler is None:
if type == "mastodon":
handler = MastodonHandler.Handler()
elif type == "atprotosocial": # Added case for atprotosocial
# Assuming session_store and config_proxy are accessible or passed if needed by Handler constructor
# For now, let's assume constructor is similar or adapted to not require them,
# or that they can be accessed via self if mainController has them.
# Based on atprotosocial.Handler, it needs session_store and config.
# mainController doesn't seem to store these directly for passing.
# This might indicate Handler init needs to be simplified or these need to be plumbed.
# For now, proceeding with a simplified instantiation, assuming it can get what it needs
# or its __init__ will be adapted.
# A common pattern is self.session_store and self.config from a base controller class if mainController inherits one.
# Let's assume for now they are not strictly needed for just getting menu labels or simple actions.
# This part might need refinement based on Handler's actual dependencies for menu updates.
# Looking at atprotosocial/handler.py, it takes session_store and config.
# mainController itself doesn't seem to have these as direct attributes to pass on.
# This implies a potential refactor need or that these handlers are simpler than thought for menu updates.
# For now, let's assume a simplified handler for menu updates or that it gets these elsewhere.
# This needs to be compatible with how MastodonHandler is instantiated and used.
# MastodonHandler() is called without params here.
handler = ATProtoSocialHandler.Handler(session_store=sessions.sessions, config=config.app) # Adjusted: Pass global sessions and config
self.handlers[type]=handler
elif type == "atprotosocial":
handler = ATProtoSocialHandler.Handler()
self.handlers[type] = handler
return handler
def __init__(self):
@@ -256,14 +250,24 @@ class Controller(object):
for i in sessions.sessions:
log.debug("Working on session %s" % (i,))
if sessions.sessions[i].is_logged == False:
self.create_ignored_session_buffer(sessions.sessions[i])
continue
# Valid types currently are mastodon (Work in progress)
# More can be added later.
valid_session_types = ["mastodon"]
# Try auto-login for ATProtoSocial sessions if credentials exist
try:
if getattr(sessions.sessions[i], "type", None) == "atprotosocial":
sessions.sessions[i].login()
except Exception:
log.exception("Auto-login attempt failed for session %s", i)
if sessions.sessions[i].is_logged == False:
self.create_ignored_session_buffer(sessions.sessions[i])
continue
# Supported session types
valid_session_types = ["mastodon", "atprotosocial"]
if sessions.sessions[i].type in valid_session_types:
handler = self.get_handler(type=sessions.sessions[i].type)
handler.create_buffers(sessions.sessions[i], controller=self)
try:
handler = self.get_handler(type=sessions.sessions[i].type)
if handler is not None:
handler.create_buffers(sessions.sessions[i], controller=self)
except Exception:
log.exception("Error creating buffers for session %s (%s)", i, sessions.sessions[i].type)
log.debug("Setting updates to buffers every %d seconds..." % (60*config.app["app-settings"]["update_period"],))
self.update_buffers_function = RepeatingTimer(60*config.app["app-settings"]["update_period"], self.update_buffers)
self.update_buffers_function.start()
@@ -294,7 +298,10 @@ class Controller(object):
session.login()
handler = self.get_handler(type=session.type)
if handler != None and hasattr(handler, "create_buffers"):
handler.create_buffers(session=session, controller=self, createAccounts=False)
try:
handler.create_buffers(session=session, controller=self, createAccounts=False)
except Exception:
log.exception("Error creating buffers after login for session %s (%s)", session.session_id, session.type)
self.start_buffers(session)
if hasattr(session, "start_streaming"):
session.start_streaming()
@@ -308,102 +315,103 @@ class Controller(object):
self.view.add_buffer(account.buffer , name=name)
def create_buffer(self, buffer_type="baseBuffer", session_type="twitter", buffer_title="", parent_tab=None, start=False, kwargs={}):
# Copy kwargs to avoid mutating a shared dict across calls
if not isinstance(kwargs, dict):
kwargs = {}
else:
kwargs = dict(kwargs)
log.debug("Creating buffer of type {0} with parent_tab of {2} arguments {1}".format(buffer_type, kwargs, parent_tab))
if kwargs.get("parent") == None:
kwargs["parent"] = self.view.nb
if not hasattr(buffers, session_type) and session_type != "atprotosocial": # Allow atprotosocial to be handled separately
raise AttributeError("Session type %s does not exist yet." % (session_type))
buffer_panel_class = None
if session_type == "atprotosocial":
from wxUI.buffers.atprotosocial import panels as ATProtoSocialPanels # Import new panels
if buffer_type == "home_timeline":
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialHomeTimelinePanel
# 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"])
if "name" not in kwargs: kwargs["name"] = buffer_title
try:
buffer_panel_class = None
if session_type == "atprotosocial":
from wxUI.buffers.atprotosocial import panels as ATProtoSocialPanels # Import new panels
if buffer_type == "home_timeline":
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialHomeTimelinePanel
# 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
elif buffer_type == "user_timeline":
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialUserTimelinePanel
# 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"])
if "name" not in kwargs: kwargs["name"] = buffer_title
# target_user_did and target_user_handle must be in kwargs from atprotosocial.Handler
elif buffer_type == "user_timeline":
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialUserTimelinePanel
# 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 atprotosocial.Handler
elif buffer_type == "notifications":
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialNotificationPanel
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
# target_user_did and target_user_handle must be in kwargs from atprotosocial.Handler
elif buffer_type == "notifications":
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialNotificationPanel
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 atprotosocial.Handler
elif buffer_type == "notifications":
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialNotificationPanel
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_list_followers" or buffer_type == "user_list_following":
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialUserListPanel
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
# Ensure 'list_type', 'target_user_did', 'target_user_handle' are in kwargs
if "list_type" not in kwargs: # Set based on buffer_type
kwargs["list_type"] = buffer_type.split('_')[-1] # followers or following
else:
log.warning(f"Unsupported ATProtoSocial buffer type: {buffer_type}. Falling back to generic.")
# Fallback to trying to find it in generic buffers or error
# For now, let it try the old way if not found above
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")
elif buffer_type == "notifications":
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialNotificationPanel
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 = ATProtoSocialPanels.ATProtoSocialUserListPanel
elif buffer_type == "following_timeline":
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialFollowingTimelinePanel
# 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:
raise AttributeError(f"ATProtoSocial buffer type {buffer_type} not found in atprotosocial.panels or base panels.")
else: # Existing logic for other session types
available_buffers = getattr(buffers, session_type)
if not hasattr(available_buffers, buffer_type):
raise AttributeError("Specified buffer type does not exist: %s" % (buffer_type,))
buffer_panel_class = getattr(available_buffers, buffer_type)
log.warning(f"Unsupported ATProtoSocial 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"ATProtoSocial buffer type {buffer_type} not found in atprotosocial.panels or base panels.")
else: # Existing logic for other session types
available_buffers = getattr(buffers, session_type)
if not hasattr(available_buffers, buffer_type):
raise AttributeError("Specified buffer type does not exist: %s" % (buffer_type,))
buffer_panel_class = getattr(available_buffers, buffer_type)
# Instantiate the panel
# Ensure 'parent' kwarg is correctly set if not already
if "parent" not in kwargs:
kwargs["parent"] = self.view.nb # self.view.nb is the wx.Treebook
# Instantiate the panel
# Ensure 'parent' kwarg is correctly set if not already
if "parent" not in kwargs:
kwargs["parent"] = self.view.nb # self.view.nb is the wx.Treebook
# Clean kwargs that are not meant for panel __init__ directly (like user_id, session_kind if used by add_buffer but not panel)
# This depends on what add_buffer and panel constructors expect.
# For now, assume kwargs are mostly for the panel.
buffer = buffer_panel_class(**kwargs) # This is the wx.Panel instance
buffer = buffer_panel_class(**kwargs) # This is the wx.Panel instance
if start: # 'start' usually means load initial data for the buffer
# The panels themselves should handle initial data loading in their __init__ or a separate load method
# For ATProtoSocial panels, this is wx.CallAfter(asyncio.create_task, self.load_initial_posts())
# The old `start_stream` logic might not apply directly.
if hasattr(buffer, "load_initial_data_async"): # A new conventional async method
wx.CallAfter(asyncio.create_task, buffer.load_initial_data_async())
elif hasattr(buffer, "start_stream"): # Legacy way
if kwargs.get("function") == "user_timeline": # This old check might be obsolete
if start:
try:
buffer.start_stream(play_sound=False)
if hasattr(buffer, "start_stream"):
buffer.start_stream(mandatory=True, play_sound=False)
except ValueError:
commonMessageDialogs.unauthorized()
return
self.buffers.append(buffer)
if parent_tab == None:
log.debug("Appending buffer {}...".format(buffer,))
self.view.add_buffer(buffer.buffer, buffer_title)
else:
call_threaded(buffer.start_stream)
self.buffers.append(buffer)
if parent_tab == None:
log.debug("Appending buffer {}...".format(buffer,))
self.view.add_buffer(buffer.buffer, buffer_title)
else:
self.view.insert_buffer(buffer.buffer, buffer_title, parent_tab)
log.debug("Inserting buffer {0} into control {1}".format(buffer, parent_tab))
self.view.insert_buffer(buffer.buffer, buffer_title, parent_tab)
log.debug("Inserting buffer {0} into control {1}".format(buffer, parent_tab))
except Exception:
log.exception("Error creating buffer '%s' for session_type '%s'", buffer_type, session_type)
def set_buffer_positions(self, session):
"Sets positions for buffers if values exist in the database."
@@ -589,6 +597,53 @@ class Controller(object):
return
session = buffer.session
# Compose for Bluesky (ATProto): dialog with attachments/CW/language
if getattr(session, "type", "") == "atprotosocial":
# In invisible interface, prefer a quick, minimal compose to avoid complex UI
if self.showing == False:
# Parent=None so it shows even if main window is hidden
dlg = wx.TextEntryDialog(None, _("Write your post:"), _("Compose"))
if dlg.ShowModal() == wx.ID_OK:
text = dlg.GetValue().strip()
dlg.Destroy()
if not text:
return
try:
uri = session.send_message(text)
if uri:
output.speak(_("Post sent successfully!"), True)
else:
output.speak(_("Failed to send post."), True)
except Exception:
log.exception("Error sending Bluesky post from invisible compose")
output.speak(_("An error occurred while posting to Bluesky."), True)
else:
dlg.Destroy()
return
from wxUI.dialogs.atprotosocial.postDialogs import Post as ATPostDialog
dlg = ATPostDialog()
if dlg.ShowModal() == wx.ID_OK:
text, files, cw_text, langs = dlg.get_payload()
dlg.Destroy()
if not text and not files:
return
try:
uri = session.send_message(text, files=files, cw_text=cw_text, is_sensitive=bool(cw_text), languages=langs)
if uri:
output.speak(_("Post sent successfully!"), True)
try:
if hasattr(buffer, "start_stream"):
buffer.start_stream(mandatory=False, play_sound=False)
except Exception:
pass
else:
output.speak(_("Failed to send post."), True)
except Exception:
log.exception("Error sending Bluesky post from compose dialog")
output.speak(_("An error occurred while posting to Bluesky."), True)
else:
dlg.Destroy()
return
# For a new post, reply_to_uri and quote_uri are None.
# Import the new dialog
from wxUI.dialogs.composeDialog import ComposeDialog
@@ -644,28 +699,67 @@ class Controller(object):
def post_reply(self, *args, **kwargs):
buffer = self.get_current_buffer() # This is the panel instance
buffer = self.get_current_buffer()
if not buffer or not buffer.session:
output.speak(_("No active session to reply."), True)
return
selected_item_uri = buffer.get_selected_item_id() # URI of the post to reply to
selected_item_uri = None
if hasattr(buffer, "get_selected_item_id"):
selected_item_uri = buffer.get_selected_item_id()
if not selected_item_uri:
output.speak(_("No item selected to reply to."), True)
return
# Optionally, get initial text for reply (e.g., mentioning users)
# initial_text = buffer.session.compose_panel.get_reply_text(selected_item_uri, author_handle_of_selected_post)
# For now, simple empty initial text for reply.
initial_text = ""
# Get author handle for reply text (if needed by compose_panel.get_reply_text)
# author_handle = buffer.get_selected_item_author_handle() # Panel needs this method
# if author_handle:
# initial_text = f"@{author_handle} "
session = buffer.session
if getattr(session, "type", "") == "atprotosocial":
if self.showing == False:
dlg = wx.TextEntryDialog(None, _("Write your reply:"), _("Reply"))
if dlg.ShowModal() == wx.ID_OK:
text = dlg.GetValue().strip()
dlg.Destroy()
if not text:
return
try:
uri = session.send_message(text, reply_to=selected_item_uri)
if uri:
output.speak(_("Reply sent."), True)
else:
output.speak(_("Failed to send reply."), True)
except Exception:
log.exception("Error sending Bluesky reply (invisible)")
output.speak(_("An error occurred while replying."), True)
else:
dlg.Destroy()
return
from wxUI.dialogs.atprotosocial.postDialogs import Post as ATPostDialog
dlg = ATPostDialog(caption=_("Reply"))
if dlg.ShowModal() == wx.ID_OK:
text, files, cw_text, langs = dlg.get_payload()
dlg.Destroy()
if not text and not files:
return
try:
uri = session.send_message(text, files=files, cw_text=cw_text, is_sensitive=bool(cw_text), languages=langs, reply_to=selected_item_uri)
if uri:
output.speak(_("Reply sent."), True)
try:
if hasattr(buffer, "start_stream"):
buffer.start_stream(mandatory=False, play_sound=False)
except Exception:
pass
else:
output.speak(_("Failed to send reply."), True)
except Exception:
log.exception("Error sending Bluesky reply (dialog)")
output.speak(_("An error occurred while replying."), True)
else:
dlg.Destroy()
return
from wxUI.dialogs.composeDialog import ComposeDialog
dialog = ComposeDialog(parent=self.view, session=buffer.session, reply_to_uri=selected_item_uri, initial_text=initial_text)
dialog.Show() # Or ShowModal, depending on how pubsub message for send is handled for dialog lifecycle
dialog = ComposeDialog(parent=self.view, session=buffer.session, reply_to_uri=selected_item_uri, initial_text="")
dialog.Show()
def send_dm(self, *args, **kwargs):
@@ -675,40 +769,58 @@ class Controller(object):
def post_retweet(self, *args, **kwargs):
buffer = self.get_current_buffer()
if hasattr(buffer, "share_item"): # Generic buffer method
return buffer.share_item() # This likely calls back to a session/handler method
# If direct handling is needed for ATProtoSocial:
elif buffer.session and buffer.session.KIND == "atprotosocial":
item_uri = buffer.get_selected_item_id() # URI of the post to potentially quote or repost
if hasattr(buffer, "share_item"):
return buffer.share_item()
session = getattr(buffer, "session", None)
if not session:
return
if getattr(session, "type", "") == "atprotosocial":
item_uri = None
if hasattr(buffer, "get_selected_item_id"):
item_uri = buffer.get_selected_item_id()
if not item_uri:
output.speak(_("No item selected."), True)
return
session = buffer.session
# For ATProtoSocial, the "Share" menu item (which maps to post_retweet)
# will now open the ComposeDialog for quoting.
# A direct/quick repost action could be added as a separate menu item if desired.
if self.showing == False:
dlg = wx.TextEntryDialog(None, _("Write your quote (optional):"), _("Quote"))
if dlg.ShowModal() == wx.ID_OK:
text = dlg.GetValue().strip()
dlg.Destroy()
try:
uri = session.send_message(text, quote_uri=item_uri)
if uri:
output.speak(_("Quote posted."), True)
else:
output.speak(_("Failed to send quote."), True)
except Exception:
log.exception("Error sending Bluesky quote (invisible)")
output.speak(_("An error occurred while posting the quote."), True)
else:
dlg.Destroy()
return
initial_text = ""
# Attempt to get context from the selected item for the quote's initial text
# The buffer panel needs a method like get_selected_item_details_for_quote()
# which might return author handle and text snippet.
if hasattr(buffer, "get_selected_item_summary_for_quote"):
# This method should return a string like "QT @author_handle: text_snippet..."
# or just the text snippet.
quote_context_text = buffer.get_selected_item_summary_for_quote()
if quote_context_text:
initial_text = quote_context_text + "\n\n" # Add space for user's own text
else: # Fallback if panel doesn't provide detailed quote summary
item_web_url = "" # Ideally, get the web URL of the post
if hasattr(buffer, "get_selected_item_web_url"):
item_web_url = buffer.get_selected_item_web_url() or ""
initial_text = f"Quoting {item_web_url}\n\n"
from wxUI.dialogs.composeDialog import ComposeDialog
dialog = ComposeDialog(parent=self.view, session=session, quote_uri=item_uri, initial_text=initial_text)
dialog.Show() # Non-modal, send is handled via pubsub
from wxUI.dialogs.atprotosocial.postDialogs import Post as ATPostDialog
dlg = ATPostDialog(caption=_("Quote post"))
if dlg.ShowModal() == wx.ID_OK:
text, files, cw_text, langs = dlg.get_payload()
dlg.Destroy()
try:
uri = session.send_message(text, files=files, cw_text=cw_text, is_sensitive=bool(cw_text), languages=langs, quote_uri=item_uri)
if uri:
output.speak(_("Quote posted."), True)
try:
if hasattr(buffer, "start_stream"):
buffer.start_stream(mandatory=False, play_sound=False)
except Exception:
pass
else:
output.speak(_("Failed to send quote."), True)
except Exception:
log.exception("Error sending Bluesky quote (dialog)")
output.speak(_("An error occurred while posting the quote."), True)
else:
dlg.Destroy()
return
def add_to_favourites(self, *args, **kwargs):
@@ -1001,11 +1113,11 @@ class Controller(object):
self.current_account = account
buffer_object = self.get_first_buffer(account)
if buffer_object == None:
output.speak(_(u"{0}: This account is not logged into Twitter.").format(account), True)
output.speak(_(u"{0}: This account is not logged in.").format(account), True)
return
buff = self.view.search(buffer_object.name, account)
if buff == None:
output.speak(_(u"{0}: This account is not logged into Twitter.").format(account), True)
output.speak(_(u"{0}: This account is not logged in.").format(account), True)
return
self.view.change_buffer(buff)
buffer = self.get_current_buffer()
@@ -1029,11 +1141,11 @@ class Controller(object):
self.current_account = account
buffer_object = self.get_first_buffer(account)
if buffer_object == None:
output.speak(_(u"{0}: This account is not logged into Twitter.").format(account), True)
output.speak(_(u"{0}: This account is not logged in.").format(account), True)
return
buff = self.view.search(buffer_object.name, account)
if buff == None:
output.speak(_(u"{0}: This account is not logged into twitter.").format(account), True)
output.speak(_(u"{0}: This account is not logged in.").format(account), True)
return
self.view.change_buffer(buff)
buffer = self.get_current_buffer()
@@ -1634,4 +1746,4 @@ class Controller(object):
buffer = self.get_best_buffer()
handler = self.get_handler(type=buffer.session.type)
if handler and hasattr(handler, 'manage_filters'):
handler.manage_filters(self, buffer)
handler.manage_filters(self, buffer)