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:
Tom Brow
2026-11-05 13:56:32 -08:00
parent b0ade6a182
commit 99b2418a01
5 changed files with 172 additions and 25 deletions

View File

@@ -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 {

View File

@@ -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) {

View File

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

View File

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

View File

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