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>
This commit is contained in:
Tom Brow
2026-11-09 06:58:58 -08:00
committed by GitHub
parent ad5699d05a
commit 40cfa72cd4
6 changed files with 348 additions and 36 deletions

View File

@@ -36,6 +36,10 @@ 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';
@@ -46,7 +50,7 @@ import 'feed_item.dart';
import 'inbox_item.dart';
import 'inbox_local_state_provider.dart';
import 'inbox_read_state.dart';
import 'reminders_provider.dart';
import 'reminders_provider.dart' hide ReminderTarget;
part 'activity_page/header_actions.dart';
part 'activity_page/inbox_row.dart';

View File

@@ -1,6 +1,7 @@
part of '../activity_page.dart';
const _inboxSwipeActionInset = Grid.half;
const _inboxReminderPreviewLength = 100;
const _inboxSwipeActionLabelMinWidth = Grid.xxl;
const _inboxSwipeLabelRevealWidth =
_inboxSwipeActionLabelMinWidth + (_inboxSwipeActionInset * 2);
@@ -200,7 +201,7 @@ class _InboxRow extends HookConsumerWidget {
child: InkWell(
key: ValueKey('inbox-row-${item.id}'),
onTap: onTap,
onLongPress: () => _showRowActions(context),
onLongPress: () => _showRowActions(context, ref),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
@@ -335,44 +336,140 @@ class _InboxRow extends HookConsumerWidget {
);
}
void _showRowActions(BuildContext context) {
/// Thread root that Follow acts on: the conversation's root for thread
/// rows, the message itself for top-level rows (matching the message
/// actions sheet). Solo chats have no threads, and grouped DM conversations
/// have no single thread, so neither offers Follow.
String? get _followRootId {
if (channel?.isSolo ?? false) return null;
if (item.conversationId.startsWith('dm:')) return null;
return item.conversationId;
}
void _showRowActions(BuildContext context, WidgetRef ref) {
final followRootId = _followRootId;
final following =
followRootId != null &&
ref.read(threadFollowsProvider).isFollowing(followRootId);
final reminderChannelId = item.item.channelId;
final canRemind =
reminderChannelId != null && ref.read(reminderServiceProvider) != null;
showBuzzModalBottomSheet<void>(
context: context,
builder: (sheetContext) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: IconTheme.merge(
data: const IconThemeData(size: 18),
child: SheetActionSection(
children: [
ListTile(
leading: Icon(isDone ? BuzzIcons.mail : BuzzIcons.mailOpen),
title: Text(isDone ? 'Mark unread' : 'Mark as read'),
onTap: () {
Navigator.of(sheetContext).pop();
isDone ? onMarkUnread() : onMarkRead();
},
),
ListTile(
leading: const Icon(BuzzIcons.externalLink),
title: const Text('Open conversation'),
onTap: () {
Navigator.of(sheetContext).pop();
onTap();
},
),
],
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: IconTheme.merge(
data: const IconThemeData(size: 18),
child: SheetActionSection(
children: [
ListTile(
leading: Icon(isDone ? BuzzIcons.mail : BuzzIcons.mailOpen),
title: Text(isDone ? 'Mark unread' : 'Mark as read'),
onTap: () {
Navigator.of(sheetContext).pop();
isDone ? onMarkUnread() : onMarkRead();
},
),
if (followRootId != null)
ListTile(
leading: Icon(
following ? BuzzIcons.bellOff : BuzzIcons.bellRing,
),
title: Text(
following ? 'Unfollow thread' : 'Follow thread',
),
onTap: () {
Navigator.of(sheetContext).pop();
final notifier = ref.read(
threadFollowsProvider.notifier,
);
following
? notifier.unfollowThread(followRootId)
: notifier.followThread(followRootId);
},
),
if (canRemind)
ListTile(
leading: const Icon(BuzzIcons.clock),
title: const Text('Remind me'),
onTap: () {
Navigator.of(sheetContext).pop();
unawaited(
_remindMe(context, ref, channelId: reminderChannelId),
);
},
),
ListTile(
leading: const Icon(BuzzIcons.externalLink),
title: const Text('Open conversation'),
onTap: () {
Navigator.of(sheetContext).pop();
onTap();
},
),
],
),
),
),
),
),
);
}
/// Opens the reminder sheet for the row's message. An existing pending
/// reminder for that message is rescheduled rather than duplicated, so the
/// reminder list must be loaded first (it otherwise loads only with the
/// Reminders filter).
Future<void> _remindMe(
BuildContext context,
WidgetRef ref, {
required String channelId,
}) async {
final List<Reminder> reminders;
try {
reminders = await ref.read(remindersProvider.future);
} catch (error) {
debugPrint('[Inbox] loading reminders failed: $error');
if (!context.mounted) return;
ScaffoldMessenger.of(context).showFeedbackSnackBar(
const SnackBar(content: Text('Couldn’t load reminders. Try again.')),
kind: ToastKind.error,
);
return;
}
if (!context.mounted) return;
final existing = reminders
.where(
(reminder) =>
reminder.status == 'pending' &&
reminder.target?.eventId == item.item.id,
)
.firstOrNull;
showRemindMeLaterSheet(
context: context,
ref: ref,
target: ReminderTarget(
eventId: item.item.id,
channelId: channelId,
preview: item.item.displayContent.characters
.take(_inboxReminderPreviewLength)
.toString(),
authorPubkey: item.item.pubkey,
),
existing: existing == null
? null
: (dTag: existing.id, note: existing.note),
onSaved: () => unawaited(ref.read(remindersProvider.notifier).refresh()),
);
}
}
class _InboxSwipeAction extends StatelessWidget {

View File

@@ -12,10 +12,16 @@ import 'reminder_time_presets.dart';
/// Bottom sheet for "Remind me later": desktop-parity presets plus a native
/// date/time picker, publishing the same author-private kind-30300 reminder
/// events desktop reads.
///
/// With [existing], the chosen time reschedules that pending reminder (same
/// `d` tag, note kept) instead of adding a second one. [onSaved] runs after
/// the relay accepts the reminder.
void showRemindMeLaterSheet({
required BuildContext context,
required WidgetRef ref,
required ReminderTarget target,
({String dTag, String? note})? existing,
VoidCallback? onSaved,
}) {
final service = ref.read(reminderServiceProvider);
final messenger = ScaffoldMessenger.of(context);
@@ -29,9 +35,17 @@ void showRemindMeLaterSheet({
Future<void> submit(int notBefore) async {
try {
await service.createReminder(target: target, notBefore: notBefore);
await service.createReminder(
target: target,
notBefore: notBefore,
note: existing?.note,
replacingDTag: existing?.dTag,
);
onSaved?.call();
messenger.showFeedbackSnackBar(
const SnackBar(content: Text('Reminder set')),
SnackBar(
content: Text(existing == null ? 'Reminder set' : 'Reminder updated'),
),
kind: ToastKind.success,
);
} catch (error) {

View File

@@ -104,19 +104,26 @@ class ReminderService {
}) : _signedEventRelay = signedEventRelay,
_crypto = crypto;
/// Encrypt, sign, and publish a new pending reminder. [notBefore] is a Unix
/// Encrypt, sign, and publish a pending reminder. [notBefore] is a Unix
/// timestamp in seconds after which clients surface the reminder.
///
/// Pass [replacingDTag] to reschedule an existing reminder: kind 30300 is
/// addressable, so reusing its `d` tag replaces it instead of adding one.
Future<void> createReminder({
required ReminderTarget target,
required int notBefore,
String? note,
String? replacingDTag,
}) async {
final plaintext = buildReminderPlaintext(target: target, note: note);
final ciphertext = _crypto.encrypt(plaintext);
await _signedEventRelay.submit(
kind: EventKind.eventReminder,
content: ciphertext,
tags: buildReminderTags(dTag: randomReminderDTag(), notBefore: notBefore),
tags: buildReminderTags(
dTag: replacingDTag ?? randomReminderDTag(),
notBefore: notBefore,
),
);
}
}

View File

@@ -16,9 +16,12 @@ import 'package:buzz/features/channels/channel_detail_page.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/message_content.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/thread_follows/thread_follows_provider.dart';
import 'package:buzz/shared/identity_names/identity_names.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
import 'package:buzz/shared/read_state/read_state_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/reminders/reminder_service.dart' as reminder;
import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/shared/theme/theme.dart';
@@ -31,6 +34,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/misc.dart';
import 'package:buzz/shared/theme/buzz_icons.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import '../../helpers/thread_ids.dart';
@@ -134,6 +138,7 @@ void main() {
ValueListenable<int>? tabReselection,
List<ComposeDraft> drafts = const [],
List<Reminder> reminders = const [],
RemindersNotifier Function()? remindersNotifier,
Set<String> knownAgentPubkeys = const {},
List<Override> overrides = const [],
UserCacheNotifier Function()? userCache,
@@ -159,7 +164,9 @@ void main() {
composeDraftsProvider.overrideWith(
() => _FakeComposeDraftsNotifier(drafts),
),
remindersProvider.overrideWith(() => _FakeRemindersNotifier(reminders)),
remindersProvider.overrideWith(
remindersNotifier ?? () => _FakeRemindersNotifier(reminders),
),
...overrides,
],
child: MaterialApp(
@@ -1100,6 +1107,156 @@ void main() {
expect(find.byKey(const ValueKey('inbox-unread-dot-m1')), findsOneWidget);
});
testWidgets('long-press follows a thread and sets a reminder', (
tester,
) async {
final reminders = _CapturingReminderService();
final remindersNotifier = _FakeRemindersNotifier(const []);
await tester.pumpWidget(
await buildTestable(
remindersNotifier: () => remindersNotifier,
overrides: [
reminder.reminderServiceProvider.overrideWithValue(reminders),
],
),
);
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.byType(ActivityPage)),
);
Finder sheetText(String text) => find.descendant(
of: find.byType(BottomSheet),
matching: find.text(text),
);
// A thread row follows its root, and the menu then offers unfollow.
await tester.longPress(find.byKey(const ValueKey('inbox-row-a1')));
await tester.pumpAndSettle();
await tester.tap(sheetText('Follow thread'));
await tester.pumpAndSettle();
expect(
container.read(threadFollowsProvider).isFollowing(threadIdRoot1),
isTrue,
);
await tester.longPress(find.byKey(const ValueKey('inbox-row-a1')));
await tester.pumpAndSettle();
expect(sheetText('Follow thread'), findsNothing);
await tester.tap(sheetText('Unfollow thread'));
await tester.pumpAndSettle();
expect(
container.read(threadFollowsProvider).isFollowing(threadIdRoot1),
isFalse,
);
// A top-level row follows the message itself as a potential thread head.
await tester.longPress(find.byKey(const ValueKey('inbox-row-m1')));
await tester.pumpAndSettle();
await tester.tap(sheetText('Follow thread'));
await tester.pumpAndSettle();
expect(container.read(threadFollowsProvider).isFollowing('m1'), isTrue);
// Remind me opens the shared reminder sheet for the row's message.
await tester.longPress(find.byKey(const ValueKey('inbox-row-m1')));
await tester.pumpAndSettle();
await tester.tap(sheetText('Remind me'));
await tester.pumpAndSettle();
expect(find.text('Remind me about this message'), findsOneWidget);
await tester.tap(find.text('In 1 hour'));
await tester.pumpAndSettle();
final first = reminders.saved.single;
expect(first.target.eventId, 'm1');
expect(first.target.channelId, 'ch1');
expect(first.target.preview, 'Hey check this out');
expect(first.dTag, isNull);
expect(find.text('Reminder set'), findsOneWidget);
expect(remindersNotifier.refreshes, 1);
});
testWidgets('long-press reschedules an existing reminder instead of adding '
'one', (tester) async {
final reminders = _CapturingReminderService();
final existing = Reminder(
id: 'reminder-d1',
notBefore: now + 600,
status: 'pending',
target: const ReminderTarget(
eventId: 'm1',
channelId: 'ch1',
preview: 'Hey check this out',
authorPubkey:
'a11ce00000000000000000000000000000000000000000000000000000000000',
),
note: 'Reply after standup',
createdAt: now - 60,
eventId: 'reminder-event-1',
);
await tester.pumpWidget(
await buildTestable(
reminders: [existing],
overrides: [
reminder.reminderServiceProvider.overrideWithValue(reminders),
],
),
);
await tester.pumpAndSettle();
await tester.longPress(find.byKey(const ValueKey('inbox-row-m1')));
await tester.pumpAndSettle();
await tester.tap(find.text('Remind me'));
await tester.pumpAndSettle();
await tester.tap(find.text('In 1 hour'));
await tester.pumpAndSettle();
final saved = reminders.saved.single;
expect(saved.dTag, 'reminder-d1');
expect(saved.note, 'Reply after standup');
expect(find.text('Reminder updated'), findsOneWidget);
});
testWidgets('long-press omits Follow in a Solo chat', (tester) async {
await tester.pumpWidget(
await buildTestable(
channels: [
Channel(
id: 'ch1',
name: 'Notes',
channelType: 'stream',
visibility: 'private',
description: '',
createdBy: 'x',
createdAt: DateTime(2025),
memberCount: 1,
isMember: true,
labels: const ['buzz.solo'],
),
testChannels[1],
],
),
);
await tester.pumpAndSettle();
await tester.longPress(find.byKey(const ValueKey('inbox-row-m1')));
await tester.pumpAndSettle();
expect(find.text('Open conversation'), findsOneWidget);
expect(find.text('Follow thread'), findsNothing);
});
testWidgets('long-press hides reminders when signed out', (tester) async {
await tester.pumpWidget(
await buildTestable(
overrides: [reminder.reminderServiceProvider.overrideWithValue(null)],
),
);
await tester.pumpAndSettle();
await tester.longPress(find.byKey(const ValueKey('inbox-row-m1')));
await tester.pumpAndSettle();
expect(find.text('Follow thread'), findsOneWidget);
expect(find.text('Remind me'), findsNothing);
});
testWidgets('swiping an inbox row reveals and runs its row actions', (
tester,
) async {
@@ -1329,8 +1486,13 @@ class _FakeRemindersNotifier extends RemindersNotifier {
final List<Reminder> _reminders;
_FakeRemindersNotifier(this._reminders);
int refreshes = 0;
@override
Future<List<Reminder>> build() async => _reminders;
@override
Future<void> refresh() async => refreshes++;
}
class _FakeComposeDraftsNotifier extends ComposeDraftsNotifier {
@@ -1340,3 +1502,30 @@ class _FakeComposeDraftsNotifier extends ComposeDraftsNotifier {
@override
List<ComposeDraft> build() => _drafts;
}
class _CapturingReminderService extends reminder.ReminderService {
_CapturingReminderService._(nostr.Keys keys)
: super(
signedEventRelay: SignedEventRelay(
session: RelaySessionNotifier(),
nsec: keys.nsec,
),
crypto: reminder.ReminderCrypto(keys.nsec, keys.public),
);
factory _CapturingReminderService() =>
_CapturingReminderService._(nostr.Keys.generate());
final saved =
<({reminder.ReminderTarget target, String? note, String? dTag})>[];
@override
Future<void> createReminder({
required reminder.ReminderTarget target,
required int notBefore,
String? note,
String? replacingDTag,
}) async {
saved.add((target: target, note: note, dTag: replacingDTag));
}
}

View File

@@ -33,6 +33,7 @@ class _RecordingReminderService extends ReminderService {
required ReminderTarget target,
required int notBefore,
String? note,
String? replacingDTag,
}) async {
final error = this.error;
if (error != null) throw error;