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>
236 lines
7.0 KiB
Dart
236 lines
7.0 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
|
|
import '../../shared/relay/thread_reference.dart';
|
|
import 'feed_item.dart';
|
|
|
|
/// Inbox filters, mirroring desktop's `InboxFilter` in
|
|
/// `desktop/src/features/home/lib/inbox.ts`.
|
|
enum InboxFilter {
|
|
all,
|
|
mention,
|
|
dm,
|
|
thread,
|
|
needsAction,
|
|
activity,
|
|
agentActivity,
|
|
reminders,
|
|
drafts,
|
|
}
|
|
|
|
/// Category ordering used to pick the label for a grouped conversation.
|
|
/// Mirrors desktop's `categoryPriority`.
|
|
int categoryPriority(String category) {
|
|
return switch (category) {
|
|
'needs_action' => 0,
|
|
'mention' => 1,
|
|
'agent_activity' => 2,
|
|
_ => 3,
|
|
};
|
|
}
|
|
|
|
/// Validated, lowercase thread IDs shared with timeline navigation.
|
|
({String? parentId, String? rootId}) threadReferenceOf(
|
|
List<List<String>> tags,
|
|
) => parseThreadReference(tags);
|
|
|
|
/// Broadcast replies surface at the channel top level — desktop's
|
|
/// `isBroadcastReply`.
|
|
bool isBroadcastReply(List<List<String>> tags) {
|
|
return tags.any(
|
|
(tag) => tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1',
|
|
);
|
|
}
|
|
|
|
/// A thread reply that is not broadcast — desktop's `isThreadReply`.
|
|
bool isThreadReply(List<List<String>> tags) {
|
|
return threadReferenceOf(tags).parentId != null && !isBroadcastReply(tags);
|
|
}
|
|
|
|
/// Stable conversation identity: `rootId ?? parentId ?? event.id`, except
|
|
/// ordinary (non-thread) DM messages, which group by DM channel identity so
|
|
/// one DM conversation renders as one row. Mirrors desktop's
|
|
/// `getInboxConversationId`.
|
|
String inboxConversationId(
|
|
List<List<String>> tags,
|
|
String eventId, {
|
|
String? dmChannelId,
|
|
}) {
|
|
final thread = threadReferenceOf(tags);
|
|
final threadId = thread.rootId ?? thread.parentId;
|
|
if (dmChannelId != null) return 'dm:$dmChannelId';
|
|
if (threadId != null) return threadId;
|
|
return eventId;
|
|
}
|
|
|
|
/// One conversation-grouped inbox row. Mirrors desktop's `InboxItem`.
|
|
@immutable
|
|
class InboxItem {
|
|
/// Stable conversation identity for the thread group.
|
|
final String conversationId;
|
|
|
|
/// Representative (latest) event id.
|
|
final String id;
|
|
|
|
/// Representative (latest) feed item.
|
|
final FeedItem item;
|
|
|
|
/// All events grouped into this conversation, unordered.
|
|
final List<FeedItem> groupItems;
|
|
|
|
/// Distinct categories present in the group, priority-sorted.
|
|
final List<String> categories;
|
|
|
|
final bool isActionRequired;
|
|
final int latestActivityAt;
|
|
|
|
const InboxItem({
|
|
required this.conversationId,
|
|
required this.id,
|
|
required this.item,
|
|
required this.groupItems,
|
|
required this.categories,
|
|
required this.isActionRequired,
|
|
required this.latestActivityAt,
|
|
});
|
|
|
|
/// Root id if any grouped event carries thread-reply tags.
|
|
String? get threadRootId {
|
|
for (final candidate in [item, ...groupItems]) {
|
|
if (isThreadReply(candidate.tags)) {
|
|
return threadReferenceOf(candidate.tags).rootId;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// The event the row should deep-link to: the oldest grouped event that
|
|
/// [isRead] rejects (oldest unread), falling back to the latest event.
|
|
FeedItem deepLinkTarget(bool Function(FeedItem event) isRead) {
|
|
FeedItem? oldestUnread;
|
|
for (final candidate in groupItems) {
|
|
if (isRead(candidate)) continue;
|
|
if (oldestUnread == null ||
|
|
candidate.createdAt < oldestUnread.createdAt) {
|
|
oldestUnread = candidate;
|
|
}
|
|
}
|
|
if (oldestUnread != null) return oldestUnread;
|
|
return item;
|
|
}
|
|
}
|
|
|
|
/// Contextual row label — desktop's `getInboxTypeLabel`:
|
|
/// "DM from X" / "Mentioned in" / "Needs action in" / "Thread in" / "... in".
|
|
({String text, String? channelLabel}) inboxTypeLabel(
|
|
InboxItem item, {
|
|
required String? channelName,
|
|
required bool isDm,
|
|
required String senderLabel,
|
|
}) {
|
|
if (isDm) {
|
|
return (
|
|
text: senderLabel.isNotEmpty ? 'DM from $senderLabel' : 'DM',
|
|
channelLabel: null,
|
|
);
|
|
}
|
|
|
|
// Label the event the row displays. A group can hold an older mention and
|
|
// a newer plain reply; the row shows the reply, so it must not say
|
|
// "Mentioned in". [InboxItem.categories] still drives filtering.
|
|
final category = item.item.category;
|
|
if (category == 'mention') {
|
|
return (
|
|
text: channelName != null ? 'Mentioned in' : 'Mentioned',
|
|
channelLabel: channelName,
|
|
);
|
|
}
|
|
if (category == 'needs_action') {
|
|
return (
|
|
text: channelName != null ? 'Needs action in' : 'Needs action',
|
|
channelLabel: channelName,
|
|
);
|
|
}
|
|
if (item.threadRootId != null) {
|
|
return (
|
|
text: channelName != null ? 'Thread in' : 'Thread',
|
|
channelLabel: channelName,
|
|
);
|
|
}
|
|
|
|
final headline = item.item.headline;
|
|
return (
|
|
text: channelName != null ? '$headline in' : headline,
|
|
channelLabel: channelName,
|
|
);
|
|
}
|
|
|
|
/// Whether [item] matches [filter] — desktop's `matchesInboxFilter`.
|
|
/// `reminders` and `drafts` are separate surfaces and never match here.
|
|
bool matchesInboxFilter(InboxItem item, InboxFilter filter) {
|
|
return switch (filter) {
|
|
InboxFilter.all => true,
|
|
InboxFilter.dm => item.conversationId.startsWith('dm:'),
|
|
InboxFilter.thread => [
|
|
item.item,
|
|
...item.groupItems,
|
|
].any((i) => isThreadReply(i.tags)),
|
|
InboxFilter.mention => item.categories.contains('mention'),
|
|
InboxFilter.needsAction => item.categories.contains('needs_action'),
|
|
InboxFilter.activity => item.categories.contains('activity'),
|
|
InboxFilter.agentActivity => item.categories.contains('agent_activity'),
|
|
InboxFilter.reminders || InboxFilter.drafts => false,
|
|
};
|
|
}
|
|
|
|
/// Group raw feed items into conversation rows sorted by latest activity.
|
|
/// [isDmChannel] identifies DM channels so their ordinary top-level messages
|
|
/// collapse into one row per DM conversation. Mirrors desktop's
|
|
/// `buildInboxItems`.
|
|
List<InboxItem> buildInboxItems(
|
|
Iterable<FeedItem> feedItems, {
|
|
bool Function(String channelId)? isDmChannel,
|
|
}) {
|
|
final groups = <String, List<FeedItem>>{};
|
|
for (final item in feedItems) {
|
|
final channelId = item.channelId;
|
|
final dmChannelId =
|
|
channelId != null && (isDmChannel?.call(channelId) ?? false)
|
|
? channelId
|
|
: null;
|
|
final key = inboxConversationId(
|
|
item.tags,
|
|
item.id,
|
|
dmChannelId: dmChannelId,
|
|
);
|
|
groups.putIfAbsent(key, () => []).add(item);
|
|
}
|
|
|
|
final rows = <InboxItem>[];
|
|
for (final entry in groups.entries) {
|
|
final items = entry.value;
|
|
var latest = items.first;
|
|
var latestActivityAt = 0;
|
|
for (final item in items) {
|
|
if (item.createdAt > latest.createdAt) latest = item;
|
|
if (item.createdAt > latestActivityAt) latestActivityAt = item.createdAt;
|
|
}
|
|
final categories = items.map((i) => i.category).toSet().toList()
|
|
..sort((a, b) => categoryPriority(a).compareTo(categoryPriority(b)));
|
|
|
|
rows.add(
|
|
InboxItem(
|
|
conversationId: entry.key,
|
|
id: latest.id,
|
|
item: latest,
|
|
groupItems: List.unmodifiable(items),
|
|
categories: List.unmodifiable(categories),
|
|
isActionRequired: categories.contains('needs_action'),
|
|
latestActivityAt: latestActivityAt,
|
|
),
|
|
);
|
|
}
|
|
|
|
rows.sort((a, b) => b.latestActivityAt.compareTo(a.latestActivityAt));
|
|
return rows;
|
|
}
|