mirror of
https://github.com/block/buzz.git
synced 2026-11-04 16:31:38 +00:00
Builderlab agent cards (printed for the event, and generated at the kiosk) carry a QR to a share page, `app.builderlab.xyz/a/<id>`. Its **Add to Buzz** button opens `buzz://agent/new?card=<id>`, which no Buzz client handled until now. This PR makes mobile handle it. ### Behavior - Opening the link shows the Agents page, then a Create agent form prefilled from the card. Everything stays editable, and nothing is created until the user taps Create. - The app fetches the card's `agent.json` from the avatars CDN for this build's Builderlab environment (production or staging). The link carries only the card id, never a host, so the QR stays small no matter how long a card's instructions get. - `agent.json` is validated against the card schema. `id` (matching the link), `name` and `avatarUrl` (an https URL) are required. `instructions`, `model` and `reasoningEffort` are optional (published cards don't have them yet) and left for the user when absent. A missing required field or a wrongly typed known field rejects the card; any other key is ignored whatever its type, including `v` and `description` (Buzz agents have no description to prefill), so Builderlab can add fields without breaking released apps. The model and effort are preselected only if the live catalog offers them. The card id is opaque: any non-empty id is accepted and percent-encoded into the URL. - The fetch uses the same limits as image downloads, now shared in `bounded_fetch.dart`: aborted after 20 s without a byte or 5 min overall, and rejected over 2 MB. `MediaImageProvider` now calls the same helper, with no behavior change. - The link waits for sign-in, then reloads the registry for the signed-in account before anything else. It's dropped with a message if the card is unavailable (403/404), invalid, can't be loaded (any other response), or an agent setup is unfinished, including a setup saved before a sign-in the link triggered. - A link that arrives while Agents shows only its list replaces that page instead of stacking a second one. If a form or sheet is open there, the link is dropped with "Finish or close what’s open in Agents, then reopen the link." and the open work is kept. ### Demo Simulator (iPhone 17 Pro, debug build of this branch plus the sign-in fix from [#8914](https://github.com/block/buzz/pull/8914) and the hidden debug banner from [#8915](https://github.com/block/buzz/pull/8915), signed in to Builderlab, remote-agent-pilot), recorded in one take with idle time trimmed: the card's share page in Safari, Add to Buzz, the prefilled Create agent form, a model, thinking level and instructions, Create, then a message and the agent's reply. https://github.com/user-attachments/assets/e66aae17-884a-4e68-a1b8-ecb40562be1d ### Validation Added tests: - Table tests for the link shape (only `?card=<id>` with an opaque id; the inline `#` form, extra parameters and dot segments are rejected) and for reading `agent.json` (a published card, a card with instructions and model defaults, unknown keys of any type ignored, including `v`, `description`, arrays, objects and null, and invalid cards: missing or mismatched required fields, wrong types, and non-https or relative avatars). - Table tests for fetching: the request goes to this build's CDN without following redirects, with the id percent-encoded as one path segment; 403/404, a redirect, a 500, a network error, declared and streamed oversize bodies, non-JSON and a JSON array each give a specific message. The image loader's existing stall, deadline, cap and cancel tests now cover the shared helper. - A journey test from link to Create against a fake CDN: prefill and edit, then submit; an unavailable card; a failed model catalog whose Retry then preselects the card's model; a Builderlab account change while the card loads, which drops the draft; a v1 card with no instructions and an unknown model; an unfinished setup; a link arriving while a Create form with edits is open, which keeps the form and its edits; and a link arriving while Agents is open. Disabling the https or id check makes the table test fail, and removing either the account recheck or the loaded-catalog check fails the journey. The journey runs through the production root scope (removing its link handler fails it) and runs in CI's configured-authentication-domains step. Full mobile check and test suite pass locally. Live data: a real staging card's `agent.json` (`av_MioTAG6dm7q3Zcm0UV2j8A`) has the v1 shape the table test uses, and an unknown id returns 403 from the CDN. Live run on an iPhone SE (build of 63ed54c749, Builderlab sign-in enabled, remote-agent-pilot, signed in as builderlab@tombrow.com) with a real production card, [ToastyMacbeth](https://app.builderlab.xyz/a/av_lrmRlJR6ylrQKvJkoLWCOQ) (a v1 card with no instructions and an extra `description` key): - Opening `buzz://agent/new?card=av_lrmRlJR6ylrQKvJkoLWCOQ` from a cold start opens Create agent prefilled with the card's name and avatar. - After choosing a model and adding instructions, Create adds the agent with the card avatar, and it replies in a DM. - An unknown card id shows "This agent card isn’t available." | Prefilled | Created | Reply | Unknown card | | --- | --- | --- | --- | | <img src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--01-prefilled.png" width="200"> | <img src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--02-created.png" width="200"> | <img src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--03-reply.png" width="200"> | <img src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--04-unavailable.png" width="200"> | Before this PR, the app had no handler for `buzz://agent/new`, so the link did nothing. A separate widget test covers a link that needs Builderlab sign-in first: once sign-in lands, a setup saved for that account blocks the card, which is never fetched. Removing the registry reload fails it, as does removing the busy-Agents guard for the journey step above. ### Before merge - [x] Human live check waived under the repository owner's standing testing exception. In its place, agent-driven full-app runs exercised the flow end to end: the iPhone SE device run and the simulator demo above (a real card's share page, **Add to Buzz**, the prefilled form, Create and a reply from the agent). Limitation: those runs predate [638f5ecac7](638f5ecac7), whose two guards are covered by the widget tests above rather than a live run. 🤖 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: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
472 lines
14 KiB
Dart
472 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';
|
|
|
|
part 'agent_creation_link.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) =>
|
|
parseAgentCreationDeepLink(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,
|
|
);
|
|
}
|