mirror of
https://github.com/block/buzz.git
synced 2026-11-14 05:12:27 +00:00
Long-pressing an Inbox row opened a sheet with only **Mark as
read/unread** and **Open conversation**. To follow a thread or set a
reminder, you had to open the conversation and long-press the message
itself.
The row sheet now also offers:
- **Follow thread / Unfollow thread**: acts on the conversation's thread
root, or on the message itself for a top-level row (the same rule the
message actions sheet uses). It's hidden for Solo chats, which have no
threads, and for grouped DM conversations, which have no single thread
to follow.
- **Remind me**: opens the existing "Remind me later" sheet for the
row's latest message and publishes the same kind-30300 reminder. If that
message already has a pending reminder, the new time reschedules it: the
event reuses its `d` tag (an addressable replace) and keeps its note, so
you don't get a second reminder. It loads the reminder list first,
because otherwise the list loads only with the Reminders filter. It's
hidden when signed out, like the message sheet.
The sheet is now scrollable so the four rows fit on short screens and at
large text sizes (without it, the new test overflowed at the default
test surface).
### Demo
Long-press a channel mention row in the Inbox, follow the thread, reopen
the sheet (now **Unfollow thread**), then set an "In 1 hour" reminder.
The rows are agent test chatter from a pilot community.
https://github.com/user-attachments/assets/ad94e5f2-1048-4585-9663-145e814892e3
### Validation
- Added tests: one Activity page journey that follows and unfollows a
thread row, follows a top-level row, then sets a reminder through the
sheet and checks the published target (event, channel, preview). Further
tests cover rescheduling an existing pending reminder (same `d` tag,
note kept, "Reminder updated"), hiding Follow in a Solo chat, and hiding
Remind me when signed out. The `test/features/activity`,
`test/shared/reminders` and message-actions suites pass.
- Re-run on the branch head (99b2418) in an iPhone 17 Pro simulator (iOS
27) debug build, same community: long-pressed a thread-capable channel
mention row, tapped **Follow thread**, reopened the sheet and saw
**Unfollow thread**, tapped **Remind me** and then "In 1 hour", and saw
the "Reminder set" toast (this is the video above). Long-pressing a Solo
chat row showed no **Follow thread** row, as intended. Not covered on
the simulator: rescheduling an existing reminder, signed-out state,
large text sizes. Those are covered by the widget tests.
- Automated app run on an iPhone 17 Pro simulator (iOS 27) signed in to
the Remote Agents Pilot community: opened Inbox, long-pressed a channel
mention row, followed the thread, reopened the sheet and saw **Unfollow
thread**, then set an "In 1 hour" reminder and saw the "Reminder set"
toast. That run predates the reschedule and Solo changes, which are
covered by the widget tests above. I didn't re-run unrelated Inbox flows
(swipe to mark read, filters) on the simulator. Those are covered by the
existing widget tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
157 lines
4.7 KiB
Dart
157 lines
4.7 KiB
Dart
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
|
|
import '../crypto/ecdh.dart';
|
|
import '../crypto/nip44.dart';
|
|
import '../relay/relay.dart';
|
|
|
|
/// The message a reminder points back to. Mirrors the desktop
|
|
/// `ReminderTarget` shape (`desktop/src/features/reminders/lib/reminderTypes.ts`)
|
|
/// so reminders created on mobile are readable by desktop and vice versa.
|
|
class ReminderTarget {
|
|
/// Event ID of the target message.
|
|
final String eventId;
|
|
|
|
/// Channel ID where the message lives.
|
|
final String channelId;
|
|
|
|
/// Preview text of the target message (truncated).
|
|
final String preview;
|
|
|
|
/// Author pubkey of the target message.
|
|
final String authorPubkey;
|
|
|
|
const ReminderTarget({
|
|
required this.eventId,
|
|
required this.channelId,
|
|
required this.preview,
|
|
required this.authorPubkey,
|
|
});
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'eventId': eventId,
|
|
'channelId': channelId,
|
|
'preview': preview,
|
|
'authorPubkey': authorPubkey,
|
|
};
|
|
}
|
|
|
|
/// Build the NIP-ER reminder plaintext for a new pending reminder.
|
|
///
|
|
/// Matches the JSON desktop writes in `reminderService.ts#createReminder`:
|
|
/// `{"target": {...}, "note": <optional>, "status": "pending"}`. `note` is
|
|
/// omitted entirely when absent so both clients parse each other's payloads.
|
|
String buildReminderPlaintext({required ReminderTarget target, String? note}) {
|
|
return jsonEncode({
|
|
'target': target.toJson(),
|
|
if (note != null && note.isNotEmpty) 'note': note,
|
|
'status': 'pending',
|
|
});
|
|
}
|
|
|
|
/// Generate a reminder `d`-tag with 128 bits of entropy (NIP-ER MUST).
|
|
String randomReminderDTag() {
|
|
final bytes = secureRandomBytes(16);
|
|
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
|
}
|
|
|
|
/// Tags for a new pending reminder event: `d` plus strict-decimal
|
|
/// `not_before`, exactly as the relay's NIP-ER validator expects.
|
|
List<List<String>> buildReminderTags({
|
|
required String dTag,
|
|
required int notBefore,
|
|
}) {
|
|
if (notBefore < 0) {
|
|
throw ArgumentError('notBefore must be a non-negative Unix timestamp');
|
|
}
|
|
return [
|
|
['d', dTag],
|
|
['not_before', '$notBefore'],
|
|
];
|
|
}
|
|
|
|
/// NIP-44 self-encryption for author-private reminder payloads. Derives the
|
|
/// conversation key to the author's own pubkey, matching the desktop's
|
|
/// `nip44_encrypt_to_self` Tauri command.
|
|
class ReminderCrypto {
|
|
final Uint8List _conversationKey;
|
|
|
|
ReminderCrypto(String nsec, String pubkey)
|
|
: _conversationKey = _deriveKey(nsec, pubkey);
|
|
|
|
static Uint8List _deriveKey(String nsec, String pubkey) {
|
|
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
|
|
return getConversationKey(privkeyHex, pubkey);
|
|
}
|
|
|
|
String encrypt(String plaintext) => nip44Encrypt(_conversationKey, plaintext);
|
|
|
|
String decrypt(String ciphertext) =>
|
|
nip44Decrypt(_conversationKey, ciphertext);
|
|
}
|
|
|
|
/// Creates author-private kind-30300 reminders on the relay.
|
|
class ReminderService {
|
|
final SignedEventRelay _signedEventRelay;
|
|
final ReminderCrypto _crypto;
|
|
|
|
ReminderService({
|
|
required SignedEventRelay signedEventRelay,
|
|
required ReminderCrypto crypto,
|
|
}) : _signedEventRelay = signedEventRelay,
|
|
_crypto = crypto;
|
|
|
|
/// 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: replacingDTag ?? randomReminderDTag(),
|
|
notBefore: notBefore,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Provides a [ReminderService], or null when no signing identity is
|
|
/// available (signed out).
|
|
final reminderServiceProvider = Provider<ReminderService?>((ref) {
|
|
final relayConfig = ref.watch(relayConfigProvider);
|
|
final pubkey = ref.watch(myPubkeyProvider);
|
|
|
|
final nsec = relayConfig.nsec?.trim();
|
|
if (nsec == null || nsec.isEmpty || pubkey == null || pubkey.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
final ReminderCrypto crypto;
|
|
try {
|
|
crypto = ReminderCrypto(nsec, pubkey);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
|
|
return ReminderService(
|
|
signedEventRelay: SignedEventRelay(
|
|
session: ref.read(relaySessionProvider.notifier),
|
|
nsec: nsec,
|
|
),
|
|
crypto: crypto,
|
|
);
|
|
});
|