mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Relocate local community creation into create flows
This commit is contained in:
parent
ed5633c6cf
commit
c5e5662810
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user