diff --git a/desktop/src/features/onboarding/communityOnboarding.test.mjs b/desktop/src/features/onboarding/communityOnboarding.test.mjs index e9645b191e..d991f1ce13 100644 --- a/desktop/src/features/onboarding/communityOnboarding.test.mjs +++ b/desktop/src/features/onboarding/communityOnboarding.test.mjs @@ -83,6 +83,32 @@ test("same-relay ingress resumes rather than replacing progress", () => { assert.equal(resumed.inviteCode, "new-code"); }); +test("same-relay membership recovery restarts at invite claim", () => { + const storage = createMemoryStorage(); + const deniedConnect = startCommunityOnboarding( + { source: "first-community", relayUrl: "wss://relay.example" }, + storage, + new Date("2026-07-16T00:00:00Z"), + ); + assert.equal(deniedConnect.stage, "connecting"); + + const recovered = startCommunityOnboarding( + { + source: "membership-recovery", + relayUrl: "wss://relay.example/", + inviteCode: " same-relay-code ", + policyReceipt: "policy-receipt", + }, + storage, + new Date("2026-07-16T00:01:00Z"), + ); + + assert.equal(recovered.id, deniedConnect.id); + assert.equal(recovered.stage, "claiming"); + assert.equal(recovered.inviteCode, "same-relay-code"); + assert.equal(recovered.policyReceipt, "policy-receipt"); +}); + test("stale asynchronous updates cannot mutate a replacement transaction", () => { const storage = createMemoryStorage(); const original = startCommunityOnboarding( diff --git a/desktop/src/features/onboarding/communityOnboarding.tsx b/desktop/src/features/onboarding/communityOnboarding.tsx index 99da68aa95..f339e3e56b 100644 --- a/desktop/src/features/onboarding/communityOnboarding.tsx +++ b/desktop/src/features/onboarding/communityOnboarding.tsx @@ -154,8 +154,16 @@ export function startCommunityOnboarding( const relayUrl = canonicalRelayUrl(input.relayUrl); const existing = loadCommunityOnboardingTransaction(storage); if (existing?.relayUrl === relayUrl) { + const isMembershipRecoveryClaim = + input.source === "membership-recovery" && + Boolean(input.inviteCode?.trim()); const updated = { ...existing, + // A same-relay membership recovery is not a passive resume: the relay + // already rejected the connect attempt, so the invite must restart the + // transaction at the claim stage. Other same-relay ingress still keeps + // in-progress profile/finalization work intact. + stage: isMembershipRecoveryClaim ? "claiming" : existing.stage, firstCommunityPage: input.firstCommunityPage ?? existing.firstCommunityPage, inviteCode: input.inviteCode?.trim() || existing.inviteCode, diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index 98210c57cd..9c83854669 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -371,6 +371,7 @@ export function CommunityOnboardingFlow({ setIsMembershipDenied(false); update({ stage: "connecting", error: undefined }); }} + onInviteRedeemStarted={() => setIsMembershipDenied(false)} onRetry={() => { setIsMembershipDenied(false); update({ stage: "connecting", error: undefined }); diff --git a/desktop/src/features/onboarding/ui/MembershipDenied.tsx b/desktop/src/features/onboarding/ui/MembershipDenied.tsx index 3de6857d31..9f8e916d83 100644 --- a/desktop/src/features/onboarding/ui/MembershipDenied.tsx +++ b/desktop/src/features/onboarding/ui/MembershipDenied.tsx @@ -17,6 +17,7 @@ type MembershipDeniedProps = { onBack: () => void; onChangeCommunity: () => void; onImportKey: (nsec: string) => Promise; + onInviteRedeemStarted?: () => void; onRetry: () => void; pubkey: string; }; @@ -26,6 +27,7 @@ export function MembershipDenied({ onBack, onChangeCommunity, onImportKey, + onInviteRedeemStarted, onRetry, pubkey, }: MembershipDeniedProps) { @@ -86,14 +88,15 @@ export function MembershipDenied({ const handleInviteRedeem = React.useCallback( (relayWsUrl: string, code: string, policyReceipt?: string) => { - communityOnboarding.start({ + const started = communityOnboarding.start({ source: "membership-recovery", relayUrl: relayWsUrl, inviteCode: code, policyReceipt, }); + if (started) onInviteRedeemStarted?.(); }, - [communityOnboarding], + [communityOnboarding, onInviteRedeemStarted], ); return ( diff --git a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx index a3653f750f..930d46d738 100644 --- a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx @@ -435,6 +435,10 @@ export function OnboardingFlow({ }} onChangeCommunity={() => setIsCommunityChangeOpen(true)} onImportKey={importExistingKey} + onInviteRedeemStarted={() => { + setTransitionDirection("forward"); + setCurrentPage(deniedFromPage); + }} onRetry={() => { void saveProfileAndContinue(membershipRetryPage); }}