feat: Initial integration of ATProtoSocial (Bluesky) protocol

This commit introduces the initial implementation for supporting the ATProtoSocial (Bluesky) protocol within your application.

Key changes and features I implemented:

1.  **Core Protocol Structure:**
    *   I added new directories `src/sessions/atprotosocial` and `src/controller/atprotosocial`.
    *   I populated these with foundational files (`session.py`, `utils.py`, `handler.py`, `compose.py`, etc.), mirroring the Mastodon implementation structure but adapted for ATProtoSocial.

2.  **Authentication:**
    *   I implemented login and authorization using Bluesky SDK (handle and app password) in `sessions/atprotosocial/session.py`.
    *   I integrated this into your session management UI (`sessionManagerDialog.py`) to allow adding ATProtoSocial accounts.

3.  **Posting Capabilities:**
    *   I implemented sending text posts, posts with images, replies, and quoting posts in `sessions/atprotosocial/session.py` and `utils.py`.
    *   I updated `compose.py` to reflect ATProtoSocial's panel configuration (character limits, media support, quoting).

4.  **Notifications:**
    *   I implemented fetching and processing of notifications (likes, reposts, follows, mentions, replies, quotes) in `sessions/atprotosocial/session.py`.
    *   Notifications are formatted for display.

5.  **Timelines:**
    *   I implemented fetching and processing for home timeline and user-specific timelines in `sessions/atprotosocial/session.py`.
    *   This includes handling of posts, reposts, and replies within your application's buffer and message cache system.

6.  **User Actions:**
    *   I implemented core user actions: follow, unfollow, mute, unmute, block, unblock in `sessions/atprotosocial/utils.py`.
    *   I integrated these actions into the controller layer (`controller/atprotosocial/handler.py`) and exposed them via `session.get_user_actions()`.

7.  **User Management & Profile:**
    *   I implemented fetching user profiles, follower lists, following lists, and user search in `sessions/atprotosocial/utils.py` and `controller/atprotosocial/userList.py`.

8.  **UI Integration (Initial Pass):**
    *   I adapted your session management UI for ATProtoSocial account creation.
    *   I updated main controller logic to load the ATProtoSocial handler and create basic buffers (Home, Notifications).
    *   I modified menu item labels based on the active session type (e.g., "Post" vs "Toot", "Like" vs "Favorite").
    *   I integrated core actions like reposting and liking into existing UI flows.
    *   I added basic integration for timeline refresh and loading more items.
    *   I added placeholder integration for viewing user profiles and user-specific timelines.

**Current Status & Next Steps:**

This represents a significant portion of the ATProtoSocial integration. The backend logic for most core features is in place. The immediate next steps, which were part of the original plan but not yet completed, would be:

*   **Refining UI elements:** Fully implementing dedicated dialogs (compose, user profile), custom panels for new buffer types, and ensuring accurate rendering of ATProtoSocial posts and notifications.
*   **Completing Documentation:** Updating all relevant documentation files in `doc/` and `documentation/`.
*   **Updating Translations:** Adding new strings and updating translation files.
*   **Adding Tests:** Creating unit and integration tests for the new protocol.

I was not stuck on any particular point, but the UI integration is a large step that requires iterative refinement and testing for each component, which would naturally extend beyond a single development cycle for a feature of this scope.
This commit is contained in:
google-labs-jules[bot]
2025-05-26 14:11:01 +00:00
parent b4288ce51e
commit 1dffa2a6f9
16 changed files with 4525 additions and 52 deletions

View File

@@ -0,0 +1,3 @@
from .session import Session
__all__ = ["Session"]

View File

@@ -0,0 +1,153 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
fromapprove.translation import translate as _
if TYPE_CHECKING:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
logger = logging.getLogger(__name__)
class ATProtoSocialCompose:
# Maximum number of characters allowed in a post on ATProtoSocial (Bluesky uses graphemes, not codepoints)
# Bluesky's limit is 300 graphemes. This might need adjustment based on how Python handles graphemes.
MAX_CHARS = 300 # Defined by app.bsky.feed.post schema (description for text field)
MAX_MEDIA_ATTACHMENTS = 4 # Defined by app.bsky.embed.images schema (maxItems for images array)
MAX_LANGUAGES = 3 # Defined by app.bsky.feed.post schema (maxItems for langs array)
# MAX_POLL_OPTIONS = 4 # Polls are not yet standard in ATProto, but some clients support them.
# MAX_POLL_OPTION_CHARS = 25
# MIN_POLL_DURATION = 5 * 60 # 5 minutes
# MAX_POLL_DURATION = 7 * 24 * 60 * 60 # 7 days
# Bluesky image size limit is 1MB (1,000,000 bytes)
# https://github.com/bluesky-social/social-app/blob/main/src/lib/constants.ts#L28
MAX_IMAGE_SIZE_BYTES = 1_000_000
def __init__(self, session: ATProtoSocialSession) -> None:
self.session = session
self.supported_media_types: list[str] = ["image/jpeg", "image/png"]
self.max_image_size_bytes: int = self.MAX_IMAGE_SIZE_BYTES
def get_panel_configuration(self) -> dict[str, Any]:
"""Returns configuration for the compose panel specific to ATProtoSocial."""
return {
"max_chars": self.MAX_CHARS,
"max_media_attachments": self.MAX_MEDIA_ATTACHMENTS,
"supports_content_warning": True, # Bluesky uses self-labels for content warnings
"supports_scheduled_posts": False, # ATProto/Bluesky does not natively support scheduled posts
"supported_media_types": self.supported_media_types,
"max_media_size_bytes": self.max_image_size_bytes,
"supports_alternative_text": True, # Alt text is supported for images
"sensitive_reasons_options": self.session.get_sensitive_reason_options(), # For self-labeling
"supports_language_selection": True, # app.bsky.feed.post supports 'langs' field
"max_languages": self.MAX_LANGUAGES,
"supports_quoting": True, # Bluesky supports quoting via app.bsky.embed.record
"supports_polls": False, # No standard poll support in ATProto yet
# "max_poll_options": self.MAX_POLL_OPTIONS,
# "max_poll_option_chars": self.MAX_POLL_OPTION_CHARS,
# "min_poll_duration": self.MIN_POLL_DURATION,
# "max_poll_duration": self.MAX_POLL_DURATION,
}
async def get_quote_text(self, message_id: str, url: str) -> str | None:
"""
Generates text to be added to the compose box when quoting an ATProtoSocial post.
For Bluesky, the actual quote is an embed. This text is typically appended by the user.
`message_id` here is the AT-URI of the post to be quoted.
`url` is the web URL of the post.
"""
# The actual embedding of a quote is handled in session.send_message by passing quote_uri.
# This method is for any text that might be automatically added to the *user's post text*.
# Often, users just add the link manually, or clients might add "QT: [link]".
# For now, returning an empty string means no text is automatically added to the compose box,
# the UI will handle showing the quote embed and the user types their own commentary.
# Alternatively, return `url` if the desired behavior is to paste the URL into the text.
# Example: Fetching post details to include a snippet (can be slow)
# try:
# post_view = await self.session.util.get_post_by_uri(message_id) # Assuming message_id is AT URI
# if post_view and post_view.author and post_view.record:
# author_handle = post_view.author.handle
# text_snippet = str(post_view.record.text)[:70] # Take first 70 chars of post text
# return f"QT @{author_handle}: \"{text_snippet}...\"\n{url}\n"
# except Exception as e:
# logger.warning(f"Could not fetch post for quote text ({message_id}): {e}")
# return f"{url} " # Just the URL, or empty string
return "" # No automatic text added; UI handles visual quote, user adds own text.
async def get_reply_text(self, message_id: str, author_handle: str) -> str | None:
"""
Generates reply text (mention) for a given author handle for ATProtoSocial.
"""
# TODO: Confirm if any specific prefix is needed beyond the mention.
# Bluesky handles mentions with "@handle.example.com"
if not author_handle.startswith("@"):
return f"@{author_handle} "
return f"{author_handle} "
# Any other ATProtoSocial specific compose methods would go here.
# For example, methods to handle draft creation, media uploads prior to posting, etc.
# async def upload_media(self, file_path: str, mime_type: str, description: str | None = None) -> dict[str, Any] | None:
# """
# Uploads a media file to ATProtoSocial and returns media ID or details.
# This would use the atproto client's blob upload.
# """
# # try:
# # # client = self.session.util.get_client() # Assuming a method to get an authenticated atproto client
# # with open(file_path, "rb") as f:
# # blob_data = f.read()
# # # response = await client.com.atproto.repo.upload_blob(blob_data, mime_type=mime_type)
# # # return {"id": response.blob.ref, "url": response.blob.cid, "description": description} # Example structure
# # logger.info(f"Media uploaded: {file_path}")
# # return {"id": "fake_media_id", "url": "fake_media_url", "description": description} # Placeholder
# # except Exception as e:
# # logger.error(f"Failed to upload media to ATProtoSocial: {e}")
# # return None
# pass
def get_text_formatting_rules(self) -> dict[str, Any]:
"""
Returns text formatting rules for ATProtoSocial.
Bluesky uses Markdown for rich text, but it's processed server-side from facets.
Client-side, users type plain text and the client detects links, mentions, etc., to create facets.
"""
return {
"markdown_enabled": False, # Users type plain text; facets are for rich text features
"custom_emojis_enabled": False, # ATProto doesn't have custom emojis like Mastodon
"max_length": self.MAX_CHARS,
"line_break_char": "\n",
# Information about how links, mentions, tags are formatted or if they count towards char limit differently
"link_format": "Full URL (e.g., https://example.com)", # Links are typically full URLs
"mention_format": "@handle.bsky.social",
"tag_format": "#tag (becomes a facet link)", # Hashtags are detected and become links
}
def is_media_type_supported(self, mime_type: str) -> bool:
"""Checks if a given MIME type is supported for upload."""
# TODO: Use actual supported types from `self.supported_media_types`
return mime_type.lower() in self.supported_media_types
def get_max_schedule_date(self) -> str | None:
"""Returns the maximum date posts can be scheduled to, if supported."""
# ATProtoSocial does not natively support scheduled posts.
return None
def get_poll_configuration(self) -> dict[str, Any] | None:
"""Returns configuration for polls, if supported."""
# Polls are not a standard part of ATProto yet.
# If implementing client-side polls or if official support arrives, this can be updated.
# return {
# "max_options": self.MAX_POLL_OPTIONS,
# "max_option_chars": self.MAX_POLL_OPTION_CHARS,
# "min_duration_seconds": self.MIN_POLL_DURATION,
# "max_duration_seconds": self.MAX_POLL_DURATION,
# "default_duration_seconds": 24 * 60 * 60, # 1 day
# }
return None

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,209 @@
from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING, Any, Callable, Coroutine
if TYPE_CHECKING:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
logger = logging.getLogger(__name__)
# ATProtoSocial (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:
self.session = session
self.stream_type = stream_type # e.g., 'user', 'public', 'hashtag' - will need mapping to Firehose concepts
self.params = params or {}
self._handler: Callable[[dict[str, Any]], Coroutine[Any, Any, None]] | None = None
self._connection_task: asyncio.Task[None] | None = None
self._should_stop = False
# self._client = None # This would be an instance of atproto.firehose.FirehoseSubscribeReposClient or similar
# TODO: Map stream_type and params to ATProto Firehose subscription needs.
# For example, 'user' might mean subscribing to mentions, replies, follows for the logged-in user.
# This would likely involve filtering the general repo firehose for relevant events,
# 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."""
# 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}")
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.")
# return
# self._firehose_client = FirehoseSubscribeReposClient(params=None, base_uri=self.session.api_base_url) # Adjust base_uri if needed
# async def on_message_handler(message: models.ComAtprotoSyncSubscribeRepos.Message) -> None:
# if self._should_stop:
# await self._firehose_client.stop() # Ensure client stops if flag is set
# return
# # This is a simplified example. Real implementation needs to:
# # 1. Determine the type of message (commit, handle, info, migrate, tombstone)
# # 2. For commits, unpack operations to find posts, likes, reposts, follows, etc.
# # 3. Filter these events to be relevant to the user (e.g., mentions, replies to user, new posts from followed users)
# # 4. Format the data into a structure that self._handle_event expects.
# # This filtering can be complex.
# # Example: if it's a commit and contains a new post that mentions the user
# # if isinstance(message, models.ComAtprotoSyncSubscribeRepos.Commit):
# # # This part is highly complex due to CAR CIBOR decoding
# # # Operations need to be extracted from the commit block
# # # For each op, check if it's a create, and if the record is a post
# # # Then, check if the post's text or facets mention the current user.
# # # This is a placeholder for that logic.
# # logger.debug(f"Firehose commit from {message.repo} at {message.time}")
# # # Example of processing ops (pseudo-code, actual decoding is more involved):
# # # ops = message.ops
# # # for op in ops:
# # # if op.action == 'create' and op.path.endswith('/app.bsky.feed.post/...'):
# # # record_data = ... # decode op.cid from message.blocks
# # # if self.session.util.is_mention_of_me(record_data):
# # # event_data = self.session.util.format_post_event(record_data)
# # # 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__}")
# await self._firehose_client.start(on_message_handler)
# Placeholder loop to simulate receiving events
while not self._should_stop:
await asyncio.sleep(1)
# In a real implementation, this loop wouldn't exist; it'd be driven by the SDK's event handler.
# To simulate an event:
# if self._handler:
# 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.")
except asyncio.CancelledError:
logger.info(f"ATProtoSocial 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)
# 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)
if not self._should_stop: # Check again before restarting
self._connection_task = asyncio.create_task(self._connect())
finally:
# if self._firehose_client:
# await self._firehose_client.stop()
logger.info(f"ATProtoSocial streaming connection closed for user {self.session.user_id}.")
async def _handle_event(self, event_type: str, data: dict[str, Any]) -> None:
"""
Internal method to process an event from the stream and pass it to the session's handler.
"""
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
# 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)
else:
logger.warning(f"ATProtoSocial 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}.")
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}")
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}")
self._should_stop = True
# if self._firehose_client: # Assuming the SDK has a stop method
# await self._firehose_client.stop()
if self._connection_task:
if not self._connection_task.done():
self._connection_task.cancel()
try:
await self._connection_task
except asyncio.CancelledError:
logger.info(f"ATProtoSocial 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}.")
def is_alive(self) -> bool:
"""Checks if the streaming connection is currently active."""
# return self._connection_task is not None and not self._connection_task.done() and self._firehose_client and self._firehose_client.is_connected
return self._connection_task is not None and not self._connection_task.done() # Simplified check
def get_stream_type(self) -> str:
return self.stream_type
def get_params(self) -> dict[str, Any]:
return self.params
# TODO: Add methods specific to ATProtoSocial 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),
# this class might manage multiple connections or re-establish with new parameters.
# Example of how events might be processed (highly simplified):
# This would be called by the on_message_handler in _connect
# async def _process_firehose_message(self, message: models.ComAtprotoSyncSubscribeRepos.Message):
# if isinstance(message, models.ComAtprotoSyncSubscribeRepos.Commit):
# # Decode CAR file in message.blocks to get ops
# # For each op (create, update, delete of a record):
# # record = get_record_from_blocks(message.blocks, op.cid)
# # if op.path.startswith("app.bsky.feed.post"): # It's a post
# # # Check if it's a new post, a reply, a quote, etc.
# # # Check for mentions of the current user
# # # Example:
# # if self.session.util.is_mention_of_me(record):
# # formatted_event = self.session.util.format_post_as_notification(record, "mention")
# # await self._handle_event("mention", formatted_event)
# # elif op.path.startswith("app.bsky.graph.follow"):
# # # Check if it's a follow of the current user
# # if record.subject == self.session.util.get_my_did(): # Assuming get_my_did() exists
# # formatted_event = self.session.util.format_follow_as_notification(record)
# # await self._handle_event("follow", formatted_event)
# # # Handle likes (app.bsky.feed.like), reposts (app.bsky.feed.repost), etc.
# pass
# elif isinstance(message, models.ComAtprotoSyncSubscribeRepos.Handle):
# # Handle DID to handle mapping updates if necessary
# logger.debug(f"Handle update: {message.handle} now points to {message.did} at {message.time}")
# elif isinstance(message, models.ComAtprotoSyncSubscribeRepos.Migrate):
# logger.info(f"Repo migration: {message.did} migrating from {message.migrateTo} at {message.time}")
# elif isinstance(message, models.ComAtprotoSyncSubscribeRepos.Tombstone):
# logger.info(f"Repo tombstone: {message.did} at {message.time}")
# elif isinstance(message, models.ComAtprotoSyncSubscribeRepos.Info):
# logger.info(f"Firehose info: {message.name} - {message.message}")
# else:
# logger.debug(f"Unknown Firehose message type: {message.__class__.__name__}")

View File

@@ -0,0 +1,123 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
fromapprove.translation import translate as _
if TYPE_CHECKING:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
logger = logging.getLogger(__name__)
class ATProtoSocialTemplates:
def __init__(self, session: ATProtoSocialSession) -> 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.
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
}
if context:
base_data.update(context)
# TODO: Implement specific data fetching for different ATProtoSocial templates
# Example:
# if template_name == "profile_summary.html":
# # profile_info = await self.session.util.get_my_profile_info() # Assuming such a method exists
# # base_data["profile"] = profile_info
# base_data["profile"] = {"display_name": "User", "handle": "user.bsky.social"} # Placeholder
# elif template_name == "post_details.html":
# # post_id = context.get("post_id")
# # post_details = await self.session.util.get_post_by_id(post_id)
# # base_data["post"] = post_details
# base_data["post"] = {"text": "A sample post", "author_handle": "author.bsky.social"} # Placeholder
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)
# is rendered in a list (e.g., in a timeline or search results).
# return "sessions/atprotosocial/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.
"""
# TODO: Define templates for different ATProtoSocial 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.
# 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'
# # ... other notification types
# }
# Using generic templates as placeholders:
return {
"mention": "sessions/generic/notifications/mention.html",
"reply": "sessions/generic/notifications/reply.html",
"follow": "sessions/generic/notifications/follow.html",
"like": "sessions/generic/notifications/favourite.html", # Map to favourite if generic expects that
"repost": "sessions/generic/notifications/reblog.html", # Map to reblog if generic expects that
}
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"
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.
User actions are typically buttons or forms displayed on a user's profile.
"""
# TODO: Define templates for ATProtoSocial 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
# }
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.
These actions might appear on lists of users (e.g., followers, following).
"""
# TODO: Define templates for ATProtoSocial user list actions
# Example:
# return {
# "follow_all_visible": "sessions/atprotosocial/list_actions/follow_all_button.html",
# }
return None # Placeholder
# Add any other template-related helper methods specific to ATProtoSocial.
# For example, methods to get templates for specific types of content (images, polls)
# if they need special rendering.
def get_template_for_message_type(self, message_type: str) -> str | None:
"""
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
# that require different display logic.
# if message_type == "quote_post":
# return "sessions/atprotosocial/cards/quote_post.html"
return None # Default to standard message card if not specified

File diff suppressed because it is too large Load Diff