Some cleanup, a couple new features added.

This commit is contained in:
Storm Dragon
2025-07-20 04:32:37 -04:00
parent 460dfc52a5
commit 8661fa67ce
6 changed files with 540 additions and 76 deletions

View File

@ -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

View File

@ -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

View File

@ -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"""

View File

@ -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"""

View File

@ -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()

View File

@ -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"""