Implement smart notification system with intelligent content analysis

Major improvements to notification handling:
- Replace blanket skip_notifications with content source analysis
- Immediate notifications for external mentions, DMs, and posts from others
- Smart sound selection based on content type (mention/DM/timeline_update)
- Remove focus-based auto-refresh blocking for seamless background updates
- Fix notifications timeline AttributeError from removed skip_notifications
- Track user actions to prevent sound spam from own expected content
- Context-aware suppression only for user's own posts, not external content

User experience improvements:
- Never miss external mentions or DMs due to timing issues
- Auto-refresh works regardless of timeline focus
- Appropriate sounds play immediately for genuine external notifications
- No redundant sounds from user's own post actions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Storm Dragon
2025-07-23 15:38:39 -04:00
parent cd535aebdf
commit 65f59533cf
4 changed files with 243 additions and 57 deletions
+2 -2
View File
@@ -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":
+158 -49
View File
@@ -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