Relocate local community creation into create flows

This commit is contained in:
npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je
2026-08-10 12:43:26 -04:00
committed by Brother Darryl
parent ed5633c6cf
commit c5e5662810
6 changed files with 307 additions and 28 deletions
@@ -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<AddCommunityMode>("choose");
const [joinError, setJoinError] = React.useState<string | null>(null);
const appliedPrefillId = React.useRef<string | null>(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 (
<Dialog
@@ -123,7 +150,7 @@ export function AddCommunityDialog({
<DialogTitle className="truncate">{title}</DialogTitle>
</div>
<DialogDescription
className={mode === "create" ? "sr-only" : undefined}
className={mode === "create-hosted" ? "sr-only" : undefined}
>
{description}
</DialogDescription>
@@ -135,7 +162,7 @@ export function AddCommunityDialog({
<button
className={OPTION_CLASS}
data-testid="add-community-create"
onClick={() => setMode("create")}
onClick={() => setMode("create-choose")}
type="button"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
@@ -188,6 +215,13 @@ export function AddCommunityDialog({
}
variant="add-community"
/>
) : mode === "create-choose" ? (
<CommunityCreationChoice
hasLocalCommunity={hasLocalCommunity}
onChooseHosted={() => setMode("create-hosted")}
onChooseLocal={startLocalCommunity}
variant="dialog"
/>
) : (
<HostedCommunityCreateFlow onComplete={handleClose} />
)}
@@ -0,0 +1,105 @@
import { ChevronRight, Cloud, Laptop } from "lucide-react";
import { Card } from "@/shared/ui/card";
const LOCAL_DESCRIPTION =
"Private to this Mac. Only you can use it — nothing is shared online, no one can join, and other devices can't see it. Your agents still work.";
const HOSTED_DESCRIPTION =
"Claim a Buzz address so your team can join from anywhere. Opens Builderlab.";
const ONBOARDING_OPTION_CLASS =
"w-full max-w-[440px] px-6 py-5 text-left text-foreground transition-[filter] duration-150 ease-out hover:brightness-[0.98] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-foreground/35";
const DIALOG_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";
type CommunityCreationChoiceProps = {
hasLocalCommunity?: boolean;
onChooseHosted: () => void;
onChooseLocal: () => void;
variant: "onboarding" | "dialog";
};
export function CommunityCreationChoice({
hasLocalCommunity = false,
onChooseHosted,
onChooseLocal,
variant,
}: CommunityCreationChoiceProps) {
const localLabel = hasLocalCommunity
? "Open your on-this-device community"
: "Keep it on this device";
if (variant === "onboarding") {
return (
<div className="flex w-full flex-col items-center gap-5">
<Card asChild className={ONBOARDING_OPTION_CLASS} variant="textured">
<button
data-testid="community-create-hosted"
onClick={onChooseHosted}
type="button"
>
<span className="block text-sm font-medium">Host it online</span>
<span className="mt-1 block text-xs leading-5 text-foreground/70">
{HOSTED_DESCRIPTION}
</span>
</button>
</Card>
<Card asChild className={ONBOARDING_OPTION_CLASS} variant="textured">
<button
data-testid="community-choice-local"
onClick={onChooseLocal}
type="button"
>
<span className="block text-sm font-medium">{localLabel}</span>
<span className="mt-1 block text-xs leading-5 text-foreground/70">
{LOCAL_DESCRIPTION}
</span>
</button>
</Card>
</div>
);
}
return (
<div className="space-y-3">
<button
className={DIALOG_OPTION_CLASS}
data-testid="community-create-hosted"
onClick={onChooseHosted}
type="button"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Cloud className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">
Host it online
</span>
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">
{HOSTED_DESCRIPTION}
</span>
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground/60" />
</button>
<button
className={DIALOG_OPTION_CLASS}
data-testid="community-choice-local"
onClick={onChooseLocal}
type="button"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Laptop className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">
{localLabel}
</span>
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">
{LOCAL_DESCRIPTION}
</span>
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground/60" />
</button>
</div>
);
}
@@ -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({
</p>
</div>
<div className="flex w-full flex-1 translate-y-16 flex-col items-center justify-center gap-20 py-8">
<Card
asChild
className={COMMUNITY_OPTION_CARD_CLASS}
variant="textured"
>
<button
data-testid="community-choice-local"
onClick={startLocalCommunity}
type="button"
>
Use this device
</button>
</Card>
<Card
asChild
className={COMMUNITY_OPTION_CARD_CLASS}
@@ -170,7 +164,7 @@ export function WelcomeSetup({
>
<button
data-testid="community-choice-create"
onClick={() => setIsHostedSignInOpen(true)}
onClick={() => showPage("create")}
type="button"
>
Create a community
@@ -204,6 +198,38 @@ export function WelcomeSetup({
</OnboardingFooter>
) : null}
</OnboardingSlideTransition>
) : page === "create" ? (
<OnboardingSlideTransition
className="flex h-full min-h-0 w-full flex-col items-center text-center"
containerClassName="h-full min-h-0 [&>.buzz-onboarding-transition-line]:h-full"
direction={transitionDirection}
transitionKey={`create-${transitionDirection}`}
>
<div className="w-full max-w-[760px]">
<h1 className="text-title font-normal">Create a community</h1>
<p className="mt-3 text-sm leading-6 text-foreground/80">
Choose where your community will live.
</p>
</div>
<div className="flex w-full flex-1 items-center justify-center py-8">
<CommunityCreationChoice
onChooseHosted={() => setIsHostedSignInOpen(true)}
onChooseLocal={startLocalCommunity}
variant="onboarding"
/>
</div>
<OnboardingFooter>
<Button
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
data-testid="community-create-back"
onClick={() => showPage("welcome")}
type="button"
variant="ghost"
>
Back
</Button>
</OnboardingFooter>
</OnboardingSlideTransition>
) : page === "existing" ? (
<OnboardingSlideTransition
className="flex h-full min-h-0 w-full flex-col items-center text-center"
@@ -66,6 +66,7 @@ test("capture: join an existing community", async ({ page }) => {
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);
+20 -1
View File
@@ -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();
});
+94
View File
@@ -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,
}) => {