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

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.

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

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

File diff suppressed because it is too large Load Diff

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):

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

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