diff --git a/CLAUDE.md b/CLAUDE.md index 0d5cac8..96b5eaf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,74 @@ 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 +## 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 + +### 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 + ## 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 diff --git a/src/audio/sound_manager.py b/src/audio/sound_manager.py index 66faa72..05f3517 100644 --- a/src/audio/sound_manager.py +++ b/src/audio/sound_manager.py @@ -86,10 +86,15 @@ class SoundManager: # Standard sound events SOUND_EVENTS = [ "private_message", + "direct_message", "mention", "boost", "reply", + "favorite", + "follow", + "unfollow", "post_sent", + "post", "timeline_update", "notification", "startup", @@ -97,7 +102,9 @@ class SoundManager: "success", "error", "expand", - "collapse" + "collapse", + "autocomplete", + "autocomplete_end" ] def __init__(self, settings: SettingsManager): @@ -142,10 +149,15 @@ class SoundManager: "version": "1.0", "sounds": { "private_message": "private_message.wav", + "direct_message": "direct_message.wav", "mention": "mention.wav", "boost": "boost.wav", "reply": "reply.wav", + "favorite": "favorite.wav", + "follow": "follow.wav", + "unfollow": "unfollow.wav", "post_sent": "post_sent.wav", + "post": "post.wav", "timeline_update": "timeline_update.wav", "notification": "notification.wav", "startup": "startup.wav", @@ -154,7 +166,8 @@ class SoundManager: "error": "error.wav", "expand": "expand.wav", "collapse": "collapse.wav", - "autocomplete": "autocomplete.wav" + "autocomplete": "autocomplete.wav", + "autocomplete_end": "autocomplete_end.wav" } } @@ -345,6 +358,10 @@ class SoundManager: """Play private message sound""" self.play_event("private_message") + def play_direct_message(self): + """Play direct message sound""" + self.play_event("direct_message") + def play_mention(self): """Play mention sound""" self.play_event("mention") @@ -357,10 +374,26 @@ class SoundManager: """Play reply sound""" self.play_event("reply") + def play_favorite(self): + """Play favorite sound""" + self.play_event("favorite") + + def play_follow(self): + """Play follow sound""" + self.play_event("follow") + + def play_unfollow(self): + """Play unfollow sound""" + self.play_event("unfollow") + def play_post_sent(self): """Play post sent sound""" self.play_event("post_sent") + def play_post(self): + """Play post sound""" + self.play_event("post") + def play_timeline_update(self): """Play timeline update sound""" self.play_event("timeline_update") diff --git a/src/config/settings.py b/src/config/settings.py index 721c5cd..12f353f 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -94,6 +94,16 @@ class SettingsManager: self.config.set('interface', 'show_timestamps', 'true') self.config.set('interface', 'compact_mode', 'false') + # Desktop notification settings + self.config.add_section('notifications') + self.config.set('notifications', 'enabled', 'true') + self.config.set('notifications', 'direct_messages', 'true') + self.config.set('notifications', 'mentions', 'true') + self.config.set('notifications', 'boosts', 'false') + self.config.set('notifications', 'favorites', 'false') + self.config.set('notifications', 'follows', 'true') + self.config.set('notifications', 'timeline_updates', 'false') + self.save_settings() def save_settings(self): diff --git a/src/main_window.py b/src/main_window.py index c404ba6..dd12a7b 100644 --- a/src/main_window.py +++ b/src/main_window.py @@ -446,9 +446,9 @@ class MainWindow(QMainWindow): def on_timeline_tab_changed(self, index): """Handle timeline tab change""" - self.switch_timeline(index) + self.switch_timeline(index, from_tab_change=True) - def switch_timeline(self, index): + def switch_timeline(self, index, from_tab_change=False): """Switch to timeline by index with loading feedback""" timeline_names = ["Home", "Messages", "Mentions", "Local", "Federated", "Bookmarks", "Followers", "Following"] timeline_types = ["home", "conversations", "notifications", "local", "federated", "bookmarks", "followers", "following"] @@ -457,8 +457,13 @@ class MainWindow(QMainWindow): timeline_name = timeline_names[index] timeline_type = timeline_types[index] - # Set tab to match if called from keyboard shortcut - if self.timeline_tabs.currentIndex() != index: + # Prevent duplicate calls for the same timeline + if hasattr(self, '_current_timeline_switching') and self._current_timeline_switching: + return + self._current_timeline_switching = True + + # Set tab to match if called from keyboard shortcut (but not if already from tab change) + if not from_tab_change and self.timeline_tabs.currentIndex() != index: self.timeline_tabs.setCurrentIndex(index) # Announce loading @@ -478,6 +483,10 @@ class MainWindow(QMainWindow): if hasattr(self.timeline, 'sound_manager'): self.timeline.sound_manager.play_error() self.status_bar.showMessage(f"Failed to load {timeline_name} timeline: {str(e)}", 3000) + finally: + # Reset the flag after a brief delay to allow the operation to complete + from PySide6.QtCore import QTimer + QTimer.singleShot(100, lambda: setattr(self, '_current_timeline_switching', False)) def get_selected_post(self): """Get the currently selected post from timeline""" @@ -591,6 +600,9 @@ class MainWindow(QMainWindow): else: client.reblog_status(post.id) self.status_bar.showMessage("Post boosted", 2000) + # Play boost sound for successful boost + if hasattr(self.timeline, 'sound_manager'): + self.timeline.sound_manager.play_boost() # Refresh timeline to show updated state self.timeline.refresh() except Exception as e: @@ -610,6 +622,9 @@ class MainWindow(QMainWindow): else: client.favourite_status(post.id) self.status_bar.showMessage("Post favorited", 2000) + # Play favorite sound for successful favorite + if hasattr(self.timeline, 'sound_manager'): + self.timeline.sound_manager.play_favorite() # Refresh timeline to show updated state self.timeline.refresh() except Exception as e: @@ -630,6 +645,7 @@ class MainWindow(QMainWindow): def quit_application(self): """Quit the application with shutdown sound""" + self._shutdown_sound_played = True # Mark that we're handling the shutdown sound if hasattr(self.timeline, 'sound_manager'): self.timeline.sound_manager.play_shutdown() # Wait briefly for sound to start playing @@ -761,6 +777,9 @@ class MainWindow(QMainWindow): client.follow_account(post.account.id) username = post.account.display_name or post.account.username self.status_bar.showMessage(f"Followed {username}", 2000) + # Play follow sound for successful follow + if hasattr(self.timeline, 'sound_manager'): + self.timeline.sound_manager.play_follow() except Exception as e: self.status_bar.showMessage(f"Follow failed: {str(e)}", 3000) @@ -776,6 +795,9 @@ class MainWindow(QMainWindow): client.unfollow_account(post.account.id) username = post.account.display_name or post.account.username self.status_bar.showMessage(f"Unfollowed {username}", 2000) + # Play unfollow sound for successful unfollow + if hasattr(self.timeline, 'sound_manager'): + self.timeline.sound_manager.play_unfollow() except Exception as e: self.status_bar.showMessage(f"Unfollow failed: {str(e)}", 3000) @@ -858,14 +880,17 @@ class MainWindow(QMainWindow): client.follow_account(target_account['id']) display_name = target_account.get('display_name') or target_account['username'] self.status_bar.showMessage(f"Followed {display_name}", 2000) + # Play follow sound for successful follow + if hasattr(self.timeline, 'sound_manager'): + self.timeline.sound_manager.play_follow() except Exception as e: self.status_bar.showMessage(f"Follow failed: {str(e)}", 3000) def closeEvent(self, event): """Handle window close event""" - # Play shutdown sound if not already played through quit_application - if hasattr(self.timeline, 'sound_manager'): + # Only play shutdown sound if not already played through quit_application + if not hasattr(self, '_shutdown_sound_played') and hasattr(self.timeline, 'sound_manager'): self.timeline.sound_manager.play_shutdown() # Wait briefly for sound to complete from PySide6.QtCore import QTimer, QEventLoop diff --git a/src/widgets/compose_dialog.py b/src/widgets/compose_dialog.py index 687fad6..1d8ae88 100644 --- a/src/widgets/compose_dialog.py +++ b/src/widgets/compose_dialog.py @@ -301,7 +301,7 @@ class ComposeDialog(QDialog): } # Play sound when post button is pressed - self.sound_manager.play_event("post") + self.sound_manager.play_post() # Emit signal with all post data for background processing self.post_sent.emit(post_data) diff --git a/src/widgets/timeline_view.py b/src/widgets/timeline_view.py index e625217..1971ce5 100644 --- a/src/widgets/timeline_view.py +++ b/src/widgets/timeline_view.py @@ -177,14 +177,18 @@ class TimelineView(AccessibleTreeWidget): if notification_type == 'mention': self.notification_manager.notify_mention(sender, content_preview) + self.sound_manager.play_mention() elif notification_type == 'reblog': self.notification_manager.notify_boost(sender, content_preview) + self.sound_manager.play_boost() elif notification_type == 'favourite': self.notification_manager.notify_favorite(sender, content_preview) + self.sound_manager.play_favorite() elif notification_type == 'follow': # Handle follow notifications without status (skip if initial load) if not self.skip_notifications: self.notification_manager.notify_follow(sender) + self.sound_manager.play_follow() except Exception as e: print(f"Error parsing notification: {e}") continue @@ -285,11 +289,19 @@ class TimelineView(AccessibleTreeWidget): timeline_name = { 'home': 'home timeline', 'local': 'local timeline', - 'federated': 'federated timeline' + 'federated': 'federated timeline', + 'conversations': 'conversations' }.get(self.timeline_type, 'timeline') # 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.sound_manager.play_direct_message() + else: + # Use timeline update sound for other timelines + self.sound_manager.play_timeline_update() # Build thread structure (accounts don't need threading) if self.timeline_type in ["followers", "following"]: