From cd535aebdf4b4b83e806098f0e0504604f4b0897 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Wed, 23 Jul 2025 03:45:47 -0400 Subject: [PATCH] Fix context menu accessibility, conversation replies, and integrate polls into post details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 81 ++- README.md | 9 +- src/activitypub/client.py | 10 +- src/main_window.py | 900 ++++++++++++++--------- src/managers/post_manager.py | 10 +- src/widgets/compose_dialog.py | 338 ++++++--- src/widgets/post_details_dialog.py | 376 ++++++++-- src/widgets/timeline_view.py | 1084 ++++++++++++++++------------ 8 files changed, 1816 insertions(+), 992 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3edfea1..589e2b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) 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. **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 @@ -611,4 +612,82 @@ Examples of forbidden practices: - Shortened usernames or descriptions - 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. \ No newline at end of file +### 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. diff --git a/README.md b/README.md index eaca659..74aa847 100644 --- a/README.md +++ b/README.md @@ -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 - **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 -- **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 - **Blocked/Muted Management**: Dedicated tabs for managing blocked and muted users - **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+Shift+B**: Block 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 - **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 ### 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 - **Navigable List**: Vote counts and percentages in an accessible list widget - **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 - **Clear Announcements**: Descriptive text for poll status and options - **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.) ## Accessibility Features @@ -283,6 +287,9 @@ Bifrost includes comprehensive poll support with full accessibility: - Accessible names and descriptions for all controls - Thread expansion/collapse with audio feedback - 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 diff --git a/src/activitypub/client.py b/src/activitypub/client.py index 10bf654..81085fd 100644 --- a/src/activitypub/client.py +++ b/src/activitypub/client.py @@ -156,12 +156,20 @@ class ActivityPubClient: data['spoiler_text'] = content_warning if 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: data['media_ids'] = media_ids if 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: """Delete a status""" diff --git a/src/main_window.py b/src/main_window.py index d17c89b..0b38ab4 100644 --- a/src/main_window.py +++ b/src/main_window.py @@ -3,8 +3,15 @@ Main application window for Bifrost """ from PySide6.QtWidgets import ( - QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, - QLabel, QMenuBar, QStatusBar, QPushButton, QTabWidget + QMainWindow, + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QMenuBar, + QStatusBar, + QPushButton, + QTabWidget, ) from PySide6.QtCore import Qt, Signal, QTimer from PySide6.QtGui import QKeySequence, QAction, QTextCursor @@ -27,71 +34,73 @@ from managers.error_manager import ErrorManager class MainWindow(QMainWindow): """Main Bifrost application window""" - + def __init__(self): super().__init__() self.settings = SettingsManager() self.account_manager = AccountManager(self.settings) - self.logger = logging.getLogger('bifrost.main') - + self.logger = logging.getLogger("bifrost.main") + # Auto-refresh tracking self.last_activity_time = time.time() self.is_initial_load = True # Flag to skip notifications on first load - + # Refresh mode logging state tracking self._last_logged_refresh_interval = None self._last_logged_stream_mode = None - + self.setup_ui() - + # 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 - 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 self.error_manager = ErrorManager(self, self.sound_coordinator) - + # Post management using sound coordinator self.post_manager = PostManager(self.account_manager, timeline_sound_manager) self.post_manager.post_success.connect(self.on_post_success) self.post_manager.post_failed.connect(self.on_post_failed) - + self.setup_menus() self.setup_shortcuts() self.setup_auto_refresh() - + # Connect status bar to error manager after both are created self.error_manager.set_status_bar(self.status_bar) - + # Check if we need to show login dialog if not self.account_manager.has_accounts(): self.show_first_time_setup() - + # Play startup sound through coordinator to prevent duplicates if self.sound_coordinator: self.sound_coordinator.play_startup("application_startup") - + # Mark initial load as complete after startup QTimer.singleShot(2000, self.mark_initial_load_complete) - + def setup_ui(self): """Initialize the user interface""" self.setWindowTitle("Bifrost - Fediverse Client") self.setMinimumSize(800, 600) - + # Central widget central_widget = QWidget() self.setCentralWidget(central_widget) main_layout = QVBoxLayout(central_widget) - + # Account selector self.account_selector = AccountSelector(self.account_manager) self.account_selector.account_changed.connect(self.on_account_changed) self.account_selector.add_account_requested.connect(self.show_login_dialog) main_layout.addWidget(self.account_selector) - + # Timeline tabs self.timeline_tabs = QTabWidget() self.timeline_tabs.setAccessibleName("Timeline Selection") @@ -107,13 +116,13 @@ class MainWindow(QMainWindow): self.timeline_tabs.addTab(QWidget(), "Muted") self.timeline_tabs.currentChanged.connect(self.on_timeline_tab_changed) main_layout.addWidget(self.timeline_tabs) - + # Status label for connection info self.status_label = QLabel() self.status_label.setAccessibleName("Connection Status") main_layout.addWidget(self.status_label) self.update_status_label() - + # Timeline view (main content area) self.timeline = TimelineView(self.account_manager) self.timeline.setAccessibleName("Timeline") @@ -126,7 +135,7 @@ class MainWindow(QMainWindow): self.timeline.follow_requested.connect(self.follow_user) self.timeline.unfollow_requested.connect(self.unfollow_user) main_layout.addWidget(self.timeline) - + # Compose button compose_layout = QHBoxLayout() self.compose_button = QPushButton("&Compose Post") @@ -135,338 +144,356 @@ class MainWindow(QMainWindow): compose_layout.addWidget(self.compose_button) compose_layout.addStretch() main_layout.addLayout(compose_layout) - + # Status bar self.status_bar = QStatusBar() self.setStatusBar(self.status_bar) self.status_bar.showMessage("Ready") - + def setup_menus(self): """Create application menus""" menubar = self.menuBar() - + # File menu file_menu = menubar.addMenu("&File") - + # New post action new_post_action = QAction("&New Post", self) new_post_action.setShortcut(QKeySequence.New) new_post_action.triggered.connect(self.show_compose_dialog) file_menu.addAction(new_post_action) - + file_menu.addSeparator() - + # Account management add_account_action = QAction("&Add Account", self) add_account_action.setShortcut(QKeySequence("Ctrl+Shift+A")) add_account_action.triggered.connect(self.show_login_dialog) file_menu.addAction(add_account_action) - + file_menu.addSeparator() - + # Settings action settings_action = QAction("&Settings", self) settings_action.setShortcut(QKeySequence.Preferences) settings_action.triggered.connect(self.show_settings) file_menu.addAction(settings_action) - + # Soundpack Manager action soundpack_action = QAction("Sound&pack Manager", self) soundpack_action.setShortcut(QKeySequence("Ctrl+Shift+P")) soundpack_action.triggered.connect(self.show_soundpack_manager) file_menu.addAction(soundpack_action) - + file_menu.addSeparator() - + # Quit action quit_action = QAction("&Quit", self) quit_action.setShortcut(QKeySequence.Quit) quit_action.triggered.connect(self.quit_application) file_menu.addAction(quit_action) - + # View menu view_menu = menubar.addMenu("&View") - + # Refresh timeline action refresh_action = QAction("&Refresh Timeline", self) refresh_action.setShortcut(QKeySequence.Refresh) refresh_action.triggered.connect(self.refresh_timeline) view_menu.addAction(refresh_action) - + # Timeline menu timeline_menu = menubar.addMenu("&Timeline") - + # Home timeline action home_action = QAction("&Home", self) home_action.setShortcut(QKeySequence("Ctrl+1")) home_action.triggered.connect(lambda: self.switch_timeline(0)) timeline_menu.addAction(home_action) - + # Messages timeline action messages_action = QAction("&Messages", self) messages_action.setShortcut(QKeySequence("Ctrl+2")) messages_action.triggered.connect(lambda: self.switch_timeline(1)) timeline_menu.addAction(messages_action) - + # Notifications timeline action notifications_action = QAction("&Notifications", self) notifications_action.setShortcut(QKeySequence("Ctrl+3")) notifications_action.triggered.connect(lambda: self.switch_timeline(2)) timeline_menu.addAction(notifications_action) - + # Local timeline action local_action = QAction("&Local", self) local_action.setShortcut(QKeySequence("Ctrl+4")) local_action.triggered.connect(lambda: self.switch_timeline(3)) timeline_menu.addAction(local_action) - + # Federated timeline action federated_action = QAction("&Federated", self) federated_action.setShortcut(QKeySequence("Ctrl+5")) federated_action.triggered.connect(lambda: self.switch_timeline(4)) timeline_menu.addAction(federated_action) - + # Bookmarks timeline action bookmarks_action = QAction("&Bookmarks", self) bookmarks_action.setShortcut(QKeySequence("Ctrl+6")) bookmarks_action.triggered.connect(lambda: self.switch_timeline(5)) timeline_menu.addAction(bookmarks_action) - + # Followers timeline action followers_action = QAction("Follo&wers", self) followers_action.setShortcut(QKeySequence("Ctrl+7")) followers_action.triggered.connect(lambda: self.switch_timeline(6)) timeline_menu.addAction(followers_action) - + # Following timeline action following_action = QAction("Follo&wing", self) following_action.setShortcut(QKeySequence("Ctrl+8")) following_action.triggered.connect(lambda: self.switch_timeline(7)) timeline_menu.addAction(following_action) - + # Blocked users timeline action blocked_action = QAction("Bloc&ked Users", self) blocked_action.setShortcut(QKeySequence("Ctrl+9")) blocked_action.triggered.connect(lambda: self.switch_timeline(8)) timeline_menu.addAction(blocked_action) - + # Muted users timeline action muted_action = QAction("M&uted Users", self) muted_action.setShortcut(QKeySequence("Ctrl+0")) muted_action.triggered.connect(lambda: self.switch_timeline(9)) timeline_menu.addAction(muted_action) - + # Post menu post_menu = menubar.addMenu("&Post") - + # Reply action reply_action = QAction("&Reply", self) reply_action.setShortcut(QKeySequence("Ctrl+R")) reply_action.triggered.connect(self.reply_to_current_post) post_menu.addAction(reply_action) - - # Boost action + + # Boost action boost_action = QAction("&Boost", self) boost_action.setShortcut(QKeySequence("Ctrl+B")) boost_action.triggered.connect(self.boost_current_post) post_menu.addAction(boost_action) - + # Favorite action favorite_action = QAction("&Favorite", self) favorite_action.setShortcut(QKeySequence("Ctrl+F")) favorite_action.triggered.connect(self.favorite_current_post) post_menu.addAction(favorite_action) - + post_menu.addSeparator() - + # Copy action copy_action = QAction("&Copy to Clipboard", self) copy_action.setShortcut(QKeySequence("Ctrl+C")) copy_action.triggered.connect(self.copy_current_post) post_menu.addAction(copy_action) - + # Open URLs action urls_action = QAction("Open &URLs in Browser", self) urls_action.setShortcut(QKeySequence("Ctrl+U")) urls_action.triggered.connect(self.open_current_post_urls) post_menu.addAction(urls_action) - + post_menu.addSeparator() - + # Edit action (for owned posts) edit_action = QAction("&Edit Post", self) edit_action.setShortcut(QKeySequence("Ctrl+Shift+E")) edit_action.triggered.connect(self.edit_current_post) post_menu.addAction(edit_action) - + # Delete action (for owned posts) delete_action = QAction("&Delete Post", self) delete_action.setShortcut(QKeySequence("Shift+Delete")) delete_action.triggered.connect(self.delete_current_post) post_menu.addAction(delete_action) - + # Social menu social_menu = menubar.addMenu("&Social") - + # Follow action follow_action = QAction("&Follow User", self) follow_action.setShortcut(QKeySequence("Ctrl+Shift+F")) follow_action.triggered.connect(self.follow_current_user) social_menu.addAction(follow_action) - + # Unfollow action unfollow_action = QAction("&Unfollow User", self) unfollow_action.setShortcut(QKeySequence("Ctrl+Shift+U")) unfollow_action.triggered.connect(self.unfollow_current_user) social_menu.addAction(unfollow_action) - + social_menu.addSeparator() - + # Block action block_action = QAction("&Block User", self) block_action.setShortcut(QKeySequence("Ctrl+Shift+B")) block_action.triggered.connect(self.block_current_user) social_menu.addAction(block_action) - + # Mute action mute_action = QAction("&Mute User", self) mute_action.setShortcut(QKeySequence("Ctrl+Shift+M")) mute_action.triggered.connect(self.mute_current_user) social_menu.addAction(mute_action) - + social_menu.addSeparator() - + # Manual follow action manual_follow_action = QAction("Follow &Specific User...", self) manual_follow_action.setShortcut(QKeySequence("Ctrl+Shift+M")) manual_follow_action.triggered.connect(self.show_manual_follow_dialog) social_menu.addAction(manual_follow_action) - + def setup_shortcuts(self): """Set up keyboard shortcuts""" # Additional shortcuts that don't need menu items pass - + def setup_auto_refresh(self): """Set up auto-refresh timer and streaming""" # Create auto-refresh timer self.auto_refresh_timer = QTimer() self.auto_refresh_timer.timeout.connect(self.check_auto_refresh) - + # Check every 30 seconds if we should refresh self.auto_refresh_timer.start(30000) # 30 seconds - + # Initialize streaming mode state self.streaming_mode = False self.streaming_client = None - + # Check if we should start in streaming mode self.logger.debug("Initializing refresh mode") self.update_refresh_mode() self.logger.debug("Refresh mode initialization complete") - + def mark_initial_load_complete(self): """Mark that initial loading is complete""" self.is_initial_load = False # Enable notifications on the timeline - if hasattr(self.timeline, 'enable_notifications'): + if hasattr(self.timeline, "enable_notifications"): self.timeline.enable_notifications() - + def keyPressEvent(self, event): """Track keyboard activity for auto-refresh""" self.last_activity_time = time.time() super().keyPressEvent(event) - + def check_auto_refresh(self): """Check if we should auto-refresh the timeline or manage streaming""" # Check if refresh mode has changed (settings updated) self.update_refresh_mode() - + # 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 - + # Skip if no account is active if not self.account_manager.get_active_account(): return - + # If we're in streaming mode, no periodic refresh needed if self.streaming_mode: return - + # 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) if refresh_interval == 0: return - + # Check if enough time has passed since last activity time_since_activity = time.time() - self.last_activity_time 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: self.logger.debug("Auto-refresh condition met, triggering refresh") self.auto_refresh_timeline() 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): """Automatically refresh the timeline - DELEGATED TO TIMELINE""" self.logger.debug("auto_refresh_timeline() called") - + # Check if timeline can safely auto-refresh if not self.timeline.can_auto_refresh(): self.logger.debug("Timeline cannot auto-refresh, skipping") return - + self.logger.debug("Timeline can auto-refresh, calling request_auto_refresh()") - + # Use centralized refresh method success = self.timeline.request_auto_refresh() - + self.logger.debug(f"request_auto_refresh() returned: {success}") - + if success: # Reset activity timer to prevent immediate re-refresh self.last_activity_time = time.time() self.logger.debug("Auto-refresh completed, activity timer reset") - + def update_refresh_mode(self): """Update refresh mode based on settings (0 = streaming, >0 = polling)""" # 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 self._updating_refresh_mode = True - + 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 - + # Check if server supports streaming if should_stream: active_account = self.account_manager.get_active_account() if active_account: # 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: - 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 # Set a reasonable polling interval instead 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 refresh_interval = 120 # 2 minutes - + # Only log refresh interval when it changes - if (refresh_interval != self._last_logged_refresh_interval or - should_stream != self._last_logged_stream_mode): - self.logger.debug(f"Refresh interval = {refresh_interval} seconds, should_stream = {should_stream}") + if ( + refresh_interval != self._last_logged_refresh_interval + 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_stream_mode = should_stream - + # Check if mode changed if should_stream != self.streaming_mode: if should_stream: @@ -475,43 +502,47 @@ class MainWindow(QMainWindow): self.stop_streaming_mode() finally: self._updating_refresh_mode = False - + def check_server_streaming_support(self, instance_url: str) -> bool: """Check if the server supports real-time streaming APIs""" try: # Quick URL-based checks for known non-streaming servers 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") return False - + # Check if we've already determined this server doesn't support streaming active_account = self.account_manager.get_active_account() if 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") return False - + # Check instance info via API to detect server software if client: try: instance_info = client.get_instance_info() - version = instance_info.get('version', '').lower() - if 'gotosocial' in version: + version = instance_info.get("version", "").lower() + if "gotosocial" in version: self.logger.debug(f"GoToSocial detected via API: {version}") return False except Exception as e: self.logger.warning(f"Could not fetch instance info: {e}") - + # Default: assume streaming is supported (Mastodon, Pleroma, etc.) return True - + except Exception as e: self.logger.warning(f"Could not detect server streaming support: {e}") # Default to no streaming if we can't determine return False - + def start_streaming_mode(self): """Start real-time streaming mode""" self.logger.debug("start_streaming_mode() called") @@ -519,33 +550,39 @@ class MainWindow(QMainWindow): if not active_account: self.logger.warning("No active account, cannot start streaming") 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: # Stop any existing streaming self.stop_streaming_mode() - + # Create streaming client if needed - if 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 + 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: return - + # Start streaming for current 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( - timeline_type, - callback=self.handle_streaming_event + timeline_type, callback=self.handle_streaming_event ) self.streaming_mode = True self.logger.info(f"Started streaming for {timeline_type} timeline") - + except Exception as e: self.logger.error(f"Failed to start streaming: {e}") # Fall back to polling mode self.streaming_mode = False - + def stop_streaming_mode(self): """Stop streaming mode and fall back to polling""" if self.streaming_client: @@ -555,213 +592,254 @@ class MainWindow(QMainWindow): self.logger.error(f"Error stopping streaming: {e}") self.streaming_mode = False self.logger.info("Stopped streaming mode") - + def handle_streaming_event(self, event_type: str, data): """Handle real-time streaming events""" try: - if event_type == 'new_post': + if event_type == "new_post": # New post received via streaming self.add_streaming_post(data) - elif event_type == 'new_notification': + elif event_type == "new_notification": # New notification received self.handle_streaming_notification(data) - elif event_type == 'delete_post': + elif event_type == "delete_post": # Post deleted self.remove_streaming_post(data) except Exception as e: self.logger.error(f"Error handling streaming event: {e}") - + def add_streaming_post(self, post_data): """Add a new post received via streaming to the timeline""" # Only add to timeline if we're on the right timeline type current_timeline = self.timeline.timeline_type - + # Trigger a refresh to show new content # In future, could add the post directly to avoid full refresh 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 timeline_name = { - 'home': 'home timeline', - 'local': 'local timeline', - 'federated': 'federated timeline' - }.get(current_timeline, 'timeline') - - if hasattr(self.timeline, 'notification_manager') and not self.timeline.skip_notifications: + "home": "home timeline", + "local": "local timeline", + "federated": "federated timeline", + }.get(current_timeline, "timeline") + + if ( + hasattr(self.timeline, "notification_manager") + and not self.timeline.skip_notifications + ): self.timeline.notification_manager.notify_new_content(timeline_name) - + # Play sound for new content - if hasattr(self.timeline, 'sound_manager'): + if hasattr(self.timeline, "sound_manager"): self.timeline.sound_manager.play_timeline_update() - + # Try to add streaming post directly instead of full refresh self.logger.debug("Adding streaming post directly to timeline") try: from models.post import Post + streaming_post = Post.from_api_dict(post_data) self.timeline.add_streaming_post_to_timeline(streaming_post) 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) - + def handle_streaming_notification(self, notification_data): """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 self.timeline.timeline_type == 'notifications': + if self.timeline.timeline_type == "notifications": self.logger.debug("Refreshing notifications timeline for new notification") self.timeline.refresh(preserve_position=True) - + # Play appropriate notification sound based on type - if (hasattr(self.timeline, 'sound_manager') and not self.timeline.skip_notifications): - notification_type = notification_data.get('type', 'notification') - if notification_type == 'mention': + if ( + hasattr(self.timeline, "sound_manager") + and not self.timeline.skip_notifications + ): + notification_type = notification_data.get("type", "notification") + if notification_type == "mention": self.timeline.sound_manager.play_mention() - elif notification_type == 'reblog': + elif notification_type == "reblog": self.timeline.sound_manager.play_boost() - elif notification_type == 'favourite': + elif notification_type == "favourite": self.timeline.sound_manager.play_favorite() - elif notification_type == 'follow': + elif notification_type == "follow": self.timeline.sound_manager.play_follow() else: self.timeline.sound_manager.play_notification() - + def remove_streaming_post(self, status_id): """Remove a deleted post from the timeline""" # For now, this is a no-op - could implement post removal in future pass - + def show_compose_dialog(self): """Show the compose post dialog""" dialog = ComposeDialog(self.account_manager, self) dialog.post_sent.connect(self.on_post_sent) dialog.exec() - + def on_post_sent(self, post_data): """Handle post data from compose dialog - USING CENTRALIZED POSTMANAGER""" self.status_bar.showMessage("Sending post...", 2000) - + # Use centralized PostManager instead of duplicate logic success = self.post_manager.create_post( - content=post_data.get('content', ''), - visibility=post_data.get('visibility', 'public'), - content_type=post_data.get('content_type', 'text/plain'), - content_warning=post_data.get('content_warning'), - in_reply_to_id=post_data.get('in_reply_to_id'), - poll=post_data.get('poll'), - media_ids=post_data.get('media_ids') + content=post_data.get("content", ""), + visibility=post_data.get("visibility", "public"), + content_type=post_data.get("content_type", "text/plain"), + content_warning=post_data.get("content_warning"), + in_reply_to_id=post_data.get("in_reply_to_id"), + poll=post_data.get("poll"), + media_ids=post_data.get("media_ids"), ) - + if not success: self.error_manager.handle_validation_error( - "Failed to start post submission", - context="post_creation" + "Failed to start post submission", context="post_creation" ) - + def on_post_success(self, result_data): """Handle successful post submission - CENTRALIZED VIA POSTMANAGER""" # Note: Sound is handled by PostManager to avoid duplication self.error_manager.show_success_message( - "Post sent successfully!", + "Post sent successfully!", context="post_creation", - play_sound=False # PostManager already plays sound + play_sound=False, # PostManager already plays sound ) - + # Refresh timeline to show the new post self.timeline.request_post_action_refresh("post_sent") - + def on_post_failed(self, error_message: str): """Handle failed post submission - CENTRALIZED VIA POSTMANAGER""" # Note: Error sound is handled by PostManager to avoid duplication self.error_manager.handle_api_error( - f"Post failed: {error_message}", - context="post_creation" + f"Post failed: {error_message}", context="post_creation" ) - + def show_settings(self): """Show the settings dialog""" dialog = SettingsDialog(self) dialog.settings_changed.connect(self.on_settings_changed) dialog.exec() - + def on_settings_changed(self): """Handle settings changes""" # Reload sound manager with new settings - if hasattr(self.timeline, 'sound_manager'): + if hasattr(self.timeline, "sound_manager"): self.timeline.sound_manager.reload_settings() - + # Check if refresh mode changed self.update_refresh_mode() - + self.status_bar.showMessage("Settings saved successfully", 2000) - + def show_soundpack_manager(self): """Show the soundpack manager dialog""" dialog = SoundpackManagerDialog(self.settings, self) dialog.exec() - + def refresh_timeline(self): """Refresh the current timeline - DELEGATED TO TIMELINE""" self.timeline.request_manual_refresh() self.status_bar.showMessage("Timeline refreshed", 2000) - + def on_timeline_tab_changed(self, index): """Handle timeline tab change""" self.switch_timeline(index, from_tab_change=True) - + def switch_timeline(self, index, from_tab_change=False): """Switch to timeline by index with loading feedback""" - timeline_names = ["Home", "Messages", "Notifications", "Local", "Federated", "Bookmarks", "Followers", "Following", "Blocked", "Muted"] - timeline_types = ["home", "conversations", "notifications", "local", "federated", "bookmarks", "followers", "following", "blocked", "muted"] - + timeline_names = [ + "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): timeline_name = timeline_names[index] timeline_type = timeline_types[index] - + # 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 self._current_timeline_switching = True - + # Set tab to match if called from keyboard shortcut (but not if already from tab change) if not from_tab_change and self.timeline_tabs.currentIndex() != index: self.timeline_tabs.setCurrentIndex(index) - + # Announce loading self.status_bar.showMessage(f"Loading {timeline_name} timeline...") - + # Switch timeline type try: self.timeline.set_timeline_type(timeline_type) - + # Restart streaming if in streaming mode if self.streaming_mode: self.start_streaming_mode() - + # Success feedback through coordinator if self.sound_coordinator: self.sound_coordinator.play_success("timeline_switch") self.status_bar.showMessage(f"Loaded {timeline_name} timeline", 2000) - + except Exception as e: # Error feedback - if hasattr(self.timeline, 'sound_manager'): + if hasattr(self.timeline, "sound_manager"): self.timeline.sound_manager.play_error() - self.status_bar.showMessage(f"Failed to load {timeline_name} timeline: {str(e)}", 3000) + self.status_bar.showMessage( + f"Failed to load {timeline_name} timeline: {str(e)}", 3000 + ) finally: # Reset the flag after a brief delay to allow the operation to complete 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): """Get the currently selected post from timeline""" current_item = self.timeline.currentItem() if current_item: return current_item.data(0, Qt.UserRole) return None - + def reply_to_current_post(self): """Reply to the currently selected post""" post = self.get_selected_post() @@ -769,7 +847,7 @@ class MainWindow(QMainWindow): self.reply_to_post(post) else: self.status_bar.showMessage("No post selected", 2000) - + def boost_current_post(self): """Boost the currently selected post""" post = self.get_selected_post() @@ -777,7 +855,7 @@ class MainWindow(QMainWindow): self.boost_post(post) else: self.status_bar.showMessage("No post selected", 2000) - + def favorite_current_post(self): """Favorite the currently selected post""" post = self.get_selected_post() @@ -785,7 +863,7 @@ class MainWindow(QMainWindow): self.favorite_post(post) else: self.status_bar.showMessage("No post selected", 2000) - + def copy_current_post(self): """Copy the currently selected post to clipboard""" post = self.get_selected_post() @@ -793,7 +871,7 @@ class MainWindow(QMainWindow): self.timeline.copy_post_to_clipboard(post) else: self.status_bar.showMessage("No post selected", 2000) - + def open_current_post_urls(self): """Open URLs from the currently selected post""" post = self.get_selected_post() @@ -801,29 +879,29 @@ class MainWindow(QMainWindow): self.timeline.open_urls_in_browser(post) else: self.status_bar.showMessage("No post selected", 2000) - + def show_first_time_setup(self): """Show first-time setup dialog""" from PySide6.QtWidgets import QMessageBox - + result = QMessageBox.question( self, "Welcome to Bifrost", "Welcome to Bifrost! You need to add a fediverse account to get started.\n\n" "Would you like to add an account now?", QMessageBox.Yes | QMessageBox.No, - QMessageBox.Yes + QMessageBox.Yes, ) - + if result == QMessageBox.Yes: self.show_login_dialog() - + def show_login_dialog(self): """Show the login dialog""" dialog = LoginDialog(self) dialog.account_added.connect(self.on_account_added) dialog.exec() - + def on_account_added(self, account_data): """Handle new account being added""" self.account_selector.add_account(account_data) @@ -831,34 +909,118 @@ class MainWindow(QMainWindow): self.status_bar.showMessage(f"Added account: {account_data['username']}", 3000) # Refresh timeline with new account self.timeline.request_post_action_refresh("account_action") - + def on_account_changed(self, account_id): """Handle account switching""" account = self.account_manager.get_account_by_id(account_id) if account: 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 self.timeline.request_post_action_refresh("account_action") - + def reply_to_post(self, post): - """Reply to a specific post""" + """Reply to a specific post or conversation""" dialog = ComposeDialog(self.account_manager, self) - # Pre-fill with reply mention using full fediverse handle - dialog.text_edit.setPlainText(f"@{post.account.acct} ") - # Move cursor to end - cursor = dialog.text_edit.textCursor() - cursor.movePosition(QTextCursor.MoveOperation.End) - dialog.text_edit.setTextCursor(cursor) - dialog.post_sent.connect(lambda data: self.on_post_sent({**data, 'in_reply_to_id': post.id})) + # Use the new setup_reply method to handle visibility and text + dialog.setup_reply(post) + + # Handle different types of replies + if hasattr(post, "conversation") and post.conversation: + # This is a conversation - send as direct message to participants + 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() - + + 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): """Boost/unboost a post""" active_account = self.account_manager.get_active_account() if not active_account: return - + try: client = self.account_manager.get_client_for_active_account() if not client: @@ -870,20 +1032,20 @@ class MainWindow(QMainWindow): client.reblog_status(post.id) self.status_bar.showMessage("Post boosted", 2000) # 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.timeline.sound_manager.play_boost() # Refresh timeline to show updated state self.timeline.request_post_action_refresh("boost") except Exception as e: self.status_bar.showMessage(f"Boost failed: {str(e)}", 3000) - + def favorite_post(self, post): """Favorite/unfavorite a post""" active_account = self.account_manager.get_active_account() if not active_account: return - + try: client = self.account_manager.get_client_for_active_account() if not client: @@ -895,79 +1057,86 @@ class MainWindow(QMainWindow): client.favourite_status(post.id) self.status_bar.showMessage("Post favorited", 2000) # 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.timeline.sound_manager.play_favorite() # Refresh timeline to show updated state self.timeline.request_post_action_refresh("favorite") except Exception as e: self.status_bar.showMessage(f"Favorite failed: {str(e)}", 3000) - + def view_profile(self, post): """View user profile""" try: # Convert Post.account to User-compatible data and open profile dialog from models.user import User - + # Create User object from Account data account = post.account account_data = { - 'id': account.id, - 'username': account.username, - 'acct': account.acct, - 'display_name': account.display_name, - 'note': account.note, - 'url': account.url, - 'avatar': account.avatar, - 'avatar_static': account.avatar_static, - 'header': account.header, - 'header_static': account.header_static, - 'locked': account.locked, - 'bot': account.bot, - 'discoverable': account.discoverable, - 'group': account.group, - 'created_at': account.created_at.isoformat() if account.created_at else None, - 'followers_count': account.followers_count, - 'following_count': account.following_count, - 'statuses_count': account.statuses_count, - 'fields': [], # Will be loaded from API - 'emojis': [] # Will be loaded from API + "id": account.id, + "username": account.username, + "acct": account.acct, + "display_name": account.display_name, + "note": account.note, + "url": account.url, + "avatar": account.avatar, + "avatar_static": account.avatar_static, + "header": account.header, + "header_static": account.header_static, + "locked": account.locked, + "bot": account.bot, + "discoverable": account.discoverable, + "group": account.group, + "created_at": ( + account.created_at.isoformat() if account.created_at else None + ), + "followers_count": account.followers_count, + "following_count": account.following_count, + "statuses_count": account.statuses_count, + "fields": [], # Will be loaded from API + "emojis": [], # Will be loaded from API } - + user = User.from_api_dict(account_data) - + dialog = ProfileDialog( user_id=user.id, account_manager=self.account_manager, sound_manager=self.timeline.sound_manager, initial_user=user, - parent=self + parent=self, ) dialog.exec() except Exception as e: 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() - + def update_status_label(self): """Update the status label with current account info""" active_account = self.account_manager.get_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: self.status_label.setText("No account connected") - + def quit_application(self): """Quit the application with shutdown sound""" - self._shutdown_sound_played = True # Mark that we're handling the shutdown sound - if hasattr(self.timeline, 'sound_manager'): + self._shutdown_sound_played = ( + True # Mark that we're handling the shutdown sound + ) + if hasattr(self.timeline, "sound_manager"): self.timeline.sound_manager.play_shutdown() # Wait briefly for sound to start playing from PySide6.QtCore import QTimer + QTimer.singleShot(500, self.close) else: self.close() - + def delete_current_post(self): """Delete the currently selected post""" post = self.get_selected_post() @@ -975,7 +1144,7 @@ class MainWindow(QMainWindow): self.delete_post(post) else: self.status_bar.showMessage("No post selected", 2000) - + def edit_current_post(self): """Edit the currently selected post""" post = self.get_selected_post() @@ -983,7 +1152,7 @@ class MainWindow(QMainWindow): self.edit_post(post) else: self.status_bar.showMessage("No post selected", 2000) - + def follow_current_user(self): """Follow the user of the currently selected post""" post = self.get_selected_post() @@ -991,7 +1160,7 @@ class MainWindow(QMainWindow): self.follow_user(post) else: self.status_bar.showMessage("No post selected", 2000) - + def unfollow_current_user(self): """Unfollow the user of the currently selected post""" post = self.get_selected_post() @@ -999,35 +1168,40 @@ class MainWindow(QMainWindow): self.unfollow_user(post) else: self.status_bar.showMessage("No post selected", 2000) - + def delete_post(self, post): """Delete a post with confirmation dialog""" from PySide6.QtWidgets import QMessageBox - + # Check if this is user's own post 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) return - - is_own_post = (post.account.username == active_account.username and - post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', '')) - + + is_own_post = ( + 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: self.status_bar.showMessage("Cannot delete: Not your post", 2000) return - + # Show confirmation dialog content_preview = post.get_content_text() - + result = QMessageBox.question( self, "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.No + QMessageBox.No, ) - + if result == QMessageBox.Yes: try: client = self.account_manager.get_client_for_active_account() @@ -1039,22 +1213,27 @@ class MainWindow(QMainWindow): self.timeline.request_post_action_refresh("delete") except Exception as e: self.status_bar.showMessage(f"Delete failed: {str(e)}", 3000) - + def edit_post(self, post): """Edit a post""" # Check if this is user's own post 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) return - - is_own_post = (post.account.username == active_account.username and - post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', '')) - + + is_own_post = ( + 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: self.status_bar.showMessage("Cannot edit: Not your post", 2000) return - + # Open compose dialog with current post content dialog = ComposeDialog(self.account_manager, self) dialog.text_edit.setPlainText(post.get_content_text()) @@ -1062,7 +1241,7 @@ class MainWindow(QMainWindow): cursor = dialog.text_edit.textCursor() cursor.movePosition(QTextCursor.MoveOperation.End) dialog.text_edit.setTextCursor(cursor) - + def handle_edit_sent(data): try: client = self.account_manager.get_client_for_active_account() @@ -1070,27 +1249,27 @@ class MainWindow(QMainWindow): return client.edit_status( post.id, - content=data['content'], - visibility=data['visibility'], - content_type=data.get('content_type', 'text/plain'), - content_warning=data['content_warning'] + content=data["content"], + visibility=data["visibility"], + content_type=data.get("content_type", "text/plain"), + content_warning=data["content_warning"], ) self.status_bar.showMessage("Post edited successfully", 2000) # Refresh timeline to show edited post self.timeline.request_post_action_refresh("edit") except Exception as e: self.status_bar.showMessage(f"Edit failed: {str(e)}", 3000) - + dialog.post_sent.connect(handle_edit_sent) dialog.exec() - + def follow_user(self, post): """Follow a user""" 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) return - + try: client = self.account_manager.get_client_for_active_account() if not client: @@ -1099,18 +1278,18 @@ class MainWindow(QMainWindow): username = post.account.display_name or post.account.username self.status_bar.showMessage(f"Followed {username}", 2000) # Play follow sound for successful follow - if hasattr(self.timeline, 'sound_manager'): + if hasattr(self.timeline, "sound_manager"): self.timeline.sound_manager.play_follow() except Exception as e: self.status_bar.showMessage(f"Follow failed: {str(e)}", 3000) - + def unfollow_user(self, post): """Unfollow a user""" 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) return - + try: client = self.account_manager.get_client_for_active_account() if not client: @@ -1119,47 +1298,54 @@ class MainWindow(QMainWindow): username = post.account.display_name or post.account.username self.status_bar.showMessage(f"Unfollowed {username}", 2000) # Play unfollow sound for successful unfollow - if hasattr(self.timeline, 'sound_manager'): + if hasattr(self.timeline, "sound_manager"): self.timeline.sound_manager.play_unfollow() except Exception as e: self.status_bar.showMessage(f"Unfollow failed: {str(e)}", 3000) - + def show_manual_follow_dialog(self): """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.setWindowTitle("Follow User") dialog.setMinimumSize(400, 150) dialog.setModal(True) - + layout = QVBoxLayout(dialog) - + # Label label = QLabel("Enter the user to follow (e.g. @user@instance.social):") label.setAccessibleName("Follow User Instructions") layout.addWidget(label) - + # Input field self.follow_input = QLineEdit() self.follow_input.setAccessibleName("Username to Follow") self.follow_input.setPlaceholderText("@username@instance.social") layout.addWidget(self.follow_input) - + # Buttons button_box = QDialogButtonBox() - + follow_button = QPushButton("Follow") follow_button.setDefault(True) cancel_button = QPushButton("Cancel") - + button_box.addButton(follow_button, QDialogButtonBox.AcceptRole) button_box.addButton(cancel_button, QDialogButtonBox.RejectRole) - + button_box.accepted.connect(dialog.accept) button_box.rejected.connect(dialog.reject) layout.addWidget(button_box) - + # Show dialog if dialog.exec() == QDialog.Accepted: username = self.follow_input.text().strip() @@ -1167,48 +1353,53 @@ class MainWindow(QMainWindow): self.manual_follow_user(username) else: self.status_bar.showMessage("Please enter a username", 2000) - + def manual_follow_user(self, username): """Follow a user by username""" active_account = self.account_manager.get_active_account() if not active_account: self.status_bar.showMessage("Cannot follow: No active account", 2000) return - + # Remove @ prefix if present - if username.startswith('@'): + if username.startswith("@"): username = username[1:] - + try: client = self.account_manager.get_client_for_active_account() if not client: return - + # Search for the account first accounts = client.search_accounts(username) if not accounts: self.status_bar.showMessage(f"User not found: {username}", 3000) return - + # Find exact match target_account = None 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 break - + if not target_account: self.status_bar.showMessage(f"User not found: {username}", 3000) return - + # Follow the account - client.follow_account(target_account['id']) - display_name = target_account.get('display_name') or target_account['username'] + client.follow_account(target_account["id"]) + display_name = ( + target_account.get("display_name") or target_account["username"] + ) self.status_bar.showMessage(f"Followed {display_name}", 2000) # Play follow sound for successful follow - if hasattr(self.timeline, 'sound_manager'): + if hasattr(self.timeline, "sound_manager"): self.timeline.sound_manager.play_follow() - + except Exception as e: self.status_bar.showMessage(f"Follow failed: {str(e)}", 3000) @@ -1219,7 +1410,7 @@ class MainWindow(QMainWindow): self.block_user(post) else: self.status_bar.showMessage("No post selected", 2000) - + def mute_current_user(self): """Mute the user of the currently selected post""" post = self.get_selected_post() @@ -1227,35 +1418,40 @@ class MainWindow(QMainWindow): self.mute_user(post) else: self.status_bar.showMessage("No post selected", 2000) - + def block_user(self, post): """Block a user with confirmation dialog""" 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) return - + # Don't allow blocking yourself - is_own_post = (post.account.username == active_account.username and - post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', '')) - + is_own_post = ( + post.account.username == active_account.username + and post.account.acct.split("@")[-1] + == active_account.instance_url.replace("https://", "").replace( + "http://", "" + ) + ) + if is_own_post: self.status_bar.showMessage("Cannot block: Cannot block yourself", 2000) return - + # Show confirmation dialog username = post.account.display_name or post.account.username full_username = f"@{post.account.acct}" - + result = QMessageBox.question( self, "Block User", f"Are you sure you want to block {username} ({full_username})?\n\n" "This will prevent them from following you and seeing your posts.", QMessageBox.Yes | QMessageBox.No, - QMessageBox.No + QMessageBox.No, ) - + if result == QMessageBox.Yes: try: client = self.account_manager.get_client_for_active_account() @@ -1264,28 +1460,33 @@ class MainWindow(QMainWindow): client.block_account(post.account.id) self.status_bar.showMessage(f"Blocked {username}", 2000) # Play success sound for successful block - if hasattr(self.timeline, 'sound_manager'): + if hasattr(self.timeline, "sound_manager"): self.timeline.sound_manager.play_success() except Exception as e: 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() - + def mute_user(self, post): """Mute a user""" 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) return - + # Don't allow muting yourself - is_own_post = (post.account.username == active_account.username and - post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', '')) - + is_own_post = ( + post.account.username == active_account.username + and post.account.acct.split("@")[-1] + == active_account.instance_url.replace("https://", "").replace( + "http://", "" + ) + ) + if is_own_post: self.status_bar.showMessage("Cannot mute: Cannot mute yourself", 2000) return - + try: client = self.account_manager.get_client_for_active_account() if not client: @@ -1294,24 +1495,27 @@ class MainWindow(QMainWindow): username = post.account.display_name or post.account.username self.status_bar.showMessage(f"Muted {username}", 2000) # Play success sound for successful mute - if hasattr(self.timeline, 'sound_manager'): + if hasattr(self.timeline, "sound_manager"): self.timeline.sound_manager.play_success() except Exception as e: 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() def closeEvent(self, event): """Handle window close event""" # Stop streaming before closing self.stop_streaming_mode() - + # 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() # Wait briefly for sound to complete from PySide6.QtCore import QTimer, QEventLoop + loop = QEventLoop() QTimer.singleShot(500, loop.quit) loop.exec() - event.accept() \ No newline at end of file + event.accept() diff --git a/src/managers/post_manager.py b/src/managers/post_manager.py index 459f66c..b718199 100644 --- a/src/managers/post_manager.py +++ b/src/managers/post_manager.py @@ -44,7 +44,15 @@ class PostThread(QThread): self.post_success.emit(result) 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): diff --git a/src/widgets/compose_dialog.py b/src/widgets/compose_dialog.py index bafe630..662b1b3 100644 --- a/src/widgets/compose_dialog.py +++ b/src/widgets/compose_dialog.py @@ -3,9 +3,19 @@ Compose post dialog for creating new posts """ from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QTextEdit, - QPushButton, QLabel, QDialogButtonBox, QCheckBox, - QComboBox, QGroupBox, QLineEdit, QSpinBox, QMessageBox + QDialog, + QVBoxLayout, + QHBoxLayout, + QTextEdit, + QPushButton, + QLabel, + QDialogButtonBox, + QCheckBox, + QComboBox, + QGroupBox, + QLineEdit, + QSpinBox, + QMessageBox, ) from PySide6.QtCore import Qt, Signal, QThread 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 # This eliminates duplicate posting logic and centralizes all post operations + class ComposeDialog(QDialog): """Dialog for composing new posts""" - + post_sent = Signal(dict) # Emitted when a post is ready to send - + def __init__(self, account_manager, parent=None): super().__init__(parent) self.settings = SettingsManager() self.sound_manager = SoundManager(self.settings) self.account_manager = account_manager self.media_upload_widget = None - self.logger = logging.getLogger('bifrost.compose') + self.logger = logging.getLogger("bifrost.compose") self.setup_ui() self.setup_shortcuts() self.load_default_settings() - + def setup_ui(self): """Initialize the compose dialog UI""" self.setWindowTitle("Compose Post") self.setMinimumSize(500, 300) self.setModal(True) - + layout = QVBoxLayout(self) - + # Character count label self.char_count_label = QLabel("Characters: 0/500") self.char_count_label.setAccessibleName("Character Count") layout.addWidget(self.char_count_label) - + # Main text area with autocomplete self.text_edit = AutocompleteTextEdit(sound_manager=self.sound_manager) 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.setPlaceholderText("What's on your mind? Type @ for mentions, : for emojis") + self.text_edit.setAccessibleDescription( + "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.textChanged.connect(self.update_char_count) self.text_edit.mention_requested.connect(self.load_mention_suggestions) self.text_edit.emoji_requested.connect(self.load_emoji_suggestions) layout.addWidget(self.text_edit) - + # Options group options_group = QGroupBox("Post Options") options_layout = QVBoxLayout(options_group) - + # Post settings row 1: Visibility and Content Type settings_row1 = QHBoxLayout() - + # Visibility settings settings_row1.addWidget(QLabel("Visibility:")) self.visibility_combo = AccessibleComboBox() self.visibility_combo.setAccessibleName("Post Visibility") - self.visibility_combo.addItems([ - "Public", - "Unlisted", - "Followers Only", - "Direct Message" - ]) + self.visibility_combo.addItems( + ["Public", "Unlisted", "Followers Only", "Direct Message"] + ) settings_row1.addWidget(self.visibility_combo) - + settings_row1.addWidget(QLabel("Content Type:")) self.content_type_combo = AccessibleComboBox() 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("Markdown", "text/markdown") self.content_type_combo.addItem("HTML", "text/html") settings_row1.addWidget(self.content_type_combo) - + settings_row1.addStretch() options_layout.addLayout(settings_row1) - + # Content warnings self.cw_checkbox = QCheckBox("Add Content Warning") self.cw_checkbox.setAccessibleName("Content Warning Toggle") self.cw_checkbox.toggled.connect(self.toggle_content_warning) options_layout.addWidget(self.cw_checkbox) - + self.cw_edit = QTextEdit() 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.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() options_layout.addWidget(self.cw_edit) - + # Poll options self.poll_checkbox = QCheckBox("Add Poll") self.poll_checkbox.setAccessibleName("Poll Toggle") self.poll_checkbox.toggled.connect(self.toggle_poll) options_layout.addWidget(self.poll_checkbox) - + # Poll container (hidden by default) self.poll_container = QGroupBox("Poll Options") self.poll_container.hide() poll_layout = QVBoxLayout(self.poll_container) - + # Poll options self.poll_options = [] for i in range(4): # Support up to 4 poll options option_layout = QHBoxLayout() option_layout.addWidget(QLabel(f"Option {i+1}:")) - + option_edit = QLineEdit() 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}...") if i >= 2: # First two options are required, others optional option_edit.setPlaceholderText(f"Poll option {i+1} (optional)...") option_layout.addWidget(option_edit) - + self.poll_options.append(option_edit) poll_layout.addLayout(option_layout) - + # Poll settings poll_settings_layout = QHBoxLayout() - + # Duration poll_settings_layout.addWidget(QLabel("Duration:")) self.poll_duration = QSpinBox() 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.setMaximum(24 * 7) # 1 week max self.poll_duration.setValue(24) # Default 24 hours self.poll_duration.setSuffix(" hours") poll_settings_layout.addWidget(self.poll_duration) - + poll_settings_layout.addStretch() - + # Multiple choice option self.poll_multiple = QCheckBox("Allow multiple choices") self.poll_multiple.setAccessibleName("Multiple Choice Toggle") poll_settings_layout.addWidget(self.poll_multiple) - + poll_layout.addLayout(poll_settings_layout) options_layout.addWidget(self.poll_container) - + layout.addWidget(options_group) - + # Media upload section - create carefully to avoid crashes try: client = self.account_manager.get_client_for_active_account() @@ -173,8 +195,10 @@ class ComposeDialog(QDialog): # Create placeholder when no account is available self.media_upload_widget = None media_placeholder = QLabel("Please log in to upload media") - media_placeholder.setAccessibleName("Media Upload Status") - media_placeholder.setStyleSheet("color: #666; font-style: italic; padding: 10px;") + media_placeholder.setAccessibleName("Media Upload Status") + media_placeholder.setStyleSheet( + "color: #666; font-style: italic; padding: 10px;" + ) layout.addWidget(media_placeholder) except Exception as e: self.logger.error(f"Failed to create media upload widget: {e}") @@ -182,48 +206,101 @@ class ComposeDialog(QDialog): # Add error placeholder error_placeholder = QLabel("Media upload temporarily unavailable") 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) - + # Button box button_box = QDialogButtonBox() - + # Post button self.post_button = QPushButton("&Post") self.post_button.setAccessibleName("Send Post") self.post_button.setDefault(True) self.post_button.clicked.connect(self.send_post) button_box.addButton(self.post_button, QDialogButtonBox.AcceptRole) - + # Cancel button cancel_button = QPushButton("&Cancel") cancel_button.setAccessibleName("Cancel Post") cancel_button.clicked.connect(self.reject) button_box.addButton(cancel_button, QDialogButtonBox.RejectRole) - + layout.addWidget(button_box) - + # Set initial focus self.text_edit.setFocus() - + def setup_shortcuts(self): """Set up keyboard shortcuts""" # Ctrl+Enter to send post send_shortcut = QShortcut(QKeySequence("Ctrl+Return"), self) send_shortcut.activated.connect(self.send_post) - + # Escape to cancel cancel_shortcut = QShortcut(QKeySequence.Cancel, self) cancel_shortcut.activated.connect(self.reject) - + def load_default_settings(self): """Load default settings from configuration""" # 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) if index >= 0: 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): """Toggle content warning field visibility""" if enabled: @@ -232,7 +309,7 @@ class ComposeDialog(QDialog): else: self.cw_edit.hide() self.cw_edit.clear() - + def toggle_poll(self, enabled: bool): """Toggle poll options visibility""" if enabled: @@ -244,53 +321,57 @@ class ComposeDialog(QDialog): # Clear all poll options for option_edit in self.poll_options: option_edit.clear() - + def update_char_count(self): """Update character count display""" text = self.text_edit.toPlainText() char_count = len(text) self.char_count_label.setText(f"Characters: {char_count}/500") - + # Enable/disable post button based on content has_content = bool(text.strip()) within_limit = char_count <= 500 self.post_button.setEnabled(has_content and within_limit) - + # Update accessibility if char_count > 500: self.char_count_label.setAccessibleDescription("Character limit exceeded") 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): """Send the post""" content = self.text_edit.toPlainText().strip() if not content: return - + # Get active account active_account = self.account_manager.get_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 - + # Get post settings visibility_text = self.visibility_combo.currentText() visibility_map = { "Public": "public", - "Unlisted": "unlisted", + "Unlisted": "unlisted", "Followers Only": "private", - "Direct Message": "direct" + "Direct Message": "direct", } visibility = visibility_map.get(visibility_text, "public") - + # Get content type content_type = self.content_type_combo.currentData() - + content_warning = None if self.cw_checkbox.isChecked(): content_warning = self.cw_edit.toPlainText().strip() - + # Handle poll data poll_data = None if self.poll_checkbox.isChecked(): @@ -299,19 +380,22 @@ class ComposeDialog(QDialog): option_text = option_edit.text().strip() if option_text: poll_options.append(option_text) - + # Validate poll (need at least 2 options) 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 - + # Create poll data poll_data = { - 'options': poll_options, - 'expires_in': self.poll_duration.value() * 3600, # Convert hours to seconds - 'multiple': self.poll_multiple.isChecked() + "options": poll_options, + "expires_in": self.poll_duration.value() + * 3600, # Convert hours to seconds + "multiple": self.poll_multiple.isChecked(), } - + # Check if we need to upload media first media_ids = [] 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 # For now, we'll post immediately and let the API handle it media_ids = self.media_upload_widget.get_media_ids() - + # Start background posting post_data = { - 'account': active_account, - 'content': content, - 'visibility': visibility, - 'content_type': content_type, - 'content_warning': content_warning, - 'poll': poll_data, - 'media_ids': media_ids + "account": active_account, + "content": content, + "visibility": visibility, + "content_type": content_type, + "content_warning": content_warning, + "poll": poll_data, + "media_ids": media_ids, } - + # NOTE: Sound handling moved to centralized PostManager to avoid duplication # Emit signal with all post data for background processing by PostManager self.post_sent.emit(post_data) - + # Close dialog immediately self.accept() - + def load_mention_suggestions(self, prefix: str): """Load mention suggestions based on prefix""" try: @@ -347,103 +431,119 @@ class ComposeDialog(QDialog): client = self.account_manager.get_client_for_active_account() if not client: return - + # Get current user's account ID current_user = client.verify_credentials() - current_account_id = current_user['id'] - + current_account_id = current_user["id"] + # Collect usernames from multiple sources usernames = set() - + # 1. Search for accounts matching the prefix if len(prefix) >= 1: # Search when user has typed at least 1 character try: search_results = client.search_accounts(prefix, limit=40) for account in search_results: # Use full fediverse handle (acct field) or construct it - full_handle = account.get('acct', '') + full_handle = account.get("acct", "") if not full_handle: - username = account.get('username', '') - domain = account.get('url', '').split('/')[2] if account.get('url') else '' + username = account.get("username", "") + domain = ( + account.get("url", "").split("/")[2] + if account.get("url") + else "" + ) if username and domain: full_handle = f"{username}@{domain}" else: full_handle = username - + if full_handle: usernames.add(full_handle) except Exception as e: self.logger.error(f"Account search failed: {e}") - + # 2. Get followers (people who follow you) try: followers = client.get_followers(current_account_id, limit=100) for follower in followers: # Use full fediverse handle - full_handle = follower.get('acct', '') + full_handle = follower.get("acct", "") if not full_handle: - username = follower.get('username', '') - domain = follower.get('url', '').split('/')[2] if follower.get('url') else '' + username = follower.get("username", "") + domain = ( + follower.get("url", "").split("/")[2] + if follower.get("url") + else "" + ) if username and domain: full_handle = f"{username}@{domain}" else: full_handle = username - + if full_handle and full_handle.lower().startswith(prefix.lower()): usernames.add(full_handle) except Exception as e: self.logger.error(f"Failed to get followers: {e}") - + # 3. Get following (people you follow) try: following = client.get_following(current_account_id, limit=100) for account in following: # Use full fediverse handle - full_handle = account.get('acct', '') + full_handle = account.get("acct", "") if not full_handle: - username = account.get('username', '') - domain = account.get('url', '').split('/')[2] if account.get('url') else '' + username = account.get("username", "") + domain = ( + account.get("url", "").split("/")[2] + if account.get("url") + else "" + ) if username and domain: full_handle = f"{username}@{domain}" else: full_handle = username - + if full_handle and full_handle.lower().startswith(prefix.lower()): usernames.add(full_handle) except Exception as e: self.logger.error(f"Failed to get following: {e}") - + # Convert to sorted list filtered = sorted(list(usernames)) - + # Only use real API data - no fallback to incomplete sample data # The empty list will trigger a fresh API call if needed - + self.text_edit.update_mention_list(filtered) - + except Exception as e: self.logger.error(f"Failed to load mention suggestions: {e}") # Fallback to empty list self.text_edit.update_mention_list([]) - + def load_emoji_suggestions(self, prefix: str): """Load emoji suggestions based on prefix""" # 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 # We don't need to do anything here since emojis are pre-loaded pass - + def get_post_data(self) -> dict: """Get the composed post data""" data = { - 'content': self.text_edit.toPlainText().strip(), - 'visibility': self.visibility_combo.currentText().lower().replace(" ", "_"), - 'content_warning': self.cw_edit.toPlainText().strip() if self.cw_checkbox.isChecked() else None + "content": self.text_edit.toPlainText().strip(), + "visibility": self.visibility_combo.currentText().lower().replace(" ", "_"), + "content_warning": ( + self.cw_edit.toPlainText().strip() + if self.cw_checkbox.isChecked() + else None + ), } - + # Add media IDs if available if self.media_upload_widget and self.media_upload_widget.has_media(): - data['media_ids'] = self.media_upload_widget.get_media_ids() - - return data \ No newline at end of file + data["media_ids"] = self.media_upload_widget.get_media_ids() + + return data diff --git a/src/widgets/post_details_dialog.py b/src/widgets/post_details_dialog.py index 676729d..a1ce505 100644 --- a/src/widgets/post_details_dialog.py +++ b/src/widgets/post_details_dialog.py @@ -3,9 +3,21 @@ Post details dialog showing favorites, boosts, and other interaction details """ from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QLabel, QTextEdit, - QTabWidget, QListWidget, QListWidgetItem, QDialogButtonBox, - QWidget, QGroupBox, QPushButton + QDialog, + QVBoxLayout, + QHBoxLayout, + QLabel, + QTextEdit, + QTabWidget, + QListWidget, + QListWidgetItem, + QDialogButtonBox, + QWidget, + QGroupBox, + QPushButton, + QCheckBox, + QRadioButton, + QButtonGroup, ) from PySide6.QtCore import Qt, Signal, QThread from PySide6.QtGui import QFont @@ -19,100 +31,89 @@ from audio.sound_manager import SoundManager class FetchDetailsThread(QThread): """Background thread for fetching post interaction details""" - + details_loaded = Signal(dict) # Emitted with details data - details_failed = Signal(str) # Emitted with error message - + details_failed = Signal(str) # Emitted with error message + def __init__(self, client: ActivityPubClient, post_id: str): super().__init__() self.client = client self.post_id = post_id - self.logger = logging.getLogger('bifrost.post_details') - + self.logger = logging.getLogger("bifrost.post_details") + def run(self): """Fetch favorites and boosts in background""" try: - details = { - 'favourited_by': [], - 'reblogged_by': [] - } - + details = {"favourited_by": [], "reblogged_by": []} + # Fetch who favorited this post try: favourited_by_data = self.client.get_status_favourited_by(self.post_id) - details['favourited_by'] = favourited_by_data + details["favourited_by"] = favourited_by_data except Exception as e: self.logger.error(f"Failed to fetch favorites: {e}") - - # Fetch who boosted this post + + # Fetch who boosted this post try: reblogged_by_data = self.client.get_status_reblogged_by(self.post_id) - details['reblogged_by'] = reblogged_by_data + details["reblogged_by"] = reblogged_by_data except Exception as e: self.logger.error(f"Failed to fetch boosts: {e}") - + self.details_loaded.emit(details) - + except Exception as e: self.details_failed.emit(str(e)) class PostDetailsDialog(QDialog): """Dialog showing detailed post interaction information""" - - def __init__(self, post, client: ActivityPubClient, sound_manager: SoundManager, parent=None): + + vote_submitted = Signal( + object, list + ) # Emitted with post and list of selected choice indices + + def __init__( + self, post, client: ActivityPubClient, sound_manager: SoundManager, parent=None + ): super().__init__(parent) self.post = post self.client = client self.sound_manager = sound_manager - self.logger = logging.getLogger('bifrost.post_details') - + self.logger = logging.getLogger("bifrost.post_details") + self.setWindowTitle("Post Details") self.setModal(True) self.resize(600, 500) - + self.setup_ui() self.load_details() - + def setup_ui(self): """Setup the post details UI""" layout = QVBoxLayout(self) - - # Post content section - content_group = QGroupBox("Post Content") - content_group.setAccessibleName("Post Content") - content_layout = QVBoxLayout(content_group) - - # Author info - author_label = QLabel(f"@{self.post.account.username} ({self.post.account.display_name or self.post.account.username})") - author_label.setAccessibleName("Post Author") - author_font = QFont() - author_font.setBold(True) - author_label.setFont(author_font) - content_layout.addWidget(author_label) - - # Post content - content_text = QTextEdit() - content_text.setAccessibleName("Post Content") - content_text.setPlainText(self.post.get_content_text()) - content_text.setReadOnly(True) - content_text.setMaximumHeight(100) - # Enable keyboard navigation in read-only text - content_text.setTextInteractionFlags(Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse) - content_layout.addWidget(content_text) - - # Stats + + # Post statistics (keep just the basic stats at top) stats_text = f"Replies: {self.post.replies_count} | Boosts: {self.post.reblogs_count} | Favorites: {self.post.favourites_count}" stats_label = QLabel(stats_text) stats_label.setAccessibleName("Post Statistics") - content_layout.addWidget(stats_label) - - layout.addWidget(content_group) - + layout.addWidget(stats_label) + # Tabs for interaction details self.tabs = QTabWidget() self.tabs.setAccessibleName("Interaction Details") - + + # Poll tab (if poll exists) - add as first tab + if hasattr(self.post, "poll") and self.post.poll: + self.poll_widget = self.create_poll_widget() + poll_tab_index = self.tabs.addTab(self.poll_widget, "Poll") + self.logger.debug(f"Added poll tab at index {poll_tab_index}") + + # Content tab - always present + self.content_widget = self.create_content_widget() + content_tab_index = self.tabs.addTab(self.content_widget, "Content") + self.logger.debug(f"Added content tab at index {content_tab_index}") + # Favorites tab self.favorites_list = QListWidget() self.favorites_list.setAccessibleName("Users Who Favorited") @@ -120,8 +121,10 @@ class PostDetailsDialog(QDialog): fake_header = QListWidgetItem("Users who favorited this post:") fake_header.setFlags(Qt.ItemIsEnabled) # Not selectable self.favorites_list.addItem(fake_header) - self.tabs.addTab(self.favorites_list, f"Favorites ({self.post.favourites_count})") - + self.tabs.addTab( + self.favorites_list, f"Favorites ({self.post.favourites_count})" + ) + # Boosts tab self.boosts_list = QListWidget() self.boosts_list.setAccessibleName("Users Who Boosted") @@ -130,45 +133,45 @@ class PostDetailsDialog(QDialog): fake_header.setFlags(Qt.ItemIsEnabled) # Not selectable self.boosts_list.addItem(fake_header) self.tabs.addTab(self.boosts_list, f"Boosts ({self.post.reblogs_count})") - + layout.addWidget(self.tabs) - + # Loading indicator self.status_label = QLabel("Loading interaction details...") self.status_label.setAccessibleName("Loading Status") layout.addWidget(self.status_label) - + # Button box button_box = QDialogButtonBox(QDialogButtonBox.Close) button_box.setAccessibleName("Dialog Buttons") button_box.rejected.connect(self.reject) layout.addWidget(button_box) - + def load_details(self): """Load detailed interaction information""" - if not self.client or not hasattr(self.post, 'id'): + if not self.client or not hasattr(self.post, "id"): self.status_label.setText("Cannot load details: No post ID or API client") return - + # Start background fetch self.fetch_thread = FetchDetailsThread(self.client, self.post.id) self.fetch_thread.details_loaded.connect(self.on_details_loaded) self.fetch_thread.details_failed.connect(self.on_details_failed) self.fetch_thread.start() - + def on_details_loaded(self, details: dict): """Handle successful details loading""" self.status_label.setText("") - + # Populate favorites list - favourited_by = details.get('favourited_by', []) + favourited_by = details.get("favourited_by", []) if favourited_by: for account_data in favourited_by: try: user = User.from_api_dict(account_data) display_name = user.display_name or user.username item_text = f"@{user.username} ({display_name})" - + item = QListWidgetItem(item_text) item.setData(Qt.UserRole, user) self.favorites_list.addItem(item) @@ -177,16 +180,16 @@ class PostDetailsDialog(QDialog): else: item = QListWidgetItem("No one has favorited this post yet") self.favorites_list.addItem(item) - + # Populate boosts list - reblogged_by = details.get('reblogged_by', []) + reblogged_by = details.get("reblogged_by", []) if reblogged_by: for account_data in reblogged_by: try: user = User.from_api_dict(account_data) display_name = user.display_name or user.username item_text = f"@{user.username} ({display_name})" - + item = QListWidgetItem(item_text) item.setData(Qt.UserRole, user) self.boosts_list.addItem(item) @@ -195,26 +198,241 @@ class PostDetailsDialog(QDialog): else: item = QListWidgetItem("No one has boosted this post yet") self.boosts_list.addItem(item) - + # Update tab titles with actual counts actual_favorites = len(favourited_by) actual_boosts = len(reblogged_by) - self.tabs.setTabText(0, f"Favorites ({actual_favorites})") - self.tabs.setTabText(1, f"Boosts ({actual_boosts})") - + + # Account for poll tab if it exists + # Tab order: Poll (if exists), Content, Favorites, Boosts + has_poll = hasattr(self.post, "poll") and self.post.poll + favorites_tab_index = 2 if has_poll else 1 + boosts_tab_index = 3 if has_poll else 2 + + self.tabs.setTabText(favorites_tab_index, f"Favorites ({actual_favorites})") + self.tabs.setTabText(boosts_tab_index, f"Boosts ({actual_boosts})") + # Play success sound self.sound_manager.play_success() - + def on_details_failed(self, error_message: str): """Handle details loading failure""" self.status_label.setText(f"Failed to load details: {error_message}") - + # Add error items to lists error_item_fav = QListWidgetItem(f"Error loading favorites: {error_message}") self.favorites_list.addItem(error_item_fav) - + error_item_boost = QListWidgetItem(f"Error loading boosts: {error_message}") self.boosts_list.addItem(error_item_boost) - + # Play error sound - self.sound_manager.play_error() \ No newline at end of file + 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() diff --git a/src/widgets/timeline_view.py b/src/widgets/timeline_view.py index 3654b09..5607b74 100644 --- a/src/widgets/timeline_view.py +++ b/src/widgets/timeline_view.py @@ -2,7 +2,17 @@ Timeline view widget for displaying posts and threads """ -from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem, QHeaderView, QMenu, QDialog, QVBoxLayout, QListWidget, QDialogButtonBox, QLabel +from PySide6.QtWidgets import ( + QTreeWidget, + QTreeWidgetItem, + QHeaderView, + QMenu, + QDialog, + QVBoxLayout, + QListWidget, + QDialogButtonBox, + QLabel, +) from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QAction, QClipboard, QKeyEvent from typing import Optional, List, Dict @@ -23,17 +33,17 @@ from widgets.poll_voting_dialog import PollVotingDialog class TimelineView(QTreeWidget): """Main timeline display widget""" - + # Signals for post actions reply_requested = Signal(object) # Post object - boost_requested = Signal(object) # Post object + boost_requested = Signal(object) # Post object favorite_requested = Signal(object) # Post object profile_requested = Signal(object) # Post object delete_requested = Signal(object) # Post object edit_requested = Signal(object) # Post object follow_requested = Signal(object) # Post object unfollow_requested = Signal(object) # Post object - + def __init__(self, account_manager: AccountManager, parent=None): super().__init__(parent) self.timeline_type = "home" @@ -41,66 +51,68 @@ class TimelineView(QTreeWidget): self.sound_manager = SoundManager(self.settings) self.notification_manager = NotificationManager(self.settings) self.account_manager = account_manager - self.logger = logging.getLogger('bifrost.timeline') + self.logger = logging.getLogger("bifrost.timeline") self.activitypub_client = None self.posts = [] # Store loaded posts self.oldest_post_id = None # Track for pagination self.newest_post_id = None # Track newest post seen for new content detection self.skip_notifications = True # Skip notifications on initial loads - self.last_notification_check = None # Track newest notification seen for new notification detection + self.last_notification_check = ( + None # Track newest notification seen for new notification detection + ) self.setup_ui() self.refresh() - + # Setup basic accessibility self.setup_accessibility() - + # Connect selection change to mark conversations as read self.currentItemChanged.connect(self.on_selection_changed) - + # Enable context menu self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self.show_context_menu) - + def setup_accessibility(self): """Setup basic accessibility features for standard QTreeWidget""" self.setFocusPolicy(Qt.StrongFocus) self.setAccessibleName("Timeline posts") self.setAccessibleDescription("Timeline view showing posts and conversations") - - # Connect expand/collapse for sound feedback + + # Connect expand/collapse for sound feedback self.itemExpanded.connect(self.on_item_expanded) self.itemCollapsed.connect(self.on_item_collapsed) - + def on_item_expanded(self, item): """Handle item expansion with sound feedback""" - if hasattr(self, 'sound_manager'): + if hasattr(self, "sound_manager"): self.sound_manager.play_expand() - + def on_item_collapsed(self, item): """Handle item collapse with sound feedback""" - if hasattr(self, 'sound_manager'): + if hasattr(self, "sound_manager"): self.sound_manager.play_collapse() - + def setup_ui(self): """Initialize the timeline UI""" # Set up columns self.setColumnCount(1) self.setHeaderLabels(["Posts"]) - + # Hide the header self.header().hide() - + # Set selection behavior self.setSelectionBehavior(QTreeWidget.SelectRows) self.setSelectionMode(QTreeWidget.SingleSelection) - + # Enable keyboard navigation self.setFocusPolicy(Qt.StrongFocus) - + # Set accessible properties self.setAccessibleName("Timeline") self.setAccessibleDescription("Timeline showing posts and conversations") - + def set_timeline_type(self, timeline_type: str): """Set the timeline type (home, local, federated)""" self.timeline_type = timeline_type @@ -112,171 +124,228 @@ class TimelineView(QTreeWidget): self.refresh() # Timeline changes don't preserve position # Re-enable notifications after a shorter delay for notifications timeline from PySide6.QtCore import QTimer - delay = 1000 if timeline_type == "notifications" else 3000 # 1s for notifications, 3s for others + + delay = ( + 1000 if timeline_type == "notifications" else 3000 + ) # 1s for notifications, 3s for others QTimer.singleShot(delay, self.enable_notifications) - + def enable_notifications(self): """Enable desktop notifications for timeline updates""" self.skip_notifications = False - + def refresh(self, preserve_position=False): """Refresh the timeline content""" - self.logger.debug(f"Timeline refresh() called with preserve_position={preserve_position}") - + self.logger.debug( + f"Timeline refresh() called with preserve_position={preserve_position}" + ) + # Store scroll position info before clearing if preservation is requested scroll_info = None if preserve_position: scroll_info = self._store_scroll_position() - + self.clear() - + # Get active account active_account = self.account_manager.get_active_account() if not active_account: - self.show_empty_message("Nothing to see here. To get content connect to an instance.") + self.show_empty_message( + "Nothing to see here. To get content connect to an instance." + ) return - + # Create ActivityPub client for active account self.activitypub_client = ActivityPubClient( - active_account.instance_url, - active_account.access_token + active_account.instance_url, active_account.access_token ) - + try: # Get posts per page from settings - posts_per_page = int(self.settings.get('timeline', 'posts_per_page', 40) or 40) - + posts_per_page = int( + self.settings.get("timeline", "posts_per_page", 40) or 40 + ) + # Fetch timeline, notifications, followers/following, conversations, bookmarks, blocked/muted users if self.timeline_type == "notifications": - timeline_data = self.activitypub_client.get_notifications(limit=posts_per_page) + timeline_data = self.activitypub_client.get_notifications( + limit=posts_per_page + ) # No special case needed - notifications timeline type already handles all notifications properly elif self.timeline_type == "followers": # Get current user account info first user_info = self.activitypub_client.verify_credentials() - timeline_data = self.activitypub_client.get_followers(user_info['id'], limit=posts_per_page) + timeline_data = self.activitypub_client.get_followers( + user_info["id"], limit=posts_per_page + ) elif self.timeline_type == "following": # Get current user account info first user_info = self.activitypub_client.verify_credentials() - timeline_data = self.activitypub_client.get_following(user_info['id'], limit=posts_per_page) + timeline_data = self.activitypub_client.get_following( + user_info["id"], limit=posts_per_page + ) elif self.timeline_type == "conversations": timeline_data = self.load_conversations(posts_per_page) elif self.timeline_type == "bookmarks": - timeline_data = self.activitypub_client.get_bookmarks(limit=posts_per_page) + timeline_data = self.activitypub_client.get_bookmarks( + limit=posts_per_page + ) elif self.timeline_type == "blocked": - timeline_data = self.activitypub_client.get_blocked_accounts(limit=posts_per_page) + timeline_data = self.activitypub_client.get_blocked_accounts( + limit=posts_per_page + ) elif self.timeline_type == "muted": - timeline_data = self.activitypub_client.get_muted_accounts(limit=posts_per_page) + timeline_data = self.activitypub_client.get_muted_accounts( + limit=posts_per_page + ) else: - timeline_data = self.activitypub_client.get_timeline(self.timeline_type, limit=posts_per_page) + timeline_data = self.activitypub_client.get_timeline( + self.timeline_type, limit=posts_per_page + ) self.load_timeline_data(timeline_data) - + # Track oldest and newest post IDs if timeline_data: - self.oldest_post_id = timeline_data[-1]['id'] + self.oldest_post_id = timeline_data[-1]["id"] # Track newest post ID for new content detection if not self.newest_post_id: # First load - set newest to first post - self.newest_post_id = timeline_data[0]['id'] - + self.newest_post_id = timeline_data[0]["id"] + # Restore scroll position if requested if preserve_position and scroll_info: self._restore_scroll_position(scroll_info) except Exception as e: self.logger.error(f"Failed to fetch timeline: {e}") # Show error message instead of sample data - self.show_empty_message(f"Failed to load timeline: {str(e)}\nCheck your connection and try refreshing.") - + self.show_empty_message( + f"Failed to load timeline: {str(e)}\nCheck your connection and try refreshing." + ) + def load_timeline_data(self, timeline_data): """Load real timeline data from ActivityPub API""" # Check for new content by comparing newest post ID (only for regular timelines) has_new_content = False - if timeline_data and self.newest_post_id and self.timeline_type not in ["followers", "following", "blocked", "muted", "notifications"]: + if ( + timeline_data + and self.newest_post_id + and self.timeline_type + not in ["followers", "following", "blocked", "muted", "notifications"] + ): # Check if the first post (newest) is different from what we had - current_newest_id = timeline_data[0]['id'] + current_newest_id = timeline_data[0]["id"] if current_newest_id != self.newest_post_id: has_new_content = True - self.logger.info(f"New content detected: newest post changed from {self.newest_post_id} to {current_newest_id}") + self.logger.info( + f"New content detected: newest post changed from {self.newest_post_id} to {current_newest_id}" + ) self.newest_post_id = current_newest_id else: - self.logger.debug(f"No new content: newest post still {current_newest_id}") + self.logger.debug( + f"No new content: newest post still {current_newest_id}" + ) elif not timeline_data: self.logger.debug("No timeline data received") elif not self.newest_post_id: self.logger.debug("First load: no previous newest_post_id to compare") else: - self.logger.debug(f"Skipping new content check for timeline type: {self.timeline_type}") - + self.logger.debug( + f"Skipping new content check for timeline type: {self.timeline_type}" + ) + self.posts = [] - + if self.timeline_type == "notifications": # Handle notifications data structure # Track notification types to determine priority-based sound notification_types_found = set() new_notifications_found = set() # Only truly new notifications - + # Check for new notifications by comparing with last seen notification ID if timeline_data and not self.skip_notifications: - current_newest_notification_id = timeline_data[0]['id'] - + current_newest_notification_id = timeline_data[0]["id"] + for notification_data in timeline_data: try: - notification_type = notification_data['type'] - sender = notification_data['account']['display_name'] or notification_data['account']['username'] - - notification_id = notification_data['id'] - + notification_type = notification_data["type"] + sender = ( + notification_data["account"]["display_name"] + or notification_data["account"]["username"] + ) + + notification_id = notification_data["id"] + # Debug: Print notification type for troubleshooting - self.logger.debug(f"Received notification type: {notification_type} from {sender}") - + self.logger.debug( + f"Received notification type: {notification_type} from {sender}" + ) + # Track notification types for priority-based sound selection notification_types_found.add(notification_type) - + # Check if this is a new notification (only if we have a previous baseline) - if (self.last_notification_check is not None and - not self.skip_notifications and - notification_id > self.last_notification_check): + if ( + self.last_notification_check is not None + and not self.skip_notifications + and notification_id > self.last_notification_check + ): new_notifications_found.add(notification_type) - self.logger.debug(f"NEW notification detected: {notification_type} from {sender}") - + self.logger.debug( + f"NEW notification detected: {notification_type} from {sender}" + ) + # Notifications with status (mentions, boosts, favorites) - if 'status' in notification_data: - post = Post.from_api_dict(notification_data['status']) + if "status" in notification_data: + post = Post.from_api_dict(notification_data["status"]) # Add notification metadata to post post.notification_type = notification_type - post.notification_account = notification_data['account']['acct'] + post.notification_account = notification_data["account"]["acct"] self.posts.append(post) - + # Show desktop notification (skip if this is initial load) if not self.skip_notifications: content_preview = post.get_display_content() - - if notification_type == 'mention': - self.notification_manager.notify_mention(sender, content_preview) - elif notification_type == 'reblog': - self.notification_manager.notify_boost(sender, content_preview) - elif notification_type == 'favourite': - self.notification_manager.notify_favorite(sender, content_preview) - elif notification_type == 'follow': + + if notification_type == "mention": + self.notification_manager.notify_mention( + sender, content_preview + ) + elif notification_type == "reblog": + self.notification_manager.notify_boost( + sender, content_preview + ) + elif notification_type == "favourite": + self.notification_manager.notify_favorite( + sender, content_preview + ) + elif notification_type == "follow": # Handle follow notifications without status (skip if initial load) if not self.skip_notifications: self.notification_manager.notify_follow(sender) except Exception as e: self.logger.error(f"Error parsing notification: {e}") continue - + # Update our notification tracking if timeline_data: - newest_id = timeline_data[0]['id'] - if self.last_notification_check is None or newest_id > self.last_notification_check: + newest_id = timeline_data[0]["id"] + if ( + self.last_notification_check is None + or newest_id > self.last_notification_check + ): self.last_notification_check = newest_id - + # Play priority-based sound only for NEW notifications if new_notifications_found: - self.logger.debug(f"Playing sound for NEW notification types: {new_notifications_found}") + self.logger.debug( + f"Playing sound for NEW notification types: {new_notifications_found}" + ) self._play_priority_notification_sound(new_notifications_found) elif notification_types_found and not self.skip_notifications: # Fallback: if we can't determine new vs old, but skip_notifications is False - self.logger.debug(f"Playing sound for notification types (fallback): {notification_types_found}") + self.logger.debug( + f"Playing sound for notification types (fallback): {notification_types_found}" + ) self._play_priority_notification_sound(notification_types_found) elif self.timeline_type == "conversations": # Handle conversations data structure @@ -287,38 +356,62 @@ class TimelineView(QTreeWidget): def __init__(self, conversation_data, account_manager): # Get current user info for display formatting active_account = account_manager.get_active_account() - current_user_id = active_account.account_id if active_account else None + current_user_id = ( + active_account.account_id if active_account else None + ) self.account_manager = account_manager - + conv = Conversation.from_api_data(conversation_data) self.id = conv.id self.conversation = conv self.account = conv.accounts[0] if conv.accounts else None self.in_reply_to_id = None - self.conversation_type = conversation_data.get('conversation_type', 'standard') - + self.conversation_type = conversation_data.get( + "conversation_type", "standard" + ) + # Generate display content participants = conv.get_display_name(current_user_id) last_message = conv.get_last_message_preview() unread_indicator = " 🔵" if conv.unread else "" - + self.content = f"

{participants}{unread_indicator}

" if last_message and last_message != "No messages": self.content += f"

{last_message}

" - + def get_content_text(self): active_account = self.account_manager.get_active_account() - current_user_id = active_account.account_id if active_account else None - return self.conversation.get_accessible_description(current_user_id) - + current_user_id = ( + active_account.account_id if active_account else None + ) + return self.conversation.get_accessible_description( + current_user_id + ) + + def get_display_content(self): + """Get display content for conversations (for URL extraction)""" + active_account = self.account_manager.get_active_account() + current_user_id = ( + active_account.account_id if active_account else None + ) + return self.conversation.get_accessible_description( + current_user_id + ) + def get_summary_for_screen_reader(self): active_account = self.account_manager.get_active_account() - current_user_id = active_account.account_id if active_account else None - return self.conversation.get_accessible_description(current_user_id) - - conversation_post = ConversationDisplayPost(conv_data, self.account_manager) + current_user_id = ( + active_account.account_id if active_account else None + ) + return self.conversation.get_accessible_description( + current_user_id + ) + + conversation_post = ConversationDisplayPost( + conv_data, self.account_manager + ) self.posts.append(conversation_post) - + except Exception as e: self.logger.error(f"Error parsing conversation: {e}") continue @@ -328,8 +421,9 @@ class TimelineView(QTreeWidget): try: # Create a pseudo-post from account data for display from models.user import User + user = User.from_api_dict(account_data) - + # Create a special Post-like object for accounts class AccountDisplayPost: def __init__(self, user, account_type): @@ -337,18 +431,18 @@ class TimelineView(QTreeWidget): self.account = user self.account_type = account_type # "followers", "following", "blocked", "muted" self.in_reply_to_id = None - + # Add status indicator based on account type status_indicator = "" if account_type == "blocked": status_indicator = " 🚫" elif account_type == "muted": status_indicator = " 🔇" - + self.content = f"

@{user.username} - {user.display_name or user.username}{status_indicator}

" if user.note: self.content += f"
{user.note}" - + def get_content_text(self): status_text = "" if self.account_type == "blocked": @@ -356,25 +450,32 @@ class TimelineView(QTreeWidget): elif self.account_type == "muted": status_text = " (Muted)" return f"@{self.account.username} - {self.account.display_name or self.account.username}{status_text}" - + + def get_display_content(self): + """Get display content for accounts (for URL extraction)""" + return self.get_content_text() + def get_summary_for_screen_reader(self): - username = self.account.display_name or self.account.username + username = ( + self.account.display_name or self.account.username + ) status_text = "" if self.account_type == "blocked": status_text = " - Blocked user" elif self.account_type == "muted": status_text = " - Muted user" - + if self.account.note: # Strip HTML tags from note import re - note = re.sub('<[^<]+?>', '', self.account.note) + + note = re.sub("<[^<]+?>", "", self.account.note) return f"{username} (@{self.account.username}){status_text}: {note}" return f"{username} (@{self.account.username}){status_text}" - + account_post = AccountDisplayPost(user, self.timeline_type) self.posts.append(account_post) - + except Exception as e: self.logger.error(f"Error parsing account: {e}") continue @@ -389,51 +490,59 @@ class TimelineView(QTreeWidget): except Exception as e: self.logger.error(f"Error parsing post: {e}") continue - + # Show timeline update notification if new content detected (skip if initial load) if has_new_content and not self.skip_notifications: timeline_name = { - 'home': 'home timeline', - 'local': 'local timeline', - 'federated': 'federated timeline', - 'conversations': 'conversations' - }.get(self.timeline_type, 'timeline') - - self.logger.info(f"New content notification triggered for {timeline_name}") - + "home": "home timeline", + "local": "local timeline", + "federated": "federated timeline", + "conversations": "conversations", + }.get(self.timeline_type, "timeline") + + self.logger.info( + f"New content notification triggered for {timeline_name}" + ) + # Use generic "new content" message instead of counting posts self.notification_manager.notify_new_content(timeline_name) # Play appropriate sound based on timeline type - if self.timeline_type == 'conversations': + if self.timeline_type == "conversations": # Use direct message sound for conversation updates - self.logger.info("Playing direct_message sound for conversations timeline") + self.logger.info( + "Playing direct_message sound for conversations timeline" + ) self.sound_manager.play_direct_message() else: # Use timeline update sound for other timelines - self.logger.info(f"Playing timeline_update sound for {timeline_name}") + self.logger.info( + f"Playing timeline_update sound for {timeline_name}" + ) self.sound_manager.play_timeline_update() elif has_new_content and self.skip_notifications: - self.logger.debug("New content detected but notifications are disabled (initial load)") + self.logger.debug( + "New content detected but notifications are disabled (initial load)" + ) elif not has_new_content: self.logger.debug("No new content detected, no sound played") - + # Build thread structure (accounts don't need threading) if self.timeline_type in ["followers", "following"]: self.build_account_list() else: self.build_threaded_timeline() - + def build_threaded_timeline(self): """Build threaded timeline from posts""" # Find thread roots and flatten all replies under them thread_roots = {} # Maps thread root ID to list of all posts in thread orphaned_posts = [] # Posts that couldn't find their thread root - + # First pass: identify thread roots (posts with no in_reply_to_id) for post in self.posts: if not post.in_reply_to_id: thread_roots[post.id] = [post] - + # Second pass: assign all replies to their thread root for post in self.posts: if post.in_reply_to_id: @@ -450,28 +559,30 @@ class TimelineView(QTreeWidget): else: # Still can't find parent, treat as orphaned orphaned_posts.append(post) - + # Create tree items - one root with all replies as direct children for root_id, thread_posts in thread_roots.items(): root_post = thread_posts[0] # First post is always the root root_item = self.create_post_item(root_post) self.addTopLevelItem(root_item) - + # Add all other posts in thread as direct children (flattened) for post in thread_posts[1:]: reply_item = self.create_post_item(post) - reply_item.setData(0, Qt.UserRole + 1, post.in_reply_to_id) # Store what this replies to + reply_item.setData( + 0, Qt.UserRole + 1, post.in_reply_to_id + ) # Store what this replies to root_item.addChild(reply_item) - + # Add orphaned posts as top-level items for post in orphaned_posts: orphaned_item = self.create_post_item(post) self.addTopLevelItem(orphaned_item) - + # Add "Load more posts" item if we have posts if self.posts: self.add_load_more_item() - + # Collapse all initially and update accessibility self.collapseAll() # Ensure collapsed items are properly marked for screen readers @@ -479,23 +590,27 @@ class TimelineView(QTreeWidget): top_item = self.topLevelItem(i) if top_item.childCount() > 0: top_item.setExpanded(False) # Standard Qt collapse - + def build_account_list(self): """Build simple list for followers/following accounts""" for account_post in self.posts: item = self.create_post_item(account_post) self.addTopLevelItem(item) - + # Add "Load more" item if we have accounts if self.posts: self.add_load_more_item() - + def find_thread_root(self, post, all_posts): """Find the root post ID for a given reply by walking up the chain""" current_post = post visited = set() # Prevent infinite loops - - while current_post and current_post.in_reply_to_id and current_post.id not in visited: + + while ( + current_post + and current_post.in_reply_to_id + and current_post.id not in visited + ): visited.add(current_post.id) # Find the parent post parent_post = None @@ -503,7 +618,7 @@ class TimelineView(QTreeWidget): if p.id == current_post.in_reply_to_id: parent_post = p break - + if parent_post: if not parent_post.in_reply_to_id: # Found the root @@ -512,40 +627,45 @@ class TimelineView(QTreeWidget): else: # Parent not found, current post becomes root break - + # If we couldn't find a proper root, use the post's direct parent return post.in_reply_to_id - + def try_fetch_missing_parent(self, reply_post): """Try to fetch a missing parent post from the API""" if not reply_post.in_reply_to_id: return None - + try: # Get active account and client active_account = self.account_manager.get_active_account() if not active_account: return None - + from activitypub.client import ActivityPubClient - client = ActivityPubClient(active_account.instance_url, active_account.access_token) - + + client = ActivityPubClient( + active_account.instance_url, active_account.access_token + ) + # Fetch the parent post parent_data = client.get_status(reply_post.in_reply_to_id) parent_post = Post.from_api_dict(parent_data) - - self.logger.debug(f"Fetched missing parent post {parent_post.id} for reply {reply_post.id}") + + self.logger.debug( + f"Fetched missing parent post {parent_post.id} for reply {reply_post.id}" + ) return parent_post - + except Exception as e: self.logger.warning(f"Failed to fetch missing parent post: {e}") return None - + def add_streaming_post_to_timeline(self, new_post): """Add a new post from streaming directly to timeline without full refresh""" try: self.logger.debug(f"Adding streaming post {new_post.id} to timeline") - + # If this is a reply, try to find its parent in the current timeline if new_post.in_reply_to_id: parent_item = self.find_existing_post_item(new_post.in_reply_to_id) @@ -554,154 +674,161 @@ class TimelineView(QTreeWidget): reply_item = self.create_post_item(new_post) parent_item.addChild(reply_item) parent_item.setExpanded(True) # Auto-expand to show new reply - self.logger.debug(f"Added reply {new_post.id} under parent {new_post.in_reply_to_id}") + self.logger.debug( + f"Added reply {new_post.id} under parent {new_post.in_reply_to_id}" + ) return else: - self.logger.debug(f"Parent {new_post.in_reply_to_id} not found in current timeline") - + self.logger.debug( + f"Parent {new_post.in_reply_to_id} not found in current timeline" + ) + # No parent found or this is a top-level post - add at top new_item = self.create_post_item(new_post) self.insertTopLevelItem(0, new_item) # Add at top of timeline self.logger.debug(f"Added post {new_post.id} as top-level item") - + # Update our posts list self.posts.insert(0, new_post) - + except Exception as e: self.logger.error(f"Error adding streaming post to timeline: {e}") raise # Re-raise to trigger fallback refresh - + def find_existing_post_item(self, post_id): """Find an existing QTreeWidgetItem for a given post ID""" # Search top-level items for i in range(self.topLevelItemCount()): item = self.topLevelItem(i) post_data = item.data(0, Qt.UserRole) - if hasattr(post_data, 'id') and post_data.id == post_id: + if hasattr(post_data, "id") and post_data.id == post_id: return item - + # Search children child_item = self.find_post_in_children(item, post_id) if child_item: return child_item - + return None - + def find_post_in_children(self, parent_item, post_id): """Recursively search for a post in children of an item""" for i in range(parent_item.childCount()): child = parent_item.child(i) post_data = child.data(0, Qt.UserRole) - if hasattr(post_data, 'id') and post_data.id == post_id: + if hasattr(post_data, "id") and post_data.id == post_id: return child - + # Search deeper deeper_child = self.find_post_in_children(child, post_id) if deeper_child: return deeper_child - + return None - + def create_post_item(self, post: Post) -> QTreeWidgetItem: """Create a tree item for a post""" # Get display text summary = post.get_summary_for_screen_reader() - + # Create item item = QTreeWidgetItem([summary]) item.setData(0, Qt.UserRole, post) # Store post object item.setData(0, Qt.AccessibleTextRole, summary) - + return item - + def copy_post_to_clipboard(self, post: Optional[Post] = None): """Copy the selected post's content to clipboard""" if not post: post = self.get_selected_post() - + if not post: return - + # Get the full post content content = post.get_display_content() author = post.get_display_name() - + # Format for clipboard clipboard_text = f"{author}:\n{content}" - + # Copy to clipboard from PySide6.QtWidgets import QApplication + clipboard = QApplication.clipboard() clipboard.setText(clipboard_text) - + # Show feedback - if hasattr(self, 'parent') and hasattr(self.parent(), 'status_bar'): + if hasattr(self, "parent") and hasattr(self.parent(), "status_bar"): self.parent().status_bar.showMessage("Post copied to clipboard", 2000) - + def extract_urls_from_post(self, post: Optional[Post] = None) -> List[str]: """Extract URLs from post content""" if not post: post = self.get_selected_post() - + if not post: return [] - + content = post.get_display_content() - + # URL regex pattern - matches http/https URLs, more comprehensive url_pattern = r'https?://[^\s<>"\'`\)\]\}]+' urls = re.findall(url_pattern, content) - + # Also check the original HTML content for href attributes - html_content = post.content if hasattr(post, 'content') else "" + html_content = post.content if hasattr(post, "content") else "" href_pattern = r'href=["\']([^"\']+)["\']' href_urls = re.findall(href_pattern, html_content) - + # Combine and filter URLs all_urls = urls + href_urls filtered_urls = [] for url in all_urls: - if url.startswith(('http://', 'https://')): + if url.startswith(("http://", "https://")): filtered_urls.append(url) - + return list(set(filtered_urls)) # Remove duplicates - + def open_urls_in_browser(self, post: Optional[Post] = None): """Open URLs from post in browser""" urls = self.extract_urls_from_post(post) - + if not urls: - if hasattr(self, 'parent') and hasattr(self.parent(), 'status_bar'): + if hasattr(self, "parent") and hasattr(self.parent(), "status_bar"): self.parent().status_bar.showMessage("No URLs found in post", 2000) return - + if len(urls) == 1: # Single URL - open directly try: webbrowser.open(urls[0]) - if hasattr(self, 'parent') and hasattr(self.parent(), 'status_bar'): + if hasattr(self, "parent") and hasattr(self.parent(), "status_bar"): self.parent().status_bar.showMessage(f"Opened URL in browser", 2000) except Exception as e: - if hasattr(self, 'parent') and hasattr(self.parent(), 'status_bar'): - self.parent().status_bar.showMessage(f"Failed to open URL: {str(e)}", 3000) + if hasattr(self, "parent") and hasattr(self.parent(), "status_bar"): + self.parent().status_bar.showMessage( + f"Failed to open URL: {str(e)}", 3000 + ) else: # Multiple URLs - show selection dialog self.show_url_selection_dialog(urls) - + def show_url_selection_dialog(self, urls: List[str]): """Show dialog to select which URL to open""" dialog = QDialog(self) dialog.setWindowTitle("Select URL to Open") dialog.setMinimumSize(500, 300) dialog.setModal(True) - + layout = QVBoxLayout(dialog) - + # Label label = QLabel(f"Found {len(urls)} URLs in this post. Select one to open:") label.setAccessibleName("URL Selection") layout.addWidget(label) - + # URL list url_list = QListWidget() url_list.setAccessibleName("URL List") @@ -709,13 +836,13 @@ class TimelineView(QTreeWidget): url_list.addItem(url) url_list.setCurrentRow(0) # Select first item layout.addWidget(url_list) - + # Buttons button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) button_box.accepted.connect(dialog.accept) button_box.rejected.connect(dialog.reject) layout.addWidget(button_box) - + # Show dialog if dialog.exec() == QDialog.Accepted: current_item = url_list.currentItem() @@ -723,103 +850,109 @@ class TimelineView(QTreeWidget): selected_url = current_item.text() try: webbrowser.open(selected_url) - if hasattr(self, 'parent') and hasattr(self.parent(), 'status_bar'): - self.parent().status_bar.showMessage(f"Opened URL in browser", 2000) + if hasattr(self, "parent") and hasattr(self.parent(), "status_bar"): + self.parent().status_bar.showMessage( + f"Opened URL in browser", 2000 + ) except Exception as e: - if hasattr(self, 'parent') and hasattr(self.parent(), 'status_bar'): - self.parent().status_bar.showMessage(f"Failed to open URL: {str(e)}", 3000) - + if hasattr(self, "parent") and hasattr(self.parent(), "status_bar"): + self.parent().status_bar.showMessage( + f"Failed to open URL: {str(e)}", 3000 + ) + def add_load_more_item(self): """Add a 'Load more posts' item at the bottom of the timeline""" load_more_item = QTreeWidgetItem(["Load more posts (Press Enter)"]) load_more_item.setData(0, Qt.UserRole, "load_more") # Special marker - load_more_item.setData(0, Qt.AccessibleTextRole, "Load more posts from timeline") + load_more_item.setData( + 0, Qt.AccessibleTextRole, "Load more posts from timeline" + ) self.addTopLevelItem(load_more_item) - + def load_more_posts(self): """Load more posts from the current timeline""" if not self.activitypub_client or not self.oldest_post_id: return - + try: # Get posts per page from settings - posts_per_page = int(self.settings.get('timeline', 'posts_per_page', 40) or 40) - + posts_per_page = int( + self.settings.get("timeline", "posts_per_page", 40) or 40 + ) + # Fetch more posts using max_id for pagination if self.timeline_type == "notifications": more_data = self.activitypub_client.get_notifications( - limit=posts_per_page, - max_id=self.oldest_post_id + limit=posts_per_page, max_id=self.oldest_post_id ) # No special case needed - notifications are handled by the main notifications case elif self.timeline_type == "followers": user_info = self.activitypub_client.verify_credentials() more_data = self.activitypub_client.get_followers( - user_info['id'], - limit=posts_per_page, - max_id=self.oldest_post_id + user_info["id"], limit=posts_per_page, max_id=self.oldest_post_id ) elif self.timeline_type == "following": user_info = self.activitypub_client.verify_credentials() more_data = self.activitypub_client.get_following( - user_info['id'], - limit=posts_per_page, - max_id=self.oldest_post_id + user_info["id"], limit=posts_per_page, max_id=self.oldest_post_id ) elif self.timeline_type == "conversations": more_data = self.load_conversations( - limit=posts_per_page, - max_id=self.oldest_post_id + limit=posts_per_page, max_id=self.oldest_post_id ) elif self.timeline_type == "bookmarks": more_data = self.activitypub_client.get_bookmarks( - limit=posts_per_page, - max_id=self.oldest_post_id + limit=posts_per_page, max_id=self.oldest_post_id ) else: more_data = self.activitypub_client.get_timeline( - self.timeline_type, - limit=posts_per_page, - max_id=self.oldest_post_id + self.timeline_type, limit=posts_per_page, max_id=self.oldest_post_id ) - + if more_data: # Remember current "Load more" item position load_more_index = self.get_load_more_item_index() - + # Remove current "Load more" item self.remove_load_more_item() - + # Add new posts to existing list self.load_additional_timeline_data(more_data) - + # Update oldest post ID - self.oldest_post_id = more_data[-1]['id'] - + self.oldest_post_id = more_data[-1]["id"] + # Add new posts to the tree without rebuilding everything self.add_new_posts_to_tree(more_data, load_more_index) - + # Add new "Load more" item at the end self.add_load_more_item() - + # Focus on the first newly added post so user can arrow down to read them - if load_more_index is not None and load_more_index < self.topLevelItemCount(): + if ( + load_more_index is not None + and load_more_index < self.topLevelItemCount() + ): first_new_item = self.topLevelItem(load_more_index) if first_new_item: self.setCurrentItem(first_new_item) self.scrollToItem(first_new_item) - - if hasattr(self, 'parent') and hasattr(self.parent(), 'status_bar'): - self.parent().status_bar.showMessage(f"Loaded {len(more_data)} more posts", 2000) + + if hasattr(self, "parent") and hasattr(self.parent(), "status_bar"): + self.parent().status_bar.showMessage( + f"Loaded {len(more_data)} more posts", 2000 + ) else: - if hasattr(self, 'parent') and hasattr(self.parent(), 'status_bar'): + if hasattr(self, "parent") and hasattr(self.parent(), "status_bar"): self.parent().status_bar.showMessage("No more posts to load", 2000) - + except Exception as e: self.logger.error(f"Failed to load more posts: {e}") - if hasattr(self, 'parent') and hasattr(self.parent(), 'status_bar'): - self.parent().status_bar.showMessage(f"Failed to load more posts: {str(e)}", 3000) - + if hasattr(self, "parent") and hasattr(self.parent(), "status_bar"): + self.parent().status_bar.showMessage( + f"Failed to load more posts: {str(e)}", 3000 + ) + def get_load_more_item_index(self): """Get the index of the 'Load more posts' item""" for i in range(self.topLevelItemCount()): @@ -827,7 +960,7 @@ class TimelineView(QTreeWidget): if item.data(0, Qt.UserRole) == "load_more": return i return None - + def remove_load_more_item(self): """Remove the 'Load more posts' item""" for i in range(self.topLevelItemCount()): @@ -835,23 +968,26 @@ class TimelineView(QTreeWidget): if item.data(0, Qt.UserRole) == "load_more": self.takeTopLevelItem(i) break - + def load_additional_timeline_data(self, timeline_data): """Load additional timeline data and append to existing posts""" if self.timeline_type == "notifications": # Handle notifications data structure for notification_data in timeline_data: try: - notification_type = notification_data['type'] - sender = notification_data['account']['display_name'] or notification_data['account']['username'] - + notification_type = notification_data["type"] + sender = ( + notification_data["account"]["display_name"] + or notification_data["account"]["username"] + ) + # Notifications with status (mentions, boosts, favorites) - if 'status' in notification_data: - post = Post.from_api_dict(notification_data['status']) + if "status" in notification_data: + post = Post.from_api_dict(notification_data["status"]) post.notification_type = notification_type - post.notification_account = notification_data['account']['acct'] + post.notification_account = notification_data["account"]["acct"] self.posts.append(post) - elif notification_type == 'follow': + elif notification_type == "follow": # Handle follow notifications without status pass # Could create a special post type for follows except Exception as e: @@ -866,23 +1002,23 @@ class TimelineView(QTreeWidget): except Exception as e: self.logger.error(f"Error parsing post: {e}") continue - + def add_new_posts_to_tree(self, timeline_data, insert_index): """Add new posts to the tree at the specified index without rebuilding everything""" if insert_index is None: insert_index = self.topLevelItemCount() - + new_posts = [] - + # Parse the new posts if self.timeline_type == "notifications": for notification_data in timeline_data: try: - notification_type = notification_data['type'] - if 'status' in notification_data: - post = Post.from_api_dict(notification_data['status']) + notification_type = notification_data["type"] + if "status" in notification_data: + post = Post.from_api_dict(notification_data["status"]) post.notification_type = notification_type - post.notification_account = notification_data['account']['acct'] + post.notification_account = notification_data["account"]["acct"] new_posts.append(post) except Exception as e: self.logger.error(f"Error parsing notification: {e}") @@ -895,16 +1031,16 @@ class TimelineView(QTreeWidget): except Exception as e: self.logger.error(f"Error parsing post: {e}") continue - + # Group new posts by thread and insert them thread_roots = {} orphaned_posts = [] - + # Find thread roots among new posts for post in new_posts: if not post.in_reply_to_id: thread_roots[post.id] = [post] - + # Assign replies to thread roots for post in new_posts: if post.in_reply_to_id: @@ -913,83 +1049,100 @@ class TimelineView(QTreeWidget): thread_roots[root_id].append(post) else: orphaned_posts.append(post) - + # Insert thread root posts with their replies current_insert_index = insert_index for root_id, thread_posts in thread_roots.items(): root_post = thread_posts[0] root_item = self.create_post_item(root_post) self.insertTopLevelItem(current_insert_index, root_item) - + # Add replies as children for post in thread_posts[1:]: reply_item = self.create_post_item(post) reply_item.setData(0, Qt.UserRole + 1, post.in_reply_to_id) root_item.addChild(reply_item) - + # Collapse the thread initially root_item.setExpanded(False) - + current_insert_index += 1 - + # Insert orphaned posts for post in orphaned_posts: orphaned_item = self.create_post_item(post) self.insertTopLevelItem(current_insert_index, orphaned_item) current_insert_index += 1 - + def show_context_menu(self, position): """Show context menu for the selected post""" item = self.itemAt(position) if not item: return - + post = item.data(0, Qt.UserRole) if not post or post == "load_more": return - + menu = QMenu(self) - + # Check if this is a blocked/muted user management context - is_blocked_user = (self.timeline_type == "blocked" and hasattr(post, 'account_type') and post.account_type == "blocked") - is_muted_user = (self.timeline_type == "muted" and hasattr(post, 'account_type') and post.account_type == "muted") - + is_blocked_user = ( + self.timeline_type == "blocked" + and hasattr(post, "account_type") + and post.account_type == "blocked" + ) + is_muted_user = ( + self.timeline_type == "muted" + and hasattr(post, "account_type") + and post.account_type == "muted" + ) + # For blocked/muted user management, show simplified context menu if is_blocked_user or is_muted_user: # Unblock/Unmute action if is_blocked_user: unblock_action = QAction("&Unblock User", self) - unblock_action.triggered.connect(lambda: self.unblock_user_from_list(post)) + unblock_action.triggered.connect( + lambda: self.unblock_user_from_list(post) + ) menu.addAction(unblock_action) else: # is_muted_user unmute_action = QAction("&Unmute User", self) - unmute_action.triggered.connect(lambda: self.unmute_user_from_list(post)) + unmute_action.triggered.connect( + lambda: self.unmute_user_from_list(post) + ) menu.addAction(unmute_action) - + menu.addSeparator() - + # View profile action profile_action = QAction("View &Profile", self) profile_action.triggered.connect(lambda: self.profile_requested.emit(post)) menu.addAction(profile_action) - + menu.exec(self.mapToGlobal(position)) return - + # Get current user account for ownership checks active_account = self.account_manager.get_active_account() is_own_post = False - if active_account and hasattr(post, 'account'): + if active_account and hasattr(post, "account"): # Check if this is user's own post - is_own_post = (post.account.username == active_account.username and - post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', '')) - + is_own_post = ( + post.account.username == active_account.username + and post.account.acct.split("@")[-1] + == active_account.instance_url.replace("https://", "").replace( + "http://", "" + ) + ) + # Copy to clipboard action copy_action = QAction("&Copy to Clipboard", self) copy_action.setShortcut("Ctrl+C") copy_action.triggered.connect(lambda: self.copy_post_to_clipboard(post)) menu.addAction(copy_action) - + # Open URLs action urls = self.extract_urls_from_post(post) if urls: @@ -1000,127 +1153,137 @@ class TimelineView(QTreeWidget): url_action.setShortcut("Ctrl+U") url_action.triggered.connect(lambda: self.open_urls_in_browser(post)) menu.addAction(url_action) - + menu.addSeparator() - + # Reply action reply_action = QAction("&Reply", self) reply_action.setShortcut("Ctrl+R") reply_action.triggered.connect(lambda: self.reply_requested.emit(post)) menu.addAction(reply_action) - + # Boost action - boost_text = "Un&boost" if getattr(post, 'reblogged', False) else "&Boost" + boost_text = "Un&boost" if getattr(post, "reblogged", False) else "&Boost" boost_action = QAction(boost_text, self) boost_action.setShortcut("Ctrl+B") boost_action.triggered.connect(lambda: self.boost_requested.emit(post)) menu.addAction(boost_action) - + # Favorite action - fav_text = "Un&favorite" if getattr(post, 'favourited', False) else "&Favorite" + fav_text = "Un&favorite" if getattr(post, "favourited", False) else "&Favorite" favorite_action = QAction(fav_text, self) favorite_action.setShortcut("Ctrl+F") favorite_action.triggered.connect(lambda: self.favorite_requested.emit(post)) menu.addAction(favorite_action) - + # Owner-only actions if is_own_post: menu.addSeparator() - + # Edit action edit_action = QAction("&Edit Post", self) edit_action.setShortcut("Ctrl+Shift+E") edit_action.triggered.connect(lambda: self.edit_requested.emit(post)) menu.addAction(edit_action) - + # Delete action delete_action = QAction("&Delete Post", self) delete_action.setShortcut("Shift+Delete") delete_action.triggered.connect(lambda: self.delete_requested.emit(post)) menu.addAction(delete_action) - + menu.addSeparator() - + # Follow/Unfollow actions for non-own posts - if not is_own_post and hasattr(post, 'account'): + if not is_own_post and hasattr(post, "account"): # TODO: Check relationship status to show correct follow/unfollow option # For now, show both - this will be improved when we add relationship checking follow_action = QAction("&Follow User", self) follow_action.setShortcut("Ctrl+Shift+F") follow_action.triggered.connect(lambda: self.follow_requested.emit(post)) menu.addAction(follow_action) - + unfollow_action = QAction("&Unfollow User", self) unfollow_action.setShortcut("Ctrl+Shift+U") - unfollow_action.triggered.connect(lambda: self.unfollow_requested.emit(post)) + unfollow_action.triggered.connect( + lambda: self.unfollow_requested.emit(post) + ) menu.addAction(unfollow_action) - + # View profile action profile_action = QAction("View &Profile", self) profile_action.triggered.connect(lambda: self.profile_requested.emit(post)) menu.addAction(profile_action) - + menu.exec(self.mapToGlobal(position)) - - def load_conversations(self, limit: int = 20, max_id: Optional[str] = None) -> List[Dict]: + + def load_conversations( + self, limit: int = 20, max_id: Optional[str] = None + ) -> List[Dict]: """Load conversations from both standard API and Pleroma chats""" conversations = [] - + try: # Try standard conversations API first standard_conversations = self.activitypub_client.get_conversations( - limit=limit, - max_id=max_id + limit=limit, max_id=max_id ) for conv_data in standard_conversations: conversations.append(conv_data) except Exception as e: self.logger.error(f"Failed to load standard conversations: {e}") - + try: - # Try Pleroma chats as fallback/supplement + # Try Pleroma chats as fallback/supplement pleroma_chats = self.activitypub_client.get_pleroma_chats( - limit=limit, - max_id=max_id + limit=limit, max_id=max_id ) for chat_data in pleroma_chats: # Convert Pleroma chat to conversation format pleroma_conv = PleromaChatConversation.from_api_data(chat_data) conversation = pleroma_conv.to_conversation() # Add as dict for consistency with timeline data format - conversations.append({ - 'id': conversation.id, - 'unread': conversation.unread, - 'accounts': [acc.to_api_data() for acc in conversation.accounts], - 'last_status': conversation.last_status.to_api_data() if conversation.last_status else None, - 'chat_id': conversation.chat_id, - 'conversation_type': 'pleroma_chat' - }) + conversations.append( + { + "id": conversation.id, + "unread": conversation.unread, + "accounts": [ + acc.to_api_data() for acc in conversation.accounts + ], + "last_status": ( + conversation.last_status.to_api_data() + if conversation.last_status + else None + ), + "chat_id": conversation.chat_id, + "conversation_type": "pleroma_chat", + } + ) except Exception as e: self.logger.error(f"Failed to load Pleroma chats: {e}") - + return conversations - + def show_empty_message(self, message: str): """Show an empty timeline with a message""" item = QTreeWidgetItem([message]) item.setData(0, Qt.AccessibleTextRole, message) item.setDisabled(True) # Make it non-selectable self.addTopLevelItem(item) - + def get_selected_post(self) -> Optional[Post]: """Get the currently selected post""" current = self.currentItem() if current: return current.data(0, Qt.UserRole) return None - + def get_current_post_info(self) -> str: """Get information about the currently selected post""" current = self.currentItem() if not current: return "No post selected" - + # Check if this is a top-level post with replies if current.parent() is None and current.childCount() > 0: child_count = current.childCount() @@ -1130,113 +1293,138 @@ class TimelineView(QTreeWidget): return f"Reply: {current.text(0)}" else: return current.text(0) - + def on_selection_changed(self, current, previous): """Handle timeline item selection changes""" # Only mark conversations as read in the conversations timeline if self.timeline_type != "conversations" or not current: return - + # Get the post object for this item try: post_index = self.indexOfTopLevelItem(current) if 0 <= post_index < len(self.posts): post = self.posts[post_index] - + # Check if this is a conversation with unread messages - if hasattr(post, 'conversation') and post.conversation.unread: + if hasattr(post, "conversation") and post.conversation.unread: # Mark conversation as read via API if self.activitypub_client: try: - self.activitypub_client.mark_conversation_read(post.conversation.id) + self.activitypub_client.mark_conversation_read( + post.conversation.id + ) # Update local state post.conversation.unread = False # Update the display text to remove (unread) indicator self._update_conversation_display(current, post) except Exception as e: - self.logger.error(f"Failed to mark conversation as read: {e}") + self.logger.error( + f"Failed to mark conversation as read: {e}" + ) except Exception as e: self.logger.error(f"Error in selection change handler: {e}") - + def _update_conversation_display(self, item, post): """Update conversation display after marking as read""" try: # Get current user for display formatting active_account = self.account_manager.get_active_account() current_user_id = active_account.account_id if active_account else None - + # Generate new display text without unread indicator - updated_description = post.conversation.get_accessible_description(current_user_id) + updated_description = post.conversation.get_accessible_description( + current_user_id + ) item.setText(0, updated_description) item.setData(0, Qt.AccessibleTextRole, updated_description) except Exception as e: self.logger.error(f"Error updating conversation display: {e}") - + def keyPressEvent(self, event: QKeyEvent): - """Handle keyboard events, including Enter for poll voting""" + """Handle keyboard events, including Enter for poll voting and context menu keys""" key = event.key() current = self.currentItem() - - # Handle Enter key for polls + + # Handle context menu key (Menu key or Shift+F10) + if key == Qt.Key_Menu or ( + key == Qt.Key_F10 and event.modifiers() & Qt.ShiftModifier + ): + if current: + # Get the position of the current item + rect = self.visualItemRect(current) + center = rect.center() + # Emit context menu signal + self.customContextMenuRequested.emit(center) + return + + # Handle Enter key for polls, load more, and post details if (key == Qt.Key_Return or key == Qt.Key_Enter) and current: + # Check for special items first + special_data = current.data(0, Qt.UserRole) + if special_data == "load_more": + self.load_more_posts() + return + # Check if current item has poll data try: post_index = self.indexOfTopLevelItem(current) if 0 <= post_index < len(self.posts): post = self.posts[post_index] - - # Check if this post has a poll - if hasattr(post, 'has_poll') and post.has_poll(): - self.show_poll_voting_dialog(post) - return - + + # Always show post details for all posts (polls are now included as a tab) + self.show_post_details(current) + return + except Exception as e: - self.logger.error(f"Error checking for poll: {e}") - + self.logger.error(f"Error handling Enter key: {e}") + # Call parent implementation for other keys super().keyPressEvent(event) - + def show_poll_voting_dialog(self, post): """Show poll voting dialog for a post""" if not post.poll: return - + try: # Create and show poll voting dialog dialog = PollVotingDialog(post.poll, self) - dialog.vote_submitted.connect(lambda choices: self.submit_poll_vote(post, choices)) + dialog.vote_submitted.connect( + lambda choices: self.submit_poll_vote(post, choices) + ) dialog.exec() - + except Exception as e: self.logger.error(f"Error showing poll dialog: {e}") - + def submit_poll_vote(self, post, choices: List[int]): """Submit a vote in a poll""" if not self.activitypub_client or not post.poll: return - + try: # Submit vote via API - result = self.activitypub_client.vote_in_poll(post.poll['id'], choices) - + result = self.activitypub_client.vote_in_poll(post.poll["id"], choices) + # Update local poll data with new results - if 'poll' in result: - post.poll = result['poll'] + if "poll" in result: + post.poll = result["poll"] # Refresh the display to show updated results self.refresh_post_display(post) - + # Play success sound self.sound_manager.play_success() - + # Refresh the entire timeline to ensure poll state is properly updated # and prevent duplicate voting attempts self.refresh(preserve_position=True) - + except Exception as e: self.logger.error(f"Failed to submit poll vote: {e}") # Play error sound self.sound_manager.play_error() - + def refresh_post_display(self, post): """Refresh the display of a specific post (for poll updates)""" try: @@ -1249,28 +1437,27 @@ class TimelineView(QTreeWidget): item.setText(0, summary) item.setData(0, Qt.AccessibleTextRole, summary) break - + except Exception as e: self.logger.error(f"Error refreshing post display: {e}") - + def add_new_posts(self, posts): """Add new posts to timeline with sound notification""" # TODO: Implement adding real posts from API if posts: self.sound_manager.play_timeline_update() - - + def announce_current_item(self): """Announce the current item for screen readers""" # This will be handled by the AccessibleTreeWidget pass - + def unblock_user_from_list(self, post): """Unblock a user from the blocked users list""" try: # Unblock the user via API self.activitypub_client.unblock_account(post.account.id) - + # Remove from current timeline display current_item = self.currentItem() if current_item and current_item.data(0, Qt.UserRole) == post: @@ -1280,20 +1467,20 @@ class TimelineView(QTreeWidget): if item.data(0, Qt.UserRole) == post: self.takeTopLevelItem(i) break - + # Play success sound self.sound_manager.play_success() - + except Exception as e: self.logger.error(f"Error unblocking user: {e}") self.sound_manager.play_error() - + def unmute_user_from_list(self, post): """Unmute a user from the muted users list""" try: # Unmute the user via API self.activitypub_client.unmute_account(post.account.id) - + # Remove from current timeline display current_item = self.currentItem() if current_item and current_item.data(0, Qt.UserRole) == post: @@ -1303,83 +1490,90 @@ class TimelineView(QTreeWidget): if item.data(0, Qt.UserRole) == post: self.takeTopLevelItem(i) break - + # Play success sound self.sound_manager.play_success() - + except Exception as e: self.logger.error(f"Error unmuting user: {e}") self.sound_manager.play_error() - + def expand_thread_with_context(self, item): """Expand thread after fetching full conversation context""" try: # Get the post from the tree item post = item.data(0, Qt.UserRole) - if not post or not hasattr(post, 'id'): + if not post or not hasattr(post, "id"): # Fallback to regular expansion self.expandItem(item) return - + # Fetch full conversation context if not self.activitypub_client: # No client available, fallback to regular expansion self.expandItem(item) return - + context_data = self.activitypub_client.get_status_context(post.id) - + # Get descendants (replies) from context - descendants = context_data.get('descendants', []) - + descendants = context_data.get("descendants", []) + if descendants: # Clear existing children item.takeChildren() - + # Add all replies from context for reply_data in descendants: from models.post import Post + reply_post = Post.from_api_dict(reply_data) reply_item = self.create_post_item(reply_post) reply_item.setData(0, Qt.UserRole + 1, reply_post.in_reply_to_id) item.addChild(reply_item) - + # Now expand the thread self.expandItem(item) - + # Play expand sound self.sound_manager.play_expand() - + except Exception as e: self.logger.error(f"Failed to fetch thread context: {e}") # Fallback to regular expansion self.expandItem(item) - + def show_post_details(self, item): """Show detailed post information dialog""" try: post = item.data(0, Qt.UserRole) - if not post or not hasattr(post, 'id'): + if not post or not hasattr(post, "id"): self.sound_manager.play_error() return - + # Import here to avoid circular imports from widgets.post_details_dialog import PostDetailsDialog - + # Create and show details dialog - dialog = PostDetailsDialog(post, self.activitypub_client, self.sound_manager, self) + dialog = PostDetailsDialog( + post, self.activitypub_client, self.sound_manager, self + ) + # Connect poll voting signal + dialog.vote_submitted.connect( + lambda post, choices: self.submit_poll_vote(post, choices) + ) dialog.exec() - + except Exception as e: self.logger.error(f"Failed to show post details: {e}") self.sound_manager.play_error() - + def _play_priority_notification_sound(self, notification_types): """Play the highest priority sound from a set of notification types. - + Priority order (highest to lowest): 1. direct_message (direct messages) - 2. follow (follow notifications) + 2. follow (follow notifications) 3. mention (mentions) 4. reply (replies to user's posts) 4. reblog (boosts) @@ -1387,112 +1581,118 @@ class TimelineView(QTreeWidget): """ # Define priority order (higher number = higher priority) priority_map = { - 'direct_message': 5, - 'follow': 4, - 'mention': 3, - 'reply': 2, # Reply notifications should play reply sound - 'reblog': 2, - 'favourite': 1 + "direct_message": 5, + "follow": 4, + "mention": 3, + "reply": 2, # Reply notifications should play reply sound + "reblog": 2, + "favourite": 1, } - + # Find the highest priority notification type highest_priority = 0 highest_type = None - + for notification_type in notification_types: priority = priority_map.get(notification_type, 0) if priority > highest_priority: highest_priority = priority highest_type = notification_type - + # Play appropriate sound for highest priority notification - if highest_type == 'direct_message': + if highest_type == "direct_message": self.sound_manager.play_direct_message() - elif highest_type == 'follow': + elif highest_type == "follow": self.sound_manager.play_follow() - elif highest_type == 'mention': + elif highest_type == "mention": self.sound_manager.play_mention() - elif highest_type == 'reply': + elif highest_type == "reply": self.sound_manager.play_reply() - elif highest_type == 'reblog': + elif highest_type == "reblog": self.sound_manager.play_boost() - elif highest_type == 'favourite': + elif highest_type == "favourite": self.sound_manager.play_favorite() else: # Fallback for unknown notification types self.sound_manager.play_notification() - + def _store_scroll_position(self): """Store current scroll position and selected item info for restoration""" try: current_item = self.currentItem() if not current_item: return None - + # Store the selected post ID and text to help identify it after refresh post = current_item.data(0, Qt.UserRole) - if not post or not hasattr(post, 'id'): + if not post or not hasattr(post, "id"): return None - + scroll_info = { - 'post_id': post.id, - 'post_text': current_item.text(0)[:100], # First 100 chars for matching - 'item_index': self.indexOfTopLevelItem(current_item), - 'vertical_scroll': self.verticalScrollBar().value() + "post_id": post.id, + "post_text": current_item.text(0)[:100], # First 100 chars for matching + "item_index": self.indexOfTopLevelItem(current_item), + "vertical_scroll": self.verticalScrollBar().value(), } - + return scroll_info except Exception as e: self.logger.error(f"Error storing scroll position: {e}") return None - + def _restore_scroll_position(self, scroll_info): """Restore scroll position and selected item after refresh""" if not scroll_info: return - + try: # Try to find the post by ID first target_item = None for i in range(self.topLevelItemCount()): item = self.topLevelItem(i) post = item.data(0, Qt.UserRole) - if post and hasattr(post, 'id') and post.id == scroll_info['post_id']: + if post and hasattr(post, "id") and post.id == scroll_info["post_id"]: target_item = item break - + # If not found by ID, try to find by text content (partial match) if not target_item: for i in range(self.topLevelItemCount()): item = self.topLevelItem(i) item_text = item.text(0)[:100] - if item_text == scroll_info['post_text']: + if item_text == scroll_info["post_text"]: target_item = item break - + # If still not found, try to restore by index position - if not target_item and scroll_info['item_index'] < self.topLevelItemCount(): - target_item = self.topLevelItem(scroll_info['item_index']) - + if not target_item and scroll_info["item_index"] < self.topLevelItemCount(): + target_item = self.topLevelItem(scroll_info["item_index"]) + # Restore selection and scroll position if target_item: self.setCurrentItem(target_item) self.scrollToItem(target_item) - + # Fine-tune scroll position if possible - if 'vertical_scroll' in scroll_info: + if "vertical_scroll" in scroll_info: from PySide6.QtCore import QTimer + # Delay scroll restoration slightly to allow tree to fully update - QTimer.singleShot(50, lambda: self.verticalScrollBar().setValue(scroll_info['vertical_scroll'])) - + QTimer.singleShot( + 50, + lambda: self.verticalScrollBar().setValue( + scroll_info["vertical_scroll"] + ), + ) + except Exception as e: self.logger.error(f"Error restoring scroll position: {e}") - + def request_refresh(self, preserve_position: bool = False, reason: str = "manual"): """ SINGLE POINT OF TRUTH for all timeline refresh requests Handles coordination with auto-refresh, streaming mode, and position preservation - + Args: preserve_position: Whether to maintain current scroll position reason: Reason for refresh (for logging/debugging) @@ -1501,20 +1701,20 @@ class TimelineView(QTreeWidget): # Log refresh reason for debugging if reason: self.logger.debug(f"Timeline refresh requested: {reason}") - + # Delegate to core refresh method self.refresh(preserve_position=preserve_position) - + except Exception as e: self.logger.error(f"Timeline refresh failed ({reason}): {e}") - + def request_auto_refresh(self, force_update: bool = False) -> bool: """ Handle auto-refresh requests with position preservation - + Args: force_update: If True, refreshes even if no new content detected - + Returns: bool: True if refresh was performed """ @@ -1525,26 +1725,26 @@ class TimelineView(QTreeWidget): self.request_refresh(preserve_position=True, reason="auto_refresh") self.logger.debug("request_refresh() completed successfully") return True - + except Exception as e: self.logger.error(f"Auto-refresh failed: {e}") return False - + def request_manual_refresh(self): """Handle manual refresh requests (F5, menu item)""" # Manual refreshes typically reset to top (user expectation) self.request_refresh(preserve_position=False, reason="manual_user_action") - + def request_post_action_refresh(self, action: str): """Handle refreshes after post actions (boost, favorite, etc.)""" # Preserve position for post actions so user doesn't lose place self.request_refresh(preserve_position=True, reason=f"post_action_{action}") - + def request_streaming_refresh(self, event_type: str): """Handle refresh requests from streaming events""" # For streaming, we might want to add new content at top without losing position self.request_refresh(preserve_position=True, reason=f"streaming_{event_type}") - + def can_auto_refresh(self) -> bool: """ Check if timeline is ready for auto-refresh @@ -1553,13 +1753,13 @@ class TimelineView(QTreeWidget): # Don't refresh if user is currently interacting if self.hasFocus(): return False - + # Don't refresh if context menu is open if any(child for child in self.children() if child.objectName() == "QMenu"): return False - + # Don't refresh during load more operations - if hasattr(self, '_load_more_in_progress') and self._load_more_in_progress: + if hasattr(self, "_load_more_in_progress") and self._load_more_in_progress: return False - - return True \ No newline at end of file + + return True