diff --git a/CLAUDE.md b/CLAUDE.md index 589e2b6..7431f92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,57 @@ Bifrost is a fully accessible fediverse client built with PySide6, designed spec - 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 @@ -14,7 +65,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. +5. **Design**: Ensure single point of truth is used as much as possible throughout the code ### Required Patterns for Event-Heavy Operations @@ -690,4 +741,26 @@ content_text.setTextInteractionFlags(Qt.TextSelectableByKeyboard | Qt.TextSelect - 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. diff --git a/README.md b/README.md index 74aa847..bfbb98c 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,12 @@ This project was created through "vibe coding" - a collaborative development app - **Screen Reader Optimized**: Designed from the ground up for excellent accessibility - **Threaded Conversations**: Navigate complex conversation trees with keyboard shortcuts - **Timeline Switching**: Easy navigation between Home, Messages, Mentions, Local, Federated, Bookmarks, Followers, and Following timelines -- **Desktop Notifications**: Cross-platform notifications for mentions, direct messages, and timeline updates +- **Smart Notifications**: Intelligent sound system that distinguishes your own content from external mentions, DMs, and updates - **Customizable Audio Feedback**: Rich sound pack system with themed audio notifications - **Soundpack Manager**: Secure repository-based soundpack discovery and installation - **Smart Autocomplete**: Mention completion with full fediverse handles (@user@instance.com) - **Comprehensive Emoji Support**: 5,000+ Unicode emojis with keyword search -- **Auto-refresh**: Intelligent timeline updates based on user activity +- **Auto-refresh**: Intelligent timeline updates with smart notification system - **Clean Interface**: Focused on functionality over visual design - **Keyboard Navigation**: Complete keyboard control with intuitive shortcuts - **Direct Message Interface**: Dedicated conversation view with threading support @@ -35,7 +35,11 @@ This project was created through "vibe coding" - a collaborative development app ## Audio System -Bifrost includes a sophisticated sound system with: +Bifrost includes a sophisticated sound system with intelligent notification handling: +- **Smart Notification System**: Distinguishes between your own content and external notifications +- **Immediate External Alerts**: Never miss mentions, DMs, or replies from others, even during your own actions +- **Context-Aware Sounds**: Different sounds for mentions, direct messages, boosts, and timeline updates +- **No Sound Spam**: Prevents redundant sounds from your own posts while preserving important notifications - **Soundpack Manager**: Secure HTTPS-based repository system - **Repository Management**: Add/remove soundpack repositories with validation - **One-click Installation**: Download, validate, and install soundpacks securely @@ -231,9 +235,9 @@ python bifrost.py -d debug.log **New Content Detection:** ```bash -python bifrost.py -d | grep -i "new content" +python bifrost.py -d | grep -i "new content\|external content\|user.*own content" ``` -See when new posts are detected and why sounds might not play. +See when new posts are detected, whether they're from you or others, and why specific sounds are played. ### Log Levels diff --git a/src/main_window.py b/src/main_window.py index 0b38ab4..d79a60c 100644 --- a/src/main_window.py +++ b/src/main_window.py @@ -629,7 +629,7 @@ class MainWindow(QMainWindow): if ( hasattr(self.timeline, "notification_manager") - and not self.timeline.skip_notifications + and not self.timeline.should_suppress_notifications() ): self.timeline.notification_manager.notify_new_content(timeline_name) @@ -664,7 +664,7 @@ class MainWindow(QMainWindow): # Play appropriate notification sound based on type if ( hasattr(self.timeline, "sound_manager") - and not self.timeline.skip_notifications + and not self.timeline.should_suppress_notifications() ): notification_type = notification_data.get("type", "notification") if notification_type == "mention": diff --git a/src/widgets/timeline_view.py b/src/widgets/timeline_view.py index 5607b74..7773087 100644 --- a/src/widgets/timeline_view.py +++ b/src/widgets/timeline_view.py @@ -56,7 +56,12 @@ 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.skip_notifications = True # Skip notifications on initial loads + + # 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 ) @@ -116,23 +121,73 @@ class TimelineView(QTreeWidget): def set_timeline_type(self, timeline_type: str): """Set the timeline type (home, local, federated)""" self.timeline_type = timeline_type - # Disable notifications temporarily when switching timelines to prevent sound spam - self.skip_notifications = True + # Track when timeline was switched to avoid immediate notification spam + import time + self.timeline_switch_time = time.time() # 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 - # 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 - QTimer.singleShot(delay, self.enable_notifications) - - def enable_notifications(self): - """Enable desktop notifications for timeline updates""" - self.skip_notifications = False + def should_suppress_notifications(self) -> bool: + """Check if notifications should be suppressed based on recent activity""" + import time + current_time = time.time() + + # Always suppress on initial load + 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 + + return False + + def track_user_action(self, action_type: str, expected_content_hint: str = None): + """Track a user action to avoid playing sounds for expected results + + Args: + action_type: Type of action (post, reply, boost, favorite, etc.) + expected_content_hint: Hint about what content might appear (post ID, text snippet, etc.) + """ + import time + action_info = { + 'type': action_type, + 'timestamp': time.time(), + 'hint': expected_content_hint + } + self.recent_user_actions.append(action_info) + + # Keep only recent actions (last 10 seconds) + cutoff_time = time.time() - 10.0 + self.recent_user_actions = [a for a in self.recent_user_actions if a['timestamp'] > cutoff_time] + + self.logger.debug(f"Tracked user action: {action_type} with hint: {expected_content_hint}") + + def is_content_from_current_user(self, post) -> bool: + """Check if a post is from the current user + + Args: + post: Post object or dict containing post data + """ + try: + active_account = self.account_manager.get_active_account() + if not active_account: + return False + + # Handle both Post objects and raw dict data + if hasattr(post, 'account'): # Post object + post_author_id = post.account.id + else: # Raw dict data + post_author_id = post.get('account', {}).get('id') + + return post_author_id == active_account.account_id + except Exception as e: + self.logger.error(f"Error checking post authorship: {e}") + return False def refresh(self, preserve_position=False): """Refresh the timeline content""" @@ -262,7 +317,7 @@ class TimelineView(QTreeWidget): 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: + if timeline_data and not self.should_suppress_notifications(): current_newest_notification_id = timeline_data[0]["id"] for notification_data in timeline_data: @@ -286,7 +341,7 @@ class TimelineView(QTreeWidget): # 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 not self.should_suppress_notifications() and notification_id > self.last_notification_check ): new_notifications_found.add(notification_type) @@ -303,7 +358,7 @@ class TimelineView(QTreeWidget): self.posts.append(post) # Show desktop notification (skip if this is initial load) - if not self.skip_notifications: + if not self.should_suppress_notifications(): content_preview = post.get_display_content() if notification_type == "mention": @@ -320,7 +375,7 @@ class TimelineView(QTreeWidget): ) elif notification_type == "follow": # Handle follow notifications without status (skip if initial load) - if not self.skip_notifications: + if not self.should_suppress_notifications(): self.notification_manager.notify_follow(sender) except Exception as e: self.logger.error(f"Error parsing notification: {e}") @@ -341,8 +396,8 @@ class TimelineView(QTreeWidget): 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 + elif notification_types_found and not self.should_suppress_notifications(): + # Fallback: if we can't determine new vs old, but notifications are not suppressed self.logger.debug( f"Playing sound for notification types (fallback): {notification_types_found}" ) @@ -491,40 +546,96 @@ class TimelineView(QTreeWidget): 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: + # Improved notification logic - analyze new content for external vs user's own posts + if has_new_content: 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": - # Use direct message sound for conversation updates - self.logger.info( - "Playing direct_message sound for conversations timeline" + + # Check if we should suppress all notifications (initial load, recent timeline switch) + suppress_all = self.should_suppress_notifications() + + if suppress_all: + self.logger.debug( + f"New content detected but notifications suppressed: {timeline_name}" ) - 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.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)" - ) - elif not has_new_content: + # Analyze new content to separate external content from user's own + external_content_detected = False + user_content_detected = False + + # Check new posts to see if any are from external users + for post in new_posts: + if self.is_content_from_current_user(post): + user_content_detected = True + self.logger.debug(f"Detected user's own content: {post.id}") + else: + external_content_detected = True + self.logger.debug(f"Detected external content: {post.id} from {post.account.acct}") + + # Play sounds and notifications for external content + if external_content_detected: + self.logger.info( + f"External content notification triggered for {timeline_name}" + ) + + # Use generic "new content" message instead of counting posts + self.notification_manager.notify_new_content(timeline_name) + + # Check for specific types of content to play appropriate sounds + mentions_detected = False + direct_messages_detected = False + + # Analyze external posts for mentions and DMs + active_account = self.account_manager.get_active_account() + if active_account: + current_username = active_account.username + current_acct = active_account.get_full_username() + + for post in new_posts: + if not self.is_content_from_current_user(post): + # Check for mentions of current user + if post.mentions: + for mention in post.mentions: + if mention.get('username') == current_username or mention.get('acct') == current_acct: + mentions_detected = True + break + + # Check for direct messages + if post.visibility == 'direct': + direct_messages_detected = True + + # Play most specific sound available + if direct_messages_detected or self.timeline_type == "conversations": + self.logger.info( + "Playing direct_message sound for new direct messages" + ) + self.sound_manager.play_direct_message() + elif mentions_detected: + self.logger.info( + "Playing mention sound for new mentions" + ) + self.sound_manager.play_mention() + else: + # Use timeline update sound for other content + self.logger.info( + f"Playing timeline_update sound for external content in {timeline_name}" + ) + self.sound_manager.play_timeline_update() + elif user_content_detected: + self.logger.debug( + f"Only user's own content detected in {timeline_name}, no notification sound played" + ) + else: self.logger.debug("No new content detected, no sound played") + + # Mark initial load as complete after first successful load + if self.initial_load: + 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"]: @@ -1737,6 +1848,8 @@ class TimelineView(QTreeWidget): def request_post_action_refresh(self, action: str): """Handle refreshes after post actions (boost, favorite, etc.)""" + # Track the user action to avoid sound spam from expected results + self.track_user_action(action) # Preserve position for post actions so user doesn't lose place self.request_refresh(preserve_position=True, reason=f"post_action_{action}") @@ -1748,12 +1861,8 @@ class TimelineView(QTreeWidget): def can_auto_refresh(self) -> bool: """ Check if timeline is ready for auto-refresh - Prevents refresh during user interaction or other operations + Prevents refresh during specific operations that would be disruptive """ - # 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