diff --git a/src/atprotosocial.defaults b/src/atprotosocial.defaults new file mode 100644 index 00000000..b28a4240 --- /dev/null +++ b/src/atprotosocial.defaults @@ -0,0 +1,52 @@ +[atprotosocial] +handle = string(default="") +app_password = string(default="") +did = string(default="") +session_string = string(default="") +user_name = string(default="") + +[general] +relative_times = boolean(default=True) +max_posts_per_call = integer(default=40) +reverse_timelines = boolean(default=False) +persist_size = integer(default=0) +load_cache_in_memory = boolean(default=True) +show_screen_names = boolean(default=False) +hide_emojis = boolean(default=False) +buffer_order = list(default=list('home', 'notifications')) +disable_streaming = boolean(default=True) + +[sound] +volume = float(default=1.0) +input_device = string(default="Default") +output_device = string(default="Default") +session_mute = boolean(default=False) +current_soundpack = string(default="FreakyBlue") +indicate_audio = boolean(default=True) +indicate_img = boolean(default=True) + +[other_buffers] +timelines = list(default=list()) +searches = list(default=list()) +muted_buffers = list(default=list()) +autoread_buffers = list(default=list(notifications)) + +[mysc] +spelling_language = string(default="") +save_followers_in_autocompletion_db = boolean(default=False) +save_friends_in_autocompletion_db = boolean(default=False) +ocr_language = string(default="") + +[reporting] +braille_reporting = boolean(default=True) +speech_reporting = boolean(default=True) + +[templates] +post = string(default="$display_name, $safe_text $date.") +person = string(default="$display_name (@$screen_name). $followers followers, $following following, $posts posts.") +notification = string(default="$display_name $text, $date") + +[filters] + +[user-aliases] + diff --git a/src/controller/atprotosocial/handler.py b/src/controller/atprotosocial/handler.py index 85af8e15..69388545 100644 --- a/src/controller/atprotosocial/handler.py +++ b/src/controller/atprotosocial/handler.py @@ -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 diff --git a/src/controller/atprotosocial/messages.py b/src/controller/atprotosocial/messages.py index 998999be..8785afdf 100644 --- a/src/controller/atprotosocial/messages.py +++ b/src/controller/atprotosocial/messages.py @@ -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 { diff --git a/src/controller/mainController.py b/src/controller/mainController.py index 354305b9..d3fc2660 100644 --- a/src/controller/mainController.py +++ b/src/controller/mainController.py @@ -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) \ No newline at end of file + handler.manage_filters(self, buffer) diff --git a/src/sessionmanager/sessionManager.py b/src/sessionmanager/sessionManager.py index 32f58e50..9a74b002 100644 --- a/src/sessionmanager/sessionManager.py +++ b/src/sessionmanager/sessionManager.py @@ -12,6 +12,7 @@ import config_utils import config import application import asyncio # For async event handling +import wx from pubsub import pub from controller import settings from sessions.mastodon import session as MastodonSession @@ -37,8 +38,8 @@ class sessionManagerController(object): # Initialize the manager, responsible for storing session objects. manager.setup() self.view = view.sessionManagerWindow() - # Using CallAfter to handle async method from pubsub - pub.subscribe(lambda type: wx.CallAfter(asyncio.create_task, self.manage_new_account(type)), "sessionmanager.new_account") + # Handle new account synchronously on the UI thread + pub.subscribe(self.manage_new_account, "sessionmanager.new_account") pub.subscribe(self.remove_account, "sessionmanager.remove_account") if self.started == False: pub.subscribe(self.configuration, "sessionmanager.configuration") @@ -122,7 +123,7 @@ class sessionManagerController(object): log.warning(f"Unknown session type '{i.get('type')}' for ID {i.get('id')}. Skipping.") continue - s.get_configuration() # Assumes get_configuration() exists and is useful for all session types + s.get_configuration() # Load per-session configuration # For ATProtoSocial, this loads from its specific config file. # Login is now primarily handled by session.start() via mainController, @@ -138,6 +139,13 @@ class sessionManagerController(object): # except Exception as e: # log.exception(f"Exception during pre-emptive login check for session {s.uid} ({s.kind}).") # continue + # Try to auto-login for ATProtoSocial so the app starts with buffers ready + try: + if i.get("type") == "atprotosocial": + s.login() + except Exception: + log.exception("Auto-login failed for ATProtoSocial session %s", i.get("id")) + sessions.sessions[i.get("id")] = s # Add to global session store self.new_sessions[i.get("id")] = s # Track as a new session for this manager instance # self.view.destroy() @@ -145,7 +153,7 @@ class sessionManagerController(object): def show_auth_error(self): error = view.auth_error() # This seems to be a generic auth error display - async def manage_new_account(self, type): # Made async + def manage_new_account(self, type): # Generic settings for all account types. location = (str(time.time())[-6:]) # Unique ID for the session config directory log.debug("Creating %s session in the %s path" % (type, location)) @@ -166,7 +174,7 @@ class sessionManagerController(object): return try: - result = await s.authorise() # Call the (now potentially async) authorise method + result = s.authorise() if result == True: # Session config (handle, did for atproto) should be saved by authorise/login. # Here we just update the session manager's internal list and UI. diff --git a/src/sessions/atprotosocial/session.py b/src/sessions/atprotosocial/session.py index 84520a3f..d94fe788 100644 --- a/src/sessions/atprotosocial/session.py +++ b/src/sessions/atprotosocial/session.py @@ -1,1314 +1,293 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any +from typing import Any -import wx # For dialogs -from tornado.ioloop import IOLoop +import wx -from atproto import AsyncClient, Client # Bluesky SDK -from atproto.xrpc_client.models.common import XrpcError # For error handling +from sessions import base +from sessions import session_exceptions as Exceptions +import output +import application -fromapprove import channels, constants, util -fromapprove.approval import ApprovalAPI, ApprovalResult -fromapprove.config import Config, config, ConfigurableValue # Import ConfigurableValue properly -fromapprove.notifications import Notification, NotificationError, NotificationKind -fromapprove.reporting import Reporter, ReportingDecision, ReportingReasons -fromapprove.sessions.atprotosocial import compose as atprotosocial_compose -fromapprove.sessions.atprotosocial import streaming as atprotosocial_streaming -fromapprove.sessions.atprotosocial import templates as atprotosocial_templates -fromapprove.sessions.atprotosocial import utils as atprotosocial_utils -fromapprove.sessions.base import baseSession -fromapprove.translation import translate as _ -fromapprove.util import GenerateID +log = logging.getLogger("sessions.atprotosocialSession") -if TYPE_CHECKING: - fromapprove.channels import Channel - fromapprove.notifications import NotificationData - fromapprove.reporting import ReportData - fromapprove.sessions.atprotosocial.compose import ATProtoSocialCompose - fromapprove.sessions.atprotosocial.streaming import ATProtoSocialStreaming - fromapprove.sessions.atprotosocial.templates import ATProtoSocialTemplates - fromapprove.sessions.atprotosocial.utils import ATProtoSocialUtils - -logger = logging.getLogger(__name__) +# Optional import of atproto. Code handles absence gracefully. +try: + from atproto import Client as AtpClient # type: ignore +except Exception: # ImportError or missing deps + AtpClient = None # type: ignore -class Session(baseSession): +class Session(base.baseSession): + """Minimal Bluesky (atproto) session for TWBlue. + + Provides basic authorisation, login, and posting support to unblock + the integration while keeping compatibility with TWBlue's session API. + """ + + name = "Bluesky" KIND = "atprotosocial" - LABEL = "ATProtoSocial" - HAS_SETTINGS = True - CAN_APPROVE = True - CAN_REPORT = True - CAN_STREAM = True - _client: AsyncClient | None = None # Authenticated ATProto client - _compose_panel: ATProtoSocialCompose | None = None - _streaming_manager: ATProtoSocialStreaming | None = None - _templates: ATProtoSocialTemplates | None = None - _util: ATProtoSocialUtils | None = None + def __init__(self, *args, **kwargs): + super(Session, self).__init__(*args, **kwargs) + self.config_spec = "atprotosocial.defaults" + self.type = "atprotosocial" + self.char_limit = 300 + self.api = None - # Define ConfigurableValues for ATProtoSocial - handle = ConfigurableValue("handle", "") - app_password = ConfigurableValue("app_password", "", is_secret=True) # Mark as secret - did = ConfigurableValue("did", "", is_readonly=True) # Read-only, stored after login + def get_name(self): + """Return a human-friendly, stable account name for UI. - - def __init__(self, approval_api: ApprovalAPI, user_id: str, channel_id: str) -> None: - super().__init__(approval_api, user_id, channel_id) - self.client: AsyncClient | None = None # Renamed from _client to avoid conflict with base class - self._load_session_from_db() - - # Timeline specific attributes - self.home_timeline_buffer: list[str] = [] # Stores AT URIs of posts in home timeline - self.home_timeline_cursor: str | None = None - # self.user_posts_buffer: list[str] = [] # For viewing a specific user's posts - managed by UI context - # self.user_posts_cursor: str | None = None - # self.message_cache is inherited from baseSession for storing post details - - async def login(self, handle: str, app_password: str) -> bool: - """Logs into ATProtoSocial using handle and app password.""" - logger.info(f"ATProtoSocial: Attempting login for handle {handle}") + Prefer the user's handle if available so accounts are uniquely + identifiable, falling back to a generic network name otherwise. + """ try: - # Ensure util is initialized so it can also store DID/handle - _ = self.util - - temp_client = AsyncClient() - profile = await temp_client.login(handle, app_password) - if profile and profile.access_jwt and profile.did and profile.handle: - self.client = temp_client # Assign the successfully logged-in client - - self.db["access_jwt"] = profile.access_jwt - self.db["refresh_jwt"] = profile.refresh_jwt - self.db["did"] = profile.did - self.db["handle"] = profile.handle - await self.save_db() - - # Update util with new DID and handle - if self._util: - self._util._own_did = profile.did - self._util._own_handle = profile.handle - - # Update config store as well - await config.sessions.atprotosocial[self.user_id].handle.set(profile.handle) - await config.sessions.atprotosocial[self.user_id].app_password.set(app_password) # Store the password used for login - await config.sessions.atprotosocial[self.user_id].did.set(profile.did) - - logger.info(f"ATProtoSocial: Login successful for {handle}. DID: {profile.did}") - await self.notify_session_ready() - return True - else: - logger.error(f"ATProtoSocial: Login failed for {handle} - profile data missing.") - self.client = None - return False - except XrpcError as e: - logger.error(f"ATProtoSocial: Login failed for {handle} (XrpcError): {e.error} - {e.message}") - self.client = None - # Specific error for invalid credentials if possible - if e.error == 'AuthenticationFailed' or e.error == 'InvalidRequest' and 'password' in str(e.message).lower(): - # This is a guess, actual error might differ. Need to check Bluesky SDK specifics. - raise NotificationError(_("Invalid handle or app password.")) from e - raise NotificationError(_("Login failed: {error} - {message}").format(error=e.error, message=e.message or "Protocol error")) from e - except Exception as e: - logger.error(f"ATProtoSocial: Login failed for {handle} (Exception): {e}", exc_info=True) - self.client = None - raise NotificationError(_("An unexpected error occurred during login: {error}").format(error=str(e))) from e - - def _load_session_from_db(self) -> None: - """Loads session details from DB and attempts to initialize the client.""" - access_jwt = self.db.get("access_jwt") - handle = self.db.get("handle") # Or get from config: self.config_get("handle") - - if access_jwt and handle: - logger.info(f"ATProtoSocial: Found existing session for {handle} in DB. Initializing client.") - # Create a new client instance and load session. - # The atproto SDK's AsyncClient doesn't have a simple "load_session" from individual tokens. - # It re-logins or expects a full session object from client.export_session_string() - # For simplicity here, we'll rely on re-login if needed or assume test_connection handles it. - # A more robust way would be to use client.resume_session(profile_dict_from_db) if available - # or store the output of client.export_session_string() and client = AsyncClient.import_session_string(...) - - # For now, we won't auto-resume here but rely on start() or is_ready() to trigger login/test. - # self.client = AsyncClient() # Create a placeholder client - # TODO: Properly resume session with SDK if possible without re-login. - # One way: if we have refreshJwt, we could try to refresh the session. - # For now, is_ready() will be false and start() will attempt login if needed. - logger.debug(f"ATProtoSocial: Session for {handle} loaded. Further checks in is_ready/start.") - else: - logger.info("ATProtoSocial: No existing session found in DB.") - - - async def authorise(self) -> bool: - """Prompts the user for Bluesky handle and app password and attempts to log in.""" - if not wx.GetApp(): # Ensure wx App exists - logger.error("ATProtoSocial: wx.App not available for dialogs.") - self.send_text_notification( - title=_("ATProtoSocial Authentication Error"), - message=_("Cannot display login dialogs. Please check application logs.") + # Prefer runtime DB, then persisted settings, then SDK client + handle = ( + self.db.get("user_name") + or (self.settings and self.settings.get("atprotosocial", {}).get("handle")) + or (getattr(getattr(self, "api", None), "me", None) and self.api.me.handle) ) - return False - - handle_dialog = wx.TextEntryDialog( - None, - _("Enter your Bluesky handle (e.g., username.bsky.social):"), - _("Bluesky Login"), - self.config_get("handle") or "" # Pre-fill with saved handle if any - ) - if handle_dialog.ShowModal() == wx.ID_OK: - handle = handle_dialog.GetValue() - handle_dialog.Destroy() - - password_dialog = wx.PasswordEntryDialog( - None, - _("Enter your Bluesky App Password (generate one in Bluesky settings):"), - _("Bluesky Login") - ) - if password_dialog.ShowModal() == wx.ID_OK: - app_password = password_dialog.GetValue() - password_dialog.Destroy() - - try: - if await self.login(handle, app_password): - wx.MessageBox(_("Successfully logged into Bluesky!"), _("Login Success"), wx.OK | wx.ICON_INFORMATION) - return True - else: - # Login method now raises NotificationError, so this part might not be reached - # if error handling is done via exceptions. - wx.MessageBox(_("Login failed. Please check your handle and app password."), _("Login Failed"), wx.OK | wx.ICON_ERROR) - return False - except NotificationError as e: # Catch errors from login() - wx.MessageBox(str(e), _("Login Failed"), wx.OK | wx.ICON_ERROR) - return False - except Exception as e: - logger.error(f"ATProtoSocial: Unexpected error during authorise: {e}", exc_info=True) - wx.MessageBox(_("An unexpected error occurred: {error}").format(error=str(e)), _("Login Error"), wx.OK | wx.ICON_ERROR) - return False - else: - password_dialog.Destroy() - return False # User cancelled password dialog - else: - handle_dialog.Destroy() - return False # User cancelled handle dialog - return False - - - async def _ensure_dependencies_ready(self) -> None: - """Ensure all dependencies are ready to be used.""" - # This could check if the atproto library is installed, though get_dependencies handles that. - # More relevant here: ensuring the client is authenticated if credentials exist. - if not self.is_ready(): - logger.info("ATProtoSocial: Session not ready, attempting to re-establish from config.") - handle = self.config_get("handle") - app_password = self.config_get("app_password") # This might be empty if not re-saved - - # Try to login if we have handle and app_password from config - if handle and app_password: - try: - await self.login(handle, app_password) - except NotificationError: # Login failed, don't bubble up here - logger.warning(f"ATProtoSocial: Auto-login attempt failed for {handle} during ensure_dependencies_ready.") - pass # is_ready() will remain false - elif handle and not app_password: - logger.info(f"ATProtoSocial: Handle {handle} found but no app password. Manual authorization needed.") - else: - logger.info("ATProtoSocial: No credentials in config to attempt auto-login.") - - - @property - def active(self) -> bool: - return self.is_ready() - - @property - def kind(self) -> str: - return self.KIND - - @property - def label(self) -> str: - return self.LABEL - - @property - def has_settings(self) -> bool: - return self.HAS_SETTINGS - - @property - def can_approve(self) -> bool: - return self.CAN_APPROVE - - @property - def can_report(self) -> bool: - return self.CAN_REPORT - - @property - def can_stream(self) -> bool: - return self.CAN_STREAM - - @property - def util(self) -> ATProtoSocialUtils: - if not self._util: - self._util = atprotosocial_utils.ATProtoSocialUtils(self) - return self._util - - @property - def templates(self) -> ATProtoSocialTemplates: - if not self._templates: - self._templates = atprotosocial_templates.ATProtoSocialTemplates(self) - return self._templates - - @property - def compose_panel(self) -> ATProtoSocialCompose: - if not self._compose_panel: - self._compose_panel = atprotosocial_compose.ATProtoSocialCompose(self) - return self._compose_panel - - @property - def streaming_manager(self) -> ATProtoSocialStreaming: - if not self._streaming_manager: - # TODO: Ensure that this is initialized correctly, potentially with a stream_type - self._streaming_manager = atprotosocial_streaming.ATProtoSocialStreaming(self, "user") # Placeholder stream_type - return self._streaming_manager - - async def start(self) -> None: - logger.info(f"Starting ATProtoSocial session for user {self.user_id}") - await self._ensure_dependencies_ready() # This will attempt login if needed - - if self.is_ready(): - # Fetch initial home timeline - try: - await self.fetch_home_timeline(limit=20, new_only=True) # Fetch newest items - except NotificationError as e: - logger.error(f"ATProtoSocial: Failed to fetch initial home timeline: {e}") - # Non-fatal, session can still start - - if self.can_stream: - # TODO: Initialize and start streaming if applicable - # self.streaming_manager.start_streaming(self.handle_streaming_event) - logger.info(f"ATProtoSocial session for {self.user_id} started. Streaming setup placeholder.") - elif not self.is_ready(): - logger.warning(f"ATProtoSocial session for {self.user_id} could not be started: not ready (login may have failed or is needed).") - - - async def stop(self) -> None: - logger.info(f"Stopping ATProtoSocial session for user {self.user_id}") - if self._streaming_manager and self._streaming_manager.is_alive(): - await self._streaming_manager.stop_streaming() - if self.client: - # ATProto AsyncClient doesn't have an explicit close/logout that clears local session. - # We just nullify it on our end. - self.client = None - logger.info(f"ATProtoSocial session for {self.user_id} stopped.") - - async def send_message( - self, - message: str, - files: list[str] | None = None, # List of file paths - reply_to: str | None = None, # AT URI of the post being replied to - cw_text: str | None = None, # Content warning text - is_sensitive: bool = False, # General sensitivity flag - **kwargs: Any, # For additional params like quote_uri, langs, media_alt_texts - ) -> str | None: # Returns the AT URI of the new post, or None on failure - """Sends a message (post/skeet) to ATProtoSocial.""" - if not self.is_ready(): - logger.error(f"ATProtoSocial session for {self.user_id} is not ready. Cannot send message.") - raise NotificationError(_("Session is not active. Please log in or check your connection.")) - - logger.debug( - f"Sending message for ATProtoSocial session {self.user_id}: text='{message}', files={files}, reply_to='{reply_to}', cw_text='{cw_text}', sensitive={is_sensitive}, kwargs={kwargs}" - ) - - media_blobs_for_post = [] # Will hold list of dicts: {"blob_ref": BlobRef, "alt_text": "..."} - - # Media upload handling - if files: - # kwargs might contain 'media_alt_texts' as a list parallel to 'files' - media_alt_texts = kwargs.get("media_alt_texts", []) - if not isinstance(media_alt_texts, list) or len(media_alt_texts) != len(files): - media_alt_texts = [""] * len(files) # Default to empty alt text if not provided correctly - - for i, file_path in enumerate(files): - try: - # Determine mime_type (simplified, real app might use python-magic or similar) - # For now, rely on file extension. - # TODO: More robust MIME type detection - ext = file_path.split('.')[-1].lower() - mime_type = { - 'jpg': 'image/jpeg', - 'jpeg': 'image/jpeg', - 'png': 'image/png', - # 'gif': 'image/gif', # ATProto current primary support is jpeg/png - }.get(ext) - - if not mime_type: - logger.warning(f"Unsupported file type for {file_path}, skipping.") - # Optionally, notify user about skipped file - self.send_text_notification( - title=_("Unsupported File Skipped"), - message=_("File {filename} has an unsupported type and was not attached.").format(filename=file_path.split('/')[-1]) - ) - continue - - alt_text = media_alt_texts[i] - # upload_media returns a dict like {"blob_ref": BlobRef, "alt_text": "..."} or None - media_blob_info = await self.util.upload_media(file_path, mime_type, alt_text=alt_text) - if media_blob_info: - media_blobs_for_post.append(media_blob_info) - else: - # Notify user about failed upload for this specific file - self.send_text_notification( - title=_("Media Upload Failed"), - message=_("Failed to upload {filename}. It will not be attached.").format(filename=file_path.split('/')[-1]) - ) - except Exception as e: - logger.error(f"Error uploading file {file_path}: {e}", exc_info=True) - self.send_text_notification( - title=_("Media Upload Error"), - message=_("An error occurred while uploading {filename}: {error}").format(filename=file_path.split('/')[-1], error=str(e)) - ) - # Depending on policy, either continue without this file or fail the whole post - # For now, continue and try to post without this file. - - # Extract other relevant parameters from kwargs - quote_uri = kwargs.get("quote_uri") # AT URI of the post to quote - langs = kwargs.get("langs") # List of language codes, e.g., ['en', 'ja'] - if langs and not isinstance(langs, list): - logger.warning(f"Invalid 'langs' format: {langs}. Expected list of strings. Ignoring.") - langs = None - - tags = kwargs.get("tags") # List of hashtags (without '#') - if tags and not isinstance(tags, list): - logger.warning(f"Invalid 'tags' format: {tags}. Expected list of strings. Ignoring.") - tags = None - - try: - # Call the util method to actually create the post - # self.util.post_status expects media_ids to be the list of blob dicts from upload_media - post_uri = await self.util.post_status( - text=message, - media_ids=media_blobs_for_post if media_blobs_for_post else None, - reply_to_uri=reply_to, - quote_uri=quote_uri, - cw_text=cw_text, - is_sensitive=is_sensitive, - langs=langs, - tags=tags, - # Any other specific params for Bluesky can be passed via kwargs if post_status handles them - ) - - if post_uri: - logger.info(f"Message posted successfully to ATProtoSocial. URI: {post_uri}") - return post_uri - else: - # This case should ideally be covered by post_status raising an error, - # but as a fallback if it returns None on failure without raising: - logger.error("Failed to post message to ATProtoSocial, post_status returned None.") - raise NotificationError(_("Failed to send post. The server did not confirm the post creation.")) - - except NotificationError: # Re-raise known errors - raise - except Exception as e: # Catch unexpected errors from post_status or here - logger.error(f"An unexpected error occurred in send_message for ATProtoSocial: {e}", exc_info=True) - raise NotificationError(_("An unexpected error occurred while sending the post: {error}").format(error=str(e))) - - - async def delete_message(self, message_id: str) -> None: - # TODO: Implement deleting message for ATProtoSocial - logger.debug( - f"Deleting message for ATProtoSocial session {self.user_id}: {message_id}" - ) - # await self.util.delete_status(message_id) - - async def get_message_url(self, message_id: str, context: str | None = None) -> str: - # message_id here is expected to be the rkey of the post - # TODO: Confirm if self.util.get_own_username() is populated correctly before this call - own_handle = self.util.get_own_username() or self.db.get("handle", "unknown.bsky.social") - # Ensure message_id is just the rkey, not the full AT URI. - # If it's a full URI, extract rkey. This logic might need refinement based on what `message_id` contains. - if message_id.startswith("at://"): - message_id = message_id.split("/")[-1] - - return f"https://bsky.app/profile/{own_handle}/post/{message_id}" - - - async def approve_message(self, notification_data: NotificationData) -> ApprovalResult: - # TODO: Implement message approval for ATProtoSocial - logger.debug( - f"Approving message for ATProtoSocial session {self.user_id}: {notification_data['id']}" - ) - # This is a placeholder implementation - # await self.util.authorize_follow_request(notification_data["account"]["id"]) - return ApprovalResult.APPROVED - - async def reject_message(self, notification_data: NotificationData) -> ApprovalResult: - # TODO: Implement message rejection for ATProtoSocial - logger.debug( - f"Rejecting message for ATProtoSocial session {self.user_id}: {notification_data['id']}" - ) - # This is a placeholder implementation - # await self.util.reject_follow_request(notification_data["account"]["id"]) - return ApprovalResult.REJECTED - - async def report_message(self, report_data: ReportData) -> ReportingDecision: - # TODO: Implement message reporting for ATProtoSocial - logger.debug( - f"Reporting message for ATProtoSocial session {self.user_id}: {report_data['message_id']}" - ) - # This is a placeholder implementation - # await self.util.report_status( - # status_id=report_data["message_id"], - # account_id=report_data["message_author_id"], - # reason=report_data["reason"], - # ) - return ReportingDecision.REPORTED - - @classmethod - def get_configurable_values(cls) -> dict[str, ConfigurableValue]: - """Returns all configurable values for ATProtoSocial.""" - return { - "handle": cls.handle, - "app_password": cls.app_password, # Write-only through auth/settings UI - "did": cls.did, # Read-only, set after login - } - - @classmethod - def get_configurable_values_for_user(cls, user_id: str) -> dict[str, Any]: - """Returns current configuration for a specific user.""" - user_config = config.sessions.atprotosocial[user_id] - return { - "handle": user_config.handle.get(), - "app_password": "", # Never return stored password - "did": user_config.did.get(), - } - - @classmethod - def validate_config(cls, cfg: Config) -> None: # cfg is actually ConfigSectionProxy for the user - """Validates ATProtoSocial configuration for a user.""" - # This is called when settings are saved. - # `handle` and `app_password` are primary credentials. - # `did` is derived, so no need to validate its presence here as essential for saving. - if not cfg.handle.get(): - raise ValueError(_("Bluesky handle is required.")) - if not cfg.app_password.get(): # This might be an issue if password is not re-typed on every save - # Consider if validation is only for initial setup or if password must always be re-entered on save. - # For now, assume if a handle exists, a password was once entered. - # If DID exists, it means login was successful at some point. - pass # Allowing save without re-entering password if handle exists. Test_connection is key. - logger.info(f"ATProtoSocial configuration validation for user: {cfg._user_id} passed (basic checks).") - - - @classmethod - async def generate_oauth_url(cls, channel_id: str, user_id: str, redirect_uri: str) -> str | None: - # ATProtoSocial does not use OAuth2 for user login in the typical 3rd party app sense. - # App Passwords are used instead. So, this method is not applicable. - return None - - @classmethod - async def finish_oauth_authentication( - cls, - channel_id: str, - user_id: str, - redirect_uri: str, - code: str | None = None, - error: str | None = None, - error_description: str | None = None, - ) -> None: - # TODO: Implement OAuth finish authentication for ATProtoSocial if applicable - pass - - @classmethod - def get_settings_inputs( - cls, user_id: str | None = None, current_config: dict[str, Any] | None = None - ) -> list[dict[str, Any]]: - # TODO: Define settings inputs for ATProtoSocial - # This is a placeholder implementation - # Example: - # return [ - # { - # "type": "text", - # "name": "api_base_url", - # "label": _("API Base URL"), - # "value": current_config.get("api_base_url", "https://bsky.social"), - # "required": True, - # }, - # { - # "type": "password", - # "name": "access_token", # This should probably be app_password or similar for Bluesky - # "label": _("Access Token / App Password"), - # "value": current_config.get("access_token", ""), - # "required": True, - # }, - # ] - return [] - - @classmethod - def get_user_actions(cls) -> list[dict[str, Any]]: - """Defines user-specific actions available for ATProtoSocial profiles.""" - # These actions are typically displayed on a user's profile in the UI. - # 'id' is used to map to the handler in controller/handler.py - # 'action_type': 'api_call' (calls handle_user_command), 'link' (opens URL) - # 'payload_params': list of params from user context to include in payload to handle_user_command - # 'requires_target_user_did': True if the action needs a target user's DID - - # Note: Current Approve UI might not distinguish visibility based on context (e.g., don't show "Follow" if already following). - # This logic would typically reside in the UI or be supplemented by viewer state from profile data. - - actions = [ - { - "id": "atp_view_profile_web", # Unique ID - "label": _("View Profile on Web"), - "icon": "external-link-alt", - "action_type": "link", # Opens a URL - "url_template": "https://bsky.app/profile/{target_user_handle}", # {target_user_handle} will be replaced - "requires_target_user_did": False, # Needs handle, but can be derived from DID - "requires_target_user_handle": True, - }, - { - "id": "atp_follow_user", - "label": _("Follow User"), - "icon": "user-plus", - "action_type": "api_call", - "api_command": "follow_user", # Command for handle_user_command - "requires_target_user_did": True, - "confirmation_required": False, - }, - { - "id": "atp_unfollow_user", - "label": _("Unfollow User"), - "icon": "user-minus", - "action_type": "api_call", - "api_command": "unfollow_user", - "requires_target_user_did": True, - "confirmation_required": True, - "confirmation_message": _("Are you sure you want to unfollow this user?"), - }, - { - "id": "atp_mute_user", - "label": _("Mute User"), - "icon": "volume-mute", - "action_type": "api_call", - "api_command": "mute_user", - "requires_target_user_did": True, - }, - { - "id": "atp_unmute_user", - "label": _("Unmute User"), - "icon": "volume-up", - "action_type": "api_call", - "api_command": "unmute_user", - "requires_target_user_did": True, - }, - { - "id": "atp_block_user", - "label": _("Block User"), - "icon": "user-slash", # Or "ban" - "action_type": "api_call", - "api_command": "block_user", - "requires_target_user_did": True, - "confirmation_required": True, - "confirmation_message": _("Are you sure you want to block this user? They will not be able to interact with you, and you will not see their content."), - }, - { - "id": "atp_unblock_user", # This might be handled by UI if block status is known - "label": _("Unblock User"), - "icon": "user-check", # Or "undo" - "action_type": "api_call", - "api_command": "unblock_user", - "requires_target_user_did": True, - # No confirmation usually for unblock, but can be added - }, - # Example: Action to fetch user's feed (handled by UI navigation, not typically a button here) - # { - # "id": "atp_view_user_feed", - # "label": _("View User's Posts"), - # "icon": "list-alt", - # "action_type": "ui_navigation", # Special type for UI to handle - # "target_view": "user_feed", - # "requires_target_user_did": True, - # }, - ] - return actions - - @classmethod - def get_user_list_actions(cls) -> list[dict[str, Any]]: - # TODO: Define user list actions for ATProtoSocial - return [] - - def get_reporter(self) -> Reporter | None: - # TODO: Implement if ATProtoSocial has specific reporting capabilities - # that differ from the base implementation - return super().get_reporter() - - def is_ready(self) -> bool: - """Checks if the session is properly configured and authenticated.""" - # A more robust check would be to see if self.client.me is not None or similar SDK check - # For example: return self.client is not None and self.client.me is not None (after client.get_session() or login) - # For now, check if essential details are in DB, implying a successful login occurred. - return bool(self.db.get("access_jwt") and self.db.get("did") and self.db.get("handle")) - - - async def _get_user_id_from_username(self, username: str) -> str | None: # username is handle - # TODO: Implement handle to DID resolution if needed for general use. - # client = await self.util._get_client() # Ensure client is available - # if client: - # try: - # profile = await client.app.bsky.actor.get_profile({'actor': username}) - # return profile.did - # except Exception: - # return None - return None - - async def _get_username_from_user_id(self, user_id: str) -> str | None: # user_id is DID - # TODO: Implement user ID to username resolution if needed more broadly. - # For now, profile data usually contains the handle. - if not self.is_ready() or not self.client: - return None - try: - profile = await self.client.app.bsky.actor.get_profile(models.AppBskyActorGetProfile.Params(actor=user_id)) - return profile.handle if profile else None + if handle: + return handle except Exception: - return None + pass + return self.name - # --- Notification Handling --- - - async def _handle_like_notification(self, notification_item: utils.ATNotification) -> None: - author = notification_item.author - post_uri = notification_item.uri # URI of the like record itself - subject_uri = notification_item.reasonSubject # URI of the post that was liked - - title = _("{author_name} liked your post").format(author_name=author.displayName or author.handle) - body = "" # Could fetch post content for body if desired, but title is often enough for likes - - # Try to get the URL of the liked post - url = None - if subject_uri: - try: - # Assuming subject_uri is the AT URI of the post. - # We need its rkey and author handle to build a bsky.app URL. - # This is complex if we don't have the post details already. - # For simplicity, make the notification URL point to the liker's profile or the like record. - # A better UX might involve fetching the post to link to it directly. - url = await self.get_message_url(message_id=subject_uri, context="notification_like_subject") - except Exception as e: - logger.warning(f"Could not generate URL for liked post {subject_uri}: {e}") - url = f"https://bsky.app/profile/{author.handle}" # Fallback to liker's profile - - await self.send_notification_to_channel( - kind=NotificationKind.FAVOURITE, # Maps to 'like' - title=title, - body=body, - url=url, - author_name=author.displayName or author.handle, - author_id=author.did, - author_avatar_url=author.avatar, - timestamp=util.parse_iso_datetime(notification_item.indexedAt), - message_id=post_uri, # ID of the like notification/record itself - original_message_id=subject_uri, # ID of the liked post - # original_message_author_id: self.util.get_own_did(), # The user receiving the like - # original_message_author_username: self.util.get_own_username(), - ) - - async def _handle_repost_notification(self, notification_item: utils.ATNotification) -> None: - author = notification_item.author - repost_uri = notification_item.uri # URI of the repost record - subject_uri = notification_item.reasonSubject # URI of the original post that was reposted - - title = _("{author_name} reposted your post").format(author_name=author.displayName or author.handle) - body = "" # Could fetch original post content for body - url = None - if subject_uri: - try: - url = await self.get_message_url(message_id=subject_uri, context="notification_repost_subject") - except Exception as e: - logger.warning(f"Could not generate URL for reposted post {subject_uri}: {e}") - url = f"https://bsky.app/profile/{author.handle}" # Fallback to reposter's profile - - - await self.send_notification_to_channel( - kind=NotificationKind.REBLOG, # Maps to 'repost' - title=title, - body=body, - url=url, - author_name=author.displayName or author.handle, - author_id=author.did, - author_avatar_url=author.avatar, - timestamp=util.parse_iso_datetime(notification_item.indexedAt), - message_id=repost_uri, - original_message_id=subject_uri, - ) - - async def _handle_follow_notification(self, notification_item: utils.ATNotification) -> None: - author = notification_item.author - follow_uri = notification_item.uri # URI of the follow record - - title = _("{author_name} followed you").format(author_name=author.displayName or author.handle) - url = f"https://bsky.app/profile/{author.handle}" # Link to follower's profile - - await self.send_notification_to_channel( - kind=NotificationKind.FOLLOW, - title=title, - body=None, # No body needed for follow - url=url, - author_name=author.displayName or author.handle, - author_id=author.did, - author_avatar_url=author.avatar, - timestamp=util.parse_iso_datetime(notification_item.indexedAt), # type: ignore[attr-defined] - message_id=follow_uri, - ) - - async def _handle_mention_notification(self, notification_item: utils.ATNotification) -> None: - author = notification_item.author - post_record = notification_item.record # This is the app.bsky.feed.post record - post_uri = notification_item.uri # URI of the post containing the mention - - title = _("New mention from {author_name}").format(author_name=author.displayName or author.handle) - body = getattr(post_record, 'text', '') if post_record else '' - url = None - if post_uri: - try: - url = await self.get_message_url(message_id=post_uri, context="notification_mention") - except Exception as e: - logger.warning(f"Could not generate URL for mention post {post_uri}: {e}") - url = f"https://bsky.app/profile/{author.handle}" - - - await self.send_notification_to_channel( # type: ignore[attr-defined] - kind=NotificationKind.MENTION, # type: ignore[attr-defined] - title=title, - body=body, - url=url, - author_name=author.displayName or author.handle, - author_id=author.did, - author_avatar_url=author.avatar, - timestamp=util.parse_iso_datetime(notification_item.indexedAt), # type: ignore[attr-defined] - message_id=post_uri, - original_message_id=post_uri, # The mention is in this post - ) - - async def _handle_reply_notification(self, notification_item: utils.ATNotification) -> None: - author = notification_item.author - post_record = notification_item.record # The app.bsky.feed.post record (the reply post) - reply_post_uri = notification_item.uri # URI of the reply post - - # The subject of the reply notification is the user's original post that was replied to. - # notification_item.reasonSubject might be null if the reply is to a post that was deleted - # or if the notification structure is different. The reply record itself contains parent/root. - replied_to_post_uri = getattr(post_record.reply, 'parent', {}).get('uri') if post_record and hasattr(post_record, 'reply') and hasattr(post_record.reply, 'parent') and hasattr(post_record.reply.parent, 'uri') else None - - - title = _("{author_name} replied to your post").format(author_name=author.displayName or author.handle) - body = getattr(post_record, 'text', '') if post_record else '' - url = None - if reply_post_uri: # Link to the reply itself - try: - url = await self.get_message_url(message_id=reply_post_uri, context="notification_reply") - except Exception as e: - logger.warning(f"Could not generate URL for reply post {reply_post_uri}: {e}") - url = f"https://bsky.app/profile/{author.handle}" - - - await self.send_notification_to_channel( # type: ignore[attr-defined] - kind=NotificationKind.REPLY, # type: ignore[attr-defined] - title=title, - body=body, - url=url, - author_name=author.displayName or author.handle, - author_id=author.did, - author_avatar_url=author.avatar, - timestamp=util.parse_iso_datetime(notification_item.indexedAt), # type: ignore[attr-defined] - message_id=reply_post_uri, - original_message_id=replied_to_post_uri, # The post that was replied to - ) - - async def _handle_quote_notification(self, notification_item: utils.ATNotification) -> None: - author = notification_item.author - post_record = notification_item.record # The app.bsky.feed.post record (the post that quotes) - quoting_post_uri = notification_item.uri # URI of the post that contains the quote - - # The subject of the quote notification is the user's original post that was quoted. - quoted_post_uri = notification_item.reasonSubject - - title = _("{author_name} quoted your post").format(author_name=author.displayName or author.handle) - body = getattr(post_record, 'text', '') if post_record else '' # Text of the quoting post - url = None - if quoting_post_uri: # Link to the quoting post - try: - url = await self.get_message_url(message_id=quoting_post_uri, context="notification_quote") - except Exception as e: - logger.warning(f"Could not generate URL for quoting post {quoting_post_uri}: {e}") - url = f"https://bsky.app/profile/{author.handle}" - - - await self.send_notification_to_channel( # type: ignore[attr-defined] - kind=NotificationKind.QUOTE, # Assuming a QUOTE kind exists or map to MENTION/custom # type: ignore[attr-defined] - title=title, - body=body, - url=url, - author_name=author.displayName or author.handle, - author_id=author.did, - author_avatar_url=author.avatar, - timestamp=util.parse_iso_datetime(notification_item.indexedAt), # type: ignore[attr-defined] - message_id=quoting_post_uri, - original_message_id=quoted_post_uri, # The post that was quoted - ) - - def _get_notification_handler_map(self) -> dict[str, Any]: # type: ignore[type-arg] - """Maps ATProto notification reasons to handler methods.""" - return { - "like": self._handle_like_notification, - "repost": self._handle_repost_notification, - "follow": self._handle_follow_notification, - "mention": self._handle_mention_notification, - "reply": self._handle_reply_notification, - "quote": self._handle_quote_notification, - } - - async def fetch_notifications(self, cursor: str | None = None, limit: int = 20) -> str | None: - """ - Fetches notifications from ATProtoSocial and processes them. - Returns the cursor for the next page, or None if no more notifications. - """ - if not self.is_ready(): - logger.warning("Cannot fetch notifications: session not ready.") - return None - - try: - logger.info(f"Fetching ATProtoSocial notifications with cursor: {cursor}") - notifications_tuple = await self.util.get_notifications(limit=limit, cursor=cursor) - if not notifications_tuple: - logger.info("No notifications returned from util.get_notifications.") - return None - - raw_notifications, next_cursor = notifications_tuple - - if not raw_notifications: - logger.info("No new notifications found.") - # Consider updating last seen timestamp here if all caught up. - # await self.mark_notifications_as_seen() - return next_cursor # Return cursor even if no new items, could be end of list - - handler_map = self._get_notification_handler_map() - processed_count = 0 - for item in raw_notifications: - # item is models.AppBskyNotificationListNotifications.Notification - if not item.isRead: # Process only unread notifications for UI to avoid duplicates if polling - # However, for initial sync, we might want to process some read ones too. - # For now, let's assume we process and then it's up to UI to display "unread" state. - # The `mark_notifications_as_seen` would be key. - handler = handler_map.get(item.reason) - if handler: - try: - await handler(item) - processed_count +=1 - except Exception as e: - logger.error(f"Error handling notification type {item.reason} (URI: {item.uri}): {e}", exc_info=True) - else: - logger.warning(f"No handler for ATProtoSocial notification reason: {item.reason}") - - logger.info(f"Processed {processed_count} ATProtoSocial notifications.") - - # TODO: Implement marking notifications as seen. - # This should probably be done after a short delay or user action. - # If all fetched notifications were processed, and it was a full page, - # we might consider calling client.app.bsky.notification.update_seen() - # await self.mark_notifications_as_seen() # Be careful not to do this too aggressively - # If not item.isRead: # Only mark seen if we actually processed unread items. - # if processed_count > 0 and (len(raw_notifications) < limit or not next_cursor) : # If we are at the "end" of unread. - # await self.mark_notifications_as_seen() - - - return next_cursor - except NotificationError as e: # Errors from util.get_notifications - logger.error(f"Failed to fetch notifications: {e.message}") - self.send_text_notification(title=_("Notification Error"), message=str(e)) - return cursor # Return original cursor on error to retry later - except Exception as e: - logger.error(f"Unexpected error fetching notifications: {e}", exc_info=True) - self.send_text_notification(title=_("Notification Error"), message=_("An unexpected error occurred while fetching notifications.")) - return cursor - - - async def mark_notifications_as_seen(self, seen_at: str | None = None) -> None: - """Marks notifications as seen up to a certain timestamp.""" - if not self.is_ready() or not self.client: - logger.warning("Cannot mark notifications as seen: client not ready.") - return - - try: - # seen_at should be an ISO 8601 timestamp. If None, defaults to now. - # from atproto_client.models import get_or_create, ids, string_to_datetime # SDK specific import - # if not seen_at: - # seen_at = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z') - # await self.client.app.bsky.notification.update_seen({'seenAt': seen_at}) - # For now, using a placeholder as direct update_seen might need specific datetime string. - # The SDK client might have a helper for current time in correct format. - await self.client.app.bsky.notification.update_seen() # SDK might default seenAt to now - logger.info("Marked ATProtoSocial notifications as seen.") - except Exception as e: - logger.error(f"Error marking notifications as seen: {e}", exc_info=True) - - - async def handle_streaming_event(self, event_type: str, data: Any) -> None: # data type is ATNotification or similar - """ - Handles incoming notification events from a streaming connection. - `event_type` would be something like 'mention', 'like', etc. - `data` should be the ATProtoSocial notification model object. - """ - logger.debug(f"ATProtoSocial: Received streaming event: {event_type} with data: {data}") - handler = self._get_notification_handler_map().get(event_type) - if handler: - try: - # Assuming 'data' is already in the format of models.AppBskyNotificationListNotifications.Notification - # If not, it needs to be transformed here. - await handler(data) - except Exception as e: - logger.error(f"Error handling streamed notification type {event_type}: {e}", exc_info=True) - else: - logger.warning(f"No handler for ATProtoSocial streamed event type: {event_type}") - - - def get_sensitive_reason_options(self) -> dict[str, str] | None: - # TODO: If ATProtoSocial supports reasons for marking content sensitive, define them here - return None - - # --- Timeline Fetching and Processing --- - - async def fetch_home_timeline(self, cursor: str | None = None, limit: int = 20, new_only: bool = False) -> tuple[list[str], str | None]: - """Fetches the home timeline (following) and processes it.""" - if not self.is_ready(): - logger.warning("Cannot fetch home timeline: session not ready.") - raise NotificationError(_("Session is not active. Please log in or check your connection.")) - - logger.info(f"Fetching home timeline with cursor: {cursor}, limit: {limit}, new_only: {new_only}") - try: - timeline_data = await self.util.get_timeline(algorithm=None, limit=limit, cursor=cursor) - if not timeline_data: - logger.info("No home timeline data returned from util.") - return [], cursor # Return current cursor if no data - - feed_view_posts, next_cursor = timeline_data - processed_ids = await self.order_buffer( - items=feed_view_posts, - new_only=new_only, - buffer_name="home_timeline_buffer" + def _ensure_client(self): + if AtpClient is None: + raise RuntimeError( + "The 'atproto' package is not installed. Install it to use Bluesky." ) + if self.api is None: + self.api = AtpClient() + return self.api - if new_only and next_cursor: # For fetching newest, cursor logic might differ or not be used this way - self.home_timeline_cursor = next_cursor # Bluesky cursors are typically for older items - elif not new_only : # Fetching older items - self.home_timeline_cursor = next_cursor - - logger.info(f"Fetched {len(processed_ids)} items for home timeline. Next cursor: {self.home_timeline_cursor}") - return processed_ids, self.home_timeline_cursor - except NotificationError: # Re-raise critical errors - raise - except Exception as e: - logger.error(f"Unexpected error fetching home timeline: {e}", exc_info=True) - raise NotificationError(_("An error occurred while fetching your home timeline.")) - - - async def fetch_user_timeline(self, user_did: str, cursor: str | None = None, limit: int = 20, new_only: bool = False, filter_type: str = "posts_with_replies") -> tuple[list[str], str | None]: - """Fetches a specific user's timeline and processes it.""" - if not self.is_ready(): - logger.warning(f"Cannot fetch user timeline for {user_did}: session not ready.") - raise NotificationError(_("Session is not active. Please log in or check your connection.")) - - logger.info(f"Fetching user timeline for {user_did} with cursor: {cursor}, filter: {filter_type}") + def login(self, verify_credentials=True): + if self.settings.get("atprotosocial") is None: + raise Exceptions.RequireCredentialsSessionError + handle = self.settings["atprotosocial"].get("handle") + app_password = self.settings["atprotosocial"].get("app_password") + session_string = self.settings["atprotosocial"].get("session_string") + if not handle or (not app_password and not session_string): + self.logged = False + raise Exceptions.RequireCredentialsSessionError try: - feed_data = await self.util.get_author_feed(actor_did=user_did, limit=limit, cursor=cursor, filter=filter_type) - if not feed_data: - logger.info(f"No feed data returned for user {user_did}.") - return [], cursor + # Ensure db exists (can be set to None on logout paths) + if not isinstance(self.db, dict): + self.db = {} + api = self._ensure_client() + # Prefer resuming session if we have one + if session_string: + try: + api.import_session_string(session_string) + except Exception: + # Fall back to login below + pass + if not getattr(api, "me", None): + # Fresh login + api.login(handle, app_password) + # Cache basics + if getattr(api, "me", None) is None: + raise RuntimeError("Bluesky SDK client has no 'me' after login") + self.db["user_name"] = api.me.handle + self.db["user_id"] = api.me.did + # Persist DID in settings for session manager display + self.settings["atprotosocial"]["did"] = api.me.did + # Export session for future reuse + try: + self.settings["atprotosocial"]["session_string"] = api.export_session_string() + except Exception: + pass + self.settings.write() + self.logged = True + log.debug("Logged in to Bluesky as %s", api.me.handle) + except Exception: + log.exception("Bluesky login failed") + self.logged = False - feed_view_posts, next_cursor = feed_data - # For user timelines, we might not store them in a persistent session buffer like home_timeline_buffer, - # but rather just process them into message_cache for direct display or a temporary view buffer. - # For now, let's use a generic buffer name or imply it's for message_cache population. - processed_ids = await self.order_buffer( - items=feed_view_posts, - new_only=new_only, # This might be always False or True depending on how user timeline view works - buffer_name=f"user_timeline_{user_did}" # Example of a dynamic buffer name, though not stored on session directly - ) - logger.info(f"Fetched {len(processed_ids)} items for user {user_did} timeline. Next cursor: {next_cursor}") - return processed_ids, next_cursor - except NotificationError: - raise - except Exception as e: - logger.error(f"Unexpected error fetching user timeline for {user_did}: {e}", exc_info=True) - raise NotificationError(_("An error occurred while fetching the user's timeline.")) - - - async def order_buffer(self, items: list[utils.models.AppBskyFeedDefs.FeedViewPost], new_only: bool = True, buffer_name: str = "home_timeline_buffer", **kwargs) -> list[str]: # type: ignore - """Processes and orders items (FeedViewPost) into the specified buffer and message_cache.""" - if not isinstance(items, list): - logger.warning(f"order_buffer received non-list items: {type(items)}. Skipping.") - return [] - - added_ids: list[str] = [] - target_buffer_list: list[str] | None = getattr(self, buffer_name, None) - - # If buffer_name is dynamic (e.g. user timelines), target_buffer_list might be None. - # In such cases, items are added to message_cache, and added_ids are returned for direct use. - # If it's a well-known buffer like home_timeline_buffer, it's updated. - - for item in items: - if not isinstance(item, utils.models.AppBskyFeedDefs.FeedViewPost): - logger.warning(f"Skipping non-FeedViewPost item in order_buffer: {item}") - continue - - post_view = item.post - if not post_view or not post_view.uri: - logger.warning(f"FeedViewPost item missing post view or URI: {item}") - continue - - post_uri = post_view.uri - - # Cache the main post - # self.util._format_post_data can convert PostView to a dict if needed by message_cache - # For now, assume message_cache can store the PostView model directly or its dict representation - formatted_post_data = self.util._format_post_data(post_view) # Ensure this returns a dict - self.message_cache[post_uri] = formatted_post_data - - # Handle replies - cache parent/root if present and not already cached - if item.reply: - if item.reply.parent and item.reply.parent.uri not in self.message_cache: - self.message_cache[item.reply.parent.uri] = self.util._format_post_data(item.reply.parent) # type: ignore - if item.reply.root and item.reply.root.uri not in self.message_cache: - self.message_cache[item.reply.root.uri] = self.util._format_post_data(item.reply.root) # type: ignore - - - # Handle reposts - the item.post is the original post. - # The item.reason (if ReasonRepost) indicates it's a repost. - # The UI needs to use this context when rendering item.post.uri from the timeline. - # For simplicity, the buffer stores the URI of the original post. - # If a more complex object is needed in the buffer, this is where to construct it. - # For example: {"type": "repost", "reposter": item.reason.by.handle, "post_uri": post_uri, "repost_time": item.reason.indexedAt} - - if target_buffer_list is not None: - if post_uri not in target_buffer_list: # Avoid duplicates in the list itself - if new_only: # Add to the start (newer items) - target_buffer_list.insert(0, post_uri) - else: # Add to the end (older items) - target_buffer_list.append(post_uri) - added_ids.append(post_uri) - - if target_buffer_list is not None: # Trim if necessary (e.g. keep last N items) - max_buffer_size = constants.MAX_BUFFER_SIZE # From fromapprove import constants - if len(target_buffer_list) > max_buffer_size: - if new_only: # Trim from the end (oldest) - setattr(self, buffer_name, target_buffer_list[:max_buffer_size]) - else: # Trim from the start (newest - less common for this kind of buffer) - setattr(self, buffer_name, target_buffer_list[-max_buffer_size:]) - - self.cleanup_message_cache(buffers_to_check=[buffer_name] if target_buffer_list is not None else []) - return added_ids - - - async def check_buffers(self, post_data: utils.ATPost | dict[str, Any]) -> None: # type: ignore - """ - Adds a newly created post (by the current user) to relevant buffers, - primarily self.posts_buffer and self.message_cache. - `post_data` is the PostView model or its dict representation of the new post. - """ - if not post_data: - return - - post_uri = None - formatted_data = {} - - if isinstance(post_data, utils.models.AppBskyFeedDefs.PostView): - post_uri = post_data.uri - formatted_data = self.util._format_post_data(post_data) - elif isinstance(post_data, dict) and "uri" in post_data: # Assuming it's already formatted - post_uri = post_data["uri"] - formatted_data = post_data - else: - logger.warning(f"check_buffers received unexpected post_data type: {type(post_data)}") - return - - if not post_uri or not formatted_data: - logger.error("Could not process post_data in check_buffers: URI or data missing.") - return - - # Add to message_cache - self.message_cache[post_uri] = formatted_data - - # Add to user's own posts buffer (self.posts_buffer is from baseSession) - if post_uri not in self.posts_buffer: - self.posts_buffer.insert(0, post_uri) # Add to the beginning (newest) - if len(self.posts_buffer) > constants.MAX_BUFFER_SIZE: - self.posts_buffer = self.posts_buffer[:constants.MAX_BUFFER_SIZE] - - # A user's own new post might appear on their home timeline if they follow themselves - # or if the timeline algorithm includes own posts. - # For now, explicitly adding to home_timeline_buffer if not present. - # Some UIs might prefer not to duplicate, relying on separate "My Posts" view. - # if post_uri not in self.home_timeline_buffer: - # self.home_timeline_buffer.insert(0, post_uri) - # if len(self.home_timeline_buffer) > constants.MAX_BUFFER_SIZE: - # self.home_timeline_buffer = self.home_timeline_buffer[:constants.MAX_BUFFER_SIZE] - - self.cleanup_message_cache(buffers_to_check=["posts_buffer", "home_timeline_buffer"]) - logger.debug(f"Added new post {post_uri} to relevant buffers.") - - # --- User List Fetching Wrapper --- - async def get_paginated_user_list( - self, - list_type: str, # "followers", "following", "search_users" (though search might be handled differently) - identifier: str, # User DID for followers/following, or search term - limit: int, - cursor: str | None - ) -> tuple[list[dict[str,Any]], str | None]: # Returns (list_of_formatted_user_dicts, next_cursor) - """ - Wrapper to call the user list fetching functions from controller.userList. - This helps keep panel logic cleaner by calling a session method. - """ - from controller.atprotosocial import userList as atpUserListCtrl # Local import - - # Ensure the util methods used by get_user_list_paginated are available and client is ready - if not self.is_ready() or not self.util: - logger.warning(f"Session not ready for get_paginated_user_list (type: {list_type})") - return [], None - - try: - # get_user_list_paginated is expected to return formatted dicts and a cursor - users_list, next_cursor = await atpUserListCtrl.get_user_list_paginated( - session=self, # Pass self (the session instance) - list_type=list_type, - identifier=identifier, - limit=limit, - cursor=cursor - ) - return users_list, next_cursor - except Exception as e: - logger.error(f"Error in session.get_paginated_user_list for {list_type} of {identifier}: {e}", exc_info=True) - raise NotificationError(_("Failed to load user list: {error}").format(error=str(e))) - - - def get_reporting_reasons(self) -> ReportingReasons | None: - # TODO: Define specific reporting reasons for ATProtoSocial if they differ from generic ones - # This could involve fetching categories from ATProtoSocial's API or defining a static list. - # Example: - # return ReportingReasons( - # categories={ - # "spam": _("Spam"), - # "illegal": _("Illegal Content"), - # "harassment": _("Harassment"), - # # ... other ATProtoSocial specific categories - # }, - # default_text_reason_required=True, - # ) - return None - - @classmethod - def get_config_description(cls) -> str | None: - # TODO: Provide a description for the ATProtoSocial configuration section - return _( - "Configure your ATProtoSocial (Bluesky) account. You'll need your user handle (e.g., @username.bsky.social) and an App Password." + def authorise(self): + if self.logged: + raise Exceptions.AlreadyAuthorisedError("Already authorised.") + # Ask for handle + dlg = wx.TextEntryDialog( + None, + _("Enter your Bluesky handle (e.g., username.bsky.social)"), + _("Bluesky Login"), ) - - @classmethod - def is_suitable_for_channel(cls, channel: Channel) -> bool: - # TODO: Determine if this session type is suitable for the given channel. - # For now, assuming it's suitable for any channel. + if dlg.ShowModal() != wx.ID_OK: + dlg.Destroy() + return + handle = dlg.GetValue().strip() + dlg.Destroy() + # Ask for app password + pwd = wx.PasswordEntryDialog( + None, + _("Enter your Bluesky App Password (from Settings > App passwords)"), + _("Bluesky Login"), + ) + if pwd.ShowModal() != wx.ID_OK: + pwd.Destroy() + return + app_password = pwd.GetValue().strip() + pwd.Destroy() + # Create session folder and config, then attempt login + self.create_session_folder() + self.get_configuration() + self.settings["atprotosocial"]["handle"] = handle + self.settings["atprotosocial"]["app_password"] = app_password + self.settings.write() + try: + self.login() + except Exceptions.RequireCredentialsSessionError: + return + except Exception: + log.exception("Authorisation failed") + wx.MessageBox( + _("We could not log in to Bluesky. Please verify your handle and app password."), + _("Login error"), wx.ICON_ERROR + ) + return return True - @classmethod - def get_dependencies(cls) -> list[str]: - # TODO: List any Python package dependencies required for this session type. - # Example: return ["atproto"] - return ["atproto"] - - @classmethod - def get_logo_path(cls) -> str | None: - # TODO: Provide path to a logo for ATProtoSocial, e.g., "static/img/atprotosocial_logo.svg" - return "static/img/bluesky_logo.svg" # Assuming a logo file will be added here - - @classmethod - def get_auth_type(cls) -> str: - # TODO: Specify the authentication type. "password" for user/pass (or handle/app_password), "oauth" for OAuth2 - return "password" # Bluesky typically uses handle and app password - - @classmethod - def get_auth_inputs(cls, user_id: str | None = None, current_config: dict[str, Any] | None = None) -> list[dict[str, Any]]: - """Defines inputs required for ATProtoSocial authentication (handle and app password).""" - cfg = current_config or {} - return [ - { - "type": "text", - "name": "handle", - "label": _("Bluesky Handle (e.g., @username.bsky.social or username.bsky.social)"), - "value": cfg.get("handle", ""), - "required": True, - }, - { - "type": "password", - "name": "app_password", - "label": _("App Password (generate one in Bluesky settings)"), - "value": "", # Never pre-fill password - "required": True, - }, - ] - - - @classmethod - async def test_connection(cls, settings: dict[str, Any]) -> tuple[bool, str]: - """Tests connection to ATProtoSocial using provided handle and app password.""" - handle = settings.get("handle") - app_password = settings.get("app_password") - - if not handle or not app_password: - return False, _("Handle and App Password are required.") + def get_message_url(self, message_id, context=None): + # message_id may be full at:// URI or rkey + handle = self.db.get("user_name") or self.settings["atprotosocial"].get("handle", "") + rkey = message_id + if isinstance(message_id, str) and message_id.startswith("at://"): + parts = message_id.split("/") + rkey = parts[-1] + return f"https://bsky.app/profile/{handle}/post/{rkey}" + def send_message(self, message, files=None, reply_to=None, cw_text=None, is_sensitive=False, **kwargs): + if not self.logged: + raise Exceptions.NotLoggedSessionError("You are not logged in yet.") try: - # Use a temporary client for testing to not interfere with any existing session client - temp_client = AsyncClient() - logger.info(f"ATProtoSocial: Testing connection for handle {handle}...") - profile = await temp_client.login(handle, app_password) - if profile and profile.did: - logger.info(f"ATProtoSocial: Connection test successful for {handle}. DID: {profile.did}") - return True, _("Successfully connected and authenticated with Bluesky.") - else: - logger.warning(f"ATProtoSocial: Connection test for {handle} returned no profile data.") - return False, _("Authentication succeeded but no profile data was returned.") - except XrpcError as e: - logger.error(f"ATProtoSocial: Connection test failed for handle {handle} (XrpcError): {e.error} - {e.message}") - error_msg = f"{e.error or 'Error'}: {e.message or 'Failed to connect'}" - return False, _("Connection failed: {error_details}").format(error_details=error_msg) - except Exception as e: - logger.error(f"ATProtoSocial: Connection test failed for handle {handle} (Exception): {e}", exc_info=True) - return False, _("An unexpected error occurred: {error}").format(error=str(e)) + api = self._ensure_client() + # Basic text-only post for now. Attachments and CW can be extended later. + # Prefer convenience if available + uri = None + text = message or "" + # Naive CW handling: prepend CW label to text if provided + if cw_text: + text = f"CW: {cw_text}\n\n{text}" if text else f"CW: {cw_text}" + # Build base record + record: dict[str, Any] = { + "$type": "app.bsky.feed.post", + "text": text, + } + # createdAt + try: + record["createdAt"] = api.get_current_time_iso() + except Exception: + pass + # languages + langs = kwargs.get("langs") or kwargs.get("languages") + if isinstance(langs, (list, tuple)) and langs: + record["langs"] = list(langs) - async def send_notification( - self, - kind: NotificationKind, - title: str, - body: str | None = None, - url: str | None = None, - buttons: list[dict[str, str]] | None = None, - image_url: str | None = None, - message_id: str | None = None, - original_message_id: str | None = None, - original_message_author_id: str | None = None, - original_message_author_username: str | None = None, - original_message_url: str | None = None, - timestamp: float | None = None, - **kwargs: Any, - ) -> None: - """Sends a notification through the configured channel.""" - # This method is usually handled by the baseSession, but can be overridden - # if ATProtoSocial has a special way of handling notifications or needs more context. - # For now, let the base class handle it. - await super().send_notification( - kind=kind, - title=title, - body=body, - url=url, - buttons=buttons, - image_url=image_url, - message_id=message_id, - original_message_id=original_message_id, - original_message_author_id=original_message_author_id, - original_message_author_username=original_message_author_username, - original_message_url=original_message_url, - timestamp=timestamp, - **kwargs, - ) + # Helper to build a StrongRef (uri+cid) for a given post URI + def _get_strong_ref(uri: str): + try: + # Try typed models first + 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]) + posts = getattr(posts_res, "posts", None) or [] + except Exception: + posts = [] + if posts: + post0 = posts[0] + post_uri = getattr(post0, "uri", uri) + post_cid = getattr(post0, "cid", None) or (post0.get("cid") if isinstance(post0, dict) else None) + if post_cid: + return {"uri": post_uri, "cid": post_cid} + return None + + # Upload images if provided + embed_images = [] + if files: + for f in files: + path = f + alt = "" + if isinstance(f, dict): + path = f.get("path") or f.get("file") + alt = f.get("alt") or f.get("alt_text") or "" + if not path: + continue + try: + with open(path, "rb") as fp: + data = fp.read() + # Try typed upload + try: + up = api.com.atproto.repo.upload_blob(data) + blob_ref = getattr(up, "blob", None) or getattr(up, "data", None) or up + except Exception: + # Some SDK variants expose upload via api.upload_blob + up = api.upload_blob(data) + blob_ref = getattr(up, "blob", None) or getattr(up, "data", None) or up + if blob_ref: + embed_images.append({ + "image": blob_ref, + "alt": alt or "", + }) + except Exception: + log.exception("Error uploading media for Bluesky post") + continue + + # Quote post (takes precedence over images) + quote_uri = kwargs.get("quote_uri") or kwargs.get("quote") + if quote_uri: + strong = _get_strong_ref(quote_uri) + if strong: + record["embed"] = { + "$type": "app.bsky.embed.record", + "record": strong, + } + embed_images = [] # Ignore images when quoting + + if embed_images and not record.get("embed"): + record["embed"] = { + "$type": "app.bsky.embed.images", + "images": embed_images, + } + + # Reply-to handling (sets parent/root strong refs) + if reply_to: + parent_ref = _get_strong_ref(reply_to) + if parent_ref: + record["reply"] = { + "root": parent_ref, + "parent": parent_ref, + } + + # Fallback to convenience if available + try: + if hasattr(api, "send_post") and not embed_images and not langs and not cw_text: + res = api.send_post(text) + uri = getattr(res, "uri", None) or getattr(res, "cid", None) + else: + out = api.com.atproto.repo.create_record({ + "repo": api.me.did, + "collection": "app.bsky.feed.post", + "record": record, + }) + uri = getattr(out, "uri", None) + except Exception: + log.exception("Error creating Bluesky post record") + 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 diff --git a/src/wxUI/buffers/atprotosocial/panels.py b/src/wxUI/buffers/atprotosocial/panels.py index 536fd0a2..03edd948 100644 --- a/src/wxUI/buffers/atprotosocial/panels.py +++ b/src/wxUI/buffers/atprotosocial/panels.py @@ -1,693 +1,244 @@ # -*- coding: utf-8 -*- import wx -import asyncio +import languageHandler # Ensure _() is available import logging -from pubsub import pub +import wx +import config +from mysc.repeating_timer import RepeatingTimer +from datetime import datetime -from approve.translation import translate as _ -from approve.notifications import NotificationError -from multiplatform_widgets import widgets # Assuming this provides a generic list control +from multiplatform_widgets import widgets -logger = logging.getLogger(__name__) - -# Attempt to import a base panel if available, otherwise wx.Panel -try: - from ..mastodon.base import basePanel as BaseTimelinePanel # If a suitable base exists -except ImportError: - logger.warning("Mastodon basePanel not found, using wx.Panel as base for ATProtoSocial panels.") - class BaseTimelinePanel(wx.Panel): # Minimal fallback - def __init__(self, parent, name=""): - super().__init__(parent, name=name) - # Derived classes should create self.list (widgets.list) - self.list = None # Must be initialized by subclass - self.session = None # Must be set by subclass or via a method - self.account = "" # Must be set - self.name = name # Buffer name/type - self.viewer_states = {} # For like/repost URIs - - def get_selected_item_id(self): - if self.list and self.list.get_selected_count() > 0: - idx = self.list.get_selected() - # Assuming item data (URI) is stored using SetItemData or similar - # This needs to be robust based on how items are actually added. - # For now, let's assume we might store URI in a parallel list or directly. - # This was a placeholder. Correct implementation relies on GetItemData if SetItemData was used. - # If item_uris list is maintained parallel to the list control items: - # if hasattr(self, "item_uris") and self.item_uris and idx < len(self.item_uris): - # return self.item_uris[idx] - # However, using GetItemData is generally cleaner if URIs are stored there. - # This method is overridden in ATProtoSocialUserTimelinePanel to use GetItemData. - pass # Base implementation might not be suitable if not overridden. - return None - - def get_selected_item_author_details(self): - """Retrieves author details for the selected item from the message cache.""" - selected_item_uri = self.get_selected_item_id() # Relies on overridden get_selected_item_id - if selected_item_uri and self.session and hasattr(self.session, "message_cache"): - item_data = self.session.message_cache.get(selected_item_uri) - # if item_data and isinstance(item_data, dict): - author_dict = item_data.get("author") - if isinstance(author_dict, dict): - return author_dict - logger.debug(f"BaseTimelinePanel: Could not get author details for {selected_item_uri}. Cache entry: {self.session.message_cache.get(selected_item_uri) if self.session and hasattr(self.session, 'message_cache') else 'N/A'}") - return None - - def get_selected_item_summary_for_quote(self): - """Generates a summary string for quoting the selected post.""" - selected_item_uri = self.get_selected_item_id() - if selected_item_uri and self.session and hasattr(self.session, "message_cache"): - item_data = self.session.message_cache.get(selected_item_uri) - if item_data and isinstance(item_data, dict): - record = item_data.get("record") # This is the Main post record dict/object - author_info = item_data.get("author", {}) - - author_handle = author_info.get("handle", "user") - text_content = getattr(record, 'text', '') if not isinstance(record, dict) else record.get('text', '') - text_snippet = (text_content[:70] + "...") if len(text_content) > 73 else text_content - # Try to get web URL for context as well - web_url = self.get_selected_item_web_url() or selected_item_uri - return f"QT @{author_handle}: \"{text_snippet}\"\n({web_url})" - return _("Quoting post...") # Fallback - - def get_selected_item_web_url(self): - # This method should be overridden by specific panel types (like ATProtoSocialUserTimelinePanel) - # as URL structure is platform-dependent. - item_uri = self.get_selected_item_id() - if item_uri: - return f"Web URL for: {item_uri}" # Generic placeholder - return "" - - def store_item_viewer_state(self, item_uri: str, key: str, value: Any): - if item_uri not in self.viewer_states: - self.viewer_states[item_uri] = {} - self.viewer_states[item_uri][key] = value - - def get_item_viewer_state(self, item_uri: str, key: str) -> Any | None: - return self.viewer_states.get(item_uri, {}).get(key) - - def set_focus_in_list(self): - if self.list: - self.list.list.SetFocus() +log = logging.getLogger("wxUI.buffers.atprotosocial.panels") -class ATProtoSocialUserTimelinePanel(BaseTimelinePanel): - def __init__(self, parent, name: str, session, target_user_did: str, target_user_handle: str): - super().__init__(parent, name=name) - self.session = session - self.account = session.label # Or session.uid / session.get_name() - self.target_user_did = target_user_did - self.target_user_handle = target_user_handle - self.type = "user_timeline" # Buffer type identifier +class ATProtoSocialHomeTimelinePanel(object): + """Minimal Home timeline buffer for Bluesky. - self.item_uris = [] # To store AT URIs of posts, parallel to list items - self.cursor = None # For pagination to load older posts - self.newest_item_timestamp = None # For fetching newer posts (not directly used by Bluesky cursor pagination for "new") + Exposes a .buffer wx.Panel with a List control and provides + start_stream()/get_more_items() to fetch items from atproto. + """ - self._setup_ui() - - # Initial load is now typically triggered by mainController after buffer creation - # wx.CallAfter(asyncio.create_task, self.load_initial_posts()) - - - def _setup_ui(self): - self.list = widgets.list(self, _("Author"), _("Post Content"), _("Date"), style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_VIRTUAL) - # Set column widths as appropriate - self.list.set_windows_size(0, 120) # Author - self.list.set_windows_size(1, 350) # Post Content (main part) - self.list.set_windows_size(2, 150) # Date - self.list.set_size() - - # Bind list events if needed (e.g., item selection, activation) - # self.list.list.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.on_item_activated) - - sizer = wx.BoxSizer(wx.VERTICAL) - sizer.Add(self.list.list, 1, wx.EXPAND | wx.ALL, 0) # List takes most space - self.SetSizer(sizer) - self.Layout() - - async def load_initial_posts(self, limit: int = 20): - """Loads the initial set of posts for the user's timeline.""" - logger.info(f"ATProtoSocialUserTimelinePanel: Loading initial posts for {self.target_user_handle} ({self.target_user_did})") - if not self.session or not self.session.is_ready(): - logger.warning("Session not ready, cannot load posts.") - # Optionally display a message in the panel - return - try: - # filter_type="posts_no_replies" or "posts_with_replies" or "posts_and_author_threads" - # "posts_and_author_threads" is good for profile view to see everything - fetched_data = await self.session.fetch_user_timeline( - user_did=self.target_user_did, - limit=limit, - new_only=True, # To get newest first - filter_type="posts_and_author_threads" - ) - # fetch_user_timeline returns (processed_ids, next_cursor) - # The processed_ids are already in message_cache. - # We need to update the list control. - if fetched_data: - post_uris, self.cursor = fetched_data - self.item_uris = post_uris # Store URIs for get_selected_item_id - self.update_list_ctrl() - else: - self.list.list.DeleteAllItems() # Clear if no data - self.list.list.InsertItem(0, _("No posts found.")) - - except NotificationError as e: - logger.error(f"NotificationError loading posts for {self.target_user_handle}: {e.message}") - self.list.list.InsertItem(0, _("Error: ") + e.message) - except Exception as e: - logger.error(f"Error loading posts for {self.target_user_handle}: {e}", exc_info=True) - self.list.list.InsertItem(0, _("An unexpected error occurred loading posts.")) - - - async def load_more_posts(self, limit: int = 20): - """Loads older posts for the user's timeline using the current cursor.""" - logger.info(f"ATProtoSocialUserTimelinePanel: Loading more posts for {self.target_user_handle}, cursor: {self.cursor}") - if not self.session or not self.session.is_ready() or not self.cursor: - logger.warning(f"Session not ready or no cursor, cannot load more posts. Cursor: {self.cursor}") - if not self.cursor: - self.list.list.InsertItem(self.list.list.GetItemCount(), _("No more posts to load.")) - return - try: - fetched_data = await self.session.fetch_user_timeline( - user_did=self.target_user_did, - limit=limit, - cursor=self.cursor, - new_only=False, # Fetching older items - filter_type="posts_and_author_threads" - ) - if fetched_data: - new_post_uris, self.cursor = fetched_data - if new_post_uris: - self.item_uris.extend(new_post_uris) # Add to existing URIs - self.update_list_ctrl(append=True) # Append new items - else: - self.list.list.InsertItem(self.list.list.GetItemCount(), _("No more posts found.")) - self.cursor = None # No more items to load - else: - self.list.list.InsertItem(self.list.list.GetItemCount(), _("Failed to load more posts or no more posts.")) - self.cursor = None # Stop further attempts if API returns no data structure - - except NotificationError as e: - logger.error(f"NotificationError loading more posts for {self.target_user_handle}: {e.message}") - self.list.list.InsertItem(self.list.list.GetItemCount(), _("Error: ") + e.message) - except Exception as e: - logger.error(f"Error loading more posts for {self.target_user_handle}: {e}", exc_info=True) - self.list.list.InsertItem(self.list.list.GetItemCount(), _("An unexpected error occurred.")) - - - def update_list_ctrl(self, append: bool = False): - """Populates or updates the list control with cached post data.""" - if not append: - self.list.list.DeleteAllItems() - current_uris_to_display = self.item_uris - else: # Appending, so only add new URIs - # This assumes self.item_uris has already been extended with new URIs - # And we need to find which ones are truly new to the list control items - # A simpler append strategy is just to add all from the new batch. - # For now, if append is true, this method isn't directly called with new_only=True logic from session. - # This method is mostly for full refresh or initial population. - # The `order_buffer` in session.py handles adding to `self.item_uris`. - # This method should just render what's in self.item_uris. - # Let's simplify: this method always redraws based on self.item_uris. - # If appending, the caller (load_more_posts) should have extended self.item_uris. - pass # No, if appending, we add items, don't delete all. This logic needs care. - - if not append: - self.list.list.DeleteAllItems() - - start_index = 0 - if append: - start_index = self.list.list.GetItemCount() # Add after existing items - - for i, post_uri in enumerate(self.item_uris[start_index:] if append else self.item_uris): - post_data = self.session.message_cache.get(post_uri) - if post_data and isinstance(post_data, dict): - display_string = self.session.compose_panel.compose_post_for_display(post_data) - # Split display string for columns (simplified) - lines = display_string.split('\n', 2) - author_line = lines[0] - content_line = lines[1] if len(lines) > 1 else "" - # Date is part of author_line, this is a simplification. - # A proper list control might need custom rendering or more structured data. - - # For a virtual list, we'd use self.list.list.SetItemCount(len(self.item_uris)) - # and implement OnGetItemText. For now, direct insertion: - actual_index = start_index + i - self.list.list.InsertItem(actual_index, author_line) # Column 0: Author + Timestamp - self.list.list.SetItem(actual_index, 1, content_line) # Column 1: Main content - self.list.list.SetItem(actual_index, 2, "") # Column 2: Date (already in header) - self.list.list.SetItemData(actual_index, post_uri) # Store URI for retrieval - else: - logger.warning(f"Post data for URI {post_uri} not found in cache or invalid format.") - self.list.list.InsertItem(start_index + i, post_uri) - self.list.list.SetItem(start_index + i, 1, _("Error: Post data missing.")) - - if not self.item_uris and not append: - self.list.list.InsertItem(0, _("No posts to display.")) - - # --- Item Interaction Methods --- - # These are now part of BaseTimelinePanel and inherited - # get_selected_item_id() -> Returns item URI from self.item_uris - # get_selected_item_author_details() -> Returns author dict from message_cache - # get_selected_item_summary_for_quote() -> Returns "QT @author: snippet..." from message_cache - # get_selected_item_web_url() -> Constructs bsky.app URL for the post - # store_item_viewer_state(item_uri, key, value) -> Stores in self.viewer_states - # get_item_viewer_state(item_uri, key) -> Retrieves from self.viewer_states - - # Overriding from BaseTimelinePanel to use SetItemData for URI storage directly - def get_selected_item_id(self): - if self.list and self.list.get_selected_count() > 0: - idx = self.list.get_selected() - return self.list.list.GetItemData(idx) # Assumes URI was stored with SetItemData - return None - - def get_selected_item_web_url(self): - item_uri = self.get_selected_item_id() - if item_uri and self.session: - # Attempt to get handle from cached author data if available, otherwise use DID from URI - post_data = self.session.message_cache.get(item_uri) - author_handle_or_did = item_uri.split('/')[2] # Extract DID from at:///... - if post_data and isinstance(post_data, dict): - author_info = post_data.get("author") - if author_info and isinstance(author_info, dict) and author_info.get("handle"): - author_handle_or_did = author_info.get("handle") - - rkey = item_uri.split('/')[-1] - return f"https://bsky.app/profile/{author_handle_or_did}/post/{rkey}" - return "" - - -class ATProtoSocialHomeTimelinePanel(ATProtoSocialUserTimelinePanel): def __init__(self, parent, name: str, session): - super().__init__(parent, name, session, - target_user_did=session.util.get_own_did() or "N/A", - target_user_handle=session.util.get_own_username() or "N/A") + super().__init__() + self.session = session + self.account = session.get_name() + self.name = name self.type = "home_timeline" - - async def load_initial_posts(self, limit: int = 20): - """Loads the initial set of posts for the home timeline.""" - logger.info(f"ATProtoSocialHomeTimelinePanel: Loading initial posts for home timeline for {self.session.label}") - if not self.session or not self.session.is_ready(): - logger.warning("Session not ready for home timeline.") - return - try: - # The session's fetch_home_timeline updates self.session.home_timeline_buffer and self.session.home_timeline_cursor - # It returns (processed_ids, next_cursor) - processed_ids, _ = await self.session.fetch_home_timeline(limit=limit, new_only=True) - - if processed_ids: - self.item_uris = list(self.session.home_timeline_buffer) # Reflect the session buffer - self.update_list_ctrl() - else: - self.list.list.DeleteAllItems() - self.list.list.InsertItem(0, _("Home timeline is empty or failed to load.")) - except Exception as e: - logger.error(f"Error loading home timeline: {e}", exc_info=True) - if self.list.list: self.list.list.InsertItem(0, _("Error loading home timeline.")) - - async def load_more_posts(self, limit: int = 20): - """Loads older posts for the home timeline using the session's cursor.""" - logger.info(f"ATProtoSocialHomeTimelinePanel: Loading more posts, cursor: {self.session.home_timeline_cursor}") - if not self.session or not self.session.is_ready(): - logger.warning("Session not ready, cannot load more posts for home timeline.") - return - if not self.session.home_timeline_cursor: - if self.list.list: self.list.list.InsertItem(self.list.list.GetItemCount(), _("No more posts.")) - return - - try: - new_post_uris, _ = await self.session.fetch_home_timeline( - cursor=self.session.home_timeline_cursor, - limit=limit, - new_only=False - ) - if new_post_uris: - # self.item_uris is now just a reflection of session.home_timeline_buffer - self.item_uris = list(self.session.home_timeline_buffer) - self.update_list_ctrl() # Redraw the list with the full buffer - else: - if self.list.list: self.list.list.InsertItem(self.list.list.GetItemCount(), _("No more posts found.")) - self.session.home_timeline_cursor = None - except Exception as e: - logger.error(f"Error loading more for home timeline: {e}", exc_info=True) - if self.list.list: self.list.list.InsertItem(self.list.list.GetItemCount(), _("Error loading more posts.")) - - -class ATProtoSocialNotificationPanel(BaseTimelinePanel): - def __init__(self, parent, name: str, session): - super().__init__(parent, name=name) - self.session = session - self.account = session.label - self.type = "notifications" - self.item_uris = [] # Stores notification URIs or unique IDs + 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, text, indexed_at} self.cursor = None - self._setup_ui() - # Initial load handled by session.fetch_notifications -> send_notification_to_channel - # This panel should listen to pubsub or have a method to add notifications. - # For now, it's a static list that needs manual refresh. - pub.subscribe(self.on_new_notification_processed, f"approve.notification_processed.{self.session.uid}") + self._auto_timer = None - - def _setup_ui(self): - # Simplified list for notifications: Author, Action, Snippet/Link, Date - self.list = widgets.list(self, _("Author"), _("Action"), _("Details"), _("Date"), style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_VIRTUAL) - self.list.set_windows_size(0, 100) - self.list.set_windows_size(1, 250) - self.list.set_windows_size(2, 150) - self.list.set_windows_size(3, 120) - self.list.set_size() - sizer = wx.BoxSizer(wx.VERTICAL) - sizer.Add(self.list.list, 1, wx.EXPAND | wx.ALL, 0) - self.SetSizer(sizer) - self.Layout() - wx.CallAfter(asyncio.create_task, self.load_initial_notifications()) - - - async def load_initial_notifications(self, limit: int = 30): - logger.info(f"ATProtoSocialNotificationPanel: Loading initial notifications for {self.session.label}") - if not self.session or not self.session.is_ready(): return + def start_stream(self, mandatory=False, play_sound=True): + """Fetch newest items and render them.""" try: - # fetch_notifications in session.py handles sending to channel, not directly populating a list here. - # This panel needs to be populated by notifications received by send_notification_to_channel. - # For a poll-based refresh: - self.cursor = await self.session.fetch_notifications(limit=limit, cursor=None) # Returns next cursor - # The actual display items are added via pubsub from session's notification handlers - # So, this load_initial_notifications mainly serves to trigger the fetch. - # The list will be populated by on_new_notification_processed. - # If no items appear, it means they were all read or no new ones. - if not self.list.list.GetItemCount(): - # If fetch_notifications itself doesn't add to list (only via pubsub), - # and no pubsub messages came through for unread items, this will be shown. - # If fetch_notifications is expected to return items directly for initial load, - # this logic would be different. For now, assuming pubsub populates. - self.list.list.InsertItem(0, _("No new unread notifications found or failed to load initial set.")) - elif self.list.list.GetItemText(0).startswith(_("No new unread notifications")): # If only placeholder is there - pass # Keep placeholder until real notif comes via pubsub - - except Exception as e: - logger.error(f"Error in NotificationPanel load_initial_notifications/refresh: {e}", exc_info=True) - if self.list.list and self.list.list.GetItemCount() == 0: - self.list.list.InsertItem(0, _("Error loading notifications.")) - - async def load_more_notifications(self, limit: int = 20): - """Fetches older notifications using the current cursor.""" - logger.info(f"ATProtoSocialNotificationPanel: Loading more notifications for {self.session.label}, cursor: {self.cursor}") - if not self.session or not self.session.is_ready(): - logger.warning("Session not ready, cannot load more notifications.") - return - if not self.cursor: - logger.info("No older notifications cursor available.") - if self.list.list: self.list.list.InsertItem(self.list.list.GetItemCount(), _("No more older notifications.")) - return - + count = self.session.settings["general"]["max_posts_per_call"] or 40 + except Exception: + count = 40 try: - # This fetch will send items via pubsub if they are "new" in the context of this fetch. - # The panel's on_new_notification_processed will then add them. - # We need to ensure that fetch_notifications correctly handles pagination for older items. - # The session's fetch_notifications should ideally return the list of processed items too for direct handling here. - # For now, we rely on it sending via pubsub and updating self.cursor. - - # Make 'fetch_notifications' return the items directly for "load more" scenarios - # to avoid complex pubsub interaction for prepending vs appending. - # This requires a change in session.fetch_notifications or a new method. - # Let's assume session.fetch_notifications can be used for this for now and it returns items. - - # Conceptual: if session.fetch_notifications returned items directly: - # items, next_cursor = await self.session.fetch_notifications(cursor=self.cursor, limit=limit, fetch_mode="older") - # if items: - # for notif_obj in reversed(items): # If fetch_notifications returns newest first from the page - # self._add_notification_to_list(notif_obj, prepend=False) # Append older items - # self.cursor = next_cursor - # else: - # self.list.list.InsertItem(self.list.list.GetItemCount(), _("No more older notifications found.")) - # self.cursor = None - - # Current session.fetch_notifications sends via pubsub. This is not ideal for "load more". - # For now, "load more" on notifications will just re-trigger a general refresh. - # A proper "load older" requires session.fetch_notifications to support fetching older pages - # and this panel to append them. - output.speak(_("Refreshing recent notifications. True 'load older' for notifications is not yet fully implemented."), True) - await self.refresh_notifications(limit=limit) - - - except Exception as e: - logger.error(f"Error loading more notifications: {e}", exc_info=True) - if self.list.list: self.list.list.InsertItem(self.list.list.GetItemCount(), _("Error loading more notifications.")) - - - def on_new_notification_processed(self, notification_obj: Any): - """Handles new notification object from pubsub, adds to list control.""" - # This needs to be called via wx.CallAfter if pubsub is from another thread - # For now, assuming it's called on main thread or handled by pubsub config. - - # Convert Notification object to a dictionary suitable for compose_notification_for_display - # This assumes notification_obj is an instance of approve.notifications.Notification - notif_dict_for_display = { - "title": notification_obj.title, - "body": notification_obj.body, - "author_name": notification_obj.author_name, - "timestamp_dt": datetime.fromtimestamp(notification_obj.timestamp) if notification_obj.timestamp else None, - "kind": notification_obj.kind.value # Pass the string value of the enum - # Add any other fields that compose_notification_for_display might use - } - - display_string = self.session.compose_panel.compose_notification_for_display(notif_dict_for_display) - - # For a simple list, we might just display the string. - # If the list has columns, we need to parse `display_string` or have `compose_notification_for_display` return parts. - # For now, let's assume a single main column for the formatted string, and author for the first. - # This panel's list setup: _("Author"), _("Action"), _("Details"), _("Date") - - author_display = notification_obj.author_name or _("System") - # The `display_string` from `compose_notification_for_display` usually has timestamp and title. - # We need to adapt how this is split into columns or simplify the columns. - # Let's try putting the main part of the composed string in "Action" and snippet in "Details". - - parts = display_string.split('\n', 1) # Split by first newline if any - main_action_line = parts[0] - details_line = parts[1] if len(parts) > 1 else (notification_obj.body or "") - - timestamp_str = "" - if notification_obj.timestamp: - timestamp_str = datetime.fromtimestamp(notification_obj.timestamp).strftime("%I:%M %p %b %d") - - - # Prepend to list - # Columns: Author, Action (title from compose), Details (body snippet from compose), Date - idx = self.list.list.InsertItem(0, author_display) - self.list.list.SetItem(idx, 1, main_action_line) - self.list.list.SetItem(idx, 2, (details_line[:75] + "...") if len(details_line) > 78 else details_line) - self.list.list.SetItem(idx, 3, timestamp_str) # Date string from notification object - - # Store a unique ID for the notification if available (e.g., its URI or a generated one) - # This helps if we need to interact with it (e.g., mark as read, navigate to source) - unique_id = notification_obj.message_id or notification_obj.url or str(notification_obj.timestamp) # Fallback ID - self.list.list.SetItemData(idx, unique_id) - - if self.list.list.GetItemCount() > 0: - # Remove placeholder "No unread notifications..." if it exists and isn't the item we just added - # This check needs to be more robust if the placeholder is generic. - first_item_text = self.list.list.GetItemText(0) if self.list.list.GetItemCount() == 1 else self.list.list.GetItemText(1) # Check previous first item if count > 1 - if first_item_text.startswith(_("No unread notifications")) and self.list.list.GetItemCount() > 1: - # Find and delete the placeholder; it's safer to check by a specific marker or ensure it's always at index 0 when list is empty - for i in range(self.list.list.GetItemCount()): - if self.list.list.GetItemText(i).startswith(_("No unread notifications")): - self.list.list.DeleteItem(i) - break - elif self.list.list.GetItemText(0).startswith(_("No unread notifications")) and self.list.list.GetItemCount() == 1 and unique_id != self.list.list.GetItemData(0): - # This case can happen if the placeholder was the only item, and we added a new one. - # However, the InsertItem(0,...) already shifted it. This logic is tricky. - # A better way: if list was empty and had placeholder, clear it BEFORE inserting new. - pass - - - def UnbindPubSub(self): # Call this on panel close - if hasattr(self, 'session') and self.session: # Ensure session exists before trying to get uid - pub.unsubscribe(self.on_new_notification_processed, f"approve.notification_processed.{self.session.uid}") - super().Destroy() - - def get_selected_item_id(self): # Returns Notification URI or URL stored with item - if self.list and self.list.get_selected_count() > 0: - idx = self.list.get_selected() - return self.list.list.GetItemData(idx) - return None - - def get_selected_item_web_url(self): # Attempt to return a web URL if stored, or construct one - item_identifier = self.get_selected_item_id() # This might be a post URI, like URI, or follow URI - if item_identifier and item_identifier.startswith("at://"): - # This is a generic AT URI, try to make a bsky.app link if it's a post. - # More specific handling might be needed depending on what ID is stored. + api = self.session._ensure_client() + # The atproto SDK expects params, not raw kwargs try: - # Example: at://did:plc:xyz/app.bsky.feed.post/3k අඩුk අඩුj අඩු - parts = item_identifier.replace("at://", "").split("/") - if len(parts) == 3 and parts[1] == "app.bsky.feed.post": - did_or_handle = parts[0] - rkey = parts[2] - # Try to resolve DID to handle for a nicer URL if possible (complex here) - return f"https://bsky.app/profile/{did_or_handle}/post/{rkey}" - elif len(parts) == 3 and parts[1] == "app.bsky.actor.profile": # Link to profile - did_or_handle = parts[0] - return f"https://bsky.app/profile/{did_or_handle}" - except Exception as e: - logger.debug(f"Could not parse AT URI {item_identifier} for web URL: {e}") - elif item_identifier and item_identifier.startswith("http"): # Already a web URL - return item_identifier - return item_identifier # Fallback to returning the ID itself + from atproto import models as at_models # type: ignore + params = at_models.AppBskyFeedGetTimeline.Params(limit=count) + res = api.app.bsky.feed.get_timeline(params) + except Exception: + # Fallback to plain dict params if typed models unavailable + res = api.app.bsky.feed.get_timeline({"limit": count}) + 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 "" + indexed_at = getattr(post, "indexed_at", None) + self.items.append({ + "uri": getattr(post, "uri", ""), + "author": handle, + "text": text, + "indexed_at": indexed_at, + }) + 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) + res = api.app.bsky.feed.get_timeline(params) + except Exception: + res = api.app.bsky.feed.get_timeline({"limit": 40, "cursor": self.cursor}) + 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 "" + indexed_at = getattr(post, "indexed_at", None) + new_items.append({ + "uri": getattr(post, "uri", ""), + "author": handle, + "text": text, + "indexed_at": indexed_at, + }) + if not new_items: + return 0 + self.items.extend(new_items) + 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 + + 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: + # indexed_at is ISO format; show HH:MM or date + dt = str(it["indexed_at"])[:16].replace("T", " ") + except Exception: + dt = "" + text = it.get("text", "").replace("\n", " ") + if len(text) > 200: + text = text[:197] + "..." + self.buffer.list.insert_item(False, it.get("author", ""), 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 + + # 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 ATProtoSocialUserListPanel(BaseTimelinePanel): - def __init__(self, parent, name: str, session, list_type: str, target_user_did: str, target_user_handle: str | None = None): +class _HomePanel(wx.Panel): + def __init__(self, parent, name): super().__init__(parent, name=name) - self.session = session - self.account = session.label - self.list_type = list_type - self.target_user_did = target_user_did - self.target_user_handle = target_user_handle or target_user_did - self.type = f"user_list_{list_type}" - - self.user_list_data = [] - self.cursor = None - - self._setup_ui() - wx.CallAfter(asyncio.create_task, self.load_initial_users()) - - def _setup_ui(self): - self.list = widgets.list(self, _("Display Name"), _("Handle"), _("Bio"), style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_VIRTUAL) - self.list.set_windows_size(0, 150) - self.list.set_windows_size(1, 150) - self.list.set_windows_size(2, 300) - self.list.set_size() - + self.name = name + 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.list.set_windows_size(0, 120) + self.list.set_windows_size(1, 360) + self.list.set_windows_size(2, 150) + self.list.set_size() sizer.Add(self.list.list, 1, wx.EXPAND | wx.ALL, 0) self.SetSizer(sizer) - self.Layout() - async def load_initial_users(self, limit: int = 30): - logger.info(f"ATProtoSocialUserListPanel: Loading initial users for {self.list_type} of {self.target_user_handle or self.target_user_did}") - if not self.session or not self.session.is_ready(): - logger.warning(f"Session not ready, cannot load {self.list_type}.") - return + +class ATProtoSocialFollowingTimelinePanel(ATProtoSocialHomeTimelinePanel): + """Following-only timeline (reverse-chronological).""" + + def __init__(self, parent, name: str, session): + super().__init__(parent, name, session) + self.type = "following_timeline" + # Make sure the underlying wx panel also reflects this type try: - # Using the controller.userList function for paginated fetching directly - # This requires access to mainController or passing it down. - # For simplicity, let's assume a helper on session that calls the controller.userList function. - # Or, we can make this panel call a new session method that wraps this. - # For now, let's assume session has a method like `get_paginated_user_list`. - # This method needs to exist on the session: - # async def get_paginated_user_list(self, list_type, identifier, limit, cursor) -> tuple[list, str|None]: - # from controller.atprotosocial import userList as atpUserListCtrl # Keep import local - # return await atpUserListCtrl.get_user_list_paginated(self, list_type, identifier, limit, cursor) - - # Always call the session method now - users, self.cursor = await self.session.get_paginated_user_list( - list_type=self.list_type, - identifier=self.target_user_did, - limit=limit, - cursor=None - ) - - if users: - self.user_list_data = users - self.update_list_ctrl() - else: - self.list.list.DeleteAllItems() - self.list.list.InsertItem(0, _("No users found in this list.")) - - except Exception as e: - logger.error(f"Error loading {self.list_type} for {self.target_user_handle}: {e}", exc_info=True) - self.list.list.InsertItem(0, _("Error loading user list.")) - - - async def load_more_users(self, limit: int = 30): - logger.info(f"ATProtoSocialUserListPanel: Loading more users for {self.list_type} of {self.target_user_handle or self.target_user_did}, cursor: {self.cursor}") - if not self.session or not self.session.is_ready(): - logger.warning(f"Session not ready, cannot load more {self.list_type}.") - return - if not self.cursor: # No cursor means no more pages or initial load failed to get one - logger.info(f"No cursor available for {self.list_type} of {self.target_user_handle or self.target_user_did}, assuming no more items.") - # self.list.list.InsertItem(self.list.list.GetItemCount(), _("No more users to load.")) # Avoid duplicate messages if already shown - return + self.buffer.type = "following_timeline" + except Exception: + pass + def start_stream(self, mandatory=False, play_sound=True): try: - new_users, next_cursor = await self.session.get_paginated_user_list( - list_type=self.list_type, - identifier=self.target_user_did, - limit=limit, - cursor=self.cursor - ) - - self.cursor = next_cursor # Update cursor regardless of whether new_users were found - - if new_users: - self.user_list_data.extend(new_users) - self.update_list_ctrl(append=True) - logger.info(f"Loaded {len(new_users)} more users for {self.list_type} of {self.target_user_handle or self.target_user_did}.") - else: - logger.info(f"No more users found for {self.list_type} of {self.target_user_handle or self.target_user_did} with cursor {self.cursor}.") - # self.list.list.InsertItem(self.list.list.GetItemCount(), _("No more users found.")) # Message can be optional - except NotificationError as e: # Catch errors from session.get_paginated_user_list - logger.error(f"NotificationError loading more {self.list_type} for {self.target_user_handle}: {e.message}", exc_info=True) - if self.list.list: self.list.list.InsertItem(self.list.list.GetItemCount(), _("Error: ") + e.message) - except Exception as e: - logger.error(f"Error loading more {self.list_type} for {self.target_user_handle}: {e}", exc_info=True) - if self.list.list: self.list.list.InsertItem(self.list.list.GetItemCount(), _("An unexpected error occurred while loading more users.")) - - def update_list_ctrl(self, append: bool = False): - """Populates or updates the list control with user data.""" - if not append: - self.list.list.DeleteAllItems() - - start_index = 0 - if append: - start_index = self.list.list.GetItemCount() - items_to_add = self.user_list_data[start_index:] - else: - items_to_add = self.user_list_data - - for i, user_data in enumerate(items_to_add): - if not isinstance(user_data, dict): continue # Should be formatted dicts - - display_name = user_data.get("displayName", "") - handle = user_data.get("handle", "") - description = user_data.get("description", "") - - actual_index = start_index + i - self.list.list.InsertItem(actual_index, display_name) - self.list.list.SetItem(actual_index, 1, f"@{handle}") - self.list.list.SetItem(actual_index, 2, description.replace("\n", " ")) # Show bio on one line - self.list.list.SetItemData(actual_index, user_data.get("did")) # Store DID for actions - - if not self.user_list_data and not append: - self.list.list.InsertItem(0, _("This list is empty.")) - - # Override item interaction methods if the data stored/retrieved needs different handling - def get_selected_item_id(self): # Returns DID for users - if self.list and self.list.get_selected_count() > 0: - idx = self.list.get_selected() - return self.list.list.GetItemData(idx) # DID was stored here - return None - - def get_selected_item_author_details(self): # For a user list, the "author" is the user item itself - selected_did = self.get_selected_item_id() - if selected_did: - # Find the user_data dict in self.user_list_data - for user_data_item in self.user_list_data: - if user_data_item.get("did") == selected_did: - return user_data_item # Return the whole dict, mainController.user_details can use it - return None - - def get_selected_item_summary_for_quote(self): # Not applicable for a list of users - return "" - - def get_selected_item_web_url(self): # Construct profile URL - selected_did = self.get_selected_item_id() - if selected_did: - # Find handle from self.user_list_data - for user_data_item in self.user_list_data: - if user_data_item.get("did") == selected_did: - handle = user_data_item.get("handle") - if handle: return f"https://bsky.app/profile/{handle}" - return f"https://bsky.app/profile/{selected_did}" # Fallback to DID - return "" + count = self.session.settings["general"]["max_posts_per_call"] or 40 + except Exception: + count = 40 + try: + api = self.session._ensure_client() + # Use plain dict params to ensure algorithm is passed regardless of SDK models version + res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": "reverse-chronological"}) + 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 "" + indexed_at = getattr(post, "indexed_at", None) + self.items.append({ + "uri": getattr(post, "uri", ""), + "author": handle, + "text": text, + "indexed_at": indexed_at, + }) + 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 diff --git a/src/wxUI/commonMessageDialogs.py b/src/wxUI/commonMessageDialogs.py index 401ba8a4..ae6a0719 100644 --- a/src/wxUI/commonMessageDialogs.py +++ b/src/wxUI/commonMessageDialogs.py @@ -59,3 +59,8 @@ def remove_filter(): return dlg.ShowModal() def error_removing_filters(): return wx.MessageDialog(None, _("TWBlue was unable to remove the filter you specified. Please try again."), _("Error"), wx.ICON_ERROR).ShowModal() + +def common_error(message): + """Show a generic error dialog with the provided message.""" + dlg = wx.MessageDialog(None, message, _("Error"), wx.OK | wx.ICON_ERROR) + return dlg.ShowModal() diff --git a/src/wxUI/dialogs/atprotosocial/postDialogs.py b/src/wxUI/dialogs/atprotosocial/postDialogs.py new file mode 100644 index 00000000..f8ca1ee3 --- /dev/null +++ b/src/wxUI/dialogs/atprotosocial/postDialogs.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +import wx + + +class Post(wx.Dialog): + def __init__(self, caption=_("Post"), text="", *args, **kwds): + super(Post, self).__init__(parent=None, id=wx.ID_ANY, *args, **kwds) + self.SetTitle(caption) + main_sizer = wx.BoxSizer(wx.VERTICAL) + + # Text + self.text = wx.TextCtrl(self, wx.ID_ANY, text, style=wx.TE_MULTILINE) + self.text.SetMinSize((400, 160)) + main_sizer.Add(self.text, 1, wx.EXPAND | wx.ALL, 6) + + # Sensitive + CW + cw_box = wx.BoxSizer(wx.HORIZONTAL) + self.sensitive = wx.CheckBox(self, wx.ID_ANY, _("Sensitive content (CW)")) + self.spoiler = wx.TextCtrl(self, wx.ID_ANY) + self.spoiler.Enable(False) + self.sensitive.Bind(wx.EVT_CHECKBOX, lambda evt: self.spoiler.Enable(self.sensitive.GetValue())) + cw_box.Add(self.sensitive, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 4) + cw_box.Add(self.spoiler, 1, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 4) + main_sizer.Add(cw_box, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 2) + + # Attachments (images only) + attach_box = wx.StaticBoxSizer(wx.VERTICAL, self, _("Attachments (images)")) + self.attach_list = wx.ListCtrl(self, style=wx.LC_REPORT | wx.LC_SINGLE_SEL) + self.attach_list.InsertColumn(0, _("File")) + self.attach_list.InsertColumn(1, _("Alt")) + attach_box.Add(self.attach_list, 1, wx.EXPAND | wx.ALL, 5) + btn_row = wx.BoxSizer(wx.HORIZONTAL) + self.btn_add = wx.Button(self, wx.ID_ADD, _("Add image...")) + self.btn_remove = wx.Button(self, wx.ID_REMOVE, _("Remove")) + self.btn_remove.Enable(False) + btn_row.Add(self.btn_add, 0, wx.ALL, 2) + btn_row.Add(self.btn_remove, 0, wx.ALL, 2) + attach_box.Add(btn_row, 0, wx.ALIGN_LEFT) + main_sizer.Add(attach_box, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 6) + + # Language (single optional) + lang_row = wx.BoxSizer(wx.HORIZONTAL) + lang_row.Add(wx.StaticText(self, label=_("Language")), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 4) + self.lang_choice = wx.ComboBox(self, wx.ID_ANY, choices=["", "en", "es", "fr", "de", "ja", "pt", "ru", "zh"], style=wx.CB_DROPDOWN | wx.CB_READONLY) + self.lang_choice.SetSelection(0) + lang_row.Add(self.lang_choice, 0, wx.ALIGN_CENTER_VERTICAL) + main_sizer.Add(lang_row, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 6) + + # Buttons + btn_sizer = wx.StdDialogButtonSizer() + self.send = wx.Button(self, wx.ID_OK, _("Send")) + self.send.SetDefault() + btn_sizer.AddButton(self.send) + cancel = wx.Button(self, wx.ID_CANCEL, _("Cancel")) + btn_sizer.AddButton(cancel) + btn_sizer.Realize() + main_sizer.Add(btn_sizer, 0, wx.ALIGN_RIGHT | wx.ALL, 4) + + self.SetSizer(main_sizer) + main_sizer.Fit(self) + self.Layout() + + # Bindings + self.btn_add.Bind(wx.EVT_BUTTON, self.on_add) + self.btn_remove.Bind(wx.EVT_BUTTON, self.on_remove) + 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 on_add(self, evt): + if self.attach_list.GetItemCount() >= 4: + wx.MessageBox(_("You can attach up to 4 images."), _("Attachment limit"), wx.ICON_INFORMATION) + return + fd = wx.FileDialog(self, _("Select image"), wildcard=_("Image files (*.png;*.jpg;*.jpeg;*.gif)|*.png;*.jpg;*.jpeg;*.gif"), style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) + if fd.ShowModal() != wx.ID_OK: + fd.Destroy() + return + path = fd.GetPath() + fd.Destroy() + alt_dlg = wx.TextEntryDialog(self, _("Alternative text (optional)"), _("Description")) + alt = "" + if alt_dlg.ShowModal() == wx.ID_OK: + alt = alt_dlg.GetValue() + alt_dlg.Destroy() + idx = self.attach_list.InsertItem(self.attach_list.GetItemCount(), path) + self.attach_list.SetItem(idx, 1, alt) + + def on_remove(self, evt): + sel = self.attach_list.GetFirstSelected() + if sel != -1: + self.attach_list.DeleteItem(sel) + + def get_payload(self): + text = self.text.GetValue().strip() + cw_text = self.spoiler.GetValue().strip() if self.sensitive.GetValue() else None + lang = self.lang_choice.GetValue().strip() or None + files = [] + for i in range(self.attach_list.GetItemCount()): + files.append({ + "path": self.attach_list.GetItemText(i, 0), + "alt": self.attach_list.GetItemText(i, 1), + }) + return text, files, cw_text, (lang and [lang] or []) +