From d17f79acfb7cd71a98aedc78fe20c0677988979d Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 6 Nov 2026 12:53:46 -0800 Subject: [PATCH] fix(mobile): keep filtered Activity reads synced msg: marks are local-only, so a filtered row marked read stayed unread on other devices. Advance the synced thread or channel marker up to just before the first unread event the filter hides, and fall back to local msg: marks only for shown events past that point. Co-authored-by: studio Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tom Brow --- .../lib/features/activity/activity_page.dart | 48 ++++++++++++++++--- .../features/activity/activity_page_test.dart | 16 +++++++ 2 files changed, 57 insertions(+), 7 deletions(-) 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); }, );