diff --git a/doc/changelog.md b/doc/changelog.md index dac500cc..0e82875b 100644 --- a/doc/changelog.md +++ b/doc/changelog.md @@ -1,5 +1,51 @@ TWBlue Changelog +## Changes in version 2024.X.X (Upcoming - ATProtoSocial Integration) + +This version introduces comprehensive support for the AT Protocol (ATProto), enabling users to connect to and interact with Bluesky accounts. + +* Core: + * **New Protocol Support**: Added ATProtoSocial (Bluesky) as a new session type. + * **Session Management**: Users can add Bluesky accounts using their handle and an App Password. Includes session creation, loading, and management through the Session Manager. + * **UI Adaptation**: + * Menus (e.g., "Post", "User Actions") dynamically update labels and available actions based on whether an ATProtoSocial session is active (e.g., "Tweet" becomes "Post", "Retweet" becomes "Repost", "Favorite" becomes "Like"). + * New compose dialog (`src/wxUI/dialogs/composeDialog.py`) created to be more generic and configurable by session type, supporting features like character limits, media attachments (images with alt text), language selection, content warnings, and quoting specific to Bluesky. + * New user profile dialog (`src/wxUI/dialogs/atprotosocial/showUserProfile.py`) for displaying Bluesky user details and performing actions. + * New UI panels (`src/wxUI/buffers/atprotosocial/panels.py`) for displaying Home timelines, User timelines, Notifications, and User Lists (Followers/Following) for Bluesky. +* ATProtoSocial (Bluesky) Features: + * **Authentication**: Secure login using user handle and App Passwords. + * **Posting**: + * Create posts (skeets) with text. + * Attach images (up to 4, with alt text). + * Specify post language(s). + * Add content warnings (sensitive content labels). + * Quote other posts. + * Reply to posts. + * **Timelines**: + * View Home timeline (posts from followed users), with support for loading newer and older posts. + * View other users' timelines (their posts and replies). + * **Notifications**: + * Fetch and display notifications for likes, reposts, follows, mentions, replies, and quotes. + * Notifications are displayed in a dedicated buffer and trigger desktop alerts. + * **User Actions**: + * View user profiles (display name, handle, bio, counts, etc.). + * Follow / Unfollow users. + * Mute / Unmute users. + * Block / Unblock users. + * **User Interaction**: + * Like / Unlike posts. + * Repost / Unrepost posts (Unrepost might be deleting the repost record). + * **User Discovery**: + * Search for users by handle or display name. + * View lists of followers and accounts a user is following. + * **Content Display**: + * Posts are formatted for display, showing author, text, timestamp, embedded media (images, quotes, external links with placeholders), reply/repost/like counts, and CWs. + * Notifications are formatted for display in their buffer. +* Developer / Internal: + * New session module: `sessions.atprotosocial` (Session, Utils, Compose, Streaming placeholders). + * New controller module: `controller.atprotosocial` (Handler, UserList, etc.). + * Extensive use of the `atproto` Python SDK for Bluesky API interactions. + ## changes in this version In this version, we have focused on providing initial support for Mastodon filters and pinned posts. From TWBlue, it is now possible to initially use filters for posts in most buffers, as well as manage them (create, edit, and delete filters, in addition to adding keywords). A new variable has also been added for post templates in the invisible interface that allows displaying whether a post has been pinned by its author. diff --git a/documentation/source/atprotosocial.rst b/documentation/source/atprotosocial.rst new file mode 100644 index 00000000..e2d463e2 --- /dev/null +++ b/documentation/source/atprotosocial.rst @@ -0,0 +1,58 @@ +.. _atprotosocial_bluesky: + +************************************** +ATProtoSocial (Bluesky) Integration +************************************** + +TWBlue now supports the AT Protocol (ATProto), the decentralized social networking protocol that powers Bluesky. This allows you to interact with your Bluesky account directly within TWBlue. + +Adding an ATProtoSocial Account +=============================== + +To connect your Bluesky account to TWBlue, you will need your user **handle** and an **App Password**. + +1. **User Handle**: This is your unique Bluesky identifier, often in the format ``@username.bsky.social`` or a custom domain you've configured (e.g., ``@yourname.com``). +2. **App Password**: Bluesky uses App Passwords for third-party applications like TWBlue instead of your main account password. You need to generate an App Password from your Bluesky account settings. + * Go to Bluesky Settings (usually accessible from the Bluesky app or website). + * Navigate to the "App passwords" section (this might be under "Advanced" or "Security"). + * Generate a new App Password. Give it a descriptive name (e.g., "TWBlue"). + * Copy the generated App Password immediately. It will usually only be shown once. + +Once you have your handle and the App Password: + +1. Open TWBlue and go to the Session Manager (Application Menu -> Manage accounts). +2. Click on "New account". +3. Select "ATProtoSocial (Bluesky)" from the menu. +4. A dialog will prompt you to confirm that you want to authorize your account. Click "Yes". +5. You will then be asked for your Bluesky Handle. Enter your full handle (e.g., ``@username.bsky.social`` or ``username.bsky.social``). +6. Next, you will be asked for the App Password you generated. Enter it carefully. +7. If the credentials are correct, TWBlue will log in to your Bluesky account, and the new session will be added to your accounts list. + +Key Features +============ + +Once your ATProtoSocial account is connected, you can use the following features in TWBlue: + +* **Posting**: Create new posts (often called "skeets") with text, images, and specify language. +* **Timelines**: + * **Home Timeline (Skyline)**: View posts from users you follow. + * **User Timelines**: View posts from specific users. + * **Mentions & Replies**: These will appear in your Notifications. +* **Notifications**: Receive notifications for likes, reposts, follows, mentions, replies, and quotes. +* **User Actions**: + * Follow and unfollow users. + * Mute and unmute users. + * Block and unblock users (blocking is done on your PDS/server). +* **Quoting Posts**: Quote other users' posts when you create a new post. +* **User Search**: Search for users by their handle or display name. +* **Content Warnings**: Create posts with content warnings (sensitive content labels). + +Basic Concepts for ATProtoSocial +================================== + +* **DID (Decentralized Identifier)**: A unique, permanent identifier for users and data on the AT Protocol. It doesn't change even if your handle does. You generally won't need to interact with DIDs directly in TWBlue, as handles are used more commonly. +* **Handle**: Your user-facing address on Bluesky (e.g., ``@username.bsky.social``). This is what you use to log in with an App Password in TWBlue. Handles can be changed, but your DID remains the same. +* **App Password**: A specific password you generate within your Bluesky account settings for use with third-party applications like TWBlue. This is more secure than using your main account password. +* **PDS (Personal Data Server)**: Where your account data is stored on the AT Protocol network. Most users are on the main Bluesky PDS (bsky.social), but you could potentially use a different one. TWBlue will typically connect to the default Bluesky PDS. + +Further details on specific actions can be found in the relevant sections of this documentation. As Bluesky and the AT Protocol evolve, TWBlue will aim to incorporate new features and refinements. diff --git a/documentation/source/basic_concepts.rst b/documentation/source/basic_concepts.rst index 5ed83eee..684050e4 100644 --- a/documentation/source/basic_concepts.rst +++ b/documentation/source/basic_concepts.rst @@ -28,4 +28,15 @@ The invisible interface, as its name suggests, has no graphical window and works Global settings and session settings ++++++++++++++++++++++++++++++++++++++++++++++ -TWBlue has two different configuration dialogs: the global configuration dialog, which affects how TWBlue works for all sessions, and the session configuration dialog, which only affects how the current session works. You will find specific information about the session settings dialog for Twitter and Mastodon in its corresponding chapter in this guide. \ No newline at end of file +TWBlue has two different configuration dialogs: the global configuration dialog, which affects how TWBlue works for all sessions, and the session configuration dialog, which only affects how the current session works. You will find specific information about the session settings dialog for Twitter and Mastodon in its corresponding chapter in this guide. + +ATProtoSocial / Bluesky Specific Terms +++++++++++++++++++++++++++++++++++++++ + +When using the ATProtoSocial (Bluesky) integration, you might encounter these terms: + +* **Handle**: Your user-facing address on Bluesky (e.g., ``@username.bsky.social`` or a custom domain like ``@yourname.com``). This is what you use to log in with an App Password in TWBlue. Handles can be changed, but your DID remains the same. +* **App Password**: A specific password you generate within your Bluesky account settings (usually under Settings -> Advanced -> App passwords) for use with third-party applications like TWBlue. This is more secure than using your main account password, as each App Password can be revoked individually. +* **DID (Decentralized Identifier)**: A unique, permanent identifier for users and data on the AT Protocol. It typically starts with ``did:plc:``. Your DID doesn't change even if your handle does. You generally won't need to interact with DIDs directly in TWBlue, as handles are used more commonly for user interaction. +* **Skyline**: This is the term Bluesky uses for your main home timeline, showing posts from people you follow. +* **Skeet**: An informal term for a post on Bluesky (akin to a "tweet" on Twitter). \ No newline at end of file diff --git a/documentation/source/index.rst b/documentation/source/index.rst index 531658f4..e6eba196 100644 --- a/documentation/source/index.rst +++ b/documentation/source/index.rst @@ -16,6 +16,7 @@ This is the user guide for the latest available version of TWBlue. The purpose o system_requirements installation basic_concepts + atprotosocial usage global_settings credits diff --git a/src/controller/atprotosocial/handler.py b/src/controller/atprotosocial/handler.py index 98f062d4..85af8e15 100644 --- a/src/controller/atprotosocial/handler.py +++ b/src/controller/atprotosocial/handler.py @@ -58,7 +58,7 @@ class Handler(BaseHandler): 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 + session_kind=self.SESSION_KIND ) # Notifications Buffer @@ -68,10 +68,10 @@ class Handler(BaseHandler): 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", + # 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), @@ -221,7 +221,7 @@ fromapprove.translation import translate as _ # For user-facing messages 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 @@ -245,7 +245,7 @@ fromapprove.translation import translate as _ # For user-facing messages 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. @@ -256,18 +256,13 @@ fromapprove.translation import translate as _ # For user-facing messages try: profile_data = await session.util.get_user_profile(user_ident) if profile_data: - # TODO: Integrate with a wx dialog for displaying profile. - # For now, show a simple message box with some details. # Example: from src.wxUI.dialogs.mastodon.showUserProfile import UserProfileDialog - # profile_dialog = UserProfileDialog(self.main_controller.view, session, profile_data_dict) - # profile_dialog.Show() - formatted_info = f"User: {profile_data.displayName} (@{profile_data.handle})\n" - formatted_info += f"DID: {profile_data.did}\n" - formatted_info += f"Followers: {profile_data.followersCount or 0}\n" - formatted_info += f"Following: {profile_data.followsCount or 0}\n" - formatted_info += f"Posts: {profile_data.postsCount or 0}\n" - formatted_info += f"Bio: {profile_data.description or ''}" - wx.MessageBox(formatted_info, _("User Profile (ATProtoSocial)"), wx.OK | wx.ICON_INFORMATION, self.main_controller.view) + # 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: @@ -297,14 +292,14 @@ fromapprove.translation import translate as _ # For user-facing messages 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 @@ -333,7 +328,7 @@ fromapprove.translation import translate as _ # For user-facing messages 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: @@ -392,7 +387,7 @@ fromapprove.translation import translate as _ # For user-facing messages """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 @@ -412,11 +407,11 @@ fromapprove.translation import translate as _ # For user-facing messages 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): @@ -429,7 +424,7 @@ fromapprove.translation import translate as _ # For user-facing messages # 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.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() diff --git a/src/controller/atprotosocial/settings.py b/src/controller/atprotosocial/settings.py index 7568d189..06bf6071 100644 --- a/src/controller/atprotosocial/settings.py +++ b/src/controller/atprotosocial/settings.py @@ -26,7 +26,7 @@ class ATProtoSocialSettingsForm(Form): """ # Example fields - these should align with what ATProtoSocialSession.get_settings_inputs defines # and what ATProtoSocialSession.get_configurable_values expects for its config. - + # instance_url = TextField( # _("Instance URL"), # default="https://bsky.social", # Default PDS for Bluesky @@ -45,7 +45,7 @@ class ATProtoSocialSettingsForm(Form): ) # Add more fields as needed for ATProtoSocial configuration. # For example, if there were specific notification settings, content filters, etc. - + submit = SubmitField(_("Save ATProtoSocial Settings")) @@ -62,7 +62,7 @@ async def get_settings_form( # form_data["instance_url"] = session.config_get("api_base_url", "https://bsky.social") form_data["handle"] = session.config_get("handle", "") # App password should not be pre-filled for security. - form_data["app_password"] = "" + form_data["app_password"] = "" elif config: # Fallback to persisted config if no active session # form_data["instance_url"] = config.api_base_url.get("https://bsky.social") form_data["handle"] = config.handle.get("") @@ -109,7 +109,7 @@ async def process_settings_form( # await session.stop() # Stop it # # Update session instance with new values directly or rely on it re-reading config # session.api_base_url = form.instance_url.data - # session.handle = form.handle.data + # session.handle = form.handle.data # # App password should be handled carefully, session might need to re-login # await session.start() # Restart with new settings # Or, more simply, the session might have a reconfigure method: diff --git a/src/controller/atprotosocial/templateEditor.py b/src/controller/atprotosocial/templateEditor.py index 97ae8696..bc4a6dd8 100644 --- a/src/controller/atprotosocial/templateEditor.py +++ b/src/controller/atprotosocial/templateEditor.py @@ -34,7 +34,7 @@ class ATProtoSocialTemplateEditor: """ # This would typically fetch template definitions from a default set # and override with any user-customized versions from config. - + # Example structure for an editable template: # templates = [ # { @@ -95,7 +95,7 @@ class ATProtoSocialTemplateEditor: """ # content_to_render = custom_content if custom_content is not None else self._get_template_content(template_id) # sample_data = self._get_sample_data_for_template(template_id) - + # try: # # Use a templating engine (like Jinja2) to render the preview # # from jinja2 import Template diff --git a/src/controller/atprotosocial/userList.py b/src/controller/atprotosocial/userList.py index 6294963d..3332ba6d 100644 --- a/src/controller/atprotosocial/userList.py +++ b/src/controller/atprotosocial/userList.py @@ -54,14 +54,14 @@ async def fetch_followers( # # ) # # if not response or not response.followers: # # break - + # # for user_profile_view in response.followers: # # yield session.util._format_profile_data(user_profile_view) # Use a utility to standardize format # # current_cursor = response.cursor # # if not current_cursor or len(response.followers) < limit : # Or however the API indicates end of list # # break - + # # This is a placeholder loop for demonstration # if current_cursor == "simulated_end_cursor": break # Stop after one simulated page # for i in range(limit): @@ -83,7 +83,7 @@ async def fetch_followers( logger.warning(f"Cannot fetch followers for {user_id}: ATProtoSocial session not ready.") # yield {} # Stop iteration if not ready return - + try: followers_data = await session.util.get_followers(user_did=user_id, limit=limit, cursor=cursor) if followers_data: @@ -92,7 +92,7 @@ async def fetch_followers( yield session.util._format_profile_data(user_profile_view) else: logger.info(f"No followers data returned for user {user_id}.") - + except Exception as e: logger.error(f"Error in fetch_followers for ATProtoSocial user {user_id}: {e}", exc_info=True) # Depending on desired error handling, could raise or yield an error marker @@ -120,7 +120,7 @@ async def fetch_following( yield session.util._format_profile_data(user_profile_view) else: logger.info(f"No following data returned for user {user_id}.") - + except Exception as e: logger.error(f"Error in fetch_following for ATProtoSocial user {user_id}: {e}", exc_info=True) @@ -129,7 +129,7 @@ async def search_users( session: ATProtoSocialSession, query: str, limit: int = 20, - cursor: str | None = None + cursor: str | None = None ) -> AsyncGenerator[ATProtoSocialUserListItem, None]: """ Searches for users on ATProtoSocial based on a query string. @@ -147,7 +147,7 @@ async def search_users( yield session.util._format_profile_data(user_profile_view) else: logger.info(f"No users found for search term '{query}'.") - + except Exception as e: logger.error(f"Error in search_users for ATProtoSocial query '{query}': {e}", exc_info=True) @@ -183,12 +183,12 @@ async def get_user_list_paginated( else: logger.error(f"Unknown list_type: {list_type}") return [], None - + except Exception as e: logger.error(f"Error fetching paginated user list '{list_type}' for '{identifier}': {e}", exc_info=True) # Optionally re-raise or return empty with no cursor to indicate error - return [], None - + return [], None + return users_list, next_cursor @@ -200,7 +200,7 @@ async def get_user_profile_details(session: ATProtoSocialSession, user_ident: st if not session.is_ready(): logger.warning(f"Cannot get profile for {user_ident}: ATProtoSocial session not ready.") return None - + try: profile_view_detailed = await session.util.get_user_profile(user_ident=user_ident) if profile_view_detailed: diff --git a/src/controller/mainController.py b/src/controller/mainController.py index 3331bb68..354305b9 100644 --- a/src/controller/mainController.py +++ b/src/controller/mainController.py @@ -108,6 +108,7 @@ class Controller(object): pub.subscribe(self.invisible_shorcuts_changed, "invisible-shorcuts-changed") pub.subscribe(self.create_account_buffer, "core.create_account") pub.subscribe(self.change_buffer_title, "core.change_buffer_title") + pub.subscribe(self.handle_compose_dialog_send, "compose_dialog.send_post") # For new compose dialog # Mastodon specific events. pub.subscribe(self.mastodon_new_item, "mastodon.new_item") @@ -224,7 +225,7 @@ class Controller(object): # main window self.view = view.mainFrame() # buffers list. - self.buffers = [] + self.buffers: list[buffers.base.Buffer] = [] # Added type hint self.started = False # accounts list. self.accounts = [] @@ -310,14 +311,85 @@ class Controller(object): 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): + 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)) - 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 = getattr(available_buffers, buffer_type)(**kwargs) - if start: - if kwargs.get("function") == "user_timeline": + + 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 + + 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 == "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"]) + 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") + 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 + + # 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 + + 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 try: buffer.start_stream(play_sound=False) except ValueError: @@ -509,15 +581,92 @@ class Controller(object): buffer = self.get_best_buffer() alias_controller = userAlias.userAliasController(buffer.session.settings) - def post_tweet(self, event=None): + def post_tweet(self, event=None): # This is the "New Post" menu item + """Opens the compose dialog for a new post.""" buffer = self.get_best_buffer() - if hasattr(buffer, "post_status"): - buffer.post_status() + if not buffer or not buffer.session: + output.speak(_("No active session to compose a post."), True) + return + + session = buffer.session + # For a new post, reply_to_uri and quote_uri are None. + # Import the new dialog + from wxUI.dialogs.composeDialog import ComposeDialog + # Pass self.view as parent + dialog = ComposeDialog(parent=self.view, session=session) + # We don't call dialog.ShowModal() directly if its on_send uses pubsub. + # The dialog will be shown, and its on_send will publish a message. + # mainController.handle_compose_dialog_send will handle the rest. + dialog.Show() # Use Show() for non-modal if pubsub handles closing, or ShowModal() if dialog handles its lifecycle + + def handle_compose_dialog_send(self, session, text, files, reply_to, cw_text, is_sensitive, kwargs, dialog_instance): + """Handles the actual sending of a post after ComposeDialog publishes data.""" + async def do_send_post(): + try: + wx.CallAfter(dialog_instance.send_btn.Disable) + wx.CallAfter(wx.BeginBusyCursor) + + post_uri = await session.send_message( + message=text, + files=files, + reply_to=reply_to, + cw_text=cw_text, + is_sensitive=is_sensitive, + **kwargs + ) + if post_uri: + output.speak(_("Post sent successfully!"), True) + wx.CallAfter(dialog_instance.EndModal, wx.ID_OK) + # Optionally, add to relevant buffer or update UI + # This might involve fetching the new post and adding to message_cache and posts_buffer + # new_post_data = await session.util.get_post_by_uri(post_uri) # Assuming such a util method + # if new_post_data: + # await self.check_buffers(new_post_data) # check_buffers needs to handle PostView or dict + else: + # This case should ideally be handled by send_message raising an error + output.speak(_("Failed to send post. The server did not confirm the post creation."), True) + wx.CallAfter(dialog_instance.send_btn.Enable, True) + + except NotificationError as e: + logger.error(f"NotificationError sending post via dialog: {e}", exc_info=True) + output.speak(_("Error sending post: {error}").format(error=str(e)), True) + wx.CallAfter(wx.MessageBox, str(e), _("Post Error"), wx.OK | wx.ICON_ERROR, dialog_instance) + if not dialog_instance.IsBeingDeleted(): wx.CallAfter(dialog_instance.send_btn.Enable, True) + except Exception as e: + logger.error(f"Unexpected error sending post via dialog: {e}", exc_info=True) + output.speak(_("An unexpected error occurred: {error}").format(error=str(e)), True) + wx.CallAfter(wx.MessageBox, str(e), _("Error"), wx.OK | wx.ICON_ERROR, dialog_instance) + if not dialog_instance.IsBeingDeleted(): wx.CallAfter(dialog_instance.send_btn.Enable, True) + finally: + if not dialog_instance.IsBeingDeleted(): wx.CallAfter(wx.EndBusyCursor) + + asyncio.create_task(do_send_post()) + def post_reply(self, *args, **kwargs): - buffer = self.get_current_buffer() - if hasattr(buffer, "reply"): - return buffer.reply() + buffer = self.get_current_buffer() # This is the panel instance + 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 + 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} " + + 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 + def send_dm(self, *args, **kwargs): buffer = self.get_current_buffer() @@ -530,16 +679,37 @@ class Controller(object): 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() # Assuming this gets the AT URI + item_uri = buffer.get_selected_item_id() # URI of the post to potentially quote or repost if not item_uri: - output.speak(_("No item selected to repost."), True) + output.speak(_("No item selected."), True) return - social_handler = self.get_handler(buffer.session.KIND) - async def _repost(): - result = await social_handler.repost_item(buffer.session, item_uri) - output.speak(result["message"], True) - wx.CallAfter(asyncio.create_task, _repost()) + 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. + + 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 + return def add_to_favourites(self, *args, **kwargs): buffer = self.get_current_buffer() @@ -553,12 +723,21 @@ class Controller(object): social_handler = self.get_handler(buffer.session.KIND) async def _like(): result = await social_handler.like_item(buffer.session, item_uri) - output.speak(result["message"], True) + wx.CallAfter(output.speak, result["message"], True) # Ensure UI updates on main thread if result.get("status") == "success" and result.get("like_uri"): - # Store the like URI if the buffer supports it, for unliking if hasattr(buffer, "store_item_viewer_state"): - buffer.store_item_viewer_state(item_uri, "like_uri", result["like_uri"]) - wx.CallAfter(asyncio.create_task, _like()) + # Ensure store_item_viewer_state is called on main thread if it modifies UI/shared data + wx.CallAfter(buffer.store_item_viewer_state, item_uri, "like_uri", result["like_uri"]) + # Also update the item in message_cache to reflect the like + if buffer.session and hasattr(buffer.session, "message_cache") and item_uri in buffer.session.message_cache: + cached_post = buffer.session.message_cache[item_uri] + if isinstance(cached_post, dict) and isinstance(cached_post.get("viewer"), dict): + cached_post["viewer"]["like"] = result["like_uri"] + elif hasattr(cached_post, "viewer") and cached_post.viewer: # SDK model + cached_post.viewer.like = result["like_uri"] + # No need to call buffer.update_item here unless it re-renders from scratch + # The visual feedback might come from a list refresh or specific item update later + asyncio.create_task(_like()) # wx.CallAfter for the task itself if _like might interact with UI before await def remove_from_favourites(self, *args, **kwargs): @@ -566,33 +745,43 @@ class Controller(object): if hasattr(buffer, "remove_from_favorites"): # Generic buffer method return buffer.remove_from_favorites() elif buffer.session and buffer.session.KIND == "atprotosocial": - item_uri = buffer.get_selected_item_id() # URI of the post + item_uri = buffer.get_selected_item_id() if not item_uri: output.speak(_("No item selected to unlike."), True) return - + like_uri = None - if hasattr(buffer, "get_item_viewer_state"): + # Check viewer state from message_cache first, then panel's internal viewer_states + if buffer.session and hasattr(buffer.session, "message_cache") and item_uri in buffer.session.message_cache: + cached_post = buffer.session.message_cache[item_uri] + if isinstance(cached_post, dict) and isinstance(cached_post.get("viewer"), dict): + like_uri = cached_post["viewer"].get("like") + elif hasattr(cached_post, "viewer") and cached_post.viewer: # SDK model + like_uri = cached_post.viewer.like + + if not like_uri and hasattr(buffer, "get_item_viewer_state"): # Fallback to panel's state if any like_uri = buffer.get_item_viewer_state(item_uri, "like_uri") if not like_uri: - output.speak(_("Could not find the original like record for this post. You might need to unlike it from the Bluesky app directly or refresh your timeline."), True) - # As a fallback, one could try to *find* the like record by listing likes for the post, - # but this is complex and slow for a quick action. - # For now, we rely on having the like_uri stored. - # Alternatively, some platforms allow unliking by post URI directly if the like exists. - # ATProto delete_like requires the like record URI. - logger.warning(f"Attempted to unlike post {item_uri} but its like_uri was not found in buffer's viewer_state.") + output.speak(_("Could not find the original like record for this post, or it's already unliked."), True) + logger.warning(f"Attempted to unlike post {item_uri} but its like_uri was not found.") return social_handler = self.get_handler(buffer.session.KIND) async def _unlike(): - result = await social_handler.unlike_item(buffer.session, like_uri) # Pass the like's own URI - output.speak(result["message"], True) + result = await social_handler.unlike_item(buffer.session, like_uri) + wx.CallAfter(output.speak, result["message"], True) if result.get("status") == "success": if hasattr(buffer, "store_item_viewer_state"): - buffer.store_item_viewer_state(item_uri, "like_uri", None) # Clear stored like URI - wx.CallAfter(asyncio.create_task, _unlike()) + wx.CallAfter(buffer.store_item_viewer_state, item_uri, "like_uri", None) + # Also update the item in message_cache + if buffer.session and hasattr(buffer.session, "message_cache") and item_uri in buffer.session.message_cache: + cached_post = buffer.session.message_cache[item_uri] + if isinstance(cached_post, dict) and isinstance(cached_post.get("viewer"), dict): + cached_post["viewer"]["like"] = None + elif hasattr(cached_post, "viewer") and cached_post.viewer: + cached_post.viewer.like = None + asyncio.create_task(_unlike()) def toggle_like(self, *args, **kwargs): @@ -1101,28 +1290,29 @@ class Controller(object): output.speak(_(u"Updating buffer..."), True) session = bf.session - + async def do_update(): new_ids = [] try: if session.KIND == "atprotosocial": if bf.name == f"{session.label} Home": # Assuming buffer name indicates type - # ATProtoSocial home timeline uses new_only=True for fetching newest - new_ids, _ = await session.fetch_home_timeline(limit=config.app["app-settings"].get("items_per_request", 20), new_only=True) - elif bf.name == f"{session.label} Notifications": - _, _ = await session.fetch_notifications(limit=config.app["app-settings"].get("items_per_request", 20)) # new_only implied by unread - # fetch_notifications itself handles UI updates via send_notification_to_channel - # so new_ids might not be directly applicable here unless fetch_notifications returns them - # For simplicity, we'll assume it updates the buffer internally or via pubsub. - # The count 'n' below might not be accurate for notifications this way. - # Add other ATProtoSocial buffer types here (e.g., user timeline, mentions) - # elif bf.name.startswith(f"{session.label} User Feed"): # Example for a user feed buffer - # target_user_did = getattr(bf, 'target_user_did', None) # Panel needs to store this - # if target_user_did: - # new_ids, _ = await session.fetch_user_timeline(user_did=target_user_did, limit=config.app["app-settings"].get("items_per_request", 20), new_only=True) + # Its panel's load_initial_posts calls session.fetch_home_timeline + if hasattr(bf, "load_initial_posts"): # Generic for timeline panels + await bf.load_initial_posts(limit=config.app["app-settings"].get("items_per_request", 20)) + new_ids = getattr(bf, "item_uris", []) + else: # Should not happen if panel is correctly typed + logger.warning(f"Home timeline panel for {session.KIND} missing load_initial_posts") + elif bf.type == "notifications" and hasattr(bf, "refresh_notifications"): + await bf.refresh_notifications(limit=config.app["app-settings"].get("items_per_request", 20)) + new_ids = [] + elif bf.type == "user_timeline" and hasattr(bf, "load_initial_posts"): + await bf.load_initial_posts(limit=config.app["app-settings"].get("items_per_request", 20)) + new_ids = getattr(bf, "item_uris", []) + elif bf.type in ["user_list_followers", "user_list_following"] and hasattr(bf, "load_initial_users"): + await bf.load_initial_users(limit=config.app["app-settings"].get("items_per_request", 30)) + new_ids = [u.get("did") for u in getattr(bf, "user_list_data", []) if isinstance(u,dict)] else: - # Fallback to original buffer's start_stream if it's not an ATProtoSocial specific buffer we handle here - if hasattr(bf, "start_stream"): + if hasattr(bf, "start_stream"): # Fallback for non-ATProtoSocial panels or unhandled types count = bf.start_stream(mandatory=True, avoid_autoreading=True) if count is not None: new_ids = [str(x) for x in range(count)] # Dummy IDs for count else: @@ -1135,15 +1325,18 @@ class Controller(object): else: output.speak(_(u"Unable to update this buffer."), True) return - - # Generic feedback based on new_ids for timelines - if bf.name == f"{session.label} Home" or bf.name.startswith(f"{session.label} User Feed"): # Refine condition - output.speak(_("{0} items retrieved").format(len(new_ids)), True) - elif bf.name == f"{session.label} Notifications": - output.speak(_("Notifications updated."), True) # Or specific count if fetch_notifications returns it + + # Generic feedback based on new_ids for timelines or user lists + if bf.type in ["home_timeline", "user_timeline"]: + output.speak(_("{0} posts retrieved").format(len(new_ids)), True) + elif bf.type in ["user_list_followers", "user_list_following"]: + output.speak(_("{0} users retrieved").format(len(new_ids)), True) + elif bf.type == "notifications": + output.speak(_("Notifications updated."), True) + # else, original start_stream might have given feedback except NotificationError as e: - output.speak(str(e), True) + output.speak(str(e), True) # Ensure output.speak is on main thread if called from here except Exception as e_general: logger.error(f"Error updating buffer {bf.name}: {e_general}", exc_info=True) output.speak(_("An error occurred while updating the buffer."), True) @@ -1162,25 +1355,32 @@ class Controller(object): # The buffer panel (bf) needs to store its own cursor for pagination of older items # e.g., bf.pagination_cursor or bf.older_items_cursor # This cursor should be set by the result of previous fetch_..._timeline(new_only=False) calls. - + # For ATProtoSocial, session methods like fetch_home_timeline store their own cursor (e.g., session.home_timeline_cursor) - # The panel might need to get this initial cursor or manage its own if it's for a dynamic list (user feed). - + # The panel (bf) itself should manage its own cursor for "load more" + current_cursor = None + can_load_more_natively = False + if session.KIND == "atprotosocial": - if bf.name == f"{session.label} Home": - current_cursor = session.home_timeline_cursor - # elif bf.name.startswith(f"{session.label} User Feed"): - # current_cursor = getattr(bf, 'pagination_cursor', None) # Panel specific cursor - # elif bf.name == f"{session.label} Notifications": - # current_cursor = getattr(bf, 'pagination_cursor', None) # Panel specific cursor for notifications - else: # Fallback or other buffer types - if hasattr(bf, "get_more_items"): # Try generic buffer method + if hasattr(bf, "load_more_posts"): # For ATProtoSocialUserTimelinePanel & ATProtoSocialHomeTimelinePanel + can_load_more_natively = True + if hasattr(bf, "load_more_posts"): + can_load_more_natively = True + elif hasattr(bf, "load_more_users"): + can_load_more_natively = True + elif bf.type == "notifications" and hasattr(bf, "load_more_notifications"): # Check for specific load_more + can_load_more_natively = True + elif bf.type == "notifications" and hasattr(bf, "refresh_notifications"): # Fallback for notifications to refresh + # If load_more_notifications doesn't exist, 'Load More' will just refresh. + can_load_more_natively = True # It will call refresh_notifications via the final 'else' + else: + if hasattr(bf, "get_more_items"): return bf.get_more_items() else: output.speak(_(u"This buffer does not support loading more items in this way."), True) return - else: # For other session types + else: # For other non-ATProtoSocial session types if hasattr(bf, "get_more_items"): return bf.get_more_items() else: @@ -1188,35 +1388,26 @@ class Controller(object): return output.speak(_(u"Loading more items..."), True) - + async def do_load_more(): - loaded_ids = [] try: if session.KIND == "atprotosocial": - if bf.name == f"{session.label} Home": - loaded_ids, _ = await session.fetch_home_timeline(cursor=current_cursor, limit=config.app["app-settings"].get("items_per_request", 20), new_only=False) - # elif bf.name.startswith(f"{session.label} User Feed"): - # target_user_did = getattr(bf, 'target_user_did', None) - # if target_user_did: - # loaded_ids, new_cursor = await session.fetch_user_timeline(user_did=target_user_did, cursor=current_cursor, limit=config.app["app-settings"].get("items_per_request", 20), new_only=False) - # if hasattr(bf, "pagination_cursor"): bf.pagination_cursor = new_cursor - # elif bf.name == f"{session.label} Notifications": - # new_cursor = await session.fetch_notifications(cursor=current_cursor, limit=config.app["app-settings"].get("items_per_request", 20)) - # if hasattr(bf, "pagination_cursor"): bf.pagination_cursor = new_cursor - # fetch_notifications updates UI itself. loaded_ids might not be directly applicable. - # For now, only home timeline "load more" is fully wired via session cursor. - - if loaded_ids: # Check if any IDs were actually loaded - output.speak(_("{0} more items retrieved").format(len(loaded_ids)), True) - else: - output.speak(_("No more items found or loaded."), True) + if hasattr(bf, "load_more_posts"): + await bf.load_more_posts(limit=config.app["app-settings"].get("items_per_request", 20)) + elif hasattr(bf, "load_more_users"): + await bf.load_more_users(limit=config.app["app-settings"].get("items_per_request", 30)) + elif bf.type == "notifications" and hasattr(bf, "refresh_notifications"): + # This will re-fetch recent, not older. A true "load_more_notifications(cursor=...)" is needed for that. + wx.CallAfter(output.speak, _("Refreshing notifications..."), True) + await bf.refresh_notifications(limit=config.app["app-settings"].get("items_per_request", 20)) + # Feedback is handled by panel methods for consistency except NotificationError as e: - output.speak(str(e), True) + wx.CallAfter(output.speak, str(e), True) except Exception as e_general: logger.error(f"Error loading more items for buffer {bf.name}: {e_general}", exc_info=True) output.speak(_("An error occurred while loading more items."), True) - + wx.CallAfter(asyncio.create_task, do_load_more()) @@ -1314,29 +1505,27 @@ class Controller(object): buffer = self.get_current_buffer() # Use current buffer to get context if item is selected if not buffer or not buffer.session: buffer = self.get_best_buffer() # Fallback if current buffer has no session - + if not buffer or not buffer.session: output.speak(_("No active session to view user details."), True) return handler = self.get_handler(type=buffer.session.type) if handler and hasattr(handler, 'user_details'): - # user_details handler in Mastodon takes the buffer directly, which then extracts item/user - # For ATProtoSocial, we might need to pass the user DID or handle if available from selected item - # This part assumes the buffer has a way to provide the target user's identifier - handler.user_details(buffer) + # The handler's user_details method is responsible for extracting context + # (e.g., selected user) from the buffer and displaying the profile. + # For ATProtoSocial, handler.user_details calls the ShowUserProfileDialog. + # It's an async method, so needs to be called appropriately. + async def _show_details(): + await handler.user_details(buffer) + wx.CallAfter(asyncio.create_task, _show_details()) else: output.speak(_("This session type does not support viewing user details in this way."), True) def openPostTimeline(self, *args, user=None): # "user" here is often the user object from selected item - """Opens selected user's posts timeline - Parameters: - args: Other argument. Useful when binding to widgets. - user: if specified, open this user timeline. It is currently mandatory, but could be optional when user selection is implemented in handler. - `user` is typically a dict or object with user info from the selected post/item. - """ - current_buffer = self.get_current_buffer() # Get context from current buffer first + """Opens selected user's posts timeline. Renamed to open_user_timeline in handlers for clarity.""" + current_buffer = self.get_current_buffer() if not current_buffer or not current_buffer.session: current_buffer = self.get_best_buffer() @@ -1347,23 +1536,21 @@ class Controller(object): session_to_use = current_buffer.session handler = self.get_handler(type=session_to_use.type) - if handler and hasattr(handler, 'open_user_timeline'): # Changed to a more generic name - # The handler's open_user_timeline should extract user_id (DID for ATProto) - # from the 'user' object or prompt if 'user' is None. - # 'user' object is often derived from the selected item in the current buffer. - if user is None and hasattr(current_buffer, 'get_selected_item_author_details'): - # Try to get author from selected item in current buffer if 'user' not passed directly + # Prefer the new standardized 'open_user_timeline' + if hasattr(handler, 'open_user_timeline'): + user_payload = user # Use passed 'user' if available + if user_payload is None and hasattr(current_buffer, 'get_selected_item_author_details'): author_details = current_buffer.get_selected_item_author_details() if author_details: - user = author_details # This would be a dict/object the handler can parse - - # Call the handler method. It will be responsible for creating the new buffer. - # The handler's open_user_timeline will need access to 'self' (mainController) to call add_buffer. + user_payload = author_details + async def _open_timeline(): - await handler.open_user_timeline(main_controller=self, session=session_to_use, user_payload=user) + # Pass self (mainController) to the handler method so it can call self.add_buffer + await handler.open_user_timeline(main_controller=self, session=session_to_use, user_payload=user_payload) wx.CallAfter(asyncio.create_task, _open_timeline()) - - elif handler and hasattr(handler, 'openPostTimeline'): # Fallback for older handler structure + + elif hasattr(handler, 'openPostTimeline'): # Fallback for older handler structure + # This path might not correctly pass main_controller if the old handler expects it differently handler.openPostTimeline(self, current_buffer, user) else: output.speak(_("This session type does not support opening user timelines directly."), True) @@ -1378,7 +1565,7 @@ class Controller(object): current_buffer = self.get_current_buffer() if not current_buffer or not current_buffer.session: current_buffer = self.get_best_buffer() - + if not current_buffer or not current_buffer.session: output.speak(_("No active session available."), True) return @@ -1413,14 +1600,14 @@ class Controller(object): if not current_buffer or not current_buffer.session: output.speak(_("No active session available."), True) return - + session_to_use = current_buffer.session handler = self.get_handler(type=session_to_use.type) if user is None and hasattr(current_buffer, 'get_selected_item_author_details'): author_details = current_buffer.get_selected_item_author_details() if author_details: user = author_details - + if handler and hasattr(handler, 'open_following_timeline'): async def _open_following(): await handler.open_following_timeline(main_controller=self, session=session_to_use, user_payload=user) diff --git a/src/sessionmanager/sessionManager.py b/src/sessionmanager/sessionManager.py index ce0afaa1..32f58e50 100644 --- a/src/sessionmanager/sessionManager.py +++ b/src/sessionmanager/sessionManager.py @@ -121,10 +121,10 @@ class sessionManagerController(object): else: 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 # For ATProtoSocial, this loads from its specific config file. - + # Login is now primarily handled by session.start() via mainController, # which calls _ensure_dependencies_ready(). # Explicit s.login() here might be redundant or premature if full app context isn't ready. @@ -149,9 +149,9 @@ class sessionManagerController(object): # 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)) - + s: sessions.base.baseSession | None = None # Type hint for session object - + if type == "mastodon": s = MastodonSession.Session(location) elif type == "atprotosocial": @@ -159,7 +159,7 @@ class sessionManagerController(object): # Add other session types here if needed (e.g., gotosocial) # elif type == "gotosocial": # s = GotosocialSession.Session(location) - + if not s: log.error(f"Unsupported session type for creation: {type}") self.view.show_unauthorised_error() # Or a more generic "cannot create" error diff --git a/src/sessionmanager/wxUI.py b/src/sessionmanager/wxUI.py index fbecd111..178355f8 100644 --- a/src/sessionmanager/wxUI.py +++ b/src/sessionmanager/wxUI.py @@ -53,10 +53,10 @@ class sessionManagerWindow(wx.Dialog): menu = wx.Menu() mastodon = menu.Append(wx.ID_ANY, _("Mastodon")) menu.Bind(wx.EVT_MENU, self.on_new_mastodon_account, mastodon) - + atprotosocial = menu.Append(wx.ID_ANY, _("ATProtoSocial (Bluesky)")) menu.Bind(wx.EVT_MENU, self.on_new_atprotosocial_account, atprotosocial) - + self.PopupMenu(menu, self.new.GetPosition()) def on_new_mastodon_account(self, *args, **kwargs): diff --git a/src/sessions/atprotosocial/compose.py b/src/sessions/atprotosocial/compose.py index beaddd77..2ef6ee23 100644 --- a/src/sessions/atprotosocial/compose.py +++ b/src/sessions/atprotosocial/compose.py @@ -1,153 +1,245 @@ +# -*- coding: utf-8 -*- from __future__ import annotations import logging from typing import TYPE_CHECKING, Any +from datetime import datetime -fromapprove.translation import translate as _ +from approve.translation import translate as _ +from approve.util import parse_iso_datetime # For parsing ISO timestamps if TYPE_CHECKING: - fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession + from approve.sessions.atprotosocial.session import Session as ATProtoSocialSession + from atproto.xrpc_client import models # For type hinting ATProto models logger = logging.getLogger(__name__) +# For SUPPORTED_LANG_CHOICES in composeDialog.py +SUPPORTED_LANG_CHOICES_COMPOSE = { + _("English"): "en", _("Spanish"): "es", _("French"): "fr", _("German"): "de", + _("Japanese"): "ja", _("Portuguese"): "pt", _("Russian"): "ru", _("Chinese"): "zh", +} + class ATProtoSocialCompose: - # Maximum number of characters allowed in a post on ATProtoSocial (Bluesky uses graphemes, not codepoints) - # Bluesky's limit is 300 graphemes. This might need adjustment based on how Python handles graphemes. - MAX_CHARS = 300 # Defined by app.bsky.feed.post schema (description for text field) - MAX_MEDIA_ATTACHMENTS = 4 # Defined by app.bsky.embed.images schema (maxItems for images array) - MAX_LANGUAGES = 3 # Defined by app.bsky.feed.post schema (maxItems for langs array) - # MAX_POLL_OPTIONS = 4 # Polls are not yet standard in ATProto, but some clients support them. - # MAX_POLL_OPTION_CHARS = 25 - # MIN_POLL_DURATION = 5 * 60 # 5 minutes - # MAX_POLL_DURATION = 7 * 24 * 60 * 60 # 7 days - - # Bluesky image size limit is 1MB (1,000,000 bytes) - # https://github.com/bluesky-social/social-app/blob/main/src/lib/constants.ts#L28 - MAX_IMAGE_SIZE_BYTES = 1_000_000 - + MAX_CHARS = 300 + MAX_MEDIA_ATTACHMENTS = 4 + MAX_LANGUAGES = 3 + MAX_IMAGE_SIZE_BYTES = 1_000_000 def __init__(self, session: ATProtoSocialSession) -> None: self.session = session self.supported_media_types: list[str] = ["image/jpeg", "image/png"] self.max_image_size_bytes: int = self.MAX_IMAGE_SIZE_BYTES - def get_panel_configuration(self) -> dict[str, Any]: """Returns configuration for the compose panel specific to ATProtoSocial.""" return { "max_chars": self.MAX_CHARS, "max_media_attachments": self.MAX_MEDIA_ATTACHMENTS, - "supports_content_warning": True, # Bluesky uses self-labels for content warnings - "supports_scheduled_posts": False, # ATProto/Bluesky does not natively support scheduled posts + "supports_content_warning": True, + "supports_scheduled_posts": False, "supported_media_types": self.supported_media_types, "max_media_size_bytes": self.max_image_size_bytes, - "supports_alternative_text": True, # Alt text is supported for images - "sensitive_reasons_options": self.session.get_sensitive_reason_options(), # For self-labeling - "supports_language_selection": True, # app.bsky.feed.post supports 'langs' field + "supports_alternative_text": True, + "sensitive_reasons_options": self.session.get_sensitive_reason_options(), + "supports_language_selection": True, "max_languages": self.MAX_LANGUAGES, - "supports_quoting": True, # Bluesky supports quoting via app.bsky.embed.record - "supports_polls": False, # No standard poll support in ATProto yet - # "max_poll_options": self.MAX_POLL_OPTIONS, - # "max_poll_option_chars": self.MAX_POLL_OPTION_CHARS, - # "min_poll_duration": self.MIN_POLL_DURATION, - # "max_poll_duration": self.MAX_POLL_DURATION, + "supports_quoting": True, + "supports_polls": False, } async def get_quote_text(self, message_id: str, url: str) -> str | None: - """ - Generates text to be added to the compose box when quoting an ATProtoSocial post. - For Bluesky, the actual quote is an embed. This text is typically appended by the user. - `message_id` here is the AT-URI of the post to be quoted. - `url` is the web URL of the post. - """ - # The actual embedding of a quote is handled in session.send_message by passing quote_uri. - # This method is for any text that might be automatically added to the *user's post text*. - # Often, users just add the link manually, or clients might add "QT: [link]". - # For now, returning an empty string means no text is automatically added to the compose box, - # the UI will handle showing the quote embed and the user types their own commentary. - # Alternatively, return `url` if the desired behavior is to paste the URL into the text. - - # Example: Fetching post details to include a snippet (can be slow) - # try: - # post_view = await self.session.util.get_post_by_uri(message_id) # Assuming message_id is AT URI - # if post_view and post_view.author and post_view.record: - # author_handle = post_view.author.handle - # text_snippet = str(post_view.record.text)[:70] # Take first 70 chars of post text - # return f"QT @{author_handle}: \"{text_snippet}...\"\n{url}\n" - # except Exception as e: - # logger.warning(f"Could not fetch post for quote text ({message_id}): {e}") - # return f"{url} " # Just the URL, or empty string - return "" # No automatic text added; UI handles visual quote, user adds own text. - + return "" async def get_reply_text(self, message_id: str, author_handle: str) -> str | None: - """ - Generates reply text (mention) for a given author handle for ATProtoSocial. - """ - # TODO: Confirm if any specific prefix is needed beyond the mention. - # Bluesky handles mentions with "@handle.example.com" if not author_handle.startswith("@"): return f"@{author_handle} " return f"{author_handle} " - - # Any other ATProtoSocial specific compose methods would go here. - # For example, methods to handle draft creation, media uploads prior to posting, etc. - # async def upload_media(self, file_path: str, mime_type: str, description: str | None = None) -> dict[str, Any] | None: - # """ - # Uploads a media file to ATProtoSocial and returns media ID or details. - # This would use the atproto client's blob upload. - # """ - # # try: - # # # client = self.session.util.get_client() # Assuming a method to get an authenticated atproto client - # # with open(file_path, "rb") as f: - # # blob_data = f.read() - # # # response = await client.com.atproto.repo.upload_blob(blob_data, mime_type=mime_type) - # # # return {"id": response.blob.ref, "url": response.blob.cid, "description": description} # Example structure - # # logger.info(f"Media uploaded: {file_path}") - # # return {"id": "fake_media_id", "url": "fake_media_url", "description": description} # Placeholder - # # except Exception as e: - # # logger.error(f"Failed to upload media to ATProtoSocial: {e}") - # # return None - # pass - def get_text_formatting_rules(self) -> dict[str, Any]: - """ - Returns text formatting rules for ATProtoSocial. - Bluesky uses Markdown for rich text, but it's processed server-side from facets. - Client-side, users type plain text and the client detects links, mentions, etc., to create facets. - """ return { - "markdown_enabled": False, # Users type plain text; facets are for rich text features - "custom_emojis_enabled": False, # ATProto doesn't have custom emojis like Mastodon + "markdown_enabled": False, + "custom_emojis_enabled": False, "max_length": self.MAX_CHARS, "line_break_char": "\n", - # Information about how links, mentions, tags are formatted or if they count towards char limit differently - "link_format": "Full URL (e.g., https://example.com)", # Links are typically full URLs + "link_format": "Full URL (e.g., https://example.com)", "mention_format": "@handle.bsky.social", - "tag_format": "#tag (becomes a facet link)", # Hashtags are detected and become links + "tag_format": "#tag (becomes a facet link)", } def is_media_type_supported(self, mime_type: str) -> bool: - """Checks if a given MIME type is supported for upload.""" - # TODO: Use actual supported types from `self.supported_media_types` return mime_type.lower() in self.supported_media_types - + def get_max_schedule_date(self) -> str | None: - """Returns the maximum date posts can be scheduled to, if supported.""" - # ATProtoSocial does not natively support scheduled posts. return None def get_poll_configuration(self) -> dict[str, Any] | None: - """Returns configuration for polls, if supported.""" - # Polls are not a standard part of ATProto yet. - # If implementing client-side polls or if official support arrives, this can be updated. - # return { - # "max_options": self.MAX_POLL_OPTIONS, - # "max_option_chars": self.MAX_POLL_OPTION_CHARS, - # "min_duration_seconds": self.MIN_POLL_DURATION, - # "max_duration_seconds": self.MAX_POLL_DURATION, - # "default_duration_seconds": 24 * 60 * 60, # 1 day - # } return None + + def compose_post_for_display(self, post_data: dict[str, Any], session_settings: dict[str, Any] | None = None) -> str: + """ + Composes a string representation of a Bluesky post for display in UI timelines. + """ + if not post_data or not isinstance(post_data, dict): + return _("Invalid post data.") + + author_info = post_data.get("author", {}) + record = post_data.get("record", {}) + embed_data = post_data.get("embed") + viewer_state = post_data.get("viewer", {}) + + display_name = author_info.get("displayName", "") or author_info.get("handle", _("Unknown User")) + handle = author_info.get("handle", _("unknown.handle")) + + post_text = getattr(record, 'text', '') if not isinstance(record, dict) else record.get('text', '') + + created_at_str = getattr(record, 'createdAt', '') if not isinstance(record, dict) else record.get('createdAt', '') + timestamp_str = "" + if created_at_str: + try: + dt_obj = parse_iso_datetime(created_at_str) + timestamp_str = dt_obj.strftime("%I:%M %p - %b %d, %Y") if dt_obj else created_at_str + except Exception as e: + logger.debug(f"Could not parse timestamp {created_at_str}: {e}") + timestamp_str = created_at_str + + header = f"{display_name} (@{handle}) - {timestamp_str}" + + labels = post_data.get("labels", []) + spoiler_text = None + is_sensitive_post = False + if labels: + for label_obj in labels: + label_val = getattr(label_obj, 'val', '') if not isinstance(label_obj, dict) else label_obj.get('val', '') + if label_val == "!warn": + is_sensitive_post = True + elif label_val in ["porn", "sexual", "nudity", "gore", "graphic-media", "corpse", "self-harm", "hate", "spam", "impersonation"]: + is_sensitive_post = True + if not spoiler_text: spoiler_text = _("Sensitive Content: {label}").format(label=label_val) + elif label_val.startswith("warn:") and len(label_val) > 5: + spoiler_text = label_val.split("warn:", 1)[-1].strip() + is_sensitive_post = True + + post_text_display = post_text + if spoiler_text: + post_text_display = f"CW: {spoiler_text}\n\n{post_text}" + elif is_sensitive_post and not spoiler_text: + post_text_display = f"CW: {_('Sensitive Content')}\n\n{post_text}" + + embed_display = "" + if embed_data: + embed_type = getattr(embed_data, '$type', '') + if not embed_type and isinstance(embed_data, dict): embed_type = embed_data.get('$type', '') + + if embed_type in ['app.bsky.embed.images#view', 'app.bsky.embed.images']: + images = getattr(embed_data, 'images', []) if hasattr(embed_data, 'images') else embed_data.get('images', []) + if images: + img_count = len(images) + alt_texts_present = any(getattr(img, 'alt', '') for img in images if hasattr(img, 'alt')) or \ + any(img_dict.get('alt', '') for img_dict in images if isinstance(img_dict, dict)) + embed_display += f"\n[{img_count} Image" + if img_count > 1: embed_display += "s" + if alt_texts_present: embed_display += _(" (Alt text available)") + embed_display += "]" + + elif embed_type in ['app.bsky.embed.record#view', 'app.bsky.embed.record']: + record_embed_data = getattr(embed_data, 'record', None) if hasattr(embed_data, 'record') else embed_data.get('record', None) + record_embed_type = getattr(record_embed_data, '$type', '') + if not record_embed_type and isinstance(record_embed_data, dict): record_embed_type = record_embed_data.get('$type', '') + + if record_embed_type == 'app.bsky.embed.record#viewNotFound': + embed_display += f"\n[{_('Quoted post not found or unavailable')}]" + elif record_embed_type == 'app.bsky.embed.record#viewBlocked': + embed_display += f"\n[{_('Content from the quoted account is blocked')}]" + elif record_embed_data and (isinstance(record_embed_data, dict) or hasattr(record_embed_data, 'author')): + quote_author_info = getattr(record_embed_data, 'author', record_embed_data.get('author')) + quote_value = getattr(record_embed_data, 'value', record_embed_data.get('value')) + + if quote_author_info and quote_value: + quote_author_handle = getattr(quote_author_info, 'handle', 'unknown') + quote_text_content = getattr(quote_value, 'text', '') if not isinstance(quote_value, dict) else quote_value.get('text', '') + quote_text_snippet = (quote_text_content[:75] + "...") if quote_text_content else _("post content") + embed_display += f"\n[ {_('Quote by')} @{quote_author_handle}: \"{quote_text_snippet}\" ]" + else: + embed_display += f"\n[{_('Quoted Post')}]" + + elif embed_type in ['app.bsky.embed.external#view', 'app.bsky.embed.external']: + external_data = getattr(embed_data, 'external', None) if hasattr(embed_data, 'external') else embed_data.get('external', None) + if external_data: + ext_uri = getattr(external_data, 'uri', _('External Link')) + ext_title = getattr(external_data, 'title', '') or ext_uri + embed_display += f"\n[{_('Link')}: {ext_title}]" + + reply_context_str = "" + actual_record = post_data.get("record", {}) + reply_ref = getattr(actual_record, 'reply', None) if not isinstance(actual_record, dict) else actual_record.get('reply') + + if reply_ref: + reply_context_str = f"[{_('In reply to a post')}] " + + counts_str_parts = [] + reply_count = post_data.get("replyCount", 0) + repost_count = post_data.get("repostCount", 0) + like_count = post_data.get("likeCount", 0) + + if reply_count > 0: counts_str_parts.append(f"{_('Replies')}: {reply_count}") + if repost_count > 0: counts_str_parts.append(f"{_('Reposts')}: {repost_count}") + if like_count > 0: counts_str_parts.append(f"{_('Likes')}: {like_count}") + + viewer_liked_uri = viewer_state.get("like") if isinstance(viewer_state, dict) else getattr(viewer_state, 'like', None) + viewer_reposted_uri = viewer_state.get("repost") if isinstance(viewer_state, dict) else getattr(viewer_state, 'repost', None) + + if viewer_liked_uri: counts_str_parts.append(f"({_('Liked by you')})") + if viewer_reposted_uri: counts_str_parts.append(f"({_('Reposted by you')})") + + counts_line = "" + if counts_str_parts: + counts_line = "\n" + " | ".join(counts_str_parts) + + full_display = f"{header}\n{reply_context_str}{post_text_display}{embed_display}{counts_line}" + return full_display.strip() + + def compose_notification_for_display(self, notif_data: dict[str, Any]) -> str: + """ + Composes a string representation of a Bluesky notification for display. + + Args: + notif_data: A dictionary representing the notification, + typically from ATProtoSocialSession._handle_*_notification methods + which create an approve.notifications.Notification object and then + convert it to dict or pass relevant parts. + Expected keys: 'title', 'body', 'author_name', 'timestamp_dt', 'kind'. + The 'title' usually already contains the core action. + Returns: + A formatted string for display. + """ + if not notif_data or not isinstance(notif_data, dict): + return _("Invalid notification data.") + + title = notif_data.get('title', _("Notification")) + body = notif_data.get('body', '') + author_name = notif_data.get('author_name') # Author of the action (e.g. who liked) + timestamp_dt = notif_data.get('timestamp_dt') # datetime object + + timestamp_str = "" + if timestamp_dt and isinstance(timestamp_dt, datetime): + try: + timestamp_str = timestamp_dt.strftime("%I:%M %p - %b %d, %Y") + except Exception as e: + logger.debug(f"Could not format notification timestamp {timestamp_dt}: {e}") + timestamp_str = str(timestamp_dt) + + display_parts = [] + if timestamp_str: + display_parts.append(f"[{timestamp_str}]") + + # Title already contains good info like "UserX liked your post" + display_parts.append(title) + + if body: # Body might be text of a reply/mention/quote + # Truncate body if too long for a list display + body_snippet = (body[:100] + "...") if len(body) > 103 else body + display_parts.append(f"\"{body_snippet}\"") + + return " ".join(display_parts).strip() diff --git a/src/sessions/atprotosocial/session.py b/src/sessions/atprotosocial/session.py index 476ca516..84520a3f 100644 --- a/src/sessions/atprotosocial/session.py +++ b/src/sessions/atprotosocial/session.py @@ -47,7 +47,7 @@ class Session(baseSession): _streaming_manager: ATProtoSocialStreaming | None = None _templates: ATProtoSocialTemplates | None = None _util: ATProtoSocialUtils | None = None - + # Define ConfigurableValues for ATProtoSocial handle = ConfigurableValue("handle", "") app_password = ConfigurableValue("app_password", "", is_secret=True) # Mark as secret @@ -58,7 +58,7 @@ class Session(baseSession): 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 @@ -77,7 +77,7 @@ class Session(baseSession): 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 @@ -88,7 +88,7 @@ class Session(baseSession): 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 @@ -118,7 +118,7 @@ class Session(baseSession): """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. @@ -127,7 +127,7 @@ class Session(baseSession): # 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. @@ -200,7 +200,7 @@ class Session(baseSession): 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: @@ -241,7 +241,7 @@ class Session(baseSession): @property def can_stream(self) -> bool: return self.CAN_STREAM - + @property def util(self) -> ATProtoSocialUtils: if not self._util: @@ -270,7 +270,7 @@ class Session(baseSession): 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: @@ -278,7 +278,7 @@ class Session(baseSession): 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) @@ -316,7 +316,7 @@ class Session(baseSession): ) 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' @@ -345,7 +345,7 @@ class Session(baseSession): 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) @@ -372,7 +372,7 @@ class Session(baseSession): 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.") @@ -392,7 +392,7 @@ class Session(baseSession): 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 @@ -424,7 +424,7 @@ class Session(baseSession): # 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}" @@ -546,10 +546,10 @@ class Session(baseSession): # '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 @@ -672,10 +672,10 @@ class Session(baseSession): 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: @@ -730,8 +730,8 @@ class Session(baseSession): 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, + message_id=repost_uri, + original_message_id=subject_uri, ) async def _handle_follow_notification(self, notification_item: utils.ATNotification) -> None: @@ -786,7 +786,7 @@ class Session(baseSession): 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. @@ -821,7 +821,7 @@ class Session(baseSession): 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 @@ -877,7 +877,7 @@ class Session(baseSession): 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. @@ -901,9 +901,9 @@ class Session(baseSession): 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, @@ -930,7 +930,7 @@ class Session(baseSession): 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 @@ -975,21 +975,21 @@ class Session(baseSession): 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, + items=feed_view_posts, + new_only=new_only, buffer_name="home_timeline_buffer" ) - + 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 @@ -1016,13 +1016,13 @@ class Session(baseSession): if not feed_data: logger.info(f"No feed data returned for user {user_did}.") return [], cursor - + 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, + 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 ) @@ -1043,7 +1043,7 @@ class Session(baseSession): 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. @@ -1057,15 +1057,15 @@ class Session(baseSession): 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: @@ -1080,7 +1080,7 @@ class Session(baseSession): # 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) @@ -1096,7 +1096,7 @@ class Session(baseSession): 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 @@ -1129,13 +1129,13 @@ class Session(baseSession): # 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. @@ -1148,6 +1148,39 @@ class Session(baseSession): 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 diff --git a/src/sessions/atprotosocial/streaming.py b/src/sessions/atprotosocial/streaming.py index cdd51f41..5237cb74 100644 --- a/src/sessions/atprotosocial/streaming.py +++ b/src/sessions/atprotosocial/streaming.py @@ -81,7 +81,7 @@ class ATProtoSocialStreaming: # await self._firehose_client.start(on_message_handler) - + # Placeholder loop to simulate receiving events while not self._should_stop: await asyncio.sleep(1) @@ -174,7 +174,7 @@ class ATProtoSocialStreaming: # For Bluesky Firehose, this might not be applicable as you usually connect and filter client-side. # However, if there were different Firehose endpoints (e.g., one for public posts, one for user-specific events), # this class might manage multiple connections or re-establish with new parameters. - + # Example of how events might be processed (highly simplified): # This would be called by the on_message_handler in _connect # async def _process_firehose_message(self, message: models.ComAtprotoSyncSubscribeRepos.Message): diff --git a/src/sessions/atprotosocial/utils.py b/src/sessions/atprotosocial/utils.py index 290efb58..f3abce6f 100644 --- a/src/sessions/atprotosocial/utils.py +++ b/src/sessions/atprotosocial/utils.py @@ -36,7 +36,7 @@ class ATProtoSocialUtils: self._own_handle: str | None = self.session.db.get("handle") or self.session.config_get("handle") # --- Client Initialization and Management --- - + async def _get_client(self) -> AsyncClient | None: """Returns the authenticated ATProto AsyncClient from the session.""" if self.session.client and self.session.is_ready(): # is_ready checks if client is authenticated @@ -46,7 +46,7 @@ class ATProtoSocialUtils: if not self._own_handle and self.session.client.me: self._own_handle = self.session.client.me.handle return self.session.client - + logger.warning("ATProtoSocialUtils: Client not available or not authenticated.") # Optionally, try to trigger re-authentication if appropriate, # but generally, the caller should ensure session is ready. @@ -85,7 +85,7 @@ class ATProtoSocialUtils: """Returns the authenticated user's DID.""" if not self._own_did: # If not set during init (e.g. session not fully loaded yet) self._own_did = self.session.db.get("did") or self.session.config_get("did") - + # Fallback: try to get from client if it's alive and has .me property if not self._own_did and self.session.client and self.session.client.me: self._own_did = self.session.client.me.did @@ -131,10 +131,10 @@ class ATProtoSocialUtils: try: # Prepare core post record post_record_data = {'text': text, 'created_at': client.get_current_time_iso()} # SDK handles datetime format - + if langs: post_record_data['langs'] = langs - + # Facets (mentions, links, tags) should be processed before other embeds # as they are part of the main post record. facets = await self._extract_facets(text, tags) # Pass client for potential resolutions @@ -146,7 +146,7 @@ class ATProtoSocialUtils: embed_to_add: models.AppBskyFeedPost.Embed | None = None # Embeds: images, quote posts, external links - # ATProto allows one main embed type: app.bsky.embed.images, app.bsky.embed.record (quote/post embed), + # ATProto allows one main embed type: app.bsky.embed.images, app.bsky.embed.record (quote/post embed), # or app.bsky.embed.external. # Priority: 1. Quote, 2. Images. External embeds are not handled in this example. # If both quote and images are provided, quote takes precedence. @@ -162,12 +162,12 @@ class ATProtoSocialUtils: logger.warning(f"Quote URI provided ({quote_uri}), images will be ignored due to embed priority.") else: logger.warning(f"Could not create strong reference for quote URI: {quote_uri}. Quote will be omitted.") - + # Handle media attachments (images) only if no quote embed was successfully created if not embed_to_add and media_ids: logger.info(f"Attempting to add image embed with {len(media_ids)} media items.") images_for_embed = [] - for media_info in media_ids: + for media_info in media_ids: if isinstance(media_info, dict) and media_info.get("blob_ref"): images_for_embed.append( models.AppBskyEmbedImages.Image( @@ -177,7 +177,7 @@ class ATProtoSocialUtils: ) if images_for_embed: embed_to_add = models.AppBskyEmbedImages.Main(images=images_for_embed) - + if embed_to_add: post_record_data['embed'] = embed_to_add @@ -213,10 +213,10 @@ class ATProtoSocialUtils: # post_labels.append(models.ComAtprotoLabelDefs.SelfLabel(val=cw_text)) # if cw_text is like "nudity" if is_sensitive and not any(l.val == "!warn" for l in post_labels): # Add generic !warn if sensitive and not already added by cw_text post_labels.append(models.ComAtprotoLabelDefs.SelfLabel(val="!warn")) - + if post_labels: post_record_data['labels'] = models.ComAtprotoLabelDefs.SelfLabels(values=post_labels) - + # Create the post record object final_post_record = models.AppBskyFeedPost.Main(**post_record_data) @@ -228,7 +228,7 @@ class ATProtoSocialUtils: ) ) logger.info(f"Successfully posted to ATProtoSocial. URI: {response.uri}") - return response.uri + return response.uri except AtProtocolError as e: logger.error(f"Error posting status to ATProtoSocial: {e.error} - {e.message}", exc_info=True) raise NotificationError(_("Failed to post: {error} - {message}").format(error=e.error or "Error", message=e.message or "Protocol error")) from e @@ -246,7 +246,7 @@ class ATProtoSocialUtils: if not self.get_own_did(): logger.error("Cannot delete status: User DID not available.") return False - + try: # Extract rkey from URI. URI format: at://// uri_parts = post_uri.replace("at://", "").split("/") @@ -289,9 +289,9 @@ class ATProtoSocialUtils: try: with open(file_path, "rb") as f: image_data = f.read() - + # The SDK's upload_blob takes bytes directly. - response = await client.com.atproto.repo.upload_blob(image_data, mime_type=mime_type) + response = await client.com.atproto.repo.upload_blob(image_data, mime_type=mime_type) if response and response.blob: logger.info(f"Media uploaded successfully: {file_path}, Blob CID: {response.blob.cid}") # Return the actual blob object from the SDK, as it's needed for post creation. @@ -335,7 +335,7 @@ class ATProtoSocialUtils: if not self.get_own_did(): logger.error("Cannot follow user: Own DID not available.") return False - + try: await client.com.atproto.repo.create_record( models.ComAtprotoRepoCreateRecord.Input( @@ -368,7 +368,7 @@ class ATProtoSocialUtils: if not follow_rkey: logger.warning(f"Could not find follow record for user {user_did} to unfollow.") return False - + await client.com.atproto.repo.delete_record( models.ComAtprotoRepoDeleteRecord.Input( repo=self.get_own_did(), @@ -384,7 +384,7 @@ class ATProtoSocialUtils: logger.error(f"Unexpected error unfollowing user {user_did}: {e}", exc_info=True) return False - + # --- Notifications and Timelines (Illustrative - actual implementation is complex) --- async def get_notifications(self, limit: int = 20, cursor: str | None = None) -> tuple[list[ATNotification], str | None] | None: @@ -417,7 +417,7 @@ class ATProtoSocialUtils: params = models.AppBskyFeedGetTimeline.Params(limit=limit, cursor=cursor) if algorithm: # Only add algorithm if it's specified, SDK might default to 'following' params.algorithm = algorithm - + response = await client.app.bsky.feed.get_timeline(params) # response.feed is a list of FeedViewPost items return response.feed, response.cursor @@ -451,7 +451,7 @@ class ATProtoSocialUtils: # Actually, getAuthorFeed's `filter` param does not directly control inclusion of reposts in a way that # "posts_and_reposts" would imply. Reposts by the author of things *they reposted* are part of their feed. # A common default is 'posts_with_replies'. If we want to see their reposts, that's typically included by default. - + current_filter_value = filter if current_filter_value not in ['posts_with_replies', 'posts_no_replies', 'posts_and_author_threads', 'posts_with_media']: logger.warning(f"Invalid filter '{current_filter_value}' for getAuthorFeed. Defaulting to 'posts_with_replies'.") @@ -546,7 +546,7 @@ class ATProtoSocialUtils: async def mute_user(self, user_did: str) -> bool: """Mutes a user by their DID.""" client = await self._get_client() - if not client: + if not client: logger.error("Cannot mute user: ATProto client not available.") return False try: @@ -564,7 +564,7 @@ class ATProtoSocialUtils: async def unmute_user(self, user_did: str) -> bool: """Unmutes a user by their DID.""" client = await self._get_client() - if not client: + if not client: logger.error("Cannot unmute user: ATProto client not available.") return False try: @@ -584,13 +584,13 @@ class ATProtoSocialUtils: Returns the AT URI of the block record on success, None on failure. """ client = await self._get_client() - if not client: + if not client: logger.error("Cannot block user: ATProto client not available.") return None if not self.get_own_did(): logger.error("Cannot block user: Own DID not available.") return None - + try: response = await client.com.atproto.repo.create_record( models.ComAtprotoRepoCreateRecord.Input( @@ -614,31 +614,31 @@ class ATProtoSocialUtils: client = await self._get_client() own_did = self.get_own_did() if not client or not own_did: return None - + cursor = None try: while True: response = await client.com.atproto.repo.list_records( models.ComAtprotoRepoListRecords.Params( repo=own_did, - collection=ids.AppBskyGraphBlock, - limit=100, + collection=ids.AppBskyGraphBlock, + limit=100, cursor=cursor, ) ) if not response or not response.records: - break - + break + for record_item in response.records: if record_item.value and isinstance(record_item.value, models.AppBskyGraphBlock.Main): if record_item.value.subject == target_did: return record_item.uri.split("/")[-1] # Extract rkey from URI - + cursor = response.cursor if not cursor: break logger.info(f"No active block record found for user {target_did} by {own_did}.") - return None + return None except AtProtocolError as e: logger.error(f"Error listing block records for {own_did} to find {target_did}: {e.error} - {e.message}") return None @@ -652,7 +652,7 @@ class ATProtoSocialUtils: if not client or not self.get_own_did(): logger.error("Cannot repost: client or own DID not available.") return None - + if not post_cid: # If CID is not provided, try to get it strong_ref = await self._get_strong_ref_for_uri(post_uri) if not strong_ref: @@ -723,7 +723,7 @@ class ATProtoSocialUtils: if not client or not self.get_own_did(): logger.error("Cannot delete like: client or own DID not available.") return False - + try: # Extract rkey from like_uri # Format: at:///app.bsky.feed.like/ @@ -731,9 +731,9 @@ class ATProtoSocialUtils: if len(uri_parts) != 3 or uri_parts[1] != ids.AppBskyFeedLike: logger.error(f"Invalid like URI format for deletion: {like_uri}") return False - + rkey = uri_parts[2] - + await client.com.atproto.repo.delete_record( models.ComAtprotoRepoDeleteRecord.Input( repo=self.get_own_did(), @@ -757,7 +757,7 @@ class ATProtoSocialUtils: logger.error("Cannot repost: client or own DID not available.") # raise NotificationError(_("Session not ready. Please log in.")) # Alternative return None - + if not post_cid: # If CID is not provided, try to get it from the URI strong_ref_to_post = await self._get_strong_ref_for_uri(post_uri) if not strong_ref_to_post: @@ -831,7 +831,7 @@ class ATProtoSocialUtils: if not client or not self.get_own_did(): logger.error("Cannot delete like: client or own DID not available.") return False - + try: # Extract rkey from like_uri # Format: at:///app.bsky.feed.like/ @@ -839,14 +839,14 @@ class ATProtoSocialUtils: if len(uri_parts) != 3 or uri_parts[1] != ids.AppBskyFeedLike: # Check collection is correct logger.error(f"Invalid like URI format for deletion: {like_uri}") return False # Or raise error - + # own_did_from_uri = uri_parts[0] # This should match self.get_own_did() # if own_did_from_uri != self.get_own_did(): # logger.error(f"Attempting to delete a like not owned by the current user: {like_uri}") # return False rkey = uri_parts[2] - + await client.com.atproto.repo.delete_record( models.ComAtprotoRepoDeleteRecord.Input( repo=self.get_own_did(), # Must be own DID @@ -870,7 +870,7 @@ class ATProtoSocialUtils: async def unblock_user(self, user_did: str) -> bool: """Unblocks a user by their DID. Requires finding the block record's rkey.""" client = await self._get_client() - if not client: + if not client: logger.error("Cannot unblock user: ATProto client not available.") return False if not self.get_own_did(): @@ -882,8 +882,8 @@ class ATProtoSocialUtils: if not block_rkey: logger.warning(f"Could not find block record for user {user_did} to unblock. User might not be blocked.") # Depending on desired UX, this could be True (idempotency) or False (strict "not found") - return False - + return False + await client.com.atproto.repo.delete_record( models.ComAtprotoRepoDeleteRecord.Input( repo=self.get_own_did(), @@ -899,7 +899,7 @@ class ATProtoSocialUtils: logger.error(f"Unexpected error unblocking user {user_did}: {e}", exc_info=True) return False - + # --- Helper Methods for Formatting and URI/DID manipulation --- def _format_profile_data(self, profile_model: models.AppBskyActorDefs.ProfileViewDetailed | models.AppBskyActorDefs.ProfileView | models.AppBskyActorDefs.ProfileViewBasic) -> dict[str, Any]: @@ -929,7 +929,7 @@ class ATProtoSocialUtils: # text_content = "Unsupported post record type" # else: # text_content = record_data.text - + return { "uri": post_view_model.uri, "cid": post_view_model.cid, @@ -973,9 +973,9 @@ class ATProtoSocialUtils: if len(parts) != 3: logger.error(f"Invalid AT URI for strong ref: {at_uri}") return None - + repo_did, collection, rkey = parts - + # This is one way to get the CID if not already known. # If the CID is known, models.ComAtprotoRepoStrongRef.Main(uri=at_uri, cid=known_cid) is simpler. # However, for replies/quotes, the record must exist and be resolvable. @@ -1000,7 +1000,7 @@ class ATProtoSocialUtils: client = await self._get_client() own_did = self.get_own_did() if not client or not own_did: return None - + cursor = None try: while True: @@ -1008,20 +1008,20 @@ class ATProtoSocialUtils: models.ComAtprotoRepoListRecords.Params( repo=own_did, collection=ids.AppBskyGraphFollow, # "app.bsky.graph.follow" - limit=100, + limit=100, cursor=cursor, ) ) if not response or not response.records: - break - + break + for record_item in response.records: # record_item.value is the actual follow record (AppBskyGraphFollow.Main) if record_item.value and isinstance(record_item.value, models.AppBskyGraphFollow.Main): if record_item.value.subject == target_did: # The rkey is part of the URI: at:///app.bsky.graph.follow/ return record_item.uri.split("/")[-1] - + cursor = response.cursor if not cursor: break @@ -1050,7 +1050,7 @@ class ATProtoSocialUtils: # For now, assume a simplified version or that client might expose it. # A full implementation needs to handle byte offsets correctly. # This is a complex part of posting. - + # Placeholder for actual facet detection logic. # This would involve regex for mentions (@handle.bsky.social), links (http://...), and tags (#tag). # For mentions, DIDs need to be resolved. For links, URI needs to be validated. @@ -1076,7 +1076,7 @@ class ATProtoSocialUtils: # for tag in tags: # # find occurrences of #tag in text and add facet # pass - + # If the SDK has a robust way to do this (even if it's a static method you import) use it. # e.g. from atproto. अमीर_text import RichText # rt = RichText(text) @@ -1100,7 +1100,7 @@ class ATProtoSocialUtils: if not client: logger.error("ATProtoSocial client not available for reporting.") return False - + try: # We need a strong reference to the post being reported. subject_strong_ref = await self._get_strong_ref_for_uri(post_uri) @@ -1110,14 +1110,14 @@ class ATProtoSocialUtils: # The 'subject' for reporting a record is ComAtprotoRepoStrongRef.Main report_subject = models.ComAtprotoRepoStrongRef.Main(uri=subject_strong_ref.uri, cid=subject_strong_ref.cid) - + # For reporting an account, it would be ComAtprotoAdminDefs.RepoRef(did=...) await client.com.atproto.moderation.create_report( models.ComAtprotoModerationCreateReport.Input( reasonType=reason_type, # e.g. lexicon_models.COM_ATPROTO_MODERATION_DEFS_REASONSPAM reason=reason_text if reason_text else None, - subject=report_subject + subject=report_subject ) ) logger.info(f"Successfully reported post {post_uri} for reason {reason_type}.") @@ -1138,7 +1138,7 @@ class ATProtoSocialUtils: my_did = self.get_own_did() if not my_did: return False - + facets_to_check = None if isinstance(post_data, models.AppBskyFeedPost.Main): facets_to_check = post_data.facets @@ -1151,7 +1151,7 @@ class ATProtoSocialUtils: if not facets_to_check: return False - + for facet_item_model in facets_to_check: # Ensure facet_item_model is the correct SDK model type if it came from dict if isinstance(facet_item_model, models.AppBskyRichtextFacet.Main): diff --git a/src/test/sessions/atprotosocial/__init__.py b/src/test/sessions/atprotosocial/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/test/sessions/atprotosocial/test_atprotosocial_session.py b/src/test/sessions/atprotosocial/test_atprotosocial_session.py new file mode 100644 index 00000000..e3c9b2e3 --- /dev/null +++ b/src/test/sessions/atprotosocial/test_atprotosocial_session.py @@ -0,0 +1,363 @@ +# -*- coding: utf-8 -*- +import sys +import unittest +from unittest.mock import patch, AsyncMock, MagicMock, PropertyMock + +# Assuming paths are set up correctly for test environment to find these +from sessions.atprotosocial.session import Session as ATProtoSocialSession +from sessions.session_exceptions import SessionLoginError, SessionError +from approve.notifications import NotificationError # Assuming this is the correct import path +from atproto.xrpc_client.models.common import XrpcError +from atproto.xrpc_client import models as atp_models # For ATProto models +from atproto.xrpc_client.models import ids # For lexicon IDs + +# Mock wx for headless testing +class MockWxDialog: + def __init__(self, parent, message, caption, value="", style=0): + self.message = message + self.caption = caption + self.value = value + self.return_code = mock_wx.ID_CANCEL # Default to cancel, specific tests can change this + + def ShowModal(self): + return self.return_code + + def GetValue(self): + return self.value + + def Destroy(self): + pass + +class MockWxMessageBox(MockWxDialog): + pass + +# Need to mock wx before it's imported by other modules if they do it at import time. +# Patching directly where used in session.py is generally safer. +mock_wx = MagicMock() +mock_wx.TextEntryDialog = MockWxDialog +mock_wx.PasswordEntryDialog = MockWxDialog +mock_wx.MessageBox = MockWxMessageBox +mock_wx.ID_OK = 1 +mock_wx.ID_CANCEL = 2 +mock_wx.ICON_ERROR = 16 +mock_wx.ICON_INFORMATION = 64 +mock_wx.OK = 4 +mock_wx.YES_NO = 1 # Example, actual value might differ but not critical for test logic +mock_wx.YES = 1 # Example +mock_wx.ICON_QUESTION = 32 # Example + +# Mock config objects +# This structure tries to mimic how config is accessed in session.py +# e.g., config.sessions.atprotosocial[user_id].handle +class MockConfigNode: + def __init__(self, initial_value=None): + self._value = initial_value + self.get = MagicMock(return_value=self._value) + self.set = AsyncMock() # .set() is async + +class MockUserSessionConfig: + def __init__(self): + self.handle = MockConfigNode("") + self.app_password = MockConfigNode("") + self.did = MockConfigNode("") + # Add other config values if session.py uses them for atprotosocial + +class MockATProtoSocialConfig: + def __init__(self): + self._user_configs = {"test_user": MockUserSessionConfig()} + def __getitem__(self, key): + return self._user_configs.get(key, MagicMock(return_value=MockUserSessionConfig())) # Return a mock if key not found + +class MockSessionsConfig: + def __init__(self): + self.atprotosocial = MockATProtoSocialConfig() + +mock_config_global = MagicMock() +mock_config_global.sessions = MockSessionsConfig() + + +class TestATProtoSocialSession(unittest.IsolatedAsyncioTestCase): + + @patch('sessions.atprotosocial.session.wx', mock_wx) + @patch('sessions.atprotosocial.session.config', mock_config_global) + def setUp(self): + self.mock_approval_api = MagicMock() + + # Reset mocks for user_config part of global mock_config_global for each test + self.mock_user_config_instance = MockUserSessionConfig() + mock_config_global.sessions.atprotosocial.__getitem__.return_value = self.mock_user_config_instance + + self.session = ATProtoSocialSession(approval_api=self.mock_approval_api, user_id="test_user", channel_id="test_channel") + + self.session.db = {} + self.session.save_db = AsyncMock() + self.session.notify_session_ready = AsyncMock() + self.session.send_text_notification = MagicMock() + + # Mock the util property to return a MagicMock for ATProtoSocialUtils + self.mock_util_instance = AsyncMock() # Make it an AsyncMock if its methods are async + self.mock_util_instance._own_did = None # These are set directly by session.login + self.mock_util_instance._own_handle = None + # Add any methods from util that are directly called by session methods being tested + # e.g., self.mock_util_instance.get_own_did = MagicMock(return_value="did:plc:test") + self.session._util = self.mock_util_instance + self.session.util # Call property to ensure _util is set if it's lazy loaded + + def test_session_initialization(self): + self.assertIsInstance(self.session, ATProtoSocialSession) + self.assertEqual(self.session.KIND, "atprotosocial") + self.assertIsNone(self.session.client) + self.assertEqual(self.session.user_id, "test_user") + + @patch('sessions.atprotosocial.session.AsyncClient') + async def test_login_successful(self, MockAsyncClient): + mock_client_instance = MockAsyncClient.return_value + # Use actual ATProto models for spec if possible for better type checking in mocks + mock_profile = MagicMock(spec=atp_models.ComAtprotoServerDefs.Session) + mock_profile.access_jwt = "fake_access_jwt" + mock_profile.refresh_jwt = "fake_refresh_jwt" + mock_profile.did = "did:plc:testdid" + mock_profile.handle = "testhandle.bsky.social" + mock_client_instance.login = AsyncMock(return_value=mock_profile) + + self.session.config_get = MagicMock(return_value=None) # Simulate no pre-existing config + + result = await self.session.login("testhandle.bsky.social", "test_password") + + self.assertTrue(result) + self.assertIsNotNone(self.session.client) + mock_client_instance.login.assert_called_once_with("testhandle.bsky.social", "test_password") + + self.assertEqual(self.session.db.get("access_jwt"), "fake_access_jwt") + self.assertEqual(self.session.db.get("did"), "did:plc:testdid") + self.assertEqual(self.session.db.get("handle"), "testhandle.bsky.social") + self.session.save_db.assert_called_once() + + self.mock_user_config_instance.handle.set.assert_called_once_with("testhandle.bsky.social") + self.mock_user_config_instance.app_password.set.assert_called_once_with("test_password") + self.mock_user_config_instance.did.set.assert_called_once_with("did:plc:testdid") + + self.assertEqual(self.session._util._own_did, "did:plc:testdid") + self.assertEqual(self.session._util._own_handle, "testhandle.bsky.social") + + self.session.notify_session_ready.assert_called_once() + + @patch('sessions.atprotosocial.session.AsyncClient') + async def test_login_failure_xrpc(self, MockAsyncClient): + mock_client_instance = MockAsyncClient.return_value + mock_client_instance.login = AsyncMock(side_effect=XrpcError(error="AuthenticationFailed", message="Invalid credentials")) + self.session.config_get = MagicMock(return_value=None) + + with self.assertRaises(NotificationError) as ctx: + await self.session.login("testhandle.bsky.social", "wrong_password") + + self.assertTrue("Invalid handle or app password." in str(ctx.exception) or "Invalid credentials" in str(ctx.exception)) + self.assertIsNone(self.session.client) + self.session.notify_session_ready.assert_not_called() + + @patch('sessions.atprotosocial.session.wx', new=mock_wx) + @patch.object(ATProtoSocialSession, 'login', new_callable=AsyncMock) + async def test_authorise_successful(self, mock_login_method): + mock_login_method.return_value = True + + mock_wx.TextEntryDialog.return_value.GetValue = MagicMock(return_value="test_handle") + mock_wx.TextEntryDialog.return_value.ShowModal = MagicMock(return_value=mock_wx.ID_OK) + mock_wx.PasswordEntryDialog.return_value.GetValue = MagicMock(return_value="password_ok") + mock_wx.PasswordEntryDialog.return_value.ShowModal = MagicMock(return_value=mock_wx.ID_OK) + + self.session.config_get = MagicMock(return_value="prefill_handle") # For pre-filling handle dialog + + result = await self.session.authorise() + + self.assertTrue(result) + mock_login_method.assert_called_once_with("test_handle", "password_ok") + # Further check if wx.MessageBox was called with success + # This requires more complex mocking or inspection of calls to mock_wx.MessageBox + + @patch('sessions.atprotosocial.session.wx', new=mock_wx) + @patch.object(ATProtoSocialSession, 'login', new_callable=AsyncMock) + async def test_authorise_login_fails_with_notification_error(self, mock_login_method): + mock_login_method.side_effect = NotificationError("Specific login failure from mock.") + + mock_wx.TextEntryDialog.return_value.GetValue = MagicMock(return_value="test_handle") + mock_wx.TextEntryDialog.return_value.ShowModal = MagicMock(return_value=mock_wx.ID_OK) + mock_wx.PasswordEntryDialog.return_value.GetValue = MagicMock(return_value="any_password") + mock_wx.PasswordEntryDialog.return_value.ShowModal = MagicMock(return_value=mock_wx.ID_OK) + + self.session.config_get = MagicMock(return_value="") + + result = await self.session.authorise() + self.assertFalse(result) + mock_login_method.assert_called_once() + + + # --- Test Sending Posts --- + async def test_send_simple_post_successful(self): + self.session.is_ready = MagicMock(return_value=True) # Assume session is ready + self.session.util.post_status = AsyncMock(return_value="at://mock_post_uri") + + post_uri = await self.session.send_message("Test text post") + + self.assertEqual(post_uri, "at://mock_post_uri") + self.session.util.post_status.assert_called_once_with( + text="Test text post", media_ids=None, reply_to_uri=None, quote_uri=None, + cw_text=None, is_sensitive=False, langs=None, tags=None + ) + + async def test_send_post_with_quote_and_lang(self): + self.session.is_ready = MagicMock(return_value=True) + self.session.util.post_status = AsyncMock(return_value="at://mock_post_uri_quote") + + post_uri = await self.session.send_message( + "Quoting another post", + quote_uri="at://did:plc:someuser/app.bsky.feed.post/somepostid", + langs=["en", "es"] + ) + self.assertEqual(post_uri, "at://mock_post_uri_quote") + self.session.util.post_status.assert_called_once_with( + text="Quoting another post", media_ids=None, reply_to_uri=None, + quote_uri="at://did:plc:someuser/app.bsky.feed.post/somepostid", + cw_text=None, is_sensitive=False, langs=["en", "es"], tags=None + ) + + @patch('sessions.atprotosocial.session.os.path.basename', return_value="image.png") # Mock os.path.basename + async def test_send_post_with_media(self, mock_basename): + self.session.is_ready = MagicMock(return_value=True) + mock_blob_info = {"blob_ref": MagicMock(spec=atp_models.ComAtprotoRepoStrongRef.Blob), "alt_text": "A test image"} + self.session.util.upload_media = AsyncMock(return_value=mock_blob_info) + self.session.util.post_status = AsyncMock(return_value="at://mock_post_uri_media") + + post_uri = await self.session.send_message( + "Post with media", files=["dummy/path/image.png"], media_alt_texts=["A test image"] + ) + self.assertEqual(post_uri, "at://mock_post_uri_media") + self.session.util.upload_media.assert_called_once_with("dummy/path/image.png", "image/png", alt_text="A test image") + self.session.util.post_status.assert_called_once_with( + text="Post with media", media_ids=[mock_blob_info], reply_to_uri=None, quote_uri=None, + cw_text=None, is_sensitive=False, langs=None, tags=None + ) + + async def test_send_post_util_failure(self): + self.session.is_ready = MagicMock(return_value=True) + self.session.util.post_status = AsyncMock(side_effect=NotificationError("Failed to post from util")) + with self.assertRaisesRegex(NotificationError, "Failed to post from util"): + await self.session.send_message("This will fail") + + # --- Test Fetching Timelines --- + def _create_mock_feed_view_post(self, uri_suffix): + post_view = MagicMock(spec=atp_models.AppBskyFeedDefs.PostView) + post_view.uri = f"at://did:plc:test/app.bsky.feed.post/{uri_suffix}" + post_view.cid = f"cid_{uri_suffix}" + author_mock = MagicMock(spec=atp_models.AppBskyActorDefs.ProfileViewBasic) + author_mock.did = "did:plc:author" + author_mock.handle = "author.bsky.social" + post_view.author = author_mock + record_mock = MagicMock(spec=atp_models.AppBskyFeedPost.Main) + record_mock.text = f"Text of post {uri_suffix}" + record_mock.createdAt = "2024-01-01T00:00:00Z" + post_view.record = record_mock + feed_view_post = MagicMock(spec=atp_models.AppBskyFeedDefs.FeedViewPost) + feed_view_post.post = post_view + feed_view_post.reason = None + feed_view_post.reply = None + return feed_view_post + + async def test_fetch_home_timeline_successful(self): + self.session.is_ready = MagicMock(return_value=True) + mock_post1 = self._create_mock_feed_view_post("post1") + mock_post2 = self._create_mock_feed_view_post("post2") + self.session.util.get_timeline = AsyncMock(return_value=([mock_post1, mock_post2], "cursor_for_home")) + self.session.order_buffer = AsyncMock(return_value=["uri1", "uri2"]) + + processed_uris, next_cursor = await self.session.fetch_home_timeline(limit=5, new_only=True) + + self.session.util.get_timeline.assert_called_once_with(algorithm=None, limit=5, cursor=None) + self.session.order_buffer.assert_called_once_with(items=[mock_post1, mock_post2], new_only=True, buffer_name="home_timeline_buffer") + self.assertEqual(self.session.home_timeline_cursor, "cursor_for_home") + self.assertEqual(processed_uris, ["uri1", "uri2"]) + + async def test_fetch_user_timeline_successful(self): + self.session.is_ready = MagicMock(return_value=True) + mock_post3 = self._create_mock_feed_view_post("post3") + self.session.util.get_author_feed = AsyncMock(return_value=([mock_post3], "cursor_for_user")) + self.session.order_buffer = AsyncMock(return_value=["uri3"]) + + processed_uris, next_cursor = await self.session.fetch_user_timeline( + user_did="did:plc:targetuser", limit=10, filter_type="posts_no_replies" + ) + self.session.util.get_author_feed.assert_called_once_with( + actor_did="did:plc:targetuser", limit=10, cursor=None, filter="posts_no_replies" + ) + self.session.order_buffer.assert_called_once_with(items=[mock_post3], new_only=False, buffer_name='user_timeline_did:plc:targetuser') + self.assertEqual(next_cursor, "cursor_for_user") + self.assertEqual(processed_uris, ["uri3"]) + + async def test_fetch_timeline_failure(self): + self.session.is_ready = MagicMock(return_value=True) + self.session.util.get_timeline = AsyncMock(side_effect=NotificationError("API error for timeline")) + with self.assertRaisesRegex(NotificationError, "API error for timeline"): + await self.session.fetch_home_timeline() + + # --- Test Fetching Notifications --- + def _create_mock_notification(self, reason: str, uri_suffix: str, isRead: bool = False): + notif = MagicMock(spec=atp_models.AppBskyNotificationListNotifications.Notification) + notif.uri = f"at://did:plc:test/app.bsky.feed.like/{uri_suffix}" + notif.cid = f"cid_notif_{uri_suffix}" + author_mock = MagicMock(spec=atp_models.AppBskyActorDefs.ProfileView) + author_mock.did = f"did:plc:otheruser{uri_suffix}" + author_mock.handle = f"other{uri_suffix}.bsky.social" + author_mock.displayName = f"Other User {uri_suffix}" + author_mock.avatar = "http://example.com/avatar.png" + notif.author = author_mock + notif.reason = reason + notif.reasonSubject = f"at://did:plc:test/app.bsky.feed.post/mypost{uri_suffix}" if reason != "follow" else None + if reason in ["mention", "reply", "quote"]: + record_mock = MagicMock(spec=atp_models.AppBskyFeedPost.Main) + record_mock.text = f"Notification related text for {reason}" + record_mock.createdAt = "2024-01-02T00:00:00Z" + notif.record = record_mock + else: + notif.record = MagicMock() + notif.isRead = isRead + notif.indexedAt = "2024-01-02T00:00:00Z" + return notif + + async def test_fetch_notifications_successful_and_handler_dispatch(self): + self.session.is_ready = MagicMock(return_value=True) + self.session.util = AsyncMock() + mock_like_notif = self._create_mock_notification("like", "like1", isRead=False) + mock_mention_notif = self._create_mock_notification("mention", "mention1", isRead=False) + self.session.util.get_notifications = AsyncMock(return_value=([mock_like_notif, mock_mention_notif], "next_notif_cursor")) + + self.session._handle_like_notification = AsyncMock() + self.session._handle_mention_notification = AsyncMock() + self.session._handle_repost_notification = AsyncMock() + self.session._handle_follow_notification = AsyncMock() + self.session._handle_reply_notification = AsyncMock() + self.session._handle_quote_notification = AsyncMock() + + returned_cursor = await self.session.fetch_notifications(limit=10) + + self.session.util.get_notifications.assert_called_once_with(limit=10, cursor=None) + self.session._handle_like_notification.assert_called_once_with(mock_like_notif) + self.session._handle_mention_notification.assert_called_once_with(mock_mention_notif) + self.assertEqual(returned_cursor, "next_notif_cursor") + +if __name__ == '__main__': + unittest.main() + +# Minimal wx mock for running tests headlessly +if 'wx' not in sys.modules: # type: ignore + sys.modules['wx'] = MagicMock() + mock_wx_module = sys.modules['wx'] + mock_wx_module.ID_OK = 1 + mock_wx_module.ID_CANCEL = 2 + mock_wx_module.ICON_ERROR = 16 + mock_wx_module.ICON_INFORMATION = 64 + mock_wx_module.OK = 4 + mock_wx_module.TextEntryDialog = MockWxDialog + mock_wx_module.PasswordEntryDialog = MockWxDialog + mock_wx_module.MessageBox = MockWxMessageBox + mock_wx_module.CallAfter = MagicMock() + mock_wx_module.GetApp = MagicMock() +>>>>>>> REPLACE diff --git a/src/twblue.pot b/src/twblue.pot new file mode 100644 index 00000000..e69de29b diff --git a/src/wxUI/buffers/atprotosocial/panels.py b/src/wxUI/buffers/atprotosocial/panels.py new file mode 100644 index 00000000..536fd0a2 --- /dev/null +++ b/src/wxUI/buffers/atprotosocial/panels.py @@ -0,0 +1,693 @@ +# -*- coding: utf-8 -*- +import wx +import asyncio +import logging +from pubsub import pub + +from approve.translation import translate as _ +from approve.notifications import NotificationError +from multiplatform_widgets import widgets # Assuming this provides a generic list control + +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() + + +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 + + 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") + + 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") + 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.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}") + + + 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 + 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 + + 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. + 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 + + +class ATProtoSocialUserListPanel(BaseTimelinePanel): + def __init__(self, parent, name: str, session, list_type: str, target_user_did: str, target_user_handle: str | None = None): + 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() + + sizer = wx.BoxSizer(wx.VERTICAL) + 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 + 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 + + 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 "" diff --git a/src/wxUI/dialogs/atprotosocial/showUserProfile.py b/src/wxUI/dialogs/atprotosocial/showUserProfile.py new file mode 100644 index 00000000..68dd8ac7 --- /dev/null +++ b/src/wxUI/dialogs/atprotosocial/showUserProfile.py @@ -0,0 +1,297 @@ +# -*- coding: utf-8 -*- +import wx +import asyncio +import logging +from pubsub import pub + +from approve.translation import translate as _ +from approve.notifications import NotificationError +# Assuming controller.atprotosocial.userList.get_user_profile_details and session.util._format_profile_data exist +# For direct call to util: +# from sessions.atprotosocial import utils as ATProtoSocialUtils + +logger = logging.getLogger(__name__) + +class ShowUserProfileDialog(wx.Dialog): + def __init__(self, parent, session, user_identifier: str): # user_identifier can be DID or handle + super(ShowUserProfileDialog, self).__init__(parent, title=_("User Profile"), style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) + + self.session = session + self.user_identifier = user_identifier + self.profile_data = None # Will store the formatted profile dict + self.target_user_did = None # Will store the resolved DID of the profile being viewed + + self._init_ui() + self.SetMinSize((400, 300)) + self.CentreOnParent() + + wx.CallAfter(asyncio.create_task, self.load_profile_data()) + + def _init_ui(self): + panel = wx.Panel(self) + main_sizer = wx.BoxSizer(wx.VERTICAL) + + # Profile Info Section (StaticTexts for labels and values) + self.info_grid_sizer = wx.FlexGridSizer(cols=2, vgap=5, hgap=5) + self.info_grid_sizer.AddGrowableCol(1, 1) + + fields = [ + (_("Display Name:"), "displayName"), (_("Handle:"), "handle"), (_("DID:"), "did"), + (_("Followers:"), "followersCount"), (_("Following:"), "followsCount"), (_("Posts:"), "postsCount"), + (_("Bio:"), "description") + ] + self.profile_field_ctrls = {} + + for label_text, data_key in fields: + lbl = wx.StaticText(panel, label=label_text) + val_ctrl = wx.TextCtrl(panel, style=wx.TE_READONLY | wx.TE_MULTILINE if data_key == "description" else wx.TE_READONLY | wx.BORDER_NONE) + if data_key != "description": # Make it look like a label + val_ctrl.SetBackgroundColour(panel.GetBackgroundColour()) + + self.info_grid_sizer.Add(lbl, 0, wx.ALIGN_RIGHT | wx.ALIGN_TOP | wx.ALL, 2) + self.info_grid_sizer.Add(val_ctrl, 1, wx.EXPAND | wx.ALL, 2) + self.profile_field_ctrls[data_key] = val_ctrl + + # Avatar and Banner (placeholders for now) + self.avatar_text = wx.StaticText(panel, label=_("Avatar URL: ") + _("N/A")) + self.info_grid_sizer.Add(self.avatar_text, 0, wx.ALIGN_RIGHT | wx.ALIGN_TOP | wx.ALL, 2) + self.banner_text = wx.StaticText(panel, label=_("Banner URL: ") + _("N/A")) + self.info_grid_sizer.Add(self.banner_text, 0, wx.ALIGN_RIGHT | wx.ALIGN_TOP | wx.ALL, 2) + + + main_sizer.Add(self.info_grid_sizer, 1, wx.EXPAND | wx.ALL, 10) + + # Action Buttons + actions_sizer = wx.BoxSizer(wx.HORIZONTAL) + # Placeholders, enable/disable logic will be in load_profile_data + self.follow_btn = wx.Button(panel, label=_("Follow")) + self.unfollow_btn = wx.Button(panel, label=_("Unfollow")) + self.mute_btn = wx.Button(panel, label=_("Mute")) + self.unmute_btn = wx.Button(panel, label=_("Unmute")) + self.block_btn = wx.Button(panel, label=_("Block")) + # Unblock might be more complex if it needs block URI or is shown conditionally + + self.follow_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="follow_user": self.on_user_action(evt, cmd)) + self.unfollow_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="unfollow_user": self.on_user_action(evt, cmd)) + self.mute_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="mute_user": self.on_user_action(evt, cmd)) + self.unmute_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="unmute_user": self.on_user_action(evt, cmd)) + self.block_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="block_user": self.on_user_action(evt, cmd)) + self.unblock_btn = wx.Button(panel, label=_("Unblock")) # Added unblock button + self.unblock_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="unblock_user": self.on_user_action(evt, cmd)) + + + actions_sizer.Add(self.follow_btn, 0, wx.ALL, 3) + actions_sizer.Add(self.unfollow_btn, 0, wx.ALL, 3) + actions_sizer.Add(self.mute_btn, 0, wx.ALL, 3) + actions_sizer.Add(self.unmute_btn, 0, wx.ALL, 3) + actions_sizer.Add(self.block_btn, 0, wx.ALL, 3) + actions_sizer.Add(self.unblock_btn, 0, wx.ALL, 3) # Added unblock button + main_sizer.Add(actions_sizer, 0, wx.ALIGN_CENTER | wx.TOP | wx.BOTTOM, 10) + + # Close Button + close_btn = wx.Button(panel, wx.ID_CANCEL, _("Close")) + close_btn.SetDefault() # Allow Esc to close + main_sizer.Add(close_btn, 0, wx.ALIGN_RIGHT | wx.ALL, 10) + + panel.SetSizer(main_sizer) + self.Fit() # Fit dialog to content + + async def load_profile_data(self): + self.SetStatusText(_("Loading profile...")) + for ctrl in self.profile_field_ctrls.values(): + ctrl.SetValue(_("Loading...")) + + # Initially hide all action buttons until state is known + self.follow_btn.Hide() + self.unfollow_btn.Hide() + self.mute_btn.Hide() + self.unmute_btn.Hide() + self.block_btn.Hide() + self.unblock_btn.Hide() + + try: + raw_profile = await self.session.util.get_user_profile(self.user_identifier) + if raw_profile: + self.profile_data = self.session.util._format_profile_data(raw_profile) # This should return a dict + self.target_user_did = self.profile_data.get("did") # Store the canonical DID + self.user_identifier = self.target_user_did # Update identifier to resolved DID for consistency + + self.update_ui_fields() + self.update_action_buttons_state() + self.SetTitle(_("Profile: {handle}").format(handle=self.profile_data.get("handle", ""))) + self.SetStatusText(_("Profile loaded.")) + else: + for ctrl in self.profile_field_ctrls.values(): + ctrl.SetValue(_("Not found.")) + self.SetStatusText(_("Profile not found for '{ident}'.").format(ident=self.user_identifier)) + wx.MessageBox(_("User profile for '{ident}' not found.").format(ident=self.user_identifier), _("Error"), wx.OK | wx.ICON_ERROR, self) + + except Exception as e: + logger.error(f"Error loading profile for {self.user_identifier}: {e}", exc_info=True) + for ctrl in self.profile_field_ctrls.values(): + ctrl.SetValue(_("Error loading.")) + self.SetStatusText(_("Error loading profile.")) + wx.MessageBox(_("Error loading profile: {error}").format(error=str(e)), _("Error"), wx.OK | wx.ICON_ERROR, self) + finally: + self.Layout() # Refresh layout after hiding/showing buttons + + def update_ui_fields(self): + if not self.profile_data: + return + + for key, ctrl in self.profile_field_ctrls.items(): + value = self.profile_data.get(key) # _format_profile_data should provide values or None/empty + if key == "description" and value: # Make bio multi-line if content exists + ctrl.SetMinSize((-1, 60)) # Allow some height for bio + + if isinstance(value, (int, float)): + ctrl.SetValue(str(value)) + else: # String or None + ctrl.SetValue(value or _("N/A")) + + # For URLs, could make them clickable or add a "Copy URL" button + avatar_url = self.profile_data.get("avatar") or _("N/A") + banner_url = self.profile_data.get("banner") or _("N/A") + self.avatar_text.SetLabel(_("Avatar URL: ") + avatar_url) + self.avatar_text.SetToolTip(avatar_url if avatar_url != _("N/A") else "") + self.banner_text.SetLabel(_("Banner URL: ") + banner_url) + self.banner_text.SetToolTip(banner_url if banner_url != _("N/A") else "") + self.Layout() + + def update_action_buttons_state(self): + if not self.profile_data or not self.target_user_did or self.target_user_did == self.session.util.get_own_did(): + self.follow_btn.Hide() + self.unfollow_btn.Hide() + self.mute_btn.Hide() + self.unmute_btn.Hide() + self.block_btn.Hide() + self.unblock_btn.Hide() + self.Layout() + return + + viewer_state = self.profile_data.get("viewer", {}) + is_following = bool(viewer_state.get("following")) + is_muted = bool(viewer_state.get("muted")) + # 'blocking' in viewer state is the URI of *our* block record, if we are blocking them. + is_blocking_them = bool(viewer_state.get("blocking")) + # 'blockedBy' means *they* are blocking us. If true, most actions might fail or be hidden. + is_blocked_by_them = bool(viewer_state.get("blockedBy")) + + if is_blocked_by_them: # If they block us, we can't do much. + self.follow_btn.Hide() + self.unfollow_btn.Hide() + self.mute_btn.Hide() + self.unmute_btn.Hide() + # We can still block them, or unblock them if we previously did. + self.block_btn.Show(not is_blocking_them) + self.unblock_btn.Show(is_blocking_them) + self.Layout() + return + + self.follow_btn.Show(not is_following and not is_blocking_them) + self.unfollow_btn.Show(is_following and not is_blocking_them) + + self.mute_btn.Show(not is_muted and not is_blocking_them) + self.unmute_btn.Show(is_muted and not is_blocking_them) + + self.block_btn.Show(not is_blocking_them) # Show block if we are not currently blocking them (even if they block us) + self.unblock_btn.Show(is_blocking_them) # Show unblock if we are currently blocking them + + self.Layout() # Refresh sizer to show/hide buttons correctly + + + def on_user_action(self, event, command: str): + if not self.target_user_did: # Should be set by load_profile_data + wx.MessageBox(_("User identifier (DID) not available for this action."), _("Error"), wx.OK | wx.ICON_ERROR) + return + + # Confirmation for sensitive actions + confirmation_map = { + "unfollow_user": _("Are you sure you want to unfollow @{handle}?").format(handle=self.profile_data.get("handle","this user")), + "block_user": _("Are you sure you want to block @{handle}? This will prevent them from interacting with you and hide their content.").format(handle=self.profile_data.get("handle","this user")), + # Unblock usually doesn't need confirmation, but can be added if desired. + } + if command in confirmation_map: + dlg = wx.MessageDialog(self, confirmation_map[command], _("Confirm Action"), wx.YES_NO | wx.ICON_QUESTION) + if dlg.ShowModal() != wx.ID_YES: + dlg.Destroy() + return + dlg.Destroy() + + async def do_action(): + wx.BeginBusyCursor() + self.SetStatusText(_("Performing action: {action}...").format(action=command)) + action_button = event.GetEventObject() + if action_button: action_button.Disable() # Disable the clicked button + + try: + # Ensure controller_handler is available on the session + if not hasattr(self.session, 'controller_handler') or not self.session.controller_handler: + app = wx.GetApp() + if hasattr(app, 'mainController'): + self.session.controller_handler = app.mainController.get_handler(self.session.KIND) + if not self.session.controller_handler: # Still not found + raise RuntimeError("Controller handler not found for session.") + + result = await self.session.controller_handler.handle_user_command( + command=command, + user_id=self.session.uid, + target_user_id=self.target_user_did, + payload={} + ) + wx.EndBusyCursor() + # Use CallAfter for UI updates from async task + wx.CallAfter(wx.MessageBox, result.get("message", _("Action completed.")), + _("Success") if result.get("status") == "success" else _("Error"), + wx.OK | (wx.ICON_INFORMATION if result.get("status") == "success" else wx.ICON_ERROR), + self) + + if result.get("status") == "success": + # Re-fetch profile data to update UI (especially button states) + wx.CallAfter(asyncio.create_task, self.load_profile_data()) + else: # Re-enable button if action failed + if action_button: wx.CallAfter(action_button.Enable, True) + self.SetStatusText(_("Action failed.")) + + + except NotificationError as e: + wx.EndBusyCursor() + if action_button: wx.CallAfter(action_button.Enable, True) + self.SetStatusText(_("Action failed.")) + wx.CallAfter(wx.MessageBox, str(e), _("Action Error"), wx.OK | wx.ICON_ERROR, self) + except Exception as e: + wx.EndBusyCursor() + if action_button: wx.CallAfter(action_button.Enable, True) + self.SetStatusText(_("Action failed.")) + logger.error(f"Error performing user action '{command}' on {self.target_user_did}: {e}", exc_info=True) + wx.CallAfter(wx.MessageBox, _("An unexpected error occurred: {error}").format(error=str(e)), _("Error"), wx.OK | wx.ICON_ERROR, self) + + asyncio.create_task(do_action()) # No wx.CallAfter needed for starting the task itself + + def SetStatusText(self, text): # Simple status text for dialog title + self.SetTitle(f"{_('User Profile')} - {text}") + +```python +# Example of how this dialog might be called from atprotosocial.Handler.user_details: +# (This is conceptual, actual integration in handler.py will use the dialog) +# +# async def user_details(self, buffer_panel_or_user_ident): +# session = self._get_session(self.current_user_id_from_context) # Get current session +# user_identifier_to_show = None +# if isinstance(buffer_panel_or_user_ident, str): # It's a DID or handle +# user_identifier_to_show = buffer_panel_or_user_ident +# elif hasattr(buffer_panel_or_user_ident, 'get_selected_item_author_details'): # It's a panel +# author_details = buffer_panel_or_user_ident.get_selected_item_author_details() +# if author_details: +# user_identifier_to_show = author_details.get("did") or author_details.get("handle") +# +# if not user_identifier_to_show: +# # Optionally prompt for user_identifier if not found +# output.speak(_("No user selected or identified to view details."), True) +# return +# +# dialog = ShowUserProfileDialog(self.main_controller.view, session, user_identifier_to_show) +# dialog.ShowModal() +# dialog.Destroy() + +``` diff --git a/src/wxUI/dialogs/composeDialog.py b/src/wxUI/dialogs/composeDialog.py new file mode 100644 index 00000000..b5bd2c99 --- /dev/null +++ b/src/wxUI/dialogs/composeDialog.py @@ -0,0 +1,394 @@ +# -*- coding: utf-8 -*- +import wx +import logging +from pubsub import pub +from multiplatform_widgets import widgets # Assuming this provides generic widgets +from approve.translation import translate as _ # For Approve's _ shortcut +from approve.notifications import NotificationError + +logger = logging.getLogger(__name__) + +# Supported languages for posts (ISO 639-1 codes) - can be expanded +# This might ideally come from the session or a global config +SUPPORTED_LANG_CHOICES = { + _("English"): "en", + _("Spanish"): "es", + _("French"): "fr", + _("German"): "de", + _("Japanese"): "ja", + _("Portuguese"): "pt", + _("Russian"): "ru", + _("Chinese"): "zh", + # Add more as needed +} + +class ComposeDialog(wx.Dialog): + def __init__(self, parent, session, reply_to_uri: str | None = None, quote_uri: str | None = None, initial_text: str = ""): + super(ComposeDialog, self).__init__(parent, title=_("Compose Post"), style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) + + self.session = session + self.panel_config = self.session.compose_panel.get_panel_configuration() + self.reply_to_uri = reply_to_uri + self.initial_quote_uri = quote_uri # Store initial quote URI + self.current_quote_uri = quote_uri # Mutable quote URI + self.attached_files_info = [] # List of dicts: {"path": str, "alt_text": str} + + self._init_ui(initial_text) + self.SetMinSize((550, 450)) # Increased min size + self.CentreOnParent() + + def _init_ui(self, initial_text: str): + panel = wx.Panel(self) + main_sizer = wx.BoxSizer(wx.VERTICAL) + + # Reply Info (if applicable) + if self.reply_to_uri: + # In a real app, fetch & show post snippet or author + reply_info_label = wx.StaticText(panel, label=_("Replying to: {uri_placeholder}").format(uri_placeholder=self.reply_to_uri[-10:])) + reply_info_label.SetToolTip(self.reply_to_uri) + main_sizer.Add(reply_info_label, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP, 5) + + # Text Area + self.text_ctrl = wx.TextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_RICH2 | wx.HSCROLL) + self.text_ctrl.SetValue(initial_text) + self.text_ctrl.Bind(wx.EVT_TEXT, self.on_text_changed) + main_sizer.Add(self.text_ctrl, 1, wx.EXPAND | wx.ALL, 5) + + # Character Counter + self.max_chars = self.panel_config.get("max_chars", 0) + self.char_count_label = wx.StaticText(panel, label=f"0 / {self.max_chars if self.max_chars > 0 else 'N/A'}") + main_sizer.Add(self.char_count_label, 0, wx.ALIGN_RIGHT | wx.RIGHT | wx.BOTTOM, 5) + self.on_text_changed(None) + + # Attachments Area + self.max_media_attachments = self.panel_config.get("max_media_attachments", 0) + if self.max_media_attachments > 0: + attachment_sizer = wx.StaticBoxSizer(wx.VERTICAL, panel, _("Media Attachments") + f" (Max: {self.max_media_attachments})") + self.attachment_list = wx.ListBox(attachment_sizer.GetStaticBox(), style=wx.LB_SINGLE, size=(-1, 60)) # Fixed height for listbox + attachment_sizer.Add(self.attachment_list, 1, wx.EXPAND | wx.ALL, 5) + + attach_btn_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.add_attachment_btn = wx.Button(attachment_sizer.GetStaticBox(), label=_("Add Media...")) + self.add_attachment_btn.Bind(wx.EVT_BUTTON, self.on_add_attachment) + attach_btn_sizer.Add(self.add_attachment_btn, 0, wx.ALL, 2) + + self.remove_attachment_btn = wx.Button(attachment_sizer.GetStaticBox(), label=_("Remove Selected")) + self.remove_attachment_btn.Bind(wx.EVT_BUTTON, self.on_remove_attachment) + self.remove_attachment_btn.Enable(False) + self.attachment_list.Bind(wx.EVT_LISTBOX, lambda evt: self.remove_attachment_btn.Enable(self.attachment_list.GetSelection() != wx.NOT_FOUND)) + attach_btn_sizer.Add(self.remove_attachment_btn, 0, wx.ALL, 2) + attachment_sizer.Add(attach_btn_sizer, 0, wx.ALIGN_LEFT) + main_sizer.Add(attachment_sizer, 0, wx.EXPAND | wx.ALL, 5) + + # Quoting Area + if self.panel_config.get("supports_quoting", False): + quote_box_sizer = wx.StaticBoxSizer(wx.VERTICAL, panel, _("Quoting Post")) + quote_display_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.quote_uri_text_display = wx.TextCtrl(quote_box_sizer.GetStaticBox(), value=self.current_quote_uri or _("None"), style=wx.TE_READONLY | wx.BORDER_NONE) + self.quote_uri_text_display.SetBackgroundColour(panel.GetBackgroundColour()) + quote_display_sizer.Add(wx.StaticText(quote_box_sizer.GetStaticBox(), label=_("Quoting URI: ")), 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2) + quote_display_sizer.Add(self.quote_uri_text_display, 1, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2) + quote_box_sizer.Add(quote_display_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 2) + + quote_btn_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.add_quote_btn = wx.Button(quote_box_sizer.GetStaticBox(), label=_("Set/Change Quote...")) + self.add_quote_btn.Bind(wx.EVT_BUTTON, self.on_add_quote) + quote_btn_sizer.Add(self.add_quote_btn, 0, wx.ALL, 2) + + self.remove_quote_btn = wx.Button(quote_box_sizer.GetStaticBox(), label=_("Remove Quote")) + self.remove_quote_btn.Bind(wx.EVT_BUTTON, self.on_remove_quote) + self.remove_quote_btn.Enable(bool(self.current_quote_uri)) + quote_btn_sizer.Add(self.remove_quote_btn, 0, wx.ALL, 2) + quote_box_sizer.Add(quote_btn_sizer, 0, wx.ALIGN_LEFT) + main_sizer.Add(quote_box_sizer, 0, wx.EXPAND | wx.ALL, 5) + + # Options (Content Warning, Language) + options_box = wx.StaticBoxSizer(wx.VERTICAL, panel, _("Options")) + options_grid_sizer = wx.FlexGridSizer(cols=2, vgap=5, hgap=5) + options_grid_sizer.AddGrowableCol(1, 1) + + if self.panel_config.get("supports_content_warning", False): + self.sensitive_checkbox = wx.CheckBox(options_box.GetStaticBox(), label=_("Sensitive content (CW)")) + self.sensitive_checkbox.Bind(wx.EVT_CHECKBOX, self.on_sensitive_changed) + options_grid_sizer.Add(self.sensitive_checkbox, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2) + + self.spoiler_text_ctrl = wx.TextCtrl(options_box.GetStaticBox()) + self.spoiler_text_ctrl.SetHint(_("Content warning text (optional)")) + self.spoiler_text_ctrl.Enable(False) + options_grid_sizer.Add(self.spoiler_text_ctrl, 1, wx.EXPAND | wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2) + + if self.panel_config.get("supports_language_selection", False): + lang_label = wx.StaticText(options_box.GetStaticBox(), label=_("Languages:")) + options_grid_sizer.Add(lang_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2) + + self.max_langs = self.panel_config.get("max_languages", 1) + self.lang_choices_map = SUPPORTED_LANG_CHOICES # Using global for now + lang_display_names = list(self.lang_choices_map.keys()) + + if self.max_langs == 1: # Single choice + choices = [_("Automatic")] + lang_display_names + self.lang_choice_ctrl = wx.Choice(options_box.GetStaticBox(), choices=choices) + self.lang_choice_ctrl.SetSelection(0) # Default to Automatic/None + else: # Multiple choices + self.lang_choice_ctrl = wx.CheckListBox(options_box.GetStaticBox(), choices=lang_display_names, size=(-1, 70)) + self.lang_choice_ctrl.Bind(wx.EVT_CHECKLISTBOX, self.on_lang_checklist_changed) + options_grid_sizer.Add(self.lang_choice_ctrl, 1, wx.EXPAND | wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2) + + if options_grid_sizer.GetChildren(): + options_box.Add(options_grid_sizer, 1, wx.EXPAND | wx.ALL, 0) # No border for grid sizer itself + main_sizer.Add(options_box, 0, wx.EXPAND | wx.ALL, 5) + + # Buttons (Send, Cancel) + btn_sizer = wx.StdDialogButtonSizer() + self.send_btn = wx.Button(panel, wx.ID_OK, _("Send")) + self.send_btn.SetDefault() + self.send_btn.Bind(wx.EVT_BUTTON, self.on_send) + btn_sizer.AddButton(self.send_btn) + + cancel_btn = wx.Button(panel, wx.ID_CANCEL, _("Cancel")) + btn_sizer.AddButton(cancel_btn) + btn_sizer.Realize() + main_sizer.Add(btn_sizer, 0, wx.ALIGN_CENTER | wx.ALL, 5) + + panel.SetSizer(main_sizer) + self.Fit() + + + def on_text_changed(self, event): + text_length = len(self.text_ctrl.GetValue()) + self.char_count_label.SetLabel(f"{text_length} / {self.max_chars}") + if self.max_chars > 0 and text_length > self.max_chars: + self.char_count_label.SetForegroundColour(wx.RED) + else: + self.char_count_label.SetForegroundColour(wx.BLACK) # System default + + def on_add_attachment(self, event): + max_attachments = self.panel_config.get("max_media_attachments", 0) + if len(self.attached_files_info) >= self.max_media_attachments: + wx.MessageBox(_("Maximum number of attachments ({max}) reached.").format(max=self.max_media_attachments), _("Attachment Limit"), wx.OK | wx.ICON_INFORMATION) + return + + supported_mimes = self.panel_config.get("supported_media_types", []) + wildcard_parts = [] + if not supported_mimes: # Default if none specified by session + wildcard_parts.append("All files (*.*)|*.*") + else: + for mime_type in supported_mimes: + # Example: "image/jpeg" -> "JPEG files (*.jpg;*.jpeg)|*.jpg;*.jpeg" + name = mime_type.split('/')[0].capitalize() + " " + mime_type.split('/')[1].upper() + if mime_type == "image/jpeg": exts = "*.jpg;*.jpeg" + elif mime_type == "image/png": exts = "*.png" + elif mime_type == "image/gif": exts = "*.gif" # If supported + else: exts = "*." + mime_type.split('/')[-1] + wildcard_parts.append(f"{name} ({exts})|{exts}") + + wildcard = "|".join(wildcard_parts) if wildcard_parts else wx.FileSelectorDefaultWildcardStr + + dialog = wx.FileDialog(self, _("Select Media File"), wildcard=wildcard, style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) + if dialog.ShowModal() == wx.ID_OK: + path = dialog.GetPath() + alt_text = "" + if self.panel_config.get("supports_alternative_text", False) and \ + any(pt in path.lower() for pt in ['.jpg', '.jpeg', '.png']): # crude check for image + alt_text_dialog = wx.TextEntryDialog(self, _("Enter accessibility description (alt text) for the image:"), _("Image Description")) + if alt_text_dialog.ShowModal() == wx.ID_OK: + alt_text = alt_text_dialog.GetValue() + alt_text_dialog.Destroy() + + self.attached_files_info.append({"path": path, "alt_text": alt_text}) + self.attachment_list.Append(os.path.basename(path) + (f" ({_('Alt:')} {alt_text})" if alt_text else "")) + dialog.Destroy() + + def on_remove_attachment(self, event): + selected_index = self.attachment_list.GetSelection() + if selected_index != wx.NOT_FOUND: + self.attachment_list.Delete(selected_index) + del self.attached_files_info[selected_index] + + def on_add_quote(self, event): + dialog = wx.TextEntryDialog(self, _("Enter the AT-URI of the Bluesky post to quote:"), _("Quote Post"), self.current_quote_uri or "") + if dialog.ShowModal() == wx.ID_OK: + self.current_quote_uri = dialog.GetValue().strip() + self.quote_uri_text_display.SetValue(self.current_quote_uri or _("None")) + self.remove_quote_btn.Enable(bool(self.current_quote_uri)) + dialog.Destroy() + + def on_remove_quote(self, event): + self.current_quote_uri = None + self.quote_uri_text_display.SetValue(_("None")) + self.remove_quote_btn.Enable(False) + + + def on_sensitive_changed(self, event): + if hasattr(self, 'spoiler_text_ctrl'): + self.spoiler_text_ctrl.Enable(event.IsChecked()) + if event.IsChecked(): + self.spoiler_text_ctrl.SetFocus() + + def on_lang_checklist_changed(self, event): + """Ensure no more than max_languages are selected for CheckListBox.""" + if isinstance(self.lang_choice_ctrl, wx.CheckListBox): + checked_indices = self.lang_choice_ctrl.GetCheckedItems() + if len(checked_indices) > self.max_langs: + # Find the item that was just checked to cause the overflow + # This is a bit tricky as EVT_CHECKLISTBOX triggers after the change. + # A simpler approach is to inform the user and let them uncheck. + wx.MessageBox( + _("You can select a maximum of {num} languages.").format(num=self.max_langs), + _("Language Selection Limit"), wx.OK | wx.ICON_EXCLAMATION + ) + # Optionally, uncheck the last checked item if possible to determine + # For now, just warn. User has to manually correct. + + + def on_send(self, event): # Renamed from async on_send + text_content = self.text_ctrl.GetValue() + if not text_content.strip() and not self.attached_files_info and not self.current_quote_uri: + wx.MessageBox(_("Cannot send an empty post."), _("Error"), wx.OK | wx.ICON_ERROR) + return + + # Language processing + langs = [] + if hasattr(self, 'lang_choice_ctrl'): + if isinstance(self.lang_choice_ctrl, wx.Choice): + sel_idx = self.lang_choice_ctrl.GetSelection() + if sel_idx > 0: # Index 0 is empty/no selection + lang_display_name = self.lang_choice_ctrl.GetString(sel_idx) + langs.append(self.lang_choices_map[lang_display_name]) + elif isinstance(self.lang_choice_ctrl, wx.CheckListBox): + checked_indices = self.lang_choice_ctrl.GetCheckedItems() + if len(checked_indices) > self.max_langs: + wx.MessageBox(_("Please select no more than {num} languages.").format(num=self.max_langs), _("Language Error"), wx.OK | wx.ICON_ERROR) + return + for idx in checked_indices: + lang_display_name = self.lang_choice_ctrl.GetString(idx) + langs.append(self.lang_choices_map[lang_display_name]) + + # Files and Alt Texts + files_to_send = [f_info["path"] for f_info in self.attached_files_info] + alt_texts_to_send = [f_info["alt_text"] for f_info in self.attached_files_info] + + # Content Warning + cw_text = None + is_sensitive_flag = False + if hasattr(self, 'sensitive_checkbox') and self.sensitive_checkbox.IsChecked(): + is_sensitive_flag = True + if hasattr(self, 'spoiler_text_ctrl'): + cw_text = self.spoiler_text_ctrl.GetValue().strip() or None # Use None if empty for Bluesky + + kwargs_for_send = { + "quote_uri": self.current_quote_uri, + "langs": langs if langs else None, + "media_alt_texts": alt_texts_to_send if alt_texts_to_send else None, + # "tags" could be extracted from text server-side or client-side (not implemented here) + } + + # Filter out None values from kwargs to avoid sending them if not set + kwargs_for_send = {k: v for k, v in kwargs_for_send.items() if v is not None} + + try: + self.send_btn.Disable() + # This is an async call, so it should be handled appropriately in wxPython + # For simplicity in this step, assuming it's handled by the caller or a wrapper + # In a real wxPython app, this would involve asyncio.create_task and wx.CallAfter + # or running the send in a separate thread and using wx.CallAfter for UI updates. + # For now, we'll make this method async and let the caller handle it. + + # wx.BeginBusyCursor() # Indicate work + # Using pubsub to decouple UI from direct async call to session + pub.sendMessage( + "compose_dialog.send_post", + session=self.session, + text=text_content, + files=files_to_send if files_to_send else None, + reply_to=self.reply_to_uri, + cw_text=cw_text, + is_sensitive=is_sensitive_flag, + kwargs=kwargs_for_send + ) + # Success will be signaled by another pubsub message if needed, or just close. + # self.EndModal(wx.ID_OK) # Moved to controller after successful send via pubsub + + except NotificationError as e: + wx.MessageBox(str(e), _("Post Error"), wx.OK | wx.ICON_ERROR) + except Exception as e: + logger.error("Error sending post from compose dialog: %s", e, exc_info=True) + wx.MessageBox(_("An unexpected error occurred: {error}").format(error=str(e)), _("Error"), wx.OK | wx.ICON_ERROR) + finally: + # wx.EndBusyCursor() + if not self.IsBeingDeleted(): # Ensure dialog still exists + self.send_btn.Enable() + # Do not automatically close here; let the controller do it on success signal. + # self.EndModal(wx.ID_OK) # if successful and no further UI feedback needed in dialog + + def get_data(self): + """Helper to get all data, though on_send handles it directly.""" + # This method isn't strictly necessary if on_send does all the work, + # but can be useful for other patterns. + pass + +if __name__ == '__main__': + # Example usage (requires a mock session and panel_config) + app = wx.App(False) + + class MockComposePanel: + def get_panel_configuration(self): + return { + "max_chars": 300, + "max_media_attachments": 4, + "supported_media_types": ["image/jpeg", "image/png"], + "supports_alternative_text": True, + "supports_content_warning": True, + "supports_language_selection": True, + "max_languages": 3, + "supports_quoting": True, + } + + class MockSession: + def __init__(self): + self.compose_panel = MockComposePanel() + self.uid = "mock_user" # Needed by some base methods if called + + async def send_message(self, message, files=None, reply_to=None, cw_text=None, is_sensitive=False, **kwargs): + print("MockSession.send_message called:") + print(f" Text: {message}") + print(f" Files: {files}") + print(f" Reply To: {reply_to}") + print(f" CW: {cw_text}, Sensitive: {is_sensitive}") + print(f" kwargs: {kwargs}") + # Simulate success or failure + # raise NotificationError("This is a mock send error!") + return "at://did:plc:mockposturi/app.bsky.feed.post/mockrkey" + + # Pubsub listener for the send_post event (simulates what mainController would do) + def on_actual_send(session, text, files, reply_to, cw_text, is_sensitive, kwargs): + print("Pubsub: compose_dialog.send_post received. Calling session.send_message...") + async def do_send(): + try: + uri = await session.send_message( + message=text, + files=files, + reply_to=reply_to, + cw_text=cw_text, + is_sensitive=is_sensitive, + **kwargs + ) + print(f"Pubsub: Send successful, URI: {uri}") + # In real app, would call dialog.EndModal(wx.ID_OK) via wx.CallAfter + wx.CallAfter(dialog.EndModal, wx.ID_OK) + except Exception as e: + print(f"Pubsub: Send failed: {e}") + # In real app, show error and re-enable send button in dialog via wx.CallAfter + wx.CallAfter(wx.MessageBox, str(e), "Error", wx.OK | wx.ICON_ERROR, dialog) + wx.CallAfter(dialog.send_btn.Enable, True) + + asyncio.create_task(do_send()) + + pub.subscribe(on_actual_send, "compose_dialog.send_post") + + session = MockSession() + # Example: dialog = ComposeDialog(None, session, reply_to_uri="at://reply_uri", quote_uri="at://quote_uri", initial_text="Hello") + dialog = ComposeDialog(None, session, initial_text="Hello Bluesky!") + dialog.ShowModal() + dialog.Destroy() + app.MainLoop() diff --git a/tools/twblue-documentation.pot b/tools/twblue-documentation.pot index 43d442f1..fd003dc2 100644 --- a/tools/twblue-documentation.pot +++ b/tools/twblue-documentation.pot @@ -1,1544 +1,246 @@ # Translations template for PROJECT. -# Copyright (C) 2022 MCV software +# Copyright (C) 2024 MCV software # This file is distributed under the same license as the PROJECT project. -# FIRST AUTHOR , 2022. +# FIRST AUTHOR , 2024. # #, fuzzy msgid "" msgstr "" -"Project-Id-Version: PROJECT VERSION\n" -"Report-Msgid-Bugs-To: manuel@manuelcortez.net\n" -"POT-Creation-Date: 2022-12-20 17:18-0600\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.10.3\n" +"Project-Id-Version: TWBlue Documentation VERSION\\n" +"Report-Msgid-Bugs-To: manuel@manuelcortez.net\\n" +"POT-Creation-Date: 2024-05-26 12:00+0000\\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n" +"Last-Translator: FULL NAME \\n" +"Language-Team: LANGUAGE \\n" +"MIME-Version: 1.0\\n" +"Content-Type: text/plain; charset=utf-8\\n" +"Content-Transfer-Encoding: 8bit\\n" +"Generated-By: TWBlue Manual Process\\n" -#: ../doc/strings.py:3 -msgid "Documentation for TWBlue" +#: documentation/source/atprotosocial.rst +msgid "ATProtoSocial (Bluesky) Integration" msgstr "" -#: ../doc/strings.py:5 -msgid "## Table of contents" +#: documentation/source/atprotosocial.rst +msgid "TWBlue now supports the AT Protocol (ATProto), the decentralized social networking protocol that powers Bluesky. This allows you to interact with your Bluesky account directly within TWBlue." msgstr "" -#: ../doc/strings.py:7 -msgid "[TOC]" +#: documentation/source/atprotosocial.rst +msgid "Adding an ATProtoSocial Account" msgstr "" -#: ../doc/strings.py:9 -msgid "## Warning!" +#: documentation/source/atprotosocial.rst +msgid "To connect your Bluesky account to TWBlue, you will need your user **handle** and an **App Password**." msgstr "" -#: ../doc/strings.py:11 -msgid "" -"You are reading documentation produced for a program still in " -"development. The object of this manual is to explain some details of the " -"operation of the program. Bear in mind that as the software is in the " -"process of active development, parts of this user guide may change in the" -" near future, so it is advisable to keep checking from time to time to " -"avoid missing important information." +#: documentation/source/atprotosocial.rst +msgid "User Handle" msgstr "" -#: ../doc/strings.py:13 -msgid "" -"If you want to see what has changed from the previous version, [read the " -"list of updates here.](changes.html)" +#: documentation/source/atprotosocial.rst +msgid "This is your unique Bluesky identifier, often in the format ``@username.bsky.social`` or a custom domain you've configured (e.g., ``@yourname.com``)." msgstr "" -#: ../doc/strings.py:15 -msgid "## Introduction" +#: documentation/source/atprotosocial.rst +msgid "App Password" msgstr "" -#: ../doc/strings.py:17 -msgid "" -"TWBlue is an application to make Twitter simple and fast, while using as " -"few resources as possible. With TWBlue, you can do things like the " -"following:" +#: documentation/source/atprotosocial.rst +msgid "Bluesky uses App Passwords for third-party applications like TWBlue instead of your main account password. You need to generate an App Password from your Bluesky account settings." msgstr "" -#: ../doc/strings.py:19 -msgid "* Tweet, reply to, retweet and delete tweets," +#: documentation/source/atprotosocial.rst +msgid "Go to Bluesky Settings (usually accessible from the Bluesky app or website)." msgstr "" -#: ../doc/strings.py:20 -msgid "* Like and unlike a tweet," +#: documentation/source/atprotosocial.rst +msgid "Navigate to the \"App passwords\" section (this might be under \"Advanced\" or \"Security\")." msgstr "" -#: ../doc/strings.py:21 -msgid "* Send and delete direct messages," +#: documentation/source/atprotosocial.rst +msgid "Generate a new App Password. Give it a descriptive name (e.g., \"TWBlue\")." msgstr "" -#: ../doc/strings.py:22 -msgid "* See your friends and followers," +#: documentation/source/atprotosocial.rst +msgid "Copy the generated App Password immediately. It will usually only be shown once." msgstr "" -#: ../doc/strings.py:23 -msgid "* Follow, unfollow, report and block a user," +#: documentation/source/atprotosocial.rst +msgid "Once you have your handle and the App Password:" msgstr "" -#: ../doc/strings.py:24 -msgid "* Open a user's timeline to see their tweets separately," +#: documentation/source/atprotosocial.rst +msgid "Open TWBlue and go to the Session Manager (Application Menu -> Manage accounts)." msgstr "" -#: ../doc/strings.py:25 -msgid "* Open URLs from a tweet or direct message," +#: documentation/source/atprotosocial.rst +msgid "Click on \"New account\"." msgstr "" -#: ../doc/strings.py:26 -msgid "* Play several types of audio files from addresses," +#: documentation/source/atprotosocial.rst +msgid "Select \"ATProtoSocial (Bluesky)\" from the menu." msgstr "" -#: ../doc/strings.py:27 -msgid "* And more." +#: documentation/source/atprotosocial.rst +msgid "A dialog will prompt you to confirm that you want to authorize your account. Click \"Yes\"." msgstr "" -#: ../doc/strings.py:29 -msgid "## Usage" +#: documentation/source/atprotosocial.rst +msgid "You will then be asked for your Bluesky Handle. Enter your full handle (e.g., ``@username.bsky.social`` or ``username.bsky.social``)." msgstr "" -#: ../doc/strings.py:31 -msgid "" -"Twitter is a social networking or micro-blogging tool which allows you to" -" compose short status updates of your activities in 280 characters or " -"less. Twitter is a way for friends, family and co-workers to communicate " -"and stay connected through the exchange of quick, frequent messages. You " -"can restrict delivery of updates to those in your circle of friends or, " -"by default, allow anyone to access them." +#: documentation/source/atprotosocial.rst +msgid "Next, you will be asked for the App Password you generated. Enter it carefully." msgstr "" -#: ../doc/strings.py:33 -msgid "" -"You can monitor the status of updates from your friends, family or co-" -"workers (known as following), and they in turn can read any updates you " -"create, (known as followers). The updates are referred to as Tweets. The " -"Tweets are posted to your Twitter profile or Blog and are searchable " -"using Twitter Search." +#: documentation/source/atprotosocial.rst +msgid "If the credentials are correct, TWBlue will log in to your Bluesky account, and the new session will be added to your accounts list." msgstr "" -#: ../doc/strings.py:35 -msgid "" -"In order to use TWBlue, you must first have created an account on the " -"Twitter website. The process for signing up for a Twitter account is very" -" accessible. During the account registration, you will need to choose a " -"Twitter username. This serves two purposes. This is the method through " -"which people will comunicate with you, but most importantly, your " -"username and password will be required to connect TWBlue to your Twitter " -"account. We suggest you choose a username which is memorable both to you " -"and the people you hope will follow you." +#: documentation/source/atprotosocial.rst +msgid "Key Features" msgstr "" -#: ../doc/strings.py:37 -msgid "" -"We'll start from the premise that you have a Twitter account with its " -"corresponding username and password." +#: documentation/source/atprotosocial.rst +msgid "Once your ATProtoSocial account is connected, you can use the following features in TWBlue:" msgstr "" -#: ../doc/strings.py:39 -msgid "### Authorising the application" +#: documentation/source/atprotosocial.rst +msgid "Posting" msgstr "" -#: ../doc/strings.py:41 -msgid "" -"First of all, it's necessary to authorise the program so it can access " -"your Twitter account and act on your behalf. The authorisation process is" -" quite simple, and the program never retains data such as your password. " -"In order to authorise the application, you just need to run the main " -"executable file, called TWBlue.exe (on some computers it may appear " -"simply as TWBlue if Windows Explorer is not set to display file " -"extensions). We suggest you may like to place a Windows shortcut on your " -"Desktop pointing to this executable file for quick and easy location." +#: documentation/source/atprotosocial.rst +msgid "Create new posts (often called \"skeets\") with text, images, and specify language." msgstr "" -#: ../doc/strings.py:43 -msgid "" -"You can log into several Twitter accounts simultaneously. The program " -"refers to each Twitter account you have configured as a \"Session\". If " -"this is the first time you have launched TWBlue, and if no Twitter " -"session exists, you will see the Session Manager. This dialogue box " -"allows you to authorise as many accounts as you wish. If you press the " -"Tab key to reach the \"new account\" button and activate it by pressing " -"the Space Bar, a dialogue box will advise you that your default internet " -"browser will be opened in order to authorise the application and you will" -" be asked if you would like to continue. Activate the \"yes\" Button by " -"pressing the letter \"Y\" so the process may start." +#: documentation/source/atprotosocial.rst +msgid "Timelines" msgstr "" -#: ../doc/strings.py:45 -msgid "" -"Your default browser will open on the Twitter page to request " -"authorisation. Enter your username and password into the appropriate edit" -" fields if you're not already logged in, select the authorise button, and" -" press it." +#: documentation/source/atprotosocial.rst +msgid "Home Timeline (Skyline)" msgstr "" -#: ../doc/strings.py:47 -msgid "" -"Once you've authorised your twitter account, the website will redirect " -"you to a page which will notify you that TWBlue has been authorised " -"successfully. On this page, you will be shown a code composed of several " -"numbers that you must paste in the TWBlue authorization dialogue in order" -" to allow the application to access your account. Once you have pasted " -"the code in the corresponding text field, press enter to finish the " -"account setup and go back to the session manager. On the session list, " -"you will see a new item temporarily called \"Authorised account x\" " -"-where x is a number. The session name will change once you open that " -"session." +#: documentation/source/atprotosocial.rst +msgid "View posts from users you follow." msgstr "" -#: ../doc/strings.py:49 -msgid "" -"To start running TWBlue, press the Ok button in the Session Manager " -"dialogue. By default, the program starts all the configured sessions " -"automatically, however, you can change this behavior." +#: documentation/source/atprotosocial.rst +msgid "User Timelines" msgstr "" -#: ../doc/strings.py:51 -msgid "" -"If all went well, the application will start playing sounds, indicating " -"your data is being updated." +#: documentation/source/atprotosocial.rst +msgid "View posts from specific users." msgstr "" -#: ../doc/strings.py:53 -msgid "" -"When the process is finished, by default the program will play another " -"sound, and the screen reader will say \"ready\" (this behaviour can be " -"configured)." +#: documentation/source/atprotosocial.rst +msgid "Mentions & Replies" msgstr "" -#: ../doc/strings.py:55 -msgid "## General concepts" +#: documentation/source/atprotosocial.rst +msgid "These will appear in your Notifications." msgstr "" -#: ../doc/strings.py:57 -msgid "" -"Before starting to describe TWBlue's usage, we'll explain some concepts " -"that will be used extensively throughout this manual." +#: documentation/source/atprotosocial.rst +msgid "Notifications" msgstr "" -#: ../doc/strings.py:59 -msgid "### Buffer" +#: documentation/source/atprotosocial.rst +msgid "Receive notifications for likes, reposts, follows, mentions, replies, and quotes." msgstr "" -#: ../doc/strings.py:61 -msgid "" -"A buffer is a list of items to manage the data which arrives from " -"Twitter, after being processed by the application. When you configure a " -"new session on TWBlue and start it, many buffers are created. Each of " -"them may contain some of the items which this program works with: Tweets," -" direct messages, users, trends or. According to the buffer you are " -"focusing, you will be able to do different actions with these items." +#: documentation/source/atprotosocial.rst +msgid "User Actions" msgstr "" -#: ../doc/strings.py:63 -msgid "" -"The following is a description for every one of TWBlue's buffers and the " -"kind of items they work with." +#: documentation/source/atprotosocial.rst +msgid "Follow and unfollow users." msgstr "" -#: ../doc/strings.py:65 -msgid "" -"* Home: this shows all the tweets on the main timeline. These are the " -"tweets by users you follow." +#: documentation/source/atprotosocial.rst +msgid "Mute and unmute users." msgstr "" -#: ../doc/strings.py:66 -msgid "" -"* Mentions: if a user, whether you follow them or not, mentions you on " -"Twitter, you will find it in this list." +#: documentation/source/atprotosocial.rst +msgid "Block and unblock users (blocking is done on your PDS/server)." msgstr "" -#: ../doc/strings.py:67 -msgid "" -"* Direct messages: here you will find the private direct messages you " -"exchange with users who follow you , or with any user, if you allow " -"direct messages from everyone (this setting is configurable from " -"Twitter). This list only shows received messages." +#: documentation/source/atprotosocial.rst +msgid "Quoting Posts" msgstr "" -#: ../doc/strings.py:68 -msgid "" -"* Sent direct messages: this buffer shows all the direct messages sent " -"from your account." +#: documentation/source/atprotosocial.rst +msgid "Quote other users' posts when you create a new post." msgstr "" -#: ../doc/strings.py:69 -msgid "* Sent tweets: this shows all the tweets sent from your account." +#: documentation/source/atprotosocial.rst +msgid "User Search" msgstr "" -#: ../doc/strings.py:70 -msgid "* Likes: here you will see all the tweets you have liked." +#: documentation/source/atprotosocial.rst +msgid "Search for users by their handle or display name." msgstr "" -#: ../doc/strings.py:71 -msgid "" -"* Followers: when users follow you, you'll be able to see them on this " -"buffer, with some of their account details." +#: documentation/source/atprotosocial.rst +msgid "Content Warnings" msgstr "" -#: ../doc/strings.py:72 -msgid "" -"* Friends: the same as the previous buffer, but these are the users you " -"follow." +#: documentation/source/atprotosocial.rst +msgid "Create posts with content warnings (sensitive content labels)." msgstr "" -#: ../doc/strings.py:73 -msgid "" -"* User timelines: these are buffers you may create. They contain only the" -" tweets by a specific user. They're used so you can see the tweets by a " -"single person and you don't want to look all over your timeline. You may " -"create as many as you like." +#: documentation/source/atprotosocial.rst +msgid "Basic Concepts for ATProtoSocial" msgstr "" -#: ../doc/strings.py:74 -msgid "" -"* Lists: A list is similar to a user timeline, except that you can " -"configure it to contain tweets from multiple users." +#: documentation/source/atprotosocial.rst +msgid "Further details on specific actions can be found in the relevant sections of this documentation. As Bluesky and the AT Protocol evolve, TWBlue will aim to incorporate new features and refinements." msgstr "" -#: ../doc/strings.py:75 -msgid "* Search: A search buffer contains the results of a search operation." +#: documentation/source/basic_concepts.rst +msgid "ATProtoSocial / Bluesky Specific Terms" msgstr "" -#: ../doc/strings.py:76 -msgid "" -"* User likes: You can have the program create a buffer containing tweets " -"liked by a particular user." +#: documentation/source/basic_concepts.rst +msgid "When using the ATProtoSocial (Bluesky) integration, you might encounter these terms:" msgstr "" -#: ../doc/strings.py:77 -msgid "" -"* Followers or following timeline: You can have TWBlue create a buffer " -"containing all users who follow, or are followed by a specific user." +#: documentation/source/basic_concepts.rst +msgid "Handle" msgstr "" -#: ../doc/strings.py:78 -msgid "" -"* Trending Topics: a trend buffer shows the top ten most used terms in a " -"geographical region. This region may be a country or a city. Trends are " -"updated every five minutes." +#: documentation/source/basic_concepts.rst +msgid "Your user-facing address on Bluesky (e.g., ``@username.bsky.social`` or a custom domain like ``@yourname.com``). This is what you use to log in with an App Password in TWBlue. Handles can be changed, but your DID remains the same." msgstr "" -#: ../doc/strings.py:80 -msgid "" -"If a tweet contains a URL, you can press enter in the GUI or Control + " -"Windows + Enter in the invisible interface to open it. If it contains " -"video or audio, including live stream content, you can press Control + " -"Enter or Control + Windows + Alt + Enter to play it, respectively. TWBlue" -" will play a sound if the tweet contains video metadata or the \\#audio " -"hashtag, but there may be tweets which contain media without this. " -"Finally, if a tweet contains geographical information, you can press " -"Control + Windows + G in the invisible interface to retrieve it." +#: documentation/source/basic_concepts.rst +msgid "App Password" msgstr "" -#: ../doc/strings.py:82 -msgid "### Username fields" +#: documentation/source/basic_concepts.rst +msgid "A specific password you generate within your Bluesky account settings (usually under Settings -> Advanced -> App passwords) for use with third-party applications like TWBlue. This is more secure than using your main account password, as each App Password can be revoked individually." msgstr "" -#: ../doc/strings.py:84 -msgid "" -"These fields accept a Twitter username (without the at sign) as the " -"input. They are present in the send direct message, the user actions " -"dialogue and the user alias dialogue boxes, to name a few examples. Those" -" dialogues will be discussed later. The initial value of these fields " -"depends on where they were opened from. They are prepopulated with the " -"username of the sender of the focused tweet (if they were opened from the" -" home and sent timelines, from users' timelines or from lists), the " -"sender of the focused direct message (if from the received or sent direct" -" message buffers) or in the focused user (if from the followers' or " -"friends' buffer). If one of those dialogue boxes is opened from a tweet, " -"and if there are more users mentioned in it, you can use the arrow keys " -"to switch between them. Alternatively, you can also type a username." +#: documentation/source/basic_concepts.rst +msgid "DID (Decentralized Identifier)" msgstr "" -#: ../doc/strings.py:86 -msgid "## The program's interfaces" +#: documentation/source/basic_concepts.rst +msgid "A unique, permanent identifier for users and data on the AT Protocol. It typically starts with ``did:plc:``. Your DID doesn't change even if your handle does. You generally won't need to interact with DIDs directly in TWBlue, as handles are used more commonly for user interaction." msgstr "" -#: ../doc/strings.py:88 -msgid "### The graphical user interface (GUI)" +#: documentation/source/basic_concepts.rst +msgid "Skyline" msgstr "" -#: ../doc/strings.py:90 -msgid "The graphical user interface of TWBlue consists of a window containing:" +#: documentation/source/basic_concepts.rst +msgid "This is the term Bluesky uses for your main home timeline, showing posts from people you follow." msgstr "" -#: ../doc/strings.py:92 -msgid "" -"* a menu bar accomodating six menus (application, tweet, user, buffer, " -"audio and help);" +#: documentation/source/basic_concepts.rst +msgid "Skeet" msgstr "" -#: ../doc/strings.py:93 -msgid "* One tree view," +#: documentation/source/basic_concepts.rst +msgid "An informal term for a post on Bluesky (akin to a \"tweet\" on Twitter)." msgstr "" - -#: ../doc/strings.py:94 -msgid "* One list of items" -msgstr "" - -#: ../doc/strings.py:95 -msgid "* Four buttons in most dialogs: Tweet, retweet , reply and direct message." -msgstr "" - -#: ../doc/strings.py:97 -msgid "The actions that are available for every item will be described later." -msgstr "" - -#: ../doc/strings.py:99 -msgid "" -"In summary, the GUI contains two core components. These are the controls " -"you will find while pressing the Tab key within the program's interface, " -"and the different elements present on the menu bar." -msgstr "" - -#: ../doc/strings.py:101 -msgid "#### Buttons in the application" -msgstr "" - -#: ../doc/strings.py:103 -msgid "" -"* Tweet: this button opens up a dialogue box to write your tweet. Normal " -"tweets must not exceed 280 characters. However you can press the long " -"tweet checkbox and your tweet will be posted throught Twishort, wich will" -" allow you to write longer tweets (10000 characters). If you write past " -"this limit, a sound will play to warn you. Note that the character count " -"is displayed in the title bar. You can upload a picture, check spelling, " -"attach audio or translate your message by selecting one of the available " -"buttons in the dialogue box. In addition, you can autocomplete the " -"entering of users by pressing Alt + C or the button for that purpose if " -"you have the database of users configured. Press enter to send the tweet." -" If all goes well, you'll hear a sound confirming it. Otherwise, the " -"screen reader will speak an error message in English describing the " -"problem." -msgstr "" - -#: ../doc/strings.py:104 -msgid "" -"* Retweet: this button retweets the message you're reading. After you " -"press it, if you haven't configured the application not to do so, you'll " -"be asked if you want to add a comment or simply send it as written. If " -"you choose to add a comment, it will post a quoted tweet, that is, the " -"comment with a link to the originating tweet." -msgstr "" - -#: ../doc/strings.py:105 -msgid "" -"* Reply: when you're viewing a tweet, you can reply to the user who sent " -"it by pressing this button. A dialogue will open up similar to the one " -"for tweeting. If there are more users referred to in the tweet, you can " -"press tab and activate the mention to all checkbox, or enabling checkbox " -"for the users you want to mention separately. Note, however, that " -"sometimes -especially when replying to a retweet or quoted tweet, the " -"user who made the retweet or quote may also be mentioned. This is done by" -" Twitter automatically. When you're on the friends or followers lists, " -"the button will be called mention instead." -msgstr "" - -#: ../doc/strings.py:106 -msgid "" -"* Direct message: exactly like sending a tweet, but it's a private " -"message which can only be read by the user you send it to. Press shift-" -"tab twice to see the recipient. If there were other users mentioned in " -"the tweet you were reading, you can arrow up or down to choose which one " -"to send it to, or write the username yourself without the at sign. In " -"addition, you can autocomplete the entering of users by pressing Alt + C " -"or the button for that purpose if you have the database of users " -"configured." -msgstr "" - -#: ../doc/strings.py:108 -msgid "" -"Bear in mind that buttons will appear according to which actions are " -"possible on the list you are browsing. For example, on the home timeline," -" mentions, sent, likes and user timelines you will see the four buttons, " -"while on the direct messages list you'll only get the direct message and " -"tweet buttons, and on friends and followers lists the direct message, " -"tweet, and mention buttons will be available." -msgstr "" - -#: ../doc/strings.py:110 -msgid "#### Menus" -msgstr "" - -#: ../doc/strings.py:112 -msgid "" -"Visually, Towards the top of the main application window, can be found a " -"menu bar which contains many of the same functions as listed in the " -"previous section, together with some additional items. To access the menu" -" bar, press the alt key. You will find six menus listed: application, " -"tweet, user, buffer, audio and help. This section describes the items on " -"each one of them." -msgstr "" - -#: ../doc/strings.py:114 -msgid "##### Application menu" -msgstr "" - -#: ../doc/strings.py:116 -msgid "" -"* Manage accounts: Opens a window with all the sessions configured in " -"TWBlue, where you can add new sessions or delete the ones you've already " -"created." -msgstr "" - -#: ../doc/strings.py:117 -msgid "" -"* Update profile: opens a dialogue where you can update your information " -"on Twitter: name, location, website and bio. If you have already set this" -" up the fields will be prefilled with the existing information. Also, you" -" can upload a photo to your profile." -msgstr "" - -#: ../doc/strings.py:118 -msgid "" -"* Hide window: turns off the Graphical User Interface. Read the section " -"on the invisible interface for further details." -msgstr "" - -#: ../doc/strings.py:119 -msgid "" -"* Search: shows a dialogue box where you can search for tweets or users " -"on Twitter." -msgstr "" - -#: ../doc/strings.py:120 -msgid "" -"* Lists Manager: This dialogue box allows you to manage your Twitter " -"lists. In order to use them, you must first create them. Here, you can " -"view, edit, create, delete or, optionally, open them in buffers similar " -"to user timelines." -msgstr "" - -#: ../doc/strings.py:121 -msgid "" -"* Manage user aliases: Opens up a dialogue where you can manage user " -"aliases for the active session. In this dialog you can add new aliases, " -"as well as edit and delete existing ones." -msgstr "" - -#: ../doc/strings.py:122 -msgid "" -"* Edit keystrokes: this opens a dialogue where you can see and edit the " -"shortcuts used in the invisible interface." -msgstr "" - -#: ../doc/strings.py:123 -msgid "" -"* Account settings: Opens a dialogue box which lets you customize " -"settings for the current account." -msgstr "" - -#: ../doc/strings.py:124 -msgid "" -"* Global settings: Opens a dialogue which lets you configure settings for" -" the entire application." -msgstr "" - -#: ../doc/strings.py:125 -msgid "" -"* Exit: asks whether you want to exit the program. If the answer is yes, " -"it closes the application. If you do not want to be asked for " -"confirmation before exiting, uncheck the checkbox from the global " -"settings dialogue box." -msgstr "" - -#: ../doc/strings.py:127 -msgid "##### Tweet menu" -msgstr "" - -#: ../doc/strings.py:129 -msgid "" -"* You will first find the items to tweet, reply and retweet, which are " -"equivalent to the buttons with the same name." -msgstr "" - -#: ../doc/strings.py:130 -msgid "* Like: Adds the tweet you're viewing to your likes list." -msgstr "" - -#: ../doc/strings.py:131 -msgid "* Unlike: removes the tweet from your likes, but not from Twitter." -msgstr "" - -#: ../doc/strings.py:132 -msgid "" -"* Show tweet: opens up a dialogue box where you can read the tweet, " -"direct message, friend or follower which has focus. You can read the text" -" with the arrow keys. It's a similar dialog box as used for composing " -"tweets, without the ability to send the tweet, file attachment and " -"autocompleting capabilities. It does however include a retweets and likes" -" count. If you are in the followers or the friends list, it will only " -"contain a read-only edit box with the information in the focused item and" -" a close button." -msgstr "" - -#: ../doc/strings.py:133 -msgid "" -"* View address: If the selected tweet has geographical information, " -"TWBlue may display a dialogue box where you can read the tweet address. " -"This address is retrieved by sending the geographical coordinates of the " -"tweet to Google maps." -msgstr "" - -#: ../doc/strings.py:134 -msgid "" -"* View conversation: If you are focusing a tweet with a mention, it opens" -" a buffer where you can view the whole conversation." -msgstr "" - -#: ../doc/strings.py:135 -msgid "" -"* Read text in pictures: Attempt to apply OCR technology to the image " -"attached to the tweet. The result will be displayed in another dialog." -msgstr "" - -#: ../doc/strings.py:136 -msgid "" -"* Delete: permanently removes the tweet or direct message which has focus" -" from Twitter and from your lists. Bear in mind that Twitter only allows " -"you to delete tweets you have posted yourself." -msgstr "" - -#: ../doc/strings.py:138 -msgid "##### User menu" -msgstr "" - -#: ../doc/strings.py:140 -msgid "" -"* Actions: Opens a dialogue where you can interact with a user. This " -"dialogue box will be populated with the user who sent the tweet or direct" -" message in focus or the selected user in the friends or followers " -"buffer. You can edit it or leave it as is and choose one of the following" -" actions:" -msgstr "" - -#: ../doc/strings.py:141 -msgid "" -" * Follow: Follows a user. This means you'll see his/her tweets on your " -"home timeline, and if he/she also follows you, you'll be able to exchange" -" direct messages. You may also send / receive direct messages from each " -"other if you have configured the option to allow direct messages from " -"anyone." -msgstr "" - -#: ../doc/strings.py:142 -msgid "" -" * Unfollow: Stops following a user, which causes you not being able to " -"see his/her tweets on your main timeline neither exchanging direct " -"messages, unless they have enabled receiving direct messages from anyone." -msgstr "" - -#: ../doc/strings.py:143 -msgid "" -" * Mute: While muting someone, TWBlue won't show you nor his/her tweets " -"on your main timeline; neither will you see that person's mentions. But " -"you both will be able to exchange direct messages. The muted user is not " -"informed of this action." -msgstr "" - -#: ../doc/strings.py:144 -msgid "" -" * Unmute: this option allows TWBlue to display the user's tweets and " -"mentions again." -msgstr "" - -#: ../doc/strings.py:145 -msgid " * Block: Blocks a user. This forces the user to unfollow you ." -msgstr "" - -#: ../doc/strings.py:146 -msgid " * Unblock: Stops blocking a user." -msgstr "" - -#: ../doc/strings.py:147 -msgid "" -" * Report as spam: this option sends a message to Twitter suggesting the " -"user is performing prohibited practices on the social network." -msgstr "" - -#: ../doc/strings.py:148 -msgid "" -" * Ignore tweets from this client: Adds the client from which the focused" -" tweet was sent to the ignored clients list." -msgstr "" - -#: ../doc/strings.py:149 -msgid "" -"* View timeline: Lets you open a user's timeline by choosing the user in " -"a dialog box. It is created when you press enter. If you invoke this " -"option relative to a user that has no tweets, the operation will fail. If" -" you try creating an existing timeline the program will warn you and will" -" not create it again." -msgstr "" - -#: ../doc/strings.py:150 -msgid "* Direct message: same action as the button." -msgstr "" - -#: ../doc/strings.py:151 -msgid "" -"* Add Alias: An user alias allows you to rename user's display names on " -"Twitter, so the next time you'll read an user it will be announced as you" -" configured. This feature works only if you have set display screen names" -" unchecked, in account settings." -msgstr "" - -#: ../doc/strings.py:152 -msgid "" -"* Add to List: In order to see someone's tweets in one or more of your " -"lists, you must add them first. In the dialogue box that opens after " -"selecting the user, you will be asked to select the list you wish to add " -"the user to. Thereafter, the list will contain a new member and their " -"tweets will be displayed there." -msgstr "" - -#: ../doc/strings.py:153 -msgid "* Remove from list: lets you remove a user from a list." -msgstr "" - -#: ../doc/strings.py:154 -msgid "* View lists: Shows the lists created by a specified user." -msgstr "" - -#: ../doc/strings.py:155 -msgid "" -"* Show user profile: opens a dialogue with the profile of the specified " -"user." -msgstr "" - -#: ../doc/strings.py:156 -msgid "" -"* View likes: Opens a buffer where you can see the tweets which have been" -" liked by a particular user." -msgstr "" - -#: ../doc/strings.py:158 -msgid "##### Buffer menu" -msgstr "" - -#: ../doc/strings.py:160 -msgid "" -"* Update buffer: Retrieves the newest items for the focused buffer. " -"Normally, every buffer gets updated every couple of minutes, however you " -"can force a specific buffer to be updated inmediately. Take into account," -" however, that the usage of this option repeatedly might exceed your " -"allowed Twitter's API usage, in which case you would have to wait until " -"it gets reset, tipycally within the next 15 minutes." -msgstr "" - -#: ../doc/strings.py:161 -msgid "" -"* New trending topics buffer: This opens a buffer to get the worlwide " -"trending topics or those of a country or a city. You'll be able to select" -" from a dialogue box if you wish to retrieve countries' trends, cities' " -"trends or worldwide trends (this option is in the cities' list) and " -"choose one from the selected list. The trending topics buffer will be " -"created once the \"OK\" button has been activated within the dialogue " -"box. Remember this kind of buffer will be updated every five minutes." -msgstr "" - -#: ../doc/strings.py:162 -msgid "" -"* Load previous items: This allows more items to be loaded for the " -"specified buffer." -msgstr "" - -#: ../doc/strings.py:163 -msgid "" -"* Create filter: Creates a filter in the current buffer. Filters allow " -"loading or ignoring tweets that meet certain conditions into a buffer. " -"You can, for example, set a filter in the \"home\" buffer that loads " -"tweets that are in English language only. By default, the filter creation" -" dialog will place the focus on the field to name the filter. Currently, " -"you can filter by word, by language, or both. In the filter by word, you " -"can make TWBlue allow or ignore tweets with the desired word. In the " -"filter by language, you can make the program load tweets in the languages" -" you want, or ignore tweets written in certain languages. Once created, " -"every filter will be saved in the session config and will be kept across " -"application restarts." -msgstr "" - -#: ../doc/strings.py:164 -msgid "" -"* Manage filters: Opens up a dialogue which allows you to delete filters " -"for the current session." -msgstr "" - -#: ../doc/strings.py:165 -msgid "" -"* Find a string in the currently focused buffer: Opens a dialogue where " -"you can search for items in the current buffer." -msgstr "" - -#: ../doc/strings.py:166 -msgid "" -"* Mute: Mutes notifications of a particular buffer so you will not hear " -"when new tweets arrive." -msgstr "" - -#: ../doc/strings.py:167 -msgid "" -"* autoread: When enabled, the screen reader or SAPI 5 Text to Speech " -"voice (if enabled) will read the text of incoming tweets. Please note " -"that this could get rather chatty if there are a lot of incoming tweets." -msgstr "" - -#: ../doc/strings.py:168 -msgid "* Clear buffer: Deletes all items from the buffer." -msgstr "" - -#: ../doc/strings.py:169 -msgid "* Destroy: dismisses the list you're on." -msgstr "" - -#: ../doc/strings.py:171 -msgid "##### Audio menu" -msgstr "" - -#: ../doc/strings.py:173 -msgid "" -"* Play/pause: try to play audio for the selected item (if available), or " -"stop the currently played audio." -msgstr "" - -#: ../doc/strings.py:174 -msgid "" -"* Seek back 5 seconds: If an audio is being played, seek 5 seconds back " -"in the playback. This will work only in audio files. This feature cannot " -"be used in radio stations or other streamed files." -msgstr "" - -#: ../doc/strings.py:175 -msgid "" -"* Seek forward 5 seconds: If an audio is being played, seek 5 seconds " -"forward in the playback. This feature cannot be used in radio stations " -"or other streamed files." -msgstr "" - -#: ../doc/strings.py:177 -msgid "##### Help menu" -msgstr "" - -#: ../doc/strings.py:179 -msgid "" -"* Documentation: opens up this file, where you can read some useful " -"program concepts." -msgstr "" - -#: ../doc/strings.py:180 -msgid "" -"* Sounds tutorial: Opens a dialog box where you can familiarize yourself " -"with the different sounds of the program." -msgstr "" - -#: ../doc/strings.py:181 -msgid "" -"* What's new in this version?: opens up a document with the list of " -"changes from the current version to the earliest." -msgstr "" - -#: ../doc/strings.py:182 -msgid "" -"* Check for updates: every time you open the program it automatically " -"checks for new versions. If an update is available, it will ask you if " -"you want to download the update. If you accept, the updating process will" -" commence. When complete, TWBlue will be restarted. This item checks for " -"new updates without having to restart the application." -msgstr "" - -#: ../doc/strings.py:183 -msgid "" -"* TWBlue's website: visit our [home page](http://twblue.es) where you can" -" find all relevant information and downloads for TWBlue and become a part" -" of the community." -msgstr "" - -#: ../doc/strings.py:184 -msgid "* Get soundpacks for TWBlue: " -msgstr "" - -#: ../doc/strings.py:185 -msgid "" -"* Make a Donation: Opens a website from which you can donate to the " -"TWBlue project. Donations are made through paypal and you don't need an " -"account to donate." -msgstr "" - -#: ../doc/strings.py:186 -msgid "* About TWBlue: shows the credits of the program." -msgstr "" - -#: ../doc/strings.py:188 -msgid "### The invisible user interface" -msgstr "" - -#: ../doc/strings.py:190 -msgid "" -"The invisible interface, as its name suggests, has no graphical window " -"and works directly with screen readers such as JAWS for Windows, NVDA and" -" System Access. This interface is disabled by default, but you can enable" -" it by pressing Control + M. It works similarly to TheQube and Chicken " -"Nugget. Its shortcuts are similar to those found in these two clients. In" -" addition, the program has builtin support for the keymaps for these " -"applications, configurable through the global settings dialogue. By " -"default, you cannot use this interface's shortcuts in the GUI, but you " -"can configure this in the global settings dialogue." -msgstr "" - -#: ../doc/strings.py:192 -msgid "" -"The next section contains a list of keyboard shortcuts for both " -"interfaces. Bear in mind that we will only describe the default keymap." -msgstr "" - -#: ../doc/strings.py:194 -msgid "## Keyboard shortcuts" -msgstr "" - -#: ../doc/strings.py:196 -msgid "### Shortcuts of the graphical user interface (GUI)" -msgstr "" - -#: ../doc/strings.py:198 -msgid "* Enter: Open URL." -msgstr "" - -#: ../doc/strings.py:199 -msgid "* Control + Enter: Play audio." -msgstr "" - -#: ../doc/strings.py:200 -msgid "* Control + M: Hide the GUI." -msgstr "" - -#: ../doc/strings.py:201 -msgid "* Control + N: Compose a new tweet." -msgstr "" - -#: ../doc/strings.py:202 -msgid "* Control + R: Reply / mention." -msgstr "" - -#: ../doc/strings.py:203 -msgid "* Control + Shift + R: Retweet." -msgstr "" - -#: ../doc/strings.py:204 -msgid "* Control + D: Send a direct message." -msgstr "" - -#: ../doc/strings.py:205 -msgid "* control + F: Add tweet to likes." -msgstr "" - -#: ../doc/strings.py:206 -msgid "* Control + Shift + F: Remove a tweet from likes." -msgstr "" - -#: ../doc/strings.py:207 -msgid "* Control + S: Open the user actions dialogue." -msgstr "" - -#: ../doc/strings.py:208 -msgid "* Control + Shift + V: Show tweet." -msgstr "" - -#: ../doc/strings.py:209 -msgid "* Control + Q: Quit this program." -msgstr "" - -#: ../doc/strings.py:210 -msgid "* Control + I: Open user timeline." -msgstr "" - -#: ../doc/strings.py:211 -msgid "* Control + Shift + i: Destroy buffer." -msgstr "" - -#: ../doc/strings.py:212 -msgid "* F5: Increase volume by 5%." -msgstr "" - -#: ../doc/strings.py:213 -msgid "* F6: Decrease volume by 5%." -msgstr "" - -#: ../doc/strings.py:214 -msgid "* Control + P: Edit your profile." -msgstr "" - -#: ../doc/strings.py:215 -msgid "* Control + Delete: Delete a tweet or direct message." -msgstr "" - -#: ../doc/strings.py:216 -msgid "* Control + Shift + Delete: Empty the current buffer." -msgstr "" - -#: ../doc/strings.py:218 -msgid "### Shortcuts of the invisible interface (default keymap)" -msgstr "" - -#: ../doc/strings.py:220 -msgid "" -"The invisible interface of TWBlue can be customised by using a keymap. " -"Every keymap defines a set of keystrokes to be used along with the " -"invisible interface. You can change the keymap in the global settings " -"dialogue, under the application menu in the menu bar, and check or edit " -"keystrokes for the selected keymap in the keystroke editor, also " -"available in the application menu." -msgstr "" - -#: ../doc/strings.py:222 -msgid "* Control + Windows + Up Arrow: moves to the previous item in the buffer." -msgstr "" - -#: ../doc/strings.py:223 -msgid "* Control + Windows + Down Arrow: moves to the next item in the buffer." -msgstr "" - -#: ../doc/strings.py:224 -msgid "* Control + Windows + Left Arrow: Move to the previous buffer." -msgstr "" - -#: ../doc/strings.py:225 -msgid "* Control + Windows + Right Arrow: Move to the next buffer." -msgstr "" - -#: ../doc/strings.py:226 -msgid "* Control + Windows + Shift + Left: Focus the previous session." -msgstr "" - -#: ../doc/strings.py:227 -msgid "* Control + Windows + Shift + Right: Focus the next session." -msgstr "" - -#: ../doc/strings.py:228 -msgid "* Control + Windows + C: View conversation." -msgstr "" - -#: ../doc/strings.py:229 -msgid "* Control + Windows + Enter: Open URL." -msgstr "" - -#: ../doc/strings.py:230 -msgid "* Control + Windows + ALT + Enter: Play audio." -msgstr "" - -#: ../doc/strings.py:231 -msgid "* Control + Windows + M: Show or hide the GUI." -msgstr "" - -#: ../doc/strings.py:232 -msgid "* Control + Windows + N: New tweet." -msgstr "" - -#: ../doc/strings.py:233 -msgid "* Control + Windows + R: Reply / Mention." -msgstr "" - -#: ../doc/strings.py:234 -msgid "* Control + Windows + Shift + R: Retweet." -msgstr "" - -#: ../doc/strings.py:235 -msgid "* Control + Windows + D: Send direct message." -msgstr "" - -#: ../doc/strings.py:236 -msgid "* Windows+ Alt + F: Like a tweet." -msgstr "" - -#: ../doc/strings.py:237 -msgid "* Alt + Windows + Shift + F: Remove from likes." -msgstr "" - -#: ../doc/strings.py:238 -msgid "* Control + Windows + S: Open the user actions dialogue." -msgstr "" - -#: ../doc/strings.py:239 -msgid "* Control + Windows + Alt + N: See user details." -msgstr "" - -#: ../doc/strings.py:240 -msgid "* Control + Windows + V: Show tweet." -msgstr "" - -#: ../doc/strings.py:241 -msgid "* Control + Windows + F4: Quit TWBlue." -msgstr "" - -#: ../doc/strings.py:242 -msgid "* Control + Windows + I: Open user timeline." -msgstr "" - -#: ../doc/strings.py:243 -msgid "* Control + Windows + Shift + I: Destroy buffer." -msgstr "" - -#: ../doc/strings.py:244 -msgid "* Control + Windows + Alt + Up: Increase volume by 5%." -msgstr "" - -#: ../doc/strings.py:245 -msgid "* Control + Windows + Alt + Down: Decrease volume by 5%." -msgstr "" - -#: ../doc/strings.py:246 -msgid "" -"* Control + Windows + Home: Jump to the first element of the current " -"buffer." -msgstr "" - -#: ../doc/strings.py:247 -msgid "* Control + Windows + End: Jump to the last element of the current buffer." -msgstr "" - -#: ../doc/strings.py:248 -msgid "* Control + Windows + PageUp: Jump 20 elements up in the current buffer." -msgstr "" - -#: ../doc/strings.py:249 -msgid "" -"* Control + Windows + PageDown: Jump 20 elements down in the current " -"buffer." -msgstr "" - -#: ../doc/strings.py:250 -msgid "* Windows + Alt + P: Edit profile." -msgstr "" - -#: ../doc/strings.py:251 -msgid "* Control + Windows + Delete: Delete a tweet or direct message." -msgstr "" - -#: ../doc/strings.py:252 -msgid "* Control + Windows + Shift + Delete: Empty the current buffer." -msgstr "" - -#: ../doc/strings.py:253 -msgid "* Control + Windows + Space: Repeat last item." -msgstr "" - -#: ../doc/strings.py:254 -msgid "* Control + Windows + Shift + C: Copy to clipboard." -msgstr "" - -#: ../doc/strings.py:255 -msgid "* Control + Windows+ A: Add user to list." -msgstr "" - -#: ../doc/strings.py:256 -msgid "* Control + Windows + Shift + A: Remove user from list." -msgstr "" - -#: ../doc/strings.py:257 -msgid "* Control + Windows + Shift + M: Mute / unmute the current buffer." -msgstr "" - -#: ../doc/strings.py:258 -msgid "* Windows + Alt + M: Mute / unmute the current session." -msgstr "" - -#: ../doc/strings.py:259 -msgid "" -"* Control + Windows + E: Toggle the automatic reading of incoming tweets " -"in the current buffer." -msgstr "" - -#: ../doc/strings.py:260 -msgid "* Control + Windows + -: Search on Twitter." -msgstr "" - -#: ../doc/strings.py:261 -msgid "* Control + Windows + K: Show the keystroke editor." -msgstr "" - -#: ../doc/strings.py:262 -msgid "* Control + Windows + L: Show lists for a specified user." -msgstr "" - -#: ../doc/strings.py:263 -msgid "* Windows + Alt + PageUp: Load previous items for the current buffer." -msgstr "" - -#: ../doc/strings.py:264 -msgid "* Control + Windows + G: Get geolocation." -msgstr "" - -#: ../doc/strings.py:265 -msgid "" -"* Control + Windows + Shift + G: Display the tweet's geolocation in a " -"dialogue." -msgstr "" - -#: ../doc/strings.py:266 -msgid "* Control + Windows + T: Create a trending topics' buffer." -msgstr "" - -#: ../doc/strings.py:267 -msgid "* Control + Windows + {: Find a string in the current buffer." -msgstr "" - -#: ../doc/strings.py:268 -msgid "" -"* Alt + Windows + O: Extracts text from the picture and display the " -"result in a dialog." -msgstr "" - -#: ../doc/strings.py:270 -msgid "## Configuration" -msgstr "" - -#: ../doc/strings.py:272 -msgid "" -"As described above, this application has two configuration dialogues, the" -" global settings dialogue and the account settings dialogue." -msgstr "" - -#: ../doc/strings.py:274 -msgid "### The account settings dialogue" -msgstr "" - -#: ../doc/strings.py:276 -msgid "#### General tab" -msgstr "" - -#: ../doc/strings.py:278 -msgid "" -"* Autocompletion settings: Allows you to configure the autocompletion " -"database. You can add users manually or let TWBlue add your followers, " -"friends or both." -msgstr "" - -#: ../doc/strings.py:279 -msgid "" -"* Relative timestamps: Allows you to configure whether the application " -"will calculate the time the tweet or direct message was sent or received " -"based on the current time, or simply say the time it was received or " -"sent." -msgstr "" - -#: ../doc/strings.py:280 -msgid "" -"* API calls: Allows you to adjust the number of API calls to be made to " -"Twitter by this program." -msgstr "" - -#: ../doc/strings.py:281 -msgid "" -"* Items on each API call: Allows you to specify how many items should be " -"retrieved from Twitter for each API call (default and maximum is 200)." -msgstr "" - -#: ../doc/strings.py:282 -msgid "" -"* Inverted buffers: Allows you to specify whether the buffers should be " -"inverted, which means that the oldest items will show at the end of them " -"and the newest at the beginning." -msgstr "" - -#: ../doc/strings.py:283 -msgid "" -"* Retweet mode: Allows you to specify the behaviour when posting a " -"retweet: you can choose between retweeting with a comment, retweeting " -"without comment or being asked." -msgstr "" - -#: ../doc/strings.py:284 -msgid "" -"* Number of items per buffer to cache in database: This allows you to " -"specify how many items TWBlue should cache in a database. You can type " -"any number, 0 to cache all items, or leave blank to disable caching " -"entirely." -msgstr "" - -#: ../doc/strings.py:286 -msgid "#### buffers tab" -msgstr "" - -#: ../doc/strings.py:288 -msgid "" -"This tab displays a list for each buffer you have available in TWBlue, " -"except for searches, timelines, likes' timelines and lists. You can show," -" hide and move them." -msgstr "" - -#: ../doc/strings.py:290 -msgid "#### The ignored clients tab" -msgstr "" - -#: ../doc/strings.py:292 -msgid "In this tab, you can add and remove clients to be ignored by the program." -msgstr "" - -#: ../doc/strings.py:294 -msgid "#### Sound tab" -msgstr "" - -#: ../doc/strings.py:296 -msgid "" -"In this tab, you can adjust the sound volume, select the input and output" -" device and set the soundpack used by the program." -msgstr "" - -#: ../doc/strings.py:298 -msgid "#### Audio service tab" -msgstr "" - -#: ../doc/strings.py:300 -msgid "" -"In this tab, you can enter your SndUp API key (if you have one) to upload" -" audio to SndUp with your account. Note that if account credentials are " -"not specified you will upload anonimously." -msgstr "" - -#: ../doc/strings.py:302 -msgid "### Global settings" -msgstr "" - -#: ../doc/strings.py:304 -msgid "" -"This dialogue allows you to configure some settings which will affect the" -" entire application." -msgstr "" - -#: ../doc/strings.py:306 -msgid "#### General tab {#general-tab_1}" -msgstr "" - -#: ../doc/strings.py:308 -msgid "" -"* Language: This allows you to change the language of this program. " -"Currently supported languages are arabic, Catalan, German, English, " -"Spanish, Basque, Finnish, French, Galician, Croatian, Hungarian, Italian," -" Polish, Portuguese, Russian and Turkish." -msgstr "" - -#: ../doc/strings.py:309 -msgid "" -"* Ask before exiting TWBlue: This checkbox allows you to control " -"whetherthe program will ask for confirmation before exiting." -msgstr "" - -#: ../doc/strings.py:310 -msgid "" -"* Play a sound when TWBlue launches: This checkbox allows you to " -"configure whether the application will play a sound when it has finished " -"loading the buffers." -msgstr "" - -#: ../doc/strings.py:311 -msgid "" -"* Speak a message when TWBlue launches: This is the same as the previous " -"option, but this checkbox configures whether the screen reader will say " -"\"ready\"." -msgstr "" - -#: ../doc/strings.py:312 -msgid "" -"* Use the invisible interface's shortcuts in the GUI: As the invisible " -"interface and the Graphical User Interface have their own shortcuts, you " -"may want to use the invisible interface's keystrokes all the time. If " -"this option is checked, the invisible interface's shortcuts will be " -"usable in the GUI." -msgstr "" - -#: ../doc/strings.py:313 -msgid "" -"* Activate SAPI5 when any other screen reader is not being run: This " -"checkbox allows to activate SAPI 5 TTS when no other screen reader is " -"being run." -msgstr "" - -#: ../doc/strings.py:314 -msgid "" -"* Hide GUI on launch: This allows you to configure whether TWBlue will " -"start with the GUI or the invisible interface." -msgstr "" - -#: ../doc/strings.py:315 -msgid "" -"* Keymap: This option allows you to change the keymap used by the program" -" in the invisible interface. The shipped keymaps are Default, Qwitter, " -"Windows 10 and Chicken Nugget. The keymaps are in the \"keymaps\" folder," -" and you can create new ones. Just create a new \".keymap\" file and " -"change the keystrokes associated with the actions, as it is done in the " -"shipped keymaps." -msgstr "" - -#: ../doc/strings.py:317 -msgid "#### Proxi tab" -msgstr "" - -#: ../doc/strings.py:319 -msgid "" -"In this tab you can configure TWBlue to use a Proxy server by completing " -"the fields displayed (type, server, port, user and password)." -msgstr "" - -#: ../doc/strings.py:321 -msgid "## License, source code and donations" -msgstr "" - -#: ../doc/strings.py:323 -msgid "" -"Tw Blue is free software, licensed under the GNU GPL license, either " -"version 2 or, at your option, any later version. You can view the license" -" in the file named license.txt, or online at ." -msgstr "" - -#: ../doc/strings.py:325 -msgid "" -"The source code of the program is available on GitHub at " -"." -msgstr "" - -#: ../doc/strings.py:327 -msgid "" -"If you want to donate to the project, you can do so at " -". Thank you for your support!" -msgstr "" - -#: ../doc/strings.py:329 -msgid "## Contact" -msgstr "" - -#: ../doc/strings.py:331 -msgid "" -"If you still have questions after reading this document, if you wish to " -"collaborate to the project in some other way, or if you simply want to " -"get in touch with the application developer, follow the Twitter account " -"[@tw\\_blue2](https://twitter.com/tw_blue2) or " -"[@manuelcortez00.](https://twitter.com/manuelcortez00) You can also visit" -" [our website](https://twblue.es)" -msgstr "" - -#: ../doc/strings.py:333 -msgid "## Credits" -msgstr "" - -#: ../doc/strings.py:335 -msgid "" -"TWBlue is developed and maintained by [Manuel " -"Cortéz](https://twitter.com/manuelcortez00) and [José Manuel " -"Delicado](https://twitter.com/jmdaweb)." -msgstr "" - -#: ../doc/strings.py:337 -msgid "" -"We would also like to thank the translators of TWBlue, who have allowed " -"the spreading of the application." -msgstr "" - -#: ../doc/strings.py:339 -msgid "" -"* Arabic: [Mohammed Al Shara,](https://twitter.com/mohammed0204) [Hatoun " -"Felemban](https://twitter.com/HatounFelemban)" -msgstr "" - -#: ../doc/strings.py:340 -msgid "* Basque: [Sukil Etxenike](https://twitter.com/sukil2011)." -msgstr "" - -#: ../doc/strings.py:341 -msgid "* Catalan: [Francisco Torres](https://twitter.com/ftgalleg)" -msgstr "" - -#: ../doc/strings.py:342 -msgid "* Croatian: [Zvonimir Stanečić](https://twitter.com/zvonimirek222)." -msgstr "" - -#: ../doc/strings.py:343 -msgid "* English: [Manuel Cortéz](https://twitter.com/manuelcortez00)." -msgstr "" - -#: ../doc/strings.py:344 -msgid "* Finnish: [Jani Kinnunen](https://twitter.com/jani_kinnunen)." -msgstr "" - -#: ../doc/strings.py:345 -msgid "* French: [Rémy Ruiz](https://twitter.com/blindhelp38)." -msgstr "" - -#: ../doc/strings.py:346 -msgid "* Galician: [Juan Buño](https://twitter.com/Quetzatl_)." -msgstr "" - -#: ../doc/strings.py:347 -msgid "* German: [Steffen Schultz](https://twitter.com/schulle4u)." -msgstr "" - -#: ../doc/strings.py:348 -msgid "* Hungarian: Robert Osztolykan." -msgstr "" - -#: ../doc/strings.py:349 -msgid "* Italian: [Christian Leo Mameli](https://twitter.com/llajta2012)." -msgstr "" - -#: ../doc/strings.py:350 -msgid "* Japanese: [Riku](https://twitter.com/_riku02)" -msgstr "" - -#: ../doc/strings.py:351 -msgid "* Polish: [Pawel Masarczyk.](https://twitter.com/Piciok)" -msgstr "" - -#: ../doc/strings.py:352 -msgid "* Portuguese: [Odenilton Júnior Santos.](https://twitter.com/romaleif)" -msgstr "" - -#: ../doc/strings.py:353 -msgid "" -"* Romanian: [Florian Ionașcu](https://twitter.com/florianionascu7) and " -"[Nicușor Untilă](https://twitter.com/dj_storm2001)" -msgstr "" - -#: ../doc/strings.py:354 -msgid "" -"* Russian: [Наталья Хедлунд](https://twitter.com/Lifestar_n) and [Валерия" -" Кузнецова](https://twitter.com/ValeriaK305)." -msgstr "" - -#: ../doc/strings.py:355 -msgid "* Serbian: [Aleksandar Đurić](https://twitter.com/sokodtreshnje)" -msgstr "" - -#: ../doc/strings.py:356 -msgid "* Spanish: [Manuel Cortéz](https://twitter.com/manuelcortez00)." -msgstr "" - -#: ../doc/strings.py:357 -msgid "* Turkish: [Burak Yüksek](https://twitter.com/burakyuksek)." -msgstr "" - -#: ../doc/strings.py:359 -msgid "" -"Many thanks also to the people who worked on the documentation. " -"Initially, [Manuel Cortez](https://twitter.com/manuelcortez00) did the " -"documentation in Spanish, and translated to English by [Bryner " -"Villalobos](https://twitter.com/Bry_StarkCR), [Robert " -"Spangler](https://twitter.com/glasscity1837), [Sussan " -"Rey](https://twitter.com/sussanrey17), [Anibal " -"Hernandez](https://twitter.com/AnimalMetal), and [Holly Scott-" -"Gardner](https://twitter.com/CatchTheseWords). It was updated by [Sukil " -"Etxenike](https://twitter.com/sukil2011), with some valuable corrections " -"by [Brian Hartgen](https://twitter.com/brianhartgen) and [Bill " -"Dengler](https://twitter.com/codeofdusk)." -msgstr "" - -#: ../doc/strings.py:361 -msgid "------------------------------------------------------------------------" -msgstr "" - -#: ../doc/strings.py:363 -msgid "Copyright © 2013-2021. Manuel Cortéz" -msgstr "" - diff --git a/tools/twblue.pot b/tools/twblue.pot index a1992f85..412d1821 100644 --- a/tools/twblue.pot +++ b/tools/twblue.pot @@ -1,4422 +1,642 @@ # Translations template for PROJECT. -# Copyright (C) 2022 MCV software +# Copyright (C) 2024 MCV software # This file is distributed under the same license as the PROJECT project. -# FIRST AUTHOR , 2022. +# FIRST AUTHOR , 2024. # #, fuzzy msgid "" msgstr "" -"Project-Id-Version: PROJECT VERSION\n" -"Report-Msgid-Bugs-To: manuel@manuelcortez.net\n" -"POT-Creation-Date: 2022-12-20 17:15-0600\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.10.3\n" +"Project-Id-Version: TWBlue VERSION\\n" +"Report-Msgid-Bugs-To: manuel@manuelcortez.net\\n" +"POT-Creation-Date: 2024-05-26 12:00+0000\\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n" +"Last-Translator: FULL NAME \\n" +"Language-Team: LANGUAGE \\n" +"MIME-Version: 1.0\\n" +"Content-Type: text/plain; charset=utf-8\\n" +"Content-Transfer-Encoding: 8bit\\n" +"Generated-By: TWBlue Manual Process\\n" -#: ../src/languageHandler.py:61 -msgctxt "languageName" -msgid "Amharic" +#: src/sessions/atprotosocial/session.py +msgid "Invalid handle or app password." msgstr "" -#: ../src/languageHandler.py:62 -msgctxt "languageName" -msgid "Aragonese" +#: src/sessions/atprotosocial/session.py +msgid "Login failed: {error} - {message}" msgstr "" -#: ../src/languageHandler.py:63 -msgctxt "languageName" -msgid "Spanish" +#: src/sessions/atprotosocial/session.py +msgid "An unexpected error occurred during login: {error}" msgstr "" -#: ../src/languageHandler.py:64 -msgctxt "languageName" -msgid "Portuguese" +#: src/sessions/atprotosocial/session.py +msgid "Enter your Bluesky handle (e.g., username.bsky.social):" msgstr "" -#: ../src/languageHandler.py:65 -msgctxt "languageName" -msgid "Russian" +#: src/sessions/atprotosocial/session.py +msgid "Bluesky Login" msgstr "" -#: ../src/languageHandler.py:66 -msgctxt "languageName" -msgid "italian" +#: src/sessions/atprotosocial/session.py +msgid "Enter your Bluesky App Password (generate one in Bluesky settings):" msgstr "" -#: ../src/languageHandler.py:67 -msgctxt "languageName" -msgid "Turkey" +#: src/sessions/atprotosocial/session.py +msgid "Successfully logged into Bluesky!" msgstr "" -#: ../src/languageHandler.py:68 -msgctxt "languageName" -msgid "Galician" +#: src/sessions/atprotosocial/session.py +msgid "Login Success" msgstr "" -#: ../src/languageHandler.py:69 -msgctxt "languageName" -msgid "Catala" +#: src/sessions/atprotosocial/session.py +msgid "Login failed. Please check your handle and app password." msgstr "" -#: ../src/languageHandler.py:70 -msgctxt "languageName" -msgid "Vasque" +#: src/sessions/atprotosocial/session.py +msgid "Login Failed" msgstr "" -#: ../src/languageHandler.py:71 -msgctxt "languageName" -msgid "polish" +#: src/sessions/atprotosocial/session.py +msgid "ATProtoSocial Authentication Error" msgstr "" -#: ../src/languageHandler.py:72 -msgctxt "languageName" -msgid "Arabic" +#: src/sessions/atprotosocial/session.py +msgid "Cannot display login dialogs. Please check application logs." msgstr "" -#: ../src/languageHandler.py:73 -msgctxt "languageName" -msgid "Nepali" +#: src/sessions/atprotosocial/session.py +msgid "Bluesky handle is required." msgstr "" -#: ../src/languageHandler.py:74 -msgctxt "languageName" -msgid "Serbian (Latin)" +#: src/sessions/atprotosocial/session.py +msgid "App Password (generate one in Bluesky settings)" msgstr "" -#: ../src/languageHandler.py:75 -msgctxt "languageName" -msgid "Japanese" +#: src/sessions/atprotosocial/session.py +msgid "Handle and App Password are required." msgstr "" -#: ../src/languageHandler.py:99 -msgid "User default" +#: src/sessions/atprotosocial/session.py +msgid "Successfully connected and authenticated with Bluesky." msgstr "" -#: ../src/build/lib/main.py:109 ../src/main.py:109 -msgid "https://twblue.es/donate" +#: src/sessions/atprotosocial/session.py +msgid "Authentication succeeded but no profile data was returned." msgstr "" -#: ../src/build/lib/main.py:126 ../src/main.py:122 -msgid "" -"{0} is already running. Close the other instance before starting this " -"one. If you're sure that {0} isn't running, try deleting the file at {1}." -" If you're unsure of how to do this, contact the {0} developers." +#: src/sessions/atprotosocial/session.py +msgid "Connection failed: {error_details}" msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:136 ../src/sound.py:148 -msgid "Playing..." +#: src/sessions/atprotosocial/session.py +msgid "{author_name} quoted your post" msgstr "" -#: ../src/sound.py:161 -msgid "Stopped." +#: src/sessions/atprotosocial/session.py +msgid "An unexpected error occurred while fetching notifications." msgstr "" -#: ../src/controller/mainController.py:285 -msgid "Ready" +#: src/sessions/atprotosocial/session.py +msgid "Session is not active. Please log in or check your connection." msgstr "" -#: ../src/controller/mainController.py:395 -#: ../src/controller/mainController.py:411 -#: ../src/controller/mainController.py:729 -#: ../src/controller/mainController.py:748 -#: ../src/controller/mainController.py:767 -#: ../src/controller/mainController.py:786 -msgid "" -"No session is currently in focus. Focus a session with the next or " -"previous session shortcut." +#: src/sessions/atprotosocial/session.py +msgid "An error occurred while fetching your home timeline." msgstr "" -#: ../src/controller/mainController.py:399 -msgid "Empty buffer." +#: src/sessions/atprotosocial/session.py +msgid "An error occurred while fetching the user's timeline." msgstr "" -#: ../src/controller/mainController.py:406 -msgid "{0} not found." +#: src/sessions/atprotosocial/session.py +msgid "Unsupported File Skipped" msgstr "" -#: ../src/controller/mainController.py:777 -#: ../src/controller/mainController.py:796 -#, python-format -msgid "%s, %s of %s" +#: src/sessions/atprotosocial/session.py +msgid "File {filename} has an unsupported type and was not attached." msgstr "" -#: ../src/controller/mainController.py:779 -#: ../src/controller/mainController.py:798 -#: ../src/controller/mainController.py:823 -#: ../src/controller/mainController.py:848 -#, python-format -msgid "%s. Empty" +#: src/sessions/atprotosocial/session.py +msgid "Media Upload Failed" msgstr "" -#: ../src/controller/mainController.py:811 -#: ../src/controller/mainController.py:815 -#: ../src/controller/mainController.py:836 -msgid "{0}: This account is not logged into Twitter." +#: src/sessions/atprotosocial/session.py +msgid "Failed to upload {filename}. It will not be attached." msgstr "" -#: ../src/controller/mainController.py:821 -#: ../src/controller/mainController.py:846 -#, python-format -msgid "%s. %s, %s of %s" +#: src/sessions/atprotosocial/session.py +msgid "Media Upload Error" msgstr "" -#: ../src/controller/mainController.py:840 -msgid "{0}: This account is not logged into twitter." +#: src/sessions/atprotosocial/session.py +msgid "An error occurred while uploading {filename}: {error}" msgstr "" -#: ../src/controller/mainController.py:1047 -#: ../src/controller/mainController.py:1063 -msgid "An error happened while trying to connect to the server. Please try later." +#: src/sessions/atprotosocial/session.py +msgid "Failed to send post. The server did not confirm the post creation." msgstr "" -#: ../src/controller/mainController.py:1104 -msgid "The auto-reading of new tweets is enabled for this buffer" +#: src/sessions/atprotosocial/session.py +msgid "An unexpected error occurred while sending the post: {error}" msgstr "" -#: ../src/controller/mainController.py:1107 -msgid "The auto-reading of new tweets is disabled for this buffer" +#: src/sessions/atprotosocial/utils.py +msgid "Not connected to ATProtoSocial. Please check your connection settings or log in." msgstr "" -#: ../src/controller/mainController.py:1114 -msgid "Session mute on" +#: src/sessions/atprotosocial/utils.py +msgid "User identity not found. Cannot create post." msgstr "" -#: ../src/controller/mainController.py:1117 -msgid "Session mute off" +#: src/sessions/atprotosocial/utils.py +msgid "Failed to post: {error} - {message}" msgstr "" -#: ../src/controller/mainController.py:1126 -msgid "Buffer mute on" +#: src/sessions/atprotosocial/utils.py +msgid "An unexpected error occurred while posting: {error}" msgstr "" -#: ../src/controller/mainController.py:1129 -msgid "Buffer mute off" +#: src/sessions/atprotosocial/utils.py +msgid "You have already reposted this post." msgstr "" -#: ../src/controller/mainController.py:1146 -msgid "Copied" +#: src/sessions/atprotosocial/utils.py +msgid "Failed to repost: {error}" msgstr "" -#: ../src/controller/mainController.py:1176 -msgid "Unable to update this buffer." +#: src/sessions/atprotosocial/utils.py +msgid "An unexpected error occurred while reposting." msgstr "" -#: ../src/controller/mainController.py:1178 -msgid "Updating buffer..." +#: src/sessions/atprotosocial/utils.py +msgid "You have already liked this post." msgstr "" -#: ../src/controller/mainController.py:1181 -msgid "{0} items retrieved" +#: src/sessions/atprotosocial/utils.py +msgid "Failed to like post: {error}" msgstr "" -#: ../src/controller/mainController.py:1185 -#: ../src/controller/mastodon/handler.py:192 -#: ../src/controller/twitter/handler.py:85 -#: ../src/controller/twitter/handler.py:271 -msgid "Timeline for {}" +#: src/sessions/atprotosocial/utils.py +msgid "An unexpected error occurred while liking the post." msgstr "" -#: ../src/controller/mainController.py:1187 -#: ../src/controller/twitter/handler.py:89 -#: ../src/controller/twitter/handler.py:282 -msgid "Likes for {}" +#: src/sessions/atprotosocial/utils.py +msgid "Could not find the post to repost." msgstr "" -#: ../src/controller/mainController.py:1189 -#: ../src/controller/mastodon/handler.py:93 -#: ../src/controller/mastodon/handler.py:203 -#: ../src/controller/twitter/handler.py:93 -#: ../src/controller/twitter/handler.py:293 -msgid "Followers for {}" +#: src/sessions/atprotosocial/utils.py +msgid "Could not find the post to like." msgstr "" -#: ../src/controller/mainController.py:1191 -#: ../src/controller/twitter/handler.py:97 -#: ../src/controller/twitter/handler.py:304 -msgid "Friends for {}" +#: src/controller/atprotosocial/handler.py +msgid "&New Post" msgstr "" -#: ../src/controller/mainController.py:1193 -#: ../src/controller/mastodon/handler.py:95 -#: ../src/controller/mastodon/handler.py:214 -msgid "Following for {}" +#: src/controller/atprotosocial/handler.py +msgid "&Repost" msgstr "" -#: ../src/controller/mainController.py:1195 -#: ../src/controller/twitter/handler.py:107 -#: ../src/controller/twitter/handler.py:326 -#, python-format -msgid "Trending topics for %s" -msgstr "" - -#: ../src/controller/settings.py:66 -msgid "System default" -msgstr "" - -#: ../src/controller/settings.py:66 -msgid "HTTP" -msgstr "" - -#: ../src/controller/settings.py:66 -msgid "SOCKS v4" -msgstr "" - -#: ../src/controller/settings.py:66 -msgid "SOCKS v4 with DNS support" -msgstr "" - -#: ../src/controller/settings.py:66 -msgid "SOCKS v5" -msgstr "" - -#: ../src/controller/settings.py:66 -msgid "SOCKS v5 with DNS support" -msgstr "" - -#: ../src/controller/userAlias.py:31 -msgid "Edit alias for {}" -msgstr "" - -#: ../src/controller/userSelector.py:10 -msgid "Select user" -msgstr "" - -#: ../src/controller/buffers/base/base.py:91 -#: ../src/controller/buffers/mastodon/conversations.py:226 -msgid "This action is not supported for this buffer" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/buffers/twitter/base.py:65 -#: ../src/controller/mastodon/handler.py:63 -#: ../src/controller/mastodon/settings.py:186 -#: ../src/controller/twitter/handler.py:63 -#: ../src/controller/twitter/settings.py:220 -msgid "Home" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/mastodon/handler.py:65 -#: ../src/controller/mastodon/settings.py:187 -msgid "Local" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/mastodon/handler.py:67 -#: ../src/controller/mastodon/settings.py:188 -msgid "Federated" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/buffers/twitter/base.py:65 -#: ../src/controller/mastodon/handler.py:69 -#: ../src/controller/mastodon/settings.py:189 -#: ../src/controller/twitter/handler.py:65 -#: ../src/controller/twitter/settings.py:221 -msgid "Mentions" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/mastodon/handler.py:77 -#: ../src/controller/mastodon/settings.py:193 -msgid "Bookmarks" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/buffers/twitter/base.py:65 -#: ../src/controller/mastodon/handler.py:71 -#: ../src/controller/twitter/handler.py:67 -msgid "Direct messages" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/mastodon/handler.py:73 -#: ../src/controller/mastodon/settings.py:191 -msgid "Sent" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/mastodon/handler.py:75 -#: ../src/controller/mastodon/settings.py:192 -msgid "Favorites" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/buffers/twitter/base.py:65 -#: ../src/controller/mastodon/handler.py:79 -#: ../src/controller/mastodon/settings.py:194 -#: ../src/controller/twitter/handler.py:75 -#: ../src/controller/twitter/settings.py:226 -msgid "Followers" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/mastodon/handler.py:81 -#: ../src/controller/mastodon/settings.py:195 -#: ../src/controller/twitter/handler.py:77 -msgid "Following" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/buffers/twitter/base.py:65 -#: ../src/controller/mastodon/handler.py:85 -#: ../src/controller/mastodon/settings.py:196 -#: ../src/controller/twitter/handler.py:79 -#: ../src/controller/twitter/settings.py:228 -msgid "Blocked users" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/buffers/twitter/base.py:65 -#: ../src/controller/mastodon/handler.py:83 -#: ../src/controller/mastodon/settings.py:197 -#: ../src/controller/twitter/handler.py:81 -#: ../src/controller/twitter/settings.py:229 -msgid "Muted users" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/mastodon/handler.py:87 -#: ../src/controller/mastodon/settings.py:198 -msgid "Notifications" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:55 -#: ../src/controller/buffers/twitter/base.py:71 -msgid "{username}'s timeline" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:57 -#: ../src/controller/buffers/twitter/base.py:75 -msgid "{username}'s followers" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:59 -msgid "{username}'s following" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:61 -#: ../src/controller/buffers/twitter/base.py:79 -msgid "Unknown buffer" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:64 -#: ../src/wxUI/buffers/mastodon/base.py:24 -#: ../src/wxUI/buffers/mastodon/conversationList.py:24 -#: ../src/wxUI/buffers/mastodon/notifications.py:22 -#: ../src/wxUI/buffers/mastodon/user.py:18 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:4 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:172 -msgid "Post" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:65 -msgid "Write your post here" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:126 -msgid "New post in {0}" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:129 -msgid "{0} new posts in {1}." -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:162 -#: ../src/controller/buffers/mastodon/conversations.py:98 -#: ../src/controller/buffers/mastodon/mentions.py:68 -#: ../src/controller/buffers/mastodon/users.py:133 -#: ../src/controller/buffers/twitter/base.py:231 -#: ../src/controller/buffers/twitter/directMessages.py:87 -#: ../src/controller/buffers/twitter/people.py:171 -#, python-format -msgid "%s items retrieved" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:180 -#: ../src/controller/buffers/mastodon/users.py:200 -#: ../src/controller/buffers/twitter/base.py:263 -#: ../src/controller/buffers/twitter/people.py:74 -msgid "This buffer is not a timeline; it can't be deleted." -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:296 -#: ../src/controller/buffers/mastodon/base.py:327 -msgid "Conversation with {}" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:297 -#: ../src/controller/buffers/mastodon/base.py:328 -#: ../src/controller/buffers/mastodon/conversations.py:174 -#: ../src/controller/buffers/mastodon/users.py:51 -msgid "Write your message here" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:299 -msgid "Reply to {}" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:300 -msgid "Write your reply here" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:353 -msgid "This action is not supported on conversation posts." -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:418 -#: ../src/controller/buffers/twitter/base.py:524 -msgid "Opening URL..." -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:431 -msgid "You can delete only your own posts." -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:456 -#: ../src/controller/buffers/twitter/base.py:582 -msgid "Opening item in web browser..." -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:463 -#: ../src/controller/buffers/mastodon/base.py:477 -msgid "Adding to favorites..." -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:469 -#: ../src/controller/buffers/mastodon/base.py:479 -msgid "Removing from favorites..." -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:487 -msgid "Adding to bookmarks..." -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:489 -msgid "Removing from bookmarks..." -msgstr "" - -#: ../src/controller/buffers/mastodon/conversations.py:173 -msgid "Reply to conversation with {}" -msgstr "" - -#: ../src/controller/buffers/mastodon/notifications.py:59 -msgid "Notification dismissed." -msgstr "" - -#: ../src/controller/buffers/mastodon/users.py:50 -msgid "New conversation with {}" -msgstr "" - -#: ../src/controller/buffers/mastodon/users.py:104 -msgid "There are no more items in this buffer." -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:65 -#: ../src/controller/twitter/handler.py:69 -#: ../src/controller/twitter/settings.py:223 -msgid "Sent direct messages" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:65 -#: ../src/controller/twitter/handler.py:71 -#: ../src/controller/twitter/settings.py:224 -msgid "Sent tweets" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:65 -#: ../src/controller/twitter/handler.py:73 -#: ../src/controller/twitter/settings.py:225 -msgid "Likes" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:65 -#: ../src/controller/twitter/settings.py:227 -msgid "Friends" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:73 -msgid "{username}'s likes" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:77 -msgid "{username}'s friends" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:82 -#: ../src/controller/buffers/twitter/trends.py:37 -#: ../src/controller/buffers/twitter/trends.py:128 -#: ../src/controller/twitter/messages.py:298 -#: ../src/wxUI/buffers/twitter/base.py:25 -#: ../src/wxUI/buffers/twitter/events.py:15 -#: ../src/wxUI/buffers/twitter/trends.py:18 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:309 -#: ../src/wxUI/sysTrayIcon.py:35 -msgid "Tweet" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:83 -#: ../src/controller/buffers/twitter/trends.py:38 -#: ../src/controller/buffers/twitter/trends.py:129 -msgid "Write the tweet here" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:189 -msgid "New tweet in {0}" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:192 -msgid "{0} new tweets in {1}." -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:401 -msgid "Reply to {arg0}" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:403 -#: ../src/keystrokeEditor/actions/mastodon.py:11 -#: ../src/keystrokeEditor/actions/twitter.py:11 -#: ../src/wxUI/buffers/mastodon/base.py:26 -#: ../src/wxUI/buffers/mastodon/conversationList.py:25 -#: ../src/wxUI/buffers/twitter/base.py:27 -msgid "Reply" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:404 -#, python-format -msgid "Reply to %s" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:427 -#: ../src/controller/twitter/messages.py:270 -#, python-format -msgid "Direct message to %s" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:427 -#: ../src/controller/buffers/twitter/directMessages.py:116 -msgid "New direct message" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:443 -msgid "This action is not supported on protected accounts." -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:460 -msgid "Quote" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:460 -msgid "Add your comment to the tweet" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:561 -msgid "User details" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:606 -#: ../src/controller/buffers/twitter/directMessages.py:163 -#: ../src/controller/twitter/messages.py:329 -msgid "MMM D, YYYY. H:m" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:621 -msgid "There are no coordinates in this tweet" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:625 -msgid "Error decoding coordinates. Try again later." -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:641 -msgid "Picture {0}" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:642 -msgid "Select the picture" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:650 -msgid "Invalid buffer" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:661 -msgid "Unable to extract text" -msgstr "" - -#: ../src/controller/buffers/twitter/directMessages.py:119 -msgid "{0} new direct messages." -msgstr "" - -#: ../src/controller/buffers/twitter/directMessages.py:122 -msgid "This action is not supported in the buffer yet." -msgstr "" - -#: ../src/controller/buffers/twitter/directMessages.py:138 -msgid "" -"Getting more items cannot be done in this buffer. Use the direct messages" -" buffer instead." -msgstr "" - -#: ../src/controller/buffers/twitter/people.py:92 -#: ../src/wxUI/buffers/twitter/people.py:17 -msgid "Mention" -msgstr "" - -#: ../src/controller/buffers/twitter/people.py:92 -#, python-format -msgid "Mention to %s" -msgstr "" - -#: ../src/controller/buffers/twitter/people.py:244 -msgid "{0} new followers." -msgstr "" - -#: ../src/controller/buffers/twitter/trends.py:144 -msgid "This action is not supported in the buffer, yet." -msgstr "" - -#: ../src/controller/mastodon/handler.py:24 -#: ../src/controller/twitter/handler.py:24 -#: ../src/wxUI/dialogs/mastodon/search.py:10 ../src/wxUI/dialogs/search.py:13 -#: ../src/wxUI/view.py:19 -msgid "&Search" -msgstr "" - -#: ../src/controller/mastodon/handler.py:28 -msgid "&Post" -msgstr "" - -#: ../src/controller/mastodon/handler.py:29 -#: ../src/controller/twitter/handler.py:29 -#: ../src/wxUI/dialogs/mastodon/menus.py:9 ../src/wxUI/menus.py:10 -#: ../src/wxUI/menus.py:34 ../src/wxUI/view.py:30 -msgid "Re&ply" -msgstr "" - -#: ../src/controller/mastodon/handler.py:30 -#: ../src/wxUI/dialogs/mastodon/menus.py:7 -msgid "&Boost" -msgstr "" - -#: ../src/controller/mastodon/handler.py:31 -#: ../src/wxUI/dialogs/mastodon/menus.py:11 -msgid "&Add to favorites" -msgstr "" - -#: ../src/controller/mastodon/handler.py:32 -msgid "Remove from favorites" -msgstr "" - -#: ../src/controller/mastodon/handler.py:33 -msgid "&Show post" -msgstr "" - -#: ../src/controller/mastodon/handler.py:35 -#: ../src/controller/twitter/handler.py:35 ../src/wxUI/view.py:36 -msgid "View conversa&tion" -msgstr "" - -#: ../src/controller/mastodon/handler.py:37 -#: ../src/controller/twitter/handler.py:37 -#: ../src/wxUI/dialogs/mastodon/menus.py:25 ../src/wxUI/menus.py:26 -#: ../src/wxUI/menus.py:44 ../src/wxUI/menus.py:62 ../src/wxUI/menus.py:72 -#: ../src/wxUI/view.py:38 -msgid "&Delete" -msgstr "" - -#: ../src/controller/mastodon/handler.py:39 -#: ../src/controller/twitter/handler.py:39 ../src/wxUI/view.py:42 -msgid "&Actions..." -msgstr "" - -#: ../src/controller/mastodon/handler.py:40 -#: ../src/controller/twitter/handler.py:40 ../src/wxUI/view.py:43 -msgid "&View timeline..." -msgstr "" - -#: ../src/controller/mastodon/handler.py:41 -#: ../src/controller/twitter/handler.py:41 ../src/wxUI/view.py:44 -msgid "Direct me&ssage" -msgstr "" - -#: ../src/controller/mastodon/handler.py:88 -#: ../src/controller/twitter/handler.py:82 -msgid "Timelines" -msgstr "" - -#: ../src/controller/mastodon/handler.py:91 -msgid "Timelines for {}" -msgstr "" - -#: ../src/controller/mastodon/handler.py:100 -#: ../src/controller/twitter/handler.py:102 -msgid "Searches" -msgstr "" - -#: ../src/controller/mastodon/handler.py:124 -#: ../src/controller/twitter/handler.py:317 -msgid "Conversation with {0}" -msgstr "" - -#: ../src/controller/mastodon/handler.py:158 -#: ../src/controller/twitter/handler.py:105 -#: ../src/controller/twitter/handler.py:369 -#: ../src/controller/twitter/handler.py:374 -msgid "Search for {}" -msgstr "" - -#: ../src/controller/mastodon/messages.py:174 -msgid "Poll with {} options" -msgstr "" - -#: ../src/controller/mastodon/messages.py:193 -msgid "Post from {}" -msgstr "" - -#: ../src/controller/mastodon/messages.py:197 -#: ../src/sessions/mastodon/templates.py:85 ../src/wxUI/dialogs/lists.py:76 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:48 -msgid "Public" -msgstr "" - -#: ../src/controller/mastodon/messages.py:197 -#: ../src/sessions/mastodon/templates.py:85 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:48 -msgid "Not listed" -msgstr "" - -#: ../src/controller/mastodon/messages.py:197 -msgid "followers only" -msgstr "" - -#: ../src/controller/mastodon/messages.py:197 -#: ../src/sessions/mastodon/templates.py:85 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:48 -msgid "Direct" -msgstr "" - -#: ../src/controller/mastodon/messages.py:204 -msgid "Remote instance" -msgstr "" - -#: ../src/controller/mastodon/messages.py:227 -#: ../src/controller/twitter/messages.py:382 -msgid "Link copied to clipboard." -msgstr "" - -#: ../src/controller/mastodon/settings.py:79 -#: ../src/controller/twitter/settings.py:85 -#, python-format -msgid "Account settings for %s" -msgstr "" - -#: ../src/controller/mastodon/settings.py:89 -#: ../src/wxUI/dialogs/mastodon/configuration.py:51 -msgid "Edit template for posts. Current template: {}" -msgstr "" - -#: ../src/controller/mastodon/settings.py:98 -#: ../src/wxUI/dialogs/mastodon/configuration.py:53 -msgid "Edit template for conversations. Current template: {}" -msgstr "" - -#: ../src/controller/mastodon/settings.py:107 -#: ../src/controller/twitter/settings.py:122 -#: ../src/wxUI/dialogs/configuration.py:253 -#: ../src/wxUI/dialogs/mastodon/configuration.py:55 -msgid "Edit template for persons. Current template: {}" -msgstr "" - -#: ../src/controller/mastodon/settings.py:190 -#: ../src/controller/twitter/settings.py:222 -msgid "Direct Messages" -msgstr "" - -#: ../src/controller/twitter/handler.py:23 -#: ../src/wxUI/dialogs/update_profile.py:35 ../src/wxUI/view.py:17 -msgid "&Update profile" -msgstr "" - -#: ../src/controller/twitter/handler.py:25 ../src/wxUI/view.py:20 -msgid "&Lists manager" -msgstr "" - -#: ../src/controller/twitter/handler.py:26 ../src/wxUI/view.py:21 -msgid "Manage user aliases" -msgstr "" - -#: ../src/controller/twitter/handler.py:28 ../src/wxUI/view.py:29 -msgid "&Tweet" -msgstr "" - -#: ../src/controller/twitter/handler.py:30 ../src/wxUI/menus.py:8 -#: ../src/wxUI/view.py:31 -msgid "&Retweet" -msgstr "" - -#: ../src/controller/twitter/handler.py:31 ../src/wxUI/menus.py:12 -#: ../src/wxUI/view.py:32 -msgid "&Like" -msgstr "" - -#: ../src/controller/twitter/handler.py:32 ../src/wxUI/menus.py:14 -#: ../src/wxUI/view.py:33 +#: src/controller/atprotosocial/handler.py msgid "&Unlike" msgstr "" -#: ../src/controller/twitter/handler.py:33 -#: ../src/wxUI/dialogs/mastodon/menus.py:21 ../src/wxUI/menus.py:22 -#: ../src/wxUI/menus.py:58 ../src/wxUI/view.py:34 -msgid "&Show tweet" +#: src/controller/atprotosocial/handler.py +msgid "{label} Home" msgstr "" -#: ../src/controller/twitter/handler.py:34 ../src/wxUI/view.py:35 -msgid "View &address" +#: src/controller/atprotosocial/handler.py +msgid "{label} Notifications" msgstr "" -#: ../src/controller/twitter/handler.py:36 ../src/wxUI/view.py:37 -msgid "Read text in picture" +#: src/controller/atprotosocial/handler.py +msgid "Session not ready." msgstr "" -#: ../src/controller/twitter/handler.py:42 ../src/wxUI/view.py:45 -msgid "Add a&lias" +#: src/controller/atprotosocial/handler.py +msgid "Post reposted successfully." msgstr "" -#: ../src/controller/twitter/handler.py:43 ../src/wxUI/view.py:46 -msgid "&Add to list" +#: src/controller/atprotosocial/handler.py +msgid "Failed to repost post." msgstr "" -#: ../src/controller/twitter/handler.py:44 ../src/wxUI/view.py:47 -msgid "R&emove from list" +#: src/controller/atprotosocial/handler.py +msgid "Post liked successfully." msgstr "" -#: ../src/controller/twitter/handler.py:45 ../src/wxUI/menus.py:80 -#: ../src/wxUI/view.py:48 -msgid "&View lists" +#: src/controller/atprotosocial/handler.py +msgid "Failed to like post." msgstr "" -#: ../src/controller/twitter/handler.py:46 ../src/wxUI/menus.py:83 -#: ../src/wxUI/view.py:49 -msgid "Show user &profile" +#: src/controller/atprotosocial/handler.py +msgid "Like removed successfully." msgstr "" -#: ../src/controller/twitter/handler.py:47 -msgid "View likes" +#: src/controller/atprotosocial/handler.py +msgid "Failed to remove like." msgstr "" -#: ../src/controller/twitter/handler.py:49 ../src/wxUI/view.py:55 -msgid "New &trending topics buffer..." +#: src/controller/atprotosocial/handler.py +msgid "An unexpected error occurred while unliking." msgstr "" -#: ../src/controller/twitter/handler.py:50 ../src/wxUI/view.py:56 -msgid "Create a &filter" +#: src/controller/atprotosocial/handler.py +msgid "ATProtoSocial session is not active or authenticated." msgstr "" -#: ../src/controller/twitter/handler.py:51 ../src/wxUI/view.py:57 -msgid "&Manage filters" +#: src/controller/atprotosocial/handler.py +msgid "Target user DID not provided." msgstr "" -#: ../src/controller/twitter/handler.py:86 -msgid "Likes timelines" +#: src/controller/atprotosocial/handler.py +msgid "User followed successfully." msgstr "" -#: ../src/controller/twitter/handler.py:90 -msgid "Followers timelines" +#: src/controller/atprotosocial/handler.py +msgid "Failed to follow user." msgstr "" -#: ../src/controller/twitter/handler.py:94 -msgid "Following timelines" +#: src/controller/atprotosocial/handler.py +msgid "User unfollowed successfully." msgstr "" -#: ../src/controller/twitter/handler.py:98 ../src/wxUI/dialogs/lists.py:13 -msgid "Lists" +#: src/controller/atprotosocial/handler.py +msgid "Failed to unfollow user." msgstr "" -#: ../src/controller/twitter/handler.py:101 -#: ../src/controller/twitter/lists.py:94 -msgid "List for {}" +#: src/controller/atprotosocial/handler.py +msgid "User muted successfully." msgstr "" -#: ../src/controller/twitter/handler.py:112 -msgid "Filters cannot be applied on this buffer" +#: src/controller/atprotosocial/handler.py +msgid "Failed to mute user." msgstr "" -#: ../src/controller/twitter/handler.py:228 -msgid "Add an user alias" +#: src/controller/atprotosocial/handler.py +msgid "User unmuted successfully." msgstr "" -#: ../src/controller/twitter/handler.py:236 -msgid "Alias has been set correctly for {}." +#: src/controller/atprotosocial/handler.py +msgid "Failed to unmute user." msgstr "" -#: ../src/controller/twitter/messages.py:50 -msgid "Translated" +#: src/controller/atprotosocial/handler.py +msgid "User blocked successfully." msgstr "" -#: ../src/controller/twitter/messages.py:57 -#, python-format -msgid "%s - %s of %d characters" +#: src/controller/atprotosocial/handler.py +msgid "Failed to block user." msgstr "" -#: ../src/controller/twitter/messages.py:356 -msgid "View item" +#: src/controller/atprotosocial/handler.py +msgid "User unblocked successfully." msgstr "" -#: ../src/controller/twitter/settings.py:37 -#: ../src/controller/twitter/settings.py:151 -#: ../src/wxUI/dialogs/configuration.py:121 -msgid "Ask" +#: src/controller/atprotosocial/handler.py +msgid "Failed to unblock user, or user was not blocked." msgstr "" -#: ../src/controller/twitter/settings.py:39 -#: ../src/controller/twitter/settings.py:153 -#: ../src/wxUI/dialogs/configuration.py:121 -msgid "Retweet without comments" +#: src/controller/atprotosocial/handler.py +msgid "Unknown action: {command}" msgstr "" -#: ../src/controller/twitter/settings.py:41 -#: ../src/wxUI/dialogs/configuration.py:121 -msgid "Retweet with comments" +#: src/controller/atprotosocial/handler.py +msgid "Profile: {handle}" msgstr "" -#: ../src/controller/twitter/settings.py:95 -#: ../src/wxUI/dialogs/configuration.py:247 -msgid "Edit template for tweets. Current template: {}" +#: src/controller/atprotosocial/handler.py +msgid "Could not fetch profile for {user_ident}." msgstr "" -#: ../src/controller/twitter/settings.py:104 -#: ../src/wxUI/dialogs/configuration.py:249 -msgid "Edit template for direct messages. Current template: {}" +#: src/controller/atprotosocial/handler.py +msgid "Error displaying profile: {error}" msgstr "" -#: ../src/controller/twitter/settings.py:113 -#: ../src/wxUI/dialogs/configuration.py:251 -msgid "Edit template for sent direct messages. Current template: {}" +#: src/controller/atprotosocial/handler.py +msgid "Enter user DID or handle:" msgstr "" -#: ../src/controller/twitter/user.py:29 ../src/wxUI/commonMessageDialogs.py:39 -msgid "That user does not exist" +#: src/controller/atprotosocial/handler.py +msgid "View User Timeline" msgstr "" -#: ../src/controller/twitter/user.py:29 ../src/controller/twitter/user.py:31 -#: ../src/extra/SpellChecker/wx_ui.py:79 -#: ../src/extra/autocompletionUsers/wx_scan.py:47 -#: ../src/wxUI/commonMessageDialogs.py:39 -#: ../src/wxUI/commonMessageDialogs.py:51 -#: ../src/wxUI/commonMessageDialogs.py:58 -#: ../src/wxUI/commonMessageDialogs.py:61 -#: ../src/wxUI/commonMessageDialogs.py:64 -#: ../src/wxUI/commonMessageDialogs.py:67 -#: ../src/wxUI/commonMessageDialogs.py:77 -#: ../src/wxUI/commonMessageDialogs.py:80 -#: ../src/wxUI/commonMessageDialogs.py:83 -#: ../src/wxUI/commonMessageDialogs.py:89 -#: ../src/wxUI/commonMessageDialogs.py:92 -#: ../src/wxUI/commonMessageDialogs.py:95 -#: ../src/wxUI/dialogs/mastodon/dialogs.py:38 -#: ../src/wxUI/dialogs/mastodon/dialogs.py:43 -#: ../src/wxUI/dialogs/mastodon/dialogs.py:48 -#: ../src/wxUI/dialogs/mastodon/dialogs.py:53 -msgid "Error" +#: src/controller/atprotosocial/handler.py +msgid "User {user_ident} not found." msgstr "" -#: ../src/controller/twitter/user.py:31 -msgid "User has been suspended" +#: src/controller/atprotosocial/handler.py +msgid "Failed to open user timeline: {error}" msgstr "" -#: ../src/controller/twitter/user.py:37 -#, python-format -msgid "Information for %s" +#: src/controller/atprotosocial/handler.py +msgid "View Followers" msgstr "" -#: ../src/controller/twitter/user.py:67 -#: ../src/extra/AudioUploader/audioUploader.py:127 -msgid "Discarded" +#: src/controller/atprotosocial/handler.py +msgid "{user_handle}'s Posts" msgstr "" -#: ../src/controller/twitter/user.py:95 -#, python-format -msgid "Username: @%s\n" +#: src/controller/atprotosocial/handler.py +msgid "Followers of {user_handle}" msgstr "" -#: ../src/controller/twitter/user.py:96 -#, python-format -msgid "Name: %s\n" +#: src/controller/atprotosocial/handler.py +msgid "Failed to open followers list: {error}" msgstr "" -#: ../src/controller/twitter/user.py:98 -#, python-format -msgid "Location: %s\n" +#: src/controller/atprotosocial/handler.py +msgid "View Following" msgstr "" -#: ../src/controller/twitter/user.py:100 -#, python-format -msgid "URL: %s\n" +#: src/controller/atprotosocial/handler.py +msgid "Following by {user_handle}" msgstr "" -#: ../src/controller/twitter/user.py:104 -#, python-format -msgid "Bio: %s\n" +#: src/controller/atprotosocial/handler.py +msgid "Failed to open following list: {error}" msgstr "" -#: ../src/controller/twitter/user.py:105 ../src/controller/twitter/user.py:120 -msgid "Yes" +#: src/wxUI/dialogs/composeDialog.py +msgid "Compose Post" msgstr "" -#: ../src/controller/twitter/user.py:106 ../src/controller/twitter/user.py:121 -msgid "No" +#: src/wxUI/dialogs/composeDialog.py +msgid "Replying to: {uri_placeholder}" msgstr "" -#: ../src/controller/twitter/user.py:107 -#, python-format -msgid "Protected: %s\n" +#: src/wxUI/dialogs/composeDialog.py +msgid "Media Attachments" msgstr "" -#: ../src/controller/twitter/user.py:110 -msgid "Relationship: " +#: src/wxUI/dialogs/composeDialog.py +msgid "Max: {max_attachments}" msgstr "" -#: ../src/controller/twitter/user.py:112 -msgid "You follow {0}. " +#: src/wxUI/dialogs/composeDialog.py +msgid "Add Media..." msgstr "" -#: ../src/controller/twitter/user.py:115 -msgid "{0} is following you." +#: src/wxUI/dialogs/composeDialog.py +msgid "Quoting Post" msgstr "" -#: ../src/controller/twitter/user.py:119 -#, python-format -msgid "" -"Followers: %s\n" -" Friends: %s\n" +#: src/wxUI/dialogs/composeDialog.py +msgid "Quoting URI: " msgstr "" -#: ../src/controller/twitter/user.py:122 -#, python-format -msgid "Verified: %s\n" +#: src/wxUI/dialogs/composeDialog.py +msgid "None" msgstr "" -#: ../src/controller/twitter/user.py:123 -#, python-format -msgid "Tweets: %s\n" +#: src/wxUI/dialogs/composeDialog.py +msgid "Set/Change Quote..." msgstr "" -#: ../src/controller/twitter/user.py:124 -#, python-format -msgid "Likes: %s" +#: src/wxUI/dialogs/composeDialog.py +msgid "Remove Quote" msgstr "" -#: ../src/controller/twitter/userActions.py:80 -msgid "You can't ignore direct messages" +#: src/wxUI/dialogs/composeDialog.py +msgid "Options" msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:57 -msgid "Attaching..." +#: src/wxUI/dialogs/composeDialog.py +msgid "Sensitive content (CW)" msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:74 -msgid "Pause" +#: src/wxUI/dialogs/composeDialog.py +msgid "Content warning text (optional)" msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:76 -msgid "&Resume" +#: src/wxUI/dialogs/composeDialog.py +msgid "Languages:" msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:77 -msgid "Resume" +#: src/wxUI/dialogs/composeDialog.py +msgid "Automatic" msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:79 -#: ../src/extra/AudioUploader/audioUploader.py:106 -#: ../src/extra/AudioUploader/wx_ui.py:37 -msgid "&Pause" +#: src/wxUI/dialogs/composeDialog.py +msgid "Maximum number of attachments ({max}) reached." msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:94 -#: ../src/extra/AudioUploader/audioUploader.py:140 -msgid "&Stop" +#: src/wxUI/dialogs/composeDialog.py +msgid "Attachment Limit" msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:95 -msgid "Recording" +#: src/wxUI/dialogs/composeDialog.py +msgid "Select Media File" msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:100 -#: ../src/extra/AudioUploader/audioUploader.py:151 -msgid "Stopped" +#: src/wxUI/dialogs/composeDialog.py +msgid "Enter accessibility description (alt text) for the image:" msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:102 -#: ../src/extra/AudioUploader/wx_ui.py:39 -msgid "&Record" +#: src/wxUI/dialogs/composeDialog.py +msgid "Image Description" msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:144 -#: ../src/extra/AudioUploader/audioUploader.py:154 -#: ../src/extra/AudioUploader/wx_ui.py:35 -msgid "&Play" +#: src/wxUI/dialogs/composeDialog.py +msgid "Enter the AT-URI of the Bluesky post to quote:" msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:159 -msgid "Recoding audio..." +#: src/wxUI/dialogs/composeDialog.py +msgid "You can select a maximum of {num} languages." msgstr "" -#: ../src/extra/AudioUploader/transfer.py:82 -#: ../src/extra/AudioUploader/transfer.py:88 -msgid "Error in file upload: {0}" +#: src/wxUI/dialogs/composeDialog.py +msgid "Language Selection Limit" msgstr "" -#: ../src/extra/AudioUploader/utils.py:29 ../src/update/utils.py:29 -#, python-format -msgid "%d day, " +#: src/wxUI/dialogs/composeDialog.py +msgid "Cannot send an empty post." msgstr "" -#: ../src/extra/AudioUploader/utils.py:31 ../src/update/utils.py:31 -#, python-format -msgid "%d days, " +#: src/wxUI/dialogs/composeDialog.py +msgid "Please select no more than {num} languages." msgstr "" -#: ../src/extra/AudioUploader/utils.py:33 ../src/update/utils.py:33 -#, python-format -msgid "%d hour, " +#: src/wxUI/dialogs/composeDialog.py +msgid "Language Error" msgstr "" -#: ../src/extra/AudioUploader/utils.py:35 ../src/update/utils.py:35 -#, python-format -msgid "%d hours, " +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "User Profile" msgstr "" -#: ../src/extra/AudioUploader/utils.py:37 ../src/update/utils.py:37 -#, python-format -msgid "%d minute, " +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Loading profile..." msgstr "" -#: ../src/extra/AudioUploader/utils.py:39 ../src/update/utils.py:39 -#, python-format -msgid "%d minutes, " +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Profile not found." msgstr "" -#: ../src/extra/AudioUploader/utils.py:41 ../src/update/utils.py:41 -#, python-format -msgid "%s second" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Error loading profile." msgstr "" -#: ../src/extra/AudioUploader/utils.py:43 ../src/update/utils.py:43 -#, python-format -msgid "%s seconds" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Profile loaded." msgstr "" -#: ../src/extra/AudioUploader/wx_transfer_dialogs.py:15 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:23 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:35 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:171 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:255 -msgid "File" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Action failed." msgstr "" -#: ../src/extra/AudioUploader/wx_transfer_dialogs.py:21 -msgid "Transferred" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Performing action: {action}..." msgstr "" -#: ../src/extra/AudioUploader/wx_transfer_dialogs.py:26 -msgid "Total file size" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Display Name:" msgstr "" -#: ../src/extra/AudioUploader/wx_transfer_dialogs.py:31 -msgid "Transfer rate" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Handle:" msgstr "" -#: ../src/extra/AudioUploader/wx_transfer_dialogs.py:36 -msgid "Time left" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "DID:" msgstr "" -#: ../src/extra/AudioUploader/wx_ui.py:29 -msgid "Attach audio" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Bio:" msgstr "" -#: ../src/extra/AudioUploader/wx_ui.py:41 -msgid "&Add an existing file" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Avatar URL: " msgstr "" -#: ../src/extra/AudioUploader/wx_ui.py:42 -msgid "&Discard" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Banner URL: " msgstr "" -#: ../src/extra/AudioUploader/wx_ui.py:44 -msgid "Upload to" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Follow" msgstr "" -#: ../src/extra/AudioUploader/wx_ui.py:49 -msgid "Attach" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Unfollow" msgstr "" -#: ../src/extra/AudioUploader/wx_ui.py:51 -msgid "&Cancel" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Unblock" msgstr "" -#: ../src/extra/AudioUploader/wx_ui.py:76 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:154 -msgid "Select the audio file to be uploaded" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "User DID not available for this action." msgstr "" -#: ../src/extra/AudioUploader/wx_ui.py:76 -msgid "Audio Files (*.mp3, *.ogg, *.wav)|*.mp3; *.ogg; *.wav" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Confirm Action" msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:4 -msgid "Audio tweet." +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Are you sure you want to unfollow @{handle}?" msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:5 -msgid "User timeline buffer created." +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Are you sure you want to block @{handle}? This will prevent them from interacting with you and hide their content." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:6 -msgid "Buffer destroied." +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Action Error" msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:7 -msgid "Direct message received." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "No posts found." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:8 -msgid "Direct message sent." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "An unexpected error occurred loading posts." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:9 -msgid "Error." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "No more posts to load." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:10 -msgid "Tweet liked." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "Failed to load more posts or no more posts." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:11 -msgid "Likes buffer updated." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "An unexpected error occurred while loading more users." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:12 -msgid "Geotweet." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "Home timeline is empty or failed to load." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:13 -msgid "Tweet contains one or more images" +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "Error loading home timeline." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:14 -msgid "Boundary reached." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "No more posts." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:15 -msgid "List updated." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "Error loading more posts." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:16 -msgid "Too many characters." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "No unread notifications or failed to load initial set." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:17 -msgid "Mention received." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "Error loading notifications." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:18 -msgid "New event." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "No more older notifications." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:19 -msgid "{0} is ready." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "Error loading more notifications." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:20 -msgid "Mention sent." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "No users found in this list." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:21 -msgid "Tweet retweeted." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "Error loading user list." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:22 -msgid "Search buffer updated." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "This list is empty." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:23 -msgid "Tweet received." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "Post Content" msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:24 -msgid "Tweet sent." -msgstr "" - -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:25 -msgid "Trending topics buffer updated." -msgstr "" - -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:26 -msgid "New tweet in user timeline buffer." -msgstr "" - -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:27 -msgid "New follower." -msgstr "" - -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:28 -msgid "Volume changed." -msgstr "" - -#: ../src/extra/SoundsTutorial/wx_ui.py:8 -msgid "Sounds tutorial" -msgstr "" - -#: ../src/extra/SoundsTutorial/wx_ui.py:11 -msgid "Press enter to listen to the sound for the selected event" -msgstr "" - -#: ../src/extra/SpellChecker/spellchecker.py:56 -#, python-format -msgid "Misspelled word: %s" -msgstr "" - -#: ../src/extra/SpellChecker/wx_ui.py:27 -msgid "Misspelled word" -msgstr "" - -#: ../src/extra/SpellChecker/wx_ui.py:32 -msgid "Context" -msgstr "" - -#: ../src/extra/SpellChecker/wx_ui.py:37 -msgid "Suggestions" -msgstr "" - -#: ../src/extra/SpellChecker/wx_ui.py:42 -msgid "&Ignore" -msgstr "" - -#: ../src/extra/SpellChecker/wx_ui.py:43 -msgid "I&gnore all" -msgstr "" - -#: ../src/extra/SpellChecker/wx_ui.py:44 -msgid "&Replace" -msgstr "" - -#: ../src/extra/SpellChecker/wx_ui.py:45 -msgid "R&eplace all" -msgstr "" - -#: ../src/extra/SpellChecker/wx_ui.py:46 -msgid "&Add to personal dictionary" -msgstr "" - -#: ../src/extra/SpellChecker/wx_ui.py:79 -msgid "" -"An error has occurred. There are no dictionaries available for the " -"selected language in {0}" -msgstr "" - -#: ../src/extra/SpellChecker/wx_ui.py:82 -msgid "Spell check complete." -msgstr "" - -#: ../src/extra/autocompletionUsers/completion.py:39 -#: ../src/extra/autocompletionUsers/completion.py:57 -msgid "You have to start writing" -msgstr "" - -#: ../src/extra/autocompletionUsers/completion.py:49 -#: ../src/extra/autocompletionUsers/completion.py:66 -msgid "There are no results in your users database" -msgstr "" - -#: ../src/extra/autocompletionUsers/completion.py:51 -msgid "Autocompletion only works for users." -msgstr "" - -#: ../src/extra/autocompletionUsers/scan.py:54 -msgid "" -"Updating database... You can close this window now. A message will tell " -"you when the process finishes." -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_manage.py:9 -msgid "Manage Autocompletion database" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_manage.py:12 -msgid "Editing {0} users database" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_manage.py:13 -msgid "Username" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_manage.py:13 -#: ../src/wxUI/dialogs/configuration.py:151 -msgid "Name" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_manage.py:16 -msgid "Add user" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_manage.py:17 -msgid "Remove user" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_manage.py:38 -msgid "Twitter username" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_manage.py:38 -msgid "Add user to database" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_manage.py:44 -msgid "The user does not exist" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_manage.py:44 -#: ../src/wxUI/commonMessageDialogs.py:45 -msgid "Error!" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_scan.py:8 -msgid "Autocomplete users' settings" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_scan.py:11 -msgid "Add followers to database" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_scan.py:12 -msgid "Add friends to database" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_scan.py:26 -msgid "Updating autocompletion database" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_scan.py:37 -msgid "" -"This process will retrieve the users you selected from Twitter, and add " -"them to the user autocomplete database. Please note that if there are " -"many users or you have tried to perform this action less than 15 minutes " -"ago, TWBlue may reach a limit in Twitter API calls when trying to load " -"the users into the database. If this happens, we will show you an error, " -"in which case you will have to try this process again in a few minutes. " -"If this process ends with no error, you will be redirected back to the " -"account settings dialog. Do you want to continue?" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_scan.py:37 -#: ../src/wxUI/commonMessageDialogs.py:36 -#: ../src/wxUI/commonMessageDialogs.py:86 -msgid "Attention" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_scan.py:43 -msgid "TWBlue has imported {} users successfully." -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_scan.py:43 -msgid "Done" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_scan.py:47 -msgid "Error adding users from Twitter. Please try again in about 15 minutes." -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 -msgid "Detect automatically" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:41 -msgid "Danish" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:43 -msgid "Dutch" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:44 -msgid "English" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:48 -msgid "Finnish" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:49 -msgid "French" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:52 -msgid "German" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:58 -msgid "Hungarian" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:68 -msgid "Korean" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:63 -msgid "Italian" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:64 -msgid "Japanese" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:85 -msgid "Polish" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:86 -msgid "Portuguese" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:89 -msgid "Russian" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:96 -msgid "Spanish" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:105 -msgid "Turkish" -msgstr "" - -#: ../src/extra/translator/translator.py:22 -msgid "Afrikaans" -msgstr "" - -#: ../src/extra/translator/translator.py:23 -msgid "Albanian" -msgstr "" - -#: ../src/extra/translator/translator.py:24 -msgid "Amharic" -msgstr "" - -#: ../src/extra/translator/translator.py:25 -msgid "Arabic" -msgstr "" - -#: ../src/extra/translator/translator.py:26 -msgid "Armenian" -msgstr "" - -#: ../src/extra/translator/translator.py:27 -msgid "Azerbaijani" -msgstr "" - -#: ../src/extra/translator/translator.py:28 -msgid "Basque" -msgstr "" - -#: ../src/extra/translator/translator.py:29 -msgid "Belarusian" -msgstr "" - -#: ../src/extra/translator/translator.py:30 -msgid "Bengali" -msgstr "" - -#: ../src/extra/translator/translator.py:31 -msgid "Bihari" -msgstr "" - -#: ../src/extra/translator/translator.py:32 -msgid "Bulgarian" -msgstr "" - -#: ../src/extra/translator/translator.py:33 -msgid "Burmese" -msgstr "" - -#: ../src/extra/translator/translator.py:34 -msgid "Catalan" -msgstr "" - -#: ../src/extra/translator/translator.py:35 -msgid "Cherokee" -msgstr "" - -#: ../src/extra/translator/translator.py:36 -msgid "Chinese" -msgstr "" - -#: ../src/extra/translator/translator.py:37 -msgid "Chinese_simplified" -msgstr "" - -#: ../src/extra/translator/translator.py:38 -msgid "Chinese_traditional" -msgstr "" - -#: ../src/extra/translator/translator.py:39 -msgid "Croatian" -msgstr "" - -#: ../src/extra/translator/translator.py:40 -msgid "Czech" -msgstr "" - -#: ../src/extra/translator/translator.py:42 -msgid "Dhivehi" -msgstr "" - -#: ../src/extra/translator/translator.py:45 -msgid "Esperanto" -msgstr "" - -#: ../src/extra/translator/translator.py:46 -msgid "Estonian" -msgstr "" - -#: ../src/extra/translator/translator.py:47 -msgid "Filipino" -msgstr "" - -#: ../src/extra/translator/translator.py:50 -msgid "Galician" -msgstr "" - -#: ../src/extra/translator/translator.py:51 -msgid "Georgian" -msgstr "" - -#: ../src/extra/translator/translator.py:53 -msgid "Greek" -msgstr "" - -#: ../src/extra/translator/translator.py:54 -msgid "Guarani" -msgstr "" - -#: ../src/extra/translator/translator.py:55 -msgid "Gujarati" -msgstr "" - -#: ../src/extra/translator/translator.py:56 -msgid "Hebrew" -msgstr "" - -#: ../src/extra/translator/translator.py:57 -msgid "Hindi" -msgstr "" - -#: ../src/extra/translator/translator.py:59 -msgid "Icelandic" -msgstr "" - -#: ../src/extra/translator/translator.py:60 -msgid "Indonesian" -msgstr "" - -#: ../src/extra/translator/translator.py:61 -msgid "Inuktitut" -msgstr "" - -#: ../src/extra/translator/translator.py:62 -msgid "Irish" -msgstr "" - -#: ../src/extra/translator/translator.py:65 -msgid "Kannada" -msgstr "" - -#: ../src/extra/translator/translator.py:66 -msgid "Kazakh" -msgstr "" - -#: ../src/extra/translator/translator.py:67 -msgid "Khmer" -msgstr "" - -#: ../src/extra/translator/translator.py:69 -msgid "Kurdish" -msgstr "" - -#: ../src/extra/translator/translator.py:70 -msgid "Kyrgyz" -msgstr "" - -#: ../src/extra/translator/translator.py:71 -msgid "Laothian" -msgstr "" - -#: ../src/extra/translator/translator.py:72 -msgid "Latvian" -msgstr "" - -#: ../src/extra/translator/translator.py:73 -msgid "Lithuanian" -msgstr "" - -#: ../src/extra/translator/translator.py:74 -msgid "Macedonian" -msgstr "" - -#: ../src/extra/translator/translator.py:75 -msgid "Malay" -msgstr "" - -#: ../src/extra/translator/translator.py:76 -msgid "Malayalam" -msgstr "" - -#: ../src/extra/translator/translator.py:77 -msgid "Maltese" -msgstr "" - -#: ../src/extra/translator/translator.py:78 -msgid "Marathi" -msgstr "" - -#: ../src/extra/translator/translator.py:79 -msgid "Mongolian" -msgstr "" - -#: ../src/extra/translator/translator.py:80 -msgid "Nepali" -msgstr "" - -#: ../src/extra/translator/translator.py:81 -msgid "Norwegian" -msgstr "" - -#: ../src/extra/translator/translator.py:82 -msgid "Oriya" -msgstr "" - -#: ../src/extra/translator/translator.py:83 -msgid "Pashto" -msgstr "" - -#: ../src/extra/translator/translator.py:84 -msgid "Persian" -msgstr "" - -#: ../src/extra/translator/translator.py:87 -msgid "Punjabi" -msgstr "" - -#: ../src/extra/translator/translator.py:88 -msgid "Romanian" -msgstr "" - -#: ../src/extra/translator/translator.py:90 -msgid "Sanskrit" -msgstr "" - -#: ../src/extra/translator/translator.py:91 -msgid "Serbian" -msgstr "" - -#: ../src/extra/translator/translator.py:92 -msgid "Sindhi" -msgstr "" - -#: ../src/extra/translator/translator.py:93 -msgid "Sinhalese" -msgstr "" - -#: ../src/extra/translator/translator.py:94 -msgid "Slovak" -msgstr "" - -#: ../src/extra/translator/translator.py:95 -msgid "Slovenian" -msgstr "" - -#: ../src/extra/translator/translator.py:97 -msgid "Swahili" -msgstr "" - -#: ../src/extra/translator/translator.py:98 -msgid "Swedish" -msgstr "" - -#: ../src/extra/translator/translator.py:99 -msgid "Tajik" -msgstr "" - -#: ../src/extra/translator/translator.py:100 -msgid "Tamil" -msgstr "" - -#: ../src/extra/translator/translator.py:101 -msgid "Tagalog" -msgstr "" - -#: ../src/extra/translator/translator.py:102 -msgid "Telugu" -msgstr "" - -#: ../src/extra/translator/translator.py:103 -msgid "Thai" -msgstr "" - -#: ../src/extra/translator/translator.py:104 -msgid "Tibetan" -msgstr "" - -#: ../src/extra/translator/translator.py:106 -msgid "Ukrainian" -msgstr "" - -#: ../src/extra/translator/translator.py:107 -msgid "Urdu" -msgstr "" - -#: ../src/extra/translator/translator.py:108 -msgid "Uzbek" -msgstr "" - -#: ../src/extra/translator/translator.py:109 -msgid "Uighur" -msgstr "" - -#: ../src/extra/translator/translator.py:110 -msgid "Vietnamese" -msgstr "" - -#: ../src/extra/translator/translator.py:111 -msgid "Welsh" -msgstr "" - -#: ../src/extra/translator/translator.py:112 -msgid "Yiddish" -msgstr "" - -#: ../src/extra/translator/wx_ui.py:29 -msgid "Translate message" -msgstr "" - -#: ../src/extra/translator/wx_ui.py:32 -msgid "Target language" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:8 -msgid "Keystroke editor" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:11 -msgid "Select a keystroke to edit" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:12 -#: ../src/wxUI/dialogs/mastodon/userActions.py:9 -#: ../src/wxUI/dialogs/mastodon/userActions.py:18 -#: ../src/wxUI/dialogs/mastodon/userActions.py:19 -#: ../src/wxUI/dialogs/userActions.py:10 ../src/wxUI/dialogs/userActions.py:19 -#: ../src/wxUI/dialogs/userActions.py:20 +#: src/wxUI/buffers/atprotosocial/panels.py msgid "Action" msgstr "" -#: ../src/keystrokeEditor/wx_ui.py:12 -msgid "Keystroke" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:17 ../src/wxUI/dialogs/filterDialogs.py:135 -#: ../src/wxUI/dialogs/lists.py:20 ../src/wxUI/dialogs/userAliasDialogs.py:53 -msgid "Edit" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:19 ../src/keystrokeEditor/wx_ui.py:49 -msgid "Undefine keystroke" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:20 -msgid "Execute action" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:21 ../src/wxUI/dialogs/configuration.py:421 -#: ../src/wxUI/dialogs/mastodon/configuration.py:170 -#: ../src/wxUI/dialogs/userAliasDialogs.py:25 ../src/wxUI/dialogs/utils.py:39 -msgid "Close" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:41 -msgid "Undefined" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:49 -msgid "Are you sure you want to undefine this keystroke?" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:53 -msgid "Editing keystroke" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:56 -msgid "Control" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:57 -msgid "Alt" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:58 -msgid "Shift" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:59 -msgid "Windows" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:65 -msgid "Key" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:70 ../src/wxUI/dialogs/filterDialogs.py:80 -#: ../src/wxUI/dialogs/find.py:21 ../src/wxUI/dialogs/userAliasDialogs.py:23 -#: ../src/wxUI/dialogs/utils.py:36 -msgid "OK" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:83 -msgid "You need to use the Windows key" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:83 ../src/keystrokeEditor/wx_ui.py:86 -msgid "Invalid keystroke" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:86 -msgid "You must provide a character for the keystroke" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:3 -#: ../src/keystrokeEditor/actions/twitter.py:3 -msgid "Go up in the current buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:4 -#: ../src/keystrokeEditor/actions/twitter.py:4 -msgid "Go down in the current buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:5 -#: ../src/keystrokeEditor/actions/twitter.py:5 -msgid "Go to the previous buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:6 -#: ../src/keystrokeEditor/actions/twitter.py:6 -msgid "Go to the next buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:7 -#: ../src/keystrokeEditor/actions/twitter.py:7 -msgid "Focus the next session" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:8 -#: ../src/keystrokeEditor/actions/twitter.py:8 -msgid "Focus the previous session" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:9 -#: ../src/keystrokeEditor/actions/twitter.py:9 -msgid "Show or hide the GUI" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:10 -msgid "Make a new post" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:12 -#: ../src/wxUI/buffers/mastodon/base.py:25 -#: ../src/wxUI/dialogs/mastodon/dialogs.py:7 -msgid "Boost" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:13 -#: ../src/keystrokeEditor/actions/twitter.py:13 -msgid "Send direct message" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:14 -msgid "Add post to favorites" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:15 -msgid "Remove post from favorites" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:16 -msgid "Add/remove post from favorites" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:17 -#: ../src/keystrokeEditor/actions/twitter.py:17 -msgid "Open the user actions dialogue" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:19 -msgid "Show post" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:20 -#: ../src/keystrokeEditor/actions/twitter.py:20 -msgid "Quit" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:21 -#: ../src/keystrokeEditor/actions/twitter.py:21 -msgid "Open user timeline" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:22 -#: ../src/keystrokeEditor/actions/twitter.py:22 -msgid "Destroy buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:23 -msgid "Interact with the currently focused post." -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:24 -#: ../src/keystrokeEditor/actions/twitter.py:24 -msgid "Open URL" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:25 -msgid "View in browser" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:26 -#: ../src/keystrokeEditor/actions/twitter.py:26 -msgid "Increase volume by 5%" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:27 -#: ../src/keystrokeEditor/actions/twitter.py:27 -msgid "Decrease volume by 5%" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:28 -#: ../src/keystrokeEditor/actions/twitter.py:28 -msgid "Jump to the first element of a buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:29 -#: ../src/keystrokeEditor/actions/twitter.py:29 -msgid "Jump to the last element of the current buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:30 -#: ../src/keystrokeEditor/actions/twitter.py:30 -msgid "Jump 20 elements up in the current buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:31 -#: ../src/keystrokeEditor/actions/twitter.py:31 -msgid "Jump 20 elements down in the current buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:33 -msgid "Delete post" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:34 -#: ../src/keystrokeEditor/actions/twitter.py:34 -msgid "Empty the current buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:35 -#: ../src/keystrokeEditor/actions/twitter.py:35 -msgid "Repeat last item" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:36 -#: ../src/keystrokeEditor/actions/twitter.py:36 -msgid "Copy to clipboard" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:39 -#: ../src/keystrokeEditor/actions/twitter.py:39 -msgid "Mute/unmute the active buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:40 -#: ../src/keystrokeEditor/actions/twitter.py:40 -msgid "Mute/unmute the current session" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:41 -#: ../src/keystrokeEditor/actions/twitter.py:41 -msgid "toggle the automatic reading of incoming tweets in the active buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:42 -msgid "Search on instance" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:43 -#: ../src/keystrokeEditor/actions/twitter.py:43 -msgid "Find a string in the currently focused buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:44 -#: ../src/keystrokeEditor/actions/twitter.py:44 -msgid "Show the keystroke editor" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:46 -#: ../src/keystrokeEditor/actions/twitter.py:46 -msgid "load previous items" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:48 -#: ../src/keystrokeEditor/actions/twitter.py:50 -msgid "View conversation" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:49 -#: ../src/keystrokeEditor/actions/twitter.py:51 -msgid "Check and download updates" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:50 -#: ../src/keystrokeEditor/actions/twitter.py:53 -msgid "Opens the global settings dialogue" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:52 -#: ../src/keystrokeEditor/actions/twitter.py:55 -msgid "Opens the account settings dialogue" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:53 -#: ../src/keystrokeEditor/actions/twitter.py:56 -msgid "Try to play a media file" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:54 -#: ../src/keystrokeEditor/actions/twitter.py:57 -msgid "Updates the buffer and retrieves possible lost items there." -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:10 -msgid "New tweet" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:12 -#: ../src/wxUI/buffers/twitter/base.py:26 -#: ../src/wxUI/commonMessageDialogs.py:10 -msgid "Retweet" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:14 -msgid "Like a tweet" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:15 -msgid "Like/unlike a tweet" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:16 -msgid "Unlike a tweet" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:18 -msgid "See user details" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:19 -msgid "Show tweet" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:23 -msgid "Interact with the currently focused tweet." -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:25 -msgid "View in Twitter" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:32 -msgid "Edit profile" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:33 -msgid "Delete a tweet or direct message" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:37 -msgid "Add to list" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:38 -msgid "Remove from list" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:42 -msgid "Search on twitter" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:45 -msgid "Show lists for a specified user" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:47 -msgid "Get geolocation" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:48 -msgid "Display the tweet's geolocation in a dialog" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:49 -msgid "Create a trending topics buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:52 -msgid "" -"Opens the list manager, which allows you to create, edit, delete and open" -" lists in buffers." -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:54 -msgid "Opens the list manager" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:58 -msgid "Extracts the text from a picture and displays the result in a dialog." -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:59 -msgid "Adds an alias to an user" -msgstr "" - -#: ../src/sessionmanager/sessionManager.py:68 -msgid "{account_name} (Twitter)" -msgstr "" - -#: ../src/sessionmanager/sessionManager.py:73 -msgid "{account_name} (Mastodon)" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:10 -msgid "Session manager" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:13 -msgid "Accounts list" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:15 -msgid "Account" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:19 -msgid "New account" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:21 ../src/sessionmanager/wxUI.py:87 -msgid "Remove account" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:23 -msgid "Global Settings" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:47 -msgid "You need to configure an account." -msgstr "" - -#: ../src/sessionmanager/wxUI.py:47 -msgid "Account Error" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:53 -msgid "Twitter" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:54 -msgid "Mastodon" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:60 -msgid "" -"You will be prompted for your Mastodon data (instance URL, email address " -"and password) so we can authorise TWBlue in your instance. Would you like" -" to authorise your account now?" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:60 ../src/sessionmanager/wxUI.py:67 -msgid "Authorization" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:67 -msgid "" -"The request to authorize your Twitter account will be opened in your " -"browser. You only need to do this once. Would you like to continue?" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:75 -#, python-format -msgid "Authorized account %d" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:81 -msgid "" -"Your access token is invalid or the authorization has failed. Please try " -"again." -msgstr "" - -#: ../src/sessionmanager/wxUI.py:81 -msgid "Invalid user token" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:87 -msgid "Do you really want to delete this account?" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:111 -msgid "" -"TWBlue is unable to authenticate the account for {} in Twitter. It might " -"be due to an invalid or expired token, revoqued access to the " -"application, or after an account reactivation. Please remove the account " -"manually from your Twitter sessions in order to stop seeing this message." -msgstr "" - -#: ../src/sessionmanager/wxUI.py:111 -msgid "Authentication error for session {}" -msgstr "" - -#: ../src/sessions/base.py:125 -msgid "" -"An exception occurred while saving the {app} database. It will be deleted" -" and rebuilt automatically. If this error persists, send the error log to" -" the {app} developers." -msgstr "" - -#: ../src/sessions/base.py:165 -msgid "" -"An exception occurred while loading the {app} database. It will be " -"deleted and rebuilt automatically. If this error persists, send the error" -" log to the {app} developers." -msgstr "" - -#: ../src/sessions/mastodon/compose.py:17 -#: ../src/sessions/mastodon/compose.py:64 -msgid "dddd, MMMM D, YYYY H:m" -msgstr "" - -#: ../src/sessions/mastodon/compose.py:19 -#: ../src/sessions/mastodon/templates.py:80 -#: ../src/sessions/mastodon/templates.py:81 -msgid "Boosted from @{}: {}" -msgstr "" - -#: ../src/sessions/mastodon/compose.py:35 -#: ../src/sessions/mastodon/templates.py:28 -#: ../src/sessions/twitter/compose.py:22 ../src/sessions/twitter/compose.py:62 -#: ../src/sessions/twitter/compose.py:124 -#: ../src/sessions/twitter/compose.py:130 -#: ../src/sessions/twitter/templates.py:26 -msgid "dddd, MMMM D, YYYY H:m:s" -msgstr "" - -#: ../src/sessions/mastodon/compose.py:39 -#, python-format -msgid "%s (@%s). %s followers, %s following, %s posts. Joined %s" -msgstr "" - -#: ../src/sessions/mastodon/compose.py:50 -msgid "Last message from {}: {}" -msgstr "" - -#: ../src/sessions/mastodon/compose.py:67 -msgid "{username} has mentionned you: {status}" -msgstr "" - -#: ../src/sessions/mastodon/compose.py:69 -msgid "{username} has boosted: {status}" -msgstr "" - -#: ../src/sessions/mastodon/compose.py:71 -msgid "{username} has added to favorites: {status}" -msgstr "" - -#: ../src/sessions/mastodon/compose.py:73 -msgid "{username} has followed you." -msgstr "" - -#: ../src/sessions/mastodon/compose.py:75 -#: ../src/sessions/mastodon/templates.py:172 -msgid "A poll in which you have voted has expired: {status}" -msgstr "" - -#: ../src/sessions/mastodon/compose.py:77 -msgid "{username} wants to follow you." -msgstr "" - -#: ../src/sessions/mastodon/session.py:60 -msgid "Please enter your instance URL." -msgstr "" - -#: ../src/sessions/mastodon/session.py:60 -msgid "Mastodon instance" -msgstr "" - -#: ../src/sessions/mastodon/session.py:71 -msgid "" -"We could not connect to your mastodon instance. Please verify that the " -"domain exists and the instance is accessible via a web browser." -msgstr "" - -#: ../src/sessions/mastodon/session.py:71 -msgid "Instance error" -msgstr "" - -#: ../src/sessions/mastodon/session.py:76 -msgid "Enter the verification code" -msgstr "" - -#: ../src/sessions/mastodon/session.py:76 -msgid "PIN code authorization" -msgstr "" - -#: ../src/sessions/mastodon/session.py:85 -msgid "" -"We could not authorice your mastodon account to be used in TWBlue. This " -"might be caused due to an incorrect verification code. Please try to add " -"the session again." -msgstr "" - -#: ../src/sessions/mastodon/session.py:85 -#: ../src/sessions/twitter/session.py:171 -msgid "Authorization error" -msgstr "" - -#: ../src/sessions/mastodon/session.py:182 -#: ../src/sessions/twitter/session.py:206 -#: ../src/sessions/twitter/session.py:233 -#, python-format -msgid "%s failed. Reason: %s" -msgstr "" - -#: ../src/sessions/mastodon/session.py:188 -#: ../src/sessions/twitter/session.py:212 -#: ../src/sessions/twitter/session.py:236 -#, python-format -msgid "%s succeeded." -msgstr "" - -#: ../src/sessions/mastodon/templates.py:18 -#: ../src/sessions/twitter/templates.py:16 -msgid "$display_name, $text $image_descriptions $date. $source" -msgstr "" - -#: ../src/sessions/mastodon/templates.py:19 -#: ../src/sessions/twitter/templates.py:18 -msgid "Dm to $recipient_display_name, $text $date" -msgstr "" - -#: ../src/sessions/mastodon/templates.py:20 -msgid "" -"$display_name (@$screen_name). $followers followers, $following " -"following, $posts posts. Joined $created_at." -msgstr "" - -#: ../src/sessions/mastodon/templates.py:21 -msgid "$display_name $text, $date" -msgstr "" - -#: ../src/sessions/mastodon/templates.py:34 -msgid "Content warning: {}" -msgstr "" - -#: ../src/sessions/mastodon/templates.py:45 -msgid "Image description: {}" -msgstr "" - -#: ../src/sessions/mastodon/templates.py:85 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:48 -msgid "Followers only" -msgstr "" - -#: ../src/sessions/mastodon/templates.py:162 -msgid "has mentionned you: {status}" -msgstr "" - -#: ../src/sessions/mastodon/templates.py:164 -msgid "has boosted: {status}" -msgstr "" - -#: ../src/sessions/mastodon/templates.py:166 -msgid "has added to favorites: {status}" -msgstr "" - -#: ../src/sessions/mastodon/templates.py:168 -msgid "has updated a status: {status}" -msgstr "" - -#: ../src/sessions/mastodon/templates.py:170 -msgid "has followed you." -msgstr "" - -#: ../src/sessions/mastodon/templates.py:174 -msgid "wants to follow you." -msgstr "" - -#: ../src/sessions/mastodon/wxUI.py:6 ../src/sessions/twitter/session.py:162 -#: ../src/sessions/twitter/wxUI.py:6 -msgid "Authorising account..." -msgstr "" - -#: ../src/sessions/mastodon/wxUI.py:9 -msgid "URL of mastodon instance:" -msgstr "" - -#: ../src/sessions/mastodon/wxUI.py:15 -msgid "Email address:" -msgstr "" - -#: ../src/sessions/mastodon/wxUI.py:21 -msgid "Password:" -msgstr "" - -#: ../src/sessions/twitter/compose.py:68 ../src/sessions/twitter/compose.py:70 -#, python-format -msgid "Dm to %s " -msgstr "" - -#: ../src/sessions/twitter/compose.py:109 -msgid "{0}. Quoted tweet from @{1}: {2}" -msgstr "" - -#: ../src/sessions/twitter/compose.py:132 -msgid "Unavailable" -msgstr "" - -#: ../src/sessions/twitter/compose.py:133 -#, python-format -msgid "" -"%s (@%s). %s followers, %s friends, %s tweets. Last tweeted %s. Joined " -"Twitter %s" -msgstr "" - -#: ../src/sessions/twitter/compose.py:137 -msgid "No description available" -msgstr "" - -#: ../src/sessions/twitter/compose.py:141 -msgid "private" -msgstr "" - -#: ../src/sessions/twitter/compose.py:142 -msgid "public" -msgstr "" - -#: ../src/sessions/twitter/session.py:162 ../src/sessions/twitter/wxUI.py:9 -msgid "Enter your PIN code here" -msgstr "" - -#: ../src/sessions/twitter/session.py:171 -msgid "" -"We could not authorice your Twitter account to be used in TWBlue. This " -"might be caused due to an incorrect verification code. Please try to add " -"the session again." -msgstr "" - -#: ../src/sessions/twitter/session.py:440 -#: ../src/sessions/twitter/session.py:523 -msgid "Deleted account" -msgstr "" - -#: ../src/sessions/twitter/templates.py:17 -msgid "$sender_display_name, $text $date" -msgstr "" - -#: ../src/sessions/twitter/templates.py:19 -msgid "" -"$display_name (@$screen_name). $followers followers, $following " -"following, $tweets tweets. Joined Twitter $created_at." -msgstr "" - -#: ../src/sessions/twitter/templates.py:54 -msgid "Image description: {}." -msgstr "" - -#: ../src/sessions/twitter/utils.py:243 -msgid "Sorry, you are not authorised to see this status." -msgstr "" - -#: ../src/sessions/twitter/utils.py:245 -msgid "No status found with that ID" -msgstr "" - -#: ../src/sessions/twitter/utils.py:247 -msgid "Error {0}" -msgstr "" - -#: ../src/sessions/twitter/utils.py:274 -msgid "{user_1}, {user_2} and {all_users} more: {text}" -msgstr "" - -#: ../src/update/wxUpdater.py:11 -#, python-format -msgid "" -"There's a new %s version available, released on %s. Would you like to " -"download it now?\n" -"\n" -" %s version: %s\n" -"\n" -"Changes:\n" -"%s" -msgstr "" - -#: ../src/update/wxUpdater.py:14 -#, python-format -msgid "" -"There's a new %s version available, released on %s. Updates are not " -"automatic in Windows 7, so you would need to visit TWBlue's download " -"website to get the latest version.\n" -"\n" -" %s version: %s\n" -"\n" -"Changes:\n" -"%s" -msgstr "" - -#: ../src/update/wxUpdater.py:16 -#, python-format -msgid "New version for %s" -msgstr "" - -#: ../src/update/wxUpdater.py:23 -msgid "Download in Progress" -msgstr "" - -#: ../src/update/wxUpdater.py:23 -msgid "Downloading the new version..." -msgstr "" - -#: ../src/update/wxUpdater.py:33 -#, python-format -msgid "Updating... %s of %s" -msgstr "" - -#: ../src/update/wxUpdater.py:36 -msgid "" -"The update has been downloaded and installed successfully. Press OK to " -"continue." -msgstr "" - -#: ../src/update/wxUpdater.py:36 -msgid "Done!" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:7 -msgid "" -"This retweet is over 140 characters. Would you like to post it as a " -"mention to the poster with your comments and a link to the original " -"tweet?" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:10 -msgid "Would you like to add a comment to this tweet?" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:13 -msgid "" -"Do you really want to delete this tweet? It will be deleted from Twitter " -"as well." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:13 ../src/wxUI/dialogs/lists.py:149 -#: ../src/wxUI/dialogs/mastodon/dialogs.py:15 -msgid "Delete" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:16 -msgid "Do you really want to close {0}?" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:16 -msgid "Exit" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:20 -msgid " {0} must be restarted for these changes to take effect." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:20 -msgid "Restart {0} " -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:23 -msgid "" -"Are you sure you want to delete this user from the database? This user " -"will not appear in autocomplete results anymore." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:23 -msgid "Confirm" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:26 -msgid "Enter the name of the client : " -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:26 -#: ../src/wxUI/dialogs/configuration.py:267 -msgid "Add client" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:32 -msgid "" -"Do you really want to empty this buffer? It's items will be removed from" -" the list but not from Twitter" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:32 -#: ../src/wxUI/dialogs/mastodon/dialogs.py:31 -msgid "Empty buffer" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:36 -msgid "Do you really want to destroy this buffer?" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:42 -msgid "A timeline for this user already exists. You can't open another" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:42 -msgid "Existing timeline" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:45 -msgid "This user has no tweets, so you can't open a timeline for them." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:48 -msgid "" -"This is a protected Twitter user, which means you can't open a timeline " -"using the Streaming API. The user's tweets will not update due to a " -"twitter policy. Do you want to continue?" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:48 -#: ../src/wxUI/commonMessageDialogs.py:98 -msgid "Warning" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:51 -msgid "" -"This is a protected user account, you need to follow this user to view " -"their tweets or likes." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:54 -msgid "" -"If you like {0} we need your help to keep it going. Help us by donating " -"to the project. This will help us pay for the server, the domain and some" -" other things to ensure that {0} will be actively maintained. Your " -"donation will give us the means to continue the development of {0}, and " -"to keep {0} free. Would you like to donate now?" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:54 -msgid "We need your help" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:58 -msgid "This user has no tweets. {0} can't create a timeline." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:61 -msgid "This user has no favorited tweets. {0} can't create a timeline." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:64 -msgid "This user has no followers. {0} can't create a timeline." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:67 -msgid "This user has no friends. {0} can't create a timeline." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:71 -msgid "Geolocation data: {0}" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:71 -msgid "Geo data for this tweet" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:74 -msgid "" -"TWBlue has detected that you're running windows 10 and has changed the " -"default keymap to the Windows 10 keymap. It means that some keyboard " -"shorcuts could be different. Please check the keystroke editor by " -"pressing Alt+Win+K to see all available keystrokes for this keymap." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:74 -msgid "Information" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:77 -msgid "You have been blocked from viewing this content" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:80 -msgid "" -"You have been blocked from viewing someone's content. In order to avoid " -"conflicts with the full session, TWBlue will remove the affected " -"timeline." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:83 -msgid "" -"TWBlue cannot load this timeline because the user has been suspended from" -" Twitter." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:86 -msgid "Do you really want to delete this filter?" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:89 -msgid "This filter already exists. Please use a different title" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:95 -msgid "The configuration file is invalid." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:98 -msgid "" -"{0} quit unexpectedly the last time it was run. If the problem persists, " -"please report it to the {0} developers." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/menus.py:15 ../src/wxUI/menus.py:16 -#: ../src/wxUI/menus.py:36 ../src/wxUI/menus.py:52 -msgid "&Open URL" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/menus.py:17 ../src/wxUI/menus.py:18 -#: ../src/wxUI/menus.py:54 ../src/wxUI/menus.py:87 -msgid "&Open in Twitter" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/menus.py:19 ../src/wxUI/menus.py:20 -#: ../src/wxUI/menus.py:38 ../src/wxUI/menus.py:56 -msgid "&Play audio" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/menus.py:23 ../src/wxUI/menus.py:24 -#: ../src/wxUI/menus.py:42 ../src/wxUI/menus.py:60 ../src/wxUI/menus.py:70 -#: ../src/wxUI/menus.py:89 ../src/wxUI/menus.py:103 -msgid "&Copy to clipboard" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/menus.py:27 ../src/wxUI/menus.py:28 -#: ../src/wxUI/menus.py:46 ../src/wxUI/menus.py:91 -msgid "&User actions..." -msgstr "" - -#: ../src/wxUI/menus.py:40 -msgid "&Show direct message" -msgstr "" - -#: ../src/wxUI/menus.py:68 -msgid "&Show event" -msgstr "" - -#: ../src/wxUI/menus.py:78 -msgid "Direct &message" -msgstr "" - -#: ../src/wxUI/menus.py:85 -msgid "&Show user" -msgstr "" - -#: ../src/wxUI/buffers/twitter/trends.py:20 ../src/wxUI/menus.py:97 -msgid "Search topic" -msgstr "" - -#: ../src/wxUI/menus.py:99 -msgid "&Tweet about this trend" -msgstr "" - -#: ../src/wxUI/menus.py:101 -msgid "&Show item" -msgstr "" - -#: ../src/wxUI/sysTrayIcon.py:36 ../src/wxUI/view.py:24 -msgid "&Global settings" -msgstr "" - -#: ../src/wxUI/sysTrayIcon.py:37 ../src/wxUI/view.py:23 -msgid "Account se&ttings" -msgstr "" - -#: ../src/wxUI/sysTrayIcon.py:38 -msgid "Update &profile" -msgstr "" - -#: ../src/wxUI/sysTrayIcon.py:39 -msgid "&Show / hide" -msgstr "" - -#: ../src/wxUI/sysTrayIcon.py:40 ../src/wxUI/view.py:73 -msgid "&Documentation" -msgstr "" - -#: ../src/wxUI/sysTrayIcon.py:41 -msgid "Check for &updates" -msgstr "" - -#: ../src/wxUI/sysTrayIcon.py:42 -msgid "&Exit" -msgstr "" - -#: ../src/wxUI/view.py:16 -msgid "&Manage accounts" -msgstr "" - -#: ../src/wxUI/view.py:18 -msgid "&Hide window" -msgstr "" - -#: ../src/wxUI/view.py:22 -msgid "&Edit keystrokes" -msgstr "" - -#: ../src/wxUI/view.py:25 -msgid "E&xit" -msgstr "" - -#: ../src/wxUI/view.py:50 -msgid "V&iew likes" -msgstr "" - -#: ../src/wxUI/view.py:54 -msgid "&Update buffer" -msgstr "" - -#: ../src/wxUI/view.py:58 -msgid "Find a string in the currently focused buffer..." -msgstr "" - -#: ../src/wxUI/view.py:59 -msgid "&Load previous items" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userActions.py:21 -#: ../src/wxUI/dialogs/userActions.py:22 ../src/wxUI/view.py:61 -msgid "&Mute" -msgstr "" - -#: ../src/wxUI/view.py:62 -msgid "&Autoread" -msgstr "" - -#: ../src/wxUI/view.py:63 -msgid "&Clear buffer" -msgstr "" - -#: ../src/wxUI/view.py:64 -msgid "&Destroy" -msgstr "" - -#: ../src/wxUI/view.py:68 -msgid "&Seek back 5 seconds" -msgstr "" - -#: ../src/wxUI/view.py:69 -msgid "&Seek forward 5 seconds" -msgstr "" - -#: ../src/wxUI/view.py:74 -msgid "Sounds &tutorial" -msgstr "" - -#: ../src/wxUI/view.py:75 -msgid "&What's new in this version?" -msgstr "" - -#: ../src/wxUI/view.py:76 -msgid "&Check for updates" -msgstr "" - -#: ../src/wxUI/view.py:77 -msgid "&Report an error" -msgstr "" - -#: ../src/wxUI/view.py:78 -msgid "{0}'s &website" -msgstr "" - -#: ../src/wxUI/view.py:79 -msgid "Get soundpacks for TWBlue" -msgstr "" - -#: ../src/wxUI/view.py:80 -msgid "About &{0}" -msgstr "" - -#: ../src/wxUI/view.py:83 -msgid "&Application" -msgstr "" - -#: ../src/wxUI/view.py:84 -msgid "&Item" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userActions.py:10 -#: ../src/wxUI/dialogs/userActions.py:11 ../src/wxUI/view.py:85 -msgid "&User" -msgstr "" - -#: ../src/wxUI/view.py:86 -msgid "&Buffer" -msgstr "" - -#: ../src/wxUI/view.py:87 -msgid "&Audio" -msgstr "" - -#: ../src/wxUI/view.py:88 -msgid "&Help" -msgstr "" - -#: ../src/wxUI/view.py:174 -msgid "Address" -msgstr "" - -#: ../src/wxUI/view.py:205 -msgid "Your {0} version is up to date" -msgstr "" - -#: ../src/wxUI/view.py:205 -msgid "Update" -msgstr "" - -#: ../src/wxUI/buffers/panels.py:12 ../src/wxUI/buffers/panels.py:20 -msgid "Login" -msgstr "" - -#: ../src/wxUI/buffers/panels.py:14 -msgid "Log in automatically" -msgstr "" - -#: ../src/wxUI/buffers/panels.py:22 -msgid "Logout" -msgstr "" - -#: ../src/wxUI/buffers/mastodon/base.py:11 -#: ../src/wxUI/buffers/mastodon/conversationList.py:11 -#: ../src/wxUI/buffers/mastodon/user.py:8 -#: ../src/wxUI/buffers/twitter/base.py:12 -#: ../src/wxUI/buffers/twitter/people.py:12 -#: ../src/wxUI/buffers/twitter/user_searches.py:11 -#: ../src/wxUI/dialogs/mastodon/userTimeline.py:10 -#: ../src/wxUI/dialogs/userAliasDialogs.py:14 -#: ../src/wxUI/dialogs/userSelection.py:11 ../src/wxUI/dialogs/utils.py:32 -msgid "User" -msgstr "" - -#: ../src/wxUI/buffers/mastodon/base.py:11 -#: ../src/wxUI/buffers/mastodon/conversationList.py:11 -#: ../src/wxUI/buffers/mastodon/notifications.py:11 -#: ../src/wxUI/buffers/twitter/base.py:12 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:36 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:47 -msgid "Text" -msgstr "" - -#: ../src/wxUI/buffers/mastodon/base.py:11 -#: ../src/wxUI/buffers/mastodon/conversationList.py:11 -#: ../src/wxUI/buffers/mastodon/notifications.py:11 -#: ../src/wxUI/buffers/twitter/base.py:12 -#: ../src/wxUI/buffers/twitter/events.py:14 -msgid "Date" -msgstr "" - -#: ../src/wxUI/buffers/mastodon/base.py:11 -#: ../src/wxUI/buffers/mastodon/conversationList.py:11 -#: ../src/wxUI/buffers/twitter/base.py:12 -msgid "Client" -msgstr "" - -#: ../src/wxUI/buffers/mastodon/base.py:27 -msgid "Favorite" -msgstr "" - -#: ../src/wxUI/buffers/mastodon/base.py:28 -msgid "Bookmark" -msgstr "" - -#: ../src/wxUI/buffers/mastodon/base.py:29 -#: ../src/wxUI/buffers/twitter/base.py:28 -msgid "Direct message" -msgstr "" - -#: ../src/wxUI/buffers/mastodon/notifications.py:23 -#: ../src/wxUI/dialogs/mastodon/dialogs.py:23 -msgid "Dismiss" -msgstr "" - -#: ../src/wxUI/buffers/mastodon/user.py:19 -#: ../src/wxUI/dialogs/userAliasDialogs.py:48 -msgid "Actions" -msgstr "" - -#: ../src/wxUI/buffers/mastodon/user.py:20 -msgid "Message" -msgstr "" - -#: ../src/wxUI/buffers/twitter/events.py:14 -msgid "Event" -msgstr "" - -#: ../src/wxUI/buffers/twitter/events.py:16 -msgid "Remove event" -msgstr "" - -#: ../src/wxUI/buffers/twitter/trends.py:9 -msgid "Trending topic" -msgstr "" - -#: ../src/wxUI/buffers/twitter/trends.py:19 -msgid "Tweet about this trend" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:15 -msgid "Language" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:22 -msgid "Run {0} at Windows startup" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:23 -msgid "ask before exiting {0}" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:26 -msgid "Disable Streaming functions" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:29 -msgid "Buffer update interval, in minutes" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:35 -msgid "Play a sound when {0} launches" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:37 -msgid "Speak a message when {0} launches" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:39 -msgid "Use invisible interface's keyboard shortcuts while GUI is visible" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:41 -msgid "Activate Sapi5 when any other screen reader is not being run" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:43 -msgid "Hide GUI on launch" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:45 -msgid "Use Codeofdusk's longtweet handlers (may decrease client performance)" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:47 -msgid "Remember state for mention all and long tweet" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:50 -msgid "Keymap" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:55 -msgid "Check for updates when {0} launches" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:65 -msgid "Proxy type: " -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:72 -msgid "Proxy server: " -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:78 -msgid "Port: " -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:84 -msgid "User: " -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:90 -msgid "Password: " -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:102 -#: ../src/wxUI/dialogs/mastodon/configuration.py:14 -msgid "User autocompletion settings" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:103 -msgid "" -"Scan account and add friends and followers to the user autocompletion " -"database" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:104 -#: ../src/wxUI/dialogs/mastodon/configuration.py:17 -msgid "Manage autocompletion database" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:109 -#: ../src/wxUI/dialogs/mastodon/configuration.py:23 -msgid "Relative timestamps" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:112 -#: ../src/wxUI/dialogs/mastodon/configuration.py:26 -msgid "Items on each API call" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:118 -msgid "" -"Inverted buffers: The newest tweets will be shown at the beginning while " -"the oldest at the end" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:120 -msgid "Retweet mode" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:126 -#: ../src/wxUI/dialogs/mastodon/configuration.py:36 -msgid "Show screen names instead of full names" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:128 -#: ../src/wxUI/dialogs/mastodon/configuration.py:38 -msgid "hide emojis in usernames" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:130 -#: ../src/wxUI/dialogs/mastodon/configuration.py:40 -msgid "" -"Number of items per buffer to cache in database (0 to disable caching, " -"blank for unlimited)" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:134 -msgid "" -"Load cache for tweets in memory (much faster in big datasets but requires" -" more RAM)" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:141 -msgid "Enable automatic speech feedback" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:143 -msgid "Enable automatic Braille feedback" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:151 -#: ../src/wxUI/dialogs/filterDialogs.py:130 -msgid "Buffer" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:151 -msgid "Status" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:154 -msgid "Show/hide" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:155 -msgid "Move up" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:156 -msgid "Move down" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:166 -#: ../src/wxUI/dialogs/configuration.py:231 -#: ../src/wxUI/dialogs/configuration.py:234 -#: ../src/wxUI/dialogs/configuration.py:239 -msgid "Show" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:168 -#: ../src/wxUI/dialogs/configuration.py:178 -#: ../src/wxUI/dialogs/configuration.py:202 -#: ../src/wxUI/dialogs/configuration.py:232 -msgid "Hide" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:176 -#: ../src/wxUI/dialogs/configuration.py:200 -msgid "Select a buffer first." -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:179 -#: ../src/wxUI/dialogs/configuration.py:203 -msgid "The buffer is hidden, show it first." -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:182 -msgid "The buffer is already at the top of the list." -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:206 -msgid "The buffer is already at the bottom of the list." -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:261 -#: ../src/wxUI/dialogs/configuration.py:402 -msgid "Ignored clients" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:268 -msgid "Remove client" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:292 -#: ../src/wxUI/dialogs/mastodon/configuration.py:63 -msgid "Volume" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:303 -#: ../src/wxUI/dialogs/mastodon/configuration.py:74 -msgid "Session mute" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:305 -#: ../src/wxUI/dialogs/mastodon/configuration.py:76 -msgid "Output device" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:312 -#: ../src/wxUI/dialogs/mastodon/configuration.py:83 -msgid "Input device" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:320 -#: ../src/wxUI/dialogs/mastodon/configuration.py:91 -msgid "Sound pack" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:326 -msgid "Indicate audio tweets with sound" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:328 -msgid "Indicate geotweets with sound" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:330 -msgid "Indicate tweets containing images with sound" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:353 -#: ../src/wxUI/dialogs/mastodon/configuration.py:122 -msgid "Language for OCR" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:359 -msgid "API Key for SndUp" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:374 -msgid "{0} preferences" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:380 -#: ../src/wxUI/dialogs/configuration.py:389 -#: ../src/wxUI/dialogs/mastodon/configuration.py:142 -msgid "General" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:385 -msgid "Proxy" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:394 -#: ../src/wxUI/dialogs/mastodon/configuration.py:147 -msgid "Feedback" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:398 -#: ../src/wxUI/dialogs/mastodon/configuration.py:151 -msgid "Buffers" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:406 -#: ../src/wxUI/dialogs/mastodon/configuration.py:155 -msgid "Templates" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:410 -#: ../src/wxUI/dialogs/mastodon/configuration.py:159 -msgid "Sound" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:414 -#: ../src/wxUI/dialogs/mastodon/configuration.py:163 -msgid "Extras" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:419 -#: ../src/wxUI/dialogs/mastodon/configuration.py:168 -msgid "Save" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:13 -msgid "Create a filter for this buffer" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:14 -msgid "Filter title" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:24 -#: ../src/wxUI/dialogs/filterDialogs.py:130 -msgid "Filter by word" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:25 -msgid "Ignore tweets wich contain the following word" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:26 -msgid "Ignore tweets without the following word" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:31 -msgid "word" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:36 -msgid "Allow retweets" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:37 -msgid "Allow quoted tweets" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:38 -msgid "Allow replies" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:46 -msgid "Use this term as a regular expression" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:48 -#: ../src/wxUI/dialogs/filterDialogs.py:130 -msgid "Filter by language" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:49 -msgid "Load tweets in the following languages" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:50 -msgid "Ignore tweets in the following languages" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:51 -msgid "Don't filter by language" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:62 -msgid "Supported languages" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:67 -msgid "Add selected language to filter" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:71 -msgid "Selected languages" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:73 -#: ../src/wxUI/dialogs/filterDialogs.py:137 ../src/wxUI/dialogs/lists.py:21 -#: ../src/wxUI/dialogs/lists.py:132 ../src/wxUI/dialogs/userAliasDialogs.py:57 -msgid "Remove" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:83 ../src/wxUI/dialogs/find.py:23 -msgid "Cancel" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:120 -msgid "You must define a name for the filter before creating it." -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:120 -msgid "Missing filter name" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:127 -msgid "Manage filters" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:129 -msgid "Filters" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:130 -msgid "Filter" -msgstr "" - -#: ../src/wxUI/dialogs/find.py:13 -msgid "Find in current buffer" -msgstr "" - -#: ../src/wxUI/dialogs/find.py:14 -msgid "String" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:11 -msgid "Lists manager" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:14 -msgid "List" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:14 ../src/wxUI/dialogs/lists.py:70 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:25 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:134 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:37 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:126 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:173 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:257 -msgid "Description" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:14 -msgid "Owner" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:14 -msgid "Members" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:14 -msgid "mode" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:19 ../src/wxUI/dialogs/lists.py:62 -msgid "Create a new list" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:22 -msgid "Open in buffer" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:52 -#, python-format -msgid "Viewing lists for %s" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:53 -msgid "Subscribe" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:54 -msgid "Unsubscribe" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:65 -msgid "Name (20 characters maximun)" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:75 -msgid "Mode" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:77 -msgid "Private" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:97 -#, python-format -msgid "Editing the list %s" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:108 -msgid "Select a list to add the user" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:109 -msgid "Add" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:131 -msgid "Select a list to remove the user" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:149 -msgid "Do you really want to delete this list?" -msgstr "" - -#: ../src/wxUI/dialogs/search.py:12 -msgid "Search on Twitter" -msgstr "" - -#: ../src/wxUI/dialogs/search.py:21 -msgid "Tweets" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/search.py:19 ../src/wxUI/dialogs/search.py:22 -#: ../src/wxUI/dialogs/userAliasDialogs.py:43 -msgid "Users" -msgstr "" - -#: ../src/wxUI/dialogs/search.py:29 -msgid "&Language for results: " -msgstr "" - -#: ../src/wxUI/dialogs/search.py:31 ../src/wxUI/dialogs/search.py:55 -msgid "any" -msgstr "" - -#: ../src/wxUI/dialogs/search.py:37 -msgid "Results &type: " -msgstr "" - -#: ../src/wxUI/dialogs/search.py:38 ../src/wxUI/dialogs/search.py:63 -msgid "Mixed" -msgstr "" - -#: ../src/wxUI/dialogs/search.py:38 ../src/wxUI/dialogs/search.py:64 -msgid "Recent" -msgstr "" - -#: ../src/wxUI/dialogs/search.py:38 ../src/wxUI/dialogs/search.py:65 -msgid "Popular" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/search.py:24 -#: ../src/wxUI/dialogs/mastodon/userActions.py:36 -#: ../src/wxUI/dialogs/mastodon/userTimeline.py:30 -#: ../src/wxUI/dialogs/search.py:43 ../src/wxUI/dialogs/trends.py:25 -#: ../src/wxUI/dialogs/userActions.py:41 -#: ../src/wxUI/dialogs/userSelection.py:33 -msgid "&OK" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/search.py:26 -#: ../src/wxUI/dialogs/mastodon/userActions.py:38 -#: ../src/wxUI/dialogs/mastodon/userTimeline.py:32 -#: ../src/wxUI/dialogs/search.py:45 ../src/wxUI/dialogs/show_user.py:19 -#: ../src/wxUI/dialogs/trends.py:27 ../src/wxUI/dialogs/update_profile.py:37 -#: ../src/wxUI/dialogs/userActions.py:43 -#: ../src/wxUI/dialogs/userSelection.py:35 -msgid "&Close" -msgstr "" - -#: ../src/wxUI/dialogs/show_user.py:12 +#: src/wxUI/buffers/atprotosocial/panels.py msgid "Details" msgstr "" -#: ../src/wxUI/dialogs/show_user.py:17 -msgid "&Go to URL" +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "Refreshing recent notifications. True 'load older' for notifications is not yet fully implemented." msgstr "" -#: ../src/wxUI/dialogs/trends.py:10 -msgid "View trending topics" +#: src/sessionmanager/wxUI.py +msgid "ATProtoSocial (Bluesky)" msgstr "" -#: ../src/wxUI/dialogs/trends.py:11 -msgid "Trending topics by" +#: src/sessionmanager/wxUI.py +msgid "You will be prompted for your ATProtoSocial (Bluesky) data (user handle and App Password) to authorize TWBlue. Would you like to authorize your account now?" msgstr "" -#: ../src/wxUI/dialogs/trends.py:12 -msgid "Country" +#: src/sessionmanager/wxUI.py +msgid "ATProtoSocial Authorization" msgstr "" -#: ../src/wxUI/dialogs/trends.py:13 -msgid "City" +#: src/sessionmanager/sessionManager.py +msgid "{handle} (Bluesky)" msgstr "" - -#: ../src/wxUI/dialogs/trends.py:19 ../src/wxUI/dialogs/update_profile.py:18 -msgid "&Location" -msgstr "" - -#: ../src/wxUI/dialogs/update_profile.py:10 -msgid "Update your profile" -msgstr "" - -#: ../src/wxUI/dialogs/update_profile.py:12 -msgid "&Name (50 characters maximum)" -msgstr "" - -#: ../src/wxUI/dialogs/update_profile.py:23 -msgid "&Website" -msgstr "" - -#: ../src/wxUI/dialogs/update_profile.py:28 -msgid "&Bio (160 characters maximum)" -msgstr "" - -#: ../src/wxUI/dialogs/update_profile.py:34 -msgid "Upload a &picture" -msgstr "" - -#: ../src/wxUI/dialogs/update_profile.py:77 -msgid "Upload a picture" -msgstr "" - -#: ../src/wxUI/dialogs/update_profile.py:79 -msgid "Discard image" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:141 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:133 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:289 -#: ../src/wxUI/dialogs/update_profile.py:82 -msgid "Select the picture to be uploaded" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:141 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:133 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:289 -#: ../src/wxUI/dialogs/update_profile.py:82 -msgid "Image files (*.png, *.jpg, *.gif)|*.png; *.jpg; *.gif" -msgstr "" - -#: ../src/wxUI/dialogs/urlList.py:6 -msgid "Select URL" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userActions.py:13 -#: ../src/wxUI/dialogs/mastodon/userTimeline.py:13 -#: ../src/wxUI/dialogs/userActions.py:14 -#: ../src/wxUI/dialogs/userAliasDialogs.py:13 -#: ../src/wxUI/dialogs/userSelection.py:14 ../src/wxUI/dialogs/utils.py:31 -msgid "&Autocomplete users" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userActions.py:19 -#: ../src/wxUI/dialogs/userActions.py:20 -msgid "&Follow" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userActions.py:20 -#: ../src/wxUI/dialogs/userActions.py:21 -msgid "U&nfollow" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userActions.py:22 -#: ../src/wxUI/dialogs/userActions.py:23 -msgid "Unmu&te" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userActions.py:23 -#: ../src/wxUI/dialogs/userActions.py:24 -msgid "&Block" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userActions.py:24 -#: ../src/wxUI/dialogs/userActions.py:25 -msgid "Unbl&ock" -msgstr "" - -#: ../src/wxUI/dialogs/userActions.py:26 -msgid "&Report as spam" -msgstr "" - -#: ../src/wxUI/dialogs/userActions.py:27 -msgid "&Ignore tweets from this client" -msgstr "" - -#: ../src/wxUI/dialogs/userAliasDialogs.py:18 -msgid "Alias" -msgstr "" - -#: ../src/wxUI/dialogs/userAliasDialogs.py:41 -msgid "Edit user aliases" -msgstr "" - -#: ../src/wxUI/dialogs/userAliasDialogs.py:50 -msgid "Add alias" -msgstr "" - -#: ../src/wxUI/dialogs/userAliasDialogs.py:51 -msgid "Adds a new user alias" -msgstr "" - -#: ../src/wxUI/dialogs/userAliasDialogs.py:54 -msgid "Edit the currently focused user Alias." -msgstr "" - -#: ../src/wxUI/dialogs/userAliasDialogs.py:58 -msgid "Remove the currently focused user alias." -msgstr "" - -#: ../src/wxUI/dialogs/userAliasDialogs.py:82 -msgid "Are you sure you want to delete this user alias?" -msgstr "" - -#: ../src/wxUI/dialogs/userAliasDialogs.py:82 -msgid "Remove user alias" -msgstr "" - -#: ../src/wxUI/dialogs/userAliasDialogs.py:93 -msgid "User alias" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userTimeline.py:9 -#: ../src/wxUI/dialogs/userSelection.py:10 -#, python-format -msgid "Timeline for %s" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userTimeline.py:18 -#: ../src/wxUI/dialogs/userSelection.py:19 -msgid "Buffer type" -msgstr "" - -#: ../src/wxUI/dialogs/userSelection.py:20 -msgid "&Tweets" -msgstr "" - -#: ../src/wxUI/dialogs/userSelection.py:21 -msgid "&Likes" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userTimeline.py:20 -#: ../src/wxUI/dialogs/userSelection.py:22 -msgid "&Followers" -msgstr "" - -#: ../src/wxUI/dialogs/userSelection.py:23 -msgid "F&riends" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/configuration.py:15 -msgid "" -"Scan account and add followers and following users to the user " -"autocompletion database" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/configuration.py:32 -msgid "" -"Inverted buffers: The newest items will be shown at the beginning while " -"the oldest at the end" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/configuration.py:34 -msgid "Ask confirmation before boosting a post" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/configuration.py:44 -msgid "" -"Load cache for items in memory (much faster in big datasets but requires " -"more RAM)" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/configuration.py:97 -msgid "Indicate audio or video in posts with sound" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/configuration.py:99 -msgid "Indicate posts containing images with sound" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/dialogs.py:7 -msgid "Would you like to share this post?" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/dialogs.py:15 -msgid "" -"Do you really want to delete this post? It will be deleted from the " -"instance as well." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/dialogs.py:23 -msgid "" -"Are you sure you want to dismiss this notification? If you dismiss a " -"mention notification, it also disappears from your mentions buffer. The " -"post is not going to be deleted from the instance, though." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/dialogs.py:31 -msgid "" -"Do you really want to empty this buffer? It's items will be removed from" -" the list but not from the instance" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/dialogs.py:38 -msgid "This user has no posts. {0} can't create a timeline." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/dialogs.py:43 -msgid "This user has no favorited posts. {0} can't create a timeline." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/dialogs.py:48 -msgid "This user has no followers yet. {0} can't create a timeline." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/dialogs.py:53 -msgid "This user is not following anyone. {0} can't create a timeline." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/menus.py:13 -msgid "R&emove from favorites" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:19 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:37 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:32 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:48 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:168 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:252 -msgid "Attachments" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:24 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:36 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:172 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:256 -msgid "Type" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:27 -msgid "Remove Attachment" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:32 -msgid "Post in the thread" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:39 -msgid "Remove post" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:46 -msgid "Visibility" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:51 -msgid "A&dd" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:52 -msgid "Sensitive content" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:57 -msgid "Content warning" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:64 -msgid "Add p&ost" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:68 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:65 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:196 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:235 -msgid "Auto&complete users" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:70 -msgid "Check &spelling" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:72 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:69 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:200 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:272 -msgid "&Translate" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:99 -msgid "Post - {} characters" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:123 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:117 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:218 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:296 -msgid "Image" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:125 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:119 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:220 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:298 -msgid "Video" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:127 -msgid "Audio" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:129 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:121 -msgid "Poll" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:134 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:126 -msgid "please provide a description" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:148 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:140 -msgid "Select the video to be uploaded" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:148 -msgid "Video files (*.mp4, *.mov, *.m4v, *.webm)| *.mp4; *.m4v; *.mov; *.webm" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:154 -msgid "" -"Audio files (*.mp3, *.ogg, *.wav, *.flac, *.opus, *.aac, *.m4a, " -"*.3gp)|*.mp3; *.ogg; *.wav; *.flac; *.opus; *.aac; *.m4a; *.3gp" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:160 -msgid "" -"It is not possible to add more attachments. Please take into account that" -" You can add only a maximum of 4 images, or one audio, video or poll per" -" post. Please remove other attachments before continuing." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:160 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:146 -msgid "Error adding attachment" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:163 -msgid "" -"You can add a poll or media files. In order to add your poll, please " -"remove other attachments first." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:163 -msgid "Error adding poll" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:167 -#, python-format -msgid "Post - %i characters " -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:180 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:321 -msgid "Image description" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:187 -msgid "Privacy" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:192 -msgid "Boosts: " -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:197 -msgid "Favorites: " -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:202 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:342 -msgid "Source: " -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:207 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:347 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:425 -msgid "Date: " -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:219 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:362 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:435 -msgid "Copy link to clipboard" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:221 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:67 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:198 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:270 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:364 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:437 -msgid "Check &spelling..." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:222 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:365 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:440 -msgid "&Translate..." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:223 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:366 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:441 -msgid "C&lose" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:258 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:477 -msgid "Add a poll" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:262 -msgid "Participation time" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:264 -msgid "5 minutes" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:264 -msgid "30 minutes" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:264 -msgid "1 hour" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:264 -msgid "6 hours" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:264 -msgid "1 day" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:264 -msgid "2 days" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:264 -msgid "3 days" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:264 -msgid "4 days" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:264 -msgid "5 days" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:264 -msgid "6 days" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:264 -msgid "7 days" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:268 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:488 -msgid "Choices" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:272 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:492 -msgid "Option 1" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:279 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:499 -msgid "Option 2" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:286 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:506 -msgid "Option 3" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:293 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:513 -msgid "Option 4" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:298 -msgid "Allow multiple votes per user" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:301 -msgid "Hide votes count until the poll expires" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:327 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:541 -msgid "Please make sure you have provided at least two options for the poll." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:327 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:541 -msgid "Not enough information" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/search.py:9 -msgid "Search" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/search.py:18 -msgid "Posts" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userTimeline.py:19 -msgid "&Posts" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userTimeline.py:21 -msgid "Fo&llowing" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/templateDialogs.py:8 -msgid "Edit Template" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/templateDialogs.py:13 -msgid "Edit template" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/templateDialogs.py:17 -msgid "Available variables" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/templateDialogs.py:29 -msgid "Restore template" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/templateDialogs.py:48 -msgid "Restored template to {}." -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/templateDialogs.py:52 -msgid "" -"the template you have specified include variables that do not exists for " -"the object. Please fix the template and try again. For your reference, " -"you can see a list of all available variables in the variables list while" -" editing your template." -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/templateDialogs.py:52 -msgid "Invalid template" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:39 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:175 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:259 -msgid "Delete attachment" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:44 -msgid "Added Tweets" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:51 -msgid "Delete tweet" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:56 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:190 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:264 -msgid "A&dd..." -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:58 -msgid "Add t&weet" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:61 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:192 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:266 -msgid "&Attach audio..." -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:73 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:204 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:276 -msgid "Sen&d" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:140 -msgid "Video files (*.mp4)|*.mp4" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:146 -msgid "" -"It is not possible to add more attachments. Please make sure your tweet " -"complies with Twitter'S attachment rules. You can add only one video or " -"GIF in every tweet, and a maximum of 4 photos." -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:180 -msgid "&Mention to all" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:233 -msgid "&Recipient" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:304 -#, python-format -msgid "Tweet - %i characters " -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:332 -msgid "Retweets: " -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:337 -msgid "Likes: " -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:410 -msgid "View" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:412 -msgid "Item" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:438 -msgid "&Expand URL" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:481 -msgid "Participation time (in days)" -msgstr "" -