mirror of
https://github.com/block/buzz.git
synced 2026-11-12 04:12:21 +00:00
## 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 | | --- | --- | |  |  | ### 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>
93 lines
3.3 KiB
Dart
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);
|
|
}
|