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
+79
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
### 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. 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
+8
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
try:
return self._make_request('POST', '/api/v1/statuses', data=data) 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"""
+341 -137
View File
@@ -3,8 +3,15 @@ Main application window for Bifrost
""" """
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QMainWindow,
QLabel, QMenuBar, QStatusBar, QPushButton, QTabWidget QWidget,
QVBoxLayout,
QHBoxLayout,
QLabel,
QMenuBar,
QStatusBar,
QPushButton,
QTabWidget,
) )
from PySide6.QtCore import Qt, Signal, QTimer from PySide6.QtCore import Qt, Signal, QTimer
from PySide6.QtGui import QKeySequence, QAction, QTextCursor from PySide6.QtGui import QKeySequence, QAction, QTextCursor
@@ -32,7 +39,7 @@ class MainWindow(QMainWindow):
super().__init__() super().__init__()
self.settings = SettingsManager() self.settings = SettingsManager()
self.account_manager = AccountManager(self.settings) self.account_manager = AccountManager(self.settings)
self.logger = logging.getLogger('bifrost.main') self.logger = logging.getLogger("bifrost.main")
# Auto-refresh tracking # Auto-refresh tracking
self.last_activity_time = time.time() self.last_activity_time = time.time()
@@ -45,10 +52,12 @@ class MainWindow(QMainWindow):
self.setup_ui() self.setup_ui()
# Initialize centralized managers after timeline is created # Initialize centralized managers after timeline is created
timeline_sound_manager = getattr(self.timeline, 'sound_manager', None) timeline_sound_manager = getattr(self.timeline, "sound_manager", None)
# Sound coordination - single point of truth for all audio events # Sound coordination - single point of truth for all audio events
self.sound_coordinator = SoundCoordinator(timeline_sound_manager) if timeline_sound_manager else None self.sound_coordinator = (
SoundCoordinator(timeline_sound_manager) if timeline_sound_manager else None
)
# Error management - single point of truth for error handling # Error management - single point of truth for error handling
self.error_manager = ErrorManager(self, self.sound_coordinator) self.error_manager = ErrorManager(self, self.sound_coordinator)
@@ -369,7 +378,7 @@ class MainWindow(QMainWindow):
"""Mark that initial loading is complete""" """Mark that initial loading is complete"""
self.is_initial_load = False self.is_initial_load = False
# Enable notifications on the timeline # Enable notifications on the timeline
if hasattr(self.timeline, 'enable_notifications'): if hasattr(self.timeline, "enable_notifications"):
self.timeline.enable_notifications() self.timeline.enable_notifications()
def keyPressEvent(self, event): def keyPressEvent(self, event):
@@ -383,7 +392,7 @@ class MainWindow(QMainWindow):
self.update_refresh_mode() self.update_refresh_mode()
# Skip if auto-refresh is disabled # Skip if auto-refresh is disabled
if not self.settings.get_bool('general', 'auto_refresh_enabled', True): if not self.settings.get_bool("general", "auto_refresh_enabled", True):
return return
# Skip if no account is active # Skip if no account is active
@@ -395,7 +404,9 @@ class MainWindow(QMainWindow):
return return
# Get refresh interval from settings # Get refresh interval from settings
refresh_interval = self.settings.get_int('general', 'timeline_refresh_interval', 300) refresh_interval = self.settings.get_int(
"general", "timeline_refresh_interval", 300
)
# Skip if streaming mode (interval = 0) # Skip if streaming mode (interval = 0)
if refresh_interval == 0: if refresh_interval == 0:
@@ -405,13 +416,17 @@ class MainWindow(QMainWindow):
time_since_activity = time.time() - self.last_activity_time time_since_activity = time.time() - self.last_activity_time
required_idle_time = refresh_interval + 10 # refresh_rate + 10 seconds required_idle_time = refresh_interval + 10 # refresh_rate + 10 seconds
self.logger.debug(f"Auto-refresh check: {time_since_activity:.1f}s since activity, need {required_idle_time}s idle") self.logger.debug(
f"Auto-refresh check: {time_since_activity:.1f}s since activity, need {required_idle_time}s idle"
)
if time_since_activity >= required_idle_time: if time_since_activity >= required_idle_time:
self.logger.debug("Auto-refresh condition met, triggering refresh") self.logger.debug("Auto-refresh condition met, triggering refresh")
self.auto_refresh_timeline() self.auto_refresh_timeline()
else: else:
self.logger.debug(f"Auto-refresh skipped: need {required_idle_time - time_since_activity:.1f}s more idle time") self.logger.debug(
f"Auto-refresh skipped: need {required_idle_time - time_since_activity:.1f}s more idle time"
)
def auto_refresh_timeline(self): def auto_refresh_timeline(self):
"""Automatically refresh the timeline - DELEGATED TO TIMELINE""" """Automatically refresh the timeline - DELEGATED TO TIMELINE"""
@@ -437,12 +452,14 @@ class MainWindow(QMainWindow):
def update_refresh_mode(self): def update_refresh_mode(self):
"""Update refresh mode based on settings (0 = streaming, >0 = polling)""" """Update refresh mode based on settings (0 = streaming, >0 = polling)"""
# Prevent infinite recursion # Prevent infinite recursion
if hasattr(self, '_updating_refresh_mode') and self._updating_refresh_mode: if hasattr(self, "_updating_refresh_mode") and self._updating_refresh_mode:
return return
self._updating_refresh_mode = True self._updating_refresh_mode = True
try: try:
refresh_interval = self.settings.get_int('general', 'timeline_refresh_interval', 300) refresh_interval = self.settings.get_int(
"general", "timeline_refresh_interval", 300
)
should_stream = refresh_interval == 0 should_stream = refresh_interval == 0
# Check if server supports streaming # Check if server supports streaming
@@ -450,20 +467,30 @@ class MainWindow(QMainWindow):
active_account = self.account_manager.get_active_account() active_account = self.account_manager.get_active_account()
if active_account: if active_account:
# Disable streaming for known non-supporting servers # Disable streaming for known non-supporting servers
server_supports_streaming = self.check_server_streaming_support(active_account.instance_url) server_supports_streaming = self.check_server_streaming_support(
active_account.instance_url
)
if not server_supports_streaming: if not server_supports_streaming:
self.logger.info("Server does not support streaming, switching to polling") self.logger.info(
"Server does not support streaming, switching to polling"
)
should_stream = False should_stream = False
# Set a reasonable polling interval instead # Set a reasonable polling interval instead
if refresh_interval == 0: if refresh_interval == 0:
self.logger.debug("Using 2-minute polling instead of streaming") self.logger.debug(
"Using 2-minute polling instead of streaming"
)
# Don't save this change to settings, just use it temporarily # Don't save this change to settings, just use it temporarily
refresh_interval = 120 # 2 minutes refresh_interval = 120 # 2 minutes
# Only log refresh interval when it changes # Only log refresh interval when it changes
if (refresh_interval != self._last_logged_refresh_interval or if (
should_stream != self._last_logged_stream_mode): refresh_interval != self._last_logged_refresh_interval
self.logger.debug(f"Refresh interval = {refresh_interval} seconds, should_stream = {should_stream}") or should_stream != self._last_logged_stream_mode
):
self.logger.debug(
f"Refresh interval = {refresh_interval} seconds, should_stream = {should_stream}"
)
self._last_logged_refresh_interval = refresh_interval self._last_logged_refresh_interval = refresh_interval
self._last_logged_stream_mode = should_stream self._last_logged_stream_mode = should_stream
@@ -481,7 +508,7 @@ class MainWindow(QMainWindow):
try: try:
# Quick URL-based checks for known non-streaming servers # Quick URL-based checks for known non-streaming servers
url_lower = instance_url.lower() url_lower = instance_url.lower()
if 'gotosocial' in url_lower: if "gotosocial" in url_lower:
self.logger.debug("GoToSocial detected in URL - no streaming support") self.logger.debug("GoToSocial detected in URL - no streaming support")
return False return False
@@ -489,7 +516,11 @@ class MainWindow(QMainWindow):
active_account = self.account_manager.get_active_account() active_account = self.account_manager.get_active_account()
if active_account: if active_account:
client = self.account_manager.get_client_for_active_account() client = self.account_manager.get_client_for_active_account()
if client and hasattr(client, 'streaming_supported') and not client.streaming_supported: if (
client
and hasattr(client, "streaming_supported")
and not client.streaming_supported
):
self.logger.debug("Server previously failed streaming attempts") self.logger.debug("Server previously failed streaming attempts")
return False return False
@@ -497,8 +528,8 @@ class MainWindow(QMainWindow):
if client: if client:
try: try:
instance_info = client.get_instance_info() instance_info = client.get_instance_info()
version = instance_info.get('version', '').lower() version = instance_info.get("version", "").lower()
if 'gotosocial' in version: if "gotosocial" in version:
self.logger.debug(f"GoToSocial detected via API: {version}") self.logger.debug(f"GoToSocial detected via API: {version}")
return False return False
except Exception as e: except Exception as e:
@@ -519,24 +550,30 @@ class MainWindow(QMainWindow):
if not active_account: if not active_account:
self.logger.warning("No active account, cannot start streaming") self.logger.warning("No active account, cannot start streaming")
return return
self.logger.debug(f"Active account: {active_account.username}@{active_account.instance_url}") self.logger.debug(
f"Active account: {active_account.username}@{active_account.instance_url}"
)
try: try:
# Stop any existing streaming # Stop any existing streaming
self.stop_streaming_mode() self.stop_streaming_mode()
# Create streaming client if needed # Create streaming client if needed
if not self.streaming_client or self.streaming_client.instance_url != active_account.instance_url: if (
self.streaming_client = self.account_manager.get_client_for_active_account() not self.streaming_client
or self.streaming_client.instance_url != active_account.instance_url
):
self.streaming_client = (
self.account_manager.get_client_for_active_account()
)
if not self.streaming_client: if not self.streaming_client:
return return
# Start streaming for current timeline type # Start streaming for current timeline type
timeline_type = self.timeline.timeline_type timeline_type = self.timeline.timeline_type
if timeline_type in ['home', 'local', 'federated', 'notifications']: if timeline_type in ["home", "local", "federated", "notifications"]:
self.streaming_client.start_streaming( self.streaming_client.start_streaming(
timeline_type, timeline_type, callback=self.handle_streaming_event
callback=self.handle_streaming_event
) )
self.streaming_mode = True self.streaming_mode = True
self.logger.info(f"Started streaming for {timeline_type} timeline") self.logger.info(f"Started streaming for {timeline_type} timeline")
@@ -559,13 +596,13 @@ class MainWindow(QMainWindow):
def handle_streaming_event(self, event_type: str, data): def handle_streaming_event(self, event_type: str, data):
"""Handle real-time streaming events""" """Handle real-time streaming events"""
try: try:
if event_type == 'new_post': if event_type == "new_post":
# New post received via streaming # New post received via streaming
self.add_streaming_post(data) self.add_streaming_post(data)
elif event_type == 'new_notification': elif event_type == "new_notification":
# New notification received # New notification received
self.handle_streaming_notification(data) self.handle_streaming_notification(data)
elif event_type == 'delete_post': elif event_type == "delete_post":
# Post deleted # Post deleted
self.remove_streaming_post(data) self.remove_streaming_post(data)
except Exception as e: except Exception as e:
@@ -579,51 +616,64 @@ class MainWindow(QMainWindow):
# Trigger a refresh to show new content # Trigger a refresh to show new content
# In future, could add the post directly to avoid full refresh # In future, could add the post directly to avoid full refresh
if not self.is_initial_load: if not self.is_initial_load:
self.logger.debug(f"New streaming post received for {current_timeline} timeline") self.logger.debug(
f"New streaming post received for {current_timeline} timeline"
)
# Show notification of new content # Show notification of new content
timeline_name = { timeline_name = {
'home': 'home timeline', "home": "home timeline",
'local': 'local timeline', "local": "local timeline",
'federated': 'federated timeline' "federated": "federated timeline",
}.get(current_timeline, 'timeline') }.get(current_timeline, "timeline")
if hasattr(self.timeline, 'notification_manager') and not self.timeline.skip_notifications: if (
hasattr(self.timeline, "notification_manager")
and not self.timeline.skip_notifications
):
self.timeline.notification_manager.notify_new_content(timeline_name) self.timeline.notification_manager.notify_new_content(timeline_name)
# Play sound for new content # Play sound for new content
if hasattr(self.timeline, 'sound_manager'): if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.play_timeline_update() self.timeline.sound_manager.play_timeline_update()
# Try to add streaming post directly instead of full refresh # Try to add streaming post directly instead of full refresh
self.logger.debug("Adding streaming post directly to timeline") self.logger.debug("Adding streaming post directly to timeline")
try: try:
from models.post import Post from models.post import Post
streaming_post = Post.from_api_dict(post_data) streaming_post = Post.from_api_dict(post_data)
self.timeline.add_streaming_post_to_timeline(streaming_post) self.timeline.add_streaming_post_to_timeline(streaming_post)
except Exception as e: except Exception as e:
self.logger.warning(f"Failed to add streaming post directly, falling back to refresh: {e}") self.logger.warning(
f"Failed to add streaming post directly, falling back to refresh: {e}"
)
self.timeline.refresh(preserve_position=True) self.timeline.refresh(preserve_position=True)
def handle_streaming_notification(self, notification_data): def handle_streaming_notification(self, notification_data):
"""Handle new notifications received via streaming""" """Handle new notifications received via streaming"""
self.logger.debug(f"New streaming notification received: {notification_data.get('type', 'unknown')}") self.logger.debug(
f"New streaming notification received: {notification_data.get('type', 'unknown')}"
)
# If we're on the notifications timeline, refresh to show the new notification # If we're on the notifications timeline, refresh to show the new notification
if self.timeline.timeline_type == 'notifications': if self.timeline.timeline_type == "notifications":
self.logger.debug("Refreshing notifications timeline for new notification") self.logger.debug("Refreshing notifications timeline for new notification")
self.timeline.refresh(preserve_position=True) self.timeline.refresh(preserve_position=True)
# Play appropriate notification sound based on type # Play appropriate notification sound based on type
if (hasattr(self.timeline, 'sound_manager') and not self.timeline.skip_notifications): if (
notification_type = notification_data.get('type', 'notification') hasattr(self.timeline, "sound_manager")
if notification_type == 'mention': and not self.timeline.skip_notifications
):
notification_type = notification_data.get("type", "notification")
if notification_type == "mention":
self.timeline.sound_manager.play_mention() self.timeline.sound_manager.play_mention()
elif notification_type == 'reblog': elif notification_type == "reblog":
self.timeline.sound_manager.play_boost() self.timeline.sound_manager.play_boost()
elif notification_type == 'favourite': elif notification_type == "favourite":
self.timeline.sound_manager.play_favorite() self.timeline.sound_manager.play_favorite()
elif notification_type == 'follow': elif notification_type == "follow":
self.timeline.sound_manager.play_follow() self.timeline.sound_manager.play_follow()
else: else:
self.timeline.sound_manager.play_notification() self.timeline.sound_manager.play_notification()
@@ -645,19 +695,18 @@ class MainWindow(QMainWindow):
# Use centralized PostManager instead of duplicate logic # Use centralized PostManager instead of duplicate logic
success = self.post_manager.create_post( success = self.post_manager.create_post(
content=post_data.get('content', ''), content=post_data.get("content", ""),
visibility=post_data.get('visibility', 'public'), visibility=post_data.get("visibility", "public"),
content_type=post_data.get('content_type', 'text/plain'), content_type=post_data.get("content_type", "text/plain"),
content_warning=post_data.get('content_warning'), content_warning=post_data.get("content_warning"),
in_reply_to_id=post_data.get('in_reply_to_id'), in_reply_to_id=post_data.get("in_reply_to_id"),
poll=post_data.get('poll'), poll=post_data.get("poll"),
media_ids=post_data.get('media_ids') media_ids=post_data.get("media_ids"),
) )
if not success: if not success:
self.error_manager.handle_validation_error( self.error_manager.handle_validation_error(
"Failed to start post submission", "Failed to start post submission", context="post_creation"
context="post_creation"
) )
def on_post_success(self, result_data): def on_post_success(self, result_data):
@@ -666,7 +715,7 @@ class MainWindow(QMainWindow):
self.error_manager.show_success_message( self.error_manager.show_success_message(
"Post sent successfully!", "Post sent successfully!",
context="post_creation", context="post_creation",
play_sound=False # PostManager already plays sound play_sound=False, # PostManager already plays sound
) )
# Refresh timeline to show the new post # Refresh timeline to show the new post
@@ -676,8 +725,7 @@ class MainWindow(QMainWindow):
"""Handle failed post submission - CENTRALIZED VIA POSTMANAGER""" """Handle failed post submission - CENTRALIZED VIA POSTMANAGER"""
# Note: Error sound is handled by PostManager to avoid duplication # Note: Error sound is handled by PostManager to avoid duplication
self.error_manager.handle_api_error( self.error_manager.handle_api_error(
f"Post failed: {error_message}", f"Post failed: {error_message}", context="post_creation"
context="post_creation"
) )
def show_settings(self): def show_settings(self):
@@ -689,7 +737,7 @@ class MainWindow(QMainWindow):
def on_settings_changed(self): def on_settings_changed(self):
"""Handle settings changes""" """Handle settings changes"""
# Reload sound manager with new settings # Reload sound manager with new settings
if hasattr(self.timeline, 'sound_manager'): if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.reload_settings() self.timeline.sound_manager.reload_settings()
# Check if refresh mode changed # Check if refresh mode changed
@@ -713,15 +761,40 @@ class MainWindow(QMainWindow):
def switch_timeline(self, index, from_tab_change=False): def switch_timeline(self, index, from_tab_change=False):
"""Switch to timeline by index with loading feedback""" """Switch to timeline by index with loading feedback"""
timeline_names = ["Home", "Messages", "Notifications", "Local", "Federated", "Bookmarks", "Followers", "Following", "Blocked", "Muted"] timeline_names = [
timeline_types = ["home", "conversations", "notifications", "local", "federated", "bookmarks", "followers", "following", "blocked", "muted"] "Home",
"Messages",
"Notifications",
"Local",
"Federated",
"Bookmarks",
"Followers",
"Following",
"Blocked",
"Muted",
]
timeline_types = [
"home",
"conversations",
"notifications",
"local",
"federated",
"bookmarks",
"followers",
"following",
"blocked",
"muted",
]
if 0 <= index < len(timeline_names): if 0 <= index < len(timeline_names):
timeline_name = timeline_names[index] timeline_name = timeline_names[index]
timeline_type = timeline_types[index] timeline_type = timeline_types[index]
# Prevent duplicate calls for the same timeline # Prevent duplicate calls for the same timeline
if hasattr(self, '_current_timeline_switching') and self._current_timeline_switching: if (
hasattr(self, "_current_timeline_switching")
and self._current_timeline_switching
):
return return
self._current_timeline_switching = True self._current_timeline_switching = True
@@ -747,13 +820,18 @@ class MainWindow(QMainWindow):
except Exception as e: except Exception as e:
# Error feedback # Error feedback
if hasattr(self.timeline, 'sound_manager'): if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.play_error() self.timeline.sound_manager.play_error()
self.status_bar.showMessage(f"Failed to load {timeline_name} timeline: {str(e)}", 3000) self.status_bar.showMessage(
f"Failed to load {timeline_name} timeline: {str(e)}", 3000
)
finally: finally:
# Reset the flag after a brief delay to allow the operation to complete # Reset the flag after a brief delay to allow the operation to complete
from PySide6.QtCore import QTimer from PySide6.QtCore import QTimer
QTimer.singleShot(100, lambda: setattr(self, '_current_timeline_switching', False))
QTimer.singleShot(
100, lambda: setattr(self, "_current_timeline_switching", False)
)
def get_selected_post(self): def get_selected_post(self):
"""Get the currently selected post from timeline""" """Get the currently selected post from timeline"""
@@ -812,7 +890,7 @@ class MainWindow(QMainWindow):
"Welcome to Bifrost! You need to add a fediverse account to get started.\n\n" "Welcome to Bifrost! You need to add a fediverse account to get started.\n\n"
"Would you like to add an account now?", "Would you like to add an account now?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes QMessageBox.Yes,
) )
if result == QMessageBox.Yes: if result == QMessageBox.Yes:
@@ -837,22 +915,106 @@ class MainWindow(QMainWindow):
account = self.account_manager.get_account_by_id(account_id) account = self.account_manager.get_account_by_id(account_id)
if account: if account:
self.update_status_label() self.update_status_label()
self.status_bar.showMessage(f"Switched to {account.get_display_text()}", 2000) self.status_bar.showMessage(
f"Switched to {account.get_display_text()}", 2000
)
# Refresh timeline with new account # Refresh timeline with new account
self.timeline.request_post_action_refresh("account_action") self.timeline.request_post_action_refresh("account_action")
def reply_to_post(self, post): def reply_to_post(self, post):
"""Reply to a specific post""" """Reply to a specific post or conversation"""
dialog = ComposeDialog(self.account_manager, self) dialog = ComposeDialog(self.account_manager, self)
# Pre-fill with reply mention using full fediverse handle # Use the new setup_reply method to handle visibility and text
dialog.text_edit.setPlainText(f"@{post.account.acct} ") dialog.setup_reply(post)
# Move cursor to end
cursor = dialog.text_edit.textCursor() # Handle different types of replies
cursor.movePosition(QTextCursor.MoveOperation.End) if hasattr(post, "conversation") and post.conversation:
dialog.text_edit.setTextCursor(cursor) # This is a conversation - send as direct message to participants
dialog.post_sent.connect(lambda data: self.on_post_sent({**data, 'in_reply_to_id': post.id})) dialog.post_sent.connect(
lambda data: self.on_conversation_reply_sent(post, data)
)
else:
# This is a regular post - reply normally
dialog.post_sent.connect(
lambda data: self.on_post_sent({**data, "in_reply_to_id": post.id})
)
dialog.exec() dialog.exec()
def on_conversation_reply_sent(self, conversation_post, data):
"""Handle sending a reply to a conversation"""
try:
active_account = self.account_manager.get_active_account()
if not active_account:
return
client = self.account_manager.get_client_for_active_account()
if not client:
return
# Get conversation participants
participants = []
if (
hasattr(conversation_post, "conversation")
and conversation_post.conversation
):
for account in conversation_post.conversation.accounts:
# Don't include ourselves in the mention
if account.acct != active_account.username:
participants.append(f"@{account.acct}")
# Add participants to the message content if not already mentioned
content = data.get("content", "")
for participant in participants:
if participant not in content:
content = f"{participant} {content}"
# Check if this is a Pleroma chat conversation
if (
hasattr(conversation_post.conversation, "chat_id")
and conversation_post.conversation.chat_id
):
# Use Pleroma chat API
try:
result = client.send_pleroma_chat_message(
conversation_post.conversation.chat_id, content.strip()
)
self.logger.info(
f"Sent Pleroma chat message to conversation {conversation_post.conversation.chat_id}"
)
self.post_manager._handle_post_success(result)
# Refresh timeline to show the new message
self.timeline.request_post_action_refresh("conversation_reply")
except Exception as e:
self.logger.error(f"Failed to send Pleroma chat message: {e}")
# Fall back to regular direct message
self.send_direct_message_reply(client, content, data)
else:
# Send as regular direct message
self.send_direct_message_reply(client, content, data)
except Exception as e:
self.logger.error(f"Failed to send conversation reply: {e}")
self.post_manager._handle_post_failed(str(e))
def send_direct_message_reply(self, client, content, data):
"""Send a direct message reply to conversation participants"""
try:
# Create a direct message post
result = client.post_status(
content=content,
visibility="direct",
content_warning=data.get("content_warning"),
media_ids=data.get("media_ids", []),
)
self.logger.info("Sent direct message reply to conversation")
self.post_manager._handle_post_success(result)
# Refresh timeline to show the new message
self.timeline.request_post_action_refresh("conversation_reply")
except Exception as e:
self.logger.error(f"Failed to send direct message reply: {e}")
self.post_manager._handle_post_failed(str(e))
def boost_post(self, post): def boost_post(self, post):
"""Boost/unboost a post""" """Boost/unboost a post"""
active_account = self.account_manager.get_active_account() active_account = self.account_manager.get_active_account()
@@ -870,7 +1032,7 @@ class MainWindow(QMainWindow):
client.reblog_status(post.id) client.reblog_status(post.id)
self.status_bar.showMessage("Post boosted", 2000) self.status_bar.showMessage("Post boosted", 2000)
# Play boost sound for successful boost # Play boost sound for successful boost
if hasattr(self.timeline, 'sound_manager'): if hasattr(self.timeline, "sound_manager"):
self.logger.debug("Playing boost sound for user boost action") self.logger.debug("Playing boost sound for user boost action")
self.timeline.sound_manager.play_boost() self.timeline.sound_manager.play_boost()
# Refresh timeline to show updated state # Refresh timeline to show updated state
@@ -895,7 +1057,7 @@ class MainWindow(QMainWindow):
client.favourite_status(post.id) client.favourite_status(post.id)
self.status_bar.showMessage("Post favorited", 2000) self.status_bar.showMessage("Post favorited", 2000)
# Play favorite sound for successful favorite # Play favorite sound for successful favorite
if hasattr(self.timeline, 'sound_manager'): if hasattr(self.timeline, "sound_manager"):
self.logger.debug("Playing favorite sound for user favorite action") self.logger.debug("Playing favorite sound for user favorite action")
self.timeline.sound_manager.play_favorite() self.timeline.sound_manager.play_favorite()
# Refresh timeline to show updated state # Refresh timeline to show updated state
@@ -912,26 +1074,28 @@ class MainWindow(QMainWindow):
# Create User object from Account data # Create User object from Account data
account = post.account account = post.account
account_data = { account_data = {
'id': account.id, "id": account.id,
'username': account.username, "username": account.username,
'acct': account.acct, "acct": account.acct,
'display_name': account.display_name, "display_name": account.display_name,
'note': account.note, "note": account.note,
'url': account.url, "url": account.url,
'avatar': account.avatar, "avatar": account.avatar,
'avatar_static': account.avatar_static, "avatar_static": account.avatar_static,
'header': account.header, "header": account.header,
'header_static': account.header_static, "header_static": account.header_static,
'locked': account.locked, "locked": account.locked,
'bot': account.bot, "bot": account.bot,
'discoverable': account.discoverable, "discoverable": account.discoverable,
'group': account.group, "group": account.group,
'created_at': account.created_at.isoformat() if account.created_at else None, "created_at": (
'followers_count': account.followers_count, account.created_at.isoformat() if account.created_at else None
'following_count': account.following_count, ),
'statuses_count': account.statuses_count, "followers_count": account.followers_count,
'fields': [], # Will be loaded from API "following_count": account.following_count,
'emojis': [] # Will be loaded from API "statuses_count": account.statuses_count,
"fields": [], # Will be loaded from API
"emojis": [], # Will be loaded from API
} }
user = User.from_api_dict(account_data) user = User.from_api_dict(account_data)
@@ -941,29 +1105,34 @@ class MainWindow(QMainWindow):
account_manager=self.account_manager, account_manager=self.account_manager,
sound_manager=self.timeline.sound_manager, sound_manager=self.timeline.sound_manager,
initial_user=user, initial_user=user,
parent=self parent=self,
) )
dialog.exec() dialog.exec()
except Exception as e: except Exception as e:
self.status_bar.showMessage(f"Error opening profile: {str(e)}", 3000) self.status_bar.showMessage(f"Error opening profile: {str(e)}", 3000)
if hasattr(self.timeline, 'sound_manager'): if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.play_error() self.timeline.sound_manager.play_error()
def update_status_label(self): def update_status_label(self):
"""Update the status label with current account info""" """Update the status label with current account info"""
active_account = self.account_manager.get_active_account() active_account = self.account_manager.get_active_account()
if active_account: if active_account:
self.status_label.setText(f"Connected as {active_account.get_display_text()}") self.status_label.setText(
f"Connected as {active_account.get_display_text()}"
)
else: else:
self.status_label.setText("No account connected") self.status_label.setText("No account connected")
def quit_application(self): def quit_application(self):
"""Quit the application with shutdown sound""" """Quit the application with shutdown sound"""
self._shutdown_sound_played = True # Mark that we're handling the shutdown sound self._shutdown_sound_played = (
if hasattr(self.timeline, 'sound_manager'): True # Mark that we're handling the shutdown sound
)
if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.play_shutdown() self.timeline.sound_manager.play_shutdown()
# Wait briefly for sound to start playing # Wait briefly for sound to start playing
from PySide6.QtCore import QTimer from PySide6.QtCore import QTimer
QTimer.singleShot(500, self.close) QTimer.singleShot(500, self.close)
else: else:
self.close() self.close()
@@ -1006,12 +1175,17 @@ class MainWindow(QMainWindow):
# Check if this is user's own post # Check if this is user's own post
active_account = self.account_manager.get_active_account() active_account = self.account_manager.get_active_account()
if not active_account or not hasattr(post, 'account'): if not active_account or not hasattr(post, "account"):
self.status_bar.showMessage("Cannot delete: No active account", 2000) self.status_bar.showMessage("Cannot delete: No active account", 2000)
return return
is_own_post = (post.account.username == active_account.username and is_own_post = (
post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', '')) post.account.username == active_account.username
and post.account.acct.split("@")[-1]
== active_account.instance_url.replace("https://", "").replace(
"http://", ""
)
)
if not is_own_post: if not is_own_post:
self.status_bar.showMessage("Cannot delete: Not your post", 2000) self.status_bar.showMessage("Cannot delete: Not your post", 2000)
@@ -1023,9 +1197,9 @@ class MainWindow(QMainWindow):
result = QMessageBox.question( result = QMessageBox.question(
self, self,
"Delete Post", "Delete Post",
f"Are you sure you want to delete this post?\n\n\"{content_preview}\"", f'Are you sure you want to delete this post?\n\n"{content_preview}"',
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes | QMessageBox.No,
QMessageBox.No QMessageBox.No,
) )
if result == QMessageBox.Yes: if result == QMessageBox.Yes:
@@ -1044,12 +1218,17 @@ class MainWindow(QMainWindow):
"""Edit a post""" """Edit a post"""
# Check if this is user's own post # Check if this is user's own post
active_account = self.account_manager.get_active_account() active_account = self.account_manager.get_active_account()
if not active_account or not hasattr(post, 'account'): if not active_account or not hasattr(post, "account"):
self.status_bar.showMessage("Cannot edit: No active account", 2000) self.status_bar.showMessage("Cannot edit: No active account", 2000)
return return
is_own_post = (post.account.username == active_account.username and is_own_post = (
post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', '')) post.account.username == active_account.username
and post.account.acct.split("@")[-1]
== active_account.instance_url.replace("https://", "").replace(
"http://", ""
)
)
if not is_own_post: if not is_own_post:
self.status_bar.showMessage("Cannot edit: Not your post", 2000) self.status_bar.showMessage("Cannot edit: Not your post", 2000)
@@ -1070,10 +1249,10 @@ class MainWindow(QMainWindow):
return return
client.edit_status( client.edit_status(
post.id, post.id,
content=data['content'], content=data["content"],
visibility=data['visibility'], visibility=data["visibility"],
content_type=data.get('content_type', 'text/plain'), content_type=data.get("content_type", "text/plain"),
content_warning=data['content_warning'] content_warning=data["content_warning"],
) )
self.status_bar.showMessage("Post edited successfully", 2000) self.status_bar.showMessage("Post edited successfully", 2000)
# Refresh timeline to show edited post # Refresh timeline to show edited post
@@ -1087,7 +1266,7 @@ class MainWindow(QMainWindow):
def follow_user(self, post): def follow_user(self, post):
"""Follow a user""" """Follow a user"""
active_account = self.account_manager.get_active_account() active_account = self.account_manager.get_active_account()
if not active_account or not hasattr(post, 'account'): if not active_account or not hasattr(post, "account"):
self.status_bar.showMessage("Cannot follow: No active account", 2000) self.status_bar.showMessage("Cannot follow: No active account", 2000)
return return
@@ -1099,7 +1278,7 @@ class MainWindow(QMainWindow):
username = post.account.display_name or post.account.username username = post.account.display_name or post.account.username
self.status_bar.showMessage(f"Followed {username}", 2000) self.status_bar.showMessage(f"Followed {username}", 2000)
# Play follow sound for successful follow # Play follow sound for successful follow
if hasattr(self.timeline, 'sound_manager'): if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.play_follow() self.timeline.sound_manager.play_follow()
except Exception as e: except Exception as e:
self.status_bar.showMessage(f"Follow failed: {str(e)}", 3000) self.status_bar.showMessage(f"Follow failed: {str(e)}", 3000)
@@ -1107,7 +1286,7 @@ class MainWindow(QMainWindow):
def unfollow_user(self, post): def unfollow_user(self, post):
"""Unfollow a user""" """Unfollow a user"""
active_account = self.account_manager.get_active_account() active_account = self.account_manager.get_active_account()
if not active_account or not hasattr(post, 'account'): if not active_account or not hasattr(post, "account"):
self.status_bar.showMessage("Cannot unfollow: No active account", 2000) self.status_bar.showMessage("Cannot unfollow: No active account", 2000)
return return
@@ -1119,14 +1298,21 @@ class MainWindow(QMainWindow):
username = post.account.display_name or post.account.username username = post.account.display_name or post.account.username
self.status_bar.showMessage(f"Unfollowed {username}", 2000) self.status_bar.showMessage(f"Unfollowed {username}", 2000)
# Play unfollow sound for successful unfollow # Play unfollow sound for successful unfollow
if hasattr(self.timeline, 'sound_manager'): if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.play_unfollow() self.timeline.sound_manager.play_unfollow()
except Exception as e: except Exception as e:
self.status_bar.showMessage(f"Unfollow failed: {str(e)}", 3000) self.status_bar.showMessage(f"Unfollow failed: {str(e)}", 3000)
def show_manual_follow_dialog(self): def show_manual_follow_dialog(self):
"""Show dialog to manually follow a user by @username@instance""" """Show dialog to manually follow a user by @username@instance"""
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLineEdit, QLabel, QDialogButtonBox, QPushButton from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QLineEdit,
QLabel,
QDialogButtonBox,
QPushButton,
)
dialog = QDialog(self) dialog = QDialog(self)
dialog.setWindowTitle("Follow User") dialog.setWindowTitle("Follow User")
@@ -1176,7 +1362,7 @@ class MainWindow(QMainWindow):
return return
# Remove @ prefix if present # Remove @ prefix if present
if username.startswith('@'): if username.startswith("@"):
username = username[1:] username = username[1:]
try: try:
@@ -1193,7 +1379,10 @@ class MainWindow(QMainWindow):
# Find exact match # Find exact match
target_account = None target_account = None
for account in accounts: for account in accounts:
if account['acct'] == username or account['username'] == username.split('@')[0]: if (
account["acct"] == username
or account["username"] == username.split("@")[0]
):
target_account = account target_account = account
break break
@@ -1202,11 +1391,13 @@ class MainWindow(QMainWindow):
return return
# Follow the account # Follow the account
client.follow_account(target_account['id']) client.follow_account(target_account["id"])
display_name = target_account.get('display_name') or target_account['username'] display_name = (
target_account.get("display_name") or target_account["username"]
)
self.status_bar.showMessage(f"Followed {display_name}", 2000) self.status_bar.showMessage(f"Followed {display_name}", 2000)
# Play follow sound for successful follow # Play follow sound for successful follow
if hasattr(self.timeline, 'sound_manager'): if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.play_follow() self.timeline.sound_manager.play_follow()
except Exception as e: except Exception as e:
@@ -1231,13 +1422,18 @@ class MainWindow(QMainWindow):
def block_user(self, post): def block_user(self, post):
"""Block a user with confirmation dialog""" """Block a user with confirmation dialog"""
active_account = self.account_manager.get_active_account() active_account = self.account_manager.get_active_account()
if not active_account or not hasattr(post, 'account'): if not active_account or not hasattr(post, "account"):
self.status_bar.showMessage("Cannot block: No active account", 2000) self.status_bar.showMessage("Cannot block: No active account", 2000)
return return
# Don't allow blocking yourself # Don't allow blocking yourself
is_own_post = (post.account.username == active_account.username and is_own_post = (
post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', '')) post.account.username == active_account.username
and post.account.acct.split("@")[-1]
== active_account.instance_url.replace("https://", "").replace(
"http://", ""
)
)
if is_own_post: if is_own_post:
self.status_bar.showMessage("Cannot block: Cannot block yourself", 2000) self.status_bar.showMessage("Cannot block: Cannot block yourself", 2000)
@@ -1253,7 +1449,7 @@ class MainWindow(QMainWindow):
f"Are you sure you want to block {username} ({full_username})?\n\n" f"Are you sure you want to block {username} ({full_username})?\n\n"
"This will prevent them from following you and seeing your posts.", "This will prevent them from following you and seeing your posts.",
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes | QMessageBox.No,
QMessageBox.No QMessageBox.No,
) )
if result == QMessageBox.Yes: if result == QMessageBox.Yes:
@@ -1264,23 +1460,28 @@ class MainWindow(QMainWindow):
client.block_account(post.account.id) client.block_account(post.account.id)
self.status_bar.showMessage(f"Blocked {username}", 2000) self.status_bar.showMessage(f"Blocked {username}", 2000)
# Play success sound for successful block # Play success sound for successful block
if hasattr(self.timeline, 'sound_manager'): if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.play_success() self.timeline.sound_manager.play_success()
except Exception as e: except Exception as e:
self.status_bar.showMessage(f"Block failed: {str(e)}", 3000) self.status_bar.showMessage(f"Block failed: {str(e)}", 3000)
if hasattr(self.timeline, 'sound_manager'): if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.play_error() self.timeline.sound_manager.play_error()
def mute_user(self, post): def mute_user(self, post):
"""Mute a user""" """Mute a user"""
active_account = self.account_manager.get_active_account() active_account = self.account_manager.get_active_account()
if not active_account or not hasattr(post, 'account'): if not active_account or not hasattr(post, "account"):
self.status_bar.showMessage("Cannot mute: No active account", 2000) self.status_bar.showMessage("Cannot mute: No active account", 2000)
return return
# Don't allow muting yourself # Don't allow muting yourself
is_own_post = (post.account.username == active_account.username and is_own_post = (
post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', '')) post.account.username == active_account.username
and post.account.acct.split("@")[-1]
== active_account.instance_url.replace("https://", "").replace(
"http://", ""
)
)
if is_own_post: if is_own_post:
self.status_bar.showMessage("Cannot mute: Cannot mute yourself", 2000) self.status_bar.showMessage("Cannot mute: Cannot mute yourself", 2000)
@@ -1294,11 +1495,11 @@ class MainWindow(QMainWindow):
username = post.account.display_name or post.account.username username = post.account.display_name or post.account.username
self.status_bar.showMessage(f"Muted {username}", 2000) self.status_bar.showMessage(f"Muted {username}", 2000)
# Play success sound for successful mute # Play success sound for successful mute
if hasattr(self.timeline, 'sound_manager'): if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.play_success() self.timeline.sound_manager.play_success()
except Exception as e: except Exception as e:
self.status_bar.showMessage(f"Mute failed: {str(e)}", 3000) self.status_bar.showMessage(f"Mute failed: {str(e)}", 3000)
if hasattr(self.timeline, 'sound_manager'): if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.play_error() self.timeline.sound_manager.play_error()
def closeEvent(self, event): def closeEvent(self, event):
@@ -1307,10 +1508,13 @@ class MainWindow(QMainWindow):
self.stop_streaming_mode() self.stop_streaming_mode()
# Only play shutdown sound if not already played through quit_application # Only play shutdown sound if not already played through quit_application
if not hasattr(self, '_shutdown_sound_played') and hasattr(self.timeline, 'sound_manager'): if not hasattr(self, "_shutdown_sound_played") and hasattr(
self.timeline, "sound_manager"
):
self.timeline.sound_manager.play_shutdown() self.timeline.sound_manager.play_shutdown()
# Wait briefly for sound to complete # Wait briefly for sound to complete
from PySide6.QtCore import QTimer, QEventLoop from PySide6.QtCore import QTimer, QEventLoop
loop = QEventLoop() loop = QEventLoop()
QTimer.singleShot(500, loop.quit) QTimer.singleShot(500, loop.quit)
loop.exec() loop.exec()
+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):
+148 -48
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,6 +32,7 @@ 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"""
@@ -33,7 +44,7 @@ class ComposeDialog(QDialog):
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()
@@ -54,8 +65,12 @@ class ComposeDialog(QDialog):
# 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)
@@ -73,18 +88,17 @@ class ComposeDialog(QDialog):
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")
@@ -101,10 +115,14 @@ class ComposeDialog(QDialog):
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)
@@ -127,7 +145,9 @@ class ComposeDialog(QDialog):
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)...")
@@ -143,7 +163,9 @@ class ComposeDialog(QDialog):
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
@@ -174,7 +196,9 @@ class ComposeDialog(QDialog):
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,7 +206,9 @@ 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
@@ -219,11 +245,62 @@ class ComposeDialog(QDialog):
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:
@@ -260,7 +337,9 @@ class ComposeDialog(QDialog):
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"""
@@ -271,7 +350,9 @@ class ComposeDialog(QDialog):
# 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
@@ -280,7 +361,7 @@ class ComposeDialog(QDialog):
"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")
@@ -302,14 +383,17 @@ class ComposeDialog(QDialog):
# 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
@@ -324,13 +408,13 @@ class ComposeDialog(QDialog):
# 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
@@ -350,7 +434,7 @@ class ComposeDialog(QDialog):
# 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()
@@ -361,10 +445,14 @@ class ComposeDialog(QDialog):
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:
@@ -380,10 +468,14 @@ class ComposeDialog(QDialog):
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:
@@ -399,10 +491,14 @@ class ComposeDialog(QDialog):
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:
@@ -437,13 +533,17 @@ class ComposeDialog(QDialog):
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
+263 -45
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
@@ -27,27 +39,24 @@ class FetchDetailsThread(QThread):
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}")
@@ -60,12 +69,18 @@ class FetchDetailsThread(QThread):
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)
@@ -78,41 +93,27 @@ class PostDetailsDialog(QDialog):
"""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,7 +121,9 @@ 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()
@@ -146,7 +149,7 @@ class PostDetailsDialog(QDialog):
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
@@ -161,7 +164,7 @@ class PostDetailsDialog(QDialog):
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:
@@ -179,7 +182,7 @@ class PostDetailsDialog(QDialog):
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:
@@ -199,8 +202,15 @@ class PostDetailsDialog(QDialog):
# 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()
@@ -218,3 +228,211 @@ class PostDetailsDialog(QDialog):
# 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