mobile: add follow and remind actions to inbox row menu

Long-pressing an Inbox row now offers Follow/Unfollow thread (the
conversation's thread root, or the message itself for top-level rows;
hidden for grouped DM conversations) and Remind me (the shared reminder
sheet for the row's latest message; hidden when signed out). The sheet
scrolls so the extra rows fit on short screens and large text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tom Brow <tomb@squareup.com>
This commit is contained in:
Tom Brow
2026-11-05 13:34:33 -08:00
parent 74abe2f0fe
commit b0ade6a182
3 changed files with 197 additions and 31 deletions

View File

@@ -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';

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);
@@ -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<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();
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();
},
),
],
),
),
),
),

View File

@@ -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<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 targets = <reminder.ReminderTarget>[];
@override
Future<void> createReminder({
required reminder.ReminderTarget target,
required int notBefore,
String? note,
}) async {
targets.add(target);
}
}