diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 8bddff508b..30377a2084 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -1,4 +1,5 @@ import 'package:buzz/shared/widgets/feedback_snackbar.dart'; + import 'dart:async'; import 'package:flutter/foundation.dart'; @@ -31,6 +32,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'; @@ -40,7 +45,7 @@ import 'dm_resurface.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 d07eabe835..ac75ec42cb 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); @@ -235,7 +236,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, @@ -370,38 +371,92 @@ 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). Grouped DM conversations have no single thread to follow. + String? get _followRootId => + item.conversationId.startsWith('dm:') ? null : 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(); + 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, + ), + ); + }, + ), + ListTile( + leading: const Icon(BuzzIcons.externalLink), + title: const Text('Open conversation'), + onTap: () { + Navigator.of(sheetContext).pop(); + onTap(); + }, + ), + ], + ), ), ), ), diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index 1dfb850c70..3cc44c7e12 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -12,9 +12,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'; @@ -27,6 +30,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'; @@ -884,6 +888,83 @@ 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(); + await tester.pumpWidget( + await buildTestable( + 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 target = reminders.targets.single; + expect(target.eventId, 'm1'); + expect(target.channelId, 'ch1'); + expect(target.preview, 'Hey check this out'); + expect(find.text('Reminder set'), findsOneWidget); + }); + + 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 { @@ -1113,3 +1194,28 @@ 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 targets = []; + + @override + Future createReminder({ + required reminder.ReminderTarget target, + required int notBefore, + String? note, + }) async { + targets.add(target); + } +}