Files
buzz/mobile/test/shared/deeplink/deep_link_test.dart
Taylor Ho 5ddf23d700 feat(mobile-messages): render compact Buzz permalink chips (#5639)
**Category:** improvement
**User Impact:** Buzz channel, message, repository, pull request, and
issue links now display recognizable context and navigate reliably in
the mobile app.
**Problem:** Bare Buzz permalinks appeared as raw or ambiguous URLs on
mobile, while channel and message links were not handled consistently
across Markdown forms and startup states.
**Solution:** Normalize eligible bare Buzz URLs without consuming
Markdown syntax, render them as semantic icon-prefixed chips, and route
channel/message targets through the mobile deep-link dispatcher while
preserving authored Markdown labels as ordinary links.

<details>
<summary>File changes</summary>

**mobile/lib/features/channels/deep_link_dispatcher.dart**
Routes parsed channel and message links through the appropriate in-app
navigation callbacks.

**mobile/lib/features/channels/message_content.dart**
Presents all bare Buzz permalinks as semantic icon chips and keeps
authored labels as ordinary links.

**mobile/lib/features/channels/message_content/link_normalizer.dart**
Normalizes bare and autolinked Buzz URLs without consuming Markdown
delimiters, code, or punctuation.

**mobile/lib/shared/deeplink/deep_link.dart**
Adds strict channel and project-entity parsing alongside message deep
links.

**mobile/lib/shared/deeplink/pending_deep_link_provider.dart**
Preserves pending navigation until the mobile routing surface is ready.

**mobile/test/features/channels/channel_detail_page_test.dart**
Updates navigation integration coverage for icon-prefixed channel chips.

**mobile/test/features/channels/deep_link_dispatcher_test.dart**
Covers channel/message dispatch and missing-target behavior.


**mobile/test/features/channels/message_content/link_normalizer_test.dart**
Exercises Markdown-safe normalization across the full Buzz link suite.

**mobile/test/features/channels/message_content_test.dart**
Verifies chip labels, icons, semantics, authored-label opt-out, and
navigation callbacks.

**mobile/test/shared/deeplink/deep_link_test.dart**
Covers strict parsing for channel, message, repository, pull-request,
and issue links.

</details>

## Reproduction steps
1. Run the mobile app and open a channel containing bare
`buzz://channel`, `buzz://message`, `buzz://repo`, `buzz://pr`, and
`buzz://issue` URLs.
2. Confirm each bare URL renders as one cohesive chip with a type icon,
a useful name or shortened identifier, and no duplicated channel `#`
character.
3. Add an authored Markdown link such as `[design
discussion](buzz://issue?...)` and confirm the supplied label remains an
ordinary link rather than becoming a chip.
4. Select channel and message links and confirm they navigate correctly
from inline and autolinked forms.

## Screenshots / demos
**iOS Simulator — channel, message, repository, pull request, and issue
permalink chips**

Real app build (`37b2cb5eb`) running on an iPhone 17 Pro simulator.

![Mobile permalink chips on iOS
Simulator](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5639/mobile-permalink-chips-simulator.png)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-14 10:44:58 -07:00

409 lines
12 KiB
Dart

import 'package:buzz/shared/deeplink/deep_link.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
_inviteTests();
_channelTests();
_buildMessageLinkTests();
group('parseMessageDeepLink', () {
const channel = '580ca78b-9dae-46f3-8854-bd671853ba32';
const id =
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const thread =
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
test('parses canonical channel, id, and optional thread', () {
expect(
parseMessageDeepLink(
Uri.parse('buzz://message?channel=$channel&id=$id&thread=$thread'),
),
const MessageDeepLink(
channelId: channel,
messageId: id,
threadRootId: thread,
),
);
});
test('rejects malformed or ambiguous forms', () {
for (final url in [
'buzz://message?id=$id',
'buzz://message?channel=&id=$id',
'buzz://message?channel=$channel',
'https://message?channel=$channel&id=$id',
'buzz://connect?channel=$channel&id=$id',
'buzz://message:1234?channel=$channel&id=$id',
'buzz://message/path?channel=$channel&id=$id',
'buzz://message?channel=$channel&id=$id#fragment',
'buzz://user@message?channel=$channel&id=$id',
'buzz://message?channel=$channel&id=$id&extra=true',
'buzz://message?channel=$channel&channel=$channel&id=$id',
'buzz://message?channel=$channel&id=$id&id=$id',
'buzz://message?channel=$channel&id=$id&thread=',
'buzz://message?channel=not-a-uuid&id=$id',
'buzz://message?channel=$channel&id=not-hex',
'buzz://message?channel=$channel&id=$id&thread=not-hex',
]) {
expect(parseMessageDeepLink(Uri.parse(url)), isNull, reason: url);
}
});
});
}
void _channelTests() {
group('parseChannelDeepLink', () {
test('parses canonical channel path', () {
expect(
parseChannelDeepLink(
Uri.parse('buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32'),
),
const ChannelDeepLink(
channelId: '580ca78b-9dae-46f3-8854-bd671853ba32',
),
);
});
test('accepts v7 and canonicalizes uppercase UUIDs', () {
expect(
parseChannelDeepLink(
Uri.parse('buzz://channel/018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9'),
),
const ChannelDeepLink(
channelId: '018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9',
),
);
expect(
parseChannelDeepLink(
Uri.parse('buzz://channel/580CA78B-9DAE-46F3-8854-BD671853BA32'),
),
const ChannelDeepLink(
channelId: '580ca78b-9dae-46f3-8854-bd671853ba32',
),
);
});
test('rejects missing, extra, query, and fragment forms', () {
for (final url in [
'buzz://channel',
'buzz://channel/',
'buzz://channel/one/two',
'buzz://channel:1234/580ca78b-9dae-46f3-8854-bd671853ba32',
'buzz://channel/one?extra=true',
'buzz://channel/one#fragment',
'https://channel/one',
'buzz://channel/not-a-uuid',
'buzz://channel/%2F',
'buzz://channel/%00',
]) {
expect(parseChannelDeepLink(Uri.parse(url)), isNull, reason: url);
}
});
test('is included in the top-level parser', () {
expect(
parseBuzzDeepLink(
Uri.parse('buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32'),
),
const ChannelDeepLink(
channelId: '580ca78b-9dae-46f3-8854-bd671853ba32',
),
);
});
});
}
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: '580ca78b-9dae-46f3-8854-bd671853ba32',
messageId:
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
),
'buzz://message?channel=580ca78b-9dae-46f3-8854-bd671853ba32&id=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
);
});
test('includes thread root when present', () {
expect(
buildMessageLink(
channelId: '580ca78b-9dae-46f3-8854-bd671853ba32',
messageId:
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
threadRootId:
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
),
'buzz://message?channel=580ca78b-9dae-46f3-8854-bd671853ba32&id=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&thread=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
);
});
test('treats empty thread root as absent', () {
expect(
buildMessageLink(
channelId: '580ca78b-9dae-46f3-8854-bd671853ba32',
messageId:
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
threadRootId: '',
),
'buzz://message?channel=580ca78b-9dae-46f3-8854-bd671853ba32&id=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
);
});
test('round-trips through parseMessageDeepLink', () {
final url = buildMessageLink(
channelId: '580ca78b-9dae-46f3-8854-bd671853ba32',
messageId:
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
threadRootId:
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
);
final parsed = parseMessageDeepLink(Uri.parse(url));
expect(
parsed,
const MessageDeepLink(
channelId: '580ca78b-9dae-46f3-8854-bd671853ba32',
messageId:
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
threadRootId:
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
),
);
});
test('throws on empty channel or id', () {
expect(
() => buildMessageLink(channelId: '', messageId: 'abc'),
throwsArgumentError,
);
expect(
() => buildMessageLink(channelId: 'chan', messageId: ''),
throwsArgumentError,
);
});
});
group('entity deep links', () {
final owner = 'ab' * 32;
final id = 'cd' * 32;
test('parses repo, PR, and issue permalinks', () {
expect(
parseEntityDeepLink(Uri.parse('buzz://repo?owner=$owner&d=buzz'))?.type,
'repo',
);
expect(
parseEntityDeepLink(
Uri.parse('buzz://pr?id=$id&owner=$owner&d=buzz'),
)?.eventId,
id,
);
expect(
parseEntityDeepLink(
Uri.parse('buzz://issue?id=$id&owner=$owner&d=buzz'),
)?.type,
'issue',
);
});
test('rejects malformed entity permalinks', () {
expect(
parseEntityDeepLink(Uri.parse('buzz://repo?owner=short&d=buzz')),
isNull,
);
expect(
parseEntityDeepLink(
Uri.parse('buzz://pr?id=$id&owner=$owner&d=buzz&extra=true'),
),
isNull,
);
expect(
parseEntityDeepLink(Uri.parse('buzz://repo?owner=$owner&d=a..b')),
isNull,
);
expect(
parseEntityDeepLink(
Uri.parse('buzz://repo?owner=$owner&d=${'a' * 65}'),
),
isNull,
);
for (final url in [
'buzz://repo?owner=$owner&owner=$owner&d=buzz',
'buzz://repo?owner=$owner&d=buzz&d=other',
'buzz://pr?id=$id&id=$id&owner=$owner&d=buzz',
'buzz://issue?id=$id&owner=$owner&owner=$owner&d=buzz',
]) {
expect(parseEntityDeepLink(Uri.parse(url)), isNull, reason: url);
}
});
});
}