Files
buzz/mobile/lib/features/activity/inbox_item.dart
9d36778c37 feat(mobile): refactor Activity behavior and ui (#2889)
**Category:** improvement
**User Impact:** Mobile users can scan Activity as a focused
conversation inbox and open the exact unread message or thread
represented by each item.

## Context

Mobile's Activity tab had not kept pace with Desktop: it presented
isolated event headlines, advertised categories that were often empty,
and opened a channel without clearly landing on the selected item.

This PR brings the Mobile surface toward the conversation-oriented
direction explored in Clay Delk's Desktop [Inbox refactor PR
#2045](https://github.com/block/buzz/pull/2045), while adapting it to
Mobile rather than copying the Desktop split-pane implementation. The
related product/UX discussion is captured in the originating [Buzz
thread](buzz://message?channel=a9bbc0e5-d25d-4740-849c-93c34bb578a4&id=a7d9a4d33dcd8c6bf0dc67d81c328892b9e38dedaa8548920224ef388301b6ab).

## UX decisions in this PR

- **Conversation-oriented, not event-oriented:** related updates
collapse into one row per thread/DM conversation, represented by the
latest update and ordered by latest activity. Separate top-level
conversations in the same channel remain separate rows.
- **Resume at the oldest unread:** tapping a grouped row opens the
represented canonical message/thread/DM at its oldest unread item,
rather than merely opening the channel at an arbitrary position.
- **Desktop-aligned row hierarchy:** rows lead with a full avatar and
sender, followed by contextual location/type metadata, unread dot +
time, and a two-line preview. A **New** boundary separates unread and
read content.
- **Mobile-native navigation:** Mobile keeps a single-column `Activity →
canonical conversation → Back` flow. It does not introduce Desktop's
persistent detail pane.
- **Compact filtering:** the old horizontal chip rail becomes a compact
filter menu so the source set fits a phone viewport without horizontal
scanning. Filters are All, Mentions, Threads, Needs Action, Activity,
Agents, Reminders, and Drafts.
- **Focused source semantics:** All covers personally relevant work—DMs,
mentions, thread replies, needs-action events, owned-agent activity, due
reminders, and active drafts—rather than becoming a generic stream of
every channel message. Mobile's standalone Activity source is currently
limited to DM traffic because it does not have Desktop's aggregated
channel-activity feed.
- **Shared read behavior:** rows project canonical
channel/thread/message markers, support unread-only and mark-all-read,
and use local overrides only where canonical markers cannot represent an
item.
- **Reminders and drafts are real data:** reminders use the same
encrypted NIP-ER events as Desktop. Drafts persist device-local composer
state, restore on return, survive failed sends, and clear after
successful sends.
- **Explain navigation failures:** an unavailable destination produces
an explanatory message rather than silently doing nothing or falling
back to an unrelated channel position.

## Implementation summary

- Adds a Mobile inbox model for conversation grouping, category
priority, contextual labels, sorting, filtering, and oldest-unread
targets.
- Expands relay-backed sources for mentions, approvals, owned-agent
lifecycle events, and DM traffic.
- Adds fail-closed NIP-ER reminder decryption and device-local
compose-draft persistence.
- Redesigns Activity rows, boundaries, filters, unread controls, and
empty/loading states.
- Routes rows through Mobile's existing canonical channel/thread screens
with precise target IDs.
- Adds model, provider, widget, reminder, read-state, draft-lifecycle,
and deep-link coverage.

## Reproduction steps

1. Run Mobile and open **Activity**.
2. Confirm full avatars, sender-first rows, context labels, unread
indicators, timestamps, two-line previews, and the compact filter
control.
3. Open the filter menu and verify All, Mentions, Threads, Needs Action,
Activity, Agents, Reminders, and Drafts.
4. Tap a grouped thread row and confirm the canonical conversation opens
at its oldest unread message.
5. Mark rows read/unread, enable unread-only mode, and use
mark-all-read; confirm state agrees with the channel/thread destination.
6. Type without sending in a channel or thread, leave, and confirm the
draft appears in Activity and restores in the composer.

## Screenshots

| Before — merge-base `dd222a509` | After — PR head `52ad40aee` |
|---|---|
| <img width="1206" height="2622" alt="image"
src="https://github.com/user-attachments/assets/ae961b08-bf8a-4bd5-b487-f6321ae8d85b"
/> | <img width="1206" height="2622" alt="image"
src="https://github.com/user-attachments/assets/bcbe7ab0-552a-417c-9e85-7a85eb4592ee"
/> |

Recaptured on the same authenticated iPhone 17 simulator, account,
theme, and Activity view, at this PR's current merge-base (`dd222a509`)
and head (`52ad40aee`). Both frames were taken within a few minutes on
the same live feed, so the visible conversation set overlaps closely
(the recent Ned/Bart/Tommy items appear in both). The compared change is
the row *structure*: Before leads with an `@ Mention` headline over a
small inline avatar and a horizontal chip rail; After leads with a full
avatar, a compact `labelMedium` sender label, contextual "Mentioned in"
metadata, and a filter menu. The sender username now renders at the same
compact scale the old `@ Mention` label used.

## Verification

- Current rebased head: `5bd87f4f4` on `origin/main` at `dd222a509`;
GitHub reports the PR mergeable.
- `flutter analyze` — clean at `5bd87f4f4`.
- Full Mobile suite — 698 passed, 1 skipped, 4 failed; all four failures
reproduce identically on clean `origin/main` (`channels_page_test`
create-channel sheet and three `compose_bar_test` agent-mention cases).
- The prior PR-specific `home_page_test` failures were fixed by
providing the Activity local-state dependency in that harness.
- Independent code and simulator UI review — approved.
- Post-rebase GitHub checks are running.

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@buzz.block.builderlab.xyz>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Co-authored-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@buzz.block.builderlab.xyz>
2026-07-27 16:55:24 +00:00

285 lines
8.2 KiB
Dart

import 'package:flutter/foundation.dart';
import 'feed_item.dart';
/// Inbox filters, mirroring desktop's `InboxFilter` in
/// `desktop/src/features/home/lib/inbox.ts`.
enum InboxFilter {
all,
mention,
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,
};
}
/// Thread reference from NIP-10 tags — desktop's `getThreadReference`.
({String? parentId, String? rootId}) threadReferenceOf(
List<List<String>> tags,
) {
List<String>? rootTag;
List<String>? replyTag;
for (final tag in tags) {
if (tag.length >= 4 && tag[0] == 'e') {
if (tag[3] == 'root') rootTag = tag;
if (tag[3] == 'reply') replyTag = tag;
}
}
if (replyTag == null) return (parentId: null, rootId: null);
final parentId = replyTag[1];
return (parentId: parentId, rootId: rootTag?[1] ?? parentId);
}
/// 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 (threadId != null) return threadId;
if (dmChannelId != null) return 'dm:$dmChannelId';
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 event in the group
/// newer than [readAt] (oldest unread), falling back to the latest event.
FeedItem deepLinkTarget(int? readAt) {
if (readAt != null) {
FeedItem? oldestUnread;
for (final candidate in groupItems) {
if (candidate.createdAt <= readAt) 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,
);
}
final category = item.categories.firstOrNull ?? 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.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;
}
/// Date-bucket label for section headers — desktop's `groupInboxItems`:
/// Today / Yesterday / weekday (within 7 days) / short date.
String inboxDayLabel(int unixSeconds, {DateTime? now}) {
final current = now ?? DateTime.now();
final date = DateTime.fromMillisecondsSinceEpoch(unixSeconds * 1000);
final today = DateTime(current.year, current.month, current.day);
final day = DateTime(date.year, date.month, date.day);
final dayDiff = today.difference(day).inDays;
if (dayDiff == 0) return 'Today';
if (dayDiff == 1) return 'Yesterday';
if (dayDiff < 7 && dayDiff > 0) {
const weekdays = [
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
];
return weekdays[date.weekday - 1];
}
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
final label = '${months[date.month - 1]} ${date.day}';
return date.year == current.year ? label : '$label, ${date.year}';
}