Fix context menu accessibility, conversation replies, and integrate polls into post details
This commit addresses several critical accessibility and functionality issues: - Fix context menu keyboard shortcuts (Applications key and Shift+F10) in messages tab - Resolve 404 errors when replying to private message conversations by implementing separate conversation reply handling - Restore Enter key functionality for viewing post details - Integrate poll voting into post details dialog as first tab instead of separate dialog - Fix accessibility issues with poll display using QTextEdit and accessible list patterns - Add comprehensive accessibility guidelines to CLAUDE.md covering widget choices, list patterns, and context menu support - Update README.md with new features including context menu shortcuts, poll integration, and accessibility improvements 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@ -3,9 +3,21 @@ Post details dialog showing favorites, boosts, and other interaction details
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QTextEdit,
|
||||
QTabWidget, QListWidget, QListWidgetItem, QDialogButtonBox,
|
||||
QWidget, QGroupBox, QPushButton
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QTextEdit,
|
||||
QTabWidget,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QDialogButtonBox,
|
||||
QWidget,
|
||||
QGroupBox,
|
||||
QPushButton,
|
||||
QCheckBox,
|
||||
QRadioButton,
|
||||
QButtonGroup,
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal, QThread
|
||||
from PySide6.QtGui import QFont
|
||||
@ -19,100 +31,89 @@ from audio.sound_manager import SoundManager
|
||||
|
||||
class FetchDetailsThread(QThread):
|
||||
"""Background thread for fetching post interaction details"""
|
||||
|
||||
|
||||
details_loaded = Signal(dict) # Emitted with details data
|
||||
details_failed = Signal(str) # Emitted with error message
|
||||
|
||||
details_failed = Signal(str) # Emitted with error message
|
||||
|
||||
def __init__(self, client: ActivityPubClient, post_id: str):
|
||||
super().__init__()
|
||||
self.client = client
|
||||
self.post_id = post_id
|
||||
self.logger = logging.getLogger('bifrost.post_details')
|
||||
|
||||
self.logger = logging.getLogger("bifrost.post_details")
|
||||
|
||||
def run(self):
|
||||
"""Fetch favorites and boosts in background"""
|
||||
try:
|
||||
details = {
|
||||
'favourited_by': [],
|
||||
'reblogged_by': []
|
||||
}
|
||||
|
||||
details = {"favourited_by": [], "reblogged_by": []}
|
||||
|
||||
# Fetch who favorited this post
|
||||
try:
|
||||
favourited_by_data = self.client.get_status_favourited_by(self.post_id)
|
||||
details['favourited_by'] = favourited_by_data
|
||||
details["favourited_by"] = favourited_by_data
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to fetch favorites: {e}")
|
||||
|
||||
# Fetch who boosted this post
|
||||
|
||||
# Fetch who boosted this post
|
||||
try:
|
||||
reblogged_by_data = self.client.get_status_reblogged_by(self.post_id)
|
||||
details['reblogged_by'] = reblogged_by_data
|
||||
details["reblogged_by"] = reblogged_by_data
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to fetch boosts: {e}")
|
||||
|
||||
|
||||
self.details_loaded.emit(details)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.details_failed.emit(str(e))
|
||||
|
||||
|
||||
class PostDetailsDialog(QDialog):
|
||||
"""Dialog showing detailed post interaction information"""
|
||||
|
||||
def __init__(self, post, client: ActivityPubClient, sound_manager: SoundManager, parent=None):
|
||||
|
||||
vote_submitted = Signal(
|
||||
object, list
|
||||
) # Emitted with post and list of selected choice indices
|
||||
|
||||
def __init__(
|
||||
self, post, client: ActivityPubClient, sound_manager: SoundManager, parent=None
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.post = post
|
||||
self.client = client
|
||||
self.sound_manager = sound_manager
|
||||
self.logger = logging.getLogger('bifrost.post_details')
|
||||
|
||||
self.logger = logging.getLogger("bifrost.post_details")
|
||||
|
||||
self.setWindowTitle("Post Details")
|
||||
self.setModal(True)
|
||||
self.resize(600, 500)
|
||||
|
||||
|
||||
self.setup_ui()
|
||||
self.load_details()
|
||||
|
||||
|
||||
def setup_ui(self):
|
||||
"""Setup the post details UI"""
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Post content section
|
||||
content_group = QGroupBox("Post Content")
|
||||
content_group.setAccessibleName("Post Content")
|
||||
content_layout = QVBoxLayout(content_group)
|
||||
|
||||
# Author info
|
||||
author_label = QLabel(f"@{self.post.account.username} ({self.post.account.display_name or self.post.account.username})")
|
||||
author_label.setAccessibleName("Post Author")
|
||||
author_font = QFont()
|
||||
author_font.setBold(True)
|
||||
author_label.setFont(author_font)
|
||||
content_layout.addWidget(author_label)
|
||||
|
||||
# Post content
|
||||
content_text = QTextEdit()
|
||||
content_text.setAccessibleName("Post Content")
|
||||
content_text.setPlainText(self.post.get_content_text())
|
||||
content_text.setReadOnly(True)
|
||||
content_text.setMaximumHeight(100)
|
||||
# Enable keyboard navigation in read-only text
|
||||
content_text.setTextInteractionFlags(Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse)
|
||||
content_layout.addWidget(content_text)
|
||||
|
||||
# Stats
|
||||
|
||||
# Post statistics (keep just the basic stats at top)
|
||||
stats_text = f"Replies: {self.post.replies_count} | Boosts: {self.post.reblogs_count} | Favorites: {self.post.favourites_count}"
|
||||
stats_label = QLabel(stats_text)
|
||||
stats_label.setAccessibleName("Post Statistics")
|
||||
content_layout.addWidget(stats_label)
|
||||
|
||||
layout.addWidget(content_group)
|
||||
|
||||
layout.addWidget(stats_label)
|
||||
|
||||
# Tabs for interaction details
|
||||
self.tabs = QTabWidget()
|
||||
self.tabs.setAccessibleName("Interaction Details")
|
||||
|
||||
|
||||
# Poll tab (if poll exists) - add as first tab
|
||||
if hasattr(self.post, "poll") and self.post.poll:
|
||||
self.poll_widget = self.create_poll_widget()
|
||||
poll_tab_index = self.tabs.addTab(self.poll_widget, "Poll")
|
||||
self.logger.debug(f"Added poll tab at index {poll_tab_index}")
|
||||
|
||||
# Content tab - always present
|
||||
self.content_widget = self.create_content_widget()
|
||||
content_tab_index = self.tabs.addTab(self.content_widget, "Content")
|
||||
self.logger.debug(f"Added content tab at index {content_tab_index}")
|
||||
|
||||
# Favorites tab
|
||||
self.favorites_list = QListWidget()
|
||||
self.favorites_list.setAccessibleName("Users Who Favorited")
|
||||
@ -120,8 +121,10 @@ class PostDetailsDialog(QDialog):
|
||||
fake_header = QListWidgetItem("Users who favorited this post:")
|
||||
fake_header.setFlags(Qt.ItemIsEnabled) # Not selectable
|
||||
self.favorites_list.addItem(fake_header)
|
||||
self.tabs.addTab(self.favorites_list, f"Favorites ({self.post.favourites_count})")
|
||||
|
||||
self.tabs.addTab(
|
||||
self.favorites_list, f"Favorites ({self.post.favourites_count})"
|
||||
)
|
||||
|
||||
# Boosts tab
|
||||
self.boosts_list = QListWidget()
|
||||
self.boosts_list.setAccessibleName("Users Who Boosted")
|
||||
@ -130,45 +133,45 @@ class PostDetailsDialog(QDialog):
|
||||
fake_header.setFlags(Qt.ItemIsEnabled) # Not selectable
|
||||
self.boosts_list.addItem(fake_header)
|
||||
self.tabs.addTab(self.boosts_list, f"Boosts ({self.post.reblogs_count})")
|
||||
|
||||
|
||||
layout.addWidget(self.tabs)
|
||||
|
||||
|
||||
# Loading indicator
|
||||
self.status_label = QLabel("Loading interaction details...")
|
||||
self.status_label.setAccessibleName("Loading Status")
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
|
||||
# Button box
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.Close)
|
||||
button_box.setAccessibleName("Dialog Buttons")
|
||||
button_box.rejected.connect(self.reject)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
|
||||
def load_details(self):
|
||||
"""Load detailed interaction information"""
|
||||
if not self.client or not hasattr(self.post, 'id'):
|
||||
if not self.client or not hasattr(self.post, "id"):
|
||||
self.status_label.setText("Cannot load details: No post ID or API client")
|
||||
return
|
||||
|
||||
|
||||
# Start background fetch
|
||||
self.fetch_thread = FetchDetailsThread(self.client, self.post.id)
|
||||
self.fetch_thread.details_loaded.connect(self.on_details_loaded)
|
||||
self.fetch_thread.details_failed.connect(self.on_details_failed)
|
||||
self.fetch_thread.start()
|
||||
|
||||
|
||||
def on_details_loaded(self, details: dict):
|
||||
"""Handle successful details loading"""
|
||||
self.status_label.setText("")
|
||||
|
||||
|
||||
# Populate favorites list
|
||||
favourited_by = details.get('favourited_by', [])
|
||||
favourited_by = details.get("favourited_by", [])
|
||||
if favourited_by:
|
||||
for account_data in favourited_by:
|
||||
try:
|
||||
user = User.from_api_dict(account_data)
|
||||
display_name = user.display_name or user.username
|
||||
item_text = f"@{user.username} ({display_name})"
|
||||
|
||||
|
||||
item = QListWidgetItem(item_text)
|
||||
item.setData(Qt.UserRole, user)
|
||||
self.favorites_list.addItem(item)
|
||||
@ -177,16 +180,16 @@ class PostDetailsDialog(QDialog):
|
||||
else:
|
||||
item = QListWidgetItem("No one has favorited this post yet")
|
||||
self.favorites_list.addItem(item)
|
||||
|
||||
|
||||
# Populate boosts list
|
||||
reblogged_by = details.get('reblogged_by', [])
|
||||
reblogged_by = details.get("reblogged_by", [])
|
||||
if reblogged_by:
|
||||
for account_data in reblogged_by:
|
||||
try:
|
||||
user = User.from_api_dict(account_data)
|
||||
display_name = user.display_name or user.username
|
||||
item_text = f"@{user.username} ({display_name})"
|
||||
|
||||
|
||||
item = QListWidgetItem(item_text)
|
||||
item.setData(Qt.UserRole, user)
|
||||
self.boosts_list.addItem(item)
|
||||
@ -195,26 +198,241 @@ class PostDetailsDialog(QDialog):
|
||||
else:
|
||||
item = QListWidgetItem("No one has boosted this post yet")
|
||||
self.boosts_list.addItem(item)
|
||||
|
||||
|
||||
# Update tab titles with actual counts
|
||||
actual_favorites = len(favourited_by)
|
||||
actual_boosts = len(reblogged_by)
|
||||
self.tabs.setTabText(0, f"Favorites ({actual_favorites})")
|
||||
self.tabs.setTabText(1, f"Boosts ({actual_boosts})")
|
||||
|
||||
|
||||
# Account for poll tab if it exists
|
||||
# Tab order: Poll (if exists), Content, Favorites, Boosts
|
||||
has_poll = hasattr(self.post, "poll") and self.post.poll
|
||||
favorites_tab_index = 2 if has_poll else 1
|
||||
boosts_tab_index = 3 if has_poll else 2
|
||||
|
||||
self.tabs.setTabText(favorites_tab_index, f"Favorites ({actual_favorites})")
|
||||
self.tabs.setTabText(boosts_tab_index, f"Boosts ({actual_boosts})")
|
||||
|
||||
# Play success sound
|
||||
self.sound_manager.play_success()
|
||||
|
||||
|
||||
def on_details_failed(self, error_message: str):
|
||||
"""Handle details loading failure"""
|
||||
self.status_label.setText(f"Failed to load details: {error_message}")
|
||||
|
||||
|
||||
# Add error items to lists
|
||||
error_item_fav = QListWidgetItem(f"Error loading favorites: {error_message}")
|
||||
self.favorites_list.addItem(error_item_fav)
|
||||
|
||||
|
||||
error_item_boost = QListWidgetItem(f"Error loading boosts: {error_message}")
|
||||
self.boosts_list.addItem(error_item_boost)
|
||||
|
||||
|
||||
# Play error sound
|
||||
self.sound_manager.play_error()
|
||||
self.sound_manager.play_error()
|
||||
|
||||
def create_poll_widget(self):
|
||||
"""Create poll voting widget for the poll tab"""
|
||||
poll_widget = QWidget()
|
||||
poll_layout = QVBoxLayout(poll_widget)
|
||||
|
||||
poll_data = self.post.poll
|
||||
|
||||
# Poll question (if exists)
|
||||
if "question" in poll_data and poll_data["question"]:
|
||||
question_label = QLabel(f"Question: {poll_data['question']}")
|
||||
question_label.setAccessibleName("Poll Question")
|
||||
question_label.setWordWrap(True)
|
||||
poll_layout.addWidget(question_label)
|
||||
|
||||
# Check if user can still vote
|
||||
can_vote = not poll_data.get("voted", False) and not poll_data.get(
|
||||
"expired", False
|
||||
)
|
||||
|
||||
if can_vote:
|
||||
# Show interactive voting interface
|
||||
self.poll_results_list = self.create_interactive_poll_widget(
|
||||
poll_data, poll_layout
|
||||
)
|
||||
else:
|
||||
# Show results as accessible list (like favorites/boosts)
|
||||
self.poll_results_list = self.create_poll_results_list(poll_data)
|
||||
poll_layout.addWidget(self.poll_results_list)
|
||||
|
||||
# Poll info
|
||||
info_text = []
|
||||
if "expires_at" in poll_data and poll_data["expires_at"]:
|
||||
info_text.append(f"Expires: {poll_data['expires_at']}")
|
||||
if "voters_count" in poll_data:
|
||||
info_text.append(f"Total voters: {poll_data['voters_count']}")
|
||||
|
||||
if info_text:
|
||||
info_label = QLabel(" | ".join(info_text))
|
||||
info_label.setAccessibleName("Poll Information")
|
||||
poll_layout.addWidget(info_label)
|
||||
|
||||
# Status message
|
||||
if poll_data.get("voted", False):
|
||||
voted_label = QLabel("✓ You have already voted in this poll")
|
||||
voted_label.setAccessibleName("Vote Status")
|
||||
poll_layout.addWidget(voted_label)
|
||||
elif poll_data.get("expired", False):
|
||||
expired_label = QLabel("This poll has expired")
|
||||
expired_label.setAccessibleName("Poll Status")
|
||||
poll_layout.addWidget(expired_label)
|
||||
|
||||
return poll_widget
|
||||
|
||||
def create_poll_results_list(self, poll_data):
|
||||
"""Create accessible list widget for poll results (expired/voted polls)"""
|
||||
results_list = QListWidget()
|
||||
results_list.setAccessibleName("Poll Results")
|
||||
|
||||
# Add fake header for single-item navigation (like favorites/boosts)
|
||||
fake_header = QListWidgetItem("Poll results:")
|
||||
fake_header.setFlags(Qt.ItemIsEnabled) # Not selectable
|
||||
results_list.addItem(fake_header)
|
||||
|
||||
# Add poll options with results
|
||||
options = poll_data.get("options", [])
|
||||
own_votes = poll_data.get("own_votes", [])
|
||||
|
||||
for i, option in enumerate(options):
|
||||
vote_count = option.get("votes_count", 0)
|
||||
option_title = option.get("title", f"Option {i + 1}")
|
||||
|
||||
# Mark user's votes
|
||||
vote_indicator = " ✓" if i in own_votes else ""
|
||||
option_text = f"{option_title}: {vote_count} votes{vote_indicator}"
|
||||
|
||||
item = QListWidgetItem(option_text)
|
||||
item.setData(Qt.UserRole, {"option_index": i, "option_data": option})
|
||||
results_list.addItem(item)
|
||||
|
||||
return results_list
|
||||
|
||||
def create_interactive_poll_widget(self, poll_data, poll_layout):
|
||||
"""Create interactive poll widget for active polls"""
|
||||
# Poll options group
|
||||
options_group = QGroupBox("Poll Options")
|
||||
options_group.setAccessibleName("Poll Options")
|
||||
options_layout = QVBoxLayout(options_group)
|
||||
|
||||
self.poll_option_widgets = []
|
||||
self.poll_button_group = None
|
||||
|
||||
# Check if poll allows multiple choices
|
||||
multiple_choice = poll_data.get("multiple", False)
|
||||
|
||||
if not multiple_choice:
|
||||
# Single choice - use radio buttons
|
||||
self.poll_button_group = QButtonGroup()
|
||||
|
||||
# Add options
|
||||
options = poll_data.get("options", [])
|
||||
|
||||
for i, option in enumerate(options):
|
||||
vote_count = option.get("votes_count", 0)
|
||||
option_title = option.get("title", f"Option {i + 1}")
|
||||
option_text = f"{option_title} ({vote_count} votes)"
|
||||
|
||||
if multiple_choice:
|
||||
# Multiple choice - use checkboxes
|
||||
option_widget = QCheckBox(option_text)
|
||||
else:
|
||||
# Single choice - use radio buttons
|
||||
option_widget = QRadioButton(option_text)
|
||||
self.poll_button_group.addButton(option_widget, i)
|
||||
|
||||
option_widget.setAccessibleName(f"Poll Option {i + 1}")
|
||||
self.poll_option_widgets.append(option_widget)
|
||||
options_layout.addWidget(option_widget)
|
||||
|
||||
poll_layout.addWidget(options_group)
|
||||
|
||||
# Vote button
|
||||
vote_button = QPushButton("Submit Vote")
|
||||
vote_button.setAccessibleName("Submit Poll Vote")
|
||||
vote_button.clicked.connect(self.submit_poll_vote)
|
||||
poll_layout.addWidget(vote_button)
|
||||
|
||||
return None # No list widget for interactive polls
|
||||
|
||||
def create_content_widget(self):
|
||||
"""Create content widget for the content tab"""
|
||||
content_widget = QWidget()
|
||||
content_layout = QVBoxLayout(content_widget)
|
||||
|
||||
# Create comprehensive post content with all details in accessible text box
|
||||
content_parts = []
|
||||
|
||||
# Author info
|
||||
if hasattr(self.post, "account") and self.post.account:
|
||||
username = getattr(self.post.account, "username", "unknown")
|
||||
display_name = getattr(self.post.account, "display_name", "") or username
|
||||
content_parts.append(f"Author: @{username} ({display_name})")
|
||||
else:
|
||||
content_parts.append("Author: Information not available")
|
||||
|
||||
content_parts.append("") # Empty line separator
|
||||
|
||||
# Post content
|
||||
post_content = self.post.get_content_text()
|
||||
if post_content.strip():
|
||||
content_parts.append("Content:")
|
||||
content_parts.append(post_content)
|
||||
else:
|
||||
content_parts.append("Content: (No text content)")
|
||||
|
||||
content_parts.append("") # Empty line separator
|
||||
|
||||
# Post metadata
|
||||
metadata_parts = []
|
||||
if hasattr(self.post, "created_at") and self.post.created_at:
|
||||
metadata_parts.append(f"Posted: {self.post.created_at}")
|
||||
if hasattr(self.post, "visibility") and self.post.visibility:
|
||||
metadata_parts.append(f"Visibility: {self.post.visibility}")
|
||||
if hasattr(self.post, "language") and self.post.language:
|
||||
metadata_parts.append(f"Language: {self.post.language}")
|
||||
|
||||
if metadata_parts:
|
||||
content_parts.append("Post Details:")
|
||||
content_parts.extend(metadata_parts)
|
||||
|
||||
# Combine all parts into one accessible text widget
|
||||
full_content = "\n".join(content_parts)
|
||||
|
||||
# Post content text (scrollable) with all details
|
||||
content_text = QTextEdit()
|
||||
content_text.setAccessibleName("Full Post Details")
|
||||
content_text.setPlainText(full_content)
|
||||
content_text.setReadOnly(True)
|
||||
# Enable keyboard navigation in read-only text
|
||||
content_text.setTextInteractionFlags(
|
||||
Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse
|
||||
)
|
||||
content_layout.addWidget(content_text)
|
||||
|
||||
return content_widget
|
||||
|
||||
def submit_poll_vote(self):
|
||||
"""Submit vote in poll"""
|
||||
try:
|
||||
selected_choices = []
|
||||
|
||||
for i, widget in enumerate(self.poll_option_widgets):
|
||||
if widget.isChecked():
|
||||
selected_choices.append(i)
|
||||
|
||||
if not selected_choices:
|
||||
self.sound_manager.play_error()
|
||||
return
|
||||
|
||||
# Emit vote signal for parent to handle
|
||||
self.vote_submitted.emit(self.post, selected_choices)
|
||||
|
||||
# Close dialog after voting
|
||||
self.accept()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error submitting poll vote: {e}")
|
||||
self.sound_manager.play_error()
|
||||
|
Reference in New Issue
Block a user