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;