fix(desktop): recover first community joins (#2087)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-18 15:31:17 -07:00
committed by GitHub
co-authored by Pinky
parent 5a656c9818
commit 775cf672d1
7 changed files with 264 additions and 95 deletions
+66 -51
View File
@@ -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<string | null>(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 = (
<WelcomeSetup
defaultRelayUrl={community.defaultRelayUrl}
initialPage={resumeFirstCommunityJoin ? "join" : undefined}
onBack={onBackToMachineConfig}
/>
);
@@ -465,6 +479,7 @@ function CommunityApp({
}
>
<CommunityOnboardingFlow
onCancel={handleCommunityOnboardingCancel}
onConnect={handleCommunityOnboardingConnect}
/>
</div>
@@ -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<WelcomeSetupPage>("welcome");
const [page, setPage] = React.useState<WelcomeSetupPage>(initialPage);
const [transitionMode, setTransitionMode] =
React.useState<WelcomeTransitionMode>(initialTransitionMode);
const [npub, setNpub] = React.useState("");
@@ -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"
@@ -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}
<Button
className="rounded-full bg-foreground/10 px-5 hover:bg-foreground/15"
onClick={clear}
onClick={onCancel}
variant="ghost"
>
Cancel
@@ -13,7 +13,6 @@ import {
persistCurrentIdentity,
} from "@/shared/api/tauriIdentity";
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
import { forceFreshOnboarding } from "@/features/onboarding/devFreshOnboarding";
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { AvatarStep } from "./AvatarStep";
@@ -513,7 +512,7 @@ export function OnboardingFlow({
}}
direction={transitionDirection}
state={profileStepState}
usesExistingIdentity={forceFreshOnboarding}
usesExistingIdentity
/>
) : currentPage === "key-import" ? (
<OnboardingSlideTransition
@@ -1,6 +1,4 @@
import { hexToBytes } from "@noble/hashes/utils.js";
import { expect, test } from "@playwright/test";
import { nsecEncode } from "nostr-tools/nip19";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
@@ -82,36 +80,19 @@ test("avatar Next button still requires an avatar to be chosen", async ({
// B4: Routing tests
// ---------------------------------------------------------------------------
test("import-key path skips backup and goes directly to avatar", async ({
page,
}) => {
// Import tyler's OWN key (same pubkey = no component remount) so the
// identityWasImported flag persists in the same component instance.
test("normal profile setup keeps the existing identity", async ({ page }) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
// Profile page — click "Use existing key" to open the key import form.
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
await page.getByTestId("onboarding-import-key").click();
await expect(
page.getByRole("heading", { name: "Use your existing key" }),
).toBeVisible();
await expect(page.getByTestId("onboarding-import-key")).toHaveCount(0);
await expect(page.getByText("Create an identity key")).toHaveCount(0);
// Enter tyler's own nsec (same pubkey → no remount, identityWasImported stays true).
const tylerNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.tyler.privateKey));
await page.getByTestId("nostr-import-nsec-input").fill(tylerNsec);
await expect(page.getByTestId("nostr-import-npub-preview")).toBeVisible();
await page.getByTestId("nostr-import-submit").click();
// After import, the flow returns to profile with identityWasImported=true.
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
// Backup page must NOT appear — avatar comes next on the import path.
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await expect(page.getByTestId("onboarding-page-backup")).not.toBeVisible();
});
test("Back from the community avatar step returns to profile", async ({
+184 -18
View File
@@ -767,6 +767,184 @@ test("first-community shows the scenario cards for localhost", async ({
).toBeVisible();
});
test("first-community direct join reaches profile", async ({ page }) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript((pubkey) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(page, undefined, {
relayWsUrl: "wss://onboarding.communities.buzz.xyz",
skipOnboardingSeed: true,
skipCommunitySeed: true,
});
await page.goto("/");
await page.getByRole("button", { name: "Add me to a community" }).click();
await page
.getByTestId("welcome-join-community-url")
.fill("wss://onboarding.communities.buzz.xyz");
await page.getByRole("button", { name: "Join community" }).click();
await expect(
page.getByRole("heading", { name: "Build your profile" }),
).toBeVisible();
await expect(page.getByText("Connecting securely…")).toHaveCount(0);
await expect(page.getByText("Create an identity key")).toHaveCount(0);
await expect
.poll(() =>
page.evaluate((transactionStorageKey) => {
const communitiesRaw = window.localStorage.getItem("buzz-communities");
const transactionRaw = window.localStorage.getItem(
transactionStorageKey,
);
const communities = communitiesRaw
? (JSON.parse(communitiesRaw) as Array<{ id: string }>)
: [];
const transaction = transactionRaw
? (JSON.parse(transactionRaw) as { communityId?: string })
: null;
return {
communityCount: communities.length,
transactionMatchesOnlyCommunity:
communities.length === 1 &&
transaction?.communityId === communities[0]?.id,
};
}, COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY),
)
.toEqual({ communityCount: 1, transactionMatchesOnlyCommunity: true });
});
test("first-community direct join cancel returns to request access", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript((pubkey) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(
page,
{ applyCommunityDelayMs: 5_000 },
{
relayWsUrl: "wss://onboarding.communities.buzz.xyz",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
);
await page.goto("/");
await page.getByRole("button", { name: "Add me to a community" }).click();
await page
.getByTestId("welcome-join-community-url")
.fill("wss://onboarding.communities.buzz.xyz");
await page.getByRole("button", { name: "Join community" }).click();
await expect(page.getByText("Connecting securely…")).toBeVisible();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(
page.getByRole("heading", { name: "Request access to community" }),
).toBeVisible();
await expect(page.getByTestId("community-change-overlay")).toHaveCount(0);
await expect(page.getByText("Create an identity key")).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(
(storageKey) => ({
communities: window.localStorage.getItem("buzz-communities"),
transaction: window.localStorage.getItem(storageKey),
}),
COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY,
),
)
.toEqual({ communities: null, transaction: null });
});
test("canceling a join to an existing inactive community preserves it", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript(
({ pubkey, relayUrl }) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
const timestamp = new Date().toISOString();
window.localStorage.setItem(
"buzz-communities",
JSON.stringify([
{
id: "active-community",
name: "Active",
relayUrl: "wss://active.example.com",
addedAt: timestamp,
},
{
id: "existing-community",
name: "Existing",
relayUrl,
addedAt: timestamp,
},
]),
);
window.localStorage.setItem(
"buzz-active-community-id",
"active-community",
);
},
{
pubkey: BLANK_TYLER_IDENTITY.pubkey,
relayUrl: "wss://onboarding.communities.buzz.xyz",
},
);
await installMockBridge(
page,
{ applyCommunityDelayMs: 5_000 },
{
relayWsUrl: "wss://active.example.com",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
);
await page.goto("/");
await page.evaluate((transactionStorageKey) => {
const timestamp = new Date().toISOString();
window.localStorage.setItem(
transactionStorageKey,
JSON.stringify({
id: "existing-community-join",
source: "add-community",
stage: "connecting",
relayUrl: "wss://onboarding.communities.buzz.xyz",
communityName: "Existing",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
window.location.reload();
}, COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY);
await expect(page.getByText("Connecting securely…")).toBeVisible();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByText("Connecting securely…")).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(() => {
const raw = window.localStorage.getItem("buzz-communities");
return raw
? (JSON.parse(raw) as Array<{ id: string }>).map(({ id }) => id)
: [];
}),
)
.toEqual(["active-community", "existing-community"]);
});
test("connected first-community profile step cannot discard resumable onboarding", async ({
page,
}) => {
@@ -1400,31 +1578,19 @@ test("existing relay profile with display name auto-skips onboarding without loc
await expectHomeView(page);
});
test("onboarding can import an existing key when the community is already set up", async ({
test("onboarding uses the existing identity when the community is already set up", async ({
page,
}) => {
// Community exists (default seed), but this identity has no profile yet,
// so the app lands on the onboarding name step — Tyler's moved-laptop /
// fresh-dev-instance case.
// Community exists (default seed), and machine onboarding has already created
// this identity. Profile setup must not offer to create or replace it.
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await expect(page.getByTestId("onboarding-display-name")).toHaveValue("");
await page.getByTestId("onboarding-import-key").click();
await expect(
page.getByRole("heading", { name: "Use your existing key" }),
).toBeVisible();
const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey));
await page.getByTestId("nostr-import-nsec-input").fill(importedNsec);
await expect(page.getByTestId("nostr-import-npub-preview")).toBeVisible();
await page.getByTestId("nostr-import-submit").click();
// Identity swap remounts the flow; alice already has a relay profile with
// a display name, so onboarding auto-completes and lands in the app.
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expectHomeView(page);
await expect(page.getByTestId("onboarding-next")).toHaveText("Continue");
await expect(page.getByTestId("onboarding-import-key")).toHaveCount(0);
await expect(page.getByText("Create an identity key")).toHaveCount(0);
});
test("completed onboarding backfills missing starter channels", async ({