diff --git a/CLAUDE.md b/CLAUDE.md index 7431f92..1c26526 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,766 +1,8 @@ # Bifrost Fediverse Client - Development Plan -## Project Overview -Bifrost is a fully accessible fediverse client built with PySide6, designed specifically for screen reader users. The application uses "post/posts" terminology instead of "toot" and focuses on excellent keyboard navigation and audio feedback. - ## Development Workflow - Check for any changes in git project before doing anything else. Make sure the latest changes have been pulled - See what has changed, use git commands and examine the code to make sure you are up to date with the latest code -## Smart Notification System - -Bifrost includes an intelligent notification system that distinguishes between user's own content and external content from others. - -### Core Features -1. **Content Source Analysis**: Determines if new content is from current user or external users -2. **Immediate External Notifications**: Never suppresses sounds for genuine mentions, DMs, or posts from others -3. **Smart Sound Selection**: Plays appropriate sounds (mention, direct_message, timeline_update) based on content type -4. **Context-Aware Suppression**: Only suppresses sounds from user's own expected content -5. **No Timeline Switch Delays**: Auto-refresh works immediately without waiting for focus loss - -### Implementation Details - -#### Timeline Notification Logic (timeline_view.py:541-638) -```python -# Check if notifications should be suppressed (initial load, recent timeline switch) -suppress_all = self.should_suppress_notifications() - -if not suppress_all: - # Analyze new content to separate external content from user's own - for post in new_posts: - if self.is_content_from_current_user(post): - user_content_detected = True - else: - external_content_detected = True - - # Play sounds and notifications for external content only - if external_content_detected: - # Check for mentions, DMs, then fallback to timeline_update -``` - -#### User Action Tracking (timeline_view.py:155-175) -```python -def track_user_action(self, action_type: str, expected_content_hint: str = None): - """Track user actions to avoid sound spam from expected results""" - action_info = { - 'type': action_type, - 'timestamp': time.time(), - 'hint': expected_content_hint - } - self.recent_user_actions.append(action_info) -``` - -### Migration from Old System - -**Removed:** `skip_notifications` blanket suppression flag -**Replaced with:** `should_suppress_notifications()` method that checks: -- Initial load state -- Timeline switch timing (3s delay for most timelines, 1s for notifications) -- But allows immediate external content notifications - -## Duplicate Code Prevention Guidelines - -### Critical Areas Requiring Attention -1. **Sound/Audio Events**: Never add multiple paths that trigger the same sound event -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 -5. **Design**: Ensure single point of truth is used as much as possible throughout the code - -### Required Patterns for Event-Heavy Operations - -#### Event Coordination Pattern -```python -def operation_method(self): - if hasattr(self, '_operation_in_progress') and self._operation_in_progress: - return - self._operation_in_progress = True - try: - # perform operation - self.sound_manager.play_success() - finally: - # Reset flag after brief delay to prevent rapid-fire calls - QTimer.singleShot(100, lambda: setattr(self, '_operation_in_progress', False)) -``` - -#### Event Source Tracking Pattern -```python -def ui_triggered_method(self, index, from_source="unknown"): - # Handle differently based on source: "tab_change", "keyboard", "menu" - if from_source == "tab_change": - # Skip certain UI updates to prevent circular calls - pass -``` - -#### Lifecycle Event Coordination Pattern -```python -def quit_application(self): - self._shutdown_handled = True # Mark that shutdown is being handled - self.sound_manager.play_shutdown() - -def closeEvent(self, event): - # Only handle if not already handled by explicit quit - if not hasattr(self, '_shutdown_handled'): - self.sound_manager.play_shutdown() -``` - -### Development Review Checklist -Before implementing any sound, notification, or UI event: -1. **Is there already another code path that triggers this same feedback?** -2. **Could this create an event loop (A calls B which calls A)?** -3. **Can rapid user actions (keyboard shortcuts) trigger this multiple times?** -4. **Does this operation need coordination with other similar operations?** -5. **Are there multiple UI elements (menu + button + shortcut) that trigger this?** - -### Common Patterns to Avoid -- Multiple event handlers calling the same sound method -- UI updates that trigger their own event handlers -- Shutdown/close/quit events without coordination -- Timeline refresh without checking if already refreshing -- Direct sound calls in multiple places for the same user action - -### Testing Requirements -When adding event-driven features, always test: -- Rapid keyboard shortcut usage -- Multiple quick UI interactions -- Combinations of keyboard shortcuts + UI clicks -- Window closing during operations -- Tab switching with keyboard vs mouse - -## Professional Logging System - -Bifrost includes a comprehensive logging system essential for AI-only development. All debugging should use proper logging instead of print statements. - -### Logging Standards - -**Command-line Debug Flags:** -- `python bifrost.py -d` - Debug output to console -- `python bifrost.py -d filename` - Debug output to file -- `python bifrost.py` - Production mode (warnings/errors only) - -**Log Format:** `message - severity - timestamp` -``` -Timeline refresh requested: auto_refresh - DEBUG - 2025-07-22 23:17:33 -New content detected: newest post changed from abc123 to def456 - INFO - 2025-07-22 23:17:34 -``` - -### Logger Setup Pattern - -Every class should have a logger in `__init__()`: -```python -import logging - -class MyClass: - def __init__(self): - self.logger = logging.getLogger('bifrost.module_name') -``` - -### Logging Guidelines - -**What to Log at Each Level:** - -- **DEBUG**: Method calls, state changes, timing information, execution flow -- **INFO**: Important events (new content detected, sounds played, operations completed) -- **WARNING**: Recoverable issues (fallbacks, missing optional features, server incompatibilities) -- **ERROR**: Serious problems (network failures, invalid data, system errors) -- **CRITICAL**: Fatal issues that prevent operation - -**Required Logging Areas:** - -1. **Auto-refresh System**: Timing, triggers, new content detection -2. **Sound Events**: Which sounds played, when, and why -3. **Network Operations**: API calls, streaming connections, failures -4. **User Actions**: Post composition, timeline navigation, settings changes -5. **Error Conditions**: All exceptions, fallbacks, and recovery attempts - -**Logging Patterns:** - -```python -# Method entry/exit for complex operations -self.logger.debug("method_name() called") -self.logger.debug("method_name() completed successfully") - -# State changes -self.logger.info(f"Timeline switched from {old} to {new}") - -# New content detection -self.logger.info(f"New content detected: {count} new posts") - -# Sound events -self.logger.info(f"Playing {sound_type} sound for {reason}") - -# Error handling with context -self.logger.error(f"Failed to {operation}: {error}") -``` - -**Never Use Print Statements:** -- All output should go through the logging system -- Print statements interfere with proper log formatting -- Use appropriate log levels instead of printing debug info - -### AI Development Benefits - -This logging system is crucial for AI-only development because: -- Provides complete visibility into application behavior -- Enables systematic debugging without human intervention -- Shows exact timing and causation of events -- Facilitates troubleshooting of complex interactions -- Maintains clean separation between debug and production modes - -## Documentation and Dependencies -- **README Updates**: When adding new functionality or sound events, update README.md with detailed descriptions -- **Requirements Management**: Check and update requirements.txt when new dependencies are added -- **Sound Pack Documentation**: Document new sound events in both CLAUDE.md and user-facing documentation -- **Version Tracking**: Update version numbers and changelog when significant features are added - -## Core Features -- Full ActivityPub protocol support (Pleroma and GoToSocial primary targets) -- Threaded conversation navigation with collapsible tree view -- Comprehensive soundpack management system with secure repository support -- Smart autocomplete for mentions (@user@instance.com) and emojis (5,000+ Unicode) -- Auto-refresh with intelligent activity-based timing -- Screen reader optimized interface with Orca compatibility fixes -- XDG Base Directory specification compliance - -## Technology Stack -- **PySide6**: Main GUI framework (proven accessibility with existing doom launcher) -- **requests**: HTTP client for ActivityPub APIs -- **simpleaudio**: Cross-platform audio with subprocess fallback -- **emoji**: Comprehensive Unicode emoji library (5,000+ emojis with keyword search) -- **plyer**: Cross-platform desktop notifications -- **XDG directories**: Configuration and data storage - -## Architecture - -### Directory Structure -``` -bifrost/ -├── bifrost/ -│ ├── __init__.py -│ ├── main.py # Application entry point -│ ├── accessibility/ # Accessibility widgets and helpers -│ │ ├── __init__.py -│ │ ├── accessible_tree.py # AccessibleTreeWidget for conversations -│ │ └── accessible_combo.py # Enhanced ComboBox from doom launcher -│ ├── activitypub/ # Federation protocol handling -│ │ ├── __init__.py -│ │ ├── client.py # Main ActivityPub client -│ │ ├── pleroma.py # Pleroma-specific implementation -│ │ └── gotosocial.py # GoToSocial-specific implementation -│ ├── models/ # Data models -│ │ ├── __init__.py -│ │ ├── post.py # Post data structure -│ │ ├── user.py # User profiles -│ │ ├── timeline.py # Timeline model for QTreeView -│ │ └── thread.py # Conversation threading -│ ├── widgets/ # Custom UI components -│ │ ├── __init__.py -│ │ ├── timeline_view.py # Main timeline widget with auto-refresh -│ │ ├── compose_dialog.py # Post composition with smart autocomplete -│ │ ├── autocomplete_textedit.py # Mention and emoji autocomplete system -│ │ ├── settings_dialog.py # Application settings -│ │ ├── soundpack_manager_dialog.py # Soundpack repository management -│ │ ├── profile_dialog.py # User profile viewer with social actions -│ │ ├── post_details_dialog.py # Post interaction details (favorites, boosts) -│ │ ├── media_upload_widget.py # Media attachment system with alt text -│ │ ├── custom_emoji_manager.py # Instance-specific emoji caching -│ │ └── login_dialog.py # Instance login -│ ├── audio/ # Sound system -│ │ ├── __init__.py -│ │ ├── sound_manager.py # Audio notification handler -│ │ └── soundpack_manager.py # Secure soundpack installation system -│ │ └── sound_pack.py # Sound pack management -│ └── config/ # Configuration management -│ ├── __init__.py -│ ├── settings.py # Settings handler with XDG compliance -│ └── accounts.py # Account management -├── sounds/ # Sound packs directory -│ ├── default/ -│ │ ├── pack.json -│ │ ├── private_message.wav -│ │ ├── mention.wav -│ │ ├── boost.wav -│ │ ├── reply.wav -│ │ ├── post_sent.wav -│ │ ├── timeline_update.wav -│ │ └── notification.wav -│ └── doom/ # Example themed sound pack -│ ├── pack.json -│ └── *.wav files -├── tests/ -│ ├── __init__.py -│ ├── test_accessibility.py -│ ├── test_activitypub.py -│ └── test_audio.py -├── requirements.txt -├── setup.py -└── README.md -``` - -### XDG Directory Usage -- **Config**: `~/.config/bifrost/` - Settings, accounts, current sound pack -- **Data**: `~/.local/share/bifrost/` - Sound packs, cached data -- **Cache**: `~/.cache/bifrost/` - Temporary files, avatar cache - -## Accessibility Implementation - -### From Doom Launcher Success -- **AccessibleComboBox**: Enhanced keyboard navigation (Page Up/Down, Home/End) -- **Proper Accessible Names**: All widgets get descriptive `setAccessibleName()` -- **Focus Management**: Clear tab order and focus indicators -- **No Custom Speech**: Screen reader handles all announcements - -### Threaded Conversation Navigation -**Navigation Pattern:** -``` -Timeline Item: "Alice posted: Hello world (3 replies, collapsed)" -[Right Arrow] → "Alice posted: Hello world (3 replies, expanded)" -[Down Arrow] → " Bob replied: Hi there" -[Down Arrow] → " Carol replied: How's it going?" -[Down Arrow] → "David posted: Another topic" -``` - -**Key Behaviors:** -- Right Arrow: Expand thread, announce "expanded" / Move to first child when expanded -- Left Arrow: Collapse thread, announce "collapsed" / Move to parent -- Shift+Left Arrow: Navigate to thread root from any reply -- Down Arrow: Next item (skip collapsed children) -- Up Arrow: Previous item -- Page Down/Up: Jump 5 items -- Home/End: First/last item - -### Known Qt Tree Widget Display Quirk - -Due to Qt's visual display synchronization, thread collapse may require double-operation: -- Navigation logic works correctly after first collapse (skips collapsed items) -- Visual display may need expand→collapse cycle to fully sync -- Workaround: Navigate to root with Shift+Left, then Left→Right→Left if needed - -### AccessibleTreeWidget Requirements -- Inherit from QTreeWidget -- Override keyPressEvent for custom navigation -- Proper accessibility roles and states -- Focus management for nested items -- Status announcements via Qt accessibility - -## Sound System Design - -### Sound Events -- **startup**: Application started -- **shutdown**: Application closing -- **private_message**: Direct message received -- **mention**: User mentioned in post -- **boost**: Post boosted/reblogged -- **reply**: Reply to user's post -- **post_sent**: User successfully posted -- **timeline_update**: New posts in timeline -- **notification**: General notification -- **expand**: Thread expanded -- **collapse**: Thread collapsed -- **success**: General success feedback -- **error**: Error occurred -- **autocomplete**: Autocomplete suggestions available -- **autocomplete_end**: Autocomplete interaction ended - -### Sound System Policy -- **No Sound Generation**: The application should never generate sounds programmatically -- **Sound Pack Reliance**: All audio feedback must come from sound pack files -- **Default Pack Requirement**: A complete default sound pack must ship with the project -- **Themed Packs**: Users can install additional themed sound packs (like Doom) -- If someone requests sound generation, gently remind them that all sounds should be covered by sound packs - -### Sound Pack Structure -**pack.json Format:** -```json -{ - "name": "Pack Display Name", - "description": "Pack description", - "author": "Creator name", - "version": "1.0", - "sounds": { - "private_message": "filename.wav", - "mention": "filename.wav", - "boost": "filename.wav", - "reply": "filename.wav", - "post_sent": "filename.wav", - "timeline_update": "filename.wav", - "notification": "filename.wav" - } -} -``` - -### SoundManager Features -- simpleaudio for cross-platform WAV playback with volume control -- Subprocess fallback (sox/play on Linux, afplay on macOS, PowerShell on Windows) -- Fallback to default pack if sound missing -- Single volume control for all sound pack audio -- "None" sound pack option to disable all sounds -- Pack discovery and validation -- Smart threading (direct calls for simpleaudio, threaded for subprocess) - -## ActivityPub Implementation - -### Core Client Features -- **Timeline Streaming**: Real-time updates via WebSocket/polling -- **Post Composition**: Text, media attachments, visibility settings -- **Thread Resolution**: Fetch complete conversation trees -- **User Profiles**: Following, followers, profile viewing -- **Notifications**: Mentions, boosts, follows, favorites - -### Server Compatibility -**Primary Targets:** -- Pleroma: Full feature support -- GoToSocial: Full feature support - -**Extended Support:** -- Mastodon: Best effort compatibility -- Other ActivityPub servers: Basic functionality - -### API Endpoints Usage -- `/api/v1/timelines/home` - Home timeline -- `/api/v1/statuses` - Post creation -- `/api/v1/statuses/:id/context` - Thread fetching -- `/api/v1/streaming` - Real-time updates -- `/api/v1/notifications` - Notification management - -## User Interface Design - -### Main Window Layout -``` -[Menu Bar] -[Instance/Account Selector] -[Timeline Tree View - Main Focus] -[Compose Box] -[Status Bar] -``` - -### Key UI Components -- **Timeline View**: AccessibleTreeWidget showing posts and threads with pagination -- **Timeline Tabs**: Home, Mentions, Local, Federated timeline switching -- **Compose Dialog**: Modal for creating posts with accessibility -- **Settings Dialog**: Sound pack, desktop notifications, accessibility options -- **Login Dialog**: Instance selection and authentication -- **URL Selection Dialog**: Choose from multiple URLs in posts -- **Context Menu**: Copy, URL opening, reply, boost, favorite actions - -### Keyboard Shortcuts -- **Ctrl+N**: New post -- **Ctrl+R**: Reply to selected post -- **Ctrl+B**: Boost selected post -- **Ctrl+F**: Favorite selected post -- **Ctrl+C**: Copy selected post to clipboard -- **Ctrl+U**: Open URLs from selected post in browser -- **F5**: Refresh timeline -- **Ctrl+,**: Settings -- **Escape**: Close dialogs - -## Development Phases - -### Phase 1: Foundation -1. Project structure setup -2. XDG configuration system -3. Basic PySide6 window with accessibility -4. AccessibleTreeWidget implementation -5. Sound system foundation - -### Phase 2: ActivityPub Core -1. Basic HTTP client -2. Authentication (OAuth2) -3. Timeline fetching and display -4. Post composition and sending -5. Basic thread display - -### Phase 3: Advanced Features -1. Thread expansion/collapse -2. Real-time updates -3. Notifications system -4. Sound pack system -5. Settings interface - -### Phase 4: Polish -1. Comprehensive accessibility testing -2. Screen reader testing (Orca, NVDA, JAWS) -3. Performance optimization -4. Error handling -5. Documentation - -## Testing Strategy - -### Accessibility Testing -- Automated testing with screen reader APIs -- Manual testing with Orca, NVDA (via Wine) -- Keyboard-only navigation testing -- Focus management verification - -### Functional Testing -- ActivityPub protocol compliance -- Thread navigation accuracy -- Sound system reliability -- Configuration persistence - -### Test Instances -- Pleroma test server -- GoToSocial test server -- Mock ActivityPub server for edge cases - -## Configuration Management - -### Settings Structure -```ini -[general] -instance_url = https://example.social -username = user -timeline_refresh_interval = 60 -auto_refresh_enabled = true - -[audio] -sound_pack = default -volume = 100 - -[notifications] -enabled = true -direct_messages = true -mentions = true -boosts = false -favorites = false -follows = true - -[timeline] -posts_per_page = 40 - -[accessibility] -announce_thread_state = true -auto_expand_mentions = false -keyboard_navigation_wrap = true -page_step_size = 5 -verbose_announcements = true -``` - -### Account Storage -- Secure credential storage -- Multiple account support -- Instance-specific settings - -## Known Challenges and Solutions - -### ActivityPub Complexity -- **Challenge**: Different server implementations vary -- **Solution**: Modular client design with server-specific adapters - -### Screen Reader Performance -- **Challenge**: Large timelines may impact performance -- **Solution**: Virtual scrolling, lazy loading, efficient tree models - -### Thread Visualization -- **Challenge**: Complex thread structures hard to navigate -- **Solution**: Clear indentation, status announcements, skip collapsed - -### Sound Customization -- **Challenge**: Users want different audio feedback -- **Solution**: Comprehensive sound pack system with easy installation - -## Recently Implemented Features - -### Completed Features -- **Direct Message Interface**: ✅ Dedicated Messages tab with conversation threading -- **Bookmarks Tab**: ✅ Timeline tab for viewing saved/bookmarked posts -- **Poll Support**: ✅ Create and vote on polls with accessible interface -- **Poll Creation**: ✅ Add poll options to compose dialog with expiration times -- **Poll Voting**: ✅ Accessible poll interaction with keyboard navigation -- **User Profile Viewer**: ✅ Comprehensive profile dialog with bio, fields, recent posts -- **Social Actions**: ✅ Follow/unfollow, block/unblock, mute/unmute from profile viewer - -### Remaining High Priority Features -- **User Blocking Management**: Block/unblock users with dedicated management interface -- **User Muting Management**: Mute/unmute users with management interface -- **Blocked Users Management**: Tab/dialog to view and manage blocked users - -### Implementation Status -- Timeline tabs completed: Home, Messages, Mentions, Local, Federated, Bookmarks, Followers, Following -- Profile viewer includes all social actions (follow, block, mute) with API integration -- Poll accessibility fully implemented with screen reader announcements -- DM interface shows conversation threads with proper threading - -## Future Enhancements - -### Advanced Features -- Custom timeline filters -- Multiple column support -- List management -- Advanced search - -### Accessibility Extensions -- Braille display optimization -- Voice control integration -- High contrast themes -- Font size scaling - -### Federation Features -- Cross-instance thread following -- Server switching -- Federation status monitoring -- Custom emoji support - -## Dependencies - -### Core Requirements -``` -PySide6>=6.0.0 -requests>=2.25.0 -simpleaudio>=1.0.4 -plyer>=2.1.0 -emoji>=2.0.0 -``` - -### Optional Dependencies -``` -pytest (testing) -coverage (test coverage) -black (code formatting) -mypy (type checking) -``` - -## Installation and Distribution - -### Development Setup -```bash -git clone -cd bifrost -pip install -r requirements.txt -# Run with proper display -DISPLAY=:0 ./bifrost.py -# Or -python bifrost.py -``` - -### Packaging -- Python wheel distribution -- AppImage for Linux -- Consideration for distro packages - -## Accessibility Compliance - -### Standards Adherence -- WCAG 2.1 AA compliance -- Qt Accessibility framework usage -- AT-SPI2 protocol support (Linux) -- Platform accessibility APIs - -### Screen Reader Testing Matrix -- **Orca** (Linux): Primary target -- **NVDA** (Windows via Wine): Secondary -- **JAWS** (Windows via Wine): Basic compatibility -- **VoiceOver** (macOS): Future consideration - -## Critical Accessibility Rules - -### Text Truncation Is Forbidden -**NEVER TRUNCATE TEXT**: Bifrost is an accessibility-first client. Text truncation (using "..." or limiting character counts) is strictly forbidden as it prevents screen reader users from accessing complete information. Always display full content, descriptions, usernames, profiles, and any other text in its entirety. - -Examples of forbidden practices: -- `content[:100] + "..."` -- Character limits on display text -- Shortened usernames or descriptions -- Abbreviated profile information - -### Screen Reader Accessibility Guidelines - -#### Widget Choice and Layout -**DO:** -- Use `QTextEdit` (read-only) for all text content that needs to be accessible -- Use `QListWidget` with fake header items for navigable lists -- Set proper `setAccessibleName()` on all interactive widgets -- Enable keyboard navigation with `Qt.TextSelectableByKeyboard` - -**DON'T:** -- Use `QLabel` for important information - screen readers often skip labels -- Use disabled `QCheckBox` or `QRadioButton` widgets - they get skipped -- Create single-item lists without fake headers - they're hard to navigate - -#### Accessible List Pattern -For any list that users need to navigate (polls, favorites, boosts): -```python -list_widget = QListWidget() -list_widget.setAccessibleName("Descriptive List Name") - -# Add fake header for single-item navigation -fake_header = QListWidgetItem("List title:") -fake_header.setFlags(Qt.ItemIsEnabled) # Not selectable -list_widget.addItem(fake_header) - -# Add actual items -for item in items: - list_item = QListWidgetItem(item_text) - list_item.setData(Qt.UserRole, item_data) - list_widget.addItem(list_item) -``` - -#### Context Menu Accessibility -**Required keyboard shortcuts for context menus:** -- Applications key (`Qt.Key_Menu`) -- Shift+F10 (`Qt.Key_F10` with `Qt.ShiftModifier`) - -Both must trigger: `self.customContextMenuRequested.emit(center)` - -#### Interactive vs Display Content -**For expired/completed interactive elements (polls, forms):** -- Replace disabled controls with accessible display lists -- Show results using the accessible list pattern -- Never leave users with disabled widgets they can't interact with - -**For live interactive elements:** -- Use standard Qt controls (`QCheckBox`, `QRadioButton`, `QPushButton`) -- Ensure all controls have meaningful `setAccessibleName()` -- Provide clear feedback for actions - -#### Information Display Priority -**All important information must be in accessible widgets:** -1. **Primary location:** `QTextEdit` (read-only, keyboard selectable) -2. **Secondary location:** `QListWidget` items -3. **Never primary:** `QLabel` widgets (often skipped) - -**Example of accessible content display:** -```python -content_text = QTextEdit() -content_text.setAccessibleName("Full Post Details") -content_text.setPlainText(f"Author: @{username}\n\nContent:\n{post_content}\n\nMetadata:\n{details}") -content_text.setReadOnly(True) -content_text.setTextInteractionFlags(Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse) -``` - -#### Dialog and Tab Design -**Avoid content duplication:** -- Don't show the same information in multiple places -- Use tabs to organize different types of information -- Keep essential stats/counts at the top level -- Put detailed content in dedicated tabs - -**Tab organization example:** -- Statistics: Brief counts at dialog top -- Poll tab: Interactive voting or accessible results list -- Content tab: Full post details in accessible text widget -- Interaction tabs: Who favorited/boosted in accessible lists - -## Recent Major Changes (July 2025) - -### Smart Notification System Implementation -**Problem Solved:** The old `skip_notifications` system suppressed ALL sounds for 1-3 seconds after user actions, causing users to miss important external content (mentions, DMs) that arrived during post-action refreshes. - -**Solution Implemented:** -1. **Replaced blanket suppression** with content source analysis -2. **Immediate external notifications** - never suppress sounds for genuine mentions/DMs from others -3. **Context-aware sound selection** - different sounds for mentions vs DMs vs timeline updates -4. **Removed focus-based auto-refresh blocking** - auto-refresh now works regardless of timeline focus -5. **Fixed notifications timeline** - resolved AttributeError from removed `skip_notifications` references - -**Key Files Modified:** -- `src/widgets/timeline_view.py`: Core notification logic, content analysis, auto-refresh improvements -- `src/main_window.py`: Updated notification suppression checks - -**User Experience Impact:** -- Users now get immediate audio feedback for external content even during their own actions -- Auto-refresh works seamlessly without waiting for focus loss -- Notifications timeline properly loads without errors -- No more missed mentions or DMs due to timing issues - -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. +## Development Notes +- When testing bifrost use DISPLAY=:0 so that it will start successfully. \ No newline at end of file diff --git a/src/activitypub/client.py b/src/activitypub/client.py index 81085fd..e43afb1 100644 --- a/src/activitypub/client.py +++ b/src/activitypub/client.py @@ -47,6 +47,139 @@ class ActivityPubClient: self.stream_callbacks = {} self.streaming_supported = True # Assume supported until proven otherwise + def _make_request_form(self, method: str, endpoint: str, params: Optional[Dict] = None, + data: Optional[Dict] = None) -> Dict: + """Make an authenticated request using form data instead of JSON""" + url = urljoin(self.instance_url, endpoint) + + try: + # Try minimal headers for GoToSocial compatibility + headers = {} + if self.access_token: + headers['Authorization'] = f'Bearer {self.access_token}' + headers['User-Agent'] = 'Bifrost/1.0.0 (Accessible Fediverse Client)' + # Don't set Content-Type or Accept - let requests handle it + + if method.upper() == 'POST': + response = self.session.post(url, data=data, headers=headers, timeout=30) + else: + raise ValueError(f"Form requests only support POST, not {method}") + + response.raise_for_status() + + # Handle different success responses + if response.status_code in [200, 201, 202]: + if response.content: + try: + return response.json() + except json.JSONDecodeError: + # Some endpoints might return non-JSON on success + return {"success": True, "status_code": response.status_code} + return {"success": True, "status_code": response.status_code} + + return {} + + except requests.exceptions.HTTPError as e: + # Get detailed error info from server response + error_details = f"HTTP {e.response.status_code}" + try: + error_json = e.response.json() + if 'error' in error_json: + error_details += f": {error_json['error']}" + elif 'message' in error_json: + error_details += f": {error_json['message']}" + else: + error_details += f": {error_json}" + except: + # If we can't parse JSON, use raw response text + if e.response.text: + error_details += f": {e.response.text[:200]}" + + self.logger.error(f"API HTTP error for {method} {url}: {error_details}") + raise Exception(f"API request failed: {error_details}") + except requests.exceptions.RequestException as e: + self.logger.error(f"API request error for {method} {url}: {e}") + raise Exception(f"API request failed: {e}") + except json.JSONDecodeError as e: + self.logger.error(f"JSON decode error for {method} {url}: {e}") + raise Exception(f"Invalid JSON response: {e}") + + def _make_request_minimal(self, method: str, endpoint: str) -> Dict: + """Make a minimal authenticated request for GoToSocial compatibility""" + url = urljoin(self.instance_url, endpoint) + + try: + # Absolutely minimal headers - only auth and user agent + headers = {} + if self.access_token: + headers['Authorization'] = f'Bearer {self.access_token[:20]}...' # Log partial token for security + headers['User-Agent'] = 'Bifrost/1.0.0' + headers['Accept'] = 'application/json' + + # Log the exact request details + self.logger.debug(f"Making {method} request to: {url}") + self.logger.debug(f"Request headers: {headers}") + self.logger.debug("Request body: {}") + + # Set actual auth header for request + actual_headers = {} + if self.access_token: + actual_headers['Authorization'] = f'Bearer {self.access_token}' + actual_headers['User-Agent'] = 'Bifrost/1.0.0' + actual_headers['Accept'] = 'application/json' + actual_headers['Accept-Encoding'] = 'gzip, deflate' + actual_headers['Content-Type'] = 'application/json' + + if method.upper() == 'POST': + # Send empty JSON object - GoToSocial may require actual JSON body + response = requests.post(url, headers=actual_headers, json={}, timeout=30) + else: + raise ValueError(f"Minimal requests only support POST, not {method}") + + self.logger.debug(f"Response status: {response.status_code}") + self.logger.debug(f"Response headers: {dict(response.headers)}") + if response.content: + self.logger.debug(f"Response body: {response.text[:500]}") # First 500 chars + + response.raise_for_status() + + # Handle different success responses + if response.status_code in [200, 201, 202]: + if response.content: + try: + return response.json() + except json.JSONDecodeError: + # Some endpoints might return non-JSON on success + return {"success": True, "status_code": response.status_code} + return {"success": True, "status_code": response.status_code} + + return {} + + except requests.exceptions.HTTPError as e: + # Get detailed error info from server response + error_details = f"HTTP {e.response.status_code}" + try: + error_json = e.response.json() + if 'error' in error_json: + error_details += f": {error_json['error']}" + elif 'message' in error_json: + error_details += f": {error_json['message']}" + else: + error_details += f": {error_json}" + except: + # If we can't parse JSON, use raw response text + if e.response.text: + error_details += f": {e.response.text[:200]}" + + self.logger.error(f"API HTTP error for {method} {url}: {error_details}") + raise Exception(f"API request failed: {error_details}") + except requests.exceptions.RequestException as e: + self.logger.error(f"API request error for {method} {url}: {e}") + raise Exception(f"API request failed: {e}") + except json.JSONDecodeError as e: + self.logger.error(f"JSON decode error for {method} {url}: {e}") + raise Exception(f"Invalid JSON response: {e}") + def _make_request(self, method: str, endpoint: str, params: Optional[Dict] = None, data: Optional[Dict] = None, files: Optional[Dict] = None) -> Dict: """Make an authenticated request to the API""" @@ -83,9 +216,29 @@ class ActivityPubClient: return {} + except requests.exceptions.HTTPError as e: + # Get detailed error info from server response + error_details = f"HTTP {e.response.status_code}" + try: + error_json = e.response.json() + if 'error' in error_json: + error_details += f": {error_json['error']}" + elif 'message' in error_json: + error_details += f": {error_json['message']}" + else: + error_details += f": {error_json}" + except: + # If we can't parse JSON, use raw response text + if e.response.text: + error_details += f": {e.response.text[:200]}" + + self.logger.error(f"API HTTP error for {method} {url}: {error_details}") + raise Exception(f"API request failed: {error_details}") except requests.exceptions.RequestException as e: + self.logger.error(f"API request error for {method} {url}: {e}") raise Exception(f"API request failed: {e}") except json.JSONDecodeError as e: + self.logger.error(f"JSON decode error for {method} {url}: {e}") raise Exception(f"Invalid JSON response: {e}") def verify_credentials(self) -> Dict: @@ -216,12 +369,14 @@ class ActivityPubClient: def follow_account(self, account_id: str) -> Dict: """Follow an account""" endpoint = f'/api/v1/accounts/{account_id}/follow' - return self._make_request('POST', endpoint) + # Try standard request method with empty JSON body + return self._make_request('POST', endpoint, data={}) def unfollow_account(self, account_id: str) -> Dict: """Unfollow an account""" endpoint = f'/api/v1/accounts/{account_id}/unfollow' - return self._make_request('POST', endpoint) + # Try standard request method with empty JSON body + return self._make_request('POST', endpoint, data={}) def get_followers(self, account_id: str, max_id: Optional[str] = None, limit: int = 40) -> List[Dict]: """Get followers for an account""" diff --git a/src/main_window.py b/src/main_window.py index d79a60c..4f1b0ef 100644 --- a/src/main_window.py +++ b/src/main_window.py @@ -27,6 +27,7 @@ from widgets.account_selector import AccountSelector from widgets.settings_dialog import SettingsDialog from widgets.soundpack_manager_dialog import SoundpackManagerDialog from widgets.profile_dialog import ProfileDialog +from widgets.accessible_text_dialog import AccessibleTextDialog from managers.post_manager import PostManager from managers.sound_coordinator import SoundCoordinator from managers.error_manager import ErrorManager @@ -790,13 +791,13 @@ class MainWindow(QMainWindow): timeline_name = timeline_names[index] timeline_type = timeline_types[index] - # Prevent duplicate calls for the same timeline + # Prevent duplicate calls for the same timeline only if ( - hasattr(self, "_current_timeline_switching") - and self._current_timeline_switching + hasattr(self, "_current_timeline_switching_index") + and self._current_timeline_switching_index == index ): return - self._current_timeline_switching = True + self._current_timeline_switching_index = index # 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: @@ -826,11 +827,11 @@ class MainWindow(QMainWindow): f"Failed to load {timeline_name} timeline: {str(e)}", 3000 ) finally: - # Reset the flag after a brief delay to allow the operation to complete + # Reset the index 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) + 100, lambda: setattr(self, "_current_timeline_switching_index", None) ) def get_selected_post(self): @@ -918,8 +919,8 @@ class MainWindow(QMainWindow): 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") + # Handle account switch with proper notification suppression + self.timeline.handle_account_switch() def reply_to_post(self, post): """Reply to a specific post or conversation""" @@ -1321,15 +1322,15 @@ class MainWindow(QMainWindow): layout = QVBoxLayout(dialog) - # Label - label = QLabel("Enter the user to follow (e.g. @user@instance.social):") + # Label with clear example + label = QLabel("Enter the user to follow.\nExample: @stormux@social.stormux.org") 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") + self.follow_input.setPlaceholderText("@stormux@social.stormux.org") layout.addWidget(self.follow_input) # Buttons @@ -1356,52 +1357,123 @@ class MainWindow(QMainWindow): def manual_follow_user(self, username): """Follow a user by username""" + self.logger.debug(f"manual_follow_user() called with username: {username}") + active_account = self.account_manager.get_active_account() if not active_account: + self.logger.warning("No active account for manual follow") self.status_bar.showMessage("Cannot follow: No active account", 2000) return # Remove @ prefix if present if username.startswith("@"): username = username[1:] + + self.logger.debug(f"Processing username after @ removal: {username}") try: client = self.account_manager.get_client_for_active_account() if not client: + self.logger.error("No client available for manual follow") + self.status_bar.showMessage("Cannot follow: No client connection", 2000) return + self.logger.debug(f"Searching for accounts matching: {username}") # Search for the account first accounts = client.search_accounts(username) + self.logger.debug(f"Search returned {len(accounts) if accounts else 0} accounts") + if not accounts: - self.status_bar.showMessage(f"User not found: {username}", 3000) + self.logger.warning(f"No accounts found for search: {username}") + self.status_bar.showMessage(f"User not found: @{username}", 3000) return # Find exact match target_account = None for account in accounts: + self.logger.debug(f"Checking account: {account.get('acct', 'unknown')} / {account.get('username', 'unknown')}") if ( - account["acct"] == username - or account["username"] == username.split("@")[0] + account.get("acct") == username + or account.get("username") == username.split("@")[0] ): target_account = account + self.logger.debug(f"Found matching account: {account.get('acct')}") break if not target_account: - self.status_bar.showMessage(f"User not found: {username}", 3000) + self.logger.warning(f"No exact match found for: {username}") + self.status_bar.showMessage(f"Exact user not found: @{username}", 3000) return + self.logger.debug(f"Attempting to follow account ID: {target_account.get('id')}") # Follow the account - client.follow_account(target_account["id"]) + follow_result = client.follow_account(target_account["id"]) + self.logger.info(f"Follow API call completed for {target_account.get('acct')}") + display_name = ( target_account.get("display_name") or target_account["username"] ) + + # Check if the follow was successful or if already following + if follow_result and hasattr(follow_result, 'get'): + following_status = follow_result.get('following', False) + requested_status = follow_result.get('requested', False) + + if following_status: + # Already following or now following + self.show_detailed_success_dialog( + "Follow Successful", + f"Successfully followed {display_name}", + f"You are now following @{target_account['acct']}" + ) + elif requested_status: + # Follow request sent (locked account) + self.show_detailed_success_dialog( + "Follow Request Sent", + f"Follow request sent to {display_name}", + f"Your follow request has been sent to @{target_account['acct']}.\nThey will need to approve it since their account is locked." + ) + else: + # Unknown state - show basic success + self.show_detailed_success_dialog( + "Follow Action Completed", + f"Follow action completed for {display_name}", + f"Follow action completed for @{target_account['acct']}" + ) + else: + # Fallback for servers that don't return detailed response + self.show_detailed_success_dialog( + "Follow Successful", + f"Successfully followed {display_name}", + f"You are now following @{target_account['acct']}" + ) + self.status_bar.showMessage(f"Followed {display_name}", 2000) # Play follow sound for successful follow 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) + self.logger.error(f"Manual follow failed for {username}: {e}") + + # Play error sound BEFORE showing dialog + if hasattr(self.timeline, "sound_manager"): + self.timeline.sound_manager.play_error() + + # Show detailed error dialog + self.show_detailed_error_dialog( + "Follow Failed", + f"Could not follow @{username}", + str(e) + ) + + def show_detailed_error_dialog(self, title, message, details): + """Show a detailed error dialog using reusable AccessibleTextDialog""" + AccessibleTextDialog.show_error(title, message, details, self) + + def show_detailed_success_dialog(self, title, message, details): + """Show a detailed success dialog using reusable AccessibleTextDialog""" + AccessibleTextDialog.show_success(title, message, details, self) def block_current_user(self): """Block the user of the currently selected post""" diff --git a/src/models/post.py b/src/models/post.py index 0be4331..5cc6112 100644 --- a/src/models/post.py +++ b/src/models/post.py @@ -98,19 +98,19 @@ class Post: @classmethod def from_api_dict(cls, data: Dict[str, Any]) -> 'Post': """Create Post from API response dictionary""" - # Parse account + # Parse account (handle both full and reduced account data from notifications) account_data = data['account'] account = Account( id=account_data['id'], username=account_data['username'], acct=account_data['acct'], - display_name=account_data['display_name'], - note=account_data['note'], - url=account_data['url'], - avatar=account_data['avatar'], - avatar_static=account_data['avatar_static'], - header=account_data['header'], - header_static=account_data['header_static'], + display_name=account_data.get('display_name', ''), + note=account_data.get('note', ''), + url=account_data.get('url', ''), + avatar=account_data.get('avatar', ''), + avatar_static=account_data.get('avatar_static', ''), + header=account_data.get('header', ''), + header_static=account_data.get('header_static', ''), locked=account_data.get('locked', False), bot=account_data.get('bot', False), discoverable=account_data.get('discoverable', True), @@ -139,10 +139,10 @@ class Post: if data.get('reblog'): reblog = cls.from_api_dict(data['reblog']) - # Create post + # Create post (handle missing fields from notification data) post = cls( id=data['id'], - uri=data['uri'], + uri=data.get('uri', ''), url=data.get('url'), account=account, content=data['content'], diff --git a/src/widgets/accessible_text_dialog.py b/src/widgets/accessible_text_dialog.py new file mode 100644 index 0000000..16d62b0 --- /dev/null +++ b/src/widgets/accessible_text_dialog.py @@ -0,0 +1,104 @@ +""" +Accessible text display dialog - single point of truth for showing text to screen reader users +""" + +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QTextEdit, QDialogButtonBox, QPushButton +) +from PySide6.QtCore import Qt + + +class AccessibleTextDialog(QDialog): + """ + Single reusable dialog for displaying text content accessibly to screen readers. + Provides keyboard navigation, text selection, and proper focus management. + """ + + def __init__(self, title: str, content: str, dialog_type: str = "info", parent=None): + """ + Initialize accessible text dialog + + Args: + title: Window title + content: Text content to display + dialog_type: "info", "error", or "warning" - affects accessible name + parent: Parent widget + """ + super().__init__(parent) + self.setWindowTitle(title) + self.setModal(True) + + # Size based on content length + if len(content) > 500: + self.setMinimumSize(600, 400) + elif len(content) > 200: + self.setMinimumSize(500, 300) + else: + self.setMinimumSize(400, 200) + + self.setup_ui(content, dialog_type) + + def setup_ui(self, content: str, dialog_type: str): + """Setup the dialog UI with accessible text widget""" + layout = QVBoxLayout(self) + + # Create accessible text edit widget + self.text_edit = QTextEdit() + self.text_edit.setPlainText(content) + self.text_edit.setReadOnly(True) + + # Set accessible name based on dialog type + accessible_names = { + "info": "Information Text", + "error": "Error Details", + "warning": "Warning Information", + "success": "Success Information" + } + self.text_edit.setAccessibleName(accessible_names.get(dialog_type, "Dialog Text")) + + # Enable keyboard text selection and navigation + self.text_edit.setTextInteractionFlags( + Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse + ) + + layout.addWidget(self.text_edit) + + # Button box + button_box = QDialogButtonBox(QDialogButtonBox.Ok) + button_box.accepted.connect(self.accept) + layout.addWidget(button_box) + + # Focus the text edit first so user can immediately read content + self.text_edit.setFocus() + + @classmethod + def show_info(cls, title: str, content: str, parent=None): + """Convenience method for info dialogs""" + dialog = cls(title, content, "info", parent) + dialog.exec() + + @classmethod + def show_error(cls, title: str, message: str, details: str = None, parent=None): + """Convenience method for error dialogs with optional details""" + if details: + content = f"{message}\n\nError Details:\n{details}" + else: + content = message + dialog = cls(title, content, "error", parent) + dialog.exec() + + @classmethod + def show_success(cls, title: str, message: str, details: str = None, parent=None): + """Convenience method for success dialogs with optional details""" + if details: + content = f"{message}\n\n{details}" + else: + content = message + dialog = cls(title, content, "success", parent) + dialog.exec() + + @classmethod + def show_warning(cls, title: str, content: str, parent=None): + """Convenience method for warning dialogs""" + dialog = cls(title, content, "warning", parent) + dialog.exec() \ No newline at end of file diff --git a/src/widgets/compose_dialog.py b/src/widgets/compose_dialog.py index 662b1b3..4efc617 100644 --- a/src/widgets/compose_dialog.py +++ b/src/widgets/compose_dialog.py @@ -27,6 +27,7 @@ from config.settings import SettingsManager from activitypub.client import ActivityPubClient from widgets.autocomplete_textedit import AutocompleteTextEdit from widgets.media_upload_widget import MediaUploadWidget +from widgets.accessible_text_dialog import AccessibleTextDialog # NOTE: PostThread removed - now using centralized PostManager in main_window.py @@ -350,8 +351,8 @@ class ComposeDialog(QDialog): # 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." + AccessibleTextDialog.show_warning( + "No Account", "Please add an account before posting.", self ) return @@ -383,8 +384,8 @@ class ComposeDialog(QDialog): # Validate poll (need at least 2 options) if len(poll_options) < 2: - QMessageBox.warning( - self, "Invalid Poll", "Polls need at least 2 options." + AccessibleTextDialog.show_warning( + "Invalid Poll", "Polls need at least 2 options.", self ) return diff --git a/src/widgets/login_dialog.py b/src/widgets/login_dialog.py index abee8c4..376a3a1 100644 --- a/src/widgets/login_dialog.py +++ b/src/widgets/login_dialog.py @@ -14,6 +14,7 @@ from urllib.parse import urljoin, urlparse from activitypub.client import ActivityPubClient from activitypub.oauth import OAuth2Handler +from widgets.accessible_text_dialog import AccessibleTextDialog class InstanceTestThread(QThread): @@ -197,18 +198,20 @@ class LoginDialog(QDialog): if success: self.result_text.setStyleSheet("color: green;") # Show success message box - QMessageBox.information( - self, - "Connection Test Successful", - f"Connection test successful!\n\n{message}\n\nYou can now proceed to login." + AccessibleTextDialog.show_success( + "Connection Test Successful", + "Connection test successful!", + f"{message}\n\nYou can now proceed to login.", + self ) else: self.result_text.setStyleSheet("color: red;") # Show error message box - QMessageBox.warning( - self, + AccessibleTextDialog.show_error( "Connection Test Failed", - f"Connection test failed:\n\n{message}\n\nPlease check your instance URL and try again." + "Connection test failed", + f"{message}\n\nPlease check your instance URL and try again.", + self ) def start_login(self): @@ -238,11 +241,11 @@ class LoginDialog(QDialog): def on_auth_success(self, account_data): """Handle successful authentication""" - QMessageBox.information( - self, + AccessibleTextDialog.show_success( "Login Successful", - f"Successfully authenticated as {account_data['display_name']} " - f"({account_data['username']}) on {account_data['instance_url']}" + "Successfully authenticated", + f"Logged in as {account_data['display_name']} ({account_data['username']}) on {account_data['instance_url']}", + self ) self.account_added.emit(account_data) @@ -250,10 +253,11 @@ class LoginDialog(QDialog): def on_auth_failed(self, error_message): """Handle authentication failure""" - QMessageBox.warning( - self, + AccessibleTextDialog.show_error( "Authentication Failed", - f"Failed to authenticate with the instance:\n\n{error_message}" + "Failed to authenticate with the instance", + error_message, + self ) # Re-enable UI diff --git a/src/widgets/media_upload_widget.py b/src/widgets/media_upload_widget.py index 73ab40b..a3e11da 100644 --- a/src/widgets/media_upload_widget.py +++ b/src/widgets/media_upload_widget.py @@ -19,6 +19,7 @@ from PySide6.QtGui import QPixmap, QIcon from activitypub.client import ActivityPubClient from audio.sound_manager import SoundManager +from widgets.accessible_text_dialog import AccessibleTextDialog @dataclass @@ -271,10 +272,10 @@ class MediaUploadWidget(QWidget): def add_media_files(self): """Open file dialog to add media files""" if len(self.attachments) >= self.server_limits['max_media_attachments']: - QMessageBox.warning( - self, + AccessibleTextDialog.show_warning( "Media Limit Reached", - f"You can only attach up to {self.server_limits['max_media_attachments']} files per post." + f"You can only attach up to {self.server_limits['max_media_attachments']} files per post.", + self ) return @@ -299,17 +300,17 @@ class MediaUploadWidget(QWidget): """Add files to attachment list""" for file_path in file_paths: if len(self.attachments) >= self.server_limits['max_media_attachments']: - QMessageBox.information( - self, + AccessibleTextDialog.show_info( "Media Limit Reached", - f"Maximum of {self.server_limits['max_media_attachments']} files reached. Remaining files were not added." + f"Maximum of {self.server_limits['max_media_attachments']} files reached. Remaining files were not added.", + self ) break # Validate file validation_result = self.validate_file(file_path) if validation_result is not True: - QMessageBox.warning(self, "Invalid File", validation_result) + AccessibleTextDialog.show_error("Invalid File", "File validation failed", validation_result, self) continue # Create attachment diff --git a/src/widgets/profile_dialog.py b/src/widgets/profile_dialog.py index 6429484..5f3fa71 100644 --- a/src/widgets/profile_dialog.py +++ b/src/widgets/profile_dialog.py @@ -18,6 +18,7 @@ from models.post import Post from activitypub.client import ActivityPubClient from config.accounts import AccountManager from audio.sound_manager import SoundManager +from widgets.accessible_text_dialog import AccessibleTextDialog class ProfileLoadThread(QThread): @@ -452,52 +453,98 @@ class ProfileDialog(QDialog): self.mute_button.setEnabled(True) def toggle_follow(self): - """Toggle follow status for this user""" + """Toggle follow status with improved error handling and state sync""" if not self.relationship: return + # Store original state for rollback on error + original_following = self.relationship.get('following', False) + original_requested = self.relationship.get('requested', False) + try: if self.relationship.get('following', False): - # Unfollow - self.client.unfollow_account(self.user_id) + # Unfollow - optimistic update first self.relationship['following'] = False self.relationship['requested'] = False + self.update_action_buttons() + + # Make API call + result = self.client.unfollow_account(self.user_id) + self.update_local_relationship(result) self.sound_manager.play_unfollow() elif self.relationship.get('requested', False): - # Cancel follow request - self.client.unfollow_account(self.user_id) + # Cancel follow request - optimistic update self.relationship['requested'] = False + self.update_action_buttons() + + # Make API call + result = self.client.unfollow_account(self.user_id) + self.update_local_relationship(result) self.sound_manager.play_success() else: - # Follow + # Follow - optimistic update (assume request will be made) + self.relationship['requested'] = True + self.update_action_buttons() + + # Make API call result = self.client.follow_account(self.user_id) - if result.get('following', False): - self.relationship['following'] = True - else: - self.relationship['requested'] = True + self.update_local_relationship(result) self.sound_manager.play_follow() + except Exception as e: + # Rollback optimistic updates on error + self.relationship['following'] = original_following + self.relationship['requested'] = original_requested self.update_action_buttons() - except Exception as e: - QMessageBox.warning(self, "Follow Error", f"Failed to update follow status: {e}") + # Show specific error message based on error type + if "404" in str(e): + error_msg = f"User @{self.user.get_full_username()} not found. They may have been deleted or moved." + elif "403" in str(e): + error_msg = f"You don't have permission to follow @{self.user.get_full_username()}. They may have blocked you." + elif "422" in str(e): + error_msg = f"Unable to follow @{self.user.get_full_username()}. You may already be following them." + elif "timeout" in str(e).lower() or "connection" in str(e).lower(): + error_msg = f"Network error while trying to follow @{self.user.get_full_username()}. Please check your connection and try again." + else: + error_msg = f"Failed to update follow status for @{self.user.get_full_username()}: {str(e)}" + + AccessibleTextDialog.show_error("Follow Error", error_msg, "", self) self.sound_manager.play_error() + + def update_local_relationship(self, api_result): + """Update local relationship state from API response""" + if api_result and isinstance(api_result, dict): + # Sync all relationship fields from server response + for key in ['following', 'followed_by', 'blocking', 'blocked_by', + 'muting', 'muting_notifications', 'requested', 'domain_blocking']: + if key in api_result: + self.relationship[key] = api_result[key] def toggle_block(self): - """Toggle block status for this user""" + """Toggle block status with improved error handling""" if not self.relationship: return + + # Store original state for rollback + original_blocking = self.relationship.get('blocking', False) + original_following = self.relationship.get('following', False) + original_followed_by = self.relationship.get('followed_by', False) try: if self.relationship.get('blocking', False): - # Unblock - self.client.unblock_account(self.user_id) + # Unblock - optimistic update self.relationship['blocking'] = False + self.update_action_buttons() + + # Make API call + result = self.client.unblock_account(self.user_id) + self.update_local_relationship(result) self.sound_manager.play_success() else: - # Block + # Block - show confirmation first result = QMessageBox.question( self, "Block User", @@ -508,16 +555,35 @@ class ProfileDialog(QDialog): ) if result == QMessageBox.Yes: - self.client.block_account(self.user_id) + # Optimistic update self.relationship['blocking'] = True self.relationship['following'] = False self.relationship['followed_by'] = False + self.update_action_buttons() + + # Make API call + api_result = self.client.block_account(self.user_id) + self.update_local_relationship(api_result) self.sound_manager.play_success() + except Exception as e: + # Rollback optimistic updates on error + self.relationship['blocking'] = original_blocking + self.relationship['following'] = original_following + self.relationship['followed_by'] = original_followed_by self.update_action_buttons() - except Exception as e: - QMessageBox.warning(self, "Block Error", f"Failed to update block status: {e}") + # Specific error messages + if "404" in str(e): + error_msg = f"User @{self.user.get_full_username()} not found." + elif "403" in str(e): + error_msg = f"You don't have permission to block/unblock @{self.user.get_full_username()}." + elif "timeout" in str(e).lower() or "connection" in str(e).lower(): + error_msg = f"Network error while updating block status. Please try again." + else: + error_msg = f"Failed to update block status: {str(e)}" + + AccessibleTextDialog.show_error("Block Error", error_msg, "", self) self.sound_manager.play_error() def toggle_mute(self): @@ -540,5 +606,5 @@ class ProfileDialog(QDialog): self.update_action_buttons() except Exception as e: - QMessageBox.warning(self, "Mute Error", f"Failed to update mute status: {e}") + AccessibleTextDialog.show_error("Mute Error", "Failed to update mute status", str(e), self) self.sound_manager.play_error() \ No newline at end of file diff --git a/src/widgets/soundpack_manager_dialog.py b/src/widgets/soundpack_manager_dialog.py index c9256d2..ce8ea44 100644 --- a/src/widgets/soundpack_manager_dialog.py +++ b/src/widgets/soundpack_manager_dialog.py @@ -15,6 +15,7 @@ from typing import List, Optional from config.settings import SettingsManager from audio.soundpack_manager import SoundpackManager, SoundpackRepository, SoundpackInfo +from widgets.accessible_text_dialog import AccessibleTextDialog class SoundpackOperationThread(QThread): @@ -327,7 +328,7 @@ class SoundpackManagerDialog(QDialog): self.soundpacks = self.manager.discover_soundpacks() self.update_soundpack_list() else: - QMessageBox.warning(self, "Discovery Failed", f"Failed to discover soundpacks: {message}") + AccessibleTextDialog.show_error("Discovery Failed", "Failed to discover soundpacks", message, self) def update_soundpack_list(self): """Update the soundpack list display""" @@ -421,7 +422,7 @@ class SoundpackManagerDialog(QDialog): self.progress_dialog.close() if success: - QMessageBox.information(self, "Installation Successful", message) + AccessibleTextDialog.show_success("Installation Successful", "Soundpack installed successfully", message, self) # Ask if user wants to switch to the new soundpack reply = QMessageBox.question( @@ -443,7 +444,7 @@ class SoundpackManagerDialog(QDialog): self.refresh_soundpacks() self.load_installed_soundpacks() else: - QMessageBox.critical(self, "Installation Failed", f"Failed to install soundpack: {message}") + AccessibleTextDialog.show_error("Installation Failed", "Failed to install soundpack", message, self) def on_progress_update(self, message: str): """Handle progress updates""" @@ -460,11 +461,11 @@ class SoundpackManagerDialog(QDialog): success, message = self.manager.switch_soundpack(soundpack.name) if success: - QMessageBox.information(self, "Soundpack Switched", message) + AccessibleTextDialog.show_success("Soundpack Switched", "Successfully switched soundpack", message, self) self.update_current_soundpack() self.load_installed_soundpacks() else: - QMessageBox.critical(self, "Switch Failed", f"Failed to switch soundpack: {message}") + AccessibleTextDialog.show_error("Switch Failed", "Failed to switch soundpack", message, self) def add_repository(self): """Add a new repository""" @@ -524,11 +525,11 @@ class SoundpackManagerDialog(QDialog): success, message = self.manager.switch_soundpack(pack_name) if success: - QMessageBox.information(self, "Soundpack Switched", message) + AccessibleTextDialog.show_success("Soundpack Switched", "Successfully switched soundpack", message, self) self.update_current_soundpack() self.load_installed_soundpacks() else: - QMessageBox.critical(self, "Switch Failed", f"Failed to switch soundpack: {message}") + AccessibleTextDialog.show_error("Switch Failed", "Failed to switch soundpack", message, self) def uninstall_selected(self): """Uninstall selected soundpack""" @@ -550,12 +551,12 @@ class SoundpackManagerDialog(QDialog): success, message = self.manager.uninstall_soundpack(pack_name) if success: - QMessageBox.information(self, "Uninstall Successful", message) + AccessibleTextDialog.show_success("Uninstall Successful", "Soundpack uninstalled successfully", message, self) self.load_installed_soundpacks() self.update_current_soundpack() self.refresh_soundpacks() # Update available list else: - QMessageBox.critical(self, "Uninstall Failed", f"Failed to uninstall soundpack: {message}") + AccessibleTextDialog.show_error("Uninstall Failed", "Failed to uninstall soundpack", message, self) class AddRepositoryDialog(QDialog): @@ -670,4 +671,4 @@ class AddRepositoryDialog(QDialog): if success: self.accept() else: - QMessageBox.critical(self, "Add Repository Failed", f"Failed to add repository: {message}") \ No newline at end of file + AccessibleTextDialog.show_error("Add Repository Failed", "Failed to add repository", message, self) \ No newline at end of file diff --git a/src/widgets/timeline_view.py b/src/widgets/timeline_view.py index 7773087..cb9622a 100644 --- a/src/widgets/timeline_view.py +++ b/src/widgets/timeline_view.py @@ -19,6 +19,7 @@ from typing import Optional, List, Dict import re import webbrowser import logging +from datetime import datetime # from accessibility.accessible_tree import AccessibleTreeWidget # Testing standard QTreeWidget from audio.sound_manager import SoundManager @@ -26,7 +27,7 @@ from notifications.notification_manager import NotificationManager from config.settings import SettingsManager from config.accounts import AccountManager from activitypub.client import ActivityPubClient -from models.post import Post +from models.post import Post, Account from models.conversation import Conversation, PleromaChatConversation from widgets.poll_voting_dialog import PollVotingDialog @@ -56,11 +57,11 @@ class TimelineView(QTreeWidget): 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.current_account_id = None # Track which account this newest_post_id belongs to # Improved notification system - track recent user actions instead of blanket suppression self.initial_load = True # Skip notifications only on very first load self.recent_user_actions = [] # Track recent user actions to avoid sound spam from own posts - self.timeline_switch_time = None # Track when timeline was last switched self.last_notification_check = ( None # Track newest notification seen for new notification detection @@ -121,29 +122,43 @@ class TimelineView(QTreeWidget): def set_timeline_type(self, timeline_type: str): """Set the timeline type (home, local, federated)""" self.timeline_type = timeline_type - # Track when timeline was switched to avoid immediate notification spam - import time - self.timeline_switch_time = time.time() + # Reset post tracking when switching timeline types since content will be different + self.newest_post_id = None # Reset notification tracking when switching to notifications timeline if timeline_type == "notifications": self.last_notification_check = None self.refresh() # Timeline changes don't preserve position - def should_suppress_notifications(self) -> bool: - """Check if notifications should be suppressed based on recent activity""" - import time - current_time = time.time() + def handle_account_switch(self): + """Handle account switching with proper post ID tracking""" + # Get the new account ID to track post IDs per account + active_account = self.account_manager.get_active_account() + new_account_id = active_account.id if active_account and hasattr(active_account, 'id') else None - # Always suppress on initial load + # If account changed, reset newest_post_id tracking + if new_account_id != self.current_account_id: + self.logger.debug(f"Account switched from {self.current_account_id} to {new_account_id}, resetting post tracking") + self.current_account_id = new_account_id + self.newest_post_id = None # Reset so first load from new account won't trigger false notifications + + # Refresh with position preservation for account switches + self.request_refresh(preserve_position=True, reason="account_switch") + + def should_suppress_notifications(self) -> bool: + """Check if notifications should be suppressed based on post tracking""" + # Only suppress on initial app load (not account switches) if self.initial_load: return True - # Suppress for 3 seconds after timeline switch (1s for notifications timeline) - if self.timeline_switch_time: - delay = 1.0 if self.timeline_type == "notifications" else 3.0 - if current_time - self.timeline_switch_time < delay: - return True - + # Check if we have proper post tracking for the current account + active_account = self.account_manager.get_active_account() + current_account_id = active_account.id if active_account and hasattr(active_account, 'id') else None + + # If account changed but we haven't reset tracking yet, suppress until reset + if current_account_id != self.current_account_id: + self.logger.debug(f"Account mismatch detected: current={current_account_id}, tracked={self.current_account_id}") + return True + return False def track_user_action(self, action_type: str, expected_content_hint: str = None): @@ -180,7 +195,11 @@ class TimelineView(QTreeWidget): # Handle both Post objects and raw dict data if hasattr(post, 'account'): # Post object - post_author_id = post.account.id + if hasattr(post.account, 'id') and post.account.id is not None: + post_author_id = post.account.id + else: + self.logger.warning(f"Post account missing id attribute. Account: {post.account}") + return False else: # Raw dict data post_author_id = post.get('account', {}).get('id') @@ -264,8 +283,21 @@ class TimelineView(QTreeWidget): 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 + # First load - set newest to first post and track account self.newest_post_id = timeline_data[0]["id"] + active_account = self.account_manager.get_active_account() + self.logger.debug(f"Active account from account_manager: {active_account}") + self.logger.debug(f"Active account type: {type(active_account)}") + if active_account: + self.logger.debug(f"Active account attributes: {dir(active_account)}") + if hasattr(active_account, 'id'): + self.current_account_id = active_account.id + else: + self.logger.error(f"Active account missing id attribute: {active_account}") + self.current_account_id = None + else: + self.current_account_id = None + self.logger.debug(f"First load: tracking posts for account {self.current_account_id}") # Restore scroll position if requested if preserve_position and scroll_info: @@ -312,16 +344,27 @@ class TimelineView(QTreeWidget): if self.timeline_type == "notifications": # Handle notifications data structure + self.logger.info(f"Processing {len(timeline_data) if timeline_data else 0} notifications") + self.logger.debug("Starting notification processing loop") + # 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.should_suppress_notifications(): - current_newest_notification_id = timeline_data[0]["id"] - - for notification_data in timeline_data: try: + self.logger.debug(f"Checking first notification for new notification detection. Type: {type(timeline_data[0])}") + self.logger.debug(f"First notification keys: {timeline_data[0].keys() if hasattr(timeline_data[0], 'keys') else 'No keys method'}") + current_newest_notification_id = timeline_data[0]["id"] + except Exception as e: + self.logger.error(f"Error accessing notification id: {e}") + self.logger.error(f"timeline_data[0] content: {timeline_data[0]}") + current_newest_notification_id = None + + for i, notification_data in enumerate(timeline_data): + try: + self.logger.debug(f"Processing notification {i}: type={notification_data.get('type', 'UNKNOWN')}") notification_type = notification_data["type"] sender = ( notification_data["account"]["display_name"] @@ -374,13 +417,82 @@ class TimelineView(QTreeWidget): sender, content_preview ) elif notification_type == "follow": - # Handle follow notifications without status (skip if initial load) + # Handle follow notifications without status + # Create a fake post object for display in notifications timeline + + # Validate account data first + if "id" not in notification_data["account"] or notification_data["account"]["id"] is None: + self.logger.error(f"Follow notification missing account id: {notification_data}") + continue + + # Create account object for the follower + account = Account( + id=notification_data["account"]["id"], + username=notification_data["account"]["username"], + acct=notification_data["account"]["acct"], + display_name=notification_data["account"].get("display_name", ""), + note="", + url=notification_data["account"].get("url", ""), + avatar=notification_data["account"].get("avatar", ""), + avatar_static=notification_data["account"].get("avatar_static", ""), + header="", + header_static="", + locked=False, + bot=False, + discoverable=True, + group=False, + created_at=None, + followers_count=0, + following_count=0, + statuses_count=0 + ) + + # Create a pseudo-post for follow notifications + follow_post = Post( + id=notification_id, + uri="", + url=None, + account=account, + content=f"@{account.acct} started following you", + created_at=datetime.now(), + visibility="direct", + sensitive=False, + spoiler_text="", + media_attachments=[], + mentions=[], + tags=[], + emojis=[], + reblogs_count=0, + favourites_count=0, + replies_count=0, + reblogged=False, + favourited=False, + bookmarked=False, + muted=False, + pinned=False, + reblog=None, + in_reply_to_id=None, + in_reply_to_account_id=None, + language=None, + text=None, + edited_at=None, + poll=None + ) + + # Add notification metadata + follow_post.notification_type = notification_type + follow_post.notification_account = notification_data["account"]["acct"] + self.posts.append(follow_post) + + # Show desktop notification (skip if initial load) if not self.should_suppress_notifications(): self.notification_manager.notify_follow(sender) except Exception as e: self.logger.error(f"Error parsing notification: {e}") continue + self.logger.info(f"Created {len(self.posts)} notification posts for display") + # Update our notification tracking if timeline_data: newest_id = timeline_data[0]["id"] @@ -477,7 +589,17 @@ class TimelineView(QTreeWidget): # Create a pseudo-post from account data for display from models.user import User - user = User.from_api_dict(account_data) + # Add error handling around User creation + try: + user = User.from_api_dict(account_data) + except Exception as user_error: + self.logger.error(f"Failed to create User object from account_data: {user_error}. Account data: {account_data}") + continue + + # Debug: Check if user has id attribute + if not hasattr(user, 'id') or user.id is None: + self.logger.error(f"User object missing id attribute. Account data: {account_data}") + continue # Create a special Post-like object for accounts class AccountDisplayPost: @@ -637,14 +759,18 @@ class TimelineView(QTreeWidget): self.initial_load = False self.logger.debug("Initial load completed, notifications now enabled") - # Build thread structure (accounts don't need threading) - if self.timeline_type in ["followers", "following"]: + # Build thread structure (accounts and notifications don't need threading) + self.logger.debug(f"Timeline type is: '{self.timeline_type}', posts count: {len(self.posts)}") + if self.timeline_type in ["followers", "following", "notifications"]: + self.logger.debug(f"Building simple list for {self.timeline_type} with {len(self.posts)} posts") self.build_account_list() else: + self.logger.debug(f"Building threaded timeline for {self.timeline_type} with {len(self.posts)} posts") self.build_threaded_timeline() def build_threaded_timeline(self): """Build threaded timeline from posts""" + self.logger.debug(f"build_threaded_timeline called with {len(self.posts)} 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 @@ -673,22 +799,37 @@ class TimelineView(QTreeWidget): # 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) + try: + root_post = thread_posts[0] # First post is always the root + self.logger.debug(f"Creating root item for post {root_post.id}") + root_item = self.create_post_item(root_post) + self.addTopLevelItem(root_item) + except Exception as e: + self.logger.error(f"Error creating root item for post {root_id}: {e}") + raise e # 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 - root_item.addChild(reply_item) + try: + self.logger.debug(f"Creating reply item for post {post.id}") + reply_item = self.create_post_item(post) + reply_item.setData( + 0, Qt.UserRole + 1, post.in_reply_to_id + ) # Store what this replies to + root_item.addChild(reply_item) + except Exception as e: + self.logger.error(f"Error creating reply item for post {post.id}: {e}") + raise e # Add orphaned posts as top-level items for post in orphaned_posts: - orphaned_item = self.create_post_item(post) - self.addTopLevelItem(orphaned_item) + try: + self.logger.debug(f"Creating orphaned item for post {post.id}") + orphaned_item = self.create_post_item(post) + self.addTopLevelItem(orphaned_item) + except Exception as e: + self.logger.error(f"Error creating orphaned item for post {post.id}: {e}") + raise e # Add "Load more posts" item if we have posts if self.posts: @@ -703,12 +844,30 @@ class TimelineView(QTreeWidget): 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) + """Build simple list for followers/following accounts and notifications""" + self.logger.debug(f"build_account_list called with {len(self.posts)} posts") + self.logger.debug("build_account_list: About to check if posts is empty") + if not self.posts: + # Show appropriate empty message based on timeline type + if self.timeline_type == "notifications": + self.show_empty_message("No notifications yet. New mentions, boosts, and follows will appear here.") + elif self.timeline_type == "followers": + self.show_empty_message("No followers yet.") + elif self.timeline_type == "following": + self.show_empty_message("Not following anyone yet.") + return - # Add "Load more" item if we have accounts + for i, account_post in enumerate(self.posts): + self.logger.debug(f"Creating item for post {i}: type={type(account_post)}") + try: + item = self.create_post_item(account_post) + self.addTopLevelItem(item) + self.logger.debug(f"Successfully created item for post {i}") + except Exception as e: + self.logger.error(f"Error creating item for post {i}: {e}") + raise e + + # Add "Load more" item if we have accounts/notifications if self.posts: self.add_load_more_item() @@ -839,15 +998,35 @@ class TimelineView(QTreeWidget): 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() + try: + self.logger.debug(f"create_post_item: Processing post {getattr(post, 'id', 'NO_ID')}, type: {type(post)}") + + # Validate account object before processing + if hasattr(post, 'account') and post.account: + if not hasattr(post.account, 'id'): + self.logger.error(f"Post {getattr(post, 'id', 'NO_ID')} has account without id attribute: {post.account}") + self.logger.error(f"Account type: {type(post.account)}, Account dict: {getattr(post.account, '__dict__', 'No __dict__')}") + raise AttributeError(f"Account object missing id attribute for post {getattr(post, 'id', 'NO_ID')}") + + # Get display text + self.logger.debug(f"create_post_item: About to call get_summary_for_screen_reader for post {getattr(post, 'id', 'NO_ID')}") + summary = post.get_summary_for_screen_reader() + self.logger.debug(f"create_post_item: Got summary for post {getattr(post, 'id', 'NO_ID')}") - # Create item - item = QTreeWidgetItem([summary]) - item.setData(0, Qt.UserRole, post) # Store post object - item.setData(0, Qt.AccessibleTextRole, summary) + # Create item + item = QTreeWidgetItem([summary]) + item.setData(0, Qt.UserRole, post) # Store post object + item.setData(0, Qt.AccessibleTextRole, summary) - return item + return item + except AttributeError as e: + if "'Account' object has no attribute 'id'" in str(e): + self.logger.error(f"Account missing id attribute in create_post_item. Post type: {type(post)}, Post content: {getattr(post, 'content', 'No content')}") + if hasattr(post, 'account'): + self.logger.error(f"Account object: {post.account}, Account type: {type(post.account)}") + if hasattr(post.account, '__dict__'): + self.logger.error(f"Account attributes: {post.account.__dict__}") + raise e def copy_post_to_clipboard(self, post: Optional[Post] = None): """Copy the selected post's content to clipboard""" @@ -1610,7 +1789,7 @@ class TimelineView(QTreeWidget): self.sound_manager.play_error() def expand_thread_with_context(self, item): - """Expand thread after fetching full conversation context""" + """Expand thread after fetching full conversation context with proper sorting""" try: # Get the post from the tree item post = item.data(0, Qt.UserRole) @@ -1625,23 +1804,31 @@ class TimelineView(QTreeWidget): self.expandItem(item) return + self.logger.debug(f"Fetching context for status {post.id}") context_data = self.activitypub_client.get_status_context(post.id) - # Get descendants (replies) from context + # Get full conversation: ancestors + current + descendants + ancestors = context_data.get("ancestors", []) descendants = context_data.get("descendants", []) + + self.logger.info(f"Context fetched: {len(ancestors)} ancestors, {len(descendants)} descendants") - if descendants: - # Clear existing children + if ancestors or descendants: + # Convert API data to Post objects + from models.post import Post + + ancestor_posts = [Post.from_api_dict(data) for data in ancestors] + descendant_posts = [Post.from_api_dict(data) for data in descendants] + + # Sort thread using Mastodon-compatible algorithm + all_posts = ancestor_posts + [post] + descendant_posts + sorted_posts = self.sort_posts_for_thread(all_posts, post.id) + + # Clear existing children and rebuild with proper hierarchy 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) + + # Build hierarchical thread structure + self.build_hierarchical_thread(item, sorted_posts, post.id) # Now expand the thread self.expandItem(item) @@ -1653,6 +1840,117 @@ class TimelineView(QTreeWidget): self.logger.error(f"Failed to fetch thread context: {e}") # Fallback to regular expansion self.expandItem(item) + + def sort_posts_for_thread(self, posts, focus_post_id): + """Sort posts for thread using Mastodon-compatible algorithm""" + if not posts: + return [] + + # Create lookup dictionaries + posts_by_id = {p.id: p for p in posts} + posts_by_reply_id = {} + + # Group posts by what they reply to + for post in posts: + reply_id = post.in_reply_to_id + if reply_id: + if reply_id not in posts_by_reply_id: + posts_by_reply_id[reply_id] = [] + posts_by_reply_id[reply_id].append(post) + + focus_post = posts_by_id.get(focus_post_id) + if not focus_post: + # If focus post not found, return posts sorted by creation time + return sorted(posts, key=lambda p: p.created_at) + + ancestors = [] + descendants = [] + + # Find ancestors (walking up reply chain) + current_post = focus_post + while current_post and current_post.in_reply_to_id: + parent = posts_by_id.get(current_post.in_reply_to_id) + if parent: + ancestors.insert(0, parent) # Insert at beginning for correct order + current_post = parent + else: + break + + # Find descendants using depth-first traversal + # This mirrors the stack-based approach from semafore + stack = [focus_post] + while stack: + current = stack.pop(0) # Take from front (breadth-first for sorting) + replies = posts_by_reply_id.get(current.id, []) + + # Sort replies by creation time (like semafore's compareTimelineItemSummaries) + replies.sort(key=lambda p: p.created_at) + + # Add to front of stack to maintain depth-first order + for reply in reversed(replies): + stack.insert(0, reply) + + # Add to descendants if not the focus post itself + if current.id != focus_post_id: + descendants.append(current) + + # Apply self-reply promotion (semafore's key feature) + promoted_descendants = [] + other_descendants = [] + + for descendant in descendants: + if self.is_unbroken_self_reply(descendant, focus_post, posts_by_id): + promoted_descendants.append(descendant) + else: + other_descendants.append(descendant) + + # Return in Mastodon order: ancestors + focus + promoted self-replies + other replies + return ancestors + [focus_post] + promoted_descendants + other_descendants + + def is_unbroken_self_reply(self, post, focus_post, posts_by_id): + """Check if this is an unbroken self-reply chain from focus post author""" + if post.account.get('id') != focus_post.account.get('id'): + return False + + # Walk up the reply chain to ensure it's unbroken to focus post + current = post + while current and current.in_reply_to_id: + if current.account.get('id') != focus_post.account.get('id'): + return False + parent = posts_by_id.get(current.in_reply_to_id) + if not parent: + break + current = parent + if current.id == focus_post.id: + return True + + return current and current.id == focus_post.id + + def build_hierarchical_thread(self, root_item, sorted_posts, focus_post_id): + """Build hierarchical thread structure in tree widget""" + posts_by_id = {p.id: p for p in sorted_posts} + item_by_post_id = {focus_post_id: root_item} + + # Skip the focus post since it's already the root item + for post in sorted_posts: + if post.id == focus_post_id: + continue + + # Create tree item for this post + post_item = self.create_post_item(post) + post_item.setData(0, Qt.UserRole + 1, post.in_reply_to_id) + + # Find the correct parent in the tree + parent_id = post.in_reply_to_id + if parent_id and parent_id in item_by_post_id: + parent_item = item_by_post_id[parent_id] + parent_item.addChild(post_item) + else: + # No parent found in current thread, add as child of root + root_item.addChild(post_item) + + # Track this item for future children + item_by_post_id[post.id] = post_item def show_post_details(self, item): """Show detailed post information dialog"""