Files
buzz/mobile/lib/features/activity/activity_page.dart
Tom Brow 40cfa72cd4 mobile: add follow and remind actions to inbox row menu (#9142)
Long-pressing an Inbox row opened a sheet with only **Mark as
read/unread** and **Open conversation**. To follow a thread or set a
reminder, you had to open the conversation and long-press the message
itself.

The row sheet now also offers:

- **Follow thread / Unfollow thread**: acts on the conversation's thread
root, or on the message itself for a top-level row (the same rule the
message actions sheet uses). It's hidden for Solo chats, which have no
threads, and for grouped DM conversations, which have no single thread
to follow.
- **Remind me**: opens the existing "Remind me later" sheet for the
row's latest message and publishes the same kind-30300 reminder. If that
message already has a pending reminder, the new time reschedules it: the
event reuses its `d` tag (an addressable replace) and keeps its note, so
you don't get a second reminder. It loads the reminder list first,
because otherwise the list loads only with the Reminders filter. It's
hidden when signed out, like the message sheet.

The sheet is now scrollable so the four rows fit on short screens and at
large text sizes (without it, the new test overflowed at the default
test surface).

### Demo

Long-press a channel mention row in the Inbox, follow the thread, reopen
the sheet (now **Unfollow thread**), then set an "In 1 hour" reminder.
The rows are agent test chatter from a pilot community.


https://github.com/user-attachments/assets/ad94e5f2-1048-4585-9663-145e814892e3

### Validation

- Added tests: one Activity page journey that follows and unfollows a
thread row, follows a top-level row, then sets a reminder through the
sheet and checks the published target (event, channel, preview). Further
tests cover rescheduling an existing pending reminder (same `d` tag,
note kept, "Reminder updated"), hiding Follow in a Solo chat, and hiding
Remind me when signed out. The `test/features/activity`,
`test/shared/reminders` and message-actions suites pass.
- Re-run on the branch head (99b2418) in an iPhone 17 Pro simulator (iOS
27) debug build, same community: long-pressed a thread-capable channel
mention row, tapped **Follow thread**, reopened the sheet and saw
**Unfollow thread**, tapped **Remind me** and then "In 1 hour", and saw
the "Reminder set" toast (this is the video above). Long-pressing a Solo
chat row showed no **Follow thread** row, as intended. Not covered on
the simulator: rescheduling an existing reminder, signed-out state,
large text sizes. Those are covered by the widget tests.
- Automated app run on an iPhone 17 Pro simulator (iOS 27) signed in to
the Remote Agents Pilot community: opened Inbox, long-pressed a channel
mention row, followed the thread, reopened the sheet and saw **Unfollow
thread**, then set an "In 1 hour" reminder and saw the "Reminder set"
toast. That run predates the reschedule and Solo changes, which are
covered by the widget tests above. I didn't re-run unrelated Inbox flows
(swipe to mark read, filters) on the simulator. Those are covered by the
existing widget tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 06:58:58 -08:00

574 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:buzz/shared/widgets/feedback_snackbar.dart';
import 'dart:async';
import 'dart:math' show max;
import '../../shared/widgets/ios_message_create_button.dart';
import '../../shared/widgets/ios_glass_navigation_button.dart';
import '../../shared/widgets/ios_scroll_to_top.dart';
import '../../shared/widgets/top_edge_fade.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/shared/theme/buzz_icons.dart';
import '../../shared/identity_names/identity_names_provider.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/mentions/mention_tags.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/utils/string_utils.dart';
import '../../shared/widgets/sheet_action_section.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/anchored_popover_menu.dart';
import '../../shared/widgets/bee_refresh_indicator.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/message_author_meta.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../channels/channel.dart';
import '../channels/channel_detail_page.dart';
import '../channels/channel_identity_names_provider.dart';
import '../channels/channel_management_provider.dart';
import '../channels/channels_provider.dart';
import '../channels/dm_channel_labels.dart';
import '../channels/message_content.dart';
import '../../shared/read_state/read_state_format.dart';
import '../../shared/reminders/remind_me_later_sheet.dart';
import '../../shared/reminders/reminder_service.dart'
show ReminderTarget, reminderServiceProvider;
import '../channels/thread_follows/thread_follows_provider.dart';
import '../../shared/read_state/read_state_provider.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/profile/user_profile.dart';
import 'activity_provider.dart';
import 'compose_drafts_provider.dart';
import 'dm_resurface.dart';
import 'feed_item.dart';
import 'inbox_item.dart';
import 'inbox_local_state_provider.dart';
import 'inbox_read_state.dart';
import 'reminders_provider.dart' hide ReminderTarget;
part 'activity_page/header_actions.dart';
part 'activity_page/inbox_row.dart';
part 'activity_page/lists.dart';
part 'activity_page/status_views.dart';
EdgeInsets _activityScrollPadding(
BuildContext context, {
double horizontal = 0,
double top = Grid.xxs,
double bottom = Grid.xxs,
}) => EdgeInsets.fromLTRB(
horizontal,
top,
horizontal,
MediaQuery.paddingOf(context).bottom + bottom,
);
/// 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.
const _activityTypeOptions = {
InboxFilter.all: 'All',
InboxFilter.thread: 'Threads',
InboxFilter.mention: 'Mentions',
InboxFilter.dm: 'DMs',
InboxFilter.reminders: 'Reminders',
InboxFilter.drafts: 'Drafts',
};
class ActivityPage extends HookConsumerWidget {
const ActivityPage({this.tabReselection, super.key});
/// Notifies this page when its already-selected tab is tapped again.
final ValueListenable<int>? tabReselection;
@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 sender = useState('Anyone');
final agents = ref.watch(knownAgentPubkeysProvider);
final profiles = ref.watch(userCacheProvider);
final scrollController = useScrollController();
final reducedMotion = MediaQuery.disableAnimationsOf(context);
useEffect(() {
final tabReselection = this.tabReselection;
if (tabReselection == null) return null;
void scrollToTop() {
if (!scrollController.hasClients) return;
final position = scrollController.position;
if (position.pixels <= position.minScrollExtent + 0.5) return;
if (reducedMotion) {
scrollController.jumpTo(position.minScrollExtent);
return;
}
unawaited(
scrollController.animateTo(
position.minScrollExtent,
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
),
);
}
tabReselection.addListener(scrollToTop);
return () => tabReselection.removeListener(scrollToTop);
}, [tabReselection, scrollController, reducedMotion]);
final headerTitleStyle = context.textTheme.titleMedium?.copyWith(
fontSize: 22,
fontWeight: FontWeight.w600,
color: navigationPrimaryForeground(context),
);
final filterHeight =
max(48.0, MediaQuery.textScalerOf(context).scale(16) * 1.4 + 16) +
Grid.xxs;
final readState = ref.watch(readStateProvider);
final localState = ref.watch(inboxLocalStateProvider);
final drafts = ref.watch(composeDraftsProvider);
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,
);
// Rows mark channel bot members as agents, so the sender filter does too.
final channelAgents = <String, Set<String>>{};
bool includesSender(FeedItem e) {
if (sender.value == 'Anyone') return true;
final pk = e.pubkey.toLowerCase();
final channelId = e.channelId;
final known = channelId == null
? agents
: channelAgents.putIfAbsent(
channelId,
() => ref.watch(agentMentionPubkeysProvider(channelId)),
);
final isAgent = known.contains(pk) || profiles[pk]?.ownerPubkey != null;
return sender.value == 'Agents' ? isAgent : !isAgent;
}
// Mentions, Threads and sender filters apply to each event before
// regrouping, so a row shows the matching activity rather than a later
// unrelated message from the same conversation.
final candidates = buildInboxItems(
allItems
.expand((i) => i.groupItems)
.where(
(e) =>
(filter.value != InboxFilter.mention ||
e.category == 'mention') &&
(filter.value != InboxFilter.thread || isThreadReply(e.tags)) &&
includesSender(e),
),
isDmChannel: (id) =>
(channelById[id]?.isDm ?? false) ||
ref.read(channelsProvider.notifier).hiddenDmIds.contains(id),
);
// A filtered DM row holds only part of its conversation, so the DM
// channel marker would also read events this view hides.
final rowsArePartial =
filter.value == InboxFilter.thread ||
filter.value == InboxFilter.mention ||
sender.value != 'Anyone';
final visibleItems = [
for (final item in candidates)
if (matchesInboxFilter(item, filter.value) &&
(!unreadOnly.value || !isDone(item)))
item,
];
// Preload sender profiles for visible rows, and for every author while a
// sender filter needs their agent ownership.
final preloadPubkeys = {
if (sender.value != 'Anyone')
for (final item in allItems)
for (final e in item.groupItems) e.pubkey.toLowerCase(),
for (final item in visibleItems) item.item.pubkey.toLowerCase(),
for (final item in visibleItems)
...mentionedPubkeysFromTags(item.item.tags),
}.toList()..sort();
final preloadPubkeysKey = preloadPubkeys.join('\u0000');
useEffect(() {
ref.read(userCacheProvider.notifier).preload(preloadPubkeys);
return null;
}, [preloadPubkeysKey]);
void markItemRead(InboxItem item) {
final notifier = ref.read(readStateProvider.notifier);
ref
.read(inboxLocalStateProvider.notifier)
.clearUnread(groupedInboxItemIds(item));
final isDmRow = item.conversationId.startsWith('dm:');
if (isDmRow && rowsArePartial) {
for (final e in {item.item, ...item.groupItems}) {
notifier.markContextRead(msgContextKey(e.id), e.createdAt);
}
return;
}
// A DM row can group replies under several thread roots; its channel
// marker reads all of them.
final threadRootId = isDmRow ? null : 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));
}
Future<void> openItem(InboxItem item) async {
final channelId = item.item.channelId;
if (channelId == null) {
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
const SnackBar(content: Text('No linked channel')),
kind: ToastKind.error,
);
return;
}
var channel = channelById[channelId];
if (channel == null &&
myPk != null &&
ref.read(channelsProvider.notifier).hiddenDmIds.contains(channelId)) {
final expectedPubkey = myPk.toLowerCase();
final expectedRelayUrl = ref.read(relayConfigProvider).baseUrl;
bool isCurrentScope() =>
context.mounted &&
ref.read(myPubkeyProvider)?.toLowerCase() == expectedPubkey &&
ref.read(relayConfigProvider).baseUrl == expectedRelayUrl;
try {
final members = await ref.read(
channelMembersProvider(channelId).future,
);
if (!isCurrentScope()) return;
final peers = dmPeerPubkeysFromMembers(
members.map((member) => member.pubkey),
expectedPubkey,
);
if (peers.isEmpty) {
throw StateError('Could not determine the DM membership.');
}
final reopened = await ref
.read(channelActionsProvider)
.openDm(pubkeys: peers.toList());
if (!isCurrentScope()) return;
if (reopened.id != channelId) {
throw StateError('Relay reopened a different DM conversation.');
}
channel = reopened;
} catch (error) {
if (!isCurrentScope()) return;
if (!context.mounted) return;
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
SnackBar(content: Text('Could not reopen conversation: $error')),
kind: ToastKind.error,
);
return;
}
}
if (channel == null) {
if (!context.mounted) return;
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
const SnackBar(content: Text('Channel not found here')),
kind: ToastKind.error,
);
return;
}
final resolvedChannel = channel;
// Deep-link to the represented message: oldest unread in the group,
// falling back to the latest event.
final target = item.deepLinkTarget(
(event) => isInboxEventRead(event, markerOf: markerOf),
);
final thread = threadReferenceOf(target.tags);
final threadRootId = isBroadcastReply(target.tags)
? null
: thread.parentId;
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ChannelDetailPage(
channel: resolvedChannel,
initialMessageId: target.id,
initialThreadRootId: threadRootId,
initialThreadRouteBehavior:
InitialThreadRouteBehavior.replaceCurrentRoute,
),
),
);
}
void openDraft(ComposeDraft draft) {
final channel = channelById[draft.channelId];
if (channel == null) {
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
const SnackBar(content: Text('Draft’s channel unavailable')),
kind: ToastKind.error,
);
return;
}
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ChannelDetailPage(
channel: channel,
initialThreadRootId: draft.threadHeadId,
initialThreadRouteBehavior:
InitialThreadRouteBehavior.replaceCurrentRoute,
),
),
);
}
void openReminder(Reminder reminder) {
final target = reminder.target;
if (target == null) {
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
const SnackBar(content: Text('Reminder has no linked message')),
kind: ToastKind.error,
);
return;
}
final channel = channelById[target.channelId];
if (channel == null) {
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
const SnackBar(content: Text('Reminder’s channel unavailable')),
kind: ToastKind.error,
);
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(),
]);
}
late final Widget body;
if (filter.value == InboxFilter.reminders) {
body = _RemindersList(
scrollController: scrollController,
onOpen: openReminder,
onRefresh: refresh,
);
} else if (filter.value == InboxFilter.drafts) {
body = _DraftsList(
drafts: drafts,
scrollController: scrollController,
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 = _LoadingSkeleton(scrollController: scrollController);
} else if (visibleItems.isEmpty) {
body = _EmptyFilterState(
filter: filter.value,
unreadOnly: unreadOnly.value,
);
} else {
body = BeeRefreshIndicator(
onRefresh: refresh,
child: CustomScrollView(
controller: scrollController,
slivers: [
DecoratedSliver(
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(Radii.dialog),
),
),
sliver: SliverPadding(
padding: _activityScrollPadding(context),
sliver: SliverList.builder(
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: [
_InboxRow(
key: ValueKey(item.id),
item: item,
channel: channel,
currentPubkey: myPk,
isDone: isDone(item),
onTap: () => unawaited(openItem(item)),
onMarkRead: () => markItemRead(item),
onMarkUnread: () => markItemUnread(item),
),
],
);
},
),
),
),
],
),
);
}
return IosScrollToTop(
child: Scaffold(
backgroundColor: context.colors.surface,
body: SafeArea(
bottom: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.xxs,
),
child: Row(
children: [
defaultTargetPlatform == TargetPlatform.iOS
? Padding(
padding: const EdgeInsets.only(right: Grid.twelve),
child: IosGlassNavigationButton(
icon: IosGlassNavigationIcon.close,
semanticLabel: 'Close activity',
width: 44,
height: 44,
controlSize: 44,
foregroundColor: navigationPrimaryForeground(
context,
),
onPressed: () => Navigator.of(context).pop(),
),
)
: IconButton(
tooltip: 'Close activity',
icon: const Icon(BuzzIcons.x),
onPressed: () => Navigator.of(context).pop(),
),
Expanded(child: Text('Activity', style: headerTitleStyle)),
],
),
),
SizedBox(
height: filterHeight,
child: SingleChildScrollView(
key: const ValueKey('activity-filters'),
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.half,
),
child: Row(
children: [
_ActivityMenuButton<InboxFilter>(
controlKey: 'activity-type-menu',
popoverKey: 'activity-type-popover',
semanticLabel:
'Activity type, ${_activityTypeOptions[filter.value]}',
value: filter.value,
active: filter.value != InboxFilter.all,
options: _activityTypeOptions,
onChanged: (value) => filter.value = value,
),
const SizedBox(width: Grid.xxs),
_ActivityMenuButton<String>(
controlKey: 'activity-sender-menu',
popoverKey: 'activity-sender-popover',
semanticLabel: 'From ${sender.value}',
value: sender.value,
active: sender.value != 'Anyone',
options: const {
'Anyone': 'Anyone',
'People': 'People',
'Agents': 'Agents',
},
onChanged: (value) => sender.value = value,
),
const SizedBox(width: Grid.xxs),
_UnreadFilterButton(
selected: unreadOnly.value,
onPressed: () => unreadOnly.value = !unreadOnly.value,
),
],
),
),
),
Expanded(
child: TopEdgeFade(
key: const ValueKey('activity-content-viewport'),
child: body,
),
),
],
),
),
),
);
}
}