diff --git a/CLAUDE.md b/CLAUDE.md index 32a445a..4f3dcf7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -197,16 +197,21 @@ Timeline Item: "Alice posted: Hello world (3 replies, collapsed)" ``` ### Key UI Components -- **Timeline View**: AccessibleTreeWidget showing posts and threads +- **Timeline View**: AccessibleTreeWidget showing posts and threads with pagination +- **Timeline Tabs**: Home, Mentions, Local, Federated timeline switching - **Compose Dialog**: Modal for creating posts with accessibility -- **Settings Dialog**: Sound pack selection, accessibility options +- **Settings Dialog**: Sound pack, desktop notifications, accessibility options - **Login Dialog**: Instance selection and authentication +- **URL Selection Dialog**: Choose from multiple URLs in posts +- **Context Menu**: Copy, URL opening, reply, boost, favorite actions ### Keyboard Shortcuts - **Ctrl+N**: New post - **Ctrl+R**: Reply to selected post - **Ctrl+B**: Boost selected post - **Ctrl+F**: Favorite selected post +- **Ctrl+C**: Copy selected post to clipboard +- **Ctrl+U**: Open URLs from selected post in browser - **F5**: Refresh timeline - **Ctrl+,**: Settings - **Escape**: Close dialogs @@ -281,10 +286,24 @@ mention_enabled = true mention_volume = 1.0 # ... other sound settings +[notifications] +enabled = true +direct_messages = true +mentions = true +boosts = false +favorites = false +follows = true +timeline_updates = false + +[timeline] +posts_per_page = 40 + [accessibility] announce_thread_state = true auto_expand_mentions = false keyboard_navigation_wrap = true +page_step_size = 5 +verbose_announcements = true ``` ### Account Storage diff --git a/README.md b/README.md index f219dbc..c82f138 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,36 @@ Bifrost includes a sophisticated sound system with: - **Plyer**: Cross-platform desktop notifications - **XDG Base Directory**: Standards-compliant configuration storage +## Keyboard Shortcuts + +### Timeline Navigation +- **Ctrl+1**: Switch to Home timeline +- **Ctrl+2**: Switch to Mentions/Notifications timeline +- **Ctrl+3**: Switch to Local timeline +- **Ctrl+4**: Switch to Federated timeline +- **Ctrl+Tab**: Switch between timeline tabs +- **F5**: Refresh current timeline + +### Post Actions +- **Ctrl+N**: Compose new post +- **Ctrl+R**: Reply to selected post +- **Ctrl+B**: Boost/reblog selected post +- **Ctrl+F**: Favorite selected post +- **Ctrl+C**: Copy selected post to clipboard +- **Ctrl+U**: Open URLs from selected post in browser + +### Navigation +- **Arrow Keys**: Navigate through posts +- **Page Up/Down**: Jump multiple posts +- **Home/End**: Go to first/last post +- **Enter**: Expand/collapse threads +- **Tab**: Move between interface elements + +### Application +- **Ctrl+,**: Open Settings +- **Ctrl+Shift+A**: Add new account +- **Ctrl+Q**: Quit application + ## Installation ```bash @@ -44,6 +74,12 @@ pip install -r requirements.txt python bifrost.py ``` +Or on Arch Linux: +```bash +sudo pacman -S python-pyside6 python-requests python-simpleaudio +yay -S python-plyer +``` + ## Accessibility Features - Complete keyboard navigation diff --git a/src/accessibility/accessible_tree.py b/src/accessibility/accessible_tree.py index de1e4e4..25aec6e 100644 --- a/src/accessibility/accessible_tree.py +++ b/src/accessibility/accessible_tree.py @@ -42,6 +42,27 @@ class AccessibleTreeWidget(QTreeWidget): super().keyPressEvent(event) return + # Handle Enter key for special items (like "Load more") + if key == Qt.Key_Return or key == Qt.Key_Enter: + special_data = current.data(0, Qt.UserRole) + if special_data == "load_more": + # Emit signal for load more action + if hasattr(self.parent(), 'load_more_posts'): + self.parent().load_more_posts() + return + + # Handle copy to clipboard shortcut + if key == Qt.Key_C and event.modifiers() & Qt.ControlModifier: + if hasattr(self.parent(), 'copy_post_to_clipboard'): + self.parent().copy_post_to_clipboard() + return + + # Handle open URLs shortcut + if key == Qt.Key_U and event.modifiers() & Qt.ControlModifier: + if hasattr(self.parent(), 'open_urls_in_browser'): + self.parent().open_urls_in_browser() + return + # Check for Shift modifier has_shift = event.modifiers() & Qt.ShiftModifier @@ -204,10 +225,27 @@ class AccessibleTreeWidget(QTreeWidget): def on_item_expanded(self, item: QTreeWidgetItem): """Handle item expansion""" self.announce_item_state(item, "expanded") + # Make child items accessible + self.update_child_accessibility(item, True) def on_item_collapsed(self, item: QTreeWidgetItem): """Handle item collapse""" self.announce_item_state(item, "collapsed") + # Hide child items from screen readers + self.update_child_accessibility(item, False) + + def update_child_accessibility(self, item: QTreeWidgetItem, visible: bool): + """Update accessibility properties of child items""" + for i in range(item.childCount()): + child = item.child(i) + if visible: + # Make child accessible + child.setFlags(child.flags() | Qt.ItemIsEnabled) + child.setData(0, Qt.AccessibleDescriptionRole, "") # Clear hidden marker + else: + # Hide from screen readers but keep in tree + child.setData(0, Qt.AccessibleDescriptionRole, "hidden") + # Don't disable completely as it affects navigation def announce_item_state(self, item: QTreeWidgetItem, state: str): """Announce item state change for screen readers""" diff --git a/src/main_window.py b/src/main_window.py index 48180b5..9f135c7 100644 --- a/src/main_window.py +++ b/src/main_window.py @@ -166,6 +166,41 @@ class MainWindow(QMainWindow): federated_action.triggered.connect(lambda: self.switch_timeline(3)) timeline_menu.addAction(federated_action) + # Post menu + post_menu = menubar.addMenu("&Post") + + # Reply action + reply_action = QAction("&Reply", self) + reply_action.setShortcut(QKeySequence("Ctrl+R")) + reply_action.triggered.connect(self.reply_to_current_post) + post_menu.addAction(reply_action) + + # Boost action + boost_action = QAction("&Boost", self) + boost_action.setShortcut(QKeySequence("Ctrl+B")) + boost_action.triggered.connect(self.boost_current_post) + post_menu.addAction(boost_action) + + # Favorite action + favorite_action = QAction("&Favorite", self) + favorite_action.setShortcut(QKeySequence("Ctrl+F")) + favorite_action.triggered.connect(self.favorite_current_post) + post_menu.addAction(favorite_action) + + post_menu.addSeparator() + + # Copy action + copy_action = QAction("&Copy to Clipboard", self) + copy_action.setShortcut(QKeySequence("Ctrl+C")) + copy_action.triggered.connect(self.copy_current_post) + post_menu.addAction(copy_action) + + # Open URLs action + urls_action = QAction("Open &URLs in Browser", self) + urls_action.setShortcut(QKeySequence("Ctrl+U")) + urls_action.triggered.connect(self.open_current_post_urls) + post_menu.addAction(urls_action) + def setup_shortcuts(self): """Set up keyboard shortcuts""" # Additional shortcuts that don't need menu items @@ -292,6 +327,53 @@ class MainWindow(QMainWindow): if hasattr(self.timeline, 'sound_manager'): self.timeline.sound_manager.play_error() self.status_bar.showMessage(f"Failed to load {timeline_name} timeline: {str(e)}", 3000) + + def get_selected_post(self): + """Get the currently selected post from timeline""" + current_item = self.timeline.currentItem() + if current_item: + return current_item.data(0, Qt.UserRole) + return None + + def reply_to_current_post(self): + """Reply to the currently selected post""" + post = self.get_selected_post() + if post: + self.reply_to_post(post) + else: + self.status_bar.showMessage("No post selected", 2000) + + def boost_current_post(self): + """Boost the currently selected post""" + post = self.get_selected_post() + if post: + self.boost_post(post) + else: + self.status_bar.showMessage("No post selected", 2000) + + def favorite_current_post(self): + """Favorite the currently selected post""" + post = self.get_selected_post() + if post: + self.favorite_post(post) + else: + self.status_bar.showMessage("No post selected", 2000) + + def copy_current_post(self): + """Copy the currently selected post to clipboard""" + post = self.get_selected_post() + if post: + self.timeline.copy_post_to_clipboard(post) + else: + self.status_bar.showMessage("No post selected", 2000) + + def open_current_post_urls(self): + """Open URLs from the currently selected post""" + post = self.get_selected_post() + if post: + self.timeline.open_urls_in_browser(post) + else: + self.status_bar.showMessage("No post selected", 2000) def show_first_time_setup(self): """Show first-time setup dialog""" diff --git a/src/widgets/settings_dialog.py b/src/widgets/settings_dialog.py index 71d2373..0d6cd05 100644 --- a/src/widgets/settings_dialog.py +++ b/src/widgets/settings_dialog.py @@ -204,6 +204,18 @@ class SettingsDialog(QDialog): sr_layout.addWidget(self.announce_thread_state) layout.addWidget(sr_group) + + # Timeline settings + timeline_group = QGroupBox("Timeline Settings") + timeline_layout = QFormLayout(timeline_group) + + self.posts_per_page = QSpinBox() + self.posts_per_page.setRange(10, 200) + self.posts_per_page.setAccessibleName("Posts Per Page") + self.posts_per_page.setAccessibleDescription("Number of posts to load at once in timeline") + timeline_layout.addRow("Posts per page:", self.posts_per_page) + + layout.addWidget(timeline_group) layout.addStretch() self.tabs.addTab(accessibility_widget, "A&ccessibility") @@ -270,6 +282,9 @@ class SettingsDialog(QDialog): self.verbose_announcements.setChecked(bool(self.settings.get('accessibility', 'verbose_announcements', True))) self.announce_thread_state.setChecked(bool(self.settings.get('accessibility', 'announce_thread_state', True))) + # Timeline settings + self.posts_per_page.setValue(int(self.settings.get('timeline', 'posts_per_page', 40) or 40)) + def apply_settings(self): """Apply the current settings without closing the dialog""" # Audio settings @@ -296,6 +311,9 @@ class SettingsDialog(QDialog): self.settings.set('accessibility', 'verbose_announcements', self.verbose_announcements.isChecked()) self.settings.set('accessibility', 'announce_thread_state', self.announce_thread_state.isChecked()) + # Timeline settings + self.settings.set('timeline', 'posts_per_page', self.posts_per_page.value()) + # Save to file self.settings.save_settings() diff --git a/src/widgets/timeline_view.py b/src/widgets/timeline_view.py index acaea8f..8ac66f9 100644 --- a/src/widgets/timeline_view.py +++ b/src/widgets/timeline_view.py @@ -2,10 +2,12 @@ Timeline view widget for displaying posts and threads """ -from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem, QHeaderView, QMenu +from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem, QHeaderView, QMenu, QDialog, QVBoxLayout, QListWidget, QDialogButtonBox, QLabel from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QAction -from typing import Optional +from PySide6.QtGui import QAction, QClipboard +from typing import Optional, List +import re +import webbrowser from accessibility.accessible_tree import AccessibleTreeWidget from audio.sound_manager import SoundManager @@ -34,6 +36,7 @@ class TimelineView(AccessibleTreeWidget): self.account_manager = account_manager self.activitypub_client = None self.posts = [] # Store loaded posts + self.oldest_post_id = None # Track for pagination self.setup_ui() self.refresh() @@ -86,12 +89,19 @@ class TimelineView(AccessibleTreeWidget): ) try: + # Get posts per page from settings + posts_per_page = int(self.settings.get('timeline', 'posts_per_page', 40) or 40) + # Fetch timeline or notifications if self.timeline_type == "notifications": - timeline_data = self.activitypub_client.get_notifications(limit=20) + timeline_data = self.activitypub_client.get_notifications(limit=posts_per_page) else: - timeline_data = self.activitypub_client.get_timeline(self.timeline_type, limit=20) + timeline_data = self.activitypub_client.get_timeline(self.timeline_type, limit=posts_per_page) self.load_timeline_data(timeline_data) + + # Track oldest post for pagination + if timeline_data: + self.oldest_post_id = timeline_data[-1]['id'] except Exception as e: print(f"Failed to fetch timeline: {e}") # Show error message instead of sample data @@ -158,31 +168,80 @@ class TimelineView(AccessibleTreeWidget): def build_threaded_timeline(self): """Build threaded timeline from posts""" - # Group posts by conversation - conversations = {} - top_level_posts = [] + # Find thread roots and flatten all replies under them + thread_roots = {} # Maps thread root ID to list of all posts in thread + orphaned_posts = [] # Posts that couldn't find their thread root + # First pass: identify thread roots (posts with no in_reply_to_id) + for post in self.posts: + if not post.in_reply_to_id: + thread_roots[post.id] = [post] + + # Second pass: assign all replies to their thread root for post in self.posts: if post.in_reply_to_id: - # This is a reply - if post.in_reply_to_id not in conversations: - conversations[post.in_reply_to_id] = [] - conversations[post.in_reply_to_id].append(post) - else: - # This is a top-level post - top_level_posts.append(post) - - # Create tree items - for post in top_level_posts: - post_item = self.create_post_item(post) - self.addTopLevelItem(post_item) + # Find the thread root for this reply + root_id = self.find_thread_root(post, self.posts) + if root_id and root_id in thread_roots: + thread_roots[root_id].append(post) + else: + # Can't find thread root, treat as orphaned + orphaned_posts.append(post) + + # Create tree items - one root with all replies as direct children + for root_id, thread_posts in thread_roots.items(): + root_post = thread_posts[0] # First post is always the root + root_item = self.create_post_item(root_post) + self.addTopLevelItem(root_item) - # Add replies if any - if post.id in conversations: - self.add_replies(post_item, conversations[post.id], conversations) + # Add all other posts in thread as direct children (flattened) + for post in thread_posts[1:]: + reply_item = self.create_post_item(post) + reply_item.setData(0, Qt.UserRole + 1, post.in_reply_to_id) # Store what this replies to + root_item.addChild(reply_item) + + # Add orphaned posts as top-level items + for post in orphaned_posts: + orphaned_item = self.create_post_item(post) + self.addTopLevelItem(orphaned_item) - # Collapse all initially + # Add "Load more posts" item if we have posts + if self.posts: + self.add_load_more_item() + + # Collapse all initially and update accessibility self.collapseAll() + # Ensure collapsed items are properly marked for screen readers + for i in range(self.topLevelItemCount()): + top_item = self.topLevelItem(i) + if top_item.childCount() > 0: + self.update_child_accessibility(top_item, False) + + 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 + visited = set() # Prevent infinite loops + + while current_post and current_post.in_reply_to_id and current_post.id not in visited: + visited.add(current_post.id) + # Find the parent post + parent_post = None + for p in all_posts: + if p.id == current_post.in_reply_to_id: + parent_post = p + break + + if parent_post: + if not parent_post.in_reply_to_id: + # Found the root + return parent_post.id + current_post = parent_post + else: + # Parent not found, current post becomes root + break + + # If we couldn't find a proper root, use the post's direct parent + return post.in_reply_to_id def create_post_item(self, post: Post) -> QTreeWidgetItem: """Create a tree item for a post""" @@ -196,15 +255,269 @@ class TimelineView(AccessibleTreeWidget): return item - def add_replies(self, parent_item: QTreeWidgetItem, replies, all_conversations): - """Recursively add replies to a post""" - for reply in replies: - reply_item = self.create_post_item(reply) - parent_item.addChild(reply_item) + def copy_post_to_clipboard(self, post: Optional[Post] = None): + """Copy the selected post's content to clipboard""" + if not post: + post = self.get_selected_post() + + if not post: + return - # Add nested replies - if reply.id in all_conversations: - self.add_replies(reply_item, all_conversations[reply.id], all_conversations) + # Get the full post content + content = post.get_content_text() + author = post.get_display_name() + + # Format for clipboard + clipboard_text = f"{author}:\n{content}" + + # Copy to clipboard + from PySide6.QtWidgets import QApplication + clipboard = QApplication.clipboard() + clipboard.setText(clipboard_text) + + # Show feedback + if hasattr(self, 'parent') and hasattr(self.parent(), 'status_bar'): + self.parent().status_bar.showMessage("Post copied to clipboard", 2000) + + def extract_urls_from_post(self, post: Optional[Post] = None) -> List[str]: + """Extract URLs from post content""" + if not post: + post = self.get_selected_post() + + if not post: + return [] + + content = post.get_content_text() + + # URL regex pattern - matches http/https URLs, more comprehensive + url_pattern = r'https?://[^\s<>"\'`\)\]\}]+' + urls = re.findall(url_pattern, content) + + # Also check the original HTML content for href attributes + html_content = post.content if hasattr(post, 'content') else "" + href_pattern = r'href=["\']([^"\']+)["\']' + href_urls = re.findall(href_pattern, html_content) + + # Combine and filter URLs + all_urls = urls + href_urls + filtered_urls = [] + for url in all_urls: + if url.startswith(('http://', 'https://')): + filtered_urls.append(url) + + return list(set(filtered_urls)) # Remove duplicates + + def open_urls_in_browser(self, post: Optional[Post] = None): + """Open URLs from post in browser""" + urls = self.extract_urls_from_post(post) + + if not urls: + if hasattr(self, 'parent') and hasattr(self.parent(), 'status_bar'): + self.parent().status_bar.showMessage("No URLs found in post", 2000) + return + + if len(urls) == 1: + # Single URL - open directly + try: + webbrowser.open(urls[0]) + if hasattr(self, 'parent') and hasattr(self.parent(), 'status_bar'): + self.parent().status_bar.showMessage(f"Opened URL in browser", 2000) + except Exception as e: + if hasattr(self, 'parent') and hasattr(self.parent(), 'status_bar'): + self.parent().status_bar.showMessage(f"Failed to open URL: {str(e)}", 3000) + else: + # Multiple URLs - show selection dialog + self.show_url_selection_dialog(urls) + + def show_url_selection_dialog(self, urls: List[str]): + """Show dialog to select which URL to open""" + dialog = QDialog(self) + dialog.setWindowTitle("Select URL to Open") + dialog.setMinimumSize(500, 300) + dialog.setModal(True) + + layout = QVBoxLayout(dialog) + + # Label + label = QLabel(f"Found {len(urls)} URLs in this post. Select one to open:") + label.setAccessibleName("URL Selection") + layout.addWidget(label) + + # URL list + url_list = QListWidget() + url_list.setAccessibleName("URL List") + for url in urls: + url_list.addItem(url) + url_list.setCurrentRow(0) # Select first item + layout.addWidget(url_list) + + # Buttons + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + button_box.accepted.connect(dialog.accept) + button_box.rejected.connect(dialog.reject) + layout.addWidget(button_box) + + # Show dialog + if dialog.exec() == QDialog.Accepted: + current_item = url_list.currentItem() + if current_item: + selected_url = current_item.text() + try: + webbrowser.open(selected_url) + if hasattr(self, 'parent') and hasattr(self.parent(), 'status_bar'): + self.parent().status_bar.showMessage(f"Opened URL in browser", 2000) + except Exception as e: + if hasattr(self, 'parent') and hasattr(self.parent(), 'status_bar'): + self.parent().status_bar.showMessage(f"Failed to open URL: {str(e)}", 3000) + + def add_load_more_item(self): + """Add a 'Load more posts' item at the bottom of the timeline""" + load_more_item = QTreeWidgetItem(["Load more posts (Press Enter)"]) + load_more_item.setData(0, Qt.UserRole, "load_more") # Special marker + load_more_item.setData(0, Qt.AccessibleTextRole, "Load more posts from timeline") + self.addTopLevelItem(load_more_item) + + def load_more_posts(self): + """Load more posts from the current timeline""" + if not self.activitypub_client or not self.oldest_post_id: + return + + try: + # Get posts per page from settings + posts_per_page = int(self.settings.get('timeline', 'posts_per_page', 40) or 40) + + # Fetch more posts using max_id for pagination + if self.timeline_type == "notifications": + more_data = self.activitypub_client.get_notifications( + limit=posts_per_page, + max_id=self.oldest_post_id + ) + else: + more_data = self.activitypub_client.get_timeline( + self.timeline_type, + limit=posts_per_page, + max_id=self.oldest_post_id + ) + + if more_data: + # Remove current "Load more" item + self.remove_load_more_item() + + # Add new posts to existing list + self.load_additional_timeline_data(more_data) + + # Update oldest post ID + self.oldest_post_id = more_data[-1]['id'] + + # Rebuild timeline with all posts + self.build_threaded_timeline() + + self.status_bar.showMessage(f"Loaded {len(more_data)} more posts", 2000) + else: + self.status_bar.showMessage("No more posts to load", 2000) + + except Exception as e: + print(f"Failed to load more posts: {e}") + self.status_bar.showMessage(f"Failed to load more posts: {str(e)}", 3000) + + def remove_load_more_item(self): + """Remove the 'Load more posts' item""" + for i in range(self.topLevelItemCount()): + item = self.topLevelItem(i) + if item.data(0, Qt.UserRole) == "load_more": + self.takeTopLevelItem(i) + break + + def load_additional_timeline_data(self, timeline_data): + """Load additional timeline data and append to existing posts""" + if self.timeline_type == "notifications": + # Handle notifications data structure + for notification_data in timeline_data: + try: + notification_type = notification_data['type'] + sender = notification_data['account']['display_name'] or notification_data['account']['username'] + + # Notifications with status (mentions, boosts, favorites) + if 'status' in notification_data: + post = Post.from_api_dict(notification_data['status']) + post.notification_type = notification_type + post.notification_account = notification_data['account']['acct'] + self.posts.append(post) + elif notification_type == 'follow': + # Handle follow notifications without status + pass # Could create a special post type for follows + except Exception as e: + print(f"Error parsing notification: {e}") + continue + else: + # Handle regular timeline data structure + for status_data in timeline_data: + try: + post = Post.from_api_dict(status_data) + self.posts.append(post) + except Exception as e: + print(f"Error parsing post: {e}") + continue + + def show_context_menu(self, position): + """Show context menu for the selected post""" + item = self.itemAt(position) + if not item: + return + + post = item.data(0, Qt.UserRole) + if not post or post == "load_more": + return + + menu = QMenu(self) + + # Copy to clipboard action + copy_action = QAction("&Copy to Clipboard", self) + copy_action.setShortcut("Ctrl+C") + copy_action.triggered.connect(lambda: self.copy_post_to_clipboard(post)) + menu.addAction(copy_action) + + # Open URLs action + urls = self.extract_urls_from_post(post) + if urls: + if len(urls) == 1: + url_action = QAction("&Open URL in Browser", self) + else: + url_action = QAction(f"Open &URLs in Browser ({len(urls)} found)", self) + url_action.setShortcut("Ctrl+U") + url_action.triggered.connect(lambda: self.open_urls_in_browser(post)) + menu.addAction(url_action) + + menu.addSeparator() + + # Reply action + reply_action = QAction("&Reply", self) + reply_action.setShortcut("Ctrl+R") + reply_action.triggered.connect(lambda: self.reply_requested.emit(post)) + menu.addAction(reply_action) + + # Boost action + boost_text = "Un&boost" if getattr(post, 'reblogged', False) else "&Boost" + boost_action = QAction(boost_text, self) + boost_action.setShortcut("Ctrl+B") + boost_action.triggered.connect(lambda: self.boost_requested.emit(post)) + menu.addAction(boost_action) + + # Favorite action + fav_text = "Un&favorite" if getattr(post, 'favourited', False) else "&Favorite" + favorite_action = QAction(fav_text, self) + favorite_action.setShortcut("Ctrl+F") + favorite_action.triggered.connect(lambda: self.favorite_requested.emit(post)) + menu.addAction(favorite_action) + + menu.addSeparator() + + # View profile action + profile_action = QAction("View &Profile", self) + profile_action.triggered.connect(lambda: self.profile_requested.emit(post)) + menu.addAction(profile_action) + + menu.exec(self.mapToGlobal(position)) def show_empty_message(self, message: str): """Show an empty timeline with a message""" @@ -249,48 +562,6 @@ class TimelineView(AccessibleTreeWidget): if posts: self.sound_manager.play_timeline_update() - def show_context_menu(self, position): - """Show context menu for post actions""" - item = self.itemAt(position) - if not item: - return - - post = item.data(0, Qt.UserRole) - if not post: - return - - menu = QMenu(self) - - # Reply action - reply_action = QAction("&Reply", self) - reply_action.setStatusTip("Reply to this post") - reply_action.triggered.connect(lambda: self.reply_requested.emit(post)) - menu.addAction(reply_action) - - # Boost action - boost_text = "Un&boost" if post.reblogged else "&Boost" - boost_action = QAction(boost_text, self) - boost_action.setStatusTip(f"{boost_text.replace('&', '')} this post") - boost_action.triggered.connect(lambda: self.boost_requested.emit(post)) - menu.addAction(boost_action) - - # Favorite action - fav_text = "&Unfavorite" if post.favourited else "&Favorite" - fav_action = QAction(fav_text, self) - fav_action.setStatusTip(f"{fav_text.replace('&', '')} this post") - fav_action.triggered.connect(lambda: self.favorite_requested.emit(post)) - menu.addAction(fav_action) - - menu.addSeparator() - - # View profile action - profile_action = QAction("View &Profile", self) - profile_action.setStatusTip(f"View profile of {post.account.display_name}") - profile_action.triggered.connect(lambda: self.profile_requested.emit(post)) - menu.addAction(profile_action) - - # Show menu - menu.exec(self.mapToGlobal(position)) def announce_current_item(self): """Announce the current item for screen readers"""