Files
buzz/mobile/lib/features/activity/inbox_read_state.dart
klopez4212 49511f02b6 fix(mobile): show observed thread replies in Activity (#9286)
## Summary

A reply in a thread you started, participated in, or followed can light
a mobile channel's attention dot without appearing in Activity. Activity
now includes eligible replies already observed by the channel unread
pipeline in **All** and **Threads**. Tapping a row uses the existing
oldest-unread reply and thread navigation.

Retain the reply payload alongside its bounded unread evidence, then
merge by event ID with the addressed feed. Mentions retain their
category and do not duplicate. Deletion, eviction, and account/community
resets share the same registry. This adds no relay queries or
subscriptions.

Coverage follows the existing channel unread catch-up and live
subscriptions. This does not introduce complete historical thread
discovery: replies outside that retained window, and replies not
eligible for the channel attention dot, are not newly fetched.

### Related issue

Related to #9271 (clearing channel rows) and closed #9186 (broader
Activity feed expansion). #9213 supplied the existing Activity filters.
This PR connects existing observed replies to those filters.

### Testing

- Full mobile suite, push-unconfigured tests, analysis, formatting and
file-size checks.
- Production-provider regression: cold catch-up, live reply, mention
deduplication, excluded unrelated/quiet replies, thread read markers,
deletion/replay and identity reset.
- Real Activity widget journey with fake relay/storage boundaries: show
the unmentioned reply, select Threads, tap the row, and verify the reply
ID and thread ID passed to the conversation page.
- Mutation check: removing reply-payload retention makes the widget
journey fail because the reply is absent.
- Independent agent source review: no blockers.

### Preview

Actual Flutter widgets at phone width with synthetic data and the new
reply wiring disabled/enabled. These are widget-test captures, not
installed-device or live-relay evidence.

| Before | After |
| --- | --- |
| ![Activity omits the unmentioned
reply](https://github.com/user-attachments/assets/a6fc8193-b1ef-4022-a5f1-a1cbbcdf5fb2)
| ![Activity shows the unmentioned thread
reply](https://github.com/user-attachments/assets/b2a6ba13-384a-4d0f-bda3-88d83307d31e)
|

### Human validation pending

Draft per the repository readiness checklist. On a device, receive an
unmentioned reply in a thread you started, replied in, or followed.
Check Activity → All and Threads, tap the row to reach the reply, then
read it and confirm Unread clears without reading unrelated channel
messages. Native/live-relay testing and human confirmation remain
pending.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 11:48:08 -08:00

93 lines
3.3 KiB
Dart

import '../../shared/read_state/read_state_format.dart';
import 'feed_item.dart';
import 'inbox_item.dart';
/// The newest read marker that reads one grouped [event], following the
/// unread badge's `observedUnreadEventReadAt`: the channel mark, the
/// event's own `msg:` mark, and for a thread reply `thread:<root>` and
/// `thread-activity:<root>`. Returns null for an event with no channel.
///
/// `activity:<channel>` never counts here. Activity rows hold only mentions,
/// needs-action and agent events addressed to the reader, eligible thread
/// replies, and DM messages. That catch-up mark reads none of those.
int? inboxEventReadAt(
FeedItem event, {
required int? Function(String contextId) markerOf,
}) {
final channelId = event.channelId;
if (channelId == null) return null;
final rootId = isThreadReply(event.tags)
? threadReferenceOf(event.tags).rootId
: null;
return maxReadAt([
markerOf(channelId),
markerOf(msgContextKey(event.id)),
if (rootId != null) ...[
markerOf(threadContextKey(rootId)),
markerOf(threadActivityContextKey(rootId)),
],
]);
}
/// Whether the marks read one grouped [event].
bool isInboxEventRead(
FeedItem event, {
required int? Function(String contextId) markerOf,
}) {
final readAt = inboxEventReadAt(event, markerOf: markerOf);
return readAt != null && event.createdAt <= readAt;
}
/// Whether the row is read ("done"): a local unread override always wins;
/// otherwise the row is done when the marks read every grouped event. Each
/// event is checked on its own, because reading a channel marks mentions
/// with per-message marks, so reading the newest says nothing about the
/// rest. Channel-less rows fall back to the local done set.
bool isInboxItemDone(
InboxItem item, {
required int? Function(String contextId) markerOf,
required Set<String> localUnreadOverrides,
required Set<String> localDoneSet,
}) {
final ids = groupedInboxItemIds(item);
if (ids.any(localUnreadOverrides.contains)) return false;
if (item.item.channelId == null) return localDoneSet.contains(item.id);
return _groupedEvents(
item,
).every((event) => isInboxEventRead(event, markerOf: markerOf));
}
Iterable<FeedItem> _groupedEvents(InboxItem item) => {
for (final event in [item.item, ...item.groupItems]) event.id: event,
}.values;
/// All event ids identified with the row — desktop's `getGroupedInboxItemIds`.
List<String> groupedInboxItemIds(InboxItem item) {
return {item.id, item.item.id, ...item.groupItems.map((i) => i.id)}.toList();
}
/// The channel-level timestamp that marking a thread row read should also
/// advance — desktop's `getGroupedChannelReadTimestamp`. Only top-level
/// (non-thread-reply) grouped events count, so marking a thread read cannot
/// swallow unrelated channel messages.
({String channelId, int timestamp})? groupedChannelReadTimestamp(
InboxItem item,
) {
final channelId = item.item.channelId;
if (channelId == null) return null;
int? timestamp;
for (final groupItem in item.groupItems) {
if (groupItem.channelId != channelId || isThreadReply(groupItem.tags)) {
continue;
}
if (timestamp == null || groupItem.createdAt > timestamp) {
timestamp = groupItem.createdAt;
}
}
return timestamp == null
? null
: (channelId: channelId, timestamp: timestamp);
}