This commit is contained in:
Jesús Pavón Abián
2026-01-10 19:46:53 +01:00
55 changed files with 1504 additions and 407 deletions

View File

@@ -1,4 +1,4 @@
[atprotosocial]
[blueski]
handle = string(default="")
app_password = string(default="")
did = string(default="")

View File

@@ -24,13 +24,13 @@ class Handler:
from pubsub import pub
pub.sendMessage("core.create_account", name=name, session_id=session.session_id, logged=True)
root_position = controller.view.search(name, name)
# Home timeline only for now
# Discover/home timeline
from pubsub import pub
pub.sendMessage(
"createBuffer",
buffer_type="home_timeline",
session_type="atprotosocial",
buffer_title=_("Home"),
session_type="blueski",
buffer_title=_("Discover"),
parent_tab=root_position,
start=True,
kwargs=dict(parent=controller.view.nb, name="home_timeline", session=session)
@@ -39,8 +39,8 @@ class Handler:
pub.sendMessage(
"createBuffer",
buffer_type="following_timeline",
session_type="atprotosocial",
buffer_title=_("Following"),
session_type="blueski",
buffer_title=_("Following (Chronological)"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="following_timeline", session=session)
@@ -71,7 +71,7 @@ class Handler:
current_mode = None
ask_default = True if current_mode in (None, "ask") else False
from wxUI.dialogs.atprotosocial.configuration import AccountSettingsDialog
from wxUI.dialogs.blueski.configuration import AccountSettingsDialog
dlg = AccountSettingsDialog(controller.view, ask_before_boost=ask_default)
resp = dlg.ShowModal()
if resp == wx.ID_OK:

View File

@@ -8,34 +8,34 @@ from typing import Any
logger = logging.getLogger(__name__)
# This file would typically contain functions to generate complex message bodies or
# interactive components for ATProtoSocial, similar to how it might be done for Mastodon.
# Since ATProtoSocial's interactive features (beyond basic posts) are still evolving
# interactive components for Blueski, similar to how it might be done for Mastodon.
# Since Blueski's interactive features (beyond basic posts) are still evolving
# or client-dependent (like polls), this might be less complex initially.
# Example: If ATProtoSocial develops a standard for "cards" or interactive messages,
# Example: If Blueski develops a standard for "cards" or interactive messages,
# functions to create those would go here. For now, we can imagine placeholders.
def format_welcome_message(session: Any) -> dict[str, Any]:
"""
Generates a welcome message for a new ATProtoSocial session.
Generates a welcome message for a new Blueski session.
This is just a placeholder and example.
"""
# user_profile = session.util.get_own_profile_info() # Assuming this method exists and is async or cached
# handle = user_profile.get("handle", _("your ATProtoSocial account")) if user_profile else _("your ATProtoSocial account")
# handle = user_profile.get("handle", _("your Blueski account")) if user_profile else _("your Blueski account")
# Expect session to expose username via db/settings
handle = (getattr(session, "db", {}).get("user_name")
or getattr(getattr(session, "settings", {}), "get", lambda *_: {})("atprotosocial").get("handle")
or getattr(getattr(session, "settings", {}), "get", lambda *_: {})("blueski").get("handle")
or _("your Bluesky account"))
return {
"text": _("Welcome to Approve for ATProtoSocial! Your account {handle} is connected.").format(handle=handle),
# "blocks": [ # If ATProtoSocial supports a block kit like Slack or Discord
"text": _("Welcome to Approve for Blueski! Your account {handle} is connected.").format(handle=handle),
# "blocks": [ # If Blueski supports a block kit like Slack or Discord
# {
# "type": "section",
# "text": {
# "type": "mrkdwn", # Or ATProtoSocial's equivalent
# "text": _("Welcome to Approve for ATProtoSocial! Your account *{handle}* is connected.").format(handle=handle)
# "type": "mrkdwn", # Or Blueski's equivalent
# "text": _("Welcome to Approve for Blueski! Your account *{handle}* is connected.").format(handle=handle)
# }
# },
# {
@@ -44,7 +44,7 @@ def format_welcome_message(session: Any) -> dict[str, Any]:
# {
# "type": "button",
# "text": {"type": "plain_text", "text": _("Post your first Skeet")},
# "action_id": "atprotosocial_compose_new_post" # Example action ID
# "action_id": "blueski_compose_new_post" # Example action ID
# }
# ]
# }
@@ -65,16 +65,16 @@ def format_error_message(error_description: str, details: str | None = None) ->
# ]
return message
# More functions could be added here as ATProtoSocial's capabilities become clearer
# More functions could be added here as Blueski's capabilities become clearer
# or as specific formatting needs for Approve arise. For example:
# - Formatting a post for display with all its embeds and cards.
# - Generating help messages specific to ATProtoSocial features.
# - Generating help messages specific to Blueski features.
# - Creating interactive messages for polls (if supported via some convention).
# Example of adapting a function that might exist in mastodon_messages:
# def build_post_summary_message(session: ATProtoSocialSession, post_uri: str, post_content: dict) -> dict[str, Any]:
# def build_post_summary_message(session: BlueskiSession, post_uri: str, post_content: dict) -> dict[str, Any]:
# """
# Builds a summary message for an ATProtoSocial post.
# Builds a summary message for an Blueski post.
# """
# author_handle = post_content.get("author", {}).get("handle", "Unknown user")
# text_preview = post_content.get("text", "")[:100] # First 100 chars of text
@@ -88,4 +88,4 @@ def format_error_message(error_description: str, details: str | None = None) ->
# # Potentially with "blocks" for richer formatting if the platform supports it
# }
logger.info("ATProtoSocial messages module loaded (placeholders).")
logger.info("Blueski messages module loaded (placeholders).")

View File

@@ -8,29 +8,29 @@ fromapprove.translation import translate as _
if TYPE_CHECKING:
fromapprove.config import ConfigSectionProxy
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession # Adjusted
fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted
logger = logging.getLogger(__name__)
# This file is for defining forms and handling for ATProtoSocial-specific settings
# This file is for defining forms and handling for Blueski-specific settings
# that might be more complex than simple key-value pairs handled by Session.get_settings_inputs.
# For ATProtoSocial, initial settings might be simple (handle, app password),
# For Blueski, initial settings might be simple (handle, app password),
# but this structure allows for expansion.
class ATProtoSocialSettingsForm(Form):
class BlueskiSettingsForm(Form):
"""
A settings form for ATProtoSocial sessions.
A settings form for Blueski sessions.
This would mirror the kind of settings found in Session.get_settings_inputs
but using the WTForms-like Form structure for more complex validation or layout.
"""
# Example fields - these should align with what ATProtoSocialSession.get_settings_inputs defines
# and what ATProtoSocialSession.get_configurable_values expects for its config.
# Example fields - these should align with what BlueskiSession.get_settings_inputs defines
# and what BlueskiSession.get_configurable_values expects for its config.
# instance_url = TextField(
# _("Instance URL"),
# default="https://bsky.social", # Default PDS for Bluesky
# description=_("The base URL of your ATProtoSocial PDS instance (e.g., https://bsky.social)."),
# description=_("The base URL of your Blueski PDS instance (e.g., https://bsky.social)."),
# validators=[], # Add validators if needed, e.g., URL validator
# )
handle = TextField(
@@ -43,19 +43,19 @@ class ATProtoSocialSettingsForm(Form):
description=_("Your Bluesky App Password. Generate this in your Bluesky account settings."),
validators=[], # e.g., DataRequired()
)
# Add more fields as needed for ATProtoSocial configuration.
# Add more fields as needed for Blueski configuration.
# For example, if there were specific notification settings, content filters, etc.
submit = SubmitField(_("Save ATProtoSocial Settings"))
submit = SubmitField(_("Save Blueski Settings"))
async def get_settings_form(
user_id: str,
session: ATProtoSocialSession | None = None,
config: ConfigSectionProxy | None = None, # User-specific config for ATProtoSocial
) -> ATProtoSocialSettingsForm:
session: BlueskiSession | None = None,
config: ConfigSectionProxy | None = None, # User-specific config for Blueski
) -> BlueskiSettingsForm:
"""
Creates and pre-populates the ATProtoSocial settings form.
Creates and pre-populates the Blueski settings form.
"""
form_data = {}
if session: # If a session exists, use its current config
@@ -68,31 +68,31 @@ async def get_settings_form(
form_data["handle"] = config.handle.get("")
form_data["app_password"] = ""
form = ATProtoSocialSettingsForm(formdata=None, **form_data) # formdata=None for initial display
form = BlueskiSettingsForm(formdata=None, **form_data) # formdata=None for initial display
return form
async def process_settings_form(
form: ATProtoSocialSettingsForm,
form: BlueskiSettingsForm,
user_id: str,
session: ATProtoSocialSession | None = None, # Pass if update should affect live session
config: ConfigSectionProxy | None = None, # User-specific config for ATProtoSocial
session: BlueskiSession | None = None, # Pass if update should affect live session
config: ConfigSectionProxy | None = None, # User-specific config for Blueski
) -> bool:
"""
Processes the submitted ATProtoSocial settings form and updates configuration.
Processes the submitted Blueski settings form and updates configuration.
Returns True if successful, False otherwise.
"""
if not form.validate(): # Assuming form has a validate method
logger.warning(f"ATProtoSocial settings form validation failed for user {user_id}: {form.errors}")
logger.warning(f"Blueski settings form validation failed for user {user_id}: {form.errors}")
return False
if not config and session: # Try to get config via session if not directly provided
# This depends on how ConfigSectionProxy is obtained.
# config = approve.config.config.sessions.atprotosocial[user_id] # Example path
# config = approve.config.config.sessions.blueski[user_id] # Example path
pass # Needs actual way to get config proxy
if not config:
logger.error(f"Cannot process ATProtoSocial settings for user {user_id}: no config proxy available.")
logger.error(f"Cannot process Blueski settings for user {user_id}: no config proxy available.")
return False
try:
@@ -101,11 +101,11 @@ async def process_settings_form(
await config.handle.set(form.handle.data)
await config.app_password.set(form.app_password.data) # Ensure this is stored securely
logger.info(f"ATProtoSocial settings updated for user {user_id}.")
logger.info(f"Blueski settings updated for user {user_id}.")
# If there's an active session, it might need to be reconfigured or restarted
if session:
logger.info(f"Requesting ATProtoSocial session re-initialization for user {user_id} due to settings change.")
logger.info(f"Requesting Blueski session re-initialization for user {user_id} due to settings change.")
# 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
@@ -118,11 +118,11 @@ async def process_settings_form(
return True
except Exception as e:
logger.error(f"Error saving ATProtoSocial settings for user {user_id}: {e}", exc_info=True)
logger.error(f"Error saving Blueski settings for user {user_id}: {e}", exc_info=True)
return False
# Any additional ATProtoSocial-specific settings views or handlers would go here.
# For instance, if ATProtoSocial had features like "Relays" or "Feed Generators"
# Any additional Blueski-specific settings views or handlers would go here.
# For instance, if Blueski had features like "Relays" or "Feed Generators"
# that needed UI configuration within Approve, those forms and handlers could be defined here.
logger.info("ATProtoSocial settings module loaded (placeholders).")
logger.info("Blueski settings module loaded (placeholders).")

View File

@@ -7,29 +7,29 @@ from typing import TYPE_CHECKING, Any
fromapprove.translation import translate as _
if TYPE_CHECKING:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession # Adjusted
fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted
logger = logging.getLogger(__name__)
# This file would handle the logic for a template editor specific to ATProtoSocial.
# This file would handle the logic for a template editor specific to Blueski.
# A template editor allows users to customize how certain information or messages
# from ATProtoSocial are displayed in Approve.
# from Blueski are displayed in Approve.
# For ATProtoSocial, this might be less relevant initially if its content structure
# For Blueski, this might be less relevant initially if its content structure
# is simpler than Mastodon's, or if user-customizable templates are not a primary feature.
# However, having the structure allows for future expansion.
# Example: Customizing the format of a "new follower" notification, or how a "skeet" is displayed.
class ATProtoSocialTemplateEditor:
def __init__(self, session: ATProtoSocialSession) -> None:
class BlueskiTemplateEditor:
def __init__(self, session: BlueskiSession) -> None:
self.session = session
# self.user_id = session.user_id
# self.config_prefix = f"sessions.atprotosocial.{self.user_id}.templates." # Example config path
# self.config_prefix = f"sessions.blueski.{self.user_id}.templates." # Example config path
def get_editable_templates(self) -> list[dict[str, Any]]:
"""
Returns a list of templates that the user can edit for ATProtoSocial.
Returns a list of templates that the user can edit for Blueski.
Each entry should describe the template, its purpose, and current value.
"""
# This would typically fetch template definitions from a default set
@@ -40,8 +40,8 @@ class ATProtoSocialTemplateEditor:
# {
# "id": "new_follower_notification", # Unique ID for this template
# "name": _("New Follower Notification Format"),
# "description": _("Customize how new follower notifications from ATProtoSocial are displayed."),
# "default_template": "{{ actor.displayName }} (@{{ actor.handle }}) is now following you on ATProtoSocial!",
# "description": _("Customize how new follower notifications from Blueski are displayed."),
# "default_template": "{{ actor.displayName }} (@{{ actor.handle }}) is now following you on Blueski!",
# "current_template": self._get_template_content("new_follower_notification"),
# "variables": [ # Available variables for this template
# {"name": "actor.displayName", "description": _("Display name of the new follower")},
@@ -50,10 +50,10 @@ class ATProtoSocialTemplateEditor:
# ],
# "category": "notifications", # For grouping in UI
# },
# # Add more editable templates for ATProtoSocial here
# # Add more editable templates for Blueski here
# ]
# return templates
return [] # Placeholder - no editable templates defined yet for ATProtoSocial
return [] # Placeholder - no editable templates defined yet for Blueski
def _get_template_content(self, template_id: str) -> str:
"""
@@ -70,7 +70,7 @@ class ATProtoSocialTemplateEditor:
"""
# This could be hardcoded or loaded from a defaults file.
# if template_id == "new_follower_notification":
# return "{{ actor.displayName }} (@{{ actor.handle }}) is now following you on ATProtoSocial!"
# return "{{ actor.displayName }} (@{{ actor.handle }}) is now following you on Blueski!"
# # ... other default templates
return "" # Placeholder
@@ -81,10 +81,10 @@ class ATProtoSocialTemplateEditor:
# config_key = self.config_prefix + template_id
# try:
# await approve.config.config.set_value(config_key, content) # Example config access
# logger.info(f"ATProtoSocial template '{template_id}' saved for user {self.user_id}.")
# logger.info(f"Blueski template '{template_id}' saved for user {self.user_id}.")
# return True
# except Exception as e:
# logger.error(f"Error saving ATProtoSocial template '{template_id}' for user {self.user_id}: {e}")
# logger.error(f"Error saving Blueski template '{template_id}' for user {self.user_id}: {e}")
# return False
return False # Placeholder
@@ -104,9 +104,9 @@ class ATProtoSocialTemplateEditor:
# # return preview
# return f"Preview for '{template_id}': {content_to_render}" # Basic placeholder
# except Exception as e:
# logger.error(f"Error generating preview for ATProtoSocial template '{template_id}': {e}")
# logger.error(f"Error generating preview for Blueski template '{template_id}': {e}")
# return _("Error generating preview.")
return _("Template previews not yet implemented for ATProtoSocial.") # Placeholder
return _("Template previews not yet implemented for Blueski.") # Placeholder
def _get_sample_data_for_template(self, template_id: str) -> dict[str, Any]:
"""
@@ -125,29 +125,29 @@ class ATProtoSocialTemplateEditor:
# Functions to be called by the main controller/handler for template editor actions.
async def get_editor_config(session: ATProtoSocialSession) -> dict[str, Any]:
async def get_editor_config(session: BlueskiSession) -> dict[str, Any]:
"""
Get the configuration needed to display the template editor for ATProtoSocial.
Get the configuration needed to display the template editor for Blueski.
"""
editor = ATProtoSocialTemplateEditor(session)
editor = BlueskiTemplateEditor(session)
return {
"editable_templates": editor.get_editable_templates(),
"help_text": _("Customize ATProtoSocial message formats. Use variables shown for each template."),
"help_text": _("Customize Blueski message formats. Use variables shown for each template."),
}
async def save_template(session: ATProtoSocialSession, template_id: str, content: str) -> bool:
async def save_template(session: BlueskiSession, template_id: str, content: str) -> bool:
"""
Save a modified template for ATProtoSocial.
Save a modified template for Blueski.
"""
editor = ATProtoSocialTemplateEditor(session)
editor = BlueskiTemplateEditor(session)
return await editor.save_template_content(template_id, content)
async def get_template_preview_html(session: ATProtoSocialSession, template_id: str, content: str) -> str:
async def get_template_preview_html(session: BlueskiSession, template_id: str, content: str) -> str:
"""
Get an HTML preview for a template with given content.
"""
editor = ATProtoSocialTemplateEditor(session)
editor = BlueskiTemplateEditor(session)
return editor.get_template_preview(template_id, custom_content=content)
logger.info("ATProtoSocial template editor module loaded (placeholders).")
logger.info("Blueski template editor module loaded (placeholders).")

View File

@@ -7,32 +7,32 @@ fromapprove.translation import translate as _
# fromapprove.controller.mastodon import userActions as mastodon_user_actions # If adapting
if TYPE_CHECKING:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession # Adjusted
fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted
logger = logging.getLogger(__name__)
# This file defines user-specific actions that can be performed on ATProtoSocial entities,
# This file defines user-specific actions that can be performed on Blueski entities,
# typically represented as buttons or links in the UI, often on user profiles or posts.
# For ATProtoSocial, actions might include:
# - Viewing a user's profile on Bluesky/ATProtoSocial instance.
# For Blueski, actions might include:
# - Viewing a user's profile on Bluesky/Blueski instance.
# - Following/Unfollowing a user.
# - Muting/Blocking a user.
# - Reporting a user.
# - Fetching a user's latest posts.
# These actions are often presented in a context menu or as direct buttons.
# The `get_user_actions` method in the ATProtoSocialSession class would define these.
# The `get_user_actions` method in the BlueskiSession class would define these.
# This file would contain the implementation or further handling logic if needed,
# or if actions are too complex for simple lambda/method calls in the session class.
# Example structure for defining an action:
# (This might be more detailed if actions require forms or multi-step processes)
# def view_profile_action(session: ATProtoSocialSession, user_id: str) -> dict[str, Any]:
# def view_profile_action(session: BlueskiSession, user_id: str) -> dict[str, Any]:
# """
# Generates data for a "View Profile on ATProtoSocial" action.
# user_id here would be the ATProtoSocial DID or handle.
# Generates data for a "View Profile on Blueski" action.
# user_id here would be the Blueski DID or handle.
# """
# # profile_url = f"https://bsky.app/profile/{user_id}" # Example, construct from handle or DID
# # This might involve resolving DID to handle or vice-versa if only one is known.
@@ -40,20 +40,20 @@ logger = logging.getLogger(__name__)
# # profile_url = f"https://bsky.app/profile/{handle}"
# return {
# "id": "atprotosocial_view_profile",
# "id": "blueski_view_profile",
# "label": _("View Profile on Bluesky"),
# "icon": "external-link-alt", # FontAwesome icon name
# "action_type": "link", # "link", "modal", "api_call"
# "url": profile_url, # For "link" type
# # "api_endpoint": "/api/atprotosocial/user_action", # For "api_call"
# # "api_endpoint": "/api/blueski/user_action", # For "api_call"
# # "payload": {"action": "view_profile", "target_user_id": user_id},
# "confirmation_required": False,
# }
# async def follow_user_action_handler(session: ATProtoSocialSession, target_user_id: str) -> dict[str, Any]:
# async def follow_user_action_handler(session: BlueskiSession, target_user_id: str) -> dict[str, Any]:
# """
# Handles the 'follow_user' action for ATProtoSocial.
# Handles the 'follow_user' action for Blueski.
# target_user_id should be the DID of the user to follow.
# """
# # success = await session.util.follow_user(target_user_id)
@@ -65,11 +65,11 @@ logger = logging.getLogger(__name__)
# The list of available actions is typically defined in the Session class,
# e.g., ATProtoSocialSession.get_user_actions(). That method would return a list
# e.g., BlueskiSession.get_user_actions(). That method would return a list
# of dictionaries, and this file might provide handlers for more complex actions
# if they aren't simple API calls defined directly in the session's util.
# For now, this file can be a placeholder if most actions are simple enough
# to be handled directly by the session.util methods or basic handler routes.
logger.info("ATProtoSocial userActions module loaded (placeholders).")
logger.info("Blueski userActions module loaded (placeholders).")

View File

@@ -7,39 +7,39 @@ fromapprove.translation import translate as _
# fromapprove.controller.mastodon import userList as mastodon_user_list # If adapting
if TYPE_CHECKING:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession # Adjusted
# Define a type for what a user entry in a list might look like for ATProtoSocial
ATProtoSocialUserListItem = dict[str, Any] # e.g. {"did": "...", "handle": "...", "displayName": "..."}
fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted
# Define a type for what a user entry in a list might look like for Blueski
BlueskiUserListItem = dict[str, Any] # e.g. {"did": "...", "handle": "...", "displayName": "..."}
logger = logging.getLogger(__name__)
# This file is responsible for fetching and managing lists of users from ATProtoSocial.
# This file is responsible for fetching and managing lists of users from Blueski.
# Examples include:
# - Followers of a user
# - Users a user is following
# - Users who liked or reposted a post
# - Users in a specific list or feed (if ATProtoSocial supports user lists like Twitter/Mastodon)
# - Users in a specific list or feed (if Blueski supports user lists like Twitter/Mastodon)
# - Search results for users
# The structure will likely involve:
# - A base class or functions for paginating through user lists from the ATProtoSocial API.
# - A base class or functions for paginating through user lists from the Blueski API.
# - Specific functions for each type of user list.
# - Formatting ATProtoSocial user data into a consistent structure for UI display.
# - Formatting Blueski user data into a consistent structure for UI display.
async def fetch_followers(
session: ATProtoSocialSession,
session: BlueskiSession,
user_id: str, # DID of the user whose followers to fetch
limit: int = 20,
cursor: str | None = None
) -> AsyncGenerator[ATProtoSocialUserListItem, None]:
) -> AsyncGenerator[BlueskiUserListItem, None]:
"""
Asynchronously fetches a list of followers for a given ATProtoSocial user.
Asynchronously fetches a list of followers for a given Blueski user.
user_id is the DID of the target user.
Yields user data dictionaries.
"""
# client = await session.util._get_client() # Get authenticated client
# if not client:
# logger.warning(f"ATProtoSocial client not available for fetching followers of {user_id}.")
# logger.warning(f"Blueski client not available for fetching followers of {user_id}.")
# return
# current_cursor = cursor
@@ -80,7 +80,7 @@ async def fetch_followers(
"""
if not session.is_ready():
logger.warning(f"Cannot fetch followers for {user_id}: ATProtoSocial session not ready.")
logger.warning(f"Cannot fetch followers for {user_id}: Blueski session not ready.")
# yield {} # Stop iteration if not ready
return
@@ -94,22 +94,22 @@ async def fetch_followers(
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)
logger.error(f"Error in fetch_followers for Blueski user {user_id}: {e}", exc_info=True)
# Depending on desired error handling, could raise or yield an error marker
async def fetch_following(
session: ATProtoSocialSession,
session: BlueskiSession,
user_id: str, # DID of the user whose followed accounts to fetch
limit: int = 20,
cursor: str | None = None
) -> AsyncGenerator[ATProtoSocialUserListItem, None]:
) -> AsyncGenerator[BlueskiUserListItem, None]:
"""
Asynchronously fetches a list of users followed by a given ATProtoSocial user.
Asynchronously fetches a list of users followed by a given Blueski user.
Yields user data dictionaries.
"""
if not session.is_ready():
logger.warning(f"Cannot fetch following for {user_id}: ATProtoSocial session not ready.")
logger.warning(f"Cannot fetch following for {user_id}: Blueski session not ready.")
return
try:
@@ -122,21 +122,21 @@ async def fetch_following(
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)
logger.error(f"Error in fetch_following for Blueski user {user_id}: {e}", exc_info=True)
async def search_users(
session: ATProtoSocialSession,
session: BlueskiSession,
query: str,
limit: int = 20,
cursor: str | None = None
) -> AsyncGenerator[ATProtoSocialUserListItem, None]:
) -> AsyncGenerator[BlueskiUserListItem, None]:
"""
Searches for users on ATProtoSocial based on a query string.
Searches for users on Blueski based on a query string.
Yields user data dictionaries.
"""
if not session.is_ready():
logger.warning(f"Cannot search users for '{query}': ATProtoSocial session not ready.")
logger.warning(f"Cannot search users for '{query}': Blueski session not ready.")
return
try:
@@ -149,25 +149,25 @@ async def search_users(
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)
logger.error(f"Error in search_users for Blueski query '{query}': {e}", exc_info=True)
# This function is designed to be called by an API endpoint that returns JSON
async def get_user_list_paginated(
session: ATProtoSocialSession,
session: BlueskiSession,
list_type: str, # "followers", "following", "search"
identifier: str, # User DID for followers/following, or search query for search
limit: int = 20,
cursor: str | None = None
) -> tuple[list[ATProtoSocialUserListItem], str | None]:
) -> tuple[list[BlueskiUserListItem], str | None]:
"""
Fetches a paginated list of users (followers, following, or search results)
and returns the list and the next cursor.
"""
users_list: list[ATProtoSocialUserListItem] = []
users_list: list[BlueskiUserListItem] = []
next_cursor: str | None = None
if not session.is_ready():
logger.warning(f"Cannot fetch user list '{list_type}': ATProtoSocial session not ready.")
logger.warning(f"Cannot fetch user list '{list_type}': Blueski session not ready.")
return [], None
try:
@@ -192,13 +192,13 @@ async def get_user_list_paginated(
return users_list, next_cursor
async def get_user_profile_details(session: ATProtoSocialSession, user_ident: str) -> ATProtoSocialUserListItem | None:
async def get_user_profile_details(session: BlueskiSession, user_ident: str) -> BlueskiUserListItem | None:
"""
Fetches detailed profile information for a user by DID or handle.
Returns a dictionary of formatted profile data, or None if not found/error.
"""
if not session.is_ready():
logger.warning(f"Cannot get profile for {user_ident}: ATProtoSocial session not ready.")
logger.warning(f"Cannot get profile for {user_ident}: Blueski session not ready.")
return None
try:
@@ -222,4 +222,4 @@ async def get_user_profile_details(session: ATProtoSocialSession, user_ident: st
# The UI part of Approve that displays user lists would call these functions.
# Each function needs to handle pagination as provided by the ATProto API (usually cursor-based).
logger.info("ATProtoSocial userList module loaded (placeholders).")
logger.info("Blueski userList module loaded (placeholders).")

View File

@@ -280,6 +280,12 @@ class BaseBuffer(base.Buffer):
return
menu = menus.base()
widgetUtils.connect_event(menu, widgetUtils.MENU, self.reply, menuitem=menu.reply)
# Enable/disable edit based on whether the post belongs to the user
item = self.get_item()
if item and item.account.id == self.session.db["user_id"] and item.reblog == None:
widgetUtils.connect_event(menu, widgetUtils.MENU, self.edit_status, menuitem=menu.edit)
else:
menu.edit.Enable(False)
widgetUtils.connect_event(menu, widgetUtils.MENU, self.user_actions, menuitem=menu.userActions)
if self.can_share() == True:
widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.boost)
@@ -501,6 +507,49 @@ class BaseBuffer(base.Buffer):
log.exception("")
self.session.db[self.name] = items
def edit_status(self, event=None, item=None, *args, **kwargs):
if item == None:
item = self.get_item()
# Check if the post belongs to the current user
if item.account.id != self.session.db["user_id"] or item.reblog != None:
output.speak(_("You can only edit your own posts."))
return
# Check if post has a poll with votes - warn user before proceeding
if hasattr(item, 'poll') and item.poll is not None:
votes_count = item.poll.votes_count if hasattr(item.poll, 'votes_count') else 0
if votes_count > 0:
# Show confirmation dialog
warning_title = _("Warning: Poll with votes")
warning_message = _("This post contains a poll with {votes} votes.\n\n"
"According to Mastodon's API, editing this post will reset ALL votes to zero, "
"even if you don't modify the poll itself.\n\n"
"Do you want to continue editing?").format(votes=votes_count)
dialog = wx.MessageDialog(self.buffer, warning_message, warning_title,
wx.YES_NO | wx.NO_DEFAULT | wx.ICON_WARNING)
result = dialog.ShowModal()
dialog.Destroy()
if result != wx.ID_YES:
output.speak(_("Edit cancelled"))
return
# Log item info for debugging
log.debug("Editing status: id={}, has_media_attachments={}, media_count={}".format(
item.id,
hasattr(item, 'media_attachments'),
len(item.media_attachments) if hasattr(item, 'media_attachments') else 0
))
# Create edit dialog with existing post data
title = _("Edit post")
caption = _("Edit your post here")
post = messages.editPost(session=self.session, item=item, title=title, caption=caption)
response = post.message.ShowModal()
if response == wx.ID_OK:
post_data = post.get_data()
# Call edit_post method in session
# Note: visibility and language cannot be changed when editing per Mastodon API
call_threaded(self.session.edit_post, post_id=post.post_id, posts=post_data)
if hasattr(post.message, "destroy"):
post.message.destroy()
def user_details(self):
item = self.get_item()
pass

View File

@@ -161,6 +161,13 @@ class NotificationsBuffer(BaseBuffer):
menu = menus.notification(notification.type)
if self.is_post():
widgetUtils.connect_event(menu, widgetUtils.MENU, self.reply, menuitem=menu.reply)
# Enable/disable edit based on whether the post belongs to the user
if hasattr(menu, 'edit'):
status = self.get_post()
if status and status.account.id == self.session.db["user_id"] and status.reblog == None:
widgetUtils.connect_event(menu, widgetUtils.MENU, self.edit_status, menuitem=menu.edit)
else:
menu.edit.Enable(False)
widgetUtils.connect_event(menu, widgetUtils.MENU, self.user_actions, menuitem=menu.userActions)
if self.can_share() == True:
widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.boost)

View File

@@ -25,7 +25,7 @@ from mysc import localization
from mysc.thread_utils import call_threaded
from mysc.repeating_timer import RepeatingTimer
from controller.mastodon import handler as MastodonHandler
from controller.atprotosocial import handler as ATProtoSocialHandler # Added import
from controller.blueski import handler as BlueskiHandler # Added import
from . import settings, userAlias
log = logging.getLogger("mainController")
@@ -99,8 +99,8 @@ class Controller(object):
try:
if type == "mastodon":
return MastodonHandler.Handler()
if type == "atprotosocial":
return ATProtoSocialHandler.Handler()
if type == "blueski":
return BlueskiHandler.Handler()
except Exception:
log.exception("Error creating handler for type %s", type)
return None
@@ -207,8 +207,8 @@ class Controller(object):
if handler is None:
if type == "mastodon":
handler = MastodonHandler.Handler()
elif type == "atprotosocial":
handler = ATProtoSocialHandler.Handler()
elif type == "blueski":
handler = BlueskiHandler.Handler()
self.handlers[type] = handler
return handler
@@ -250,9 +250,9 @@ class Controller(object):
for i in sessions.sessions:
log.debug("Working on session %s" % (i,))
if sessions.sessions[i].is_logged == False:
# Try auto-login for ATProtoSocial sessions if credentials exist
# Try auto-login for Blueski sessions if credentials exist
try:
if getattr(sessions.sessions[i], "type", None) == "atprotosocial":
if getattr(sessions.sessions[i], "type", None) == "blueski":
sessions.sessions[i].login()
except Exception:
log.exception("Auto-login attempt failed for session %s", i)
@@ -260,7 +260,7 @@ class Controller(object):
self.create_ignored_session_buffer(sessions.sessions[i])
continue
# Supported session types
valid_session_types = ["mastodon", "atprotosocial"]
valid_session_types = ["mastodon", "blueski"]
if sessions.sessions[i].type in valid_session_types:
try:
handler = self.get_handler(type=sessions.sessions[i].type)
@@ -323,15 +323,15 @@ 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) and session_type != "atprotosocial": # Allow atprotosocial to be handled separately
if not hasattr(buffers, session_type) and session_type != "blueski": # Allow blueski to be handled separately
raise AttributeError("Session type %s does not exist yet." % (session_type))
try:
buffer_panel_class = None
if session_type == "atprotosocial":
from wxUI.buffers.atprotosocial import panels as ATProtoSocialPanels # Import new panels
if session_type == "blueski":
from wxUI.buffers.blueski import panels as BlueskiPanels # Import new panels
if buffer_type == "home_timeline":
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialHomeTimelinePanel
buffer_panel_class = BlueskiPanels.BlueskiHomeTimelinePanel
# kwargs for HomeTimelinePanel: parent, name, session
# 'name' is buffer_title, 'parent' is self.view.nb
# 'session' needs to be fetched based on user_id in kwargs
@@ -343,38 +343,38 @@ class Controller(object):
if "name" not in kwargs: kwargs["name"] = buffer_title
elif buffer_type == "user_timeline":
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialUserTimelinePanel
buffer_panel_class = BlueskiPanels.BlueskiUserTimelinePanel
# kwargs for UserTimelinePanel: parent, name, session, target_user_did, target_user_handle
if "user_id" in kwargs and "session" not in kwargs:
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
kwargs.pop("user_id", None)
if "name" not in kwargs: kwargs["name"] = buffer_title
# target_user_did and target_user_handle must be in kwargs from atprotosocial.Handler
# target_user_did and target_user_handle must be in kwargs from blueski.Handler
elif buffer_type == "notifications":
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialNotificationPanel
buffer_panel_class = BlueskiPanels.BlueskiNotificationPanel
if "user_id" in kwargs and "session" not in kwargs:
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
kwargs.pop("user_id", None)
if "name" not in kwargs: kwargs["name"] = buffer_title
# target_user_did and target_user_handle must be in kwargs from atprotosocial.Handler
# target_user_did and target_user_handle must be in kwargs from blueski.Handler
elif buffer_type == "notifications":
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialNotificationPanel
buffer_panel_class = BlueskiPanels.BlueskiNotificationPanel
if "user_id" in kwargs and "session" not in kwargs:
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
kwargs.pop("user_id", None)
if "name" not in kwargs: kwargs["name"] = buffer_title
elif buffer_type == "user_list_followers" or buffer_type == "user_list_following":
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialUserListPanel
buffer_panel_class = BlueskiPanels.BlueskiUserListPanel
elif buffer_type == "following_timeline":
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialFollowingTimelinePanel
buffer_panel_class = BlueskiPanels.BlueskiFollowingTimelinePanel
# Clean stray keys that this panel doesn't accept
kwargs.pop("user_id", None)
kwargs.pop("list_type", None)
if "name" not in kwargs: kwargs["name"] = buffer_title
else:
log.warning(f"Unsupported ATProtoSocial buffer type: {buffer_type}. Falling back to generic.")
log.warning(f"Unsupported Blueski buffer type: {buffer_type}. Falling back to generic.")
# Fallback to trying to find it in generic buffers or error
available_buffers = getattr(buffers, "base", None) # Or some generic panel module
if available_buffers and hasattr(available_buffers, buffer_type):
@@ -382,7 +382,7 @@ class Controller(object):
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.")
raise AttributeError(f"Blueski buffer type {buffer_type} not found in blueski.panels or base panels.")
else: # Existing logic for other session types
available_buffers = getattr(buffers, session_type)
if not hasattr(available_buffers, buffer_type):
@@ -549,6 +549,15 @@ class Controller(object):
buffer = self.search_buffer(buffer.name, buffer.account)
buffer.destroy_status()
def edit_post(self, *args, **kwargs):
""" Edits a post in the current buffer.
Users can only edit their own posts."""
buffer = self.view.get_current_buffer()
if hasattr(buffer, "account"):
buffer = self.search_buffer(buffer.name, buffer.account)
if hasattr(buffer, "edit_status"):
buffer.edit_status()
def exit(self, *args, **kwargs):
if config.app["app-settings"]["ask_at_exit"] == True:
answer = commonMessageDialogs.exit_dialog(self.view)
@@ -598,7 +607,7 @@ class Controller(object):
session = buffer.session
# Compose for Bluesky (ATProto): dialog with attachments/CW/language
if getattr(session, "type", "") == "atprotosocial":
if getattr(session, "type", "") == "blueski":
# In invisible interface, prefer a quick, minimal compose to avoid complex UI
if self.showing == False:
# Parent=None so it shows even if main window is hidden
@@ -620,7 +629,7 @@ class Controller(object):
else:
dlg.Destroy()
return
from wxUI.dialogs.atprotosocial.postDialogs import Post as ATPostDialog
from wxUI.dialogs.blueski.postDialogs import Post as ATPostDialog
dlg = ATPostDialog()
if dlg.ShowModal() == wx.ID_OK:
text, files, cw_text, langs = dlg.get_payload()
@@ -712,7 +721,7 @@ class Controller(object):
return
session = buffer.session
if getattr(session, "type", "") == "atprotosocial":
if getattr(session, "type", "") == "blueski":
if self.showing == False:
dlg = wx.TextEntryDialog(None, _("Write your reply:"), _("Reply"))
if dlg.ShowModal() == wx.ID_OK:
@@ -732,7 +741,7 @@ class Controller(object):
else:
dlg.Destroy()
return
from wxUI.dialogs.atprotosocial.postDialogs import Post as ATPostDialog
from wxUI.dialogs.blueski.postDialogs import Post as ATPostDialog
dlg = ATPostDialog(caption=_("Reply"))
if dlg.ShowModal() == wx.ID_OK:
text, files, cw_text, langs = dlg.get_payload()
@@ -774,7 +783,7 @@ class Controller(object):
session = getattr(buffer, "session", None)
if not session:
return
if getattr(session, "type", "") == "atprotosocial":
if getattr(session, "type", "") == "blueski":
item_uri = None
if hasattr(buffer, "get_selected_item_id"):
item_uri = buffer.get_selected_item_id()
@@ -819,7 +828,7 @@ class Controller(object):
dlg.Destroy()
return
from wxUI.dialogs.atprotosocial.postDialogs import Post as ATPostDialog
from wxUI.dialogs.blueski.postDialogs import Post as ATPostDialog
dlg = ATPostDialog(caption=_("Quote post"))
if dlg.ShowModal() == wx.ID_OK:
text, files, cw_text, langs = dlg.get_payload()
@@ -865,7 +874,7 @@ class Controller(object):
buffer = self.get_current_buffer()
if hasattr(buffer, "add_to_favorites"): # Generic buffer method
return buffer.add_to_favorites()
elif buffer.session and buffer.session.KIND == "atprotosocial":
elif buffer.session and buffer.session.KIND == "blueski":
item_uri = buffer.get_selected_item_id()
if not item_uri:
output.speak(_("No item selected to like."), True)
@@ -894,7 +903,7 @@ class Controller(object):
buffer = self.get_current_buffer()
if hasattr(buffer, "remove_from_favorites"): # Generic buffer method
return buffer.remove_from_favorites()
elif buffer.session and buffer.session.KIND == "atprotosocial":
elif buffer.session and buffer.session.KIND == "blueski":
item_uri = buffer.get_selected_item_id()
if not item_uri:
output.speak(_("No item selected to unlike."), True)
@@ -1423,9 +1432,9 @@ class Controller(object):
def update_buffers(self):
for i in self.buffers[:]:
if i.session != None and i.session.is_logged == True:
# For ATProtoSocial, initial load is in session.start() or manual.
# For Blueski, initial load is in session.start() or manual.
# Periodic updates would need a separate timer or manual refresh via update_buffer.
if i.session.KIND != "atprotosocial":
if i.session.KIND != "blueski":
try:
i.start_stream(mandatory=True) # This is likely for streaming connections or timed polling within buffer
except Exception as err:
@@ -1444,7 +1453,7 @@ class Controller(object):
async def do_update():
new_ids = []
try:
if session.KIND == "atprotosocial":
if session.KIND == "blueski":
if bf.name == f"{session.label} Home": # Assuming buffer name indicates type
# Its panel's load_initial_posts calls session.fetch_home_timeline
if hasattr(bf, "load_initial_posts"): # Generic for timeline panels
@@ -1462,7 +1471,7 @@ class Controller(object):
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:
if hasattr(bf, "start_stream"): # Fallback for non-ATProtoSocial panels or unhandled types
if hasattr(bf, "start_stream"): # Fallback for non-Blueski panels or unhandled types
count = bf.start_stream(mandatory=True, avoid_autoreading=True)
if count is not None: new_ids = [str(x) for x in range(count)] # Dummy IDs for count
else:
@@ -1506,14 +1515,14 @@ class Controller(object):
# 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)
# For Blueski, session methods like fetch_home_timeline store their own cursor (e.g., session.home_timeline_cursor)
# 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 hasattr(bf, "load_more_posts"): # For ATProtoSocialUserTimelinePanel & ATProtoSocialHomeTimelinePanel
if session.KIND == "blueski":
if hasattr(bf, "load_more_posts"): # For BlueskiUserTimelinePanel & BlueskiHomeTimelinePanel
can_load_more_natively = True
if hasattr(bf, "load_more_posts"):
can_load_more_natively = True
@@ -1530,7 +1539,7 @@ class Controller(object):
else:
output.speak(_(u"This buffer does not support loading more items in this way."), True)
return
else: # For other non-ATProtoSocial session types
else: # For other non-Blueski session types
if hasattr(bf, "get_more_items"):
return bf.get_more_items()
else:
@@ -1541,7 +1550,7 @@ class Controller(object):
async def do_load_more():
try:
if session.KIND == "atprotosocial":
if session.KIND == "blueski":
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"):
@@ -1664,7 +1673,7 @@ class Controller(object):
if handler and hasattr(handler, 'user_details'):
# 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.
# For Blueski, handler.user_details calls the ShowUserProfileDialog.
# It's an async method, so needs to be called appropriately.
async def _show_details():
await handler.user_details(buffer)

View File

@@ -48,7 +48,7 @@ class Handler(object):
addAlias=_("Add a&lias"),
addToList=None,
removeFromList=None,
details=_("Show user profile"),
details=_("S&how user profile"),
favs=None,
# In buffer Menu.
community_timeline =_("Create c&ommunity timeline"),

View File

@@ -2,6 +2,7 @@
import os
import re
import wx
import logging
import widgetUtils
import config
import output
@@ -14,6 +15,8 @@ from wxUI.dialogs.mastodon import postDialogs
from extra.autocompletionUsers import completion
from . import userList
log = logging.getLogger("controller.mastodon.messages")
def character_count(post_text, post_cw, character_limit=500):
# We will use text for counting character limit only.
full_text = post_text+post_cw
@@ -262,6 +265,108 @@ class post(messages.basicMessage):
visibility_setting = visibility_settings.index(setting)
self.message.visibility.SetSelection(setting)
class editPost(post):
def __init__(self, session, item, title, caption, *args, **kwargs):
""" Initialize edit dialog with existing post data.
Note: Per Mastodon API, visibility and language cannot be changed when editing.
These fields will be displayed but disabled in the UI.
"""
# Extract text from post
if item.reblog != None:
item = item.reblog
text = item.content
# Remove HTML tags from content
import re
text = re.sub('<[^<]+?>', '', text)
# Initialize parent class
super(editPost, self).__init__(session, title, caption, text=text, *args, **kwargs)
# Store the post ID for editing
self.post_id = item.id
# Set visibility (read-only, cannot be changed)
visibility_settings = dict(public=0, unlisted=1, private=2, direct=3)
self.message.visibility.SetSelection(visibility_settings.get(item.visibility, 0))
self.message.visibility.Enable(False) # Disable as it cannot be edited
# Set language (read-only, cannot be changed)
if item.language:
self.set_language(item.language)
self.message.language.Enable(False) # Disable as it cannot be edited
# Set sensitive content and spoiler
if item.sensitive:
self.message.sensitive.SetValue(True)
if item.spoiler_text:
self.message.spoiler.ChangeValue(item.spoiler_text)
self.message.on_sensitivity_changed()
# Load existing poll (if any)
# Note: You cannot have both media and a poll, so check poll first
if hasattr(item, 'poll') and item.poll is not None:
log.debug("Loading existing poll for post {}".format(self.post_id))
poll = item.poll
# Extract poll options (just the text, not the votes)
poll_options = [option.title for option in poll.options]
# Calculate expires_in based on current time and expires_at
# For editing, we need to provide a new expiration time
# Since we can't get the original expires_in, use a default or let user configure
# For now, use 1 day (86400 seconds) as default
expires_in = 86400
if hasattr(poll, 'expires_at') and poll.expires_at and not poll.expired:
# Calculate remaining time if poll hasn't expired
from dateutil import parser as date_parser
import datetime
try:
expires_at = poll.expires_at
if isinstance(expires_at, str):
expires_at = date_parser.parse(expires_at)
now = datetime.datetime.now(datetime.timezone.utc)
remaining = (expires_at - now).total_seconds()
if remaining > 0:
expires_in = int(remaining)
except Exception as e:
log.warning("Could not calculate poll expiration: {}".format(e))
poll_info = {
"type": "poll",
"file": "",
"description": _("Poll with {} options").format(len(poll_options)),
"options": poll_options,
"expires_in": expires_in,
"multiple": poll.multiple if hasattr(poll, 'multiple') else False,
"hide_totals": poll.voters_count == 0 if hasattr(poll, 'voters_count') else False
}
self.attachments.append(poll_info)
self.message.add_item(item=[poll_info["file"], poll_info["type"], poll_info["description"]])
log.debug("Loaded poll with {} options. WARNING: Editing will reset all votes!".format(len(poll_options)))
# Load existing media attachments (only if no poll)
elif hasattr(item, 'media_attachments'):
log.debug("Loading existing media attachments for post {}".format(self.post_id))
log.debug("Item has media_attachments attribute, count: {}".format(len(item.media_attachments)))
if len(item.media_attachments) > 0:
for media in item.media_attachments:
log.debug("Processing media: id={}, type={}, url={}".format(media.id, media.type, media.url))
media_info = {
"id": media.id, # Keep the existing media ID
"type": media.type,
"file": media.url, # URL of existing media
"description": media.description or ""
}
# Include focus point if available
if hasattr(media, 'meta') and media.meta and 'focus' in media.meta:
focus = media.meta['focus']
media_info["focus"] = (focus.get('x'), focus.get('y'))
log.debug("Added focus point: {}".format(media_info["focus"]))
self.attachments.append(media_info)
# Display in the attachment list
display_name = media.url.split('/')[-1]
log.debug("Adding item to UI: name={}, type={}, desc={}".format(display_name, media.type, media.description or ""))
self.message.add_item(item=[display_name, media.type, media.description or ""])
log.debug("Total attachments loaded: {}".format(len(self.attachments)))
else:
log.debug("media_attachments list is empty")
else:
log.debug("Item has no poll or media attachments")
# Update text processor to reflect the loaded content
self.text_processor()
class viewPost(post):
def __init__(self, session, post, offset_hours=0, date="", item_url=""):
self.session = session

View File

@@ -13,8 +13,8 @@ class autocompletionManageDialog(widgetUtils.BaseDialog):
self.users = widgets.list(panel, _(u"Username"), _(u"Name"), style=wx.LC_REPORT)
sizer.Add(label, 0, wx.ALL, 5)
sizer.Add(self.users.list, 0, wx.ALL, 5)
self.add = wx.Button(panel, -1, _(u"Add user"))
self.remove = wx.Button(panel, -1, _(u"Remove user"))
self.add = wx.Button(panel, -1, _(u"&Add user"))
self.remove = wx.Button(panel, -1, _(u"&Remove user"))
optionsBox = wx.BoxSizer(wx.HORIZONTAL)
optionsBox.Add(self.add, 0, wx.ALL, 5)
optionsBox.Add(self.remove, 0, wx.ALL, 5)

View File

@@ -23,6 +23,7 @@ url = string(default="control+win+b")
go_home = string(default="control+win+home")
go_end = string(default="control+win+end")
delete = string(default="control+win+delete")
edit_post = string(default="")
clear_buffer = string(default="control+win+shift+delete")
repeat_item = string(default="control+win+space")
copy_to_clipboard = string(default="control+win+shift+c")

View File

@@ -33,6 +33,7 @@ go_page_up = string(default="control+win+pageup")
go_page_down = string(default="control+win+pagedown")
update_profile = string(default="control+win+shift+p")
delete = string(default="control+win+delete")
edit_post = string(default="")
clear_buffer = string(default="control+win+shift+delete")
repeat_item = string(default="control+win+space")
copy_to_clipboard = string(default="control+win+shift+c")

View File

@@ -33,6 +33,7 @@ go_page_up = string(default="control+win+pageup")
go_page_down = string(default="control+win+pagedown")
update_profile = string(default="alt+win+p")
delete = string(default="alt+win+delete")
edit_post = string(default="")
clear_buffer = string(default="alt+win+shift+delete")
repeat_item = string(default="alt+win+space")
copy_to_clipboard = string(default="alt+win+shift+c")

View File

@@ -33,6 +33,7 @@ go_page_up = string(default="control+win+pageup")
go_page_down = string(default="control+win+pagedown")
update_profile = string(default="alt+win+p")
delete = string(default="alt+win+delete")
edit_post = string(default="")
clear_buffer = string(default="alt+win+shift+delete")
repeat_item = string(default="control+alt+win+space")
copy_to_clipboard = string(default="alt+win+shift+c")

View File

@@ -34,6 +34,7 @@ go_page_up = string(default="control+win+pageup")
go_page_down = string(default="control+win+pagedown")
update_profile = string(default="alt+win+p")
delete = string(default="control+win+delete")
edit_post = string(default="")
clear_buffer = string(default="control+win+shift+delete")
repeat_item = string(default="control+win+space")
copy_to_clipboard = string(default="control+win+shift+c")

Binary file not shown.

View File

@@ -1,22 +1,23 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) 2019 ORGANIZATION
# FIRST AUTHOR <EMAIL@ADDRESS>, 2019.
# zvonimir stanecic <zvonimirek222@yandex.com>, 2023.
# zvonimir stanecic <zvonimirek222@yandex.com>, 2023, 2025.
msgid ""
msgstr ""
"Project-Id-Version: Tw Blue 0.80\n"
"Report-Msgid-Bugs-To: manuel@manuelcortez.net\n"
"POT-Creation-Date: 2025-04-13 01:18+0000\n"
"PO-Revision-Date: 2023-04-21 07:45+0000\n"
"PO-Revision-Date: 2025-08-10 16:08+0000\n"
"Last-Translator: zvonimir stanecic <zvonimirek222@yandex.com>\n"
"Language-Team: Polish <https://weblate.mcvsoftware.com/projects/twblue/"
"twblue/pl/>\n"
"Language: pl\n"
"Language-Team: Polish "
"<https://weblate.mcvsoftware.com/projects/twblue/twblue/pl/>\n"
"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && "
"(n%100<10 || n%100>=20) ? 1 : 2;\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 "
"|| n%100>=20) ? 1 : 2;\n"
"X-Generator: Weblate 5.10.4\n"
"Generated-By: Babel 2.17.0\n"
#: languageHandler.py:61
@@ -101,7 +102,7 @@ msgstr "Domyślne dla użytkownika"
#: main.py:105
#, fuzzy
msgid "https://twblue.mcvsoftware.com/donate"
msgstr "https://twblue.es/donate"
msgstr "https://twblue.mcvsoftware.com/donate"
#: main.py:118
#, python-brace-format
@@ -246,9 +247,8 @@ msgid "Following for {}"
msgstr "Śledzący użytkownika {}"
#: controller/messages.py:18
#, fuzzy
msgid "Translated"
msgstr "&Przetłumacz"
msgstr "Przetłumaczono"
#: controller/settings.py:60
msgid "System default"
@@ -540,9 +540,8 @@ msgid "There are no more items in this buffer."
msgstr "W tym buforze nie ma więcej elementów."
#: controller/mastodon/handler.py:30 wxUI/dialogs/mastodon/updateProfile.py:35
#, fuzzy
msgid "Update Profile"
msgstr "&Edytuj profil"
msgstr "Zaktualizuj profil"
#: controller/mastodon/handler.py:31 wxUI/dialogs/mastodon/search.py:10
#: wxUI/view.py:19
@@ -615,13 +614,12 @@ msgid "Add a&lias"
msgstr "Dodaj a&lias"
#: controller/mastodon/handler.py:51
#, fuzzy
msgid "Show user profile"
msgstr "&Pokaż profil użytkownika"
msgstr "Pokaż profil użytkownika"
#: controller/mastodon/handler.py:54
msgid "Create c&ommunity timeline"
msgstr ""
msgstr "Stwórz &oś czasu społeczności"
#: controller/mastodon/handler.py:55 wxUI/view.py:57
msgid "Create a &filter"
@@ -647,10 +645,9 @@ msgstr "Wyszukiwanie {}"
#: controller/mastodon/handler.py:111
msgid "Communities"
msgstr ""
msgstr "Społeczności"
#: controller/mastodon/handler.py:114
#, fuzzy
msgid "federated"
msgstr "federowana"
@@ -4864,4 +4861,3 @@ msgstr "Dodatki"
#~ msgid "DeepL API Key: "
#~ msgstr ""

View File

@@ -17,7 +17,7 @@ from pubsub import pub
from controller import settings
from sessions.mastodon import session as MastodonSession
from sessions.gotosocial import session as GotosocialSession
from sessions.atprotosocial import session as ATProtoSocialSession # Import ATProtoSocial session
from sessions.blueski import session as BlueskiSession # Import Blueski session
from . import manager
from . import wxUI as view
@@ -74,21 +74,37 @@ class sessionManagerController(object):
if config_test["mastodon"]["instance"] != "" and config_test["mastodon"]["access_token"] != "": # Basic validation
sessionsList.append(name)
self.sessions.append(dict(type=config_test["mastodon"].get("type", "mastodon"), id=i))
elif config_test.get("atprotosocial") != None: # Check for ATProtoSocial config
handle = config_test["atprotosocial"].get("handle")
did = config_test["atprotosocial"].get("did") # DID confirms it was authorized
elif config_test.get("blueski") != None: # Check for Blueski config
handle = config_test["blueski"].get("handle")
did = config_test["blueski"].get("did") # DID confirms it was authorized
if handle and did:
name = _("{handle} (Bluesky)").format(handle=handle)
sessionsList.append(name)
self.sessions.append(dict(type="atprotosocial", id=i))
self.sessions.append(dict(type="blueski", id=i))
else: # Incomplete config, might be an old attempt or error
log.warning(f"Incomplete ATProtoSocial session config found for {i}, skipping.")
log.warning(f"Incomplete Blueski session config found for {i}, skipping.")
# Optionally delete malformed config here too
try:
log.debug("Deleting incomplete ATProtoSocial session %s" % (i,))
log.debug("Deleting incomplete Blueski session %s" % (i,))
shutil.rmtree(os.path.join(paths.config_path(), i))
except Exception as e:
log.exception(f"Error deleting incomplete ATProtoSocial session {i}: {e}")
log.exception(f"Error deleting incomplete Blueski session {i}: {e}")
continue
elif config_test.get("atprotosocial") != None: # Legacy config namespace
handle = config_test["atprotosocial"].get("handle")
did = config_test["atprotosocial"].get("did")
if handle and did:
name = _("{handle} (Bluesky)").format(handle=handle)
sessionsList.append(name)
self.sessions.append(dict(type="blueski", id=i))
else: # Incomplete config, might be an old attempt or error
log.warning(f"Incomplete Blueski session config found for {i}, skipping.")
# Optionally delete malformed config here too
try:
log.debug("Deleting incomplete Blueski session %s" % (i,))
shutil.rmtree(os.path.join(paths.config_path(), i))
except Exception as e:
log.exception(f"Error deleting incomplete Blueski session {i}: {e}")
continue
else: # Unknown or other session type not explicitly handled here for display
try:
@@ -117,14 +133,14 @@ class sessionManagerController(object):
s = MastodonSession.Session(i.get("id"))
elif i.get("type") == "gotosocial":
s = GotosocialSession.Session(i.get("id"))
elif i.get("type") == "atprotosocial": # Handle ATProtoSocial session type
s = ATProtoSocialSession.Session(i.get("id"))
elif i.get("type") == "blueski": # Handle Blueski session type
s = BlueskiSession.Session(i.get("id"))
else:
log.warning(f"Unknown session type '{i.get('type')}' for ID {i.get('id')}. Skipping.")
continue
s.get_configuration() # Load per-session configuration
# For ATProtoSocial, this loads from its specific config file.
# For Blueski, this loads from its specific config file.
# Login is now primarily handled by session.start() via mainController,
# which calls _ensure_dependencies_ready().
@@ -132,19 +148,19 @@ class sessionManagerController(object):
# We'll rely on the mainController to call session.start() which handles login.
# if i.get("id") not in config.app["sessions"]["ignored_sessions"]:
# try:
# # For ATProtoSocial, login is async and handled by session.start()
# # For Blueski, login is async and handled by session.start()
# # if not s.is_ready(): # Only attempt login if not already ready
# # log.info(f"Session {s.uid} ({s.kind}) not ready, login will be attempted by start().")
# pass
# except Exception as e:
# log.exception(f"Exception during pre-emptive login check for session {s.uid} ({s.kind}).")
# continue
# Try to auto-login for ATProtoSocial so the app starts with buffers ready
# Try to auto-login for Blueski so the app starts with buffers ready
try:
if i.get("type") == "atprotosocial":
if i.get("type") == "blueski":
s.login()
except Exception:
log.exception("Auto-login failed for ATProtoSocial session %s", i.get("id"))
log.exception("Auto-login failed for Blueski session %s", i.get("id"))
sessions.sessions[i.get("id")] = s # Add to global session store
self.new_sessions[i.get("id")] = s # Track as a new session for this manager instance
@@ -162,8 +178,8 @@ class sessionManagerController(object):
if type == "mastodon":
s = MastodonSession.Session(location)
elif type == "atprotosocial":
s = ATProtoSocialSession.Session(location)
elif type == "blueski":
s = BlueskiSession.Session(location)
# Add other session types here if needed (e.g., gotosocial)
# elif type == "gotosocial":
# s = GotosocialSession.Session(location)

View File

@@ -54,8 +54,8 @@ class sessionManagerWindow(wx.Dialog):
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)
blueski = menu.Append(wx.ID_ANY, _("Blueski (Bluesky)"))
menu.Bind(wx.EVT_MENU, self.on_new_blueski_account, blueski)
self.PopupMenu(menu, self.new.GetPosition())
@@ -66,12 +66,12 @@ class sessionManagerWindow(wx.Dialog):
if response == wx.ID_YES:
pub.sendMessage("sessionmanager.new_account", type="mastodon")
def on_new_atprotosocial_account(self, *args, **kwargs):
dlg = wx.MessageDialog(self, _("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?"), _(u"ATProtoSocial Authorization"), wx.YES_NO)
def on_new_blueski_account(self, *args, **kwargs):
dlg = wx.MessageDialog(self, _("You will be prompted for your Blueski (Bluesky) data (user handle and App Password) to authorize TWBlue. Would you like to authorize your account now?"), _(u"Blueski Authorization"), wx.YES_NO)
response = dlg.ShowModal()
dlg.Destroy()
if response == wx.ID_YES:
pub.sendMessage("sessionmanager.new_account", type="atprotosocial")
pub.sendMessage("sessionmanager.new_account", type="blueski")
def add_new_session_to_list(self):
total = self.list.get_count()

View File

@@ -9,7 +9,7 @@ from approve.translation import translate as _
from approve.util import parse_iso_datetime # For parsing ISO timestamps
if TYPE_CHECKING:
from approve.sessions.atprotosocial.session import Session as ATProtoSocialSession
from approve.sessions.blueski.session import Session as BlueskiSession
from atproto.xrpc_client import models # For type hinting ATProto models
logger = logging.getLogger(__name__)
@@ -21,19 +21,19 @@ SUPPORTED_LANG_CHOICES_COMPOSE = {
}
class ATProtoSocialCompose:
class BlueskiCompose:
MAX_CHARS = 300
MAX_MEDIA_ATTACHMENTS = 4
MAX_LANGUAGES = 3
MAX_IMAGE_SIZE_BYTES = 1_000_000
def __init__(self, session: ATProtoSocialSession) -> None:
def __init__(self, session: BlueskiSession) -> 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."""
"""Returns configuration for the compose panel specific to Blueski."""
return {
"max_chars": self.MAX_CHARS,
"max_media_attachments": self.MAX_MEDIA_ATTACHMENTS,
@@ -206,7 +206,7 @@ class ATProtoSocialCompose:
Args:
notif_data: A dictionary representing the notification,
typically from ATProtoSocialSession._handle_*_notification methods
typically from BlueskiSession._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'.

View File

@@ -10,7 +10,7 @@ from sessions import session_exceptions as Exceptions
import output
import application
log = logging.getLogger("sessions.atprotosocialSession")
log = logging.getLogger("sessions.blueskiSession")
# Optional import of atproto. Code handles absence gracefully.
try:
@@ -27,26 +27,45 @@ class Session(base.baseSession):
"""
name = "Bluesky"
KIND = "atprotosocial"
KIND = "blueski"
def __init__(self, *args, **kwargs):
super(Session, self).__init__(*args, **kwargs)
self.config_spec = "atprotosocial.defaults"
self.type = "atprotosocial"
self.config_spec = "blueski.defaults"
self.type = "blueski"
self.char_limit = 300
self.api = None
def _ensure_settings_namespace(self) -> None:
"""Migrate legacy atprotosocial settings to blueski namespace."""
try:
if not self.settings:
return
if self.settings.get("blueski") is None and self.settings.get("atprotosocial") is not None:
self.settings["blueski"] = dict(self.settings["atprotosocial"])
try:
del self.settings["atprotosocial"]
except Exception:
pass
try:
self.settings.write()
except Exception:
pass
except Exception:
log.exception("Failed to migrate legacy Blueski settings")
def get_name(self):
"""Return a human-friendly, stable account name for UI.
Prefer the user's handle if available so accounts are uniquely
identifiable, falling back to a generic network name otherwise.
"""
self._ensure_settings_namespace()
try:
# Prefer runtime DB, then persisted settings, then SDK client
handle = (
self.db.get("user_name")
or (self.settings and self.settings.get("atprotosocial", {}).get("handle"))
or (self.settings and self.settings.get("blueski", {}).get("handle"))
or (getattr(getattr(self, "api", None), "me", None) and self.api.me.handle)
)
if handle:
@@ -65,11 +84,12 @@ class Session(base.baseSession):
return self.api
def login(self, verify_credentials=True):
if self.settings.get("atprotosocial") is None:
self._ensure_settings_namespace()
if self.settings.get("blueski") is None:
raise Exceptions.RequireCredentialsSessionError
handle = self.settings["atprotosocial"].get("handle")
app_password = self.settings["atprotosocial"].get("app_password")
session_string = self.settings["atprotosocial"].get("session_string")
handle = self.settings["blueski"].get("handle")
app_password = self.settings["blueski"].get("app_password")
session_string = self.settings["blueski"].get("session_string")
if not handle or (not app_password and not session_string):
self.logged = False
raise Exceptions.RequireCredentialsSessionError
@@ -100,10 +120,10 @@ class Session(base.baseSession):
self.db["user_name"] = api.me.handle
self.db["user_id"] = api.me.did
# Persist DID in settings for session manager display
self.settings["atprotosocial"]["did"] = api.me.did
self.settings["blueski"]["did"] = api.me.did
# Export session for future reuse
try:
self.settings["atprotosocial"]["session_string"] = api.export_session_string()
self.settings["blueski"]["session_string"] = api.export_session_string()
except Exception:
pass
self.settings.write()
@@ -114,6 +134,7 @@ class Session(base.baseSession):
self.logged = False
def authorise(self):
self._ensure_settings_namespace()
if self.logged:
raise Exceptions.AlreadyAuthorisedError("Already authorised.")
# Ask for handle
@@ -141,8 +162,8 @@ class Session(base.baseSession):
# Create session folder and config, then attempt login
self.create_session_folder()
self.get_configuration()
self.settings["atprotosocial"]["handle"] = handle
self.settings["atprotosocial"]["app_password"] = app_password
self.settings["blueski"]["handle"] = handle
self.settings["blueski"]["app_password"] = app_password
self.settings.write()
try:
self.login()
@@ -159,7 +180,8 @@ class Session(base.baseSession):
def get_message_url(self, message_id, context=None):
# message_id may be full at:// URI or rkey
handle = self.db.get("user_name") or self.settings["atprotosocial"].get("handle", "")
self._ensure_settings_namespace()
handle = self.db.get("user_name") or self.settings["blueski"].get("handle", "")
rkey = message_id
if isinstance(message_id, str) and message_id.startswith("at://"):
parts = message_id.split("/")
@@ -169,6 +191,7 @@ class Session(base.baseSession):
def send_message(self, message, files=None, reply_to=None, cw_text=None, is_sensitive=False, **kwargs):
if not self.logged:
raise Exceptions.NotLoggedSessionError("You are not logged in yet.")
self._ensure_settings_namespace()
try:
api = self._ensure_client()
# Basic text-only post for now. Attachments and CW can be extended later.
@@ -273,8 +296,8 @@ class Session(base.baseSession):
# Accept full web URL and try to resolve via get_post_thread below
return identifier
# Accept bare rkey case by constructing a guess using own handle
handle = self.db.get("user_name") or self.settings["atprotosocial"].get("handle")
did = self.db.get("user_id") or self.settings["atprotosocial"].get("did")
handle = self.db.get("user_name") or self.settings["blueski"].get("handle")
did = self.db.get("user_id") or self.settings["blueski"].get("did")
if handle and did and len(identifier) in (13, 14, 15):
# rkey length is typically ~13 chars base32
return f"at://{did}/app.bsky.feed.post/{identifier}"

View File

@@ -5,17 +5,17 @@ import logging
from typing import TYPE_CHECKING, Any, Callable, Coroutine
if TYPE_CHECKING:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
fromapprove.sessions.blueski.session import Session as BlueskiSession
logger = logging.getLogger(__name__)
# ATProtoSocial (Bluesky) uses a Firehose model for streaming.
# Blueski (Bluesky) uses a Firehose model for streaming.
# This typically involves connecting to a WebSocket endpoint and receiving events.
# The atproto SDK provides tools for this.
class ATProtoSocialStreaming:
def __init__(self, session: ATProtoSocialSession, stream_type: str, params: dict[str, Any] | None = None) -> None:
class BlueskiStreaming:
def __init__(self, session: BlueskiSession, stream_type: str, params: dict[str, Any] | None = None) -> None:
self.session = session
self.stream_type = stream_type # e.g., 'user', 'public', 'hashtag' - will need mapping to Firehose concepts
self.params = params or {}
@@ -30,19 +30,19 @@ class ATProtoSocialStreaming:
# or using a more specific subscription if available for user-level events.
async def _connect(self) -> None:
"""Internal method to connect to the ATProtoSocial Firehose."""
"""Internal method to connect to the Blueski Firehose."""
# from atproto import AsyncClient
# from atproto.firehose import FirehoseSubscribeReposClient, parse_subscribe_repos_message
# from atproto.xrpc_client.models import get_or_create, ids, models
logger.info(f"ATProtoSocial streaming: Connecting to Firehose for user {self.session.user_id}, stream type {self.stream_type}")
logger.info(f"Blueski streaming: Connecting to Firehose for user {self.session.user_id}, stream type {self.stream_type}")
self._should_stop = False
try:
# TODO: Replace with actual atproto SDK usage
# client = self.session.util.get_client() # Get authenticated client from session utils
# if not client or not client.me: # Check if client is authenticated
# logger.error("ATProtoSocial client not authenticated. Cannot start Firehose.")
# logger.error("Blueski client not authenticated. Cannot start Firehose.")
# return
# self._firehose_client = FirehoseSubscribeReposClient(params=None, base_uri=self.session.api_base_url) # Adjust base_uri if needed
@@ -77,7 +77,7 @@ class ATProtoSocialStreaming:
# # # await self._handle_event("mention", event_data)
# # For now, we'll just log that a message was received
# logger.debug(f"ATProtoSocial Firehose message received: {message.__class__.__name__}")
# logger.debug(f"Blueski Firehose message received: {message.__class__.__name__}")
# await self._firehose_client.start(on_message_handler)
@@ -91,13 +91,13 @@ class ATProtoSocialStreaming:
# mock_event = {"type": "placeholder_event", "data": {"text": "Hello from mock stream"}}
# await self._handler(mock_event) # Call the registered handler
logger.info(f"ATProtoSocial streaming: Placeholder loop for {self.session.user_id} stopped.")
logger.info(f"Blueski streaming: Placeholder loop for {self.session.user_id} stopped.")
except asyncio.CancelledError:
logger.info(f"ATProtoSocial streaming task for user {self.session.user_id} was cancelled.")
logger.info(f"Blueski streaming task for user {self.session.user_id} was cancelled.")
except Exception as e:
logger.error(f"ATProtoSocial streaming error for user {self.session.user_id}: {e}", exc_info=True)
logger.error(f"Blueski streaming error for user {self.session.user_id}: {e}", exc_info=True)
# Optional: implement retry logic here or in the start_streaming method
if not self._should_stop:
await asyncio.sleep(30) # Wait before trying to reconnect (if auto-reconnect is desired)
@@ -108,7 +108,7 @@ class ATProtoSocialStreaming:
finally:
# if self._firehose_client:
# await self._firehose_client.stop()
logger.info(f"ATProtoSocial streaming connection closed for user {self.session.user_id}.")
logger.info(f"Blueski streaming connection closed for user {self.session.user_id}.")
async def _handle_event(self, event_type: str, data: dict[str, Any]) -> None:
@@ -118,31 +118,31 @@ class ATProtoSocialStreaming:
if self._handler:
try:
# The data should be transformed into a common format expected by session.handle_streaming_event
# This is where ATProtoSocial-specific event data is mapped to Approve's internal event structure.
# For example, an ATProtoSocial 'mention' event needs to be structured similarly to
# This is where Blueski-specific event data is mapped to Approve's internal event structure.
# For example, an Blueski 'mention' event needs to be structured similarly to
# how a Mastodon 'mention' event would be.
await self.session.handle_streaming_event(event_type, data)
except Exception as e:
logger.error(f"Error handling ATProtoSocial streaming event type {event_type}: {e}", exc_info=True)
logger.error(f"Error handling Blueski streaming event type {event_type}: {e}", exc_info=True)
else:
logger.warning(f"ATProtoSocial streaming: No handler registered for session {self.session.user_id}, event: {event_type}")
logger.warning(f"Blueski streaming: No handler registered for session {self.session.user_id}, event: {event_type}")
def start_streaming(self, handler: Callable[[dict[str, Any]], Coroutine[Any, Any, None]]) -> None:
"""Starts the streaming connection."""
if self._connection_task and not self._connection_task.done():
logger.warning(f"ATProtoSocial streaming already active for user {self.session.user_id}.")
logger.warning(f"Blueski streaming already active for user {self.session.user_id}.")
return
self._handler = handler # This handler is what session.py's handle_streaming_event calls
self._should_stop = False
logger.info(f"ATProtoSocial streaming: Starting for user {self.session.user_id}, type: {self.stream_type}")
logger.info(f"Blueski streaming: Starting for user {self.session.user_id}, type: {self.stream_type}")
self._connection_task = asyncio.create_task(self._connect())
async def stop_streaming(self) -> None:
"""Stops the streaming connection."""
logger.info(f"ATProtoSocial streaming: Stopping for user {self.session.user_id}")
logger.info(f"Blueski streaming: Stopping for user {self.session.user_id}")
self._should_stop = True
# if self._firehose_client: # Assuming the SDK has a stop method
# await self._firehose_client.stop()
@@ -153,10 +153,10 @@ class ATProtoSocialStreaming:
try:
await self._connection_task
except asyncio.CancelledError:
logger.info(f"ATProtoSocial streaming task successfully cancelled for {self.session.user_id}.")
logger.info(f"Blueski streaming task successfully cancelled for {self.session.user_id}.")
self._connection_task = None
self._handler = None
logger.info(f"ATProtoSocial streaming stopped for user {self.session.user_id}.")
logger.info(f"Blueski streaming stopped for user {self.session.user_id}.")
def is_alive(self) -> bool:
"""Checks if the streaming connection is currently active."""
@@ -169,7 +169,7 @@ class ATProtoSocialStreaming:
def get_params(self) -> dict[str, Any]:
return self.params
# TODO: Add methods specific to ATProtoSocial streaming if necessary,
# TODO: Add methods specific to Blueski streaming if necessary,
# e.g., methods to modify subscription details on the fly if the API supports it.
# 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),

View File

@@ -6,30 +6,30 @@ from typing import TYPE_CHECKING, Any
fromapprove.translation import translate as _
if TYPE_CHECKING:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
fromapprove.sessions.blueski.session import Session as BlueskiSession
logger = logging.getLogger(__name__)
class ATProtoSocialTemplates:
def __init__(self, session: ATProtoSocialSession) -> None:
class BlueskiTemplates:
def __init__(self, session: BlueskiSession) -> None:
self.session = session
def get_template_data(self, template_name: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
"""
Returns data required for rendering a specific template for ATProtoSocial.
Returns data required for rendering a specific template for Blueski.
This method would populate template variables based on the template name and context.
"""
base_data = {
"session_kind": self.session.kind,
"session_label": self.session.label,
"user_id": self.session.user_id,
# Add any other common data needed by ATProtoSocial templates
# Add any other common data needed by Blueski templates
}
if context:
base_data.update(context)
# TODO: Implement specific data fetching for different ATProtoSocial templates
# TODO: Implement specific data fetching for different Blueski templates
# Example:
# if template_name == "profile_summary.html":
# # profile_info = await self.session.util.get_my_profile_info() # Assuming such a method exists
@@ -44,27 +44,27 @@ class ATProtoSocialTemplates:
return base_data
def get_message_card_template(self) -> str:
"""Returns the path to the message card template for ATProtoSocial."""
# This template would define how a single ATProtoSocial post (or other message type)
"""Returns the path to the message card template for Blueski."""
# This template would define how a single Blueski post (or other message type)
# is rendered in a list (e.g., in a timeline or search results).
# return "sessions/atprotosocial/cards/message.html" # Example path
# return "sessions/blueski/cards/message.html" # Example path
return "sessions/generic/cards/message_generic.html" # Placeholder, use generic if no specific yet
def get_notification_template_map(self) -> dict[str, str]:
"""
Returns a map of ATProtoSocial notification types to their respective template paths.
Returns a map of Blueski notification types to their respective template paths.
"""
# TODO: Define templates for different ATProtoSocial notification types
# TODO: Define templates for different Blueski notification types
# (e.g., mention, reply, new follower, like, repost).
# The keys should match the notification types used internally by Approve
# when processing ATProtoSocial events.
# when processing Blueski events.
# Example:
# return {
# "mention": "sessions/atprotosocial/notifications/mention.html",
# "reply": "sessions/atprotosocial/notifications/reply.html",
# "follow": "sessions/atprotosocial/notifications/follow.html",
# "like": "sessions/atprotosocial/notifications/like.html", # Bluesky uses 'like'
# "repost": "sessions/atprotosocial/notifications/repost.html", # Bluesky uses 'repost'
# "mention": "sessions/blueski/notifications/mention.html",
# "reply": "sessions/blueski/notifications/reply.html",
# "follow": "sessions/blueski/notifications/follow.html",
# "like": "sessions/blueski/notifications/like.html", # Bluesky uses 'like'
# "repost": "sessions/blueski/notifications/repost.html", # Bluesky uses 'repost'
# # ... other notification types
# }
# Using generic templates as placeholders:
@@ -77,37 +77,37 @@ class ATProtoSocialTemplates:
}
def get_settings_template(self) -> str | None:
"""Returns the path to the settings template for ATProtoSocial, if any."""
# This template would be used to render ATProtoSocial-specific settings in the UI.
# return "sessions/atprotosocial/settings.html"
"""Returns the path to the settings template for Blueski, if any."""
# This template would be used to render Blueski-specific settings in the UI.
# return "sessions/blueski/settings.html"
return "sessions/generic/settings_auth_password.html" # If using simple handle/password auth
def get_user_action_templates(self) -> dict[str, str] | None:
"""
Returns a map of user action identifiers to their template paths for ATProtoSocial.
Returns a map of user action identifiers to their template paths for Blueski.
User actions are typically buttons or forms displayed on a user's profile.
"""
# TODO: Define templates for ATProtoSocial user actions
# TODO: Define templates for Blueski user actions
# Example:
# return {
# "view_profile_on_bsky": "sessions/atprotosocial/actions/view_profile_button.html",
# "send_direct_message": "sessions/atprotosocial/actions/send_dm_form.html", # If DMs are supported
# "view_profile_on_bsky": "sessions/blueski/actions/view_profile_button.html",
# "send_direct_message": "sessions/blueski/actions/send_dm_form.html", # If DMs are supported
# }
return None # Placeholder
def get_user_list_action_templates(self) -> dict[str, str] | None:
"""
Returns a map of user list action identifiers to their template paths for ATProtoSocial.
Returns a map of user list action identifiers to their template paths for Blueski.
These actions might appear on lists of users (e.g., followers, following).
"""
# TODO: Define templates for ATProtoSocial user list actions
# TODO: Define templates for Blueski user list actions
# Example:
# return {
# "follow_all_visible": "sessions/atprotosocial/list_actions/follow_all_button.html",
# "follow_all_visible": "sessions/blueski/list_actions/follow_all_button.html",
# }
return None # Placeholder
# Add any other template-related helper methods specific to ATProtoSocial.
# Add any other template-related helper methods specific to Blueski.
# For example, methods to get templates for specific types of content (images, polls)
# if they need special rendering.
@@ -116,8 +116,8 @@ class ATProtoSocialTemplates:
Returns a specific template path for a given message type (e.g., post, reply, quote).
This can be useful if different types of messages need distinct rendering beyond the standard card.
"""
# TODO: Define specific templates if ATProtoSocial messages have varied structures
# TODO: Define specific templates if Blueski messages have varied structures
# that require different display logic.
# if message_type == "quote_post":
# return "sessions/atprotosocial/cards/quote_post.html"
# return "sessions/blueski/cards/quote_post.html"
return None # Default to standard message card if not specified

View File

@@ -16,7 +16,7 @@ fromapprove.notifications import NotificationError
if TYPE_CHECKING:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
fromapprove.sessions.blueski.session import Session as BlueskiSession
# Define common type aliases if needed
ATUserProfile = models.AppBskyActorDefs.ProfileViewDetailed
ATPost = models.AppBskyFeedDefs.PostView
@@ -27,8 +27,8 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
class ATProtoSocialUtils:
def __init__(self, session: ATProtoSocialSession) -> None:
class BlueskiUtils:
def __init__(self, session: BlueskiSession) -> None:
self.session = session
# _own_did and _own_handle are now set by Session.login upon successful authentication
# and directly on the util instance.
@@ -47,7 +47,7 @@ class ATProtoSocialUtils:
self._own_handle = self.session.client.me.handle
return self.session.client
logger.warning("ATProtoSocialUtils: Client not available or not authenticated.")
logger.warning("BlueskiUtils: Client not available or not authenticated.")
# Optionally, try to trigger re-authentication if appropriate,
# but generally, the caller should ensure session is ready.
# For example, by calling session.start() or session.authorise()
@@ -59,7 +59,7 @@ class ATProtoSocialUtils:
"""Retrieves the authenticated user's profile information."""
client = await self._get_client()
if not client or not self.get_own_did(): # Use getter for _own_did
logger.warning("ATProtoSocial client not available or user DID not known.")
logger.warning("Blueski client not available or user DID not known.")
return None
try:
# client.me should be populated after login by the SDK
@@ -78,7 +78,7 @@ class ATProtoSocialUtils:
return response
return None
except AtProtocolError as e:
logger.error(f"Error fetching own ATProtoSocial profile: {e}")
logger.error(f"Error fetching own Blueski profile: {e}")
return None
def get_own_did(self) -> str | None:
@@ -116,13 +116,13 @@ class ATProtoSocialUtils:
**kwargs: Any
) -> str | None: # Returns the ATURI of the new post, or None on failure
"""
Posts a status (skeet) to ATProtoSocial.
Posts a status (skeet) to Blueski.
Handles text, images, replies, quotes, and content warnings (labels).
"""
client = await self._get_client()
if not client:
logger.error("ATProtoSocial client not available for posting.")
raise NotificationError(_("Not connected to ATProtoSocial. Please check your connection settings or log in."))
logger.error("Blueski client not available for posting.")
raise NotificationError(_("Not connected to Blueski. Please check your connection settings or log in."))
if not self.get_own_did():
logger.error("Cannot post status: User DID not available.")
@@ -130,7 +130,7 @@ class ATProtoSocialUtils:
try:
# Prepare core post record
post_record_data = {'text': text, 'created_at': client.get_current_time_iso()} # SDK handles datetime format
post_record_data = {'text': text, 'createdAt': client.get_current_time_iso()} # SDK handles datetime format
if langs:
post_record_data['langs'] = langs
@@ -227,13 +227,13 @@ class ATProtoSocialUtils:
record=final_post_record,
)
)
logger.info(f"Successfully posted to ATProtoSocial. URI: {response.uri}")
logger.info(f"Successfully posted to Blueski. URI: {response.uri}")
return response.uri
except AtProtocolError as e:
logger.error(f"Error posting status to ATProtoSocial: {e.error} - {e.message}", exc_info=True)
logger.error(f"Error posting status to Blueski: {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
except Exception as e: # Catch any other unexpected errors
logger.error(f"Unexpected error posting status to ATProtoSocial: {e}", exc_info=True)
logger.error(f"Unexpected error posting status to Blueski: {e}", exc_info=True)
raise NotificationError(_("An unexpected error occurred while posting: {error}").format(error=str(e))) from e
@@ -241,7 +241,7 @@ class ATProtoSocialUtils:
"""Deletes a status (post) given its AT URI."""
client = await self._get_client()
if not client:
logger.error("ATProtoSocial client not available for deleting post.")
logger.error("Blueski client not available for deleting post.")
return False
if not self.get_own_did():
logger.error("Cannot delete status: User DID not available.")
@@ -268,10 +268,10 @@ class ATProtoSocialUtils:
rkey=rkey,
)
)
logger.info(f"Successfully deleted post {post_uri} from ATProtoSocial.")
logger.info(f"Successfully deleted post {post_uri} from Blueski.")
return True
except AtProtocolError as e:
logger.error(f"Error deleting post {post_uri} from ATProtoSocial: {e.error} - {e.message}")
logger.error(f"Error deleting post {post_uri} from Blueski: {e.error} - {e.message}")
except Exception as e:
logger.error(f"Unexpected error deleting post {post_uri}: {e}", exc_info=True)
return False
@@ -279,12 +279,12 @@ class ATProtoSocialUtils:
async def upload_media(self, file_path: str, mime_type: str, alt_text: str | None = None) -> dict[str, Any] | None:
"""
Uploads media (image) to ATProtoSocial.
Uploads media (image) to Blueski.
Returns a dictionary containing the SDK's BlobRef object and alt_text, or None on failure.
"""
client = await self._get_client()
if not client:
logger.error("ATProtoSocial client not available for media upload.")
logger.error("Blueski client not available for media upload.")
return None
try:
with open(file_path, "rb") as f:
@@ -303,7 +303,7 @@ class ATProtoSocialUtils:
logger.error(f"Media upload failed for {file_path}, no blob in response.")
return None
except AtProtocolError as e:
logger.error(f"Error uploading media {file_path} to ATProtoSocial: {e.error} - {e.message}", exc_info=True)
logger.error(f"Error uploading media {file_path} to Blueski: {e.error} - {e.message}", exc_info=True)
except Exception as e:
logger.error(f"Unexpected error uploading media {file_path}: {e}", exc_info=True)
return None
@@ -341,7 +341,7 @@ class ATProtoSocialUtils:
models.ComAtprotoRepoCreateRecord.Input(
repo=self.get_own_did(),
collection=ids.AppBskyGraphFollow, # "app.bsky.graph.follow"
record=models.AppBskyGraphFollow.Main(subject=user_did, created_at=client.get_current_time_iso()),
record=models.AppBskyGraphFollow.Main(subject=user_did, createdAt=client.get_current_time_iso()),
)
)
logger.info(f"Successfully followed user {user_did}.")
@@ -596,7 +596,7 @@ class ATProtoSocialUtils:
models.ComAtprotoRepoCreateRecord.Input(
repo=self.get_own_did(),
collection=ids.AppBskyGraphBlock, # "app.bsky.graph.block"
record=models.AppBskyGraphBlock.Main(subject=user_did, created_at=client.get_current_time_iso()),
record=models.AppBskyGraphBlock.Main(subject=user_did, createdAt=client.get_current_time_iso()),
)
)
logger.info(f"Successfully blocked user {user_did}. Block record URI: {response.uri}")
@@ -1098,7 +1098,7 @@ class ATProtoSocialUtils:
"""
client = await self._get_client()
if not client:
logger.error("ATProtoSocial client not available for reporting.")
logger.error("Blueski client not available for reporting.")
return False
try:

View File

@@ -17,6 +17,11 @@ def compose_post(post, db, settings, relative_times, show_screen_names, safe=Tru
text = _("Boosted from @{}: {}").format(post.reblog.account.acct, templates.process_text(post.reblog, safe=safe))
else:
text = templates.process_text(post, safe=safe)
# Handle quoted posts
if hasattr(post, 'quote') and post.quote != None and hasattr(post.quote, 'quoted_status') and post.quote.quoted_status != None:
quoted_user = post.quote.quoted_status.account.acct
quoted_text = templates.process_text(post.quote.quoted_status, safe=safe)
text = text + " " + _("Quoting @{}: {}").format(quoted_user, quoted_text)
filtered = utils.evaluate_filters(post=post, current_context="home")
if filtered != None:
text = _("hidden by filter {}").format(filtered)

View File

@@ -248,6 +248,106 @@ class Session(base.baseSession):
pub.sendMessage("mastodon.error_post", name=self.get_name(), reply_to=reply_to, visibility=visibility, posts=posts, lang=language)
return
def edit_post(self, post_id, posts=[]):
""" Convenience function to edit a post. Only the first item in posts list is used as threads cannot be edited.
Note: According to Mastodon API, not all fields can be edited. Visibility, language, and reply context cannot be changed.
Args:
post_id: ID of the status to edit
posts: List with post data. Only first item is used.
Returns:
Updated status object or None on failure
"""
if len(posts) == 0:
log.warning("edit_post called with empty posts list")
return None
obj = posts[0]
text = obj.get("text")
if not text:
log.warning("edit_post called without text content")
return None
media_ids = []
media_attributes = []
try:
poll = None
# Handle poll attachments
if len(obj["attachments"]) == 1 and obj["attachments"][0]["type"] == "poll":
poll = self.api.make_poll(
options=obj["attachments"][0]["options"],
expires_in=obj["attachments"][0]["expires_in"],
multiple=obj["attachments"][0]["multiple"],
hide_totals=obj["attachments"][0]["hide_totals"]
)
log.debug("Editing post with poll (this will reset votes)")
# Handle media attachments
elif len(obj["attachments"]) > 0:
for i in obj["attachments"]:
# If attachment has an 'id', it's an existing media that we keep
if "id" in i:
media_ids.append(i["id"])
# If existing media has metadata to update, use generate_media_edit_attributes
if "description" in i or "focus" in i:
media_attr = self.api.generate_media_edit_attributes(
id=i["id"],
description=i.get("description"),
focus=i.get("focus")
)
media_attributes.append(media_attr)
# Otherwise it's a new file to upload
elif "file" in i:
description = i.get("description", "")
focus = i.get("focus", None)
media = self.api_call(
"media_post",
media_file=i["file"],
description=description,
focus=focus,
synchronous=True
)
media_ids.append(media.id)
log.debug("Uploaded new media with id: {}".format(media.id))
# Prepare parameters for status_update
update_params = {
"id": post_id,
"status": text,
"_sound": "tweet_send.ogg",
"sensitive": obj.get("sensitive", False),
"spoiler_text": obj.get("spoiler_text", None),
}
# Add optional parameters only if provided
if media_ids:
update_params["media_ids"] = media_ids
if media_attributes:
update_params["media_attributes"] = media_attributes
if poll:
update_params["poll"] = poll
# Call status_update API
log.debug("Editing post {} with params: {}".format(post_id, {k: v for k, v in update_params.items() if k not in ["_sound"]}))
item = self.api_call(call_name="status_update", **update_params)
if item:
log.info("Successfully edited post {}".format(post_id))
return item
except MastodonAPIError as e:
log.exception("Mastodon API error updating post {}: {}".format(post_id, str(e)))
output.speak(_("Error editing post: {}").format(str(e)))
pub.sendMessage("mastodon.error_edit", name=self.get_name(), post_id=post_id, error=str(e))
return None
except Exception as e:
log.exception("Unexpected error updating post {}: {}".format(post_id, str(e)))
output.speak(_("Error editing post: {}").format(str(e)))
return None
def get_name(self):
instance = self.settings["mastodon"]["instance"]
instance = instance.replace("https://", "")

View File

@@ -76,6 +76,13 @@ def render_post(post, template, settings, relative_times=False, offset_hours=0):
else:
text = process_text(post, safe=False)
safe_text = process_text(post)
# Handle quoted posts
if hasattr(post, 'quote') and post.quote != None and hasattr(post.quote, 'quoted_status') and post.quote.quoted_status != None:
quoted_user = post.quote.quoted_status.account.acct
quoted_text = process_text(post.quote.quoted_status, safe=False)
quoted_safe_text = process_text(post.quote.quoted_status, safe=True)
text = text + " " + _("Quoting @{}: {}").format(quoted_user, quoted_text)
safe_text = safe_text + " " + _("Quoting @{}: {}").format(quoted_user, quoted_safe_text)
filtered = utils.evaluate_filters(post=post, current_context="home")
if filtered != None:
text = _("hidden by filter {}").format(filtered)

View File

@@ -3,23 +3,47 @@ import demoji
from html.parser import HTMLParser
from datetime import datetime, timezone
url_re = re.compile('<a\s*href=[\'|"](.*?)[\'"].*?>')
url_re = re.compile(r'<a\s*href=[\'|"](.*?)[\'"].*?>')
class HTMLFilter(HTMLParser):
# Classes to ignore when parsing HTML
IGNORED_CLASSES = ["quote-inline"]
text = ""
first_paragraph = True
skip_depth = 0 # Track nesting depth of ignored elements
def handle_data(self, data):
self.text += data
# Only add data if we're not inside an ignored element
if self.skip_depth == 0:
self.text += data
def handle_starttag(self, tag, attrs):
if tag == "br":
self.text = self.text+"\n"
elif tag == "p":
if self.first_paragraph:
self.first_paragraph = False
else:
self.text = self.text+"\n\n"
# Check if this tag has a class that should be ignored
attrs_dict = dict(attrs)
tag_class = attrs_dict.get("class", "")
# Check if any ignored class is present in this tag
should_skip = any(ignored_class in tag_class for ignored_class in self.IGNORED_CLASSES)
if should_skip:
self.skip_depth += 1
elif self.skip_depth == 0: # Only process tags if we're not skipping
if tag == "br":
self.text = self.text+"\n"
elif tag == "p":
if self.first_paragraph:
self.first_paragraph = False
else:
self.text = self.text+"\n\n"
else:
# We're inside a skipped element, increment depth for nested tags
self.skip_depth += 1
def handle_endtag(self, tag):
# Decrement skip depth when closing any tag while skipping
if self.skip_depth > 0:
self.skip_depth -= 1
def html_filter(data):
f = HTMLFilter()

View File

@@ -4,7 +4,7 @@ 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.blueski.session import Session as BlueskiSession
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
@@ -48,7 +48,7 @@ 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
# e.g., config.sessions.blueski[user_id].handle
class MockConfigNode:
def __init__(self, initial_value=None):
self._value = initial_value
@@ -60,9 +60,9 @@ class MockUserSessionConfig:
self.handle = MockConfigNode("")
self.app_password = MockConfigNode("")
self.did = MockConfigNode("")
# Add other config values if session.py uses them for atprotosocial
# Add other config values if session.py uses them for blueski
class MockATProtoSocialConfig:
class MockBlueskiConfig:
def __init__(self):
self._user_configs = {"test_user": MockUserSessionConfig()}
def __getitem__(self, key):
@@ -70,31 +70,31 @@ class MockATProtoSocialConfig:
class MockSessionsConfig:
def __init__(self):
self.atprotosocial = MockATProtoSocialConfig()
self.blueski = MockBlueskiConfig()
mock_config_global = MagicMock()
mock_config_global.sessions = MockSessionsConfig()
class TestATProtoSocialSession(unittest.IsolatedAsyncioTestCase):
class TestBlueskiSession(unittest.IsolatedAsyncioTestCase):
@patch('sessions.atprotosocial.session.wx', mock_wx)
@patch('sessions.atprotosocial.session.config', mock_config_global)
@patch('sessions.blueski.session.wx', mock_wx)
@patch('sessions.blueski.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
mock_config_global.sessions.blueski.__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 = BlueskiSession(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
# Mock the util property to return a MagicMock for BlueskiUtils
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
@@ -104,12 +104,12 @@ class TestATProtoSocialSession(unittest.IsolatedAsyncioTestCase):
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.assertIsInstance(self.session, BlueskiSession)
self.assertEqual(self.session.KIND, "blueski")
self.assertIsNone(self.session.client)
self.assertEqual(self.session.user_id, "test_user")
@patch('sessions.atprotosocial.session.AsyncClient')
@patch('sessions.blueski.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
@@ -142,7 +142,7 @@ class TestATProtoSocialSession(unittest.IsolatedAsyncioTestCase):
self.session.notify_session_ready.assert_called_once()
@patch('sessions.atprotosocial.session.AsyncClient')
@patch('sessions.blueski.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"))
@@ -155,8 +155,8 @@ class TestATProtoSocialSession(unittest.IsolatedAsyncioTestCase):
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)
@patch('sessions.blueski.session.wx', new=mock_wx)
@patch.object(BlueskiSession, 'login', new_callable=AsyncMock)
async def test_authorise_successful(self, mock_login_method):
mock_login_method.return_value = True
@@ -174,8 +174,8 @@ class TestATProtoSocialSession(unittest.IsolatedAsyncioTestCase):
# 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)
@patch('sessions.blueski.session.wx', new=mock_wx)
@patch.object(BlueskiSession, '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.")
@@ -220,7 +220,7 @@ class TestATProtoSocialSession(unittest.IsolatedAsyncioTestCase):
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
@patch('sessions.blueski.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"}
@@ -360,4 +360,3 @@ if 'wx' not in sys.modules: # type: ignore
mock_wx_module.MessageBox = MockWxMessageBox
mock_wx_module.CallAfter = MagicMock()
mock_wx_module.GetApp = MagicMock()
>>>>>>> REPLACE

View File

@@ -11,10 +11,10 @@ from datetime import datetime
from multiplatform_widgets import widgets
log = logging.getLogger("wxUI.buffers.atprotosocial.panels")
log = logging.getLogger("wxUI.buffers.blueski.panels")
class ATProtoSocialHomeTimelinePanel(object):
class BlueskiHomeTimelinePanel(object):
"""Minimal Home timeline buffer for Bluesky.
Exposes a .buffer wx.Panel with a List control and provides
@@ -27,6 +27,7 @@ class ATProtoSocialHomeTimelinePanel(object):
self.account = session.get_name()
self.name = name
self.type = "home_timeline"
self.timeline_algorithm = None
self.invisible = True
self.needs_init = True
self.buffer = _HomePanel(parent, name)
@@ -49,17 +50,16 @@ class ATProtoSocialHomeTimelinePanel(object):
# The atproto SDK expects params, not raw kwargs
try:
from atproto import models as at_models # type: ignore
# Home: algorithmic/default timeline
try:
params = at_models.AppBskyFeedGetTimeline.Params(limit=count)
res = api.app.bsky.feed.get_timeline(params)
except Exception:
# Some SDKs may require explicit algorithm for home; try behavioral
params = at_models.AppBskyFeedGetTimeline.Params(limit=count, algorithm="behavioral")
res = api.app.bsky.feed.get_timeline(params)
params = at_models.AppBskyFeedGetTimeline.Params(
limit=count,
algorithm=self.timeline_algorithm
)
res = api.app.bsky.feed.get_timeline(params)
except Exception:
# Fallback to plain dict params if typed models unavailable
res = api.app.bsky.feed.get_timeline({"limit": count})
payload = {"limit": count}
if self.timeline_algorithm:
payload["algorithm"] = self.timeline_algorithm
res = api.app.bsky.feed.get_timeline(payload)
feed = getattr(res, "feed", [])
self.cursor = getattr(res, "cursor", None)
self.items = []
@@ -103,10 +103,17 @@ class ATProtoSocialHomeTimelinePanel(object):
api = self.session._ensure_client()
try:
from atproto import models as at_models # type: ignore
params = at_models.AppBskyFeedGetTimeline.Params(limit=40, cursor=self.cursor)
params = at_models.AppBskyFeedGetTimeline.Params(
limit=40,
cursor=self.cursor,
algorithm=self.timeline_algorithm
)
res = api.app.bsky.feed.get_timeline(params)
except Exception:
res = api.app.bsky.feed.get_timeline({"limit": 40, "cursor": self.cursor})
payload = {"limit": 40, "cursor": self.cursor}
if self.timeline_algorithm:
payload["algorithm"] = self.timeline_algorithm
res = api.app.bsky.feed.get_timeline(payload)
feed = getattr(res, "feed", [])
self.cursor = getattr(res, "cursor", None)
new_items = []
@@ -144,7 +151,7 @@ class ATProtoSocialHomeTimelinePanel(object):
log.exception("Failed to load more Bluesky timeline items")
return 0
# Alias to integrate with mainController expectations for ATProto
# Alias to integrate with mainController expectations for Blueski
def load_more_posts(self, *args, **kwargs):
return self.get_more_items()
@@ -281,12 +288,13 @@ class _HomePanel(wx.Panel):
self.SetSizer(sizer)
class ATProtoSocialFollowingTimelinePanel(ATProtoSocialHomeTimelinePanel):
class BlueskiFollowingTimelinePanel(BlueskiHomeTimelinePanel):
"""Following-only timeline (reverse-chronological)."""
def __init__(self, parent, name: str, session):
super().__init__(parent, name, session)
self.type = "following_timeline"
self.timeline_algorithm = "reverse-chronological"
# Make sure the underlying wx panel also reflects this type
try:
self.buffer.type = "following_timeline"
@@ -302,7 +310,7 @@ class ATProtoSocialFollowingTimelinePanel(ATProtoSocialHomeTimelinePanel):
api = self.session._ensure_client()
# Following timeline via reverse-chronological algorithm on get_timeline
# Use plain dict to avoid typed-model mismatches across SDK versions
res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": "reverse-chronological"})
res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": self.timeline_algorithm})
feed = getattr(res, "feed", [])
self.cursor = getattr(res, "cursor", None)
self.items = []
@@ -343,7 +351,11 @@ class ATProtoSocialFollowingTimelinePanel(ATProtoSocialHomeTimelinePanel):
try:
api = self.session._ensure_client()
# Pagination via reverse-chronological algorithm on get_timeline
res = api.app.bsky.feed.get_timeline({"limit": 40, "cursor": self.cursor, "algorithm": "reverse-chronological"})
res = api.app.bsky.feed.get_timeline({
"limit": 40,
"cursor": self.cursor,
"algorithm": self.timeline_algorithm
})
feed = getattr(res, "feed", [])
self.cursor = getattr(res, "cursor", None)
new_items = []

View File

@@ -6,9 +6,9 @@ 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
# Assuming controller.blueski.userList.get_user_profile_details and session.util._format_profile_data exist
# For direct call to util:
# from sessions.atprotosocial import utils as ATProtoSocialUtils
# from sessions.blueski import utils as BlueskiUtils
logger = logging.getLogger(__name__)
@@ -272,7 +272,7 @@ class ShowUserProfileDialog(wx.Dialog):
self.SetTitle(f"{_('User Profile')} - {text}")
```python
# Example of how this dialog might be called from atprotosocial.Handler.user_details:
# Example of how this dialog might be called from blueski.Handler.user_details:
# (This is conceptual, actual integration in handler.py will use the dialog)
#
# async def user_details(self, buffer_panel_or_user_ident):

View File

@@ -8,6 +8,8 @@ class base(wx.Menu):
self.Append(self.boost)
self.reply = wx.MenuItem(self, wx.ID_ANY, _(u"Re&ply"))
self.Append(self.reply)
self.edit = wx.MenuItem(self, wx.ID_ANY, _(u"&Edit"))
self.Append(self.edit)
self.fav = wx.MenuItem(self, wx.ID_ANY, _(u"&Add to favorites"))
self.Append(self.fav)
self.unfav = wx.MenuItem(self, wx.ID_ANY, _(u"R&emove from favorites"))
@@ -36,6 +38,8 @@ class notification(wx.Menu):
self.Append(self.boost)
self.reply = wx.MenuItem(self, wx.ID_ANY, _(u"Re&ply"))
self.Append(self.reply)
self.edit = wx.MenuItem(self, wx.ID_ANY, _(u"&Edit"))
self.Append(self.edit)
self.fav = wx.MenuItem(self, wx.ID_ANY, _(u"&Add to favorites"))
self.Append(self.fav)
self.unfav = wx.MenuItem(self, wx.ID_ANY, _(u"R&emove from favorites"))

View File

@@ -51,7 +51,7 @@ class Post(wx.Dialog):
visibility_sizer.Add(self.visibility, 0, 0, 0)
language_sizer = wx.BoxSizer(wx.HORIZONTAL)
post_actions_sizer.Add(language_sizer, 0, wx.RIGHT, 20)
lang_label = wx.StaticText(self, wx.ID_ANY, _("Language"))
lang_label = wx.StaticText(self, wx.ID_ANY, _("&Language"))
language_sizer.Add(lang_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5)
self.language = wx.ComboBox(self, wx.ID_ANY, choices=languages, style=wx.CB_DROPDOWN | wx.CB_READONLY)
language_sizer.Add(self.language, 0, wx.ALIGN_CENTER_VERTICAL, 0)
@@ -234,9 +234,9 @@ class viewPost(wx.Dialog):
def create_buttons_section(self, panel):
sizer = wx.BoxSizer(wx.HORIZONTAL)
self.mute = wx.Button(panel, wx.ID_ANY, _("Mute conversation"))
self.mute = wx.Button(panel, wx.ID_ANY, _("&Mute conversation"))
self.mute.Enable(False)
self.share = wx.Button(panel, wx.ID_ANY, _("Copy link to clipboard"))
self.share = wx.Button(panel, wx.ID_ANY, _("&Copy link to clipboard"))
self.share.Enable(False)
self.spellcheck = wx.Button(panel, wx.ID_ANY, _("Check &spelling..."))
self.translateButton = wx.Button(panel, wx.ID_ANY, _("&Translate..."))
@@ -295,7 +295,7 @@ class poll(wx.Dialog):
sizer_1 = wx.BoxSizer(wx.VERTICAL)
period_sizer = wx.BoxSizer(wx.HORIZONTAL)
sizer_1.Add(period_sizer, 1, wx.EXPAND, 0)
label_period = wx.StaticText(self, wx.ID_ANY, _("Participation time"))
label_period = wx.StaticText(self, wx.ID_ANY, _("&Participation time"))
period_sizer.Add(label_period, 0, 0, 0)
self.period = wx.ComboBox(self, wx.ID_ANY, choices=[_("5 minutes"), _("30 minutes"), _("1 hour"), _("6 hours"), _("1 day"), _("2 days"), _("3 days"), _("4 days"), _("5 days"), _("6 days"), _("7 days")], style=wx.CB_DROPDOWN | wx.CB_READONLY | wx.CB_SIMPLE)
self.period.SetFocus()
@@ -305,36 +305,36 @@ class poll(wx.Dialog):
sizer_1.Add(sizer_2, 1, wx.EXPAND, 0)
option1_sizer = wx.BoxSizer(wx.HORIZONTAL)
sizer_2.Add(option1_sizer, 1, wx.EXPAND, 0)
label_2 = wx.StaticText(self, wx.ID_ANY, _("Option 1"))
label_2 = wx.StaticText(self, wx.ID_ANY, _("Option &1"))
option1_sizer.Add(label_2, 0, 0, 0)
self.option1 = wx.TextCtrl(self, wx.ID_ANY, "")
self.option1.SetMaxLength(25)
option1_sizer.Add(self.option1, 0, 0, 0)
option2_sizer = wx.BoxSizer(wx.HORIZONTAL)
sizer_2.Add(option2_sizer, 1, wx.EXPAND, 0)
label_3 = wx.StaticText(self, wx.ID_ANY, _("Option 2"))
label_3 = wx.StaticText(self, wx.ID_ANY, _("Option &2"))
option2_sizer.Add(label_3, 0, 0, 0)
self.option2 = wx.TextCtrl(self, wx.ID_ANY, "")
self.option2.SetMaxLength(25)
option2_sizer.Add(self.option2, 0, 0, 0)
option3_sizer = wx.BoxSizer(wx.HORIZONTAL)
sizer_2.Add(option3_sizer, 1, wx.EXPAND, 0)
label_4 = wx.StaticText(self, wx.ID_ANY, _("Option 3"))
label_4 = wx.StaticText(self, wx.ID_ANY, _("Option &3"))
option3_sizer.Add(label_4, 0, 0, 0)
self.option3 = wx.TextCtrl(self, wx.ID_ANY, "")
self.option3.SetMaxLength(25)
option3_sizer.Add(self.option3, 0, 0, 0)
option4_sizer = wx.BoxSizer(wx.HORIZONTAL)
sizer_2.Add(option4_sizer, 1, wx.EXPAND, 0)
label_5 = wx.StaticText(self, wx.ID_ANY, _("Option 4"))
label_5 = wx.StaticText(self, wx.ID_ANY, _("Option &4"))
option4_sizer.Add(label_5, 0, 0, 0)
self.option4 = wx.TextCtrl(self, wx.ID_ANY, "")
self.option4.SetMaxLength(25)
option4_sizer.Add(self.option4, 0, 0, 0)
self.multiple = wx.CheckBox(self, wx.ID_ANY, _("Allow multiple choices per user"))
self.multiple = wx.CheckBox(self, wx.ID_ANY, _("&Allow multiple choices per user"))
self.multiple.SetValue(False)
sizer_1.Add(self.multiple, 0, wx.ALL, 5)
self.hide_votes = wx.CheckBox(self, wx.ID_ANY, _("Hide votes count until the poll expires"))
self.hide_votes = wx.CheckBox(self, wx.ID_ANY, _("&Hide votes count until the poll expires"))
self.hide_votes.SetValue(False)
sizer_1.Add(self.hide_votes, 0, wx.ALL, 5)
btn_sizer = wx.StdDialogButtonSizer()

View File

@@ -141,7 +141,7 @@ class ShowUserProfile(wx.Dialog):
mainSizer.Add(privateSizer, 0, wx.ALL | wx.CENTER)
botSizer = wx.BoxSizer(wx.HORIZONTAL)
botLabel = wx.StaticText(self.panel, label=_("&Bot account: "))
botLabel = wx.StaticText(self.panel, label=_("B&ot account: "))
botText = self.createTextCtrl(bullSwitch[user.bot], (30, 30))
botSizer.Add(botLabel, wx.SizerFlags().Center())
botSizer.Add(botText, wx.SizerFlags().Center())
@@ -154,7 +154,7 @@ class ShowUserProfile(wx.Dialog):
discoverSizer.Add(discoverText, wx.SizerFlags().Center())
mainSizer.Add(discoverSizer, 0, wx.ALL | wx.CENTER)
posts = wx.Button(self.panel, label=_("{} p&osts. Click to open posts timeline").format(user.statuses_count))
posts = wx.Button(self.panel, label=_("{} pos&ts. Click to open posts timeline").format(user.statuses_count))
# posts.SetToolTip(_("Click to open {}'s posts").format(user.display_name))
posts.Bind(wx.EVT_BUTTON, self.onPost)
mainSizer.Add(posts, wx.SizerFlags().Center())

View File

@@ -119,7 +119,7 @@ class UpdateProfileDialog(wx.Dialog):
self.locked = wx.CheckBox(panel, label=_("&Private account"))
self.locked.SetValue(locked)
self.bot = wx.CheckBox(panel, label=_("&Bot account"))
self.bot = wx.CheckBox(panel, label=_("B&ot account"))
self.bot.SetValue(bot)
self.discoverable = wx.CheckBox(panel, label=_("&Discoverable account"))
self.discoverable.SetValue(discoverable)

View File

@@ -26,7 +26,7 @@ class EditTemplateDialog(wx.Dialog):
sizer_3.AddButton(self.button_SAVE)
self.button_CANCEL = wx.Button(self, wx.ID_CANCEL)
sizer_3.AddButton(self.button_CANCEL)
self.button_RESTORE = wx.Button(self, wx.ID_ANY, _("Restore template"))
self.button_RESTORE = wx.Button(self, wx.ID_ANY, _("&Restore template"))
self.button_RESTORE.Bind(wx.EVT_BUTTON, self.on_restore)
sizer_3.AddButton(self.button_CANCEL)
sizer_3.Realize()

View File

@@ -22,11 +22,11 @@ class UserListDialog(wx.Dialog):
user_list_sizer.Add(self.user_list, 1, wx.EXPAND | wx.ALL, 10)
main_sizer.Add(user_list_sizer, 1, wx.EXPAND | wx.ALL, 15)
buttons_sizer = wx.BoxSizer(wx.HORIZONTAL)
self.actions_button = wx.Button(panel, wx.ID_ANY, "Actions")
self.actions_button = wx.Button(panel, wx.ID_ANY, "&Actions")
buttons_sizer.Add(self.actions_button, 0, wx.RIGHT, 10)
self.details_button = wx.Button(panel, wx.ID_ANY, _("View profile"))
self.details_button = wx.Button(panel, wx.ID_ANY, _("&View profile"))
buttons_sizer.Add(self.details_button, 0, wx.RIGHT, 10)
close_button = wx.Button(panel, wx.ID_CANCEL, "Close")
close_button = wx.Button(panel, wx.ID_CANCEL, "&Close")
buttons_sizer.Add(close_button, 0)
main_sizer.Add(buttons_sizer, 0, wx.ALIGN_CENTER | wx.BOTTOM, 15)
panel.SetSizer(main_sizer)

View File

@@ -19,7 +19,7 @@ class mainFrame(wx.Frame):
self.menuitem_search = self.menubar_application.Append(wx.ID_ANY, _(u"&Search"))
self.lists = self.menubar_application.Append(wx.ID_ANY, _(u"&Lists manager"))
self.lists.Enable(False)
self.manageAliases = self.menubar_application.Append(wx.ID_ANY, _("Manage user aliases"))
self.manageAliases = self.menubar_application.Append(wx.ID_ANY, _("M&anage user aliases"))
self.keystroke_editor = self.menubar_application.Append(wx.ID_ANY, _(u"&Edit keystrokes"))
self.account_settings = self.menubar_application.Append(wx.ID_ANY, _(u"Account se&ttings"))
self.prefs = self.menubar_application.Append(wx.ID_PREFERENCES, _(u"&Global settings"))
@@ -56,7 +56,7 @@ class mainFrame(wx.Frame):
self.trends = self.menubar_buffer.Append(wx.ID_ANY, _(u"New &trending topics buffer..."))
self.filter = self.menubar_buffer.Append(wx.ID_ANY, _(u"Create a &filter"))
self.manage_filters = self.menubar_buffer.Append(wx.ID_ANY, _(u"&Manage filters"))
self.find = self.menubar_buffer.Append(wx.ID_ANY, _(u"Find a string in the currently focused buffer..."))
self.find = self.menubar_buffer.Append(wx.ID_ANY, _(u"F&ind a string in the currently focused buffer..."))
self.load_previous_items = self.menubar_buffer.Append(wx.ID_ANY, _(u"&Load previous items"))
self.menubar_buffer.AppendSeparator()
self.mute_buffer = self.menubar_buffer.AppendCheckItem(wx.ID_ANY, _(u"&Mute"))
@@ -66,8 +66,8 @@ class mainFrame(wx.Frame):
# audio menu
self.menubar_audio = wx.Menu()
self.seekLeft = self.menubar_audio.Append(wx.ID_ANY, _(u"&Seek back 5 seconds"))
self.seekRight = self.menubar_audio.Append(wx.ID_ANY, _(u"&Seek forward 5 seconds"))
self.seekLeft = self.menubar_audio.Append(wx.ID_ANY, _(u"Seek &back 5 seconds"))
self.seekRight = self.menubar_audio.Append(wx.ID_ANY, _(u"Seek &forward 5 seconds"))
# Help Menu
self.menubar_help = wx.Menu()