Files
buzz/mobile/lib/shared/hosted/hosted_flow_provider.dart
klopez4212 2ca77747d1 Fix mobile Add Community loading and membership restoration (#8796)
## Summary
Open Add Community immediately from the post-login picker and return
directly on Back, without membership restoration or repeated discovery
during navigation. Completing Add restores the saved selection while
keeping the newly added community active; failures retain progress and
offer Retry.

Follow-up to #8729. Mobile app update only; no relay deployment
required.

## Testing
`just mobile-install mobile-check mobile-test` passed (4,025 standard
tests), plus 4,049 Builderlab-enabled tests on the merged revision.
Production-path journeys cover hosted and self-hosted completion,
partial restoration and Retry, persisted memberships, and the active
destination. Removing the completion hook makes the new journeys fail.
Independent agent review found no blockers. Native walkthrough and human
verification remain pending.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-31 16:01:15 +00:00

275 lines
9.0 KiB
Dart

import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../features/invites/invite_join_provider.dart';
import '../../features/profile/profile_provider.dart';
import '../relay/relay_provider.dart';
import '../relay/relay_session.dart';
import '../auth/auth.dart';
import '../deeplink/deep_link.dart';
import 'account_identity_store.dart';
import 'browser_auth.dart';
import 'builderlab_account.dart';
import 'hosted_admission.dart';
import 'hosted_community_connection.dart';
import 'hosted_flow.dart';
import 'hosted_identity_service.dart';
import 'hosted_relay_auth.dart';
// Set by a community-entry surface while it owns setup presentation.
final hostedInlineEntryProvider = NotifierProvider<HostedInlineEntry, bool>(
HostedInlineEntry.new,
);
class HostedInlineEntry extends Notifier<bool> {
@override
bool build() => false;
void setActive(bool active) {
if (ref.mounted) state = active;
}
}
/// Signals a completed local admission to the currently mounted Add route.
final communityEntryCompletionProvider =
NotifierProvider<CommunityEntryCompletion, int>(
CommunityEntryCompletion.new,
);
class CommunityEntryCompletion extends Notifier<int> {
@override
int build() => 0;
void complete() {
if (ref.mounted) state++;
}
}
final hostedFlowProvider = Provider<HostedFlow>((ref) => createHostedFlow(ref));
/// Shared orchestration with injectable browser and relay network boundaries.
HostedFlow createHostedFlow(
Ref ref, {
BrowserAuth? browserAuth,
Future<void> Function(Community)? probe,
}) {
final account = ref.read(builderlabAccountProvider);
final browser = browserAuth ?? BrowserAuth();
final store = AccountIdentityStore();
late final HostedFlow flow;
final admission = HostedAdmission(
relayAuth: ref.read(hostedRelayAuthProvider),
account: account,
store: store,
confirmPolicy: (policy) => flow.confirmJoinPolicy(policy),
probe: probe,
);
flow = HostedFlow(
account: account,
browser: browser,
store: store,
identity: HostedIdentityService(account: account, store: store),
captureAddedCommunity: () async {
final storage = ref.read(communityStorageProvider);
final activeId = await storage.loadActiveId();
final saved = await storage.loadAll();
final destination = saved
.where((item) => item.id == activeId)
.firstOrNull;
if (destination == null) throw AccountFailure.unavailable;
return (isCurrent) async {
if (!isCurrent()) return;
final active = await storage.loadActiveId();
if (!isCurrent() || active == destination.id) return;
final saved = await storage.loadAll();
final currentDestination = saved
.where(
(item) =>
item.id == destination.id &&
item.pubkey == destination.pubkey,
)
.firstOrNull;
if (!isCurrent()) return;
if (currentDestination == null) throw AccountFailure.unavailable;
await ref
.read(authProvider.notifier)
.authenticateWithCommunity(
currentDestination,
shouldContinue: isCurrent,
);
};
},
openLandingCommunity: (link, subject, pubkey, isCurrent) async {
bool current() => isCurrent() && account.account?.subject == subject;
final storage = ref.read(communityStorageProvider);
final saved = await storage.loadAll();
if (!current()) return;
final destination = saved
.where(
(item) =>
relayIdentity(item.relayUrl) == relayIdentity(link.relayUrl!) &&
item.pubkey == pubkey,
)
.firstOrNull;
if (destination == null) throw AccountFailure.unavailable;
final active = await storage.loadActiveId();
if (!current() || active == destination.id) return;
await ref
.read(authProvider.notifier)
.authenticateWithCommunity(destination, shouldContinue: current);
},
enterCommunity: (link, subject, pubkey, isCurrent) async {
await connectHostedCommunity(
link: link,
subject: subject,
pubkey: pubkey,
isCurrent: () => isCurrent() && account.account?.subject == subject,
storage: ref.read(communityStorageProvider),
admission: admission,
persist: (community, current) => ref
.read(authProvider.notifier)
.authenticateWithCommunity(
flow.pairedSensitiveActionPolicy == null
? community
: community.copyWith(
sensitiveActionPolicy: flow.pairedSensitiveActionPolicy,
),
shouldContinue: current,
activate:
community.starterSetupIncomplete ||
(flow.addedCommunityCount == 0 && !flow.restoringAfterAdd),
),
recoverStarterChannels: (community, current) =>
finishHostedStarterSetup(ref, community, current),
);
if (flow.addedCommunityCount == 0 &&
!flow.restoringAfterAdd &&
isCurrent()) {
await offerHostedProfile(
ref,
flow,
link,
pubkey,
() => isCurrent() && account.account?.subject == subject,
);
}
},
);
void onCompleted() {
if (flow.reconnecting && flow.stage == HostedFlowStage.complete) {
unawaited(ref.read(relaySessionProvider.notifier).reconnect());
}
}
flow.addListener(onCompleted);
ref.onDispose(() {
flow.removeListener(onCompleted);
flow.dispose();
browser.dispose();
admission.dispose();
});
return flow;
}
/// Optional and scoped to the first activated community. A failed read is not
/// proof that no profile exists; leave it to Settings in that case.
Future<void> offerHostedProfile(
Ref ref,
HostedFlow flow,
BuzzDeepLink link,
String pubkey,
bool Function() isCurrent,
) async {
final relay = link.relayUrl;
if (relay == null || !ref.mounted || !isCurrent()) return;
final active = await ref.read(activeCommunityProvider.future);
bool current() {
if (!ref.mounted || !isCurrent()) return false;
final now = ref.read(activeCommunityProvider).asData?.value;
return now != null &&
now.id == active?.id &&
now.pubkey == pubkey &&
relayIdentity(now.relayUrl) == relayIdentity(relay);
}
if (!current()) return;
// Keep the provider hydrated throughout the optional sheet.
final subscription = ref.listen(profileProvider, (_, _) {});
try {
try {
final profile = await ref.read(profileProvider.future);
if (profile != null || !current()) return;
} catch (_) {
return;
}
await flow.requestProfile(
(name) async {
if (!current()) throw AccountFailure.unavailable;
await ref.read(profileProvider.notifier).updateDisplayName(name);
},
saveDraft: (name, prepareAvatar) async {
if (!current()) throw AccountFailure.unavailable;
final writer = ref.read(profileProvider.notifier);
final avatar = await prepareAvatar?.call();
if (!current()) throw AccountFailure.unavailable;
if (avatar == null) {
await writer.updateDisplayName(name);
} else {
await writer.updateSetup(name, avatar);
}
},
);
} finally {
subscription.close();
}
}
/// Completes the existing scoped invite recovery only after activation. The
/// pending flag remains durable on failure, cancellation or a community switch.
Future<void> finishHostedStarterSetup(
Ref ref,
Community community,
bool Function() isCurrent,
) async {
void checkCurrent() {
if (!ref.mounted || !isCurrent()) throw AccountFailure.unavailable;
}
bool matches(Community? candidate) =>
candidate?.id == community.id &&
candidate?.relayUrl == community.relayUrl &&
candidate?.pubkey == community.pubkey &&
candidate?.nsec == community.nsec;
checkCurrent();
final active = await ref.read(activeCommunityProvider.future);
checkCurrent();
if (!matches(active)) throw AccountFailure.unavailable;
final config = RelayConfig(baseUrl: community.relayUrl, nsec: community.nsec);
await ref
.read(inviteJoinRecoveryProvider)(
InviteJoinRecoveryScope(
relayHttpOrigin: config.baseUrl,
nsec: config.nsec,
),
)
.ensureStarterChannels();
checkCurrent();
await ref.read(communityTransitionProvider).runExclusive(() async {
checkCurrent();
final storage = ref.read(communityStorageProvider);
final saved = (await storage.loadAll())
.where((item) => item.id == community.id)
.firstOrNull;
final activeId = await storage.loadActiveId();
checkCurrent();
if (!matches(saved) || activeId != community.id) {
throw AccountFailure.unavailable;
}
await storage.save(saved!.copyWith(starterSetupIncomplete: false));
await syncStoredCommunitySnapshot(ref);
ref.invalidate(communityListProvider);
ref.invalidate(activeCommunityProvider);
ref.invalidate(authProvider);
});
}