diff --git a/desktop/src/features/communities/ui/AddCommunityDialog.tsx b/desktop/src/features/communities/ui/AddCommunityDialog.tsx index a83456652..108ba4c35 100644 --- a/desktop/src/features/communities/ui/AddCommunityDialog.tsx +++ b/desktop/src/features/communities/ui/AddCommunityDialog.tsx @@ -2,6 +2,12 @@ import * as React from "react"; import { ArrowLeft, ChevronRight, Link2, Plus } from "lucide-react"; import type { AddCommunityPrefillRequest } from "@/features/communities/addCommunityPrefill"; +import { + LOCAL_COMMUNITY_NAME, + LOCAL_COMMUNITY_RELAY_URL, +} from "@/features/communities/communityStorage"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { CommunityCreationChoice } from "@/features/communities/ui/CommunityCreationChoice"; import { HostedCommunityCreateFlow } from "@/features/communities/ui/HostedCommunityCreateFlow"; import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; import { InviteRedeemForm } from "@/features/onboarding/ui/InviteRedeemForm"; @@ -22,7 +28,7 @@ type AddCommunityDialogProps = { onOpenChange: (open: boolean) => void; }; -type AddCommunityMode = "choose" | "create" | "join"; +type AddCommunityMode = "choose" | "create-choose" | "create-hosted" | "join"; const OPTION_CLASS = "flex w-full items-center gap-3 rounded-xl border border-border/70 bg-muted/30 px-4 py-4 text-left transition-colors duration-150 ease-out hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; @@ -33,6 +39,8 @@ export function AddCommunityDialog({ onOpenChange, }: AddCommunityDialogProps) { const communityOnboarding = useCommunityOnboarding(); + const { communities } = useCommunities(); + const hasLocalCommunity = communities.some((community) => community.local); const [mode, setMode] = React.useState("choose"); const [joinError, setJoinError] = React.useState(null); const appliedPrefillId = React.useRef(null); @@ -78,19 +86,38 @@ export function AddCommunityDialog({ [communityOnboarding, handleClose, prefill?.name], ); + const startLocalCommunity = React.useCallback(() => { + const started = communityOnboarding.start({ + source: "add-community", + communityName: LOCAL_COMMUNITY_NAME, + relayUrl: LOCAL_COMMUNITY_RELAY_URL, + }); + if (!started) { + setJoinError( + "Finish connecting the community already in progress, then try again.", + ); + return; + } + handleClose(); + }, [communityOnboarding, handleClose]); + const title = - mode === "create" + mode === "create-choose" ? "Create a new community" - : mode === "join" - ? "Join an existing community" - : "Add community"; + : mode === "create-hosted" + ? "Host a community online" + : mode === "join" + ? "Join an existing community" + : "Add community"; const description = - mode === "create" - ? "Opens Builderlab in your browser." - : mode === "join" - ? "Use the community URL or invite link you received." - : "Create a new community or join one you already have."; + mode === "create-choose" + ? "Choose where your community will live." + : mode === "create-hosted" + ? "Opens Builderlab in your browser." + : mode === "join" + ? "Use the community URL or invite link you received." + : "Create a new community or join one you already have."; return ( {title} {description} @@ -135,7 +162,7 @@ export function AddCommunityDialog({ + + + + + + ); + } + + return ( +
+ + +
+ ); +} diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx index 21859c653..e309b2767 100644 --- a/desktop/src/features/communities/ui/WelcomeSetup.tsx +++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx @@ -5,6 +5,7 @@ import { LOCAL_COMMUNITY_NAME, LOCAL_COMMUNITY_RELAY_URL, } from "@/features/communities/communityStorage"; +import { CommunityCreationChoice } from "@/features/communities/ui/CommunityCreationChoice"; import { HostedCommunityOnboarding } from "@/features/communities/ui/HostedCommunityOnboarding"; import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; import { InviteRedeemForm } from "@/features/onboarding/ui/InviteRedeemForm"; @@ -25,7 +26,13 @@ import { Button } from "@/shared/ui/button"; import { Card } from "@/shared/ui/card"; import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; -type WelcomeSetupPage = "welcome" | "existing" | "join" | "member" | "owned"; +type WelcomeSetupPage = + | "welcome" + | "create" + | "existing" + | "join" + | "member" + | "owned"; type WelcomeTransitionMode = "initial" | OnboardingTransitionDirection; type WelcomeSetupProps = { @@ -137,19 +144,6 @@ export function WelcomeSetup({

- - - + + ) : page === "existing" ? ( { test("capture: create a new community", async ({ page }) => { await page.getByTestId("add-community-create").click(); + await page.getByTestId("community-create-hosted").click(); const dialog = page.getByTestId("add-community-dialog"); await page.getByLabel("Community address").waitFor(); await waitForAnimations(page); diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 403edbcda..3f5733b2e 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -1087,6 +1087,15 @@ test("first-community choices route join, create, owner, and member intents", as await expect( page.getByRole("button", { name: /Create a community/ }), ).toBeVisible(); + await expect(page.getByTestId("community-choice-local")).toHaveCount(0); + await page.getByTestId("community-choice-create").click(); + await expect(page.getByTestId("community-create-hosted")).toContainText( + "Host it online", + ); + await expect(page.getByTestId("community-choice-local")).toContainText( + "Keep it on this device", + ); + await page.getByTestId("community-create-back").click(); const existing = page.getByRole("button", { name: /I already have a community/, }); @@ -1169,6 +1178,7 @@ test("first-community owner can connect an existing hosted community", async ({ await page.goto("/"); await page.getByTestId("community-choice-create").click(); + await page.getByTestId("community-create-hosted").click(); await expect(page.getByText("North Star")).toBeVisible(); await page.getByRole("button", { name: "Connect", exact: true }).click(); await expect( @@ -1227,6 +1237,7 @@ test("first-community owner can create and connect a hosted community", async ({ await page.goto("/"); await page.getByTestId("community-choice-create").click(); + await page.getByTestId("community-create-hosted").click(); await page.getByRole("button", { name: "Sign in to continue" }).click(); await expect( page.getByRole("heading", { name: "Finish connecting Buzz" }), @@ -1303,6 +1314,7 @@ test("hosted community address line stays within the card for a long name", asyn await page.goto("/"); await page.getByTestId("community-choice-create").click(); + await page.getByTestId("community-create-hosted").click(); await page.getByRole("button", { name: "Sign in to continue" }).click(); await expect( page.getByRole("heading", { name: "Finish connecting Buzz" }), @@ -1372,6 +1384,7 @@ test("first-community reports a created community without a relay address", asyn await page.goto("/"); await page.getByTestId("community-choice-create").click(); + await page.getByTestId("community-create-hosted").click(); await page.getByRole("textbox", { name: "Community name" }).fill("bee-lab"); await expect(page.getByText("That address is available.")).toBeVisible(); await page.getByRole("button", { name: "Next" }).click(); @@ -1403,6 +1416,7 @@ test("first-community X cancels a pending sign-in", async ({ page }) => { await page.goto("/"); await page.getByTestId("community-choice-create").click(); + await page.getByTestId("community-create-hosted").click(); await page.getByRole("button", { name: "Sign in to continue" }).click(); await expect(page.getByText("Waiting for your browser…")).toBeVisible(); await expect( @@ -1410,8 +1424,9 @@ test("first-community X cancels a pending sign-in", async ({ page }) => { ).toHaveCount(0); await page.getByRole("button", { name: "Close" }).click(); await expect( - page.getByRole("button", { name: /Create a community/ }), + page.getByRole("heading", { name: "Create a community" }), ).toBeVisible(); + await expect(page.getByTestId("community-create-hosted")).toBeVisible(); await expect .poll(() => page.evaluate(() => window.__BUZZ_E2E_COMMANDS__ ?? [])) .toEqual(expect.arrayContaining(["cancel_builderlab_login"])); @@ -1445,6 +1460,7 @@ test("first-community owner can replace a mismatched account identity", async ({ await page.goto("/"); await page.getByTestId("community-choice-create").click(); + await page.getByTestId("community-create-hosted").click(); await expect( page.getByRole("heading", { name: "This account uses a different Buzz identity", @@ -1495,6 +1511,7 @@ test("first-community explains when the local identity belongs to another accoun await page.goto("/"); await page.getByTestId("community-choice-create").click(); + await page.getByTestId("community-create-hosted").click(); await page .getByRole("button", { name: "Use this device's identity" }) .click(); @@ -1536,8 +1553,10 @@ test("back clears Builderlab auth before returning to first-community choices", await page.goto("/"); await page.getByTestId("community-choice-create").click(); + await page.getByTestId("community-create-hosted").click(); await page.getByRole("button", { name: "Back" }).click(); await page.getByTestId("community-choice-create").click(); + await page.getByTestId("community-create-hosted").click(); await expect(page.getByRole("button", { name: "Continue" })).toBeVisible(); }); diff --git a/desktop/tests/e2e/sidebar.spec.ts b/desktop/tests/e2e/sidebar.spec.ts index 9dce5e759..be97ba4e0 100644 --- a/desktop/tests/e2e/sidebar.spec.ts +++ b/desktop/tests/e2e/sidebar.spec.ts @@ -103,11 +103,105 @@ test("add community starts with create and join choices", async ({ page }) => { await expect( page.getByRole("heading", { name: "Create a new community" }), ).toBeVisible(); + await expect(page.getByTestId("community-create-hosted")).toContainText( + "Host it online", + ); + await expect(page.getByTestId("community-choice-local")).toContainText( + "Keep it on this device", + ); + await page.getByTestId("community-create-hosted").click(); await page.getByRole("button", { name: "Continue to Builderlab" }).click(); await page.getByRole("button", { name: "Connect and continue" }).click(); await expect(page.getByLabel("Community address")).toBeVisible(); }); +test("add community can start an on-this-device community", async ({ + page, +}) => { + await installMockBridge(page, { applyCommunityDelayMs: 1_000 }); + await page.goto("/"); + + await openAddCommunityDialog(page); + await page.getByTestId("add-community-create").click(); + await page.getByTestId("community-choice-local").click(); + + await expect + .poll(() => + page.evaluate((key) => { + const raw = window.localStorage.getItem(key); + if (!raw) return null; + const transaction = JSON.parse(raw) as { + source?: string; + communityName?: string; + relayUrl?: string; + }; + return { + source: transaction.source, + communityName: transaction.communityName, + relayUrl: transaction.relayUrl, + }; + }, COMMUNITY_ONBOARDING_STORAGE_KEY), + ) + .toEqual({ + source: "add-community", + communityName: "On this device", + relayUrl: "buzz-local://on-this-device", + }); +}); + +test("create choice offers to open an existing on-this-device community", async ({ + page, +}) => { + await page.addInitScript(() => { + const communities = [ + { + id: "hosted", + name: "Hosted", + relayUrl: "wss://hosted.example.com", + addedAt: "2026-01-01T00:00:00.000Z", + }, + { + id: "local", + name: "On this device", + relayUrl: "buzz-local://on-this-device", + local: true, + addedAt: "2026-01-02T00:00:00.000Z", + }, + ]; + window.localStorage.setItem( + "buzz-communities", + JSON.stringify(communities), + ); + window.localStorage.setItem("buzz-active-community-id", "hosted"); + }); + await installMockBridge( + page, + { applyCommunityDelayMs: 1_000 }, + { skipCommunitySeed: true }, + ); + await page.goto("/"); + + await openAddCommunityDialog(page); + await page.getByTestId("add-community-create").click(); + await expect(page.getByTestId("community-choice-local")).toContainText( + "Open your on-this-device community", + ); + await page.getByTestId("community-choice-local").click(); + await expect + .poll(() => + page.evaluate(() => localStorage.getItem("buzz-active-community-id")), + ) + .toBe("local"); + await expect + .poll(() => + page.evaluate(() => { + const raw = localStorage.getItem("buzz-communities"); + return raw ? JSON.parse(raw).length : 0; + }), + ) + .toBe(2); +}); + test("automatically shows community join requirements near the community URL", async ({ page, }) => {