mirror of
https://github.com/block/buzz.git
synced 2026-11-10 11:21:05 +00:00
mobile: reschedule existing reminder and hide follow in solo inbox rows
Remind me on an Inbox row now loads the reminder list first and, when the message already has a pending reminder, republishes it under the same d tag (keeping its note) instead of adding a second reminder. Follow is no longer offered on Solo chat rows, which have no threads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tom Brow <tomb@squareup.com>
This commit is contained in:
@@ -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<void> _remindMe(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required String channelId,
|
||||
}) async {
|
||||
final List<Reminder> 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 {
|
||||
|
||||
@@ -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<void> 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) {
|
||||
|
||||
@@ -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<void> 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ void main() {
|
||||
ValueListenable<int>? tabReselection,
|
||||
List<ComposeDraft> drafts = const [],
|
||||
List<Reminder> reminders = const [],
|
||||
RemindersNotifier Function()? remindersNotifier,
|
||||
Set<String> knownAgentPubkeys = const {},
|
||||
List<Override> 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<Reminder> _reminders;
|
||||
_FakeRemindersNotifier(this._reminders);
|
||||
|
||||
int refreshes = 0;
|
||||
|
||||
@override
|
||||
Future<List<Reminder>> build() async => _reminders;
|
||||
|
||||
@override
|
||||
Future<void> refresh() async => refreshes++;
|
||||
}
|
||||
|
||||
class _FakeComposeDraftsNotifier extends ComposeDraftsNotifier {
|
||||
@@ -1208,14 +1289,16 @@ class _CapturingReminderService extends reminder.ReminderService {
|
||||
factory _CapturingReminderService() =>
|
||||
_CapturingReminderService._(nostr.Keys.generate());
|
||||
|
||||
final targets = <reminder.ReminderTarget>[];
|
||||
final saved =
|
||||
<({reminder.ReminderTarget target, String? note, String? dTag})>[];
|
||||
|
||||
@override
|
||||
Future<void> createReminder({
|
||||
required reminder.ReminderTarget target,
|
||||
required int notBefore,
|
||||
String? note,
|
||||
String? replacingDTag,
|
||||
}) async {
|
||||
targets.add(target);
|
||||
saved.add((target: target, note: note, dTag: replacingDTag));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user