mirror of
https://github.com/block/buzz.git
synced 2026-11-10 11:21:05 +00:00
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:
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user