diff --git a/mobile/lib/features/activity/activity_page/inbox_row.dart b/mobile/lib/features/activity/activity_page/inbox_row.dart index ac75ec42cb..ffada86d37 100644 --- a/mobile/lib/features/activity/activity_page/inbox_row.dart +++ b/mobile/lib/features/activity/activity_page/inbox_row.dart @@ -373,9 +373,13 @@ class _InboxRow extends HookConsumerWidget { /// 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). Grouped DM conversations have no single thread to follow. - String? get _followRootId => - item.conversationId.startsWith('dm:') ? null : item.conversationId; + /// 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; @@ -433,17 +437,8 @@ class _InboxRow extends HookConsumerWidget { title: const Text('Remind me'), onTap: () { Navigator.of(sheetContext).pop(); - showRemindMeLaterSheet( - context: context, - ref: ref, - target: ReminderTarget( - eventId: item.item.id, - channelId: reminderChannelId, - preview: item.item.displayContent.characters - .take(_inboxReminderPreviewLength) - .toString(), - authorPubkey: item.item.pubkey, - ), + unawaited( + _remindMe(context, ref, channelId: reminderChannelId), ); }, ), @@ -463,6 +458,53 @@ class _InboxRow extends HookConsumerWidget { ), ); } + + /// 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 3cc44c7e12..026e28900c 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -132,6 +132,7 @@ void main() { ValueListenable? tabReselection, List drafts = const [], List reminders = const [], + RemindersNotifier Function()? remindersNotifier, Set knownAgentPubkeys = const {}, List overrides = const [], }) async { @@ -156,7 +157,9 @@ void main() { composeDraftsProvider.overrideWith( () => _FakeComposeDraftsNotifier(drafts), ), - remindersProvider.overrideWith(() => _FakeRemindersNotifier(reminders)), + remindersProvider.overrideWith( + remindersNotifier ?? () => _FakeRemindersNotifier(reminders), + ), ...overrides, ], child: MaterialApp( @@ -892,8 +895,10 @@ void main() { tester, ) async { final reminders = _CapturingReminderService(); + final remindersNotifier = _FakeRemindersNotifier(const []); await tester.pumpWidget( await buildTestable( + remindersNotifier: () => remindersNotifier, overrides: [ reminder.reminderServiceProvider.overrideWithValue(reminders), ], @@ -944,11 +949,82 @@ void main() { await tester.tap(find.text('In 1 hour')); await tester.pumpAndSettle(); - final target = reminders.targets.single; - expect(target.eventId, 'm1'); - expect(target.channelId, 'ch1'); - expect(target.preview, 'Hey check this out'); + 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 { @@ -1183,8 +1259,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 { @@ -1208,14 +1289,16 @@ class _CapturingReminderService extends reminder.ReminderService { factory _CapturingReminderService() => _CapturingReminderService._(nostr.Keys.generate()); - final targets = []; + final saved = + <({reminder.ReminderTarget target, String? note, String? dTag})>[]; @override Future createReminder({ required reminder.ReminderTarget target, required int notBefore, String? note, + String? replacingDTag, }) async { - targets.add(target); + 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;