Files
buzz/mobile/lib/shared/deeplink/deep_link.dart
Tom Brow 83ec9c0e2b fix(mobile): show hosted community names consistently (#8872)
When a hosted relay has no metadata name, the hosted add paths save its
host (`remote-agent-pilot.communities.buzz.xyz`) as the community name.
Onboarding shortens that fallback to `remote-agent-pilot`. After
joining, though, the community switcher, the remove dialog, the theme
preview and the identity export showed the full host, so the same
community appeared under two names.

This change shortens the name in the model instead of at each call site.
`Community` and `AddCommunityDeepLink` keep the saved value private, and
their public `name` getter returns the display form, using the existing
`HostedEnvironment.communityDisplayName` rule. Existing and future
readers get the short name without opting in. Serialization still writes
the saved value, so stored data is unchanged and no migration is needed.
Custom names and non-hosted hosts pass through unchanged, as before.
Community identity is unaffected because it uses `id` and `relayUrl`,
never the name.

Beyond the captures below, the same getter shortens the name in the iOS
push notification subtitle, onboarding's "Adding …" progress text, the
"You joined …" invite sheet, and the VoiceOver labels for removing and
loading a community.

### Before and after

| | Before (`main`) | After |
|---|---|---|
| Community switcher |
![01a-before-switcher](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--01a-before-switcher.png)
|
![01b-after-switcher](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--01b-after-switcher.png)
|
| Remove dialog |
![02a-before-remove](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--02a-before-remove.png)
|
![02b-after-remove](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--02b-after-remove.png)
|
| Theme preview |
![03a-before-theme](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--03a-before-theme.png)
|
![03b-after-theme](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--03b-after-theme.png)
|
| Identity export |
![04a-before-nsec](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--04a-before-nsec.png)
|
![04b-after-nsec](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--04b-after-nsec.png)
|

### Before merge

- [x] Kenny Lopez reviews the visible changes ([approved in Buzz
#buzz-mobile](buzz://message?channel=9f18f3c6-b5e5-453d-b36d-65487913be0b&id=c8b01eaa3760e44c3b6583f2e02bec576d78679adfb364e5fe718cc0926bdc2d)).

### Validation

- Captures come from iPhone 17 simulators (iOS 27) running `main` and
this branch. Both builds used the same scratch-only seed with two
communities whose saved names are their hosted hosts. The seed is not
part of this PR. The relays were unreachable from the simulator, so the
screens show loading placeholders.
- Changed tests: the community switcher journey now uses a hosted
host-name fallback for one community. It asserts the grid label, the
remove tooltip and the dialog text show the short name, and it fails
when the getter returns the saved value. The onboarding label table now
asserts the model's `name` itself is shortened. The hosted-flow fixture
records relay hosts instead of names, and the auth-domain tests look up
communities by relay URL.
- The full mobile test suite and `flutter analyze` pass. The CI step
that runs with configured authentication domains also passes locally
(113 tests).
- Not captured: the push notification subtitle, onboarding's "Adding …"
text and the invite sheet. Reaching them needs hosted sign-in or push,
which the simulator doesn't support.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 07:30:09 -08:00

469 lines
14 KiB
Dart

/// Parsing for `buzz://` deep links.
///
/// Mirrors the desktop handler in `desktop/src-tauri/src/deep_link.rs`:
/// `buzz://message?channel=<uuid>&id=<hex>[&thread=<hex>]` references a
/// message (optionally inside a thread) in a channel. Required params that
/// are missing or empty make the link invalid — the caller never sees a
/// half-formed target.
library;
import '../relay/relay_validation.dart';
import '../hosted/hosted_environment.dart';
/// A parsed deep link supported by the app.
sealed class BuzzDeepLink {
const BuzzDeepLink();
/// Community addressed by entry links; other actions have no relay target.
String? get relayUrl => null;
}
/// A parsed relay invite link.
///
/// Canonical share links are `https://<relay>/invite/<code>`. The custom
/// `buzz://join?relay=<ws(s)://relay>&code=<code>` form is only an installed-app
/// handoff from the web landing page.
class InviteDeepLink extends BuzzDeepLink {
/// Relay URL normalized to the websocket scheme used by the app.
@override
final String relayUrl;
/// Invite code from the link.
final String code;
/// Optional receipt proving acceptance of the relay's current join policy.
final String? policyReceipt;
const InviteDeepLink({
required this.relayUrl,
required this.code,
this.policyReceipt,
});
@override
bool operator ==(Object other) =>
other is InviteDeepLink &&
other.relayUrl == relayUrl &&
other.code == code &&
other.policyReceipt == policyReceipt;
@override
int get hashCode => Object.hash(relayUrl, code, policyReceipt);
@override
String toString() =>
'InviteDeepLink(relay: $relayUrl, code: $code, policyReceipt: $policyReceipt)';
}
/// A parsed channel-only deep link.
///
/// Canonical form: `buzz://channel/<channel-uuid>`.
class ChannelDeepLink extends BuzzDeepLink {
/// Channel UUID from the sole path segment.
final String channelId;
const ChannelDeepLink({required this.channelId});
@override
bool operator ==(Object other) =>
other is ChannelDeepLink && other.channelId == channelId;
@override
int get hashCode => channelId.hashCode;
@override
String toString() => 'ChannelDeepLink(channel: $channelId)';
}
/// A parsed `buzz://message` deep link.
class MessageDeepLink extends BuzzDeepLink {
/// Local community identifier for notification-originated links.
/// Canonical shared links omit this because community IDs are device-local.
final String? communityId;
/// Channel UUID from the `channel` query param.
final String channelId;
/// Event ID (hex) from the `id` query param.
final String messageId;
/// Optional thread root event ID from the `thread` query param.
final String? threadRootId;
const MessageDeepLink({
this.communityId,
required this.channelId,
required this.messageId,
this.threadRootId,
});
@override
bool operator ==(Object other) =>
other is MessageDeepLink &&
other.communityId == communityId &&
other.channelId == channelId &&
other.messageId == messageId &&
other.threadRootId == threadRootId;
@override
int get hashCode =>
Object.hash(communityId, channelId, messageId, threadRootId);
@override
String toString() =>
'MessageDeepLink(community: $communityId, channel: $channelId, id: $messageId, '
'thread: $threadRootId)';
}
/// Build a canonical `buzz://message` link for a channel message.
///
/// Mirrors `desktop/src/features/messages/lib/messageLink.ts` so links copied
/// or shared from mobile round-trip through every client's parser:
/// `buzz://message?channel=<uuid>&id=<eventId>[&thread=<rootId>]`.
///
/// An empty [threadRootId] is treated as "no thread" so callers can pass
/// through a nullable thread reference without extra checks.
String buildMessageLink({
required String channelId,
required String messageId,
String? threadRootId,
}) {
if (channelId.isEmpty) {
throw ArgumentError('buildMessageLink: channelId is required');
}
if (messageId.isEmpty) {
throw ArgumentError('buildMessageLink: messageId is required');
}
final params = <String, String>{
'channel': channelId,
'id': messageId,
if (threadRootId != null && threadRootId.isNotEmpty) 'thread': threadRootId,
};
return Uri(
scheme: 'buzz',
host: 'message',
queryParameters: params,
).toString();
}
/// Parse a canonical `buzz://channel/<channel-uuid>` URI.
///
/// The channel ID must be the URI's sole non-empty path segment. Query
/// parameters and fragments are rejected so malformed or ambiguous links never
/// become navigation targets.
ChannelDeepLink? parseChannelDeepLink(Uri uri) {
if (uri.scheme != 'buzz' || uri.host != 'channel') return null;
if (uri.hasQuery ||
uri.hasFragment ||
uri.userInfo.isNotEmpty ||
uri.hasPort) {
return null;
}
if (uri.pathSegments.length != 1 || uri.pathSegments.single.isEmpty) {
return null;
}
final channelId = uri.pathSegments.single;
if (!RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
caseSensitive: false,
).hasMatch(channelId)) {
return null;
}
return ChannelDeepLink(channelId: channelId.toLowerCase());
}
/// Parse a `buzz://message?…` URI into a [MessageDeepLink].
///
/// Returns `null` unless the URI exactly matches the canonical message-link
/// shape: no path, fragment, credentials, duplicate or unknown parameters; a
/// UUID channel; and 64-character hexadecimal message/thread event IDs.
MessageDeepLink? parseMessageDeepLink(Uri uri) {
if (uri.scheme != 'buzz' || uri.host != 'message') return null;
if (uri.path.isNotEmpty ||
uri.hasFragment ||
uri.userInfo.isNotEmpty ||
uri.hasPort) {
return null;
}
const allowedParams = {'channel', 'id', 'thread'};
if (uri.queryParametersAll.keys.any((key) => !allowedParams.contains(key)) ||
uri.queryParametersAll.values.any((values) => values.length != 1)) {
return null;
}
final channel = uri.queryParameters['channel'];
final id = uri.queryParameters['id'];
final thread = uri.queryParameters['thread'];
final uuid = RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
caseSensitive: false,
);
final eventId = RegExp(r'^[0-9a-f]{64}$', caseSensitive: false);
if (channel == null ||
!uuid.hasMatch(channel) ||
id == null ||
!eventId.hasMatch(id) ||
(thread != null && !eventId.hasMatch(thread))) {
return null;
}
return MessageDeepLink(
channelId: channel.toLowerCase(),
messageId: id.toLowerCase(),
threadRootId: thread?.toLowerCase(),
);
}
/// Parse canonical HTTPS invite links and `buzz://join` app handoffs.
///
/// Accepted forms:
/// - `https://<relay>/invite/<code>` -> `wss://<relay>` + code
/// - `http://localhost/invite/<code>` -> `ws://localhost` + code in debug builds
/// - `buzz://join?relay=<wss://relay>&code=<code>` -> relay + code
/// - `buzz://join?relay=<ws://localhost>&code=<code>` -> local relay in debug
///
/// Rejects credentials, fragments, missing params, nested relay credentials, and
/// non-invite paths so scanners do not accidentally treat arbitrary URLs as
/// community admission links.
InviteDeepLink? parseInviteDeepLink(Uri uri) {
if (uri.hasFragment || uri.userInfo.isNotEmpty) return null;
if (uri.scheme == 'buzz') {
if (uri.host != 'join') return null;
final relay = uri.queryParameters['relay'];
final code = uri.queryParameters['code'];
if (relay == null || relay.isEmpty || code == null || code.isEmpty) {
return null;
}
final relayUri = Uri.tryParse(relay);
if (relayUri == null ||
(relayUri.scheme != 'ws' && relayUri.scheme != 'wss') ||
relayUri.host.isEmpty ||
relayUri.userInfo.isNotEmpty ||
relayUri.hasFragment) {
return null;
}
try {
validateInviteRelayUri(relayUri);
} on FormatException {
return null;
}
final normalizedRelay = Uri(
scheme: relayUri.scheme,
host: relayUri.host,
port: relayUri.hasPort ? relayUri.port : null,
).toString();
final policyReceipt = uri.queryParameters['policy_receipt'];
return InviteDeepLink(
relayUrl: normalizedRelay,
code: code,
policyReceipt: policyReceipt == null || policyReceipt.isEmpty
? null
: policyReceipt,
);
}
if (uri.scheme == 'https' || uri.scheme == 'http') {
if (uri.host.isEmpty) return null;
final segments = uri.pathSegments;
if (segments.length != 2 ||
segments[0] != 'invite' ||
segments[1].isEmpty) {
return null;
}
final relayScheme = uri.scheme == 'https' ? 'wss' : 'ws';
final relayUri = Uri(
scheme: relayScheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
);
try {
validateInviteRelayUri(relayUri);
} on FormatException {
return null;
}
final relay = Uri(
scheme: relayScheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
).toString();
return InviteDeepLink(relayUrl: relay, code: segments[1]);
}
return null;
}
/// Parse any supported Buzz deep link.
BuzzDeepLink? parseBuzzDeepLink(Uri uri) =>
parseNostrBindDeepLink(uri) ??
parseAddCommunityDeepLink(uri) ??
parseInviteDeepLink(uri) ??
parseChannelDeepLink(uri) ??
parseMessageDeepLink(uri);
/// A validated Buzz repository, pull request, or issue permalink.
class EntityDeepLink extends BuzzDeepLink {
final String type;
final String owner;
final String repository;
final String? eventId;
const EntityDeepLink({
required this.type,
required this.owner,
required this.repository,
this.eventId,
});
}
/// Parse canonical `buzz://repo|pr|issue` permalinks for inline presentation.
EntityDeepLink? parseEntityDeepLink(Uri uri) {
if (uri.scheme != 'buzz' || !{'repo', 'pr', 'issue'}.contains(uri.host)) {
return null;
}
if (uri.path.isNotEmpty ||
uri.hasFragment ||
uri.userInfo.isNotEmpty ||
uri.hasPort) {
return null;
}
final allowed = uri.host == 'repo' ? {'owner', 'd'} : {'id', 'owner', 'd'};
final queryParameters = uri.queryParametersAll;
final parameterKeys = queryParameters.keys.toSet();
if (parameterKeys.difference(allowed).isNotEmpty ||
allowed.difference(parameterKeys).isNotEmpty ||
allowed.any((key) => queryParameters[key]?.length != 1)) {
return null;
}
final owner = uri.queryParameters['owner'];
final repository = uri.queryParameters['d'];
final eventId = uri.queryParameters['id'];
final hex = RegExp(r'^[0-9a-f]{64}$', caseSensitive: false);
final repositoryName = RegExp(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$');
if (owner == null ||
!hex.hasMatch(owner) ||
repository == null ||
!repositoryName.hasMatch(repository) ||
repository.contains('..')) {
return null;
}
if (uri.host != 'repo' && (eventId == null || !hex.hasMatch(eventId))) {
return null;
}
return EntityDeepLink(
type: uri.host,
owner: owner.toLowerCase(),
repository: repository,
eventId: eventId?.toLowerCase(),
);
}
/// Web signup's challenge. Values are untrusted until authenticated verification.
class NostrBindDeepLink extends BuzzDeepLink {
final Map<String, String> fields;
NostrBindDeepLink._(Map<String, String> fields)
: fields = Map.unmodifiable(fields);
String get challengeId => fields['challenge_id']!;
String get nonce => fields['nonce']!;
DateTime get expiresAt => DateTime.parse(fields['expires_at']!);
@override
String toString() => 'NostrBindDeepLink(redacted)';
}
class AddCommunityDeepLink extends BuzzDeepLink {
@override
final String relayUrl;
final String _name;
/// User-facing label, shortening hosted host-name fallbacks like [Community].
String get name => HostedEnvironment.communityDisplayName(_name, relayUrl);
const AddCommunityDeepLink({required this.relayUrl, required String name})
: _name = name;
}
bool _webHandoffShape(Uri uri, String host, Set<String> allowed) =>
uri.scheme == 'buzz' &&
uri.host == host &&
uri.path.isEmpty &&
!uri.hasFragment &&
!uri.hasPort &&
uri.userInfo.isEmpty &&
uri.toString().length <= 8192 &&
uri.queryParametersAll.keys.every(allowed.contains) &&
uri.queryParametersAll.values.every((values) => values.length == 1);
NostrBindDeepLink? parseNostrBindDeepLink(Uri uri) {
const fields = {
'challenge_id',
'nonce',
'verification_code',
'audience',
'action',
'protocol',
'version',
'origin',
'expires_at',
'return',
'callback_url',
};
if (!_webHandoffShape(uri, 'nostr-bind', fields) ||
!fields.every(uri.queryParameters.containsKey)) {
return null;
}
final p = uri.queryParameters;
if (!RegExp(
r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$',
).hasMatch(p['challenge_id']!) ||
!RegExp(r'^[A-Za-z0-9_-]{43}$').hasMatch(p['nonce']!) ||
!RegExp(r'^[0-9]{6}$').hasMatch(p['verification_code']!) ||
p['audience'] != 'buzz:nostr-identity' ||
p['action'] != 'bind_nostr_identity' ||
p['protocol'] != 'buzz-nostr-identity' ||
p['version'] != '1' ||
p['origin'] != HostedEnvironment.origin.toString() ||
p['return'] != 'browser_fragment_v1' ||
p['callback_url'] !=
HostedEnvironment.origin.resolve('/buzz').toString() ||
!RegExp(r'(Z|[+-][0-9]{2}:[0-9]{2})$').hasMatch(p['expires_at']!) ||
DateTime.tryParse(p['expires_at']!) == null) {
return null;
}
return NostrBindDeepLink._(p);
}
AddCommunityDeepLink? parseAddCommunityDeepLink(Uri uri) {
if (!_webHandoffShape(uri, 'add-community', {'relay', 'name'})) return null;
final relay = Uri.tryParse(uri.queryParameters['relay'] ?? '');
final name = uri.queryParameters['name'];
if (relay == null ||
relay.scheme != 'wss' ||
relay.host.isEmpty ||
relay.host.endsWith('.') ||
relay.userInfo.isNotEmpty ||
relay.hasQuery ||
relay.hasFragment ||
(relay.path.isNotEmpty && relay.path != '/') ||
name == null ||
name.trim().isEmpty ||
name.length > 200) {
return null;
}
try {
validateInviteRelayUri(relay);
} on FormatException {
return null;
}
return AddCommunityDeepLink(
relayUrl: Uri(
scheme: 'wss',
host: relay.host,
port: relay.hasPort ? relay.port : null,
).toString(),
name: name,
);
}