diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index afbea12ab7..b0f8225caa 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -1,7 +1,7 @@ import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'dart:async'; -import 'dart:math' show max; +import 'dart:math' show max, min; import '../../shared/widgets/ios_message_create_button.dart'; import '../../shared/widgets/ios_glass_navigation_button.dart'; @@ -213,18 +213,52 @@ class ActivityPage extends HookConsumerWidget { return null; }, [preloadPubkeysKey]); + // A filtered row holds only part of its conversation. Advance its synced + // thread or channel marker only up to the first unread event the filter + // hides, and give shown events past that point local per-message marks. + void markPartialRowRead( + InboxItem item, + String channelId, { + required bool isDmRow, + }) { + final notifier = ref.read(readStateProvider.notifier); + final shown = { + for (final e in [item.item, ...item.groupItems]) e.id: e, + }; + final rootId = isDmRow ? null : item.threadRootId; + bool inContext(FeedItem e) => + e.channelId == channelId && + (rootId == null || + (isThreadReply(e.tags) && + threadReferenceOf(e.tags).rootId == rootId)); + var cap = item.latestActivityAt; + for (final e in allItems.expand((i) => i.groupItems)) { + if (shown.containsKey(e.id) || + !inContext(e) || + isInboxEventRead(e, markerOf: markerOf)) { + continue; + } + cap = min(cap, e.createdAt - 1); + } + notifier.markContextRead( + rootId == null ? channelId : threadContextKey(rootId), + cap, + ); + for (final e in shown.values) { + if (inContext(e) && e.createdAt <= cap) continue; + notifier.markContextRead(msgContextKey(e.id), e.createdAt); + } + } + void markItemRead(InboxItem item) { final notifier = ref.read(readStateProvider.notifier); ref .read(inboxLocalStateProvider.notifier) .clearUnread(groupedInboxItemIds(item)); - // A filtered row holds only part of its conversation, so channel and - // thread markers would also read events this view hides. final isDmRow = item.conversationId.startsWith('dm:'); - if (rowsArePartial && item.item.channelId != null) { - for (final e in {item.item, ...item.groupItems}) { - notifier.markContextRead(msgContextKey(e.id), e.createdAt); - } + final partialChannelId = rowsArePartial ? item.item.channelId : null; + if (partialChannelId != null) { + markPartialRowRead(item, partialChannelId, isDmRow: isDmRow); return; } // A DM row can group replies under several thread roots; its channel diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index eb4711f912..e1976ed0cb 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -18,6 +18,7 @@ import 'package:buzz/features/channels/message_content.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/shared/identity_names/identity_names.dart'; import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/read_state/read_state_format.dart'; import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; @@ -930,6 +931,21 @@ void main() { await chooseSender('Anyone'); expect(find.byKey(const ValueKey('inbox-unread-dot-p1')), findsOneWidget); + int? threadMarker() => ProviderScope.containerOf( + tester.element(find.byType(ActivityPage)), + ).read(readStateProvider).contexts[threadContextKey(threadIdRoot1)]; + // The synced marker stops short of the hidden agent reply. + expect(threadMarker(), now - 301); + + await chooseSender('Agents'); + await tester.drag( + find.byKey(const ValueKey('inbox-row-g1')), + const Offset(-500, 0), + ); + await tester.pumpAndSettle(); + expect(threadMarker(), now - 300); + await chooseSender('Anyone'); + expect(find.byKey(const ValueKey('inbox-unread-dot-p1')), findsNothing); }, );