Files
buzz/mobile/lib/shared/deeplink/deep_link.dart
Tom Brow 2b428f56b8 feat(mobile): share into Buzz from the iOS share sheet (#9433)
Lets people share screenshots, screen recordings, photos, URLs and text
from any iOS app into Buzz. Today Buzz doesn't appear in the share sheet
at all.

- **Plain Buzz icon:** opens a new Solo chat with the items in its
composer.
- **Suggested conversation:** iOS offers channels and DMs the person
recently sent top-level messages in. Choosing one opens that
conversation with the items in its composer. When the conversation is
already open, the share goes to that page's composer instead of opening
a second one; if another page covers it, Buzz stays where it is and
shows "Go back to that conversation to finish sharing", and the items
join the draft when the person goes back.
- The share extension has no UI and sends nothing. It copies the items
into the app group and opens Buzz, which prefills the normal composer,
so drafts, attachments, mentions and failed-send handling all stay the
app's (see [the
ADR](https://github.com/block/buzz/blob/mobile-share-extension-design/docs/adr/0003-share-hands-off-to-the-conversation.md)).
- If the conversation already has a draft, Buzz asks whether to add the
shared items to it or replace it.
- A share to a conversation that's no longer available (a removed
community or channel, or an unreadable share) shows "That conversation
isn't available". A share while signed out shows "Sign in to share".

Suggestions come from an outgoing `INSendMessageIntent` the app donates
after a top-level send or a new Solo chat. The notification extension no
longer donates incoming messages, so receiving a message never makes a
conversation a suggestion; it still turns the push into a communication
notification with the sender's avatar. Both use the same conversation
identifier, now the plain community and channel IDs instead of a hash,
because the extension needs to hand the identifier back to the app.

| Before | After |
| --- | --- |
| <img alt="Share sheet without Buzz"
src="https://github.com/user-attachments/assets/452fac1b-7e87-44c3-a442-099f78d0e07b"
width="300"> | <img alt="Share sheet with Buzz"
src="https://github.com/user-attachments/assets/54b9775d-29b4-49a5-8ab9-5fda8718a02f"
width="300"> |

| Buzz icon: new Solo chat | Suggested DM with a draft | After Add |
| --- | --- | --- |
| <img alt="New Solo chat with the shared photo"
src="https://github.com/user-attachments/assets/c5da4d8b-1dd8-4e30-9e92-9dba515b3623"
width="250"> | <img alt="Add to your draft prompt"
src="https://github.com/user-attachments/assets/2f879f8a-3b9f-48e0-b963-2828e90dc9fe"
width="250"> | <img alt="Draft with the shared link and photo added"
src="https://github.com/user-attachments/assets/12832006-85c5-4447-98b5-62932552c931"
width="250"> |


https://github.com/user-attachments/assets/b9d380fa-e802-4ed7-89a8-592a03784c8f

### Regressions

- Nothing changes for people who never use the share sheet, except that
sending a top-level message or starting a Solo chat now makes one local
`INInteraction.donate` call (skipped while the age restriction is on,
which still purges every interaction).
- Communication notifications look the same. Dropping the incoming
donation removes work (and a deletion fence) from the notification
extension's time budget.
- The share hand-off reuses the existing deep link dispatcher. A share
link is a new link type; notification and message links take the same
paths as before.

### Validation

- Added tests:
- Dispatcher hand-off: a share to a suggested channel opens it with the
shared text and files; shares to a removed community, an unlisted
channel, an unknown manifest version or a missing manifest show the
error and delete the pending share; share links accept only the
extension's UUID ids.
- Composer delivery: with an existing draft, Add appends the shared text
on a new line and attaches the file, Replace clears the draft first, and
the app group copy is deleted either way.
- Notification presenter: specializes the notification from the intent,
or falls back to the ordinary content when that throws (replaces the
donation and deletion-fence tests).
- Full mobile Flutter suite and BuzzPushKit Swift tests pass.
- Automated app run on an iPhone 17 Pro simulator (iOS 27, dev relay
test community), recorded above: sent a DM, shared a photo from Photos
with the Buzz icon from a cold start (new Solo chat with the photo
attached), then handed a link and photo to a DM that had a draft and
chose Add. Also checked that a share's pending files are deleted after
delivery.
- Limitation: the iOS simulator never shows share-sheet conversation
suggestions (it has no `coreduetd`), so the suggested-DM step in the
video is staged by writing the same pending share and link the extension
writes. Choosing a real suggestion still needs a check on a device.
- Known gaps, all confined to sharing: suggestions have no avatar image
yet; a suggestion for a conversation left after signing out shows "isn't
available"; choosing Later in the unfinished-chat dialog keeps the share
until the next new chat; and a pending share the app never receives (for
example when Buzz doesn't open) stays in the app group.

### Before merge

- [x] On a real iPhone, choose a suggested conversation in the share
sheet and confirm the shared items open in that conversation's composer
(the simulator can't show suggestions). Waived before merge: the managed
test iPhone shows no share-sheet suggestions for any app, so this will
be checked on a personal phone after landing.

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
2026-11-12 22:57:21 -08:00

474 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';
part 'share_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) ??
parseShareDeepLink(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,
);
}