From 32bede0da6890349dc776e1b4db163aa8ef8cdbf Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 18 Jul 2026 15:46:36 -0600 Subject: [PATCH 1/2] fix(desktop): recover first community joins Make first-community connection setup idempotent so repeated React effects cannot create competing provisional communities and strand onboarding. Advance after the selected workspace is applied, while preserving membership enforcement on profile save. Cancel removes only a newly-added provisional community and returns first-time users to request access. Profile setup consistently retains the identity already created during machine onboarding. Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- desktop/src/app/App.tsx | 117 +++++----- .../features/communities/ui/WelcomeSetup.tsx | 4 +- .../onboarding/communityOnboarding.tsx | 4 + .../onboarding/ui/CommunityOnboardingFlow.tsx | 4 +- .../features/onboarding/ui/OnboardingFlow.tsx | 3 +- desktop/tests/e2e/onboarding.spec.ts | 202 ++++++++++++++++-- 6 files changed, 261 insertions(+), 73 deletions(-) diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 0fe740ebc3..782cf1c14c 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -39,7 +39,6 @@ import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup"; import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen"; import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay"; import { createBuzzQueryClient } from "@/shared/api/queryClient"; -import { getMyRelayMembershipLookup } from "@/shared/api/relayMembers"; import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri"; import { type AddCommunityDeepLinkPayload, @@ -62,16 +61,6 @@ const BOOT_SPLASH_MIN_VISIBLE_MS = 1_200; const BOOT_SPLASH_FADE_MS = 200; const INITIAL_RENDER_READY_EVENT = "initial-render-ready"; -function isRelayMembershipDeniedError(error: unknown): boolean { - if (!(error instanceof Error)) return false; - return [ - "You must be a relay member", - "relay_membership_required", - "restricted: not a relay member", - "invalid: you are not a relay member", - ].some((message) => error.message.includes(message)); -} - type BootSplashPhase = "holding" | "fading" | "done"; function useInitialRenderReady() { @@ -275,13 +264,19 @@ function CommunityApp({ }) { const { activeCommunity, + communities, reinitKey, addCommunity, + clearCommunities, + removeCommunity, switchCommunity, reconnectCommunity, } = useCommunities(); const communityOnboarding = useCommunityOnboarding(); + const connectingTransactionRef = useRef(null); const [isCommunityChangeOpen, setIsCommunityChangeOpen] = useState(false); + const [resumeFirstCommunityJoin, setResumeFirstCommunityJoin] = + useState(false); // Surface nest-related backend events (repos-dir errors, legacy migration) // as toasts. Mounted before useCommunityInit so the listeners are registered @@ -311,10 +306,16 @@ function CommunityApp({ const handleCommunityOnboardingConnect = useCallback(() => { const transaction = communityOnboarding.transaction; if (transaction?.stage !== "connecting") return; + if (connectingTransactionRef.current === transaction.id) return; + connectingTransactionRef.current = transaction.id; if (transaction.communityId) { switchCommunity(transaction.communityId); return; } + const previousCommunityId = activeCommunity?.id; + const relayAlreadyExists = communities.some( + (community) => community.relayUrl === transaction.relayUrl, + ); const id = addCommunity({ id: crypto.randomUUID(), name: transaction.communityName, @@ -323,58 +324,70 @@ function CommunityApp({ reposDir: transaction.reposDir, addedAt: new Date().toISOString(), }); - communityOnboarding.update({ communityId: id, error: undefined }); + communityOnboarding.update({ + communityId: id, + previousCommunityId, + addedCommunity: !relayAlreadyExists, + error: undefined, + }); switchCommunity(id); reconnectCommunity(); - }, [addCommunity, communityOnboarding, reconnectCommunity, switchCommunity]); + }, [ + activeCommunity?.id, + addCommunity, + communities, + communityOnboarding, + reconnectCommunity, + switchCommunity, + ]); + + const handleCommunityOnboardingCancel = useCallback(() => { + const transaction = communityOnboarding.transaction; + communityOnboarding.clear(); + + if (!transaction?.communityId) return; + if (!transaction.addedCommunity) { + if (transaction.previousCommunityId) { + switchCommunity(transaction.previousCommunityId); + } + return; + } + if (communities.length === 1) { + if (transaction.source === "first-community") { + setResumeFirstCommunityJoin(true); + } + clearCommunities(); + return; + } + removeCommunity(transaction.communityId); + if (transaction.previousCommunityId) { + switchCommunity(transaction.previousCommunityId); + } + }, [ + clearCommunities, + communities.length, + communityOnboarding, + removeCommunity, + switchCommunity, + ]); const bootSplashPhase = useBootSplashHold(); const transaction = communityOnboarding.transaction; + useEffect(() => { + if (transaction?.stage !== "connecting") { + connectingTransactionRef.current = null; + } + }, [transaction?.stage]); const targetIsReady = transaction?.communityId === activeCommunity?.id && community.isReady && community.appliedKey === communityKey; useEffect(() => { - if ( - transaction?.stage !== "connecting" || - transaction.error || - !targetIsReady - ) { - return; + if (transaction?.stage === "connecting" && targetIsReady) { + communityOnboarding.update({ stage: "profile", error: undefined }); } - - let cancelled = false; - void getMyRelayMembershipLookup() - .then(({ membership, snapshotFound }) => { - if (cancelled) return; - if (snapshotFound && membership === null) { - communityOnboarding.update({ - error: - "You have not been added to this community yet. Ask the host to add your public key, then try again.", - }); - return; - } - communityOnboarding.update({ stage: "profile", error: undefined }); - }) - .catch((error: unknown) => { - if (cancelled) return; - communityOnboarding.update({ - error: isRelayMembershipDeniedError(error) - ? "You have not been added to this community yet. Ask the host to add your public key, then try again." - : "Could not check community access. Check the URL and try again.", - }); - }); - - return () => { - cancelled = true; - }; - }, [ - communityOnboarding.update, - targetIsReady, - transaction?.error, - transaction?.stage, - ]); + }, [communityOnboarding.update, targetIsReady, transaction?.stage]); // During "entering" the transaction stays alive as a curtain: the app mounts // underneath (already pointed at the Welcome channel route) while the // onboarding screen covers it, then fades once Welcome reports ready. @@ -398,6 +411,7 @@ function CommunityApp({ appContent = ( ); @@ -465,6 +479,7 @@ function CommunityApp({ } > diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx index 7c4dbdaaec..4c84975132 100644 --- a/desktop/src/features/communities/ui/WelcomeSetup.tsx +++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx @@ -32,6 +32,7 @@ type WelcomeTransitionMode = "initial" | OnboardingTransitionDirection; type WelcomeSetupProps = { defaultRelayUrl: string; + initialPage?: WelcomeSetupPage; initialTransitionMode?: WelcomeTransitionMode; onBack: () => void; }; @@ -50,10 +51,11 @@ function isLocalDevRelayUrl(relayUrl: string) { export function WelcomeSetup({ defaultRelayUrl, + initialPage = "welcome", initialTransitionMode = "initial", onBack, }: WelcomeSetupProps) { - const [page, setPage] = React.useState("welcome"); + const [page, setPage] = React.useState(initialPage); const [transitionMode, setTransitionMode] = React.useState(initialTransitionMode); const [npub, setNpub] = React.useState(""); diff --git a/desktop/src/features/onboarding/communityOnboarding.tsx b/desktop/src/features/onboarding/communityOnboarding.tsx index 5388c100a3..1f0fe45ec7 100644 --- a/desktop/src/features/onboarding/communityOnboarding.tsx +++ b/desktop/src/features/onboarding/communityOnboarding.tsx @@ -42,6 +42,8 @@ export type CommunityOnboardingTransaction = { */ policyReceipt?: string; communityId?: string; + previousCommunityId?: string; + addedCommunity?: boolean; createdAt: string; updatedAt: string; error?: string; @@ -56,6 +58,8 @@ export type CommunityOnboardingTransactionPatch = Partial< | "stage" | "relayUrl" | "communityId" + | "previousCommunityId" + | "addedCommunity" | "communityName" | "error" | "acknowledged" diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index e4b27fd673..f28dd8de43 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -113,8 +113,10 @@ function AvatarCircle({ } export function CommunityOnboardingFlow({ + onCancel, onConnect, }: { + onCancel: () => void; onConnect: () => void; }) { const { transaction, update, clear } = useCommunityOnboarding(); @@ -372,7 +374,7 @@ export function CommunityOnboardingFlow({ ) : null}