Files
buzz/mobile/lib/shared/hosted/hosted_flow_provider.dart
klopez4212 f7708d0694 Polish mobile onboarding and profile setup (#8438)
Originally stacked on #8375, now merged. Rebased onto main.

Polishes the onboarding landing page, community-link entry, and recovery
sheets. Profile setup uses a consistent sheet with a larger avatar,
inline display name, and draft photo/emoji/animated editing; iOS uses
native controls and coordinated avatar/sheet transitions.

Validation: mobile analysis, full tests, and normal pre-push checks
passed after rebase. Prior iOS build and device walkthrough passed;
native validation was not repeated after this rebase. Snapshots omitted
as requested.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
2026-10-22 17:27:38 +00:00

227 lines
7.2 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 '../community/paired_community_landing.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),
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,
),
recoverStarterChannels: (community, current) =>
finishHostedStarterSetup(ref, community, current),
);
if (flow.addedCommunityCount == 0 && 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());
} else if (flow.accountOnly && flow.stage == HostedFlowStage.complete) {
ref.read(communityGridLandingProvider.notifier).request();
}
}
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);
});
}