mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
**Category:** new-feature **User Impact:** Mobile users can copy permalinks, revisit, follow, and manage messages through a clearer long-press menu that matches desktop capabilities. **Problem:** The mobile message menu exposed only a small subset of desktop actions, and important workflows such as copying a permalink or scheduling a reminder were unavailable or hard to discover. **Solution:** Bring applicable desktop actions to mobile using native patterns, promote Reply, Copy link, and Remind me above the fold, and group the remaining actions by intent in a scrollable sheet. **Intentional behavior changes (per review):** - The quick-reaction row goes from 6 emojis to 4 (👀 and 🙏 dropped) to make room for larger 52px tap targets alongside the `+` picker, which still offers the full set. - **Copy link — not a native share sheet — is the permalink path.** An earlier revision shipped a `share_plus` Share message row; it was removed in review since Copy link covers the job and custom-scheme `buzz://` URIs are handled inconsistently by share targets. Native share can return as a follow-up with an https fallback. - Mark unread is message-scoped and session-local: it forces just that message unread (surfacing its channel as unread), and message-level Mark read can never clear a channel-level unread set from the channel tile. <details> <summary>File changes</summary> **mobile/lib/features/channels/channels_provider.dart** Feeds followed thread roots into unread and notification evaluation so following a thread has meaningful behavior. **mobile/lib/features/channels/message_actions.dart** Reworks the long-press sheet with promoted fast actions, message links, reminders, read state, thread following, and clearer action grouping while preserving existing guards. Quick-reaction circles share one extracted widget. **mobile/lib/features/channels/read_state/message_read_state.dart** Centralizes message-level unread evaluation across channel, message, and thread markers; channel-level forced unread deliberately does not leak into message state. **mobile/lib/features/channels/read_state/read_state_provider.dart** Forced-unread flags are per-context (channel id or `msg:` key) mapped to their channel, so message- and channel-level unread choices round-trip independently. **mobile/lib/features/channels/thread_follows/thread_follows_provider.dart** Exposes per-identity thread follow state to the message menu and notification pipeline. **mobile/lib/features/channels/thread_follows/thread_follows_storage.dart** Persists a bounded, validated set of followed thread roots on the device. **mobile/lib/shared/reminders/remind_me_later_sheet.dart** Adds reminder presets and a native custom date/time flow for deferring a message. Lives under `shared/` so the channels feature never imports another feature module. Cancelling the custom picker keeps the preset sheet open; submission failures show stable copy and log the underlying error. **mobile/lib/shared/reminders/reminder_service.dart** Creates desktop-compatible, self-encrypted kind-30300 reminder events. **mobile/lib/shared/reminders/reminder_time_presets.dart** Defines reminder choices that match the desktop experience. **mobile/lib/shared/deeplink/deep_link.dart** Builds canonical Buzz message links, including thread context when present. **mobile/lib/shared/relay/nostr_models.dart** Adds the reminder event kind to the shared Nostr model constants. **mobile/lib/shared/widgets/sheet_divider.dart** Shared bottom-sheet section divider used by the message actions and reminder sheets. **mobile/test/features/channels/message_actions_test.dart** Covers action visibility and guards, promoted actions, read/unread round-tripping (including channel- vs message-level force isolation), thread follows, and canonical links. **mobile/test/features/channels/read_state/message_read_state_test.dart** Covers unread precedence for channel, message, and thread contexts. **mobile/test/features/channels/thread_follows/thread_follows_storage_test.dart** Covers follow persistence, identity separation, validation, and storage bounds. **mobile/test/shared/reminders/reminder_service_test.dart** Covers reminder payloads, tags, crypto round-tripping, and preset behavior. **mobile/test/features/channels/read_state/read_state_provider_test.dart** Drives the production ReadStateNotifier/ReadStateManager (no fake bookkeeping) through message unread → read → unread round-trips, explicit channel-level Mark read clearing forced messages, and automatic channel-open reads preserving them. **mobile/test/shared/reminders/remind_me_later_sheet_test.dart** Covers custom-picker cancel keeping the sheet open, stable failure copy without the raw error, and the happy preset path. **mobile/test/shared/deeplink/deep_link_test.dart** Covers canonical top-level and threaded message-link generation. </details> ## Reproduction steps 1. Run the mobile app with a signed-in identity and open a channel containing regular messages and threads. 2. Long-press a message and confirm reactions plus Reply, Copy link, and Remind me appear as fast actions above the fold. 3. Use Copy link; confirm the resulting `buzz://message` link opens the correct channel and thread context. 4. Toggle Mark unread/Mark read and Follow thread/Unfollow thread, reopening the sheet to confirm each state changes correctly. Force a channel unread from the channel tile, then mark a message read — the channel stays unread. 5. Choose a reminder preset and a custom date/time; confirm the reminder is created and appears in the desktop reminder experience. Cancel the custom date picker and confirm the reminder sheet stays open. 6. Long-press a system message and a message you cannot manage; confirm utility and destructive actions remain appropriately hidden. ## Screenshots / demos <img width="1206" height="2622" alt="image" src="https://github.com/user-attachments/assets/81096cd6-329b-408f-bcff-712e23b268a4" /> --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
125 lines
3.8 KiB
Dart
125 lines
3.8 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:buzz/shared/reminders/reminder_service.dart';
|
|
import 'package:buzz/shared/reminders/reminder_time_presets.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
|
|
void main() {
|
|
const target = ReminderTarget(
|
|
eventId: 'event-1',
|
|
channelId: 'channel-1',
|
|
preview: 'hello world',
|
|
authorPubkey: 'author-pk',
|
|
);
|
|
|
|
group('buildReminderPlaintext', () {
|
|
test('matches the desktop NIP-ER content shape', () {
|
|
final plaintext = buildReminderPlaintext(target: target, note: 'ping');
|
|
expect(jsonDecode(plaintext), {
|
|
'target': {
|
|
'eventId': 'event-1',
|
|
'channelId': 'channel-1',
|
|
'preview': 'hello world',
|
|
'authorPubkey': 'author-pk',
|
|
},
|
|
'note': 'ping',
|
|
'status': 'pending',
|
|
});
|
|
});
|
|
|
|
test('omits note when absent or empty', () {
|
|
for (final note in [null, '']) {
|
|
final decoded =
|
|
jsonDecode(buildReminderPlaintext(target: target, note: note))
|
|
as Map<String, dynamic>;
|
|
expect(decoded.containsKey('note'), isFalse);
|
|
expect(decoded['status'], 'pending');
|
|
}
|
|
});
|
|
});
|
|
|
|
group('randomReminderDTag', () {
|
|
test('is 32 lowercase hex chars (128 bits)', () {
|
|
final dTag = randomReminderDTag();
|
|
expect(dTag, matches(RegExp(r'^[0-9a-f]{32}$')));
|
|
});
|
|
|
|
test('is unique across calls', () {
|
|
expect(randomReminderDTag(), isNot(randomReminderDTag()));
|
|
});
|
|
});
|
|
|
|
group('buildReminderTags', () {
|
|
test('emits d and strict-decimal not_before tags', () {
|
|
expect(buildReminderTags(dTag: 'abc', notBefore: 1753000000), [
|
|
['d', 'abc'],
|
|
['not_before', '1753000000'],
|
|
]);
|
|
});
|
|
|
|
test('rejects negative timestamps', () {
|
|
expect(
|
|
() => buildReminderTags(dTag: 'abc', notBefore: -1),
|
|
throwsArgumentError,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('ReminderCrypto', () {
|
|
test('encrypts to self and decrypts back', () {
|
|
final keys = nostr.Keys.generate();
|
|
final crypto = ReminderCrypto(keys.nsec, keys.public);
|
|
|
|
const plaintext = '{"status":"pending","note":"hi"}';
|
|
final ciphertext = crypto.encrypt(plaintext);
|
|
expect(ciphertext, isNot(contains('pending')));
|
|
expect(crypto.decrypt(ciphertext), plaintext);
|
|
});
|
|
});
|
|
|
|
group('reminder time presets', () {
|
|
test('presets resolve strictly in the future', () {
|
|
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
for (final preset in reminderTimePresets) {
|
|
expect(preset.getTimestamp(), greaterThan(now), reason: preset.label);
|
|
}
|
|
});
|
|
|
|
test('nextDayAt9am rolls past instants forward', () {
|
|
final tenAm = DateTime(2026, 7, 25, 10);
|
|
final next = nextDayAt9am(0, now: tenAm);
|
|
expect(
|
|
DateTime.fromMillisecondsSinceEpoch(next * 1000),
|
|
DateTime(2026, 7, 26, 9),
|
|
);
|
|
});
|
|
|
|
test('nextDayAt9am keeps future same-day instants', () {
|
|
final eightAm = DateTime(2026, 7, 25, 8);
|
|
final next = nextDayAt9am(0, now: eightAm);
|
|
expect(
|
|
DateTime.fromMillisecondsSinceEpoch(next * 1000),
|
|
DateTime(2026, 7, 25, 9),
|
|
);
|
|
});
|
|
|
|
test('daysUntilNextMonday is 1-7 and lands on Monday', () {
|
|
for (var day = 20; day < 27; day++) {
|
|
final now = DateTime(2026, 7, day);
|
|
final days = daysUntilNextMonday(now);
|
|
expect(days, inInclusiveRange(1, 7));
|
|
expect(now.add(Duration(days: days)).weekday, DateTime.monday);
|
|
}
|
|
});
|
|
|
|
test('combineCustomDateTime rejects past instants', () {
|
|
final yesterday = DateTime.now().subtract(const Duration(days: 1));
|
|
expect(combineCustomDateTime(yesterday, 9, 0), isNull);
|
|
|
|
final tomorrow = DateTime.now().add(const Duration(days: 1));
|
|
expect(combineCustomDateTime(tomorrow, 9, 0), isNotNull);
|
|
});
|
|
});
|
|
}
|