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:
Storm Dragon
2025-07-23 03:45:47 -04:00
parent 8b9187e23f
commit cd535aebdf
8 changed files with 1816 additions and 992 deletions
+80 -1
View File
@@ -14,6 +14,7 @@ Bifrost is a fully accessible fediverse client built with PySide6, designed spec
2. **UI Event Handlers**: Avoid circular event chains (A triggers B which triggers A) 2. **UI Event Handlers**: Avoid circular event chains (A triggers B which triggers A)
3. **Timeline Operations**: Coordinate refresh calls and state changes to prevent conflicts 3. **Timeline Operations**: Coordinate refresh calls and state changes to prevent conflicts
4. **Lifecycle Events**: Ensure shutdown, close, and quit events don't overlap 4. **Lifecycle Events**: Ensure shutdown, close, and quit events don't overlap
4. **Design**: Ensure single point of truth is used as much as possible through out the code. This means functionality should not be duplicated across files.
### Required Patterns for Event-Heavy Operations ### Required Patterns for Event-Heavy Operations
@@ -611,4 +612,82 @@ Examples of forbidden practices:
- Shortened usernames or descriptions - Shortened usernames or descriptions
- Abbreviated profile information - 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. ### Screen Reader Accessibility Guidelines
#### Widget Choice and Layout
**DO:**
- Use `QTextEdit` (read-only) for all text content that needs to be accessible
- Use `QListWidget` with fake header items for navigable lists
- Set proper `setAccessibleName()` on all interactive widgets
- Enable keyboard navigation with `Qt.TextSelectableByKeyboard`
**DON'T:**
- Use `QLabel` for important information - screen readers often skip labels
- Use disabled `QCheckBox` or `QRadioButton` widgets - they get skipped
- Create single-item lists without fake headers - they're hard to navigate
#### Accessible List Pattern
For any list that users need to navigate (polls, favorites, boosts):
```python
list_widget = QListWidget()
list_widget.setAccessibleName("Descriptive List Name")
# Add fake header for single-item navigation
fake_header = QListWidgetItem("List title:")
fake_header.setFlags(Qt.ItemIsEnabled) # Not selectable
list_widget.addItem(fake_header)
# Add actual items
for item in items:
list_item = QListWidgetItem(item_text)
list_item.setData(Qt.UserRole, item_data)
list_widget.addItem(list_item)
```
#### Context Menu Accessibility
**Required keyboard shortcuts for context menus:**
- Applications key (`Qt.Key_Menu`)
- Shift+F10 (`Qt.Key_F10` with `Qt.ShiftModifier`)
Both must trigger: `self.customContextMenuRequested.emit(center)`
#### Interactive vs Display Content
**For expired/completed interactive elements (polls, forms):**
- Replace disabled controls with accessible display lists
- Show results using the accessible list pattern
- Never leave users with disabled widgets they can't interact with
**For live interactive elements:**
- Use standard Qt controls (`QCheckBox`, `QRadioButton`, `QPushButton`)
- Ensure all controls have meaningful `setAccessibleName()`
- Provide clear feedback for actions
#### Information Display Priority
**All important information must be in accessible widgets:**
1. **Primary location:** `QTextEdit` (read-only, keyboard selectable)
2. **Secondary location:** `QListWidget` items
3. **Never primary:** `QLabel` widgets (often skipped)
**Example of accessible content display:**
```python
content_text = QTextEdit()
content_text.setAccessibleName("Full Post Details")
content_text.setPlainText(f"Author: @{username}\n\nContent:\n{post_content}\n\nMetadata:\n{details}")
content_text.setReadOnly(True)
content_text.setTextInteractionFlags(Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse)
```
#### Dialog and Tab Design
**Avoid content duplication:**
- Don't show the same information in multiple places
- Use tabs to organize different types of information
- Keep essential stats/counts at the top level
- Put detailed content in dedicated tabs
**Tab organization example:**
- Statistics: Brief counts at dialog top
- Poll tab: Interactive voting or accessible results list
- Content tab: Full post details in accessible text widget
- Interaction tabs: Who favorited/boosted in accessible lists
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.
+8 -1
View File
@@ -28,7 +28,7 @@ This project was created through "vibe coding" - a collaborative development app
- **User Profile Viewer**: Comprehensive profile viewing with bio, fields, recent posts, and social actions - **User Profile Viewer**: Comprehensive profile viewing with bio, fields, recent posts, and social actions
- **Social Features**: Follow/unfollow, block/unblock, and mute/unmute users directly from profiles - **Social Features**: Follow/unfollow, block/unblock, and mute/unmute users directly from profiles
- **Media Uploads**: Attach images, videos, and audio files with accessibility-compliant alt text - **Media Uploads**: Attach images, videos, and audio files with accessibility-compliant alt text
- **Post Details**: Press Enter on any post to see detailed interaction information - **Post Details**: Press Enter on any post to see detailed interaction information with poll integration
- **Thread Expansion**: Full conversation context fetching for complete thread viewing - **Thread Expansion**: Full conversation context fetching for complete thread viewing
- **Blocked/Muted Management**: Dedicated tabs for managing blocked and muted users - **Blocked/Muted Management**: Dedicated tabs for managing blocked and muted users
- **Custom Emoji Support**: Instance-specific emoji support with caching - **Custom Emoji Support**: Instance-specific emoji support with caching
@@ -94,6 +94,7 @@ Bifrost includes a sophisticated sound system with:
- **Ctrl+U**: Open URLs from selected post in browser - **Ctrl+U**: Open URLs from selected post in browser
- **Ctrl+Shift+B**: Block user who authored selected post - **Ctrl+Shift+B**: Block user who authored selected post
- **Ctrl+Shift+M**: Mute user who authored selected post - **Ctrl+Shift+M**: Mute user who authored selected post
- **Applications Key/Shift+F10**: Open context menu with all post actions
### Navigation ### Navigation
- **Arrow Keys**: Navigate through posts - **Arrow Keys**: Navigate through posts
@@ -263,6 +264,7 @@ Bifrost includes comprehensive poll support with full accessibility:
- **Vote Submission**: Submit votes with accessible button controls - **Vote Submission**: Submit votes with accessible button controls
### Viewing Results ### Viewing Results
- **Integrated Display**: Poll results shown in post details dialog as first tab
- **Automatic Display**: Results shown immediately after voting or for expired polls - **Automatic Display**: Results shown immediately after voting or for expired polls
- **Navigable List**: Vote counts and percentages in an accessible list widget - **Navigable List**: Vote counts and percentages in an accessible list widget
- **Arrow Key Navigation**: Review each option's results individually - **Arrow Key Navigation**: Review each option's results individually
@@ -273,6 +275,8 @@ Bifrost includes comprehensive poll support with full accessibility:
- **Keyboard Only**: Complete functionality without mouse interaction - **Keyboard Only**: Complete functionality without mouse interaction
- **Clear Announcements**: Descriptive text for poll status and options - **Clear Announcements**: Descriptive text for poll status and options
- **Focus Management**: Proper tab order and focus placement - **Focus Management**: Proper tab order and focus placement
- **Accessible Results**: Poll results displayed using accessible QListWidget pattern
- **Context Menu Support**: All poll actions available via context menu shortcuts
- **Error Handling**: Accessible feedback for voting errors (duplicate votes, etc.) - **Error Handling**: Accessible feedback for voting errors (duplicate votes, etc.)
## Accessibility Features ## Accessibility Features
@@ -283,6 +287,9 @@ Bifrost includes comprehensive poll support with full accessibility:
- Accessible names and descriptions for all controls - Accessible names and descriptions for all controls
- Thread expansion/collapse with audio feedback - Thread expansion/collapse with audio feedback
- Poll creation and voting with full accessibility support - Poll creation and voting with full accessibility support
- Context menu support with Applications key and Shift+F10
- Accessible content display using QTextEdit for complex information
- Private message conversations with proper threading
### Known Qt Display Quirk ### Known Qt Display Quirk
+9 -1
View File
@@ -156,12 +156,20 @@ class ActivityPubClient:
data['spoiler_text'] = content_warning data['spoiler_text'] = content_warning
if in_reply_to_id: if in_reply_to_id:
data['in_reply_to_id'] = in_reply_to_id data['in_reply_to_id'] = in_reply_to_id
self.logger.debug(f"Posting reply to {in_reply_to_id} with visibility {visibility}")
if media_ids: if media_ids:
data['media_ids'] = media_ids data['media_ids'] = media_ids
if poll: if poll:
data['poll'] = poll data['poll'] = poll
return self._make_request('POST', '/api/v1/statuses', data=data) try:
return self._make_request('POST', '/api/v1/statuses', data=data)
except Exception as e:
if in_reply_to_id and "404" in str(e):
self.logger.error(f"Reply target {in_reply_to_id} not found (404), may have been deleted")
raise Exception(f"The post you're replying to may have been deleted: {e}")
else:
raise
def delete_status(self, status_id: str) -> Dict: def delete_status(self, status_id: str) -> Dict:
"""Delete a status""" """Delete a status"""
+552 -348
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -44,7 +44,15 @@ class PostThread(QThread):
self.post_success.emit(result) self.post_success.emit(result)
except Exception as e: except Exception as e:
self.post_failed.emit(str(e)) error_msg = str(e)
# Provide more user-friendly error messages
if "may have been deleted" in error_msg:
user_msg = "The post you're replying to may have been deleted. Please try replying to a different post."
elif "404" in error_msg:
user_msg = "The server could not find the target post. It may have been deleted."
else:
user_msg = error_msg
self.post_failed.emit(user_msg)
class PostManager(QObject): class PostManager(QObject):
+219 -119
View File
@@ -3,9 +3,19 @@ Compose post dialog for creating new posts
""" """
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QTextEdit, QDialog,
QPushButton, QLabel, QDialogButtonBox, QCheckBox, QVBoxLayout,
QComboBox, QGroupBox, QLineEdit, QSpinBox, QMessageBox QHBoxLayout,
QTextEdit,
QPushButton,
QLabel,
QDialogButtonBox,
QCheckBox,
QComboBox,
QGroupBox,
QLineEdit,
QSpinBox,
QMessageBox,
) )
from PySide6.QtCore import Qt, Signal, QThread from PySide6.QtCore import Qt, Signal, QThread
from PySide6.QtGui import QKeySequence, QShortcut from PySide6.QtGui import QKeySequence, QShortcut
@@ -22,146 +32,158 @@ from widgets.media_upload_widget import MediaUploadWidget
# NOTE: PostThread removed - now using centralized PostManager in main_window.py # NOTE: PostThread removed - now using centralized PostManager in main_window.py
# This eliminates duplicate posting logic and centralizes all post operations # This eliminates duplicate posting logic and centralizes all post operations
class ComposeDialog(QDialog): class ComposeDialog(QDialog):
"""Dialog for composing new posts""" """Dialog for composing new posts"""
post_sent = Signal(dict) # Emitted when a post is ready to send post_sent = Signal(dict) # Emitted when a post is ready to send
def __init__(self, account_manager, parent=None): def __init__(self, account_manager, parent=None):
super().__init__(parent) super().__init__(parent)
self.settings = SettingsManager() self.settings = SettingsManager()
self.sound_manager = SoundManager(self.settings) self.sound_manager = SoundManager(self.settings)
self.account_manager = account_manager self.account_manager = account_manager
self.media_upload_widget = None self.media_upload_widget = None
self.logger = logging.getLogger('bifrost.compose') self.logger = logging.getLogger("bifrost.compose")
self.setup_ui() self.setup_ui()
self.setup_shortcuts() self.setup_shortcuts()
self.load_default_settings() self.load_default_settings()
def setup_ui(self): def setup_ui(self):
"""Initialize the compose dialog UI""" """Initialize the compose dialog UI"""
self.setWindowTitle("Compose Post") self.setWindowTitle("Compose Post")
self.setMinimumSize(500, 300) self.setMinimumSize(500, 300)
self.setModal(True) self.setModal(True)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
# Character count label # Character count label
self.char_count_label = QLabel("Characters: 0/500") self.char_count_label = QLabel("Characters: 0/500")
self.char_count_label.setAccessibleName("Character Count") self.char_count_label.setAccessibleName("Character Count")
layout.addWidget(self.char_count_label) layout.addWidget(self.char_count_label)
# Main text area with autocomplete # Main text area with autocomplete
self.text_edit = AutocompleteTextEdit(sound_manager=self.sound_manager) self.text_edit = AutocompleteTextEdit(sound_manager=self.sound_manager)
self.text_edit.setAccessibleName("Post Content") self.text_edit.setAccessibleName("Post Content")
self.text_edit.setAccessibleDescription("Enter your post content here. Type @ for mentions, : for emojis. Press Tab to move to post options.") self.text_edit.setAccessibleDescription(
self.text_edit.setPlaceholderText("What's on your mind? Type @ for mentions, : for emojis") "Enter your post content here. Type @ for mentions, : for emojis. Press Tab to move to post options."
)
self.text_edit.setPlaceholderText(
"What's on your mind? Type @ for mentions, : for emojis"
)
self.text_edit.setTabChangesFocus(True) # Allow Tab to exit the text area self.text_edit.setTabChangesFocus(True) # Allow Tab to exit the text area
self.text_edit.textChanged.connect(self.update_char_count) self.text_edit.textChanged.connect(self.update_char_count)
self.text_edit.mention_requested.connect(self.load_mention_suggestions) self.text_edit.mention_requested.connect(self.load_mention_suggestions)
self.text_edit.emoji_requested.connect(self.load_emoji_suggestions) self.text_edit.emoji_requested.connect(self.load_emoji_suggestions)
layout.addWidget(self.text_edit) layout.addWidget(self.text_edit)
# Options group # Options group
options_group = QGroupBox("Post Options") options_group = QGroupBox("Post Options")
options_layout = QVBoxLayout(options_group) options_layout = QVBoxLayout(options_group)
# Post settings row 1: Visibility and Content Type # Post settings row 1: Visibility and Content Type
settings_row1 = QHBoxLayout() settings_row1 = QHBoxLayout()
# Visibility settings # Visibility settings
settings_row1.addWidget(QLabel("Visibility:")) settings_row1.addWidget(QLabel("Visibility:"))
self.visibility_combo = AccessibleComboBox() self.visibility_combo = AccessibleComboBox()
self.visibility_combo.setAccessibleName("Post Visibility") self.visibility_combo.setAccessibleName("Post Visibility")
self.visibility_combo.addItems([ self.visibility_combo.addItems(
"Public", ["Public", "Unlisted", "Followers Only", "Direct Message"]
"Unlisted", )
"Followers Only",
"Direct Message"
])
settings_row1.addWidget(self.visibility_combo) settings_row1.addWidget(self.visibility_combo)
settings_row1.addWidget(QLabel("Content Type:")) settings_row1.addWidget(QLabel("Content Type:"))
self.content_type_combo = AccessibleComboBox() self.content_type_combo = AccessibleComboBox()
self.content_type_combo.setAccessibleName("Content Type") self.content_type_combo.setAccessibleName("Content Type")
self.content_type_combo.setAccessibleDescription("Choose the format for this post") self.content_type_combo.setAccessibleDescription(
"Choose the format for this post"
)
self.content_type_combo.addItem("Plain Text", "text/plain") self.content_type_combo.addItem("Plain Text", "text/plain")
self.content_type_combo.addItem("Markdown", "text/markdown") self.content_type_combo.addItem("Markdown", "text/markdown")
self.content_type_combo.addItem("HTML", "text/html") self.content_type_combo.addItem("HTML", "text/html")
settings_row1.addWidget(self.content_type_combo) settings_row1.addWidget(self.content_type_combo)
settings_row1.addStretch() settings_row1.addStretch()
options_layout.addLayout(settings_row1) options_layout.addLayout(settings_row1)
# Content warnings # Content warnings
self.cw_checkbox = QCheckBox("Add Content Warning") self.cw_checkbox = QCheckBox("Add Content Warning")
self.cw_checkbox.setAccessibleName("Content Warning Toggle") self.cw_checkbox.setAccessibleName("Content Warning Toggle")
self.cw_checkbox.toggled.connect(self.toggle_content_warning) self.cw_checkbox.toggled.connect(self.toggle_content_warning)
options_layout.addWidget(self.cw_checkbox) options_layout.addWidget(self.cw_checkbox)
self.cw_edit = QTextEdit() self.cw_edit = QTextEdit()
self.cw_edit.setAccessibleName("Content Warning Text") self.cw_edit.setAccessibleName("Content Warning Text")
self.cw_edit.setAccessibleDescription("Enter content warning description. Press Tab to move to next field.") self.cw_edit.setAccessibleDescription(
"Enter content warning description. Press Tab to move to next field."
)
self.cw_edit.setPlaceholderText("Describe what this post contains...") self.cw_edit.setPlaceholderText("Describe what this post contains...")
self.cw_edit.setMaximumHeight(60) self.cw_edit.setMaximumHeight(60)
self.cw_edit.setTabChangesFocus(True) # Allow Tab to exit the content warning field self.cw_edit.setTabChangesFocus(
True
) # Allow Tab to exit the content warning field
self.cw_edit.hide() self.cw_edit.hide()
options_layout.addWidget(self.cw_edit) options_layout.addWidget(self.cw_edit)
# Poll options # Poll options
self.poll_checkbox = QCheckBox("Add Poll") self.poll_checkbox = QCheckBox("Add Poll")
self.poll_checkbox.setAccessibleName("Poll Toggle") self.poll_checkbox.setAccessibleName("Poll Toggle")
self.poll_checkbox.toggled.connect(self.toggle_poll) self.poll_checkbox.toggled.connect(self.toggle_poll)
options_layout.addWidget(self.poll_checkbox) options_layout.addWidget(self.poll_checkbox)
# Poll container (hidden by default) # Poll container (hidden by default)
self.poll_container = QGroupBox("Poll Options") self.poll_container = QGroupBox("Poll Options")
self.poll_container.hide() self.poll_container.hide()
poll_layout = QVBoxLayout(self.poll_container) poll_layout = QVBoxLayout(self.poll_container)
# Poll options # Poll options
self.poll_options = [] self.poll_options = []
for i in range(4): # Support up to 4 poll options for i in range(4): # Support up to 4 poll options
option_layout = QHBoxLayout() option_layout = QHBoxLayout()
option_layout.addWidget(QLabel(f"Option {i+1}:")) option_layout.addWidget(QLabel(f"Option {i+1}:"))
option_edit = QLineEdit() option_edit = QLineEdit()
option_edit.setAccessibleName(f"Poll Option {i+1}") option_edit.setAccessibleName(f"Poll Option {i+1}")
option_edit.setAccessibleDescription(f"Enter poll option {i+1}. Leave empty if not needed.") option_edit.setAccessibleDescription(
f"Enter poll option {i+1}. Leave empty if not needed."
)
option_edit.setPlaceholderText(f"Poll option {i+1}...") option_edit.setPlaceholderText(f"Poll option {i+1}...")
if i >= 2: # First two options are required, others optional if i >= 2: # First two options are required, others optional
option_edit.setPlaceholderText(f"Poll option {i+1} (optional)...") option_edit.setPlaceholderText(f"Poll option {i+1} (optional)...")
option_layout.addWidget(option_edit) option_layout.addWidget(option_edit)
self.poll_options.append(option_edit) self.poll_options.append(option_edit)
poll_layout.addLayout(option_layout) poll_layout.addLayout(option_layout)
# Poll settings # Poll settings
poll_settings_layout = QHBoxLayout() poll_settings_layout = QHBoxLayout()
# Duration # Duration
poll_settings_layout.addWidget(QLabel("Duration:")) poll_settings_layout.addWidget(QLabel("Duration:"))
self.poll_duration = QSpinBox() self.poll_duration = QSpinBox()
self.poll_duration.setAccessibleName("Poll Duration in Hours") self.poll_duration.setAccessibleName("Poll Duration in Hours")
self.poll_duration.setAccessibleDescription("How long should the poll run? In hours.") self.poll_duration.setAccessibleDescription(
"How long should the poll run? In hours."
)
self.poll_duration.setMinimum(1) self.poll_duration.setMinimum(1)
self.poll_duration.setMaximum(24 * 7) # 1 week max self.poll_duration.setMaximum(24 * 7) # 1 week max
self.poll_duration.setValue(24) # Default 24 hours self.poll_duration.setValue(24) # Default 24 hours
self.poll_duration.setSuffix(" hours") self.poll_duration.setSuffix(" hours")
poll_settings_layout.addWidget(self.poll_duration) poll_settings_layout.addWidget(self.poll_duration)
poll_settings_layout.addStretch() poll_settings_layout.addStretch()
# Multiple choice option # Multiple choice option
self.poll_multiple = QCheckBox("Allow multiple choices") self.poll_multiple = QCheckBox("Allow multiple choices")
self.poll_multiple.setAccessibleName("Multiple Choice Toggle") self.poll_multiple.setAccessibleName("Multiple Choice Toggle")
poll_settings_layout.addWidget(self.poll_multiple) poll_settings_layout.addWidget(self.poll_multiple)
poll_layout.addLayout(poll_settings_layout) poll_layout.addLayout(poll_settings_layout)
options_layout.addWidget(self.poll_container) options_layout.addWidget(self.poll_container)
layout.addWidget(options_group) layout.addWidget(options_group)
# Media upload section - create carefully to avoid crashes # Media upload section - create carefully to avoid crashes
try: try:
client = self.account_manager.get_client_for_active_account() client = self.account_manager.get_client_for_active_account()
@@ -173,8 +195,10 @@ class ComposeDialog(QDialog):
# Create placeholder when no account is available # Create placeholder when no account is available
self.media_upload_widget = None self.media_upload_widget = None
media_placeholder = QLabel("Please log in to upload media") media_placeholder = QLabel("Please log in to upload media")
media_placeholder.setAccessibleName("Media Upload Status") media_placeholder.setAccessibleName("Media Upload Status")
media_placeholder.setStyleSheet("color: #666; font-style: italic; padding: 10px;") media_placeholder.setStyleSheet(
"color: #666; font-style: italic; padding: 10px;"
)
layout.addWidget(media_placeholder) layout.addWidget(media_placeholder)
except Exception as e: except Exception as e:
self.logger.error(f"Failed to create media upload widget: {e}") self.logger.error(f"Failed to create media upload widget: {e}")
@@ -182,48 +206,101 @@ class ComposeDialog(QDialog):
# Add error placeholder # Add error placeholder
error_placeholder = QLabel("Media upload temporarily unavailable") error_placeholder = QLabel("Media upload temporarily unavailable")
error_placeholder.setAccessibleName("Media Upload Error") error_placeholder.setAccessibleName("Media Upload Error")
error_placeholder.setStyleSheet("color: #888; font-style: italic; padding: 10px;") error_placeholder.setStyleSheet(
"color: #888; font-style: italic; padding: 10px;"
)
layout.addWidget(error_placeholder) layout.addWidget(error_placeholder)
# Button box # Button box
button_box = QDialogButtonBox() button_box = QDialogButtonBox()
# Post button # Post button
self.post_button = QPushButton("&Post") self.post_button = QPushButton("&Post")
self.post_button.setAccessibleName("Send Post") self.post_button.setAccessibleName("Send Post")
self.post_button.setDefault(True) self.post_button.setDefault(True)
self.post_button.clicked.connect(self.send_post) self.post_button.clicked.connect(self.send_post)
button_box.addButton(self.post_button, QDialogButtonBox.AcceptRole) button_box.addButton(self.post_button, QDialogButtonBox.AcceptRole)
# Cancel button # Cancel button
cancel_button = QPushButton("&Cancel") cancel_button = QPushButton("&Cancel")
cancel_button.setAccessibleName("Cancel Post") cancel_button.setAccessibleName("Cancel Post")
cancel_button.clicked.connect(self.reject) cancel_button.clicked.connect(self.reject)
button_box.addButton(cancel_button, QDialogButtonBox.RejectRole) button_box.addButton(cancel_button, QDialogButtonBox.RejectRole)
layout.addWidget(button_box) layout.addWidget(button_box)
# Set initial focus # Set initial focus
self.text_edit.setFocus() self.text_edit.setFocus()
def setup_shortcuts(self): def setup_shortcuts(self):
"""Set up keyboard shortcuts""" """Set up keyboard shortcuts"""
# Ctrl+Enter to send post # Ctrl+Enter to send post
send_shortcut = QShortcut(QKeySequence("Ctrl+Return"), self) send_shortcut = QShortcut(QKeySequence("Ctrl+Return"), self)
send_shortcut.activated.connect(self.send_post) send_shortcut.activated.connect(self.send_post)
# Escape to cancel # Escape to cancel
cancel_shortcut = QShortcut(QKeySequence.Cancel, self) cancel_shortcut = QShortcut(QKeySequence.Cancel, self)
cancel_shortcut.activated.connect(self.reject) cancel_shortcut.activated.connect(self.reject)
def load_default_settings(self): def load_default_settings(self):
"""Load default settings from configuration""" """Load default settings from configuration"""
# Set default content type # Set default content type
default_type = self.settings.get('composition', 'default_content_type', 'text/plain') default_type = self.settings.get(
"composition", "default_content_type", "text/plain"
)
index = self.content_type_combo.findData(default_type) index = self.content_type_combo.findData(default_type)
if index >= 0: if index >= 0:
self.content_type_combo.setCurrentIndex(index) self.content_type_combo.setCurrentIndex(index)
def setup_reply(self, post):
"""Configure dialog for replying to a specific post or conversation"""
# Handle conversations differently
if hasattr(post, "conversation") and post.conversation:
# This is a conversation - pre-fill with all participants
participants = []
active_account = self.account_manager.get_active_account()
current_username = active_account.username if active_account else None
for account in post.conversation.accounts:
# Don't include ourselves in the mention
if account.acct != current_username:
participants.append(f"@{account.acct}")
if participants:
mention_text = " ".join(participants) + " "
self.text_edit.setPlainText(mention_text)
# Set visibility to Direct Message for conversations
self.visibility_combo.setCurrentText("Direct Message")
self.logger.info(
"Reply visibility set to Direct Message for conversation reply"
)
else:
# Regular post reply
# Pre-fill with reply mention using full fediverse handle
self.text_edit.setPlainText(f"@{post.account.acct} ")
# Set appropriate visibility based on original post
if hasattr(post, "visibility"):
if post.visibility == "direct":
# For direct messages, set to Direct Message and make it prominent
self.visibility_combo.setCurrentText("Direct Message")
self.logger.info(
f"Reply visibility set to Direct Message for DM reply"
)
elif post.visibility == "private":
# For followers-only posts, default to followers-only
self.visibility_combo.setCurrentText("Followers Only")
self.logger.info(
f"Reply visibility set to Followers Only for private post reply"
)
# For public/unlisted posts, keep the current default (usually public)
# Move cursor to end
cursor = self.text_edit.textCursor()
cursor.movePosition(cursor.MoveOperation.End)
self.text_edit.setTextCursor(cursor)
def toggle_content_warning(self, enabled: bool): def toggle_content_warning(self, enabled: bool):
"""Toggle content warning field visibility""" """Toggle content warning field visibility"""
if enabled: if enabled:
@@ -232,7 +309,7 @@ class ComposeDialog(QDialog):
else: else:
self.cw_edit.hide() self.cw_edit.hide()
self.cw_edit.clear() self.cw_edit.clear()
def toggle_poll(self, enabled: bool): def toggle_poll(self, enabled: bool):
"""Toggle poll options visibility""" """Toggle poll options visibility"""
if enabled: if enabled:
@@ -244,53 +321,57 @@ class ComposeDialog(QDialog):
# Clear all poll options # Clear all poll options
for option_edit in self.poll_options: for option_edit in self.poll_options:
option_edit.clear() option_edit.clear()
def update_char_count(self): def update_char_count(self):
"""Update character count display""" """Update character count display"""
text = self.text_edit.toPlainText() text = self.text_edit.toPlainText()
char_count = len(text) char_count = len(text)
self.char_count_label.setText(f"Characters: {char_count}/500") self.char_count_label.setText(f"Characters: {char_count}/500")
# Enable/disable post button based on content # Enable/disable post button based on content
has_content = bool(text.strip()) has_content = bool(text.strip())
within_limit = char_count <= 500 within_limit = char_count <= 500
self.post_button.setEnabled(has_content and within_limit) self.post_button.setEnabled(has_content and within_limit)
# Update accessibility # Update accessibility
if char_count > 500: if char_count > 500:
self.char_count_label.setAccessibleDescription("Character limit exceeded") self.char_count_label.setAccessibleDescription("Character limit exceeded")
else: else:
self.char_count_label.setAccessibleDescription(f"{500 - char_count} characters remaining") self.char_count_label.setAccessibleDescription(
f"{500 - char_count} characters remaining"
)
def send_post(self): def send_post(self):
"""Send the post""" """Send the post"""
content = self.text_edit.toPlainText().strip() content = self.text_edit.toPlainText().strip()
if not content: if not content:
return return
# Get active account # Get active account
active_account = self.account_manager.get_active_account() active_account = self.account_manager.get_active_account()
if not active_account: if not active_account:
QMessageBox.warning(self, "No Account", "Please add an account before posting.") QMessageBox.warning(
self, "No Account", "Please add an account before posting."
)
return return
# Get post settings # Get post settings
visibility_text = self.visibility_combo.currentText() visibility_text = self.visibility_combo.currentText()
visibility_map = { visibility_map = {
"Public": "public", "Public": "public",
"Unlisted": "unlisted", "Unlisted": "unlisted",
"Followers Only": "private", "Followers Only": "private",
"Direct Message": "direct" "Direct Message": "direct",
} }
visibility = visibility_map.get(visibility_text, "public") visibility = visibility_map.get(visibility_text, "public")
# Get content type # Get content type
content_type = self.content_type_combo.currentData() content_type = self.content_type_combo.currentData()
content_warning = None content_warning = None
if self.cw_checkbox.isChecked(): if self.cw_checkbox.isChecked():
content_warning = self.cw_edit.toPlainText().strip() content_warning = self.cw_edit.toPlainText().strip()
# Handle poll data # Handle poll data
poll_data = None poll_data = None
if self.poll_checkbox.isChecked(): if self.poll_checkbox.isChecked():
@@ -299,19 +380,22 @@ class ComposeDialog(QDialog):
option_text = option_edit.text().strip() option_text = option_edit.text().strip()
if option_text: if option_text:
poll_options.append(option_text) poll_options.append(option_text)
# Validate poll (need at least 2 options) # Validate poll (need at least 2 options)
if len(poll_options) < 2: if len(poll_options) < 2:
QMessageBox.warning(self, "Invalid Poll", "Polls need at least 2 options.") QMessageBox.warning(
self, "Invalid Poll", "Polls need at least 2 options."
)
return return
# Create poll data # Create poll data
poll_data = { poll_data = {
'options': poll_options, "options": poll_options,
'expires_in': self.poll_duration.value() * 3600, # Convert hours to seconds "expires_in": self.poll_duration.value()
'multiple': self.poll_multiple.isChecked() * 3600, # Convert hours to seconds
"multiple": self.poll_multiple.isChecked(),
} }
# Check if we need to upload media first # Check if we need to upload media first
media_ids = [] media_ids = []
if self.media_upload_widget and self.media_upload_widget.has_media(): if self.media_upload_widget and self.media_upload_widget.has_media():
@@ -321,25 +405,25 @@ class ComposeDialog(QDialog):
# TODO: We should wait for uploads to complete before posting # TODO: We should wait for uploads to complete before posting
# For now, we'll post immediately and let the API handle it # For now, we'll post immediately and let the API handle it
media_ids = self.media_upload_widget.get_media_ids() media_ids = self.media_upload_widget.get_media_ids()
# Start background posting # Start background posting
post_data = { post_data = {
'account': active_account, "account": active_account,
'content': content, "content": content,
'visibility': visibility, "visibility": visibility,
'content_type': content_type, "content_type": content_type,
'content_warning': content_warning, "content_warning": content_warning,
'poll': poll_data, "poll": poll_data,
'media_ids': media_ids "media_ids": media_ids,
} }
# NOTE: Sound handling moved to centralized PostManager to avoid duplication # NOTE: Sound handling moved to centralized PostManager to avoid duplication
# Emit signal with all post data for background processing by PostManager # Emit signal with all post data for background processing by PostManager
self.post_sent.emit(post_data) self.post_sent.emit(post_data)
# Close dialog immediately # Close dialog immediately
self.accept() self.accept()
def load_mention_suggestions(self, prefix: str): def load_mention_suggestions(self, prefix: str):
"""Load mention suggestions based on prefix""" """Load mention suggestions based on prefix"""
try: try:
@@ -347,103 +431,119 @@ class ComposeDialog(QDialog):
client = self.account_manager.get_client_for_active_account() client = self.account_manager.get_client_for_active_account()
if not client: if not client:
return return
# Get current user's account ID # Get current user's account ID
current_user = client.verify_credentials() current_user = client.verify_credentials()
current_account_id = current_user['id'] current_account_id = current_user["id"]
# Collect usernames from multiple sources # Collect usernames from multiple sources
usernames = set() usernames = set()
# 1. Search for accounts matching the prefix # 1. Search for accounts matching the prefix
if len(prefix) >= 1: # Search when user has typed at least 1 character if len(prefix) >= 1: # Search when user has typed at least 1 character
try: try:
search_results = client.search_accounts(prefix, limit=40) search_results = client.search_accounts(prefix, limit=40)
for account in search_results: for account in search_results:
# Use full fediverse handle (acct field) or construct it # Use full fediverse handle (acct field) or construct it
full_handle = account.get('acct', '') full_handle = account.get("acct", "")
if not full_handle: if not full_handle:
username = account.get('username', '') username = account.get("username", "")
domain = account.get('url', '').split('/')[2] if account.get('url') else '' domain = (
account.get("url", "").split("/")[2]
if account.get("url")
else ""
)
if username and domain: if username and domain:
full_handle = f"{username}@{domain}" full_handle = f"{username}@{domain}"
else: else:
full_handle = username full_handle = username
if full_handle: if full_handle:
usernames.add(full_handle) usernames.add(full_handle)
except Exception as e: except Exception as e:
self.logger.error(f"Account search failed: {e}") self.logger.error(f"Account search failed: {e}")
# 2. Get followers (people who follow you) # 2. Get followers (people who follow you)
try: try:
followers = client.get_followers(current_account_id, limit=100) followers = client.get_followers(current_account_id, limit=100)
for follower in followers: for follower in followers:
# Use full fediverse handle # Use full fediverse handle
full_handle = follower.get('acct', '') full_handle = follower.get("acct", "")
if not full_handle: if not full_handle:
username = follower.get('username', '') username = follower.get("username", "")
domain = follower.get('url', '').split('/')[2] if follower.get('url') else '' domain = (
follower.get("url", "").split("/")[2]
if follower.get("url")
else ""
)
if username and domain: if username and domain:
full_handle = f"{username}@{domain}" full_handle = f"{username}@{domain}"
else: else:
full_handle = username full_handle = username
if full_handle and full_handle.lower().startswith(prefix.lower()): if full_handle and full_handle.lower().startswith(prefix.lower()):
usernames.add(full_handle) usernames.add(full_handle)
except Exception as e: except Exception as e:
self.logger.error(f"Failed to get followers: {e}") self.logger.error(f"Failed to get followers: {e}")
# 3. Get following (people you follow) # 3. Get following (people you follow)
try: try:
following = client.get_following(current_account_id, limit=100) following = client.get_following(current_account_id, limit=100)
for account in following: for account in following:
# Use full fediverse handle # Use full fediverse handle
full_handle = account.get('acct', '') full_handle = account.get("acct", "")
if not full_handle: if not full_handle:
username = account.get('username', '') username = account.get("username", "")
domain = account.get('url', '').split('/')[2] if account.get('url') else '' domain = (
account.get("url", "").split("/")[2]
if account.get("url")
else ""
)
if username and domain: if username and domain:
full_handle = f"{username}@{domain}" full_handle = f"{username}@{domain}"
else: else:
full_handle = username full_handle = username
if full_handle and full_handle.lower().startswith(prefix.lower()): if full_handle and full_handle.lower().startswith(prefix.lower()):
usernames.add(full_handle) usernames.add(full_handle)
except Exception as e: except Exception as e:
self.logger.error(f"Failed to get following: {e}") self.logger.error(f"Failed to get following: {e}")
# Convert to sorted list # Convert to sorted list
filtered = sorted(list(usernames)) filtered = sorted(list(usernames))
# Only use real API data - no fallback to incomplete sample data # Only use real API data - no fallback to incomplete sample data
# The empty list will trigger a fresh API call if needed # The empty list will trigger a fresh API call if needed
self.text_edit.update_mention_list(filtered) self.text_edit.update_mention_list(filtered)
except Exception as e: except Exception as e:
self.logger.error(f"Failed to load mention suggestions: {e}") self.logger.error(f"Failed to load mention suggestions: {e}")
# Fallback to empty list # Fallback to empty list
self.text_edit.update_mention_list([]) self.text_edit.update_mention_list([])
def load_emoji_suggestions(self, prefix: str): def load_emoji_suggestions(self, prefix: str):
"""Load emoji suggestions based on prefix""" """Load emoji suggestions based on prefix"""
# The AutocompleteTextEdit already has a built-in emoji list # The AutocompleteTextEdit already has a built-in emoji list
# This method is called when the signal is emitted, but the # This method is called when the signal is emitted, but the
# autocomplete logic is handled internally by the text edit widget # autocomplete logic is handled internally by the text edit widget
# We don't need to do anything here since emojis are pre-loaded # We don't need to do anything here since emojis are pre-loaded
pass pass
def get_post_data(self) -> dict: def get_post_data(self) -> dict:
"""Get the composed post data""" """Get the composed post data"""
data = { data = {
'content': self.text_edit.toPlainText().strip(), "content": self.text_edit.toPlainText().strip(),
'visibility': self.visibility_combo.currentText().lower().replace(" ", "_"), "visibility": self.visibility_combo.currentText().lower().replace(" ", "_"),
'content_warning': self.cw_edit.toPlainText().strip() if self.cw_checkbox.isChecked() else None "content_warning": (
self.cw_edit.toPlainText().strip()
if self.cw_checkbox.isChecked()
else None
),
} }
# Add media IDs if available # Add media IDs if available
if self.media_upload_widget and self.media_upload_widget.has_media(): if self.media_upload_widget and self.media_upload_widget.has_media():
data['media_ids'] = self.media_upload_widget.get_media_ids() data["media_ids"] = self.media_upload_widget.get_media_ids()
return data return data
+297 -79
View File
@@ -3,9 +3,21 @@ Post details dialog showing favorites, boosts, and other interaction details
""" """
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QTextEdit, QDialog,
QTabWidget, QListWidget, QListWidgetItem, QDialogButtonBox, QVBoxLayout,
QWidget, QGroupBox, QPushButton QHBoxLayout,
QLabel,
QTextEdit,
QTabWidget,
QListWidget,
QListWidgetItem,
QDialogButtonBox,
QWidget,
QGroupBox,
QPushButton,
QCheckBox,
QRadioButton,
QButtonGroup,
) )
from PySide6.QtCore import Qt, Signal, QThread from PySide6.QtCore import Qt, Signal, QThread
from PySide6.QtGui import QFont from PySide6.QtGui import QFont
@@ -19,100 +31,89 @@ from audio.sound_manager import SoundManager
class FetchDetailsThread(QThread): class FetchDetailsThread(QThread):
"""Background thread for fetching post interaction details""" """Background thread for fetching post interaction details"""
details_loaded = Signal(dict) # Emitted with details data 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): def __init__(self, client: ActivityPubClient, post_id: str):
super().__init__() super().__init__()
self.client = client self.client = client
self.post_id = post_id self.post_id = post_id
self.logger = logging.getLogger('bifrost.post_details') self.logger = logging.getLogger("bifrost.post_details")
def run(self): def run(self):
"""Fetch favorites and boosts in background""" """Fetch favorites and boosts in background"""
try: try:
details = { details = {"favourited_by": [], "reblogged_by": []}
'favourited_by': [],
'reblogged_by': []
}
# Fetch who favorited this post # Fetch who favorited this post
try: try:
favourited_by_data = self.client.get_status_favourited_by(self.post_id) 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: except Exception as e:
self.logger.error(f"Failed to fetch favorites: {e}") self.logger.error(f"Failed to fetch favorites: {e}")
# Fetch who boosted this post # Fetch who boosted this post
try: try:
reblogged_by_data = self.client.get_status_reblogged_by(self.post_id) 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: except Exception as e:
self.logger.error(f"Failed to fetch boosts: {e}") self.logger.error(f"Failed to fetch boosts: {e}")
self.details_loaded.emit(details) self.details_loaded.emit(details)
except Exception as e: except Exception as e:
self.details_failed.emit(str(e)) self.details_failed.emit(str(e))
class PostDetailsDialog(QDialog): class PostDetailsDialog(QDialog):
"""Dialog showing detailed post interaction information""" """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) super().__init__(parent)
self.post = post self.post = post
self.client = client self.client = client
self.sound_manager = sound_manager self.sound_manager = sound_manager
self.logger = logging.getLogger('bifrost.post_details') self.logger = logging.getLogger("bifrost.post_details")
self.setWindowTitle("Post Details") self.setWindowTitle("Post Details")
self.setModal(True) self.setModal(True)
self.resize(600, 500) self.resize(600, 500)
self.setup_ui() self.setup_ui()
self.load_details() self.load_details()
def setup_ui(self): def setup_ui(self):
"""Setup the post details UI""" """Setup the post details UI"""
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
# Post content section # Post statistics (keep just the basic stats at top)
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
stats_text = f"Replies: {self.post.replies_count} | Boosts: {self.post.reblogs_count} | Favorites: {self.post.favourites_count}" 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 = QLabel(stats_text)
stats_label.setAccessibleName("Post Statistics") stats_label.setAccessibleName("Post Statistics")
content_layout.addWidget(stats_label) layout.addWidget(stats_label)
layout.addWidget(content_group)
# Tabs for interaction details # Tabs for interaction details
self.tabs = QTabWidget() self.tabs = QTabWidget()
self.tabs.setAccessibleName("Interaction Details") 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 # Favorites tab
self.favorites_list = QListWidget() self.favorites_list = QListWidget()
self.favorites_list.setAccessibleName("Users Who Favorited") self.favorites_list.setAccessibleName("Users Who Favorited")
@@ -120,8 +121,10 @@ class PostDetailsDialog(QDialog):
fake_header = QListWidgetItem("Users who favorited this post:") fake_header = QListWidgetItem("Users who favorited this post:")
fake_header.setFlags(Qt.ItemIsEnabled) # Not selectable fake_header.setFlags(Qt.ItemIsEnabled) # Not selectable
self.favorites_list.addItem(fake_header) 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 # Boosts tab
self.boosts_list = QListWidget() self.boosts_list = QListWidget()
self.boosts_list.setAccessibleName("Users Who Boosted") self.boosts_list.setAccessibleName("Users Who Boosted")
@@ -130,45 +133,45 @@ class PostDetailsDialog(QDialog):
fake_header.setFlags(Qt.ItemIsEnabled) # Not selectable fake_header.setFlags(Qt.ItemIsEnabled) # Not selectable
self.boosts_list.addItem(fake_header) self.boosts_list.addItem(fake_header)
self.tabs.addTab(self.boosts_list, f"Boosts ({self.post.reblogs_count})") self.tabs.addTab(self.boosts_list, f"Boosts ({self.post.reblogs_count})")
layout.addWidget(self.tabs) layout.addWidget(self.tabs)
# Loading indicator # Loading indicator
self.status_label = QLabel("Loading interaction details...") self.status_label = QLabel("Loading interaction details...")
self.status_label.setAccessibleName("Loading Status") self.status_label.setAccessibleName("Loading Status")
layout.addWidget(self.status_label) layout.addWidget(self.status_label)
# Button box # Button box
button_box = QDialogButtonBox(QDialogButtonBox.Close) button_box = QDialogButtonBox(QDialogButtonBox.Close)
button_box.setAccessibleName("Dialog Buttons") button_box.setAccessibleName("Dialog Buttons")
button_box.rejected.connect(self.reject) button_box.rejected.connect(self.reject)
layout.addWidget(button_box) layout.addWidget(button_box)
def load_details(self): def load_details(self):
"""Load detailed interaction information""" """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") self.status_label.setText("Cannot load details: No post ID or API client")
return return
# Start background fetch # Start background fetch
self.fetch_thread = FetchDetailsThread(self.client, self.post.id) self.fetch_thread = FetchDetailsThread(self.client, self.post.id)
self.fetch_thread.details_loaded.connect(self.on_details_loaded) self.fetch_thread.details_loaded.connect(self.on_details_loaded)
self.fetch_thread.details_failed.connect(self.on_details_failed) self.fetch_thread.details_failed.connect(self.on_details_failed)
self.fetch_thread.start() self.fetch_thread.start()
def on_details_loaded(self, details: dict): def on_details_loaded(self, details: dict):
"""Handle successful details loading""" """Handle successful details loading"""
self.status_label.setText("") self.status_label.setText("")
# Populate favorites list # Populate favorites list
favourited_by = details.get('favourited_by', []) favourited_by = details.get("favourited_by", [])
if favourited_by: if favourited_by:
for account_data in favourited_by: for account_data in favourited_by:
try: try:
user = User.from_api_dict(account_data) user = User.from_api_dict(account_data)
display_name = user.display_name or user.username display_name = user.display_name or user.username
item_text = f"@{user.username} ({display_name})" item_text = f"@{user.username} ({display_name})"
item = QListWidgetItem(item_text) item = QListWidgetItem(item_text)
item.setData(Qt.UserRole, user) item.setData(Qt.UserRole, user)
self.favorites_list.addItem(item) self.favorites_list.addItem(item)
@@ -177,16 +180,16 @@ class PostDetailsDialog(QDialog):
else: else:
item = QListWidgetItem("No one has favorited this post yet") item = QListWidgetItem("No one has favorited this post yet")
self.favorites_list.addItem(item) self.favorites_list.addItem(item)
# Populate boosts list # Populate boosts list
reblogged_by = details.get('reblogged_by', []) reblogged_by = details.get("reblogged_by", [])
if reblogged_by: if reblogged_by:
for account_data in reblogged_by: for account_data in reblogged_by:
try: try:
user = User.from_api_dict(account_data) user = User.from_api_dict(account_data)
display_name = user.display_name or user.username display_name = user.display_name or user.username
item_text = f"@{user.username} ({display_name})" item_text = f"@{user.username} ({display_name})"
item = QListWidgetItem(item_text) item = QListWidgetItem(item_text)
item.setData(Qt.UserRole, user) item.setData(Qt.UserRole, user)
self.boosts_list.addItem(item) self.boosts_list.addItem(item)
@@ -195,26 +198,241 @@ class PostDetailsDialog(QDialog):
else: else:
item = QListWidgetItem("No one has boosted this post yet") item = QListWidgetItem("No one has boosted this post yet")
self.boosts_list.addItem(item) self.boosts_list.addItem(item)
# Update tab titles with actual counts # Update tab titles with actual counts
actual_favorites = len(favourited_by) actual_favorites = len(favourited_by)
actual_boosts = len(reblogged_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 # Play success sound
self.sound_manager.play_success() self.sound_manager.play_success()
def on_details_failed(self, error_message: str): def on_details_failed(self, error_message: str):
"""Handle details loading failure""" """Handle details loading failure"""
self.status_label.setText(f"Failed to load details: {error_message}") self.status_label.setText(f"Failed to load details: {error_message}")
# Add error items to lists # Add error items to lists
error_item_fav = QListWidgetItem(f"Error loading favorites: {error_message}") error_item_fav = QListWidgetItem(f"Error loading favorites: {error_message}")
self.favorites_list.addItem(error_item_fav) self.favorites_list.addItem(error_item_fav)
error_item_boost = QListWidgetItem(f"Error loading boosts: {error_message}") error_item_boost = QListWidgetItem(f"Error loading boosts: {error_message}")
self.boosts_list.addItem(error_item_boost) self.boosts_list.addItem(error_item_boost)
# Play error sound # 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()
File diff suppressed because it is too large Load Diff