Files
buzz/mobile/test/shared/deeplink/deep_link_test.dart
68f39f3697 feat(mobile): bring message actions to desktop parity (#3070)
**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>
2026-07-27 09:45:09 -07:00

296 lines
8.0 KiB
Dart

import 'package:buzz/shared/deeplink/deep_link.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
_inviteTests();
_buildMessageLinkTests();
group('parseMessageDeepLink', () {
test('parses channel and id', () {
final link = parseMessageDeepLink(
Uri.parse('buzz://message?channel=d14cd131&id=abc123'),
);
expect(
link,
const MessageDeepLink(channelId: 'd14cd131', messageId: 'abc123'),
);
});
test('parses optional thread param', () {
final link = parseMessageDeepLink(
Uri.parse('buzz://message?channel=d14cd131&id=abc123&thread=root99'),
);
expect(link?.threadRootId, 'root99');
});
test('treats empty thread as absent', () {
final link = parseMessageDeepLink(
Uri.parse('buzz://message?channel=d14cd131&id=abc123&thread='),
);
expect(link, isNotNull);
expect(link?.threadRootId, isNull);
});
test('rejects missing channel', () {
expect(parseMessageDeepLink(Uri.parse('buzz://message?id=abc')), isNull);
});
test('rejects empty channel', () {
expect(
parseMessageDeepLink(Uri.parse('buzz://message?channel=&id=abc')),
isNull,
);
});
test('rejects missing id', () {
expect(
parseMessageDeepLink(Uri.parse('buzz://message?channel=d14cd131')),
isNull,
);
});
test('rejects non-buzz scheme', () {
expect(
parseMessageDeepLink(Uri.parse('https://message?channel=a&id=b')),
isNull,
);
});
test('rejects non-message host (connect is desktop-only)', () {
expect(
parseMessageDeepLink(Uri.parse('buzz://connect?relay=wss://x')),
isNull,
);
});
});
}
void _inviteTests() {
group('parseInviteDeepLink', () {
test('parses canonical HTTPS invite URL', () {
final link = parseInviteDeepLink(
Uri.parse('https://relay.example.com/invite/abc123'),
);
expect(
link,
const InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'abc123',
),
);
});
test('parses HTTP invite URL for local/dev relays', () {
final link = parseInviteDeepLink(
Uri.parse('http://localhost:3000/invite/dev-code'),
);
expect(
link,
const InviteDeepLink(relayUrl: 'ws://localhost:3000', code: 'dev-code'),
);
});
test('parses buzz join handoff link', () {
final link = parseInviteDeepLink(
Uri.parse(
'buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=abc123',
),
);
expect(
link,
const InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'abc123',
),
);
});
test('normalizes trailing slash in buzz join handoff', () {
final link = parseInviteDeepLink(
Uri.parse(
'buzz://join?relay=wss%3A%2F%2Frelay.example.com%2F&code=abc123',
),
);
expect(link?.relayUrl, 'wss://relay.example.com');
});
test('rejects plaintext public buzz join handoff', () {
final relay = Uri.encodeQueryComponent('ws://relay.example.com');
expect(
parseInviteDeepLink(Uri.parse('buzz://join?relay=$relay&code=abc')),
isNull,
);
});
test('preserves policy receipt in buzz join handoff', () {
final link = parseInviteDeepLink(
Uri.parse(
'buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=abc123&policy_receipt=receipt.value',
),
);
expect(
link,
const InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'abc123',
policyReceipt: 'receipt.value',
),
);
});
test('rejects non-invite HTTPS paths', () {
expect(
parseInviteDeepLink(Uri.parse('https://relay.example.com/api/invites')),
isNull,
);
expect(
parseInviteDeepLink(Uri.parse('https://relay.example.com/invite/')),
isNull,
);
expect(
parseInviteDeepLink(Uri.parse('https://relay.example.com/invite/a/b')),
isNull,
);
});
test('rejects credentials and fragments', () {
expect(
parseInviteDeepLink(
Uri.parse('https://user:pass@relay.example.com/invite/abc'),
),
isNull,
);
expect(
parseInviteDeepLink(
Uri.parse('https://relay.example.com/invite/abc#x'),
),
isNull,
);
expect(
parseInviteDeepLink(
Uri.parse(
'buzz://join?relay=wss%3A%2F%2Fuser%3Apass%40relay.example.com&code=abc',
),
),
isNull,
);
});
test('rejects buzz join without websocket relay or code', () {
expect(
parseInviteDeepLink(
Uri.parse('buzz://join?relay=https://relay.example.com&code=abc'),
),
isNull,
);
expect(
parseInviteDeepLink(
Uri.parse('buzz://join?relay=wss://relay.example.com'),
),
isNull,
);
expect(
parseInviteDeepLink(Uri.parse('buzz://connect?relay=wss://x')),
isNull,
);
});
test('rejects non-public invite relay destinations', () {
for (final url in [
'https://127.0.0.1/invite/abc',
'https://169.254.169.254/invite/abc',
'https://192.168.1.1/invite/abc',
'https://[::1]/invite/abc',
'https://[::ffff:127.0.0.1]/invite/abc',
]) {
expect(parseInviteDeepLink(Uri.parse(url)), isNull, reason: url);
}
});
test('rejects buzz join with dangerous relay schemes', () {
// The `relay=` param is an allowlist — only `ws` / `wss` are safe to
// hand to a Nostr relay session. Anything else must be dropped by the
// parser so a hostile QR / share link can't smuggle a browser scheme
// (`javascript:`, `data:`), a local resource (`file:`), or an
// unrelated transport (`ftp:`, `chrome:`) into the join flow.
for (final hostile in [
'javascript:alert(1)',
'data:text/html,evil',
'file:///etc/passwd',
'ftp://relay.example.com',
'chrome://settings',
'about:blank',
'ssh://relay.example.com',
]) {
final encoded = Uri.encodeQueryComponent(hostile);
expect(
parseInviteDeepLink(Uri.parse('buzz://join?relay=$encoded&code=abc')),
isNull,
reason: 'must reject relay scheme in $hostile',
);
}
});
});
}
void _buildMessageLinkTests() {
group('buildMessageLink', () {
test('builds channel + id link', () {
expect(
buildMessageLink(channelId: 'd14cd131', messageId: 'abc123'),
'buzz://message?channel=d14cd131&id=abc123',
);
});
test('includes thread root when present', () {
expect(
buildMessageLink(
channelId: 'd14cd131',
messageId: 'abc123',
threadRootId: 'root99',
),
'buzz://message?channel=d14cd131&id=abc123&thread=root99',
);
});
test('treats empty thread root as absent', () {
expect(
buildMessageLink(
channelId: 'd14cd131',
messageId: 'abc123',
threadRootId: '',
),
'buzz://message?channel=d14cd131&id=abc123',
);
});
test('round-trips through parseMessageDeepLink', () {
final url = buildMessageLink(
channelId: 'chan-1',
messageId: 'msg-1',
threadRootId: 'root-1',
);
final parsed = parseMessageDeepLink(Uri.parse(url));
expect(
parsed,
const MessageDeepLink(
channelId: 'chan-1',
messageId: 'msg-1',
threadRootId: 'root-1',
),
);
});
test('throws on empty channel or id', () {
expect(
() => buildMessageLink(channelId: '', messageId: 'abc'),
throwsArgumentError,
);
expect(
() => buildMessageLink(channelId: 'chan', messageId: ''),
throwsArgumentError,
);
});
});
}