fix(mobile): let the synced marker catch up after filtered reads

Advance a filtered row's thread or channel marker through every event
that is now read, not just up to the row's own latest event, so replies
read under one filter sync once the hidden ones are read under another.

Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tom Brow <tomb@squareup.com>
This commit is contained in:
Tom Brow
2026-11-06 13:06:19 -08:00
parent d17f79acfb
commit 28b927e93a
2 changed files with 29 additions and 17 deletions

View File

@@ -1,7 +1,7 @@
import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'package:buzz/shared/widgets/feedback_snackbar.dart';
import 'dart:async'; import 'dart:async';
import 'dart:math' show max, min; import 'dart:math' show max;
import '../../shared/widgets/ios_message_create_button.dart'; import '../../shared/widgets/ios_message_create_button.dart';
import '../../shared/widgets/ios_glass_navigation_button.dart'; import '../../shared/widgets/ios_glass_navigation_button.dart';
@@ -213,9 +213,11 @@ class ActivityPage extends HookConsumerWidget {
return null; return null;
}, [preloadPubkeysKey]); }, [preloadPubkeysKey]);
// A filtered row holds only part of its conversation. Advance its synced // A filtered row holds only part of its conversation. Its synced thread
// thread or channel marker only up to the first unread event the filter // or channel marker is one watermark, so advance it through every event
// hides, and give shown events past that point local per-message marks. // that is now read (shown here, or already read) and stop just before the
// first unread event the filter hides. Shown events past that point get
// local per-message marks until the hidden one is read too.
void markPartialRowRead( void markPartialRowRead(
InboxItem item, InboxItem item,
String channelId, { String channelId, {
@@ -231,21 +233,30 @@ class ActivityPage extends HookConsumerWidget {
(rootId == null || (rootId == null ||
(isThreadReply(e.tags) && (isThreadReply(e.tags) &&
threadReferenceOf(e.tags).rootId == rootId)); threadReferenceOf(e.tags).rootId == rootId));
var cap = item.latestActivityAt; final events = {
for (final e in allItems.expand((i) => i.groupItems)) { for (final e in [
if (shown.containsKey(e.id) || ...allItems.expand((i) => i.groupItems),
!inContext(e) || ...shown.values,
isInboxEventRead(e, markerOf: markerOf)) { ])
continue; if (inContext(e)) e.id: e,
}.values.toList()..sort((a, b) => a.createdAt.compareTo(b.createdAt));
int? cap;
for (final e in events) {
if (!shown.containsKey(e.id) &&
!isInboxEventRead(e, markerOf: markerOf)) {
cap = e.createdAt - 1;
break;
} }
cap = min(cap, e.createdAt - 1); cap = e.createdAt;
}
if (cap != null) {
notifier.markContextRead(
rootId == null ? channelId : threadContextKey(rootId),
cap,
);
} }
notifier.markContextRead(
rootId == null ? channelId : threadContextKey(rootId),
cap,
);
for (final e in shown.values) { for (final e in shown.values) {
if (inContext(e) && e.createdAt <= cap) continue; if (inContext(e) && cap != null && e.createdAt <= cap) continue;
notifier.markContextRead(msgContextKey(e.id), e.createdAt); notifier.markContextRead(msgContextKey(e.id), e.createdAt);
} }
} }

View File

@@ -943,7 +943,8 @@ void main() {
const Offset(-500, 0), const Offset(-500, 0),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(threadMarker(), now - 300); // With the agent reply read too, the synced marker covers both.
expect(threadMarker(), now - 200);
await chooseSender('Anyone'); await chooseSender('Anyone');
expect(find.byKey(const ValueKey('inbox-unread-dot-p1')), findsNothing); expect(find.byKey(const ValueKey('inbox-unread-dot-p1')), findsNothing);
}, },