mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
**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>
285 lines
8.2 KiB
Dart
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}';
|
|
}
|