mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
**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.  --------- 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>
409 lines
12 KiB
Dart
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);
|
|
}
|
|
});
|
|
});
|
|
}
|