From bb3d6f66608b5e708fab9742f6188dc49eff3012 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 25 Jun 2026 18:23:15 -0600 Subject: [PATCH 1/2] feat(mobile): harden unread badges and float tabs Port mobile unread badge computation to the observed-event model used by desktop so read markers can clear channel, thread, and individual message scopes correctly. Replace the Material bottom navigation with a compact floating tab bar and keep page FABs clear of the floating surface. Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../channels/channel_detail_page.dart | 4 + .../lib/features/channels/channels_page.dart | 6 + .../features/channels/channels_provider.dart | 224 ++++++++++---- .../read_state/read_state_format.dart | 16 + .../features/channels/thread_detail_page.dart | 19 ++ .../unread_badge/observed_unread_event.dart | 133 +++++++++ .../unread_badge/should_notify_for_event.dart | 14 +- .../unread_badge/unread_badge_provider.dart | 73 +++-- mobile/lib/features/home/home_page.dart | 275 ++++++++++++++++-- .../unread_badge_provider_test.dart | 142 ++++++++- 10 files changed, 789 insertions(+), 117 deletions(-) create mode 100644 mobile/lib/features/channels/unread_badge/observed_unread_event.dart diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 5cc0de94126..7803eb2b4eb 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -64,6 +64,7 @@ int? _channelReadTimestamp({ if (events != null && events.isNotEmpty) { var latest = 0; for (final event in events) { + if (event.threadReference.parentId != null) continue; if (event.createdAt > latest) { latest = event.createdAt; } @@ -132,6 +133,9 @@ class ChannelDetailPage extends HookConsumerWidget { ref .read(readStateProvider.notifier) .markContextRead(channel.id, readTimestamp); + ref + .read(channelsProvider.notifier) + .clearObservedUnreadCoveredByRead(channel.id, readTimestamp); }); }, [channel.id, readState.isReady, readTimestamp]); diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index f6fb5f9d858..238c5c1d258 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -488,6 +488,9 @@ class _SliverChannelsList extends HookConsumerWidget { ref .read(readStateProvider.notifier) .markContextRead(channel.id, ts); + ref + .read(channelsProvider.notifier) + .clearObservedUnreadCoveredByRead(channel.id, ts); } }, ), @@ -1104,6 +1107,9 @@ class _ChannelTile extends ConsumerWidget { ref .read(readStateProvider.notifier) .markContextRead(channel.id, ts); + ref + .read(channelsProvider.notifier) + .clearObservedUnreadCoveredByRead(channel.id, ts); } else { ref .read(readStateProvider.notifier) diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 99dbaca8373..88adf65b990 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -1,18 +1,24 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:math'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; +import '../../shared/theme/theme_provider.dart'; import '../../shared/utils/string_utils.dart'; import 'channel.dart'; import 'channel_management_provider.dart' show channelDetailsProvider; import 'read_state/read_state_provider.dart'; import 'unread_badge/is_high_priority_event.dart'; +import 'unread_badge/observed_unread_event.dart'; import 'unread_badge/should_notify_for_event.dart'; const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2}; +const _unreadCatchUpLimit = 1000; +const _participatedRootIdsPrefix = 'buzz-thread-participation.v1'; +const _authoredRootIdsPrefix = 'buzz-thread-authored.v1'; /// Loads the user's channel list from the relay over WebSocket. /// @@ -30,10 +36,22 @@ class ChannelsNotifier extends AsyncNotifier> { final List _unsubscribers = []; int _subscriptionVersion = 0; Timer? _backstopTimer; - final Map _latestHighPriorityByChannel = {}; - - Map get latestHighPriorityByChannel => - Map.unmodifiable(_latestHighPriorityByChannel); + final Map _latestObservedByChannel = {}; + final Map> + _observedUnreadEventsByChannel = {}; + Set _participatedRootIds = {}; + Set _authoredRootIds = {}; + String? _threadInterestPubkey; + + Map get latestObservedByChannel => + Map.unmodifiable(_latestObservedByChannel); + + Map> + get observedUnreadEventsByChannel => + Map>.unmodifiable({ + for (final entry in _observedUnreadEventsByChannel.entries) + entry.key: Map.unmodifiable(entry.value), + }); @override Future> build() { @@ -50,14 +68,16 @@ class ChannelsNotifier extends AsyncNotifier> { ref.onDispose(() { _clearLiveSubscriptions(); - _latestHighPriorityByChannel.clear(); + _latestObservedByChannel.clear(); + _observedUnreadEventsByChannel.clear(); _backstopTimer?.cancel(); _backstopTimer = null; }); if (sessionState.status != SessionStatus.connected) { _clearLiveSubscriptions(); - _latestHighPriorityByChannel.clear(); + _latestObservedByChannel.clear(); + _observedUnreadEventsByChannel.clear(); // Preserve the last successfully loaded channels while reconnecting // instead of re-entering a loading/error state. The UI will show cached // channels with a "Reconnecting…" banner overlay, which is far better @@ -79,6 +99,7 @@ class ChannelsNotifier extends AsyncNotifier> { }) async { final myPk = ref.read(myPubkeyProvider); if (myPk == null) throw StateError('No signing identity available'); + _loadThreadInterestStores(myPk); final session = ref.read(relaySessionProvider.notifier); @@ -399,9 +420,7 @@ class ChannelsNotifier extends AsyncNotifier> { _unsubscribers.addAll(subscriptions.whereType()); - // Backfill high-priority map from recent history so unread @mentions that - // arrived before app launch are correctly classified as high-priority tier. - unawaited(_backfillHighPriority(channels)); + unawaited(_catchUpUnreadEvents(channels)); _backstopTimer?.cancel(); _backstopTimer = Timer.periodic( @@ -410,83 +429,76 @@ class ChannelsNotifier extends AsyncNotifier> { ); } - Future _backfillHighPriority(List channels) async { + Future _catchUpUnreadEvents(List channels) async { final myPk = ref.read(myPubkeyProvider); if (myPk == null) return; final session = ref.read(relaySessionProvider.notifier); - final readState = ref.read(readStateProvider); + final ReadStateState readState; + try { + readState = ref.read(readStateProvider); + } catch (error) { + debugPrint('[ChannelsNotifier] unread catch-up skipped: $error'); + return; + } final futures = >[]; for (final channel in channels) { if (!channel.isMember || channel.isArchived) continue; - - // All DM messages are high-priority — no need to scan event history. - if (channel.isDm) { - final lastMsg = channel.lastMessageAt; - if (lastMsg != null) { - _latestHighPriorityByChannel[channel.id] = - lastMsg.millisecondsSinceEpoch ~/ 1000; - } - continue; - } - final readAt = readState.effectiveTimestamp(channel.id); futures.add( - _backfillHighPriorityForChannel(session, channel, myPk, readAt), + _catchUpUnreadEventsForChannel(session, channel, myPk, readAt), ); } - // Batch into groups of 5 to avoid saturating the relay. const batchSize = 5; for (var i = 0; i < futures.length; i += batchSize) { await Future.wait(futures.sublist(i, min(i + batchSize, futures.length))); } - // Trigger unreadBadgeProvider to re-evaluate now that the map is populated. state = state.whenData((channels) => List.of(channels)); } - Future _backfillHighPriorityForChannel( + Future _catchUpUnreadEventsForChannel( RelaySessionNotifier session, Channel channel, String myPk, int? readAt, ) async { - // For non-DM channels, fetch events since the last read timestamp and scan - // for high-priority ones. Using `since` avoids fetching messages the user - // has already seen, and `limit: 200` covers deep mention backfills. try { final events = await session.fetchHistory( NostrFilter( - kinds: EventKind.channelEventKinds, + kinds: EventKind.channelMessageEventKinds, tags: { '#h': [channel.id], }, - since: readAt ?? 0, - limit: 200, + since: readAt == null ? 0 : readAt + 1, + limit: _unreadCatchUpLimit, ), ); - var maxHighPriority = 0; for (final event in events) { - if (event.pubkey == myPk) continue; - if (!EventKind.channelMessageEventKinds.contains(event.kind)) continue; - if (isHighPriorityEvent(event.tags, myPk) && - event.createdAt > maxHighPriority) { - maxHighPriority = event.createdAt; + if (event.pubkey.toLowerCase() == myPk.toLowerCase()) { + _recordSelfThreadInterest(event, myPk); } } - if (maxHighPriority > 0) { - final current = _latestHighPriorityByChannel[channel.id] ?? 0; - if (maxHighPriority > current) { - _latestHighPriorityByChannel[channel.id] = maxHighPriority; + for (final event in events) { + if (event.pubkey.toLowerCase() == myPk.toLowerCase()) continue; + if (readAt != null && event.createdAt <= readAt) continue; + if (!shouldNotifyForEvent( + event, + myPk, + participatedRootIds: _participatedRootIds, + authoredRootIds: _authoredRootIds, + )) { + continue; } + _recordUnreadEvent(channel, event, myPk); } } catch (error) { debugPrint( - '[ChannelsNotifier] backfill failed for ${channel.id}: $error', + '[ChannelsNotifier] unread catch-up failed for ${channel.id}: $error', ); } } @@ -506,7 +518,18 @@ class ChannelsNotifier extends AsyncNotifier> { final updated = List.of(channels); final channel = updated[idx]; - if (myPk != null && shouldNotifyForEvent(event, myPk)) { + if (myPk != null && event.pubkey.toLowerCase() == myPk.toLowerCase()) { + _recordSelfThreadInterest(event, myPk); + } + + if (myPk != null && + shouldNotifyForEvent( + event, + myPk, + participatedRootIds: _participatedRootIds, + authoredRootIds: _authoredRootIds, + )) { + _recordUnreadEvent(channel, event, myPk); final eventTime = DateTime.fromMillisecondsSinceEpoch( event.createdAt * 1000, isUtc: true, @@ -517,19 +540,91 @@ class ChannelsNotifier extends AsyncNotifier> { } } - if (myPk != null && - event.pubkey != myPk && - (channel.isDm || isHighPriorityEvent(event.tags, myPk))) { - final current = _latestHighPriorityByChannel[channelId] ?? 0; - if (event.createdAt > current) { - _latestHighPriorityByChannel[channelId] = event.createdAt; - } - } - return updated; }); } + void _loadThreadInterestStores(String pubkey) { + final normalizedPubkey = pubkey.toLowerCase(); + if (_threadInterestPubkey == normalizedPubkey) return; + _threadInterestPubkey = normalizedPubkey; + try { + final prefs = ref.read(savedPrefsProvider); + _participatedRootIds = _readRootIdSet( + prefs.getString('$_participatedRootIdsPrefix:$normalizedPubkey'), + ); + _authoredRootIds = _readRootIdSet( + prefs.getString('$_authoredRootIdsPrefix:$normalizedPubkey'), + ); + } catch (_) { + _participatedRootIds = {}; + _authoredRootIds = {}; + } + } + + void _recordSelfThreadInterest(NostrEvent event, String pubkey) { + final ref = event.threadReference; + final target = ref.rootId != null ? _participatedRootIds : _authoredRootIds; + final id = ref.rootId ?? event.id; + if (!target.add(id)) return; + _writeThreadInterestStores(pubkey); + } + + void _writeThreadInterestStores(String pubkey) { + final normalizedPubkey = pubkey.toLowerCase(); + try { + final prefs = ref.read(savedPrefsProvider); + prefs.setString( + '$_participatedRootIdsPrefix:$normalizedPubkey', + _encodeRootIdSet(_participatedRootIds), + ); + prefs.setString( + '$_authoredRootIdsPrefix:$normalizedPubkey', + _encodeRootIdSet(_authoredRootIds), + ); + } catch (_) { + // Ignore storage failures; in-memory interest still works this session. + } + } + + void _recordUnreadEvent(Channel channel, NostrEvent event, String myPk) { + final isThreadedReply = + event.threadReference.parentId != null && !_isBroadcastReply(event); + final isHighPriority = + channel.isDm || isHighPriorityEvent(event.tags, myPk); + recordObservedUnreadEvent( + _observedUnreadEventsByChannel, + channel.id, + makeObservedUnreadEvent( + id: event.id, + createdAt: event.createdAt, + rootId: _observedUnreadRootId(event), + highPriority: isHighPriority, + channelType: channel.channelType, + isThreadedReply: isThreadedReply, + ), + _unreadCatchUpLimit, + ); + + final current = _latestObservedByChannel[channel.id] ?? 0; + if (event.createdAt > current) { + _latestObservedByChannel[channel.id] = event.createdAt; + } + } + + void clearObservedUnreadForChannel(String channelId) { + _latestObservedByChannel.remove(channelId); + _observedUnreadEventsByChannel.remove(channelId); + state = state.whenData((channels) => List.of(channels)); + } + + void clearObservedUnreadCoveredByRead(String channelId, int readAt) { + final latest = _latestObservedByChannel[channelId]; + if (latest != null && latest <= readAt) { + clearObservedUnreadForChannel(channelId); + } + } + /// Backstop refresh that preserves existing state on transient failure. Future _backstopRefresh() async { try { @@ -580,3 +675,26 @@ class ChannelsNotifier extends AsyncNotifier> { final channelsProvider = AsyncNotifierProvider>( ChannelsNotifier.new, ); + +String? _observedUnreadRootId(NostrEvent event) => + _isBroadcastReply(event) ? null : event.threadReference.rootId; + +bool _isBroadcastReply(NostrEvent event) => event.tags.any( + (tag) => tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1', +); + +Set _readRootIdSet(String? raw) { + if (raw == null || raw.isEmpty) return {}; + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return {}; + return { + for (final value in decoded) + if (value is String) value, + }; + } catch (_) { + return {}; + } +} + +String _encodeRootIdSet(Set values) => jsonEncode(values.toList()); diff --git a/mobile/lib/features/channels/read_state/read_state_format.dart b/mobile/lib/features/channels/read_state/read_state_format.dart index 74e1dc0dc44..fe99b4861d4 100644 --- a/mobile/lib/features/channels/read_state/read_state_format.dart +++ b/mobile/lib/features/channels/read_state/read_state_format.dart @@ -8,6 +8,22 @@ const readStateDTagPrefix = 'read-state:'; const readStateFetchLimit = 500; const readStateHorizonSeconds = 7 * 24 * 60 * 60; const _maxContexts = 10000; +const msgContextPrefix = 'msg:'; +const threadContextPrefix = 'thread:'; + +String msgContextKey(String messageId) => '$msgContextPrefix$messageId'; +String threadContextKey(String rootId) => '$threadContextPrefix$rootId'; + +int? maxReadAt(Iterable markers) { + int? latest; + for (final marker in markers) { + if (marker == null) continue; + if (latest == null || marker > latest) { + latest = marker; + } + } + return latest; +} typedef ReadStateDecrypt = String Function(String ciphertext); diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 320496b4320..12255f439f9 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -16,6 +17,8 @@ import '../profile/user_profile_sheet.dart'; import 'message_actions.dart'; import 'message_content.dart'; import 'reaction_row.dart'; +import 'read_state/read_state_format.dart'; +import 'read_state/read_state_provider.dart'; import 'send_message_provider.dart'; import 'small_avatar.dart'; import 'timeline_message.dart'; @@ -62,6 +65,22 @@ class ThreadDetailPage extends HookConsumerWidget { } final replies = childrenByParent[threadHead.id] ?? const []; + final readState = ref.watch(readStateProvider); + final visibleReplyReadKey = replies + .map((reply) => '${reply.id}:${reply.createdAt}') + .join(','); + + useEffect(() { + if (!readState.isReady || replies.isEmpty) return null; + WidgetsBinding.instance.addPostFrameCallback((_) { + for (final reply in replies) { + ref + .read(readStateProvider.notifier) + .markContextRead(msgContextKey(reply.id), reply.createdAt); + } + }); + return null; + }, [threadHead.id, readState.isReady, visibleReplyReadKey]); // Thread-scoped typing indicators (exclude self). final allTyping = ref.watch(channelTypingProvider(channelId)); diff --git a/mobile/lib/features/channels/unread_badge/observed_unread_event.dart b/mobile/lib/features/channels/unread_badge/observed_unread_event.dart new file mode 100644 index 00000000000..f25957ad8e8 --- /dev/null +++ b/mobile/lib/features/channels/unread_badge/observed_unread_event.dart @@ -0,0 +1,133 @@ +import '../read_state/read_state_format.dart'; + +class ObservedUnreadEvent { + final String id; + final int createdAt; + final String? rootId; + final bool highPriority; + final bool countsTowardBadge; + final bool countsTowardAppBadge; + + const ObservedUnreadEvent({ + required this.id, + required this.createdAt, + required this.rootId, + required this.highPriority, + required this.countsTowardBadge, + required this.countsTowardAppBadge, + }); +} + +ObservedUnreadEvent makeObservedUnreadEvent({ + required String id, + required int createdAt, + required String? rootId, + required bool highPriority, + required String? channelType, + required bool isThreadedReply, +}) { + final isDm = channelType == 'dm'; + return ObservedUnreadEvent( + id: id, + createdAt: createdAt, + rootId: rootId, + highPriority: highPriority, + countsTowardBadge: isDm || isThreadedReply || highPriority, + countsTowardAppBadge: isDm || (!isThreadedReply && highPriority), + ); +} + +bool recordObservedUnreadEvent( + Map> eventsByChannel, + String channelId, + ObservedUnreadEvent event, + int limit, +) { + final eventsById = eventsByChannel.putIfAbsent(channelId, () => {}); + if (eventsById.containsKey(event.id)) return false; + + eventsById[event.id] = event; + if (eventsById.length <= limit) return true; + + String? oldestId; + int? oldestCreatedAt; + for (final event in eventsById.values) { + if (oldestCreatedAt == null || event.createdAt < oldestCreatedAt) { + oldestCreatedAt = event.createdAt; + oldestId = event.id; + } + } + if (oldestId != null) { + eventsById.remove(oldestId); + } + return true; +} + +int countUnreadObservedEvents( + Map? eventsById, + int? Function(ObservedUnreadEvent event) getReadAt, +) { + if (eventsById == null) return 0; + var count = 0; + for (final event in eventsById.values) { + final readAt = getReadAt(event); + if (readAt == null || event.createdAt > readAt) count++; + } + return count; +} + +int countUnreadBadgeObservedEvents( + Map? eventsById, + int? Function(ObservedUnreadEvent event) getReadAt, +) { + if (eventsById == null) return 0; + var count = 0; + for (final event in eventsById.values) { + if (!event.countsTowardBadge) continue; + final readAt = getReadAt(event); + if (readAt == null || event.createdAt > readAt) count++; + } + return count; +} + +int countUnreadAppBadgeObservedEvents( + Map? eventsById, + int? Function(ObservedUnreadEvent event) getReadAt, +) { + if (eventsById == null) return 0; + var count = 0; + for (final event in eventsById.values) { + if (!event.countsTowardAppBadge) continue; + final readAt = getReadAt(event); + if (readAt == null || event.createdAt > readAt) count++; + } + return count; +} + +int countUnreadHighPriorityObservedEvents( + Map? eventsById, + int? Function(ObservedUnreadEvent event) getReadAt, +) { + if (eventsById == null) return 0; + var count = 0; + for (final event in eventsById.values) { + if (!event.highPriority) continue; + final readAt = getReadAt(event); + if (readAt == null || event.createdAt > readAt) count++; + } + return count; +} + +int? observedUnreadEventReadAt( + ObservedUnreadEvent event, + int? channelReadAt, + int? Function(String rootId) getThreadOwnMarker, + int? Function(String messageId) getMessageOwnMarker, +) { + final markers = [channelReadAt, getMessageOwnMarker(event.id)]; + final rootId = event.rootId; + if (rootId != null) { + markers.add(getThreadOwnMarker(rootId)); + } + return maxReadAt(markers); +} diff --git a/mobile/lib/features/channels/unread_badge/should_notify_for_event.dart b/mobile/lib/features/channels/unread_badge/should_notify_for_event.dart index 35530fbbeb6..9c1a803130d 100644 --- a/mobile/lib/features/channels/unread_badge/should_notify_for_event.dart +++ b/mobile/lib/features/channels/unread_badge/should_notify_for_event.dart @@ -1,9 +1,14 @@ import '../../../shared/relay/nostr_models.dart'; -bool shouldNotifyForEvent(NostrEvent event, String myPubkey) { +bool shouldNotifyForEvent( + NostrEvent event, + String myPubkey, { + Set participatedRootIds = const {}, + Set authoredRootIds = const {}, +}) { if (!EventKind.channelMessageEventKinds.contains(event.kind)) return false; - if (event.pubkey == myPubkey) return false; + if (event.pubkey.toLowerCase() == myPubkey.toLowerCase()) return false; final ref = event.threadReference; if (ref.parentId == null) return true; @@ -23,5 +28,8 @@ bool shouldNotifyForEvent(NostrEvent event, String myPubkey) { } } - return false; + final rootId = ref.rootId; + return rootId != null && + (participatedRootIds.contains(rootId) || + authoredRootIds.contains(rootId)); } diff --git a/mobile/lib/features/channels/unread_badge/unread_badge_provider.dart b/mobile/lib/features/channels/unread_badge/unread_badge_provider.dart index 3457d0b9d75..c967aa105d8 100644 --- a/mobile/lib/features/channels/unread_badge/unread_badge_provider.dart +++ b/mobile/lib/features/channels/unread_badge/unread_badge_provider.dart @@ -2,7 +2,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../channels_provider.dart'; import '../read_state/read_state_provider.dart'; -import '../read_state/read_state_time.dart'; +import '../read_state/read_state_format.dart'; +import 'observed_unread_event.dart'; class UnreadBadgeState { const UnreadBadgeState({ @@ -20,42 +21,58 @@ final unreadBadgeProvider = Provider((ref) { return channelsAsync.when( data: (channels) { - // Safe to ref.read the notifier here: _latestHighPriorityByChannel is - // only mutated inside _handleLiveEvent's state.whenData block, which - // always emits a new channelsProvider state — so the ref.watch above - // guarantees we re-run whenever the map changes. final notifier = ref.read(channelsProvider.notifier); - final highPriorityMap = notifier.latestHighPriorityByChannel; + final observedEventsByChannel = notifier.observedUnreadEventsByChannel; + final latestObservedByChannel = notifier.latestObservedByChannel; - int highPriority = 0; - int general = 0; + var highPriority = 0; + var general = 0; for (final channel in channels) { if (!channel.isMember || channel.isArchived) continue; - final isLocallyForced = readState.locallyForcedChannelIds.contains( - channel.id, - ); - final lastMessageAt = dateTimeToUnixSeconds(channel.lastMessageAt); - if (lastMessageAt == null && !isLocallyForced) continue; + if (readState.locallyForcedChannelIds.contains(channel.id)) { + general++; + continue; + } + + if (!latestObservedByChannel.containsKey(channel.id)) continue; - final readAt = readState.effectiveTimestamp(channel.id); - final isUnread = - isLocallyForced || - readAt == null || - (lastMessageAt != null && lastMessageAt > readAt); - if (!isUnread) continue; + final observedEvents = observedEventsByChannel[channel.id]; + final channelReadAt = readState.effectiveTimestamp(channel.id); + int? readAtForObservedEvent(ObservedUnreadEvent event) => + observedUnreadEventReadAt( + event, + channelReadAt, + (rootId) => + readState.effectiveTimestamp(threadContextKey(rootId)), + (messageId) => + readState.effectiveTimestamp(msgContextKey(messageId)), + ); + + final unreadCount = countUnreadObservedEvents( + observedEvents, + readAtForObservedEvent, + ); + if (unreadCount == 0) continue; - if (channel.isDm) { - highPriority++; + if (channel.isDm || + countUnreadHighPriorityObservedEvents( + observedEvents, + readAtForObservedEvent, + ) > + 0) { + final appBadgeCount = countUnreadAppBadgeObservedEvents( + observedEvents, + readAtForObservedEvent, + ); + highPriority += appBadgeCount > 0 ? appBadgeCount : 1; } else { - final highPriorityAt = highPriorityMap[channel.id]; - if (highPriorityAt != null && - (readAt == null || highPriorityAt > readAt)) { - highPriority++; - } else { - general++; - } + final badgeCount = countUnreadBadgeObservedEvents( + observedEvents, + readAtForObservedEvent, + ); + general += badgeCount > 0 ? badgeCount : 1; } } diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 9dab1f428b2..82d2a627f72 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -1,49 +1,276 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/theme/grid.dart'; import '../activity/activity_page.dart'; import '../channels/channels_page.dart'; -import '../pulse/pulse_page.dart'; import '../search/search_page.dart'; class HomePage extends HookConsumerWidget { const HomePage({super.key}); + static const double _tabBarHeight = 60; + static const double _tabBarRadius = _tabBarHeight / 2; + static const double _tabBarInnerInset = 5; + static const double _selectedTabRadius = + (_tabBarHeight - (_tabBarInnerInset * 2)) / 2; + static const double _tabBarBottomGap = Grid.twelve; + static const double _tabBarHorizontalMargin = Grid.sm; + static const double _fabClearance = _tabBarHeight + _tabBarBottomGap; + + static const _destinations = [ + _HomeDestination( + icon: LucideIcons.house, + selectedIcon: LucideIcons.house, + label: 'Home', + ), + _HomeDestination( + icon: LucideIcons.bell, + selectedIcon: LucideIcons.bell, + label: 'Activity', + ), + _HomeDestination( + icon: LucideIcons.search, + selectedIcon: LucideIcons.search, + label: 'Search', + ), + ]; + @override Widget build(BuildContext context, WidgetRef ref) { final tabIndex = useState(0); - const pages = [ChannelsPage(), PulsePage(), ActivityPage(), SearchPage()]; + const pages = [ChannelsPage(), ActivityPage(), SearchPage()]; return Scaffold( - body: IndexedStack(index: tabIndex.value, children: pages), - bottomNavigationBar: NavigationBar( + extendBody: true, + body: MediaQuery( + data: _mediaQueryWithFloatingTabBarClearance( + context, + HomePage._fabClearance, + ), + child: IndexedStack(index: tabIndex.value, children: pages), + ), + bottomNavigationBar: _FloatingTabBar( selectedIndex: tabIndex.value, onDestinationSelected: (i) => tabIndex.value = i, - destinations: const [ - NavigationDestination( - icon: Icon(LucideIcons.house), - selectedIcon: Icon(LucideIcons.house), - label: 'Home', - ), - NavigationDestination( - icon: Icon(LucideIcons.activity), - selectedIcon: Icon(LucideIcons.activity), - label: 'Pulse', - ), - NavigationDestination( - icon: Icon(LucideIcons.bell), - selectedIcon: Icon(LucideIcons.bell), - label: 'Activity', + destinations: _destinations, + ), + ); + } +} + +MediaQueryData _mediaQueryWithFloatingTabBarClearance( + BuildContext context, + double clearance, +) { + final mediaQuery = MediaQuery.of(context); + return mediaQuery.copyWith( + padding: mediaQuery.padding.copyWith( + bottom: mediaQuery.padding.bottom + clearance, + ), + viewPadding: mediaQuery.viewPadding.copyWith( + bottom: mediaQuery.viewPadding.bottom + clearance, + ), + ); +} + +class _HomeDestination { + final IconData icon; + final IconData selectedIcon; + final String label; + + const _HomeDestination({ + required this.icon, + required this.selectedIcon, + required this.label, + }); +} + +class _FloatingTabBar extends StatelessWidget { + final int selectedIndex; + final ValueChanged onDestinationSelected; + final List<_HomeDestination> destinations; + + const _FloatingTabBar({ + required this.selectedIndex, + required this.onDestinationSelected, + required this.destinations, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + return SafeArea( + minimum: const EdgeInsets.fromLTRB( + HomePage._tabBarHorizontalMargin, + 0, + HomePage._tabBarHorizontalMargin, + HomePage._tabBarBottomGap, + ), + child: Align( + alignment: Alignment.bottomCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 336), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(HomePage._tabBarRadius), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.18), + blurRadius: 28, + offset: const Offset(0, 12), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(HomePage._tabBarRadius), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(HomePage._tabBarRadius), + color: isDark + ? colorScheme.surfaceContainerHighest.withValues( + alpha: 0.72, + ) + : null, + border: Border.all( + color: colorScheme.outlineVariant.withValues( + alpha: isDark ? 0.20 : 0.38, + ), + ), + gradient: isDark + ? null + : LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + colorScheme.surface.withValues(alpha: 0.90), + colorScheme.surfaceContainerHighest.withValues( + alpha: 0.78, + ), + ], + ), + ), + child: Stack( + children: [ + if (!isDark) + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.center, + colors: [ + Colors.white.withValues(alpha: 0.22), + Colors.white.withValues(alpha: 0.02), + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.all( + HomePage._tabBarInnerInset, + ), + child: SizedBox( + height: + HomePage._tabBarHeight - + (HomePage._tabBarInnerInset * 2), + child: Row( + children: [ + for (var i = 0; i < destinations.length; i++) + Expanded( + child: _FloatingTabDestination( + destination: destinations[i], + selected: i == selectedIndex, + onTap: () => onDestinationSelected(i), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), ), - NavigationDestination( - icon: Icon(LucideIcons.search), - selectedIcon: Icon(LucideIcons.search), - label: 'Search', + ), + ), + ); + } +} + +class _FloatingTabDestination extends StatelessWidget { + final _HomeDestination destination; + final bool selected; + final VoidCallback onTap; + + const _FloatingTabDestination({ + required this.destination, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textStyle = Theme.of(context).textTheme.labelSmall; + final foregroundColor = selected + ? colorScheme.onPrimary + : colorScheme.onSurfaceVariant; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.quarter), + child: Material( + color: selected + ? colorScheme.primary.withValues(alpha: 0.94) + : Colors.transparent, + borderRadius: BorderRadius.circular(HomePage._selectedTabRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(HomePage._selectedTabRadius), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric( + horizontal: Grid.xxs, + vertical: Grid.xxs, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + selected ? destination.selectedIcon : destination.icon, + color: foregroundColor, + size: 20, + ), + const SizedBox(height: 1), + Text( + destination.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle?.copyWith( + color: foregroundColor, + fontSize: 10.5, + fontWeight: selected ? FontWeight.w700 : FontWeight.w600, + letterSpacing: 0.05, + ), + ), + ], + ), ), - ], + ), ), ); } diff --git a/mobile/test/features/channels/unread_badge/unread_badge_provider_test.dart b/mobile/test/features/channels/unread_badge/unread_badge_provider_test.dart index a87389c6c96..2731e95307a 100644 --- a/mobile/test/features/channels/unread_badge/unread_badge_provider_test.dart +++ b/mobile/test/features/channels/unread_badge/unread_badge_provider_test.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/read_state/read_state_provider.dart'; +import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart'; import 'package:buzz/features/channels/unread_badge/unread_badge_provider.dart'; /// Unit tests for [unreadBadgeProvider]. @@ -57,10 +58,13 @@ void main() { Set locallyForcedChannelIds = const {}, bool readStateReady = true, Map highPriorityMap = const {}, + Map>? observedEventsByChannel, }) { final notifier = _StubbedChannelsNotifier( channels: channels, - highPriorityMap: highPriorityMap, + observedEventsByChannel: + observedEventsByChannel ?? + _defaultObservedEvents(channels, highPriorityMap), ); return ProviderContainer( @@ -266,6 +270,66 @@ void main() { }, ); + test('thread marker clears only replies in that thread context', () async { + const channelId = 'ch-a'; + final container = buildContainer( + channels: [makeChannel(id: channelId, lastMessageAtSeconds: t30)], + readContexts: {'thread:root-1': t30}, + observedEventsByChannel: { + channelId: [ + _observed( + id: 'reply-1', + createdAt: t20, + rootId: 'root-1', + isThreadedReply: true, + ), + _observed( + id: 'reply-2', + createdAt: t20, + rootId: 'root-2', + isThreadedReply: true, + ), + ], + }, + ); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + final badge = container.read(unreadBadgeProvider); + expect(badge.highPriorityCount, 0); + expect(badge.generalUnreadCount, 1); + }); + + test('message marker clears only that observed message', () async { + const channelId = 'ch-a'; + final container = buildContainer( + channels: [makeChannel(id: channelId, lastMessageAtSeconds: t30)], + readContexts: {'msg:reply-1': t30}, + observedEventsByChannel: { + channelId: [ + _observed( + id: 'reply-1', + createdAt: t20, + rootId: 'root-1', + isThreadedReply: true, + ), + _observed( + id: 'reply-2', + createdAt: t20, + rootId: 'root-1', + isThreadedReply: true, + ), + ], + }, + ); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + final badge = container.read(unreadBadgeProvider); + expect(badge.highPriorityCount, 0); + expect(badge.generalUnreadCount, 1); + }); + test('channelsProvider in loading state returns (0, 0)', () { // The provider returns const UnreadBadgeState() while channels are loading. // We intentionally do NOT await the future here — the channels notifier @@ -302,7 +366,12 @@ void main() { final container = buildContainer( channels: [makeChannel(id: channelId, lastMessageAtSeconds: t30)], readContexts: {channelId: t20}, - highPriorityMap: {channelId: t10}, // mention is older than read marker + observedEventsByChannel: { + channelId: [ + _observed(id: 'mention', createdAt: t10, highPriority: true), + _observed(id: 'general', createdAt: t30), + ], + }, ); addTearDown(container.dispose); @@ -314,26 +383,77 @@ void main() { ); } +ObservedUnreadEvent _observed({ + required String id, + required int createdAt, + String? rootId, + bool highPriority = false, + bool isThreadedReply = false, + String channelType = 'stream', +}) => makeObservedUnreadEvent( + id: id, + createdAt: createdAt, + rootId: rootId, + highPriority: highPriority, + channelType: channelType, + isThreadedReply: isThreadedReply, +); + +Map> _defaultObservedEvents( + List channels, + Map highPriorityMap, +) { + return { + for (final channel in channels) + if (channel.lastMessageAt != null) + channel.id: [ + _observed( + id: '${channel.id}-latest', + createdAt: channel.lastMessageAt!.millisecondsSinceEpoch ~/ 1000, + highPriority: + channel.isDm || highPriorityMap.containsKey(channel.id), + channelType: channel.channelType, + ), + ], + }; +} + /// A [ChannelsNotifier] that immediately resolves to a canned [channels] list -/// and exposes a pre-seeded [latestHighPriorityByChannel] map. +/// and exposes pre-seeded observed unread events. /// /// Extends [ChannelsNotifier] so [ref.read(channelsProvider.notifier)] returns -/// an instance whose [latestHighPriorityByChannel] getter works correctly. +/// an instance whose observed-event getters work correctly. class _StubbedChannelsNotifier extends ChannelsNotifier { _StubbedChannelsNotifier({ required List channels, - Map highPriorityMap = const {}, + Map> observedEventsByChannel = const {}, }) : _channels = channels, - _highPriorityMap = Map.unmodifiable(highPriorityMap); + _observedEventsByChannel = + Map>.unmodifiable({ + for (final entry in observedEventsByChannel.entries) + entry.key: Map.unmodifiable({ + for (final event in entry.value) event.id: event, + }), + }); final List _channels; - final Map _highPriorityMap; + final Map> _observedEventsByChannel; @override Future> build() async => _channels; @override - Map get latestHighPriorityByChannel => _highPriorityMap; + Map get latestObservedByChannel => { + for (final entry in _observedEventsByChannel.entries) + if (entry.value.isNotEmpty) + entry.key: entry.value.values + .map((event) => event.createdAt) + .reduce((left, right) => left > right ? left : right), + }; + + @override + Map> + get observedUnreadEventsByChannel => _observedEventsByChannel; } /// A [ChannelsNotifier] that stays in the loading state indefinitely. @@ -342,7 +462,11 @@ class _LoadingChannelsNotifier extends ChannelsNotifier { Future> build() => Completer>().future; @override - Map get latestHighPriorityByChannel => const {}; + Map get latestObservedByChannel => const {}; + + @override + Map> + get observedUnreadEventsByChannel => const {}; } /// A [ReadStateNotifier] that returns a fixed [ReadStateState]. From 07b825b04426375f491e8adb1b9231c88829274d Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 25 Jun 2026 18:27:47 -0600 Subject: [PATCH 2/2] chore(scripts): guard PR screenshot URLs Add a PR markdown checker for Buzz relay media URLs and run it from the screenshot posting helper when a body template is supplied. Document the mobile simulator screenshot path so PR images are hosted before being linked. Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- AGENTS.md | 11 ++++-- .../src/managed_agents/screenshot_skill.md | 6 +++ scripts/check-pr-image-urls.sh | 39 +++++++++++++++++++ scripts/post-screenshots.sh | 2 + 4 files changed, 55 insertions(+), 3 deletions(-) create mode 100755 scripts/check-pr-image-urls.sh diff --git a/AGENTS.md b/AGENTS.md index 54de0960228..4f8d1cabc67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -218,12 +218,17 @@ Desktop E2E: `cd desktop && pnpm exec playwright test` See [TESTING.md](TESTING.md) for the full multi-agent E2E guide. -### Desktop Screenshots (Playwright) +### PR Screenshots > **Do NOT use `buzz upload`, the relay media endpoint, or any third-party > image host for PR screenshots.** Relay media URLs fail through GitHub's camo -> proxy. Always use `scripts/post-screenshots.sh` — see the `desktop-screenshot` -> skill for the full workflow. +> proxy. Always use `scripts/post-screenshots.sh` for PNGs before linking them +> from a PR body/comment. If you hand-edit PR markdown, run +> `scripts/check-pr-image-urls.sh ` first to catch relay URLs. + +For mobile simulator screenshots, save the PNGs in a local directory and run +`./scripts/post-screenshots.sh ` or use the third argument +with a markdown template containing `{{filename}}` placeholders. The desktop app requires the E2E mock bridge to render — it cannot run in a plain browser. Use `just desktop-screenshot` to capture screenshots (builds frontend, diff --git a/desktop/src-tauri/src/managed_agents/screenshot_skill.md b/desktop/src-tauri/src/managed_agents/screenshot_skill.md index b822d76a275..361e747fe28 100644 --- a/desktop/src-tauri/src/managed_agents/screenshot_skill.md +++ b/desktop/src-tauri/src/managed_agents/screenshot_skill.md @@ -16,6 +16,12 @@ unreliable and may expose content. **ALWAYS use `scripts/post-screenshots.sh`** — it hosts PNGs on a per-developer git branch with immutable commit-SHA URLs that render correctly on GitHub. +If you manually compose or edit PR markdown, run +`scripts/check-pr-image-urls.sh ` before posting. The checker +fails on Buzz/relay media URLs so broken images are caught locally. + +This hosting rule applies to any PNG you want in a PR, including mobile +simulator screenshots captured outside the desktop Playwright helper. ## Step 1 — Capture Screenshots diff --git a/scripts/check-pr-image-urls.sh b/scripts/check-pr-image-urls.sh new file mode 100755 index 00000000000..3f6736e67ab --- /dev/null +++ b/scripts/check-pr-image-urls.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +MARKDOWN_FILE="$1" + +if [[ ! -f "$MARKDOWN_FILE" ]]; then + echo "error: markdown file not found: $MARKDOWN_FILE" >&2 + exit 1 +fi + +# Buzz/relay media URLs often render in Buzz but fail in GitHub PR markdown +# because GitHub's Camo proxy fetches them anonymously. PR screenshots should +# be hosted through scripts/post-screenshots.sh or another GitHub-safe host. +relay_media_pattern='https?://[^][()<>[:space:]"'"'"']*/media/[0-9a-fA-F]{64}\.(png|jpe?g|webp|gif)' +sprout_media_pattern='https?://sprout-oss[^][()<>[:space:]"'"'"']*/media/' + +tmp_matches=$(mktemp) +trap 'rm -f "$tmp_matches"' EXIT + +if grep -nE "$relay_media_pattern" "$MARKDOWN_FILE" >>"$tmp_matches"; then + : +fi +if grep -nE "$sprout_media_pattern" "$MARKDOWN_FILE" >>"$tmp_matches"; then + : +fi + +if [[ -s "$tmp_matches" ]]; then + matches=$(sort -u "$tmp_matches") + echo "error: PR markdown contains Buzz/relay media URLs that may not render on GitHub:" >&2 + printf '%s\n' "$matches" >&2 + echo >&2 + echo "Upload screenshots with scripts/post-screenshots.sh, then use its GitHub-safe image URLs in the PR body/comment." >&2 + exit 1 +fi diff --git a/scripts/post-screenshots.sh b/scripts/post-screenshots.sh index 20517ce8d25..c34c496b7a6 100755 --- a/scripts/post-screenshots.sh +++ b/scripts/post-screenshots.sh @@ -66,6 +66,8 @@ for i in "${!PNGS[@]}"; do done if [[ -n "$BODY_FILE" ]]; then + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + "$SCRIPT_DIR/check-pr-image-urls.sh" "$BODY_FILE" COMMENT_BODY="$(cat "$BODY_FILE")" UNREFERENCED=() for NAME in "${!IMAGE_URL_MAP[@]}"; do