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'; enum HostedFlowStage { idle, working, signIn, chooseIdentity, setupIdentity, chooseCommunities, communityConflict, acceptPolicy, profile, blocked, browserReturned, complete, } enum HostedFlowProblem { retry, denied, accountSetupRequired, differentAccount, identityUnreadable, backupUnavailable, 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 Function( BuzzDeepLink target, String subject, String pubkey, bool Function() isCurrent, ); /// 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, Future 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; bool get canRetry => problem == null || problem == HostedFlowProblem.retry || problem == HostedFlowProblem.invitePolicyRequired || problem == HostedFlowProblem.inviteRestricted; final Future Function(Uri) _openWeb; HostedFlowStage stage = HostedFlowStage.idle; HostedFlowProblem? problem; List identityChoices = const []; BuzzDeepLink? _target; bool accountOnly = false; bool canCreateIdentity = false; String? requiredIdentityPubkey; String? _pairedPubkey; SensitiveActionPolicy? pairedSensitiveActionPolicy; List communities = const []; Set selectedRelays = const {}; String? landingRelay; final Set _addedRelays = {}; bool _addingCommunities = false; Completer? _pendingAdd; String? _batchSubject; String? _conflictingRelay; String? _failedRelay; 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? _codes; StreamSubscription? _failures; bool _disposed = false; int _generation = 0; int _nextGeneration() { _failedRelay = null; _cancelPolicy(); _cancelProfile(); return ++_generation; } Completer? _profileCompletion; Future Function(String)? saveProfile; Future Function(String, Future Function()?)? saveProfileDraft; Future requestProfile( Future Function(String) save, { Future Function(String, Future Function()?)? saveDraft, }) async { if (_disposed) return; final completion = Completer(); _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? _policyConsent; Future confirmJoinPolicy(HostedJoinPolicy policy) async { if (_disposed) return false; joinPolicy = policy; final consent = Completer(); _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? _initialization; Future initialize() => _initialization ??= _initialize(); Future _initialize() async { if (_codes != null) return; _codes = browser.codes.listen((code) { final generation = _generation; _enqueue(() async { 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) { 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 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'}); }); } /// Explicit Connect completes identity setup and discovers joined communities. /// Passive startup restore only checks the saved session. Future signInAccount({bool openBrowser = true}) { if (_target != null || stage == HostedFlowStage.working || stage == HostedFlowStage.signIn) { return Future.value(); } final generation = openBrowser ? _nextGeneration() : _generation; if (openBrowser) { reconnecting = false; accountOnly = true; _clearCommunityBatch(); _set(HostedFlowStage.working); } return _enqueue(() async { if (!openBrowser) { if (_target != null || stage != HostedFlowStage.idle) 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. _set(HostedFlowStage.idle); } else if (restored != null) { await _continue(generation); } else { _set(HostedFlowStage.signIn); await browser.start({'type': 'account'}); } }); } /// Securely retain the transferred identity before NIP-AB completion. Hosted /// admission still waits for account, binding and backup in the normal flow. Future 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 intent) { _pairedPubkey = intent['paired_pubkey']; pairedSensitiveActionPolicy = intent['paired_protection'] == 'enabled' ? SensitiveActionPolicy.enabled : null; } Map _intent(BuzzDeepLink target) => { ..._encode(target), 'paired_pubkey': ?_pairedPubkey, if (pairedSensitiveActionPolicy == SensitiveActionPolicy.enabled) 'paired_protection': 'enabled', }; Future 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 _resume(int generation, Map 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 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, {'type': 'account'})); } } catch (_) { if (!_disposed && generation == _generation) { _set(HostedFlowStage.blocked, HostedFlowProblem.retry); } } } /// Resume only an offered public identity; reread local and cloud facts first. Future 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)); } /// A new identity is offered only after authoritative absence was established. Future createIdentity() { if (stage != HostedFlowStage.setupIdentity || !canCreateIdentity) { return Future.value(); } final generation = _nextGeneration(); _set(HostedFlowStage.working); return _enqueue(() => _continue(generation, createNew: true)); } /// Import through the existing secure store, rechecking the account binding. /// Errors propagate to the import sheet without discarding the setup choice. Future 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 _continue( int generation, { String? selectedPubkey, bool createNew = false, }) async { final target = _target; final session = account.account; if ((target == null && !accountOnly) || session == null) { throw AccountFailure.signedOut; } final store = this.store.forSubject(session.subject); if (target != null && !_acceptTarget(target)) return; if (target == null && _batchSubject != session.subject) { _clearCommunityBatch(); _batchSubject = session.subject; } var resolved = await identity.resolve( isCurrent: _isCurrent(generation), selectedPubkey: selectedPubkey ?? _pairedPubkey, createNew: createNew, ); 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 (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 = { '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; } await WebConnectBindingService().complete( request: request, subject: session.subject, expectedPubkey: resolved.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; requiredIdentityPubkey = resolved.pubkey; _set(HostedFlowStage.setupIdentity); return; } if (resolved.state == HostedIdentityState.needsSelection) { 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; } selectedRelays = Set.unmodifiable({...selectedRelays, relay}); landingRelay = relay; notifyListeners(); } /// Restore existing memberships before presenting a separate Add route. Future prepareToAddCommunity() { if (_pendingAdd != null || stage != HostedFlowStage.chooseCommunities) { return Future.value(false); } if (selectedRelays.isEmpty) return Future.value(true); final pending = _pendingAdd = Completer(); unawaited(addSelectedCommunities()); return pending.future; } void _finishPreparingAdd(bool ready) { final pending = _pendingAdd; _pendingAdd = null; pending?.complete(ready); } Future addSelectedCommunities() { if (!accountOnly || stage != HostedFlowStage.chooseCommunities || selectedRelays.isEmpty) { return Future.value(); } _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; _finishPreparingAdd(false); _nextGeneration(); _conflictingRelay = null; addingCommunityName = null; _set(HostedFlowStage.chooseCommunities); } /// Omit only the failed membership for this restore attempt. Future 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 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 _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; // Choose where to land; every membership is restored. _set(HostedFlowStage.chooseCommunities); return; } _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 (_pendingAdd != null) { _set(HostedFlowStage.chooseCommunities); _finishPreparingAdd(true); 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 _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('/v1/buzz/nostr-identities/verify', { '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 _enqueue(Future 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() { _finishPreparingAdd(false); noCommunitiesToAdd = false; communities = const []; selectedRelays = const {}; landingRelay = null; _failedRelay = null; _addedRelays.clear(); _batchSubject = null; _conflictingRelay = null; _addingCommunities = false; addingCommunityName = null; } Future leave({bool noCommunities = false}) async { _nextGeneration(); final generation = _generation; _target = null; _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 _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 intent) { if (intent['type'] == 'create') { final name = intent['name']; return name != null && HostedCreateCommunity.validName(name) ? HostedCreateCommunity(name) : null; } final fields = Map.of(intent) ..remove('type') ..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; _finishPreparingAdd(false); _cancelPolicy(); _cancelProfile(); _nextGeneration(); unawaited(_codes?.cancel()); unawaited(_failures?.cancel()); super.dispose(); } }