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 <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 12:53:46 -08:00
parent bee88f70a3
commit d17f79acfb
2 changed files with 57 additions and 7 deletions

View File

@@ -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

View File

@@ -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);
},
);