From 40cfa72cd411851f84f022b17e730dc8afe39723 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 9 Nov 2026 06:58:58 -0800 Subject: [PATCH] mobile: add follow and remind actions to inbox row menu (#9142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Tom Brow Co-authored-by: Claude Opus 5.5 --- .../lib/features/activity/activity_page.dart | 6 +- .../activity/activity_page/inbox_row.dart | 157 +++++++++++--- .../reminders/remind_me_later_sheet.dart | 18 +- .../shared/reminders/reminder_service.dart | 11 +- .../features/activity/activity_page_test.dart | 191 +++++++++++++++++- .../reminders/remind_me_later_sheet_test.dart | 1 + 6 files changed, 348 insertions(+), 36 deletions(-) diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 9c85d9a45b..83c2b66c37 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -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'; diff --git a/mobile/lib/features/activity/activity_page/inbox_row.dart b/mobile/lib/features/activity/activity_page/inbox_row.dart index 844230e88c..083fff454c 100644 --- a/mobile/lib/features/activity/activity_page/inbox_row.dart +++ b/mobile/lib/features/activity/activity_page/inbox_row.dart @@ -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( 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 _remindMe( + BuildContext context, + WidgetRef ref, { + required String channelId, + }) async { + final List 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 { diff --git a/mobile/lib/shared/reminders/remind_me_later_sheet.dart b/mobile/lib/shared/reminders/remind_me_later_sheet.dart index dfd721479d..5fecf99ef4 100644 --- a/mobile/lib/shared/reminders/remind_me_later_sheet.dart +++ b/mobile/lib/shared/reminders/remind_me_later_sheet.dart @@ -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 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) { diff --git a/mobile/lib/shared/reminders/reminder_service.dart b/mobile/lib/shared/reminders/reminder_service.dart index 08704541e6..3ccf72bf36 100644 --- a/mobile/lib/shared/reminders/reminder_service.dart +++ b/mobile/lib/shared/reminders/reminder_service.dart @@ -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 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, + ), ); } } diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index d3b42abce2..f123460cc6 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -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? tabReselection, List drafts = const [], List reminders = const [], + RemindersNotifier Function()? remindersNotifier, Set knownAgentPubkeys = const {}, List 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 _reminders; _FakeRemindersNotifier(this._reminders); + int refreshes = 0; + @override Future> build() async => _reminders; + + @override + Future refresh() async => refreshes++; } class _FakeComposeDraftsNotifier extends ComposeDraftsNotifier { @@ -1340,3 +1502,30 @@ class _FakeComposeDraftsNotifier extends ComposeDraftsNotifier { @override List 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 createReminder({ + required reminder.ReminderTarget target, + required int notBefore, + String? note, + String? replacingDTag, + }) async { + saved.add((target: target, note: note, dTag: replacingDTag)); + } +} diff --git a/mobile/test/shared/reminders/remind_me_later_sheet_test.dart b/mobile/test/shared/reminders/remind_me_later_sheet_test.dart index 3a7c199ee9..803ac4aeb7 100644 --- a/mobile/test/shared/reminders/remind_me_later_sheet_test.dart +++ b/mobile/test/shared/reminders/remind_me_later_sheet_test.dart @@ -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;