mirror of
https://github.com/block/buzz.git
synced 2026-11-11 20:01:09 +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
|
/// Thread root that Follow acts on: the conversation's root for thread
|
||||||
/// rows, the message itself for top-level rows (matching the message
|
/// rows, the message itself for top-level rows (matching the message
|
||||||
/// actions sheet). Grouped DM conversations have no single thread to follow.
|
/// actions sheet). Solo chats have no threads, and grouped DM conversations
|
||||||
String? get _followRootId =>
|
/// have no single thread, so neither offers Follow.
|
||||||
item.conversationId.startsWith('dm:') ? null : item.conversationId;
|
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) {
|
void _showRowActions(BuildContext context, WidgetRef ref) {
|
||||||
final followRootId = _followRootId;
|
final followRootId = _followRootId;
|
||||||
@@ -433,17 +437,8 @@ class _InboxRow extends HookConsumerWidget {
|
|||||||
title: const Text('Remind me'),
|
title: const Text('Remind me'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(sheetContext).pop();
|
Navigator.of(sheetContext).pop();
|
||||||
showRemindMeLaterSheet(
|
unawaited(
|
||||||
context: context,
|
_remindMe(context, ref, channelId: reminderChannelId),
|
||||||
ref: ref,
|
|
||||||
target: ReminderTarget(
|
|
||||||
eventId: item.item.id,
|
|
||||||
channelId: reminderChannelId,
|
|
||||||
preview: item.item.displayContent.characters
|
|
||||||
.take(_inboxReminderPreviewLength)
|
|
||||||
.toString(),
|
|
||||||
authorPubkey: item.item.pubkey,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -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 {
|
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
|
/// Bottom sheet for "Remind me later": desktop-parity presets plus a native
|
||||||
/// date/time picker, publishing the same author-private kind-30300 reminder
|
/// date/time picker, publishing the same author-private kind-30300 reminder
|
||||||
/// events desktop reads.
|
/// 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({
|
void showRemindMeLaterSheet({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required WidgetRef ref,
|
required WidgetRef ref,
|
||||||
required ReminderTarget target,
|
required ReminderTarget target,
|
||||||
|
({String dTag, String? note})? existing,
|
||||||
|
VoidCallback? onSaved,
|
||||||
}) {
|
}) {
|
||||||
final service = ref.read(reminderServiceProvider);
|
final service = ref.read(reminderServiceProvider);
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
@@ -29,9 +35,17 @@ void showRemindMeLaterSheet({
|
|||||||
|
|
||||||
Future<void> submit(int notBefore) async {
|
Future<void> submit(int notBefore) async {
|
||||||
try {
|
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(
|
messenger.showFeedbackSnackBar(
|
||||||
const SnackBar(content: Text('Reminder set')),
|
SnackBar(
|
||||||
|
content: Text(existing == null ? 'Reminder set' : 'Reminder updated'),
|
||||||
|
),
|
||||||
kind: ToastKind.success,
|
kind: ToastKind.success,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -104,19 +104,26 @@ class ReminderService {
|
|||||||
}) : _signedEventRelay = signedEventRelay,
|
}) : _signedEventRelay = signedEventRelay,
|
||||||
_crypto = crypto;
|
_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.
|
/// 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({
|
Future<void> createReminder({
|
||||||
required ReminderTarget target,
|
required ReminderTarget target,
|
||||||
required int notBefore,
|
required int notBefore,
|
||||||
String? note,
|
String? note,
|
||||||
|
String? replacingDTag,
|
||||||
}) async {
|
}) async {
|
||||||
final plaintext = buildReminderPlaintext(target: target, note: note);
|
final plaintext = buildReminderPlaintext(target: target, note: note);
|
||||||
final ciphertext = _crypto.encrypt(plaintext);
|
final ciphertext = _crypto.encrypt(plaintext);
|
||||||
await _signedEventRelay.submit(
|
await _signedEventRelay.submit(
|
||||||
kind: EventKind.eventReminder,
|
kind: EventKind.eventReminder,
|
||||||
content: ciphertext,
|
content: ciphertext,
|
||||||
tags: buildReminderTags(dTag: randomReminderDTag(), notBefore: notBefore),
|
tags: buildReminderTags(
|
||||||
|
dTag: replacingDTag ?? randomReminderDTag(),
|
||||||
|
notBefore: notBefore,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ void main() {
|
|||||||
ValueListenable<int>? tabReselection,
|
ValueListenable<int>? tabReselection,
|
||||||
List<ComposeDraft> drafts = const [],
|
List<ComposeDraft> drafts = const [],
|
||||||
List<Reminder> reminders = const [],
|
List<Reminder> reminders = const [],
|
||||||
|
RemindersNotifier Function()? remindersNotifier,
|
||||||
Set<String> knownAgentPubkeys = const {},
|
Set<String> knownAgentPubkeys = const {},
|
||||||
List<Override> overrides = const [],
|
List<Override> overrides = const [],
|
||||||
}) async {
|
}) async {
|
||||||
@@ -156,7 +157,9 @@ void main() {
|
|||||||
composeDraftsProvider.overrideWith(
|
composeDraftsProvider.overrideWith(
|
||||||
() => _FakeComposeDraftsNotifier(drafts),
|
() => _FakeComposeDraftsNotifier(drafts),
|
||||||
),
|
),
|
||||||
remindersProvider.overrideWith(() => _FakeRemindersNotifier(reminders)),
|
remindersProvider.overrideWith(
|
||||||
|
remindersNotifier ?? () => _FakeRemindersNotifier(reminders),
|
||||||
|
),
|
||||||
...overrides,
|
...overrides,
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
@@ -892,8 +895,10 @@ void main() {
|
|||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
final reminders = _CapturingReminderService();
|
final reminders = _CapturingReminderService();
|
||||||
|
final remindersNotifier = _FakeRemindersNotifier(const []);
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
await buildTestable(
|
await buildTestable(
|
||||||
|
remindersNotifier: () => remindersNotifier,
|
||||||
overrides: [
|
overrides: [
|
||||||
reminder.reminderServiceProvider.overrideWithValue(reminders),
|
reminder.reminderServiceProvider.overrideWithValue(reminders),
|
||||||
],
|
],
|
||||||
@@ -944,11 +949,82 @@ void main() {
|
|||||||
await tester.tap(find.text('In 1 hour'));
|
await tester.tap(find.text('In 1 hour'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final target = reminders.targets.single;
|
final first = reminders.saved.single;
|
||||||
expect(target.eventId, 'm1');
|
expect(first.target.eventId, 'm1');
|
||||||
expect(target.channelId, 'ch1');
|
expect(first.target.channelId, 'ch1');
|
||||||
expect(target.preview, 'Hey check this out');
|
expect(first.target.preview, 'Hey check this out');
|
||||||
|
expect(first.dTag, isNull);
|
||||||
expect(find.text('Reminder set'), findsOneWidget);
|
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 {
|
testWidgets('long-press hides reminders when signed out', (tester) async {
|
||||||
@@ -1183,8 +1259,13 @@ class _FakeRemindersNotifier extends RemindersNotifier {
|
|||||||
final List<Reminder> _reminders;
|
final List<Reminder> _reminders;
|
||||||
_FakeRemindersNotifier(this._reminders);
|
_FakeRemindersNotifier(this._reminders);
|
||||||
|
|
||||||
|
int refreshes = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Reminder>> build() async => _reminders;
|
Future<List<Reminder>> build() async => _reminders;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> refresh() async => refreshes++;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakeComposeDraftsNotifier extends ComposeDraftsNotifier {
|
class _FakeComposeDraftsNotifier extends ComposeDraftsNotifier {
|
||||||
@@ -1208,14 +1289,16 @@ class _CapturingReminderService extends reminder.ReminderService {
|
|||||||
factory _CapturingReminderService() =>
|
factory _CapturingReminderService() =>
|
||||||
_CapturingReminderService._(nostr.Keys.generate());
|
_CapturingReminderService._(nostr.Keys.generate());
|
||||||
|
|
||||||
final targets = <reminder.ReminderTarget>[];
|
final saved =
|
||||||
|
<({reminder.ReminderTarget target, String? note, String? dTag})>[];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> createReminder({
|
Future<void> createReminder({
|
||||||
required reminder.ReminderTarget target,
|
required reminder.ReminderTarget target,
|
||||||
required int notBefore,
|
required int notBefore,
|
||||||
String? note,
|
String? note,
|
||||||
|
String? replacingDTag,
|
||||||
}) async {
|
}) async {
|
||||||
targets.add(target);
|
saved.add((target: target, note: note, dTag: replacingDTag));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class _RecordingReminderService extends ReminderService {
|
|||||||
required ReminderTarget target,
|
required ReminderTarget target,
|
||||||
required int notBefore,
|
required int notBefore,
|
||||||
String? note,
|
String? note,
|
||||||
|
String? replacingDTag,
|
||||||
}) async {
|
}) async {
|
||||||
final error = this.error;
|
final error = this.error;
|
||||||
if (error != null) throw error;
|
if (error != null) throw error;
|
||||||
|
|||||||
Reference in New Issue
Block a user