mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
## Summary This fixes a dead end in mobile Builderlab sign-in. A new account (unbound, with no backups) on a device that already had saved identities could only pick one of those identities. Any saved key forced the picker, and creating a key required the device to have none. If the service then definitively rejected binding the chosen key, the app showed "Let's try that again" with no way forward: the chosen key stayed in the account's slot, so every retry and every launch re-sent the same rejected bind. For an unclaimed account, the picker now also offers **Create a new identity**: - Creation writes only this account's empty identity slot, so every saved and device key is kept. - Creation is offered only when every current device key was among the choices shown, so a new identity never silently passes over a key the user didn't see. - Bound accounts, accounts with backups, pairing and pending import never offer it. When binding a chosen key is definitively rejected, the blocked screen offers **Choose a different identity**: - It appears only for a 400, 404, 409 or 422 from `/verify` in the current attempt, for the current account and pubkey, and never during pairing, whose transferred key is the only choice. Evidence from an earlier attempt is cleared before each bind. A challenge rejection, a 403, a 503 or any other ambiguous failure offers nothing. - Before releasing the slot, it rechecks the account: still unbound (`/current` and status), no backups, the same subject, and a slot that still holds that pubkey without a receipt. - It releases the slot only when another durable copy of the same key exists (the device inventory or another account slot), then verifies the slot is empty. No key is deleted: only the account's pointer to the rejected choice is cleared. A failed or refused release keeps the slot and the choice, so it can be retried in place or after a restart. - After release, the picker runs again and offers the saved keys plus creation. The recovery code lives in `hosted_flow_identity_recovery.dart`, a part of `hosted_flow.dart`, to stay within the mobile file-size limit. ### Related issue None found. ### Testing **Physical iPhone, a signed release build of `1fb633d55` installed over the existing app (keychain and saved identities kept):** a saved identity was rejected, then Choose a different identity, the picker, Create a new identity and Join your community. Joining then completed. This head differs from `1fb633d55` only in the guards for leaving, pairing and a failed or refused release listed below, which this journey does not reach, and in no UI code. The status of that phone's original rejection was not captured, so it is not confirmed to be one of the rejections above. The 409 case is covered by tests. **Tests (red on `main`, green here):** - Identity service and store: creation beside offered keys; refusal for unseen or partly offered device keys; release when another copy exists; refusal for a sole copy, a binding, a backup, a receipt, another account or an unusable recheck; failed delete and retry; a cancelled attempt. - Onboarding journeys: create beside saved keys; select, rejected bind, restart, choose a different identity, create and bind; ambiguous 503 and 403 failures; challenge rejection and stale evidence. - Release boundaries: an account switch at `/current`, at status, or between the recheck and the delete; cancelling between the recheck and the delete; leaving the picker before a late creation; an unreadable device inventory; leaving while a release is in flight; a failed delete retried in place and after a restart; a release refused because the inventory is unreadable, then retried in place; a rejected paired key. Each keeps every key, leaves the new account's slot untouched and leaves a cancelled flow idle, and a retry converges. - Mutating the creation guard, the generation check, the binding recheck, the store's receipt check, the account and cancellation checks before the delete, or the device-inventory error handling fails a test. #### Screenshots Physical iPhone at `1fb633d55` (frames from a screen recording; saved public keys redacted): | Rejected bind | After Choose a different identity | After Create a new identity | | --- | --- | --- | |  |  |  | Widget-test renders of the same screens (the real `HostedFlow` against a mocked account service, with a fixture key; the square at the top left is the test renderer's placeholder for an asset): | Picker on an unclaimed account | Rejected bind | | --- | --- | |  |  | 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: John Tennant <jtennant@squareup.com> Co-authored-by: Xander <ff30c859b96bd5233ffaf5d071200458f1274bd0dab03589214cf1055198491d@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1195 lines
40 KiB
Dart
1195 lines
40 KiB
Dart
import '../storage/serial_queue.dart';
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
import '../deeplink/deep_link.dart';
|
|
import '../community/community.dart';
|
|
import 'account_identity_store.dart';
|
|
import 'device_identity_source.dart';
|
|
import 'browser_auth.dart';
|
|
import 'builderlab_account.dart';
|
|
import 'hosted_identity_service.dart';
|
|
import 'hosted_backup_service.dart';
|
|
import 'hpke_backup.dart';
|
|
import 'hosted_environment.dart';
|
|
import 'hosted_communities.dart';
|
|
import 'hosted_community_creation.dart';
|
|
import 'hosted_community_connection.dart';
|
|
import 'web_connect_binding.dart';
|
|
import 'hosted_join_policy.dart';
|
|
|
|
part 'hosted_flow_identity_recovery.dart';
|
|
|
|
enum HostedFlowStage {
|
|
idle,
|
|
working,
|
|
signIn,
|
|
chooseIdentity,
|
|
setupIdentity,
|
|
chooseCommunities,
|
|
communityConflict,
|
|
acceptPolicy,
|
|
profile,
|
|
blocked,
|
|
browserReturned,
|
|
complete,
|
|
}
|
|
|
|
enum HostedFlowProblem {
|
|
retry,
|
|
denied,
|
|
accountSetupRequired,
|
|
differentAccount,
|
|
identityUnreadable,
|
|
backupUnavailable,
|
|
unsupportedPlatform,
|
|
invalidResponse,
|
|
requestRejected,
|
|
identityMismatch,
|
|
identityUnavailable,
|
|
backupRequired,
|
|
bindingRequired,
|
|
unsupportedEnvironment,
|
|
invitePolicyRequired,
|
|
inviteExpired,
|
|
inviteExhausted,
|
|
inviteInvalid,
|
|
inviteRestricted,
|
|
inviteOwnerMismatch,
|
|
communityCreationRejected,
|
|
}
|
|
|
|
/// Resolves only after relay admission is proven and local persistence completes.
|
|
/// Implementations must check [isCurrent] immediately before durable mutations.
|
|
typedef HostedCommunityAdmission =
|
|
Future<void> Function(
|
|
BuzzDeepLink target,
|
|
String subject,
|
|
String pubkey,
|
|
bool Function() isCurrent,
|
|
);
|
|
|
|
/// Reopens the completed Add destination after restoring older memberships.
|
|
typedef HostedCommunityReturn =
|
|
Future<void> Function(bool Function() isCurrent);
|
|
|
|
/// Account-scoped picker state retained while the Add route is open.
|
|
class HostedCommunityChoices {
|
|
HostedCommunityChoices._(
|
|
this._subject,
|
|
this._communities,
|
|
this._selectedRelays,
|
|
this._landingRelay,
|
|
this._addedRelays,
|
|
);
|
|
|
|
final String _subject;
|
|
final List<AddCommunityDeepLink> _communities;
|
|
final Set<String> _selectedRelays;
|
|
final String? _landingRelay;
|
|
final Set<String> _addedRelays;
|
|
}
|
|
|
|
/// App-scoped owner: callback subscriptions must outlive any individual page.
|
|
/// Only public progress reaches widgets; pending auth secrets remain in BrowserAuth.
|
|
class HostedFlow extends ChangeNotifier {
|
|
HostedFlow({
|
|
required this.account,
|
|
required this.browser,
|
|
required this.store,
|
|
required this.identity,
|
|
required this.enterCommunity,
|
|
this.openLandingCommunity,
|
|
this.captureAddedCommunity,
|
|
Future<bool> Function(Uri)? openWeb,
|
|
}) : _openWeb =
|
|
openWeb ??
|
|
((uri) => launchUrl(uri, mode: LaunchMode.externalApplication));
|
|
final BuilderlabAccountClient account;
|
|
final BrowserAuth browser;
|
|
final AccountIdentityStore store;
|
|
final HostedIdentityService identity;
|
|
final HostedCommunityAdmission enterCommunity;
|
|
final HostedCommunityAdmission? openLandingCommunity;
|
|
|
|
/// Captures the persisted Add destination before restoring older memberships.
|
|
final Future<HostedCommunityReturn> Function()? captureAddedCommunity;
|
|
bool _returnToAddedCommunity = false;
|
|
HostedCommunityReturn? _openAddedCommunity;
|
|
|
|
/// Restoring older memberships must not replace the newly added destination.
|
|
bool get restoringAfterAdd => _returnToAddedCommunity;
|
|
bool get canRetry =>
|
|
problem == null ||
|
|
problem == HostedFlowProblem.retry ||
|
|
problem == HostedFlowProblem.invitePolicyRequired ||
|
|
problem == HostedFlowProblem.inviteRestricted;
|
|
final Future<bool> Function(Uri) _openWeb;
|
|
HostedFlowStage stage = HostedFlowStage.idle;
|
|
HostedFlowProblem? problem;
|
|
List<DeviceIdentityChoice> identityChoices = const [];
|
|
BuzzDeepLink? _target;
|
|
bool accountOnly = false;
|
|
bool _importRequested = false;
|
|
bool canCreateIdentity = false;
|
|
_RejectedBinding? _rejectedBinding;
|
|
|
|
String? requiredIdentityPubkey;
|
|
String? _pairedPubkey;
|
|
SensitiveActionPolicy? pairedSensitiveActionPolicy;
|
|
List<AddCommunityDeepLink> communities = const [];
|
|
Set<String> selectedRelays = const {};
|
|
String? landingRelay;
|
|
final Set<String> _addedRelays = {};
|
|
bool _addingCommunities = false;
|
|
String? _batchSubject;
|
|
String? _conflictingRelay;
|
|
String? _failedRelay;
|
|
String? get failedCommunityName {
|
|
final relay = _failedRelay;
|
|
if (relay == null) return null;
|
|
final community = communities
|
|
.where((item) => item.relayUrl == relay)
|
|
.firstOrNull;
|
|
return HostedEnvironment.communityDisplayName(
|
|
community?.name ?? Uri.parse(relay).host,
|
|
relay,
|
|
);
|
|
}
|
|
|
|
bool get canSkipFailedCommunity =>
|
|
canChangeCommunities &&
|
|
stage == HostedFlowStage.blocked &&
|
|
_failedRelay != null;
|
|
bool get canChangeCommunities =>
|
|
accountOnly &&
|
|
account.account?.subject == _batchSubject &&
|
|
(stage == HostedFlowStage.blocked ||
|
|
stage == HostedFlowStage.communityConflict ||
|
|
(stage == HostedFlowStage.working && _addingCommunities)) &&
|
|
communities.isNotEmpty;
|
|
String? addingCommunityName;
|
|
int get addedCommunityCount => _addedRelays.length;
|
|
final _queue = SerialQueue();
|
|
StreamSubscription<BrowserAuthCode>? _codes;
|
|
StreamSubscription<BrowserAuthFailure>? _failures;
|
|
bool _disposed = false;
|
|
int _generation = 0;
|
|
|
|
int _nextGeneration() {
|
|
_failedRelay = null;
|
|
_cancelPolicy();
|
|
_cancelProfile();
|
|
return ++_generation;
|
|
}
|
|
|
|
Completer<void>? _profileCompletion;
|
|
Future<void> Function(String)? saveProfile;
|
|
|
|
Future<void> Function(String, Future<String> Function()?)? saveProfileDraft;
|
|
|
|
Future<void> requestProfile(
|
|
Future<void> Function(String) save, {
|
|
Future<void> Function(String, Future<String> Function()?)? saveDraft,
|
|
}) async {
|
|
if (_disposed) return;
|
|
final completion = Completer<void>();
|
|
_profileCompletion = completion;
|
|
saveProfileDraft = (name, avatar) async {
|
|
if (!identical(_profileCompletion, completion) ||
|
|
completion.isCompleted) {
|
|
throw AccountFailure.unavailable;
|
|
}
|
|
if (saveDraft != null) {
|
|
await saveDraft(name, avatar);
|
|
} else {
|
|
await save(name);
|
|
}
|
|
if (identical(_profileCompletion, completion)) finishProfile();
|
|
};
|
|
final saveCurrentDraft = saveProfileDraft!;
|
|
saveProfile = (name) => saveCurrentDraft(name, null);
|
|
_set(HostedFlowStage.profile);
|
|
await completion.future;
|
|
if (identical(_profileCompletion, completion)) {
|
|
_profileCompletion = null;
|
|
saveProfile = null;
|
|
saveProfileDraft = null;
|
|
_set(HostedFlowStage.working);
|
|
}
|
|
}
|
|
|
|
void finishProfile() {
|
|
final completion = _profileCompletion;
|
|
if (completion != null && !completion.isCompleted) completion.complete();
|
|
}
|
|
|
|
void _cancelProfile() {
|
|
final completion = _profileCompletion;
|
|
_profileCompletion = null;
|
|
saveProfile = null;
|
|
saveProfileDraft = null;
|
|
if (completion != null && !completion.isCompleted) completion.complete();
|
|
}
|
|
|
|
HostedJoinPolicy? joinPolicy;
|
|
Completer<bool>? _policyConsent;
|
|
|
|
Future<bool> confirmJoinPolicy(HostedJoinPolicy policy) async {
|
|
if (_disposed) return false;
|
|
joinPolicy = policy;
|
|
final consent = Completer<bool>();
|
|
_policyConsent = consent;
|
|
_set(HostedFlowStage.acceptPolicy);
|
|
final accepted = await consent.future;
|
|
if (identical(_policyConsent, consent)) {
|
|
_policyConsent = null;
|
|
joinPolicy = null;
|
|
if (accepted) _set(HostedFlowStage.working);
|
|
}
|
|
return accepted;
|
|
}
|
|
|
|
void acceptJoinPolicy() {
|
|
final consent = _policyConsent;
|
|
if (consent != null && !consent.isCompleted) consent.complete(true);
|
|
}
|
|
|
|
void _cancelPolicy() {
|
|
final consent = _policyConsent;
|
|
_policyConsent = null;
|
|
joinPolicy = null;
|
|
if (consent != null && !consent.isCompleted) consent.complete(false);
|
|
}
|
|
|
|
bool reconnecting = false;
|
|
|
|
Future<void>? _initialization;
|
|
Future<void> initialize() => _initialization ??= _initialize();
|
|
|
|
Future<void> _initialize() async {
|
|
if (_codes != null) return;
|
|
_codes = browser.codes.listen((code) {
|
|
final generation = _generation;
|
|
_enqueue(() async {
|
|
_importRequested = code.intent['import_identity'] == 'true';
|
|
reconnecting = code.intent['type'] == 'reconnect';
|
|
accountOnly = reconnecting || code.intent['type'] == 'account';
|
|
if (accountOnly) {
|
|
_target = null;
|
|
} else {
|
|
_restorePairing(code.intent);
|
|
_target = _decode(code.intent);
|
|
if (_target == null) throw BrowserAuthFailure.restartLogin;
|
|
}
|
|
_set(HostedFlowStage.working);
|
|
await account.exchange(code: code.code, verifier: code.verifier);
|
|
if (_disposed || generation != _generation) return;
|
|
if (reconnecting) {
|
|
await browser.clearIntent();
|
|
if (_disposed || generation != _generation) return;
|
|
_set(HostedFlowStage.complete);
|
|
reconnecting = false;
|
|
} else {
|
|
await _continue(generation);
|
|
}
|
|
});
|
|
});
|
|
_failures = browser.failures.listen((failure) {
|
|
if (_disposed) return;
|
|
if (!accountOnly && _target == null) {
|
|
if (_generation == 0) {
|
|
_set(HostedFlowStage.blocked, HostedFlowProblem.retry);
|
|
}
|
|
return;
|
|
}
|
|
if (stage != HostedFlowStage.signIn) return;
|
|
if (failure == BrowserAuthFailure.cancelled) {
|
|
unawaited(leave());
|
|
} else {
|
|
_set(HostedFlowStage.blocked, HostedFlowProblem.retry);
|
|
}
|
|
});
|
|
await _enqueue(() async {
|
|
final generation = _generation;
|
|
await browser.initialize();
|
|
if (_disposed || generation != _generation) return;
|
|
if (_target == null) {
|
|
final retained = await browser.retainedIntent();
|
|
if (_disposed || generation != _generation) return;
|
|
if (retained != null && _target == null) {
|
|
_importRequested = retained['import_identity'] == 'true';
|
|
if (retained['type'] == 'reconnect') {
|
|
reconnecting = true;
|
|
accountOnly = true;
|
|
_set(HostedFlowStage.idle);
|
|
} else if (retained['type'] == 'account') {
|
|
accountOnly = true;
|
|
_set(HostedFlowStage.idle);
|
|
} else {
|
|
_restorePairing(retained);
|
|
_target = _decode(retained);
|
|
}
|
|
if (_target != null && stage == HostedFlowStage.idle) {
|
|
_set(HostedFlowStage.signIn);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Reauthenticate an existing community without enrollment, backup or discovery.
|
|
Future<void> reconnectAccount({bool changeAccount = false}) async {
|
|
await initialize();
|
|
final generation = _nextGeneration();
|
|
reconnecting = true;
|
|
accountOnly = true;
|
|
_target = null;
|
|
_set(HostedFlowStage.signIn);
|
|
await _enqueue(() async {
|
|
if (_disposed || generation != _generation) return;
|
|
if (changeAccount) await account.clearLocalSession();
|
|
if (_disposed || generation != _generation) return;
|
|
await browser.start({'type': 'reconnect'});
|
|
});
|
|
}
|
|
|
|
Map<String, String> get _accountIntent => {
|
|
'type': 'account',
|
|
if (_importRequested) 'import_identity': 'true',
|
|
};
|
|
|
|
/// Explicit Connect completes identity setup and discovers joined communities.
|
|
/// Passive startup restore only checks the saved session.
|
|
Future<void> signInAccount({
|
|
bool openBrowser = true,
|
|
bool importIdentity = false,
|
|
}) {
|
|
if (_target != null ||
|
|
stage == HostedFlowStage.working ||
|
|
stage == HostedFlowStage.signIn) {
|
|
return Future.value();
|
|
}
|
|
final generation = openBrowser ? _nextGeneration() : _generation;
|
|
if (openBrowser) {
|
|
_importRequested = _importRequested || importIdentity;
|
|
reconnecting = false;
|
|
accountOnly = true;
|
|
_clearCommunityBatch();
|
|
_set(HostedFlowStage.working);
|
|
}
|
|
return _enqueue(() async {
|
|
if (!openBrowser) {
|
|
if (_target != null || stage != HostedFlowStage.idle) return;
|
|
}
|
|
if (openBrowser) {
|
|
await browser.retainIntent(_accountIntent);
|
|
if (_disposed || generation != _generation) return;
|
|
}
|
|
final restored = await account.restore();
|
|
if (_disposed || generation != _generation) return;
|
|
if (!openBrowser) {
|
|
// Mounting the welcome page must not create/bind/back up a key.
|
|
if (_importRequested && restored != null) {
|
|
final saved = await store.forSubject(restored.subject).read();
|
|
if (_disposed || generation != _generation) return;
|
|
if (saved != null) {
|
|
// Also repair intents saved before import completion was recorded.
|
|
_importRequested = false;
|
|
await browser.retainIntent(_accountIntent);
|
|
if (_disposed || generation != _generation) return;
|
|
}
|
|
}
|
|
if (_importRequested && restored != null) {
|
|
canCreateIdentity = false;
|
|
requiredIdentityPubkey = null;
|
|
_set(HostedFlowStage.setupIdentity);
|
|
} else {
|
|
_set(HostedFlowStage.idle);
|
|
}
|
|
} else if (restored != null) {
|
|
await _continue(generation);
|
|
} else {
|
|
_set(HostedFlowStage.signIn);
|
|
await browser.start(_accountIntent);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Securely retain the transferred identity before NIP-AB completion. Hosted
|
|
/// admission still waits for account, binding and backup in the normal flow.
|
|
Future<void> stagePairedCommunity(
|
|
Community community, {
|
|
required bool Function() isCurrent,
|
|
}) async {
|
|
final uri = Uri.parse(community.relayUrl);
|
|
if (!HostedEnvironment.supportsRelay(uri)) throw AccountFailure.denied;
|
|
final nsec = community.nsec;
|
|
if (nsec == null) throw AccountIdentityFailure.absent;
|
|
final pubkey = AccountIdentityStore.publicKeyForNsec(nsec);
|
|
if (community.pubkey != null && community.pubkey != pubkey) {
|
|
throw AccountIdentityFailure.mismatch;
|
|
}
|
|
final device = await DeviceIdentitySource().choices();
|
|
if (device.isNotEmpty && !device.any((item) => item.pubkey == pubkey)) {
|
|
throw AccountIdentityFailure.mismatch;
|
|
}
|
|
final generation = _nextGeneration();
|
|
void check() {
|
|
if (!isCurrent() || generation != _generation || _disposed) {
|
|
throw AccountFailure.unavailable;
|
|
}
|
|
}
|
|
|
|
check();
|
|
await store.forPairing(pubkey).create(nsec, checkCurrent: check);
|
|
check();
|
|
final relay = uri.replace(scheme: 'wss').toString();
|
|
final target = AddCommunityDeepLink(relayUrl: relay, name: community.name);
|
|
_pairedPubkey = pubkey;
|
|
pairedSensitiveActionPolicy = community.sensitiveActionPolicy;
|
|
await browser.retainIntent(_intent(target));
|
|
check();
|
|
_target = target;
|
|
accountOnly = false;
|
|
_set(HostedFlowStage.signIn);
|
|
}
|
|
|
|
void _restorePairing(Map<String, String> intent) {
|
|
_pairedPubkey = intent['paired_pubkey'];
|
|
pairedSensitiveActionPolicy = intent['paired_protection'] == 'enabled'
|
|
? SensitiveActionPolicy.enabled
|
|
: null;
|
|
}
|
|
|
|
Map<String, String> _intent(BuzzDeepLink target) => {
|
|
..._encode(target),
|
|
if (_importRequested) 'import_identity': 'true',
|
|
'paired_pubkey': ?_pairedPubkey,
|
|
if (pairedSensitiveActionPolicy == SensitiveActionPolicy.enabled)
|
|
'paired_protection': 'enabled',
|
|
};
|
|
|
|
Future<void> start(BuzzDeepLink target, {bool resumePairing = false}) {
|
|
if (!resumePairing) {
|
|
_pairedPubkey = null;
|
|
pairedSensitiveActionPolicy = null;
|
|
}
|
|
if (target is! NostrBindDeepLink &&
|
|
target is! AddCommunityDeepLink &&
|
|
target is! InviteDeepLink) {
|
|
throw ArgumentError('Unsupported hosted target');
|
|
}
|
|
final generation = _nextGeneration();
|
|
reconnecting = false;
|
|
accountOnly = false;
|
|
_clearCommunityBatch();
|
|
_target = target;
|
|
_set(HostedFlowStage.working);
|
|
return _enqueue(() async {
|
|
if (generation != _generation) return;
|
|
if (!_acceptTarget(target)) return;
|
|
// Login may already be complete. Retain the destination before any
|
|
// remote work so restart can reconcile using the same saved identity.
|
|
await browser.retainIntent(_intent(target));
|
|
if (_disposed || generation != _generation) return;
|
|
await _resume(generation, _intent(target));
|
|
});
|
|
}
|
|
|
|
Future<void> _resume(int generation, Map<String, String> intent) async {
|
|
final restored = await account.restore();
|
|
if (_disposed || generation != _generation) return;
|
|
if (restored == null) {
|
|
_set(HostedFlowStage.signIn);
|
|
await browser.start(intent);
|
|
} else {
|
|
await _continue(generation);
|
|
}
|
|
}
|
|
|
|
Future<void> retry() async {
|
|
if (reconnecting) return reconnectAccount();
|
|
final target = _target;
|
|
if (target == null && !accountOnly) return;
|
|
final generation = _nextGeneration();
|
|
try {
|
|
await browser.cancel();
|
|
if (_disposed || generation != _generation) return;
|
|
if (target != null) {
|
|
await start(target, resumePairing: _pairedPubkey != null);
|
|
} else {
|
|
_set(HostedFlowStage.working);
|
|
await _enqueue(() => _resume(generation, _accountIntent));
|
|
}
|
|
} catch (_) {
|
|
if (!_disposed && generation == _generation) {
|
|
_set(HostedFlowStage.blocked, HostedFlowProblem.retry);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resume only an offered public identity; reread local and cloud facts first.
|
|
Future<void> chooseIdentity(String pubkey) {
|
|
if ((_target == null && !accountOnly) ||
|
|
stage != HostedFlowStage.chooseIdentity ||
|
|
!identityChoices.any((item) => item.pubkey == pubkey)) {
|
|
return Future.value();
|
|
}
|
|
final generation = _nextGeneration();
|
|
_set(HostedFlowStage.working);
|
|
return _enqueue(() => _continue(generation, selectedPubkey: pubkey));
|
|
}
|
|
|
|
/// Create explicitly from setup, or beside the saved identities offered.
|
|
Future<void> createIdentity() {
|
|
if ((_target == null && !accountOnly) ||
|
|
!canCreateIdentity ||
|
|
(stage != HostedFlowStage.setupIdentity &&
|
|
stage != HostedFlowStage.chooseIdentity)) {
|
|
return Future.value();
|
|
}
|
|
final offered = {for (final item in identityChoices) item.pubkey};
|
|
final generation = _nextGeneration();
|
|
_set(HostedFlowStage.working);
|
|
return _enqueue(
|
|
() => _continue(generation, createNew: true, offered: offered),
|
|
);
|
|
}
|
|
|
|
/// Import through the secure store, rechecking the account binding.
|
|
/// Errors propagate to the import sheet without discarding the setup choice.
|
|
Future<void> importIdentity(String nsec) {
|
|
final generation = _generation;
|
|
final subject = account.account?.subject;
|
|
if (stage != HostedFlowStage.setupIdentity || subject == null) {
|
|
throw AccountFailure.unavailable;
|
|
}
|
|
return _queue.run(() async {
|
|
bool current() =>
|
|
!_disposed &&
|
|
generation == _generation &&
|
|
account.account?.subject == subject;
|
|
if (!current()) throw AccountFailure.unavailable;
|
|
await identity.resolve(isCurrent: current, importNsec: nsec);
|
|
});
|
|
}
|
|
|
|
Future<void> _continue(
|
|
int generation, {
|
|
String? selectedPubkey,
|
|
bool createNew = false,
|
|
Set<String> offered = const {},
|
|
}) async {
|
|
final target = _target;
|
|
final session = account.account;
|
|
if ((target == null && !accountOnly) || session == null) {
|
|
throw AccountFailure.signedOut;
|
|
}
|
|
// Older native sessions may predate the web setup gate. Preserve their
|
|
// subject and destination while completing the same browser handoff.
|
|
if (session.needsAccountSetup) {
|
|
_set(HostedFlowStage.signIn);
|
|
await browser.start(target == null ? _accountIntent : _intent(target));
|
|
return;
|
|
}
|
|
final store = this.store.forSubject(session.subject);
|
|
if (target != null && !_acceptTarget(target)) return;
|
|
if (target == null && _batchSubject != session.subject) {
|
|
_clearCommunityBatch();
|
|
_batchSubject = session.subject;
|
|
}
|
|
final canSealBackup = await HpkeBackup.supportsSealing();
|
|
if (_disposed || generation != _generation) return;
|
|
var resolved = await identity.resolve(
|
|
isCurrent: _isCurrent(generation),
|
|
selectedPubkey: selectedPubkey ?? _pairedPubkey,
|
|
);
|
|
if (_disposed || generation != _generation) return;
|
|
if (_importRequested &&
|
|
{
|
|
HostedIdentityState.ready,
|
|
HostedIdentityState.needsBinding,
|
|
HostedIdentityState.needsBackup,
|
|
}.contains(resolved.state)) {
|
|
// Identity selection is durable before binding/backup can fail. Restart
|
|
// must resume that work rather than ask for the same key again.
|
|
_importRequested = false;
|
|
await browser.retainIntent(
|
|
target == null ? _accountIntent : _intent(target),
|
|
);
|
|
if (_disposed || generation != _generation) return;
|
|
}
|
|
// Selection offers creation beside saved identities only on request.
|
|
final creatable =
|
|
resolved.canCreate &&
|
|
(resolved.state == HostedIdentityState.needsSetup ||
|
|
(createNew &&
|
|
resolved.state == HostedIdentityState.needsSelection));
|
|
if (creatable && createNew && !canSealBackup) {
|
|
_set(HostedFlowStage.blocked, HostedFlowProblem.unsupportedPlatform);
|
|
return;
|
|
}
|
|
if (creatable &&
|
|
canSealBackup &&
|
|
(target is! NostrBindDeepLink || createNew) &&
|
|
!_importRequested) {
|
|
// Recheck authoritative absence in the service before generating. Never
|
|
// turn failed recovery or a pending backup into a replacement identity.
|
|
resolved = await identity.resolve(
|
|
isCurrent: _isCurrent(generation),
|
|
createNew: true,
|
|
offered: offered,
|
|
);
|
|
}
|
|
if (_pairedPubkey != null && resolved.pubkey != _pairedPubkey) {
|
|
_set(HostedFlowStage.blocked, HostedFlowProblem.identityMismatch);
|
|
return;
|
|
}
|
|
if (target is! NostrBindDeepLink) {
|
|
for (var step = 0; step < 2; step++) {
|
|
if (_disposed || generation != _generation) return;
|
|
if (!canSealBackup) {
|
|
final saved = await store.read();
|
|
if (_disposed || generation != _generation) return;
|
|
final needsNewBackup =
|
|
resolved.state == HostedIdentityState.needsSetup ||
|
|
resolved.state == HostedIdentityState.needsBackup ||
|
|
(resolved.state == HostedIdentityState.needsBinding &&
|
|
!(saved?.hasBackupFor(
|
|
backend: HostedEnvironment.origin.origin,
|
|
subject: session.subject,
|
|
) ??
|
|
false));
|
|
if (needsNewBackup) {
|
|
_set(
|
|
HostedFlowStage.blocked,
|
|
HostedFlowProblem.unsupportedPlatform,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
if (resolved.state == HostedIdentityState.needsBinding &&
|
|
resolved.pubkey != null) {
|
|
final response = await account.post(
|
|
'/v1/buzz/nostr-identities/challenge',
|
|
{'origin': HostedEnvironment.origin.toString()},
|
|
);
|
|
if (_disposed || generation != _generation) return;
|
|
final fields = <String, String>{
|
|
'audience': 'buzz:nostr-identity',
|
|
'action': 'bind_nostr_identity',
|
|
'protocol': 'buzz-nostr-identity',
|
|
'version': '1',
|
|
'return': 'browser_fragment_v1',
|
|
'callback_url': HostedEnvironment.origin
|
|
.resolve('/buzz')
|
|
.toString(),
|
|
};
|
|
for (final key in [
|
|
'challenge_id',
|
|
'nonce',
|
|
'verification_code',
|
|
'origin',
|
|
'expires_at',
|
|
]) {
|
|
final value = response[key];
|
|
if (value is! String) throw AccountFailure.invalidResponse;
|
|
fields[key] = value;
|
|
}
|
|
final request = parseNostrBindDeepLink(
|
|
Uri(scheme: 'buzz', host: 'nostr-bind', queryParameters: fields),
|
|
);
|
|
if (response['error'] != null || request == null) {
|
|
throw AccountFailure.invalidResponse;
|
|
}
|
|
final pubkey = resolved.pubkey!;
|
|
await _observeBinding(
|
|
session.subject,
|
|
pubkey,
|
|
generation,
|
|
() => WebConnectBindingService().complete(
|
|
request: request,
|
|
subject: session.subject,
|
|
expectedPubkey: pubkey,
|
|
withKeys: store.withKeys,
|
|
verify: _verifyBinding,
|
|
isCurrent: _isCurrent(generation),
|
|
),
|
|
);
|
|
} else if (resolved.state == HostedIdentityState.needsBackup) {
|
|
await HostedBackupService(
|
|
account,
|
|
store,
|
|
).backup(isCurrent: _isCurrent(generation));
|
|
} else {
|
|
break;
|
|
}
|
|
if (_disposed || generation != _generation) return;
|
|
resolved = await identity.resolve(isCurrent: _isCurrent(generation));
|
|
}
|
|
}
|
|
if (generation != _generation || _disposed) return;
|
|
if (resolved.state == HostedIdentityState.needsSetup) {
|
|
canCreateIdentity =
|
|
resolved.canCreate && target != null && !_importRequested;
|
|
requiredIdentityPubkey = resolved.pubkey;
|
|
_set(HostedFlowStage.setupIdentity);
|
|
return;
|
|
}
|
|
if (resolved.state == HostedIdentityState.needsSelection) {
|
|
canCreateIdentity =
|
|
resolved.canCreate && _pairedPubkey == null && !_importRequested;
|
|
identityChoices = List.unmodifiable(resolved.choices);
|
|
_set(HostedFlowStage.chooseIdentity);
|
|
return;
|
|
}
|
|
final pubkey = resolved.pubkey;
|
|
if (target is NostrBindDeepLink &&
|
|
pubkey != null &&
|
|
{
|
|
HostedIdentityState.ready,
|
|
HostedIdentityState.needsBinding,
|
|
HostedIdentityState.needsBackup,
|
|
}.contains(resolved.state)) {
|
|
final returnTo = await WebConnectBindingService().complete(
|
|
request: target,
|
|
subject: session.subject,
|
|
expectedPubkey: pubkey,
|
|
withKeys: store.withKeys,
|
|
verify: _verifyBinding,
|
|
isCurrent: _isCurrent(generation),
|
|
);
|
|
if (generation != _generation || _disposed) return;
|
|
if (!await _openWeb(returnTo)) throw BrowserAuthFailure.unavailable;
|
|
await browser.clearIntent();
|
|
_set(HostedFlowStage.browserReturned);
|
|
return;
|
|
}
|
|
if (resolved.state != HostedIdentityState.ready || pubkey == null) {
|
|
final failure = switch (resolved.state) {
|
|
HostedIdentityState.needsBackup => HostedFlowProblem.backupRequired,
|
|
HostedIdentityState.needsBinding => HostedFlowProblem.bindingRequired,
|
|
HostedIdentityState.mismatch => HostedFlowProblem.identityMismatch,
|
|
_ => HostedFlowProblem.identityUnavailable,
|
|
};
|
|
_set(HostedFlowStage.blocked, failure);
|
|
return;
|
|
}
|
|
if (target == null) {
|
|
await _continueCommunities(generation, session.subject, pubkey);
|
|
return;
|
|
}
|
|
final destination = target is HostedCreateCommunity
|
|
? await target.provision(account, pubkey, _isCurrent(generation))
|
|
: target;
|
|
if (_disposed || generation != _generation) return;
|
|
await enterCommunity(
|
|
destination,
|
|
session.subject,
|
|
pubkey,
|
|
_isCurrent(generation),
|
|
);
|
|
if (generation != _generation || _disposed) return;
|
|
// Save only after admission/persistence succeeds. A failure keeps the
|
|
// existing intent for retry; add is idempotent after a lost response.
|
|
await account.rememberCommunity(
|
|
destination.relayUrl ?? (throw AccountFailure.invalidResponse),
|
|
expectedSubject: session.subject,
|
|
);
|
|
if (generation != _generation || _disposed) return;
|
|
await browser.clearIntent();
|
|
_set(HostedFlowStage.complete);
|
|
}
|
|
|
|
void chooseLandingCommunity(String relay) {
|
|
if (stage != HostedFlowStage.chooseCommunities ||
|
|
!communities.any((item) => item.relayUrl == relay)) {
|
|
return;
|
|
}
|
|
_returnToAddedCommunity = false;
|
|
_openAddedCommunity = null;
|
|
selectedRelays = Set.unmodifiable({relay});
|
|
landingRelay = relay;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Capture only a current account's discovered community choices.
|
|
HostedCommunityChoices? captureCommunityChoices() {
|
|
if (stage != HostedFlowStage.chooseCommunities ||
|
|
_batchSubject == null ||
|
|
account.account?.subject != _batchSubject) {
|
|
return null;
|
|
}
|
|
return HostedCommunityChoices._(
|
|
_batchSubject!,
|
|
List.unmodifiable(communities),
|
|
Set.unmodifiable(selectedRelays),
|
|
landingRelay,
|
|
Set.unmodifiable(_addedRelays),
|
|
);
|
|
}
|
|
|
|
/// Return from Add without repeating account discovery or relay admission.
|
|
/// A changed or expired account must go through sign-in again.
|
|
bool restoreCommunityChoices(HostedCommunityChoices choices) {
|
|
if (_disposed || account.account?.subject != choices._subject) return false;
|
|
_nextGeneration();
|
|
_target = null;
|
|
_pairedPubkey = null;
|
|
pairedSensitiveActionPolicy = null;
|
|
reconnecting = false;
|
|
accountOnly = true;
|
|
_clearCommunityBatch();
|
|
_batchSubject = choices._subject;
|
|
communities = choices._communities;
|
|
selectedRelays = choices._selectedRelays;
|
|
landingRelay = choices._landingRelay;
|
|
_addedRelays.addAll(choices._addedRelays);
|
|
_set(HostedFlowStage.chooseCommunities);
|
|
return true;
|
|
}
|
|
|
|
Future<void> addSelectedCommunities({bool returnToAddedCommunity = false}) {
|
|
if (!accountOnly ||
|
|
stage != HostedFlowStage.chooseCommunities ||
|
|
selectedRelays.isEmpty) {
|
|
return Future.value();
|
|
}
|
|
_returnToAddedCommunity = returnToAddedCommunity || _returnToAddedCommunity;
|
|
_addingCommunities = true;
|
|
final generation = _nextGeneration();
|
|
_set(HostedFlowStage.working);
|
|
return _enqueue(() => _continue(generation));
|
|
}
|
|
|
|
/// Keep completed additions while allowing a different landing choice.
|
|
void backToCommunityChoices() {
|
|
if (!canChangeCommunities) return;
|
|
_nextGeneration();
|
|
_conflictingRelay = null;
|
|
addingCommunityName = null;
|
|
_set(HostedFlowStage.chooseCommunities);
|
|
}
|
|
|
|
/// Omit only the failed membership for this restore attempt.
|
|
Future<void> skipFailedCommunity() {
|
|
if (!canSkipFailedCommunity) return Future.value();
|
|
final relay = _failedRelay!;
|
|
selectedRelays = Set.unmodifiable(
|
|
selectedRelays.where((item) => item != relay),
|
|
);
|
|
_failedRelay = null;
|
|
final generation = _nextGeneration();
|
|
_set(HostedFlowStage.working);
|
|
return _enqueue(() => _continue(generation));
|
|
}
|
|
|
|
Future<void> skipConflictingCommunity() {
|
|
final relay = _conflictingRelay;
|
|
if (stage != HostedFlowStage.communityConflict || relay == null) {
|
|
return Future.value();
|
|
}
|
|
selectedRelays = Set.unmodifiable(
|
|
selectedRelays.where((item) => item != relay),
|
|
);
|
|
_conflictingRelay = null;
|
|
final generation = _nextGeneration();
|
|
_set(HostedFlowStage.working);
|
|
return _enqueue(() => _continue(generation));
|
|
}
|
|
|
|
Future<void> _continueCommunities(
|
|
int generation,
|
|
String subject,
|
|
String pubkey,
|
|
) async {
|
|
bool current() =>
|
|
!_disposed &&
|
|
generation == _generation &&
|
|
account.account?.subject == subject;
|
|
if (!_addingCommunities) {
|
|
final listed = await loadKnownHostedCommunities(account);
|
|
if (!current()) return;
|
|
communities = listed;
|
|
selectedRelays = Set.unmodifiable(listed.map((item) => item.relayUrl));
|
|
if (listed.isEmpty) {
|
|
await leave(noCommunities: true);
|
|
return;
|
|
}
|
|
landingRelay = listed.first.relayUrl;
|
|
// A picker tap narrows admission to the chosen community.
|
|
_set(HostedFlowStage.chooseCommunities);
|
|
return;
|
|
}
|
|
if (_returnToAddedCommunity && _openAddedCommunity == null) {
|
|
final capture = captureAddedCommunity;
|
|
if (capture == null) throw AccountFailure.unavailable;
|
|
final open = await capture();
|
|
if (!current()) return;
|
|
_openAddedCommunity = open;
|
|
}
|
|
_failedRelay = null;
|
|
final ordered = [
|
|
...communities.where((item) => item.relayUrl == landingRelay),
|
|
...communities.where((item) => item.relayUrl != landingRelay),
|
|
];
|
|
for (final community in ordered) {
|
|
if (!current()) return;
|
|
if (!selectedRelays.contains(community.relayUrl) ||
|
|
_addedRelays.contains(community.relayUrl)) {
|
|
continue;
|
|
}
|
|
addingCommunityName = community.name;
|
|
notifyListeners();
|
|
try {
|
|
await enterCommunity(community, subject, pubkey, current);
|
|
} on SavedCommunityIdentityConflict {
|
|
if (!current()) return;
|
|
_conflictingRelay = community.relayUrl;
|
|
_set(HostedFlowStage.communityConflict);
|
|
return;
|
|
} catch (_) {
|
|
if (current()) _failedRelay = community.relayUrl;
|
|
rethrow;
|
|
}
|
|
if (!current()) return;
|
|
_addedRelays.add(community.relayUrl);
|
|
}
|
|
if (!current()) return;
|
|
addingCommunityName = null;
|
|
if (_openAddedCommunity != null) {
|
|
await _openAddedCommunity!(current);
|
|
if (!current()) return;
|
|
await browser.clearIntent();
|
|
if (current()) _set(HostedFlowStage.complete);
|
|
return;
|
|
}
|
|
if (_addedRelays.isEmpty) {
|
|
communities = List.unmodifiable(
|
|
communities.where((item) => selectedRelays.contains(item.relayUrl)),
|
|
);
|
|
await leave(noCommunities: true);
|
|
return;
|
|
}
|
|
final destination = _addedRelays.contains(landingRelay)
|
|
? landingRelay!
|
|
: _addedRelays.first;
|
|
await openLandingCommunity?.call(
|
|
AddCommunityDeepLink(
|
|
relayUrl: destination,
|
|
name: Uri.parse(destination).host,
|
|
),
|
|
subject,
|
|
pubkey,
|
|
current,
|
|
);
|
|
if (!current()) return;
|
|
await browser.clearIntent();
|
|
if (current()) _set(HostedFlowStage.complete);
|
|
}
|
|
|
|
bool _acceptTarget(BuzzDeepLink target) {
|
|
final relay = target.relayUrl;
|
|
if (relay != null && !HostedEnvironment.supportsRelay(Uri.parse(relay))) {
|
|
_set(HostedFlowStage.blocked, HostedFlowProblem.unsupportedEnvironment);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
Future<String> _verifyBinding({
|
|
required String subject,
|
|
required String challengeId,
|
|
required String nonce,
|
|
required String signedPayload,
|
|
}) async {
|
|
if (account.account?.subject != subject) {
|
|
throw AccountFailure.differentAccount;
|
|
}
|
|
final response = await account.post(_verifyPath, {
|
|
'challenge_id': challengeId,
|
|
'nonce': nonce,
|
|
'signed_payload': signedPayload,
|
|
});
|
|
if (account.account?.subject != subject) {
|
|
throw AccountFailure.differentAccount;
|
|
}
|
|
final bound = response['identity'];
|
|
if (response['error'] != null ||
|
|
bound is! Map ||
|
|
bound['pubkey_hex'] is! String) {
|
|
throw AccountFailure.invalidResponse;
|
|
}
|
|
return bound['pubkey_hex'] as String;
|
|
}
|
|
|
|
bool Function() _isCurrent(int generation) =>
|
|
() => !_disposed && generation == _generation;
|
|
|
|
Future<void> _enqueue(Future<void> Function() operation) {
|
|
final generation = _generation;
|
|
final result = _queue.run(() async {
|
|
if (_disposed || generation != _generation) return;
|
|
try {
|
|
await operation();
|
|
} catch (error) {
|
|
if (!_disposed && generation == _generation) {
|
|
_set(HostedFlowStage.blocked, _problemFor(error));
|
|
}
|
|
}
|
|
});
|
|
return result;
|
|
}
|
|
|
|
static HostedFlowProblem _problemFor(Object error) => switch (error) {
|
|
HostedCreationFailure.rejected =>
|
|
HostedFlowProblem.communityCreationRejected,
|
|
HostedInviteFailure.policyRequired =>
|
|
HostedFlowProblem.invitePolicyRequired,
|
|
HostedInviteFailure.expired => HostedFlowProblem.inviteExpired,
|
|
HostedInviteFailure.exhausted => HostedFlowProblem.inviteExhausted,
|
|
HostedInviteFailure.invalid => HostedFlowProblem.inviteInvalid,
|
|
HostedInviteFailure.restricted => HostedFlowProblem.inviteRestricted,
|
|
HostedInviteFailure.ownerMismatch => HostedFlowProblem.inviteOwnerMismatch,
|
|
AccountFailure.denied => HostedFlowProblem.denied,
|
|
AccountFailure.accountSetupRequired =>
|
|
HostedFlowProblem.accountSetupRequired,
|
|
AccountFailure.rejected ||
|
|
HostedBackupFailure.rejected => HostedFlowProblem.requestRejected,
|
|
HostedBackupFailure.unavailable => HostedFlowProblem.backupUnavailable,
|
|
AccountFailure.differentAccount => HostedFlowProblem.differentAccount,
|
|
AccountIdentityFailure.unreadable ||
|
|
AccountIdentityFailure.invalidKey ||
|
|
AccountFailure.storage ||
|
|
HostedBackupFailure.storage => HostedFlowProblem.identityUnreadable,
|
|
SavedCommunityIdentityConflict() ||
|
|
AccountIdentityFailure.mismatch ||
|
|
AccountIdentityFailure.occupied ||
|
|
HostedBackupFailure.identityMismatch ||
|
|
HpkeBackupFailure.identityMismatch => HostedFlowProblem.identityMismatch,
|
|
HpkeBackupFailure.unavailable => HostedFlowProblem.backupUnavailable,
|
|
AccountFailure.invalidResponse ||
|
|
HostedBackupFailure.invalidResponse ||
|
|
HpkeBackupFailure.invalidEnrollment => HostedFlowProblem.invalidResponse,
|
|
_ => HostedFlowProblem.retry,
|
|
};
|
|
|
|
void _set(HostedFlowStage next, [HostedFlowProblem? failure]) {
|
|
if (_disposed) return;
|
|
if (next != HostedFlowStage.chooseIdentity) identityChoices = const [];
|
|
stage = next;
|
|
problem = failure;
|
|
notifyListeners();
|
|
}
|
|
|
|
bool noCommunitiesToAdd = false;
|
|
|
|
void _clearCommunityBatch() {
|
|
_returnToAddedCommunity = false;
|
|
_openAddedCommunity = null;
|
|
noCommunitiesToAdd = false;
|
|
communities = const [];
|
|
selectedRelays = const {};
|
|
landingRelay = null;
|
|
_failedRelay = null;
|
|
_addedRelays.clear();
|
|
_batchSubject = null;
|
|
_conflictingRelay = null;
|
|
_addingCommunities = false;
|
|
addingCommunityName = null;
|
|
}
|
|
|
|
/// Ends the account session without removing saved identities or communities.
|
|
Future<void> signOutAccount() async {
|
|
await leave();
|
|
final generation = _generation;
|
|
if (_disposed || stage != HostedFlowStage.idle) return;
|
|
accountOnly = true;
|
|
_set(HostedFlowStage.working);
|
|
await _enqueue(() async {
|
|
await account.clearLocalSession();
|
|
if (_disposed || generation != _generation) return;
|
|
accountOnly = false;
|
|
_set(HostedFlowStage.idle);
|
|
});
|
|
}
|
|
|
|
Future<void> leave({bool noCommunities = false}) async {
|
|
_nextGeneration();
|
|
final generation = _generation;
|
|
_target = null;
|
|
_importRequested = false;
|
|
_pairedPubkey = null;
|
|
pairedSensitiveActionPolicy = null;
|
|
reconnecting = false;
|
|
accountOnly = false;
|
|
_clearCommunityBatch();
|
|
noCommunitiesToAdd = noCommunities;
|
|
try {
|
|
await browser.cancel();
|
|
if (_disposed || generation != _generation) return;
|
|
await browser.clearIntent();
|
|
if (generation == _generation) _set(HostedFlowStage.idle);
|
|
} catch (_) {
|
|
if (generation == _generation) {
|
|
_set(HostedFlowStage.blocked, HostedFlowProblem.retry);
|
|
}
|
|
}
|
|
}
|
|
|
|
static Map<String, String> _encode(BuzzDeepLink target) {
|
|
if (target is HostedCreateCommunity) {
|
|
return {'type': 'create', 'name': target.name};
|
|
}
|
|
if (target is NostrBindDeepLink) return {'type': 'bind', ...target.fields};
|
|
if (target is AddCommunityDeepLink) {
|
|
return {'type': 'add', 'relay': target.relayUrl, 'name': target.name};
|
|
}
|
|
final invite = target as InviteDeepLink;
|
|
return {
|
|
'type': 'invite',
|
|
'relay': invite.relayUrl,
|
|
'code': invite.code,
|
|
if (invite.policyReceipt != null) 'policy_receipt': invite.policyReceipt!,
|
|
};
|
|
}
|
|
|
|
static BuzzDeepLink? _decode(Map<String, String> intent) {
|
|
if (intent['type'] == 'create') {
|
|
final name = intent['name'];
|
|
return name != null && HostedCreateCommunity.validName(name)
|
|
? HostedCreateCommunity(name)
|
|
: null;
|
|
}
|
|
final fields = Map<String, String>.of(intent)
|
|
..remove('type')
|
|
..remove('import_identity')
|
|
..remove('paired_pubkey')
|
|
..remove('paired_protection');
|
|
final host = switch (intent['type']) {
|
|
'bind' => 'nostr-bind',
|
|
'add' => 'add-community',
|
|
'invite' => 'join',
|
|
_ => '',
|
|
};
|
|
return parseBuzzDeepLink(
|
|
Uri(scheme: 'buzz', host: host, queryParameters: fields),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_disposed = true;
|
|
_cancelPolicy();
|
|
_cancelProfile();
|
|
_nextGeneration();
|
|
unawaited(_codes?.cancel());
|
|
unawaited(_failures?.cancel());
|
|
super.dispose();
|
|
}
|
|
}
|