From 037fcbf7e0b864df082785a56a87ba8a1b25493b Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 20 Jul 2025 19:46:31 -0400 Subject: [PATCH] Add comprehensive social features with accessibility-first design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements complete post management and social interaction capabilities: **Post Management:** - Delete posts with confirmation dialog (owned posts only) - Edit posts using existing compose dialog (owned posts only) - Robust ownership validation and error handling **Follow System:** - Follow/unfollow users from context menus and keyboard shortcuts - Manual follow dialog for @user@instance lookups - Account search and validation before following - Smart context menu options based on post ownership **Timeline Extensions:** - Followers tab showing accounts following you - Following tab showing accounts you follow - Seamless integration with existing timeline system - Account list display for social relationship viewing **Keyboard Shortcuts:** - Ctrl+Shift+E: Edit post - Shift+Delete: Delete post (with confirmation) - Ctrl+Shift+F: Follow user - Ctrl+Shift+U: Unfollow user - Ctrl+Shift+M: Manual follow dialog **ActivityPub API Extensions:** - edit_status() method for post editing - get_relationship() method for follow status checking - Enhanced followers/following pagination support **Critical Accessibility Fix:** - Eliminated ALL text truncation throughout the application - Added explicit no-truncation rule to development guidelines - Full content accessibility for screen reader users All features maintain Bifrost's accessibility-first principles with proper error handling, user feedback, and complete information display. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 31 +++- src/activitypub/client.py | 27 ++++ src/main_window.py | 271 ++++++++++++++++++++++++++++++++++- src/models/user.py | 3 - src/widgets/timeline_view.py | 125 +++++++++++++++- 5 files changed, 439 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4f3dcf7..06359d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,14 +6,18 @@ Bifrost is a fully accessible fediverse client built with PySide6, designed spec ## Core Features - Full ActivityPub protocol support (Pleroma and GoToSocial primary targets) - Threaded conversation navigation with collapsible tree view -- Customizable sound pack system for audio notifications -- Screen reader optimized interface +- Comprehensive soundpack management system with secure repository support +- Smart autocomplete for mentions (@user@instance.com) and emojis (5,000+ Unicode) +- Auto-refresh with intelligent activity-based timing +- Screen reader optimized interface with Orca compatibility fixes - XDG Base Directory specification compliance ## Technology Stack - **PySide6**: Main GUI framework (proven accessibility with existing doom launcher) - **requests**: HTTP client for ActivityPub APIs - **simpleaudio**: Cross-platform audio with subprocess fallback +- **emoji**: Comprehensive Unicode emoji library (5,000+ emojis with keyword search) +- **plyer**: Cross-platform desktop notifications - **XDG directories**: Configuration and data storage ## Architecture @@ -41,13 +45,16 @@ bifrost/ │ │ └── thread.py # Conversation threading │ ├── widgets/ # Custom UI components │ │ ├── __init__.py -│ │ ├── timeline_view.py # Main timeline widget -│ │ ├── compose_dialog.py # Post composition +│ │ ├── timeline_view.py # Main timeline widget with auto-refresh +│ │ ├── compose_dialog.py # Post composition with smart autocomplete +│ │ ├── autocomplete_textedit.py # Mention and emoji autocomplete system │ │ ├── settings_dialog.py # Application settings +│ │ ├── soundpack_manager_dialog.py # Soundpack repository management │ │ └── login_dialog.py # Instance login │ ├── audio/ # Sound system │ │ ├── __init__.py │ │ ├── sound_manager.py # Audio notification handler +│ │ └── soundpack_manager.py # Secure soundpack installation system │ │ └── sound_pack.py # Sound pack management │ └── config/ # Configuration management │ ├── __init__.py @@ -357,9 +364,8 @@ verbose_announcements = true PySide6>=6.0.0 requests>=2.25.0 simpleaudio>=1.0.4 -numpy -configparser -pathlib +plyer>=2.1.0 +emoji>=2.0.0 ``` ### Optional Dependencies @@ -402,4 +408,15 @@ python bifrost.py - **JAWS** (Windows via Wine): Basic compatibility - **VoiceOver** (macOS): Future consideration +## Critical Accessibility Rules + +### Text Truncation Is Forbidden +**NEVER TRUNCATE TEXT**: Bifrost is an accessibility-first client. Text truncation (using "..." or limiting character counts) is strictly forbidden as it prevents screen reader users from accessing complete information. Always display full content, descriptions, usernames, profiles, and any other text in its entirety. + +Examples of forbidden practices: +- `content[:100] + "..."` +- Character limits on display text +- Shortened usernames or descriptions +- Abbreviated profile information + This document serves as the comprehensive development guide for Bifrost, ensuring all accessibility, functionality, and architectural decisions are preserved and can be referenced throughout development. \ No newline at end of file diff --git a/src/activitypub/client.py b/src/activitypub/client.py index 920618e..0f897e5 100644 --- a/src/activitypub/client.py +++ b/src/activitypub/client.py @@ -244,6 +244,33 @@ class ActivityPubClient: def get_custom_emojis(self) -> List[Dict]: """Get custom emojis for this instance""" return self._make_request('GET', '/api/v1/custom_emojis') + + def edit_status(self, status_id: str, content: str, visibility: str = 'public', + content_warning: Optional[str] = None, + media_ids: Optional[List[str]] = None, + content_type: str = 'text/plain') -> Dict: + """Edit an existing status""" + data = { + 'status': content, + 'visibility': visibility + } + + if content_type == 'text/markdown': + data['content_type'] = 'text/markdown' + + if content_warning: + data['spoiler_text'] = content_warning + if media_ids: + data['media_ids'] = media_ids + + endpoint = f'/api/v1/statuses/{status_id}' + return self._make_request('PUT', endpoint, data=data) + + def get_relationship(self, account_id: str) -> Dict: + """Get relationship with an account""" + params = {'id': account_id} + result = self._make_request('GET', '/api/v1/accounts/relationships', params=params) + return result[0] if result else {} class AuthenticationError(Exception): diff --git a/src/main_window.py b/src/main_window.py index ed5ee93..a82f25b 100644 --- a/src/main_window.py +++ b/src/main_window.py @@ -72,6 +72,8 @@ class MainWindow(QMainWindow): self.timeline_tabs.addTab(QWidget(), "Mentions") self.timeline_tabs.addTab(QWidget(), "Local") self.timeline_tabs.addTab(QWidget(), "Federated") + self.timeline_tabs.addTab(QWidget(), "Followers") + self.timeline_tabs.addTab(QWidget(), "Following") self.timeline_tabs.currentChanged.connect(self.on_timeline_tab_changed) main_layout.addWidget(self.timeline_tabs) @@ -88,6 +90,10 @@ class MainWindow(QMainWindow): self.timeline.boost_requested.connect(self.boost_post) self.timeline.favorite_requested.connect(self.favorite_post) self.timeline.profile_requested.connect(self.view_profile) + self.timeline.delete_requested.connect(self.delete_post) + self.timeline.edit_requested.connect(self.edit_post) + self.timeline.follow_requested.connect(self.follow_user) + self.timeline.unfollow_requested.connect(self.unfollow_user) main_layout.addWidget(self.timeline) # Compose button @@ -218,6 +224,43 @@ class MainWindow(QMainWindow): urls_action.triggered.connect(self.open_current_post_urls) post_menu.addAction(urls_action) + post_menu.addSeparator() + + # Edit action (for owned posts) + edit_action = QAction("&Edit Post", self) + edit_action.setShortcut(QKeySequence("Ctrl+Shift+E")) + edit_action.triggered.connect(self.edit_current_post) + post_menu.addAction(edit_action) + + # Delete action (for owned posts) + delete_action = QAction("&Delete Post", self) + delete_action.setShortcut(QKeySequence("Shift+Delete")) + delete_action.triggered.connect(self.delete_current_post) + post_menu.addAction(delete_action) + + # Social menu + social_menu = menubar.addMenu("&Social") + + # Follow action + follow_action = QAction("&Follow User", self) + follow_action.setShortcut(QKeySequence("Ctrl+Shift+F")) + follow_action.triggered.connect(self.follow_current_user) + social_menu.addAction(follow_action) + + # Unfollow action + unfollow_action = QAction("&Unfollow User", self) + unfollow_action.setShortcut(QKeySequence("Ctrl+Shift+U")) + unfollow_action.triggered.connect(self.unfollow_current_user) + social_menu.addAction(unfollow_action) + + social_menu.addSeparator() + + # Manual follow action + manual_follow_action = QAction("Follow &Specific User...", self) + manual_follow_action.setShortcut(QKeySequence("Ctrl+Shift+M")) + manual_follow_action.triggered.connect(self.show_manual_follow_dialog) + social_menu.addAction(manual_follow_action) + def setup_shortcuts(self): """Set up keyboard shortcuts""" # Additional shortcuts that don't need menu items @@ -404,8 +447,8 @@ class MainWindow(QMainWindow): def switch_timeline(self, index): """Switch to timeline by index with loading feedback""" - timeline_names = ["Home", "Mentions", "Local", "Federated"] - timeline_types = ["home", "notifications", "local", "federated"] + timeline_names = ["Home", "Mentions", "Local", "Federated", "Followers", "Following"] + timeline_types = ["home", "notifications", "local", "federated", "followers", "following"] if 0 <= index < len(timeline_names): timeline_name = timeline_names[index] @@ -592,6 +635,230 @@ class MainWindow(QMainWindow): else: self.close() + def delete_current_post(self): + """Delete the currently selected post""" + post = self.get_selected_post() + if post: + self.delete_post(post) + else: + self.status_bar.showMessage("No post selected", 2000) + + def edit_current_post(self): + """Edit the currently selected post""" + post = self.get_selected_post() + if post: + self.edit_post(post) + else: + self.status_bar.showMessage("No post selected", 2000) + + def follow_current_user(self): + """Follow the user of the currently selected post""" + post = self.get_selected_post() + if post: + self.follow_user(post) + else: + self.status_bar.showMessage("No post selected", 2000) + + def unfollow_current_user(self): + """Unfollow the user of the currently selected post""" + post = self.get_selected_post() + if post: + self.unfollow_user(post) + else: + self.status_bar.showMessage("No post selected", 2000) + + def delete_post(self, post): + """Delete a post with confirmation dialog""" + from PySide6.QtWidgets import QMessageBox + + # Check if this is user's own post + active_account = self.account_manager.get_active_account() + if not active_account or not hasattr(post, 'account'): + self.status_bar.showMessage("Cannot delete: No active account", 2000) + return + + is_own_post = (post.account.username == active_account.username and + post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', '')) + + if not is_own_post: + self.status_bar.showMessage("Cannot delete: Not your post", 2000) + return + + # Show confirmation dialog + content_preview = post.get_content_text() + + result = QMessageBox.question( + self, + "Delete Post", + f"Are you sure you want to delete this post?\n\n\"{content_preview}\"", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No + ) + + if result == QMessageBox.Yes: + try: + client = ActivityPubClient(active_account.instance_url, active_account.access_token) + client.delete_status(post.id) + self.status_bar.showMessage("Post deleted successfully", 2000) + # Refresh timeline to remove deleted post + self.timeline.refresh() + except Exception as e: + self.status_bar.showMessage(f"Delete failed: {str(e)}", 3000) + + def edit_post(self, post): + """Edit a post""" + # Check if this is user's own post + active_account = self.account_manager.get_active_account() + if not active_account or not hasattr(post, 'account'): + self.status_bar.showMessage("Cannot edit: No active account", 2000) + return + + is_own_post = (post.account.username == active_account.username and + post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', '')) + + if not is_own_post: + self.status_bar.showMessage("Cannot edit: Not your post", 2000) + return + + # Open compose dialog with current post content + dialog = ComposeDialog(self.account_manager, self) + dialog.text_edit.setPlainText(post.get_content_text()) + # Move cursor to end + cursor = dialog.text_edit.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + dialog.text_edit.setTextCursor(cursor) + + def handle_edit_sent(data): + try: + client = ActivityPubClient(active_account.instance_url, active_account.access_token) + client.edit_status( + post.id, + content=data['content'], + visibility=data['visibility'], + content_warning=data['content_warning'] + ) + self.status_bar.showMessage("Post edited successfully", 2000) + # Refresh timeline to show edited post + self.timeline.refresh() + except Exception as e: + self.status_bar.showMessage(f"Edit failed: {str(e)}", 3000) + + dialog.post_sent.connect(handle_edit_sent) + dialog.exec() + + def follow_user(self, post): + """Follow a user""" + active_account = self.account_manager.get_active_account() + if not active_account or not hasattr(post, 'account'): + self.status_bar.showMessage("Cannot follow: No active account", 2000) + return + + try: + client = ActivityPubClient(active_account.instance_url, active_account.access_token) + client.follow_account(post.account.id) + username = post.account.display_name or post.account.username + self.status_bar.showMessage(f"Followed {username}", 2000) + except Exception as e: + self.status_bar.showMessage(f"Follow failed: {str(e)}", 3000) + + def unfollow_user(self, post): + """Unfollow a user""" + active_account = self.account_manager.get_active_account() + if not active_account or not hasattr(post, 'account'): + self.status_bar.showMessage("Cannot unfollow: No active account", 2000) + return + + try: + client = ActivityPubClient(active_account.instance_url, active_account.access_token) + client.unfollow_account(post.account.id) + username = post.account.display_name or post.account.username + self.status_bar.showMessage(f"Unfollowed {username}", 2000) + except Exception as e: + self.status_bar.showMessage(f"Unfollow failed: {str(e)}", 3000) + + def show_manual_follow_dialog(self): + """Show dialog to manually follow a user by @username@instance""" + from PySide6.QtWidgets import QDialog, QVBoxLayout, QLineEdit, QLabel, QDialogButtonBox, QPushButton + + dialog = QDialog(self) + dialog.setWindowTitle("Follow User") + dialog.setMinimumSize(400, 150) + dialog.setModal(True) + + layout = QVBoxLayout(dialog) + + # Label + label = QLabel("Enter the user to follow (e.g. @user@instance.social):") + label.setAccessibleName("Follow User Instructions") + layout.addWidget(label) + + # Input field + self.follow_input = QLineEdit() + self.follow_input.setAccessibleName("Username to Follow") + self.follow_input.setPlaceholderText("@username@instance.social") + layout.addWidget(self.follow_input) + + # Buttons + button_box = QDialogButtonBox() + + follow_button = QPushButton("Follow") + follow_button.setDefault(True) + cancel_button = QPushButton("Cancel") + + button_box.addButton(follow_button, QDialogButtonBox.AcceptRole) + button_box.addButton(cancel_button, QDialogButtonBox.RejectRole) + + button_box.accepted.connect(dialog.accept) + button_box.rejected.connect(dialog.reject) + layout.addWidget(button_box) + + # Show dialog + if dialog.exec() == QDialog.Accepted: + username = self.follow_input.text().strip() + if username: + self.manual_follow_user(username) + else: + self.status_bar.showMessage("Please enter a username", 2000) + + def manual_follow_user(self, username): + """Follow a user by username""" + active_account = self.account_manager.get_active_account() + if not active_account: + self.status_bar.showMessage("Cannot follow: No active account", 2000) + return + + # Remove @ prefix if present + if username.startswith('@'): + username = username[1:] + + try: + client = ActivityPubClient(active_account.instance_url, active_account.access_token) + + # Search for the account first + accounts = client.search_accounts(username) + if not accounts: + self.status_bar.showMessage(f"User not found: {username}", 3000) + return + + # Find exact match + target_account = None + for account in accounts: + if account['acct'] == username or account['username'] == username.split('@')[0]: + target_account = account + break + + if not target_account: + self.status_bar.showMessage(f"User not found: {username}", 3000) + return + + # Follow the account + client.follow_account(target_account['id']) + display_name = target_account.get('display_name') or target_account['username'] + self.status_bar.showMessage(f"Followed {display_name}", 2000) + + except Exception as e: + self.status_bar.showMessage(f"Follow failed: {str(e)}", 3000) + def closeEvent(self, event): """Handle window close event""" # Play shutdown sound if not already played through quit_application diff --git a/src/models/user.py b/src/models/user.py index 53ce372..864e919 100644 --- a/src/models/user.py +++ b/src/models/user.py @@ -159,9 +159,6 @@ class User: # Add bio if present bio = self.get_bio_text() if bio: - # Truncate long bios - if len(bio) > 150: - bio = bio[:147] + "..." summary += f" - {bio}" return summary diff --git a/src/widgets/timeline_view.py b/src/widgets/timeline_view.py index 9c0251c..7dc5b20 100644 --- a/src/widgets/timeline_view.py +++ b/src/widgets/timeline_view.py @@ -26,6 +26,10 @@ class TimelineView(AccessibleTreeWidget): boost_requested = Signal(object) # Post object favorite_requested = Signal(object) # Post object profile_requested = Signal(object) # Post object + delete_requested = Signal(object) # Post object + edit_requested = Signal(object) # Post object + follow_requested = Signal(object) # Post object + unfollow_requested = Signal(object) # Post object def __init__(self, account_manager: AccountManager, parent=None): super().__init__(parent) @@ -103,9 +107,17 @@ class TimelineView(AccessibleTreeWidget): # Get posts per page from settings posts_per_page = int(self.settings.get('timeline', 'posts_per_page', 40) or 40) - # Fetch timeline or notifications + # Fetch timeline, notifications, or followers/following if self.timeline_type == "notifications": timeline_data = self.activitypub_client.get_notifications(limit=posts_per_page) + elif self.timeline_type == "followers": + # Get current user account info first + user_info = self.activitypub_client.verify_credentials() + timeline_data = self.activitypub_client.get_followers(user_info['id'], limit=posts_per_page) + elif self.timeline_type == "following": + # Get current user account info first + user_info = self.activitypub_client.verify_credentials() + timeline_data = self.activitypub_client.get_following(user_info['id'], limit=posts_per_page) else: timeline_data = self.activitypub_client.get_timeline(self.timeline_type, limit=posts_per_page) self.load_timeline_data(timeline_data) @@ -124,9 +136,9 @@ class TimelineView(AccessibleTreeWidget): def load_timeline_data(self, timeline_data): """Load real timeline data from ActivityPub API""" - # Check for new content by comparing newest post ID + # Check for new content by comparing newest post ID (only for regular timelines) has_new_content = False - if timeline_data and self.newest_post_id: + if timeline_data and self.newest_post_id and self.timeline_type not in ["followers", "following"]: # Check if the first post (newest) is different from what we had current_newest_id = timeline_data[0]['id'] if current_newest_id != self.newest_post_id: @@ -152,7 +164,7 @@ class TimelineView(AccessibleTreeWidget): # Show desktop notification (skip if this is initial load) if not self.skip_notifications: - content_preview = post.get_content_text()[:100] + "..." if len(post.get_content_text()) > 100 else post.get_content_text() + content_preview = post.get_content_text() if notification_type == 'mention': self.notification_manager.notify_mention(sender, content_preview) @@ -167,6 +179,42 @@ class TimelineView(AccessibleTreeWidget): except Exception as e: print(f"Error parsing notification: {e}") continue + elif self.timeline_type in ["followers", "following"]: + # Handle followers/following data structure (account list) + for account_data in timeline_data: + try: + # Create a pseudo-post from account data for display + from models.user import User + user = User.from_api_dict(account_data) + + # Create a special Post-like object for accounts + class AccountDisplayPost: + def __init__(self, user): + self.id = user.id + self.account = user + self.in_reply_to_id = None + self.content = f"

@{user.username} - {user.display_name or user.username}

" + if user.note: + self.content += f"
{user.note}" + + def get_content_text(self): + return f"@{self.account.username} - {self.account.display_name or self.account.username}" + + def get_summary_for_screen_reader(self): + username = self.account.display_name or self.account.username + if self.account.note: + # Strip HTML tags from note + import re + note = re.sub('<[^<]+?>', '', self.account.note) + return f"{username} (@{self.account.username}): {note}" + return f"{username} (@{self.account.username})" + + account_post = AccountDisplayPost(user) + self.posts.append(account_post) + + except Exception as e: + print(f"Error parsing account: {e}") + continue else: # Handle regular timeline data structure new_posts = [] @@ -190,8 +238,11 @@ class TimelineView(AccessibleTreeWidget): # Use generic "new content" message instead of counting posts self.notification_manager.notify_new_content(timeline_name) - # Build thread structure - self.build_threaded_timeline() + # Build thread structure (accounts don't need threading) + if self.timeline_type in ["followers", "following"]: + self.build_account_list() + else: + self.build_threaded_timeline() def build_threaded_timeline(self): """Build threaded timeline from posts""" @@ -244,6 +295,16 @@ class TimelineView(AccessibleTreeWidget): if top_item.childCount() > 0: self.update_child_accessibility(top_item, False) + def build_account_list(self): + """Build simple list for followers/following accounts""" + for account_post in self.posts: + item = self.create_post_item(account_post) + self.addTopLevelItem(item) + + # Add "Load more" item if we have accounts + if self.posts: + self.add_load_more_item() + def find_thread_root(self, post, all_posts): """Find the root post ID for a given reply by walking up the chain""" current_post = post @@ -419,6 +480,20 @@ class TimelineView(AccessibleTreeWidget): limit=posts_per_page, max_id=self.oldest_post_id ) + elif self.timeline_type == "followers": + user_info = self.activitypub_client.verify_credentials() + more_data = self.activitypub_client.get_followers( + user_info['id'], + limit=posts_per_page, + max_id=self.oldest_post_id + ) + elif self.timeline_type == "following": + user_info = self.activitypub_client.verify_credentials() + more_data = self.activitypub_client.get_following( + user_info['id'], + limit=posts_per_page, + max_id=self.oldest_post_id + ) else: more_data = self.activitypub_client.get_timeline( self.timeline_type, @@ -595,6 +670,14 @@ class TimelineView(AccessibleTreeWidget): menu = QMenu(self) + # Get current user account for ownership checks + active_account = self.account_manager.get_active_account() + is_own_post = False + if active_account and hasattr(post, 'account'): + # Check if this is user's own post + is_own_post = (post.account.username == active_account.username and + post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', '')) + # Copy to clipboard action copy_action = QAction("&Copy to Clipboard", self) copy_action.setShortcut("Ctrl+C") @@ -634,8 +717,38 @@ class TimelineView(AccessibleTreeWidget): favorite_action.triggered.connect(lambda: self.favorite_requested.emit(post)) menu.addAction(favorite_action) + # Owner-only actions + if is_own_post: + menu.addSeparator() + + # Edit action + edit_action = QAction("&Edit Post", self) + edit_action.setShortcut("Ctrl+Shift+E") + edit_action.triggered.connect(lambda: self.edit_requested.emit(post)) + menu.addAction(edit_action) + + # Delete action + delete_action = QAction("&Delete Post", self) + delete_action.setShortcut("Shift+Delete") + delete_action.triggered.connect(lambda: self.delete_requested.emit(post)) + menu.addAction(delete_action) + menu.addSeparator() + # Follow/Unfollow actions for non-own posts + if not is_own_post and hasattr(post, 'account'): + # TODO: Check relationship status to show correct follow/unfollow option + # For now, show both - this will be improved when we add relationship checking + follow_action = QAction("&Follow User", self) + follow_action.setShortcut("Ctrl+Shift+F") + follow_action.triggered.connect(lambda: self.follow_requested.emit(post)) + menu.addAction(follow_action) + + unfollow_action = QAction("&Unfollow User", self) + unfollow_action.setShortcut("Ctrl+Shift+U") + unfollow_action.triggered.connect(lambda: self.unfollow_requested.emit(post)) + menu.addAction(unfollow_action) + # View profile action profile_action = QAction("View &Profile", self) profile_action.triggered.connect(lambda: self.profile_requested.emit(post))