feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../l10n/l10n.dart';
|
||||
import '../theme/theme.dart';
|
||||
import '../widgets/sheet_divider.dart';
|
||||
import '../widgets/modal_presentation.dart';
|
||||
import 'reminder_service.dart';
|
||||
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.
|
||||
void showRemindMeLaterSheet({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required ReminderTarget target,
|
||||
}) {
|
||||
final service = ref.read(reminderServiceProvider);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
if (service == null) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.signInSetReminders)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Future<void> submit(int notBefore) async {
|
||||
try {
|
||||
await service.createReminder(target: target, notBefore: notBefore);
|
||||
messenger.showSnackBar(SnackBar(content: Text(context.l10n.reminderSet)));
|
||||
} catch (error) {
|
||||
// Stable user-facing copy; the relay/crypto detail goes to the log.
|
||||
debugPrint('[RemindMeLater] createReminder failed: $error');
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.reminderSetFailed)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: IconTheme.merge(
|
||||
data: const IconThemeData(size: 22),
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: Grid.half,
|
||||
bottom: Grid.xxs,
|
||||
),
|
||||
child: Text(
|
||||
sheetContext.l10n.remindAboutMessage,
|
||||
style: Theme.of(sheetContext).textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
for (final preset in reminderTimePresets)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.clock),
|
||||
title: Text(_presetLabel(sheetContext, preset)),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
submit(preset.getTimestamp());
|
||||
},
|
||||
),
|
||||
const SheetDivider(),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.calendarClock),
|
||||
title: Text(sheetContext.l10n.pickDateTime),
|
||||
onTap: () async {
|
||||
final navigator = Navigator.of(sheetContext);
|
||||
final timestamp = await _pickCustomDateTime(context);
|
||||
// Cancelled or not-in-the-future: keep the preset sheet
|
||||
// open so retrying doesn't mean long-pressing again.
|
||||
if (timestamp == null) return;
|
||||
if (navigator.mounted) navigator.pop();
|
||||
await submit(timestamp);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Native date + time pickers chained together. Returns a future Unix
|
||||
/// timestamp in seconds, or null when cancelled or not in the future.
|
||||
Future<int?> _pickCustomDateTime(BuildContext context) async {
|
||||
final now = DateTime.now();
|
||||
final date = await showBuzzDialog<DateTime>(
|
||||
context: context,
|
||||
builder: (_) => DatePickerDialog(
|
||||
initialDate: now,
|
||||
firstDate: DateTime(now.year, now.month, now.day),
|
||||
lastDate: now.add(const Duration(days: 365 * 2)),
|
||||
),
|
||||
);
|
||||
if (date == null || !context.mounted) return null;
|
||||
|
||||
final time = await showBuzzDialog<TimeOfDay>(
|
||||
context: context,
|
||||
builder: (_) =>
|
||||
const TimePickerDialog(initialTime: TimeOfDay(hour: 9, minute: 0)),
|
||||
);
|
||||
if (time == null) return null;
|
||||
|
||||
final timestamp = combineCustomDateTime(date, time.hour, time.minute);
|
||||
if (timestamp == null && context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.pickFutureTime)));
|
||||
}
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
String _presetLabel(BuildContext context, ReminderTimePreset preset) {
|
||||
final l10n = context.l10n;
|
||||
return switch (preset.id) {
|
||||
ReminderTimePresetId.in30Minutes => l10n.reminderIn30Minutes,
|
||||
ReminderTimePresetId.in1Hour => l10n.reminderIn1Hour,
|
||||
ReminderTimePresetId.in3Hours => l10n.reminderIn3Hours,
|
||||
ReminderTimePresetId.tomorrow9am => l10n.reminderTomorrow9am,
|
||||
ReminderTimePresetId.nextMonday9am => l10n.reminderNextMonday9am,
|
||||
ReminderTimePresetId.legacy => preset.label,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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 new pending reminder. [notBefore] is a Unix
|
||||
/// timestamp in seconds after which clients surface the reminder.
|
||||
Future<void> createReminder({
|
||||
required ReminderTarget target,
|
||||
required int notBefore,
|
||||
String? note,
|
||||
}) 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/// Reminder time presets shared by the "Remind me later" sheet.
|
||||
///
|
||||
/// Mirrors `desktop/src/features/reminders/lib/timePresets.ts` — same labels,
|
||||
/// same "always strictly in the future" guarantee.
|
||||
library;
|
||||
|
||||
class ReminderTimePreset {
|
||||
final ReminderTimePresetId id;
|
||||
final String label;
|
||||
final int Function() getTimestamp;
|
||||
|
||||
const ReminderTimePreset({
|
||||
this.id = ReminderTimePresetId.legacy,
|
||||
required this.label,
|
||||
required this.getTimestamp,
|
||||
});
|
||||
}
|
||||
|
||||
/// Stable identifiers let the sheet localize labels without putting UI
|
||||
/// strings into the reminder scheduling model.
|
||||
enum ReminderTimePresetId {
|
||||
legacy,
|
||||
in30Minutes,
|
||||
in1Hour,
|
||||
in3Hours,
|
||||
tomorrow9am,
|
||||
nextMonday9am,
|
||||
}
|
||||
|
||||
int _nowSeconds() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
/// Next occurrence of [dayOffset] days from now at 9am local time. If that
|
||||
/// instant is already past, roll to the following day so the result is
|
||||
/// always in the future.
|
||||
int nextDayAt9am(int dayOffset, {DateTime? now}) {
|
||||
final current = now ?? DateTime.now();
|
||||
var target = DateTime(
|
||||
current.year,
|
||||
current.month,
|
||||
current.day + dayOffset,
|
||||
9,
|
||||
);
|
||||
if (!target.isAfter(current)) {
|
||||
target = DateTime(target.year, target.month, target.day + 1, 9);
|
||||
}
|
||||
return target.millisecondsSinceEpoch ~/ 1000;
|
||||
}
|
||||
|
||||
/// Days until next Monday (1-7), matching the desktop preset semantics.
|
||||
int daysUntilNextMonday(DateTime now) {
|
||||
final days = (DateTime.monday - now.weekday) % 7;
|
||||
return days == 0 ? 7 : days;
|
||||
}
|
||||
|
||||
final List<ReminderTimePreset> reminderTimePresets = [
|
||||
ReminderTimePreset(
|
||||
id: ReminderTimePresetId.in30Minutes,
|
||||
label: 'In 30 minutes',
|
||||
getTimestamp: () => _nowSeconds() + 30 * 60,
|
||||
),
|
||||
ReminderTimePreset(
|
||||
id: ReminderTimePresetId.in1Hour,
|
||||
label: 'In 1 hour',
|
||||
getTimestamp: () => _nowSeconds() + 60 * 60,
|
||||
),
|
||||
ReminderTimePreset(
|
||||
id: ReminderTimePresetId.in3Hours,
|
||||
label: 'In 3 hours',
|
||||
getTimestamp: () => _nowSeconds() + 3 * 60 * 60,
|
||||
),
|
||||
ReminderTimePreset(
|
||||
id: ReminderTimePresetId.tomorrow9am,
|
||||
label: 'Tomorrow at 9am',
|
||||
getTimestamp: () => nextDayAt9am(1),
|
||||
),
|
||||
ReminderTimePreset(
|
||||
id: ReminderTimePresetId.nextMonday9am,
|
||||
label: 'Next Monday at 9am',
|
||||
getTimestamp: () => nextDayAt9am(daysUntilNextMonday(DateTime.now())),
|
||||
),
|
||||
];
|
||||
|
||||
/// Combine a picked date and time into a future Unix timestamp (seconds), or
|
||||
/// null when the instant is not strictly in the future — the shared guard for
|
||||
/// the custom picker so a past time never fires immediately.
|
||||
int? combineCustomDateTime(DateTime date, int hour, int minute) {
|
||||
final target = DateTime(date.year, date.month, date.day, hour, minute);
|
||||
final timestamp = target.millisecondsSinceEpoch ~/ 1000;
|
||||
if (timestamp <= _nowSeconds()) return null;
|
||||
return timestamp;
|
||||
}
|
||||
Reference in New Issue
Block a user