mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00: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>
317 lines
11 KiB
Dart
317 lines
11 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
|
|
import '../../shared/relay/relay.dart';
|
|
import '../../shared/theme/theme.dart';
|
|
import '../../shared/utils/string_utils.dart';
|
|
import '../../shared/widgets/avatar_image.dart';
|
|
import '../../shared/widgets/frosted_app_bar.dart';
|
|
import '../../shared/widgets/frosted_scaffold.dart';
|
|
import '../channels/channel.dart';
|
|
import '../channels/channel_detail_page.dart';
|
|
import '../channels/channels_provider.dart';
|
|
import '../channels/dm_channel_labels.dart';
|
|
import '../channels/message_content.dart';
|
|
import '../channels/read_state/read_state_format.dart';
|
|
import '../channels/read_state/read_state_provider.dart';
|
|
import '../profile/user_cache_provider.dart';
|
|
import '../profile/user_profile.dart';
|
|
import 'activity_provider.dart';
|
|
import 'compose_drafts_provider.dart';
|
|
import 'inbox_item.dart';
|
|
import 'inbox_local_state_provider.dart';
|
|
import 'inbox_read_state.dart';
|
|
import 'reminders_provider.dart';
|
|
|
|
part 'activity_page/header_actions.dart';
|
|
part 'activity_page/inbox_row.dart';
|
|
part 'activity_page/lists.dart';
|
|
part 'activity_page/status_views.dart';
|
|
|
|
/// Conversation-oriented Activity inbox.
|
|
///
|
|
/// Matches desktop's Home inbox item design and semantics (see
|
|
/// `desktop/src/features/home/ui/InboxListPane.tsx`): full sender avatar +
|
|
/// name, contextual "Mentioned in #channel"-style label, unread dot + time,
|
|
/// message preview — while keeping mobile's list → canonical destination
|
|
/// navigation. Row taps deep-link to the represented message (oldest unread
|
|
/// for grouped conversations) rather than just opening the channel.
|
|
class ActivityPage extends HookConsumerWidget {
|
|
const ActivityPage({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final feedAsync = ref.watch(activityProvider);
|
|
final channelsAsync = ref.watch(channelsProvider);
|
|
final filter = useState(InboxFilter.all);
|
|
final unreadOnly = useState(false);
|
|
final headerTitleStyle = context.textTheme.titleMedium?.copyWith(
|
|
fontSize: 22,
|
|
fontWeight: FontWeight.w600,
|
|
);
|
|
|
|
final readState = ref.watch(readStateProvider);
|
|
final localState = ref.watch(inboxLocalStateProvider);
|
|
final drafts = ref.watch(composeDraftsProvider);
|
|
final dueReminderCount = ref.watch(dueReminderCountProvider);
|
|
final allItems = ref.watch(inboxItemsProvider);
|
|
final myPk = ref.watch(myPubkeyProvider);
|
|
|
|
// Cache the last non-empty feed so the UI doesn't flash on rebuild.
|
|
final hasLoadedOnce = useRef(false);
|
|
if (feedAsync.hasValue) hasLoadedOnce.value = true;
|
|
|
|
final channels = channelsAsync.asData?.value ?? const <Channel>[];
|
|
final channelById = {for (final c in channels) c.id: c};
|
|
|
|
int? markerOf(String contextId) => readState.effectiveTimestamp(contextId);
|
|
bool isDone(InboxItem item) => isInboxItemDone(
|
|
item,
|
|
markerOf: markerOf,
|
|
localUnreadOverrides: localState.unreadIds,
|
|
localDoneSet: localState.doneIds,
|
|
);
|
|
|
|
final visibleItems = [
|
|
for (final item in allItems)
|
|
if (matchesInboxFilter(item, filter.value) &&
|
|
(!unreadOnly.value || !isDone(item)))
|
|
item,
|
|
];
|
|
|
|
// Preload sender profiles for visible rows.
|
|
final pubkeys = visibleItems.map((i) => i.item.pubkey).toSet().toList();
|
|
ref.read(userCacheProvider.notifier).preload(pubkeys);
|
|
|
|
final unreadVisibleCount = visibleItems.where((i) => !isDone(i)).length;
|
|
|
|
void markItemRead(InboxItem item) {
|
|
final notifier = ref.read(readStateProvider.notifier);
|
|
ref
|
|
.read(inboxLocalStateProvider.notifier)
|
|
.clearUnread(groupedInboxItemIds(item));
|
|
final threadRootId = item.threadRootId;
|
|
if (threadRootId != null) {
|
|
notifier.markContextRead(
|
|
threadContextKey(threadRootId),
|
|
item.latestActivityAt,
|
|
);
|
|
final channelRead = groupedChannelReadTimestamp(item);
|
|
if (channelRead != null) {
|
|
notifier.markContextRead(
|
|
channelRead.channelId,
|
|
channelRead.timestamp,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
final channelId = item.item.channelId;
|
|
if (channelId != null) {
|
|
notifier.markContextRead(channelId, item.latestActivityAt);
|
|
ref
|
|
.read(channelsProvider.notifier)
|
|
.clearObservedUnreadCoveredByRead(channelId, item.latestActivityAt);
|
|
return;
|
|
}
|
|
ref.read(inboxLocalStateProvider.notifier).markDone(item.id);
|
|
}
|
|
|
|
void markItemUnread(InboxItem item) {
|
|
ref
|
|
.read(inboxLocalStateProvider.notifier)
|
|
.markUnread(groupedInboxItemIds(item));
|
|
}
|
|
|
|
void openItem(InboxItem item) {
|
|
final channelId = item.item.channelId;
|
|
if (channelId == null) {
|
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
|
const SnackBar(content: Text("This item isn't linked to a channel.")),
|
|
);
|
|
return;
|
|
}
|
|
final channel = channelById[channelId];
|
|
if (channel == null) {
|
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
|
const SnackBar(content: Text('Channel not found in this workspace.')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Deep-link to the represented message: oldest unread in the group,
|
|
// falling back to the latest event.
|
|
final readAt = resolveInboxItemReadAt(item, markerOf: markerOf);
|
|
final target = item.deepLinkTarget(readAt);
|
|
final thread = threadReferenceOf(target.tags);
|
|
final threadRootId = isBroadcastReply(target.tags)
|
|
? null
|
|
: (thread.rootId ?? thread.parentId);
|
|
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => ChannelDetailPage(
|
|
channel: channel,
|
|
initialMessageId: target.id,
|
|
initialThreadRootId: threadRootId,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void openDraft(ComposeDraft draft) {
|
|
final channel = channelById[draft.channelId];
|
|
if (channel == null) {
|
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Channel for this draft is no longer available.'),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => ChannelDetailPage(
|
|
channel: channel,
|
|
initialThreadRootId: draft.threadHeadId,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void openReminder(Reminder reminder) {
|
|
final target = reminder.target;
|
|
if (target == null) {
|
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
|
const SnackBar(
|
|
content: Text("This reminder isn't linked to a message."),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
final channel = channelById[target.channelId];
|
|
if (channel == null) {
|
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Channel for this reminder is no longer available.'),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => ChannelDetailPage(
|
|
channel: channel,
|
|
initialMessageId: target.eventId,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> refresh() async {
|
|
await Future.wait([
|
|
ref.read(activityProvider.notifier).refresh(),
|
|
ref.read(remindersProvider.notifier).refresh(),
|
|
]);
|
|
}
|
|
|
|
final Widget body;
|
|
if (filter.value == InboxFilter.reminders) {
|
|
body = _RemindersList(onOpen: openReminder, onRefresh: refresh);
|
|
} else if (filter.value == InboxFilter.drafts) {
|
|
body = _DraftsList(
|
|
drafts: drafts,
|
|
channelById: channelById,
|
|
myPubkey: myPk,
|
|
onOpen: openDraft,
|
|
onDelete: (draft) =>
|
|
ref.read(composeDraftsProvider.notifier).remove(draft.key),
|
|
);
|
|
} else if (feedAsync.hasError && allItems.isEmpty) {
|
|
body = _ErrorView(onRetry: refresh);
|
|
} else if (!hasLoadedOnce.value && allItems.isEmpty) {
|
|
body = const _LoadingSkeleton();
|
|
} else if (visibleItems.isEmpty) {
|
|
body = _EmptyFilterState(
|
|
filter: filter.value,
|
|
unreadOnly: unreadOnly.value,
|
|
);
|
|
} else {
|
|
// Compute the "New" boundary: index of the first unread row when the
|
|
// rows above it are read (list is newest-first, so unread rows sit on
|
|
// top; the divider marks where the unread block ends).
|
|
final firstReadIndex = visibleItems.indexWhere(isDone);
|
|
final newBoundaryIndex = !unreadOnly.value && firstReadIndex > 0
|
|
? firstReadIndex
|
|
: -1;
|
|
|
|
body = RefreshIndicator(
|
|
onRefresh: refresh,
|
|
child: ListView.builder(
|
|
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
|
itemCount: visibleItems.length,
|
|
itemBuilder: (context, index) {
|
|
final item = visibleItems[index];
|
|
final channel = item.item.channelId != null
|
|
? channelById[item.item.channelId]
|
|
: null;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (index == newBoundaryIndex) const _NewBoundaryDivider(),
|
|
_InboxRow(
|
|
item: item,
|
|
channel: channel,
|
|
currentPubkey: myPk,
|
|
isDone: isDone(item),
|
|
onTap: () => openItem(item),
|
|
onMarkRead: () => markItemRead(item),
|
|
onMarkUnread: () => markItemUnread(item),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
return FrostedScaffold(
|
|
appBar: FrostedAppBar(
|
|
gradient: context.appColors.topSectionGradient,
|
|
title: const Text('Activity'),
|
|
titleStyle: headerTitleStyle,
|
|
actions: [
|
|
_FilterMenuButton(
|
|
filter: filter.value,
|
|
dueReminderCount: dueReminderCount,
|
|
draftCount: drafts.length,
|
|
onChanged: (f) => filter.value = f,
|
|
),
|
|
_InboxOptionsButton(
|
|
unreadOnly: unreadOnly.value,
|
|
unreadCount: unreadVisibleCount,
|
|
onUnreadOnlyChanged: (v) => unreadOnly.value = v,
|
|
onMarkAllRead: () {
|
|
for (final item in visibleItems) {
|
|
if (!isDone(item)) markItemRead(item);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
body: SafeArea(
|
|
top: false,
|
|
child: Padding(
|
|
padding: EdgeInsets.only(
|
|
top: frostedAppBarHeight(context, titleStyle: headerTitleStyle),
|
|
),
|
|
child: body,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|