diff --git a/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx b/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx
index cc4c263859..1152cd8fec 100644
--- a/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx
+++ b/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx
@@ -56,7 +56,26 @@ const MODAL_PRIMARY_ACTION_CLASS = `${ONBOARDING_PRIMARY_CTA_CLASS} !text-[rgb(v
const MODAL_BACK_ACTION_CLASS =
"h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15";
-export function HostedCommunityOnboarding({ onBack }: { onBack: () => void }) {
+type HostedCommunityOnboardingProps = {
+ onBack: () => void;
+ /**
+ * Fires once the account is signed in with a linked identity — the parent
+ * uses this to reveal the stage page only after the sign-in modal has
+ * finished driving the flow.
+ */
+ onReady?: () => void;
+ /**
+ * While true, render only the sign-in modal and keep the page scaffolding
+ * hidden, so whatever screen launched the flow stays visible behind it.
+ */
+ stageHidden?: boolean;
+};
+
+export function HostedCommunityOnboarding({
+ onBack,
+ onReady,
+ stageHidden = false,
+}: HostedCommunityOnboardingProps) {
const onboarding = useCommunityOnboarding();
const shouldReduceMotion = useReducedMotion();
const localPubkey = useIdentityQuery().data?.pubkey ?? null;
@@ -310,6 +329,15 @@ export function HostedCommunityOnboarding({ onBack }: { onBack: () => void }) {
const ready = Boolean(auth && identity && !identityMismatch);
const modalOpen = !loading && !ready;
+ // Tell the parent once sign-in completes so it can reveal the stage page.
+ // Guarded so it fires exactly once per mount.
+ const readyNotifiedRef = React.useRef(false);
+ React.useEffect(() => {
+ if (loading || !ready || readyNotifiedRef.current) return;
+ readyNotifiedRef.current = true;
+ onReady?.();
+ }, [loading, onReady, ready]);
+
const errorBox = error ? (
void }) {
);
+ const signInDialog = (
+
{
+ if (open) return;
+ if (action === "Signing in…") {
+ cancelSignInAndGoBack();
+ return;
+ }
+ if (!busy) goBack();
+ }}
+ >
+
+
+
+
+ {!auth ? (
+ <>
+
+ Set up your community
+
+
+ Sign in to connect a community you already own or create a new
+ one. We’ll open Builderlab in your browser, then bring you back
+ to Buzz.
+
+ {errorBox ?
{errorBox}
: null}
+ {action === "Signing in…" ? (
+
+
+ Waiting for your browser…
+
+ ) : (
+
+ Sign in to continue
+
+ )}
+ {/* Quiet breadcrumb: Buzz itself is open source; this hosted
+ relay is the one account-backed piece of the flow. */}
+
+ Buzz is open source. Builderlab hosts the relay for this
+ account.
+
+ >
+ ) : !identity ? (
+ <>
+
+ Finish connecting Buzz
+
+
+ Your Builderlab account
+ {auth.email ? ` (${auth.email})` : ""} is ready. Connect this
+ device’s Buzz identity to finish setup. Your private key stays
+ on this device.
+
+ {errorBox ?
{errorBox}
: null}
+
void connectIdentity()}
+ >
+ {busy ? (
+
+ ) : null}
+ {busy ? action : "Connect and continue"}
+
+ >
+ ) : (
+ <>
+
+ This account uses a different Buzz identity
+
+
+ This account is connected to another Buzz identity. Reconnect
+ this device, or sign out to use a different email.
+
+
+ Account: {identity.npub ?? boundPubkey}
+
+ This device: {localNpub ?? localPubkey}
+
+ {errorBox ?
{errorBox}
: null}
+
+ void switchToDeviceIdentity()}
+ >
+ {busy ? action : "Use this device's identity"}
+
+ void signOut()}
+ variant="ghost"
+ >
+ Sign in with a different email
+
+
+ >
+ )}
+
+
+
+ );
+
+ // While the sign-in modal drives the flow, keep the launching page
+ // visible behind it instead of swapping to this stage prematurely.
+ if (stageHidden) {
+ return signInDialog;
+ }
+
return (
@@ -619,127 +771,7 @@ export function HostedCommunityOnboarding({ onBack }: { onBack: () => void }) {
) : null}
- {
- if (open) return;
- if (action === "Signing in…") {
- cancelSignInAndGoBack();
- return;
- }
- if (!busy) goBack();
- }}
- >
-
-
-
-
- {!auth ? (
- <>
-
- Set up your community
-
-
- Sign in to connect a community you already own or create a new
- one. We’ll open Builderlab in your browser, then bring you
- back to Buzz.
-
- {errorBox ? (
-
{errorBox}
- ) : null}
- {action === "Signing in…" ? (
-
-
- Waiting for your browser…
-
- ) : (
-
- Sign in to continue
-
- )}
- {/* Quiet breadcrumb: Buzz itself is open source; this hosted
- relay is the one account-backed piece of the flow. */}
-
- Buzz is open source. Builderlab hosts the relay for this
- account.
-
- >
- ) : !identity ? (
- <>
-
- Finish connecting Buzz
-
-
- Your Builderlab account
- {auth.email ? ` (${auth.email})` : ""} is ready. Connect this
- device’s Buzz identity to finish setup. Your private key stays
- on this device.
-
- {errorBox ? (
-
{errorBox}
- ) : null}
-
void connectIdentity()}
- >
- {busy ? (
-
- ) : null}
- {busy ? action : "Connect and continue"}
-
- >
- ) : (
- <>
-
- This account uses a different Buzz identity
-
-
- This account is connected to another Buzz identity. Reconnect
- this device, or sign out to use a different email.
-
-
- Account: {identity.npub ?? boundPubkey}
-
- This device: {localNpub ?? localPubkey}
-
- {errorBox ? (
-
{errorBox}
- ) : null}
-
- void switchToDeviceIdentity()}
- >
- {busy ? action : "Use this device's identity"}
-
- void signOut()}
- variant="ghost"
- >
- Sign in with a different email
-
-
- >
- )}
-
-
-
+ {signInDialog}
);
}
diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx
index 33edf14bb2..964e105f8f 100644
--- a/desktop/src/features/communities/ui/WelcomeSetup.tsx
+++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx
@@ -1,36 +1,23 @@
import * as React from "react";
-import { Check, Copy } from "lucide-react";
+import { HostedCommunityOnboarding } from "@/features/communities/ui/HostedCommunityOnboarding";
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
-import { normalizeRelayUrl } from "@/features/communities/relayProbe";
import { InviteRedeemForm } from "@/features/onboarding/ui/InviteRedeemForm";
+import { OnboardingChrome } from "@/features/onboarding/ui/OnboardingChrome";
import {
- ONBOARDING_KEY_ROW_CLASS,
- ONBOARDING_KEY_TEXT_CLASS,
-} from "@/features/onboarding/ui/NsecMaskedDisplay";
+ OnboardingFooter,
+ OnboardingFooterProvider,
+} from "@/features/onboarding/ui/OnboardingFooter";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
} from "@/features/onboarding/ui/OnboardingSlideTransition";
-import {
- OnboardingFooter,
- OnboardingFooterProvider,
-} from "@/features/onboarding/ui/OnboardingFooter";
-import { getIdentity } from "@/shared/api/tauriIdentity";
-import { pubkeyToNpub } from "@/shared/lib/nostrUtils";
+import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
-import { Input } from "@/shared/ui/input";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
-import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
-import {
- ONBOARDING_PRIMARY_CTA_CLASS,
- OnboardingChrome,
-} from "@/features/onboarding/ui/OnboardingChrome";
-import { HostedCommunityOnboarding } from "@/features/communities/ui/HostedCommunityOnboarding";
-import { writeTextToClipboard } from "@/shared/lib/clipboard";
-type WelcomeSetupPage = "welcome" | "join" | "invite" | "owned";
+type WelcomeSetupPage = "welcome" | "existing" | "join" | "member" | "owned";
type WelcomeTransitionMode = "initial" | OnboardingTransitionDirection;
type WelcomeSetupProps = {
@@ -41,15 +28,6 @@ type WelcomeSetupProps = {
const COMMUNITY_OPTION_CARD_CLASS =
"w-full max-w-[320px] items-center px-6 py-4 text-center text-sm font-normal leading-6 text-foreground [--buzz-card-textured-min-height:88px] transition-[filter] duration-150 ease-out hover:brightness-[0.98] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-foreground/35";
-const WIDE_TEXTURE_CARD_CLASS =
- "relative left-1/2 w-[min(calc(100%+10rem),calc(100vw-2rem))] max-w-[1040px] -translate-x-1/2 px-8 py-6 [--buzz-card-textured-min-height:192px]";
-const WIDE_TEXTURE_CONTENT_CLASS = "mx-auto w-full max-w-[840px]";
-const HORIZONTAL_INPUT_OVERFLOW_FADE = {
- WebkitMaskImage:
- "linear-gradient(to right, transparent, black 2rem, black calc(100% - 2rem), transparent)",
- maskImage:
- "linear-gradient(to right, transparent, black 2rem, black calc(100% - 2rem), transparent)",
-};
export function WelcomeSetup({
initialPage = "welcome",
@@ -59,63 +37,45 @@ export function WelcomeSetup({
const [page, setPage] = React.useState
(initialPage);
const [transitionMode, setTransitionMode] =
React.useState(initialTransitionMode);
- const [npub, setNpub] = React.useState("");
- const [identityError, setIdentityError] = React.useState(null);
- const [relayUrl, setRelayUrl] = React.useState("");
- const [relayUrlError, setRelayUrlError] = React.useState(null);
- const [copied, setCopied] = React.useState(false);
+ // While true, the Builderlab sign-in modal floats over the current page —
+ // we only navigate to the hosted stage once sign-in completes, so the page
+ // behind the modal never changes out from under the user.
+ const [isHostedSignInOpen, setIsHostedSignInOpen] = React.useState(false);
const communityOnboarding = useCommunityOnboarding();
const systemColorScheme = useSystemColorScheme();
- React.useEffect(() => {
- if (page !== "join" || npub || identityError) return;
- void getIdentity()
- .then((identity) => setNpub(pubkeyToNpub(identity.pubkey)))
- .catch((error: unknown) =>
- setIdentityError(
- error instanceof Error
- ? error.message
- : "Could not load your public key.",
- ),
+ const showPage = React.useCallback(
+ (nextPage: WelcomeSetupPage, direction?: OnboardingTransitionDirection) => {
+ setTransitionMode(
+ direction ?? (nextPage === "welcome" ? "backward" : "forward"),
);
- }, [identityError, npub, page]);
-
- const showPage = React.useCallback((nextPage: WelcomeSetupPage) => {
- if (nextPage === "join") setIdentityError(null);
- setTransitionMode(nextPage === "welcome" ? "backward" : "forward");
- setPage(nextPage);
- }, []);
-
- const handleJoin = React.useCallback(
- (event: React.FormEvent) => {
- event.preventDefault();
- const normalizedRelayUrl = normalizeRelayUrl(relayUrl);
- if (!normalizedRelayUrl) {
- setRelayUrlError("Enter a valid community URL.");
- return;
- }
+ setPage(nextPage);
+ },
+ [],
+ );
- setRelayUrlError(null);
+ const startConnection = React.useCallback(
+ (relayUrl: string) => {
communityOnboarding.start({
source: "first-community",
- firstCommunityPage: "join",
- relayUrl: normalizedRelayUrl,
+ firstCommunityPage: page === "member" ? "member" : "join",
+ relayUrl,
});
},
- [communityOnboarding, relayUrl],
+ [communityOnboarding, page],
);
- const handleInviteRedeem = React.useCallback(
- (relayWsUrl: string, code: string, policyReceipt?: string) => {
+ const redeemInvite = React.useCallback(
+ (relayUrl: string, code: string, policyReceipt?: string) => {
communityOnboarding.start({
source: "first-community",
- firstCommunityPage: "join",
- relayUrl: relayWsUrl,
+ firstCommunityPage: page === "member" ? "member" : "join",
+ relayUrl,
inviteCode: code,
policyReceipt,
});
},
- [communityOnboarding],
+ [communityOnboarding, page],
);
const transitionDirection =
@@ -145,8 +105,8 @@ export function WelcomeSetup({
Join or create a community
- Choose how you’d like to get started. If you have an invite
- link, you can open it directly to continue setup.
+ Join with an invite, create your own community, or reconnect
+ one you already have.
@@ -155,8 +115,12 @@ export function WelcomeSetup({
className={COMMUNITY_OPTION_CARD_CLASS}
variant="textured"
>
- showPage("join")} type="button">
- Add me to a community
+ showPage("join")}
+ type="button"
+ >
+ Join a community
- showPage("invite")} type="button">
- I have an invite link
+ setIsHostedSignInOpen(true)}
+ type="button"
+ >
+ Create a community
- showPage("owned")} type="button">
-
- Create or connect to my own community
-
+ showPage("existing")}
+ type="button"
+ >
+ I already have a community
@@ -192,146 +162,53 @@ export function WelcomeSetup({
- ) : page === "owned" ? (
+ ) : page === "existing" ? (
- showPage("welcome")} />
-
- ) : page === "join" ? (
-
-
- Request access to a community
+
+ Reconnect to your community
+
+ Tell us your role so we can find the fastest way back in.
+
-
-
-
+
+ setIsHostedSignInOpen(true)}
+ type="button"
>
-
- Step 1: Ask your community host to send you an invite link
- or add you using your public key
-
-
-
-
-
-
- {npub || "Loading…"}
-
-
-
{
- void writeTextToClipboard(npub).then(() => {
- setCopied(true);
- window.setTimeout(() => setCopied(false), 1500);
- });
- }}
- size="icon"
- type="button"
- variant="ghost"
- >
- {copied ? (
-
- ) : (
-
- )}
-
-
-
-
- {identityError ? (
-
- {identityError}
-
- ) : null}
-
-
-
+ I’m a member or admin
+
+
-
- Next
- Join community
-
showPage("welcome")}
type="button"
variant="ghost"
@@ -340,32 +217,57 @@ export function WelcomeSetup({
+ ) : page === "owned" ? (
+
+ showPage("welcome")} />
+
) : (
-
+
- Enter your invite link
+ {page === "member"
+ ? "Reconnect to your community"
+ : "Join a community"}
- If you have an invite link for a community, paste it below to
- continue setup.
+ {page === "member"
+ ? "Enter the community URL or an invite link. Your role will be restored when you connect."
+ : "Enter the invite link or community URL you received."}
showPage("welcome")}
- onRedeem={handleInviteRedeem}
+ onCancel={() =>
+ showPage(page === "member" ? "existing" : "welcome")
+ }
+ onConnect={startConnection}
+ onRedeem={redeemInvite}
+ placeholder="Invite link or community URL"
variant="onboarding-spotlight"
/>
)}
+ {isHostedSignInOpen && page !== "owned" ? (
+
setIsHostedSignInOpen(false)}
+ onReady={() => {
+ setIsHostedSignInOpen(false);
+ showPage("owned");
+ }}
+ stageHidden
+ />
+ ) : null}
diff --git a/desktop/src/features/onboarding/communityOnboarding.tsx b/desktop/src/features/onboarding/communityOnboarding.tsx
index e2215a79f8..76520b83be 100644
--- a/desktop/src/features/onboarding/communityOnboarding.tsx
+++ b/desktop/src/features/onboarding/communityOnboarding.tsx
@@ -26,7 +26,7 @@ export type CommunityOnboardingStage =
*/
| "entering";
-export type FirstCommunityPage = "join" | "owned";
+export type FirstCommunityPage = "join" | "member" | "owned";
export type CommunityOnboardingTransaction = {
id: string;
diff --git a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
index 55026d2b57..de685fe52c 100644
--- a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
+++ b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
@@ -11,6 +11,7 @@ import {
isJoinPolicyDiscoveryCandidate,
type JoinPolicy,
} from "@/shared/api/invites";
+import { normalizeRelayUrl } from "@/features/communities/relayProbe";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
@@ -45,7 +46,9 @@ type InviteRedeemFormProps = {
error: string | null;
isRedeeming: boolean;
onCancel: () => void;
+ onConnect?: (relayWsUrl: string) => void;
onRedeem: (relayWsUrl: string, code: string, policyReceipt?: string) => void;
+ placeholder?: string;
variant?: "default" | "onboarding-spotlight";
};
@@ -54,7 +57,9 @@ export function InviteRedeemForm({
error,
isRedeeming,
onCancel,
+ onConnect,
onRedeem,
+ placeholder,
variant = "default",
}: InviteRedeemFormProps) {
const formId = React.useId();
@@ -77,14 +82,22 @@ export function InviteRedeemForm({
() => parseInviteInput(inviteInput),
[inviteInput],
);
- const isBareCode = parsed !== null && !("relayWsUrl" in parsed);
+ const normalizedRelayUrl = React.useMemo(
+ () => (onConnect && !parsed ? normalizeRelayUrl(inviteInput) : null),
+ [inviteInput, onConnect, parsed],
+ );
+ const parsedInvite = parsed;
+ const isBareCode = parsedInvite !== null && !("relayWsUrl" in parsedInvite);
const needsRelayField = isBareCode && defaultRelayUrl !== undefined;
React.useEffect(() => {
- if (!parsed) return;
+ if (!parsedInvite) return;
const relayWsUrl =
- "relayWsUrl" in parsed ? parsed.relayWsUrl : bareCodeRelayUrl.trim();
+ "relayWsUrl" in parsedInvite &&
+ typeof parsedInvite.relayWsUrl === "string"
+ ? parsedInvite.relayWsUrl
+ : bareCodeRelayUrl.trim();
if (!relayWsUrl || !isJoinPolicyDiscoveryCandidate(relayWsUrl)) return;
let cancelled = false;
@@ -93,7 +106,7 @@ export function InviteRedeemForm({
.then((policy) => {
if (cancelled || !policy) return;
setJoinPolicy(policy);
- setPolicyInvite({ relayWsUrl, code: parsed.code });
+ setPolicyInvite({ relayWsUrl, code: parsedInvite.code });
setAgeConfirmed(false);
setAgreementConfirmed(false);
setPolicyError(null);
@@ -108,12 +121,13 @@ export function InviteRedeemForm({
cancelled = true;
window.clearTimeout(timeoutId);
};
- }, [bareCodeRelayUrl, parsed]);
+ }, [bareCodeRelayUrl, parsedInvite]);
const canSubmit =
- parsed !== null &&
- ("relayWsUrl" in parsed ||
- (isBareCode && bareCodeRelayUrl.trim().length > 0));
+ (parsedInvite !== null &&
+ ("relayWsUrl" in parsedInvite ||
+ (isBareCode && bareCodeRelayUrl.trim().length > 0))) ||
+ normalizedRelayUrl !== null;
const isOnboardingSpotlight = variant === "onboarding-spotlight";
const showInvalidInviteTip =
isOnboardingSpotlight && inviteInput.trim().length > 0 && !canSubmit;
@@ -121,10 +135,16 @@ export function InviteRedeemForm({
const handleSubmit = React.useCallback(
async (event: React.FormEvent) => {
event.preventDefault();
- if (!parsed) return;
+ if (normalizedRelayUrl) {
+ onConnect?.(normalizedRelayUrl);
+ return;
+ }
+ if (!parsedInvite) return;
const relayWsUrl =
- "relayWsUrl" in parsed ? parsed.relayWsUrl : bareCodeRelayUrl.trim();
+ "relayWsUrl" in parsedInvite
+ ? parsedInvite.relayWsUrl
+ : bareCodeRelayUrl.trim();
if (!relayWsUrl) return;
setPolicyError(null);
@@ -132,7 +152,7 @@ export function InviteRedeemForm({
try {
const policy = await getJoinPolicy(relayWsUrl);
if (!policy) {
- onRedeem(relayWsUrl, parsed.code);
+ onRedeem(relayWsUrl, parsedInvite.code);
return;
}
@@ -140,10 +160,10 @@ export function InviteRedeemForm({
!joinPolicy ||
joinPolicy.version !== policy.version ||
policyInvite?.relayWsUrl !== relayWsUrl ||
- policyInvite.code !== parsed.code
+ policyInvite.code !== parsedInvite.code
) {
setJoinPolicy(policy);
- setPolicyInvite({ relayWsUrl, code: parsed.code });
+ setPolicyInvite({ relayWsUrl, code: parsedInvite.code });
setAgeConfirmed(false);
setAgreementConfirmed(false);
return;
@@ -163,11 +183,11 @@ export function InviteRedeemForm({
const receipt = await acceptJoinPolicy(
relayWsUrl,
- parsed.code,
+ parsedInvite.code,
policy.version,
ageConfirmed,
);
- onRedeem(relayWsUrl, parsed.code, receipt);
+ onRedeem(relayWsUrl, parsedInvite.code, receipt);
} catch (policyFetchError) {
setPolicyError(inviteErrorMessage(policyFetchError));
} finally {
@@ -180,7 +200,9 @@ export function InviteRedeemForm({
bareCodeRelayUrl,
joinPolicy,
onRedeem,
- parsed,
+ normalizedRelayUrl,
+ onConnect,
+ parsedInvite,
policyInvite,
],
);
@@ -292,7 +314,9 @@ export function InviteRedeemForm({
disabled={isRedeeming}
id="invite-input"
onChange={handleInviteInputChange}
- placeholder="https://relay.example.com/invite/abc123"
+ placeholder={
+ placeholder ?? "https://relay.example.com/invite/abc123"
+ }
spellCheck={false}
type="text"
value={inviteInput}
@@ -336,7 +360,7 @@ export function InviteRedeemForm({
)}
data-testid="invalid-invite-tip"
>
- Please enter a valid invite link
+ Please enter a valid invite link or community URL
) : null}
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 50985cc6c1..a7b0658588 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -10489,6 +10489,10 @@ export function maybeInstallE2eTauriMocks() {
}
case "agent_metric_archive_default_enabled":
return activeConfig?.mock?.agentMetricArchiveDefaultEnabled ?? false;
+ case "set_prevent_sleep_active":
+ return null;
+ case "plugin:window|is_fullscreen":
+ return false;
case "merge_save_subscription_kinds": {
// Mirrors `merge_owner_p_kinds`: union `kind` into the owner_p row's
// kinds, creating the row if it doesn't exist yet.
diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts
index 9f1ddece86..86e22189e4 100644
--- a/desktop/tests/e2e/onboarding.spec.ts
+++ b/desktop/tests/e2e/onboarding.spec.ts
@@ -1,6 +1,6 @@
import { hexToBytes } from "@noble/hashes/utils.js";
-import { expect, test, type Locator, type Page } from "@playwright/test";
-import { npubEncode, nsecEncode } from "nostr-tools/nip19";
+import { expect, test, type Page } from "@playwright/test";
+import { nsecEncode } from "nostr-tools/nip19";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { installFakeCamera } from "../helpers/fakeCamera";
@@ -101,21 +101,6 @@ async function expectNoHomeSeenEntries(page: Page) {
await expect.poll(async () => readHomeSeenStorageKeys(page)).toEqual([]);
}
-async function expectCommunityBranchFramePosition(page: Page, frame: Locator) {
- const box = await frame.boundingBox();
- const viewport = page.viewportSize();
- if (!box || !viewport) {
- throw new Error("Could not measure community branch frame position");
- }
- const chromeOffset = 106;
- const footerOffset = 144;
- const frameCenterY = box.y + box.height / 2;
- const usableLaneCenterY =
- chromeOffset + (viewport.height - chromeOffset - footerOffset) / 2;
- expect(frameCenterY).toBeGreaterThan(usableLaneCenterY);
- expect(box.y + box.height).toBeLessThan(viewport.height - footerOffset);
-}
-
async function selectFirstEmojiFromPicker(page: Page) {
const picker = page.locator("em-emoji-picker");
await expect(picker).toBeVisible();
@@ -575,7 +560,7 @@ test("first-launch key import continues to machine setup", async ({ page }) => {
await expect(page.getByTestId("app-loading-gate")).toHaveCount(0);
});
-test("first-community choices expose npub and invite input", async ({
+test("first-community choices route join, create, owner, and member intents", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
@@ -590,155 +575,54 @@ test("first-community choices expose npub and invite input", async ({
skipOnboardingSeed: true,
skipCommunitySeed: true,
});
- await page.route(
- "https://default.example.com/api/join-policy",
- async (route) => {
- await route.fulfill({
- status: 200,
- contentType: "application/json",
- body: "{}",
- });
- },
- );
await page.goto("/");
await expect(
- page.getByRole("button", { name: "Add me to a community" }),
+ page.getByRole("button", { name: /Join a community/ }),
).toBeVisible();
await expect(
- page.getByRole("button", { name: "I have an invite link" }),
+ page.getByRole("button", { name: /Create a community/ }),
).toBeVisible();
+ const existing = page.getByRole("button", {
+ name: /I already have a community/,
+ });
+ await expect(existing).toBeVisible();
+ await existing.click();
+ // Owner/member split lives on its own page, mirroring the hub layout.
await expect(
- page.getByRole("button", {
- name: "Create or connect to my own community",
- }),
+ page.getByRole("heading", { name: "Reconnect to your community" }),
).toBeVisible();
- await page
- .getByRole("button", { name: "Create or connect to my own community" })
- .click();
- // Sign-in opens as a modal over the blurred hosted-community preview.
await expect(
- page.getByRole("heading", { name: "Set up your community" }),
+ page.getByRole("button", { name: "I own the community" }),
).toBeVisible();
await expect(
- page.getByRole("button", { name: "Sign in to continue" }),
+ page.getByRole("button", { name: "I’m a member or admin" }),
).toBeVisible();
- // The modal's close (X) dismisses back to the community picker.
- await page.getByRole("button", { name: "Close" }).click();
- await page.getByRole("button", { name: "Add me to a community" }).click();
- await expect(page.getByTestId("welcome-join-npub")).toHaveText(
- npubEncode(BLANK_TYLER_IDENTITY.pubkey),
- );
- const joinKeyFrame = page.getByTestId("welcome-join-npub-frame");
- const joinNpub = page.getByTestId("welcome-join-npub");
- await expect(joinKeyFrame).toBeVisible();
- await expect(joinNpub).toBeVisible();
- await expectCommunityBranchFramePosition(page, joinKeyFrame);
- const joinKeyFrameBox = await joinKeyFrame.boundingBox();
- expect(joinKeyFrameBox?.width).toBeGreaterThan(700);
- await expect(joinKeyFrame).toHaveClass(/buzz-card-textured/);
- const joinKeyFrameStyles = await joinKeyFrame.evaluate((element) => {
- const styles = window.getComputedStyle(element);
- return {
- backgroundColor: styles.backgroundColor,
- borderRadius: styles.borderRadius,
- };
- });
- expect(joinKeyFrameStyles.backgroundColor).toBe("rgba(0, 0, 0, 0)");
- expect(joinKeyFrameStyles.borderRadius).toBe("0px");
- await expect
- .poll(() =>
- joinNpub.evaluate((element) => {
- const styles = window.getComputedStyle(element);
- return {
- color: styles.color,
- fontFamily: styles.fontFamily,
- fontSize: styles.fontSize,
- };
- }),
- )
- .toMatchObject({
- color: "rgb(113, 113, 6)",
- fontSize: "36px",
- });
- expect(
- (
- await joinNpub.evaluate((element) =>
- window.getComputedStyle(element).fontFamily.toLowerCase(),
- )
- ).includes("mono"),
- ).toBe(true);
+ await page.getByRole("button", { name: "I’m a member or admin" }).click();
+ await expect(
+ page.getByRole("heading", { name: "Reconnect to your community" }),
+ ).toBeVisible();
+ const accessInput = page.getByTestId("invite-redeem-input");
+ await expect(accessInput).toHaveAttribute(
+ "placeholder",
+ "Invite link or community URL",
+ );
+ await accessInput.fill("https://default.example.com");
+ await expect(page.getByTestId("invite-redeem-submit")).toBeEnabled();
+ // Back from the member form returns to the role choice, then to the hub.
await page.getByRole("button", { name: "Back" }).click();
+ await expect(
+ page.getByRole("button", { name: "I own the community" }),
+ ).toBeVisible();
+ await page.getByTestId("existing-back").click();
- await page.getByRole("button", { name: "I have an invite link" }).click();
+ await page.getByRole("button", { name: /Join a community/ }).click();
await expect(
- page.getByRole("heading", { name: "Enter your invite link" }),
+ page.getByRole("heading", { name: "Join a community" }),
).toBeVisible();
- const inviteInputFrame = page.getByTestId("invite-redeem-input-frame");
- const inviteInput = page.getByTestId("invite-redeem-input");
- await expect(inviteInputFrame).toBeVisible();
- await expect(inviteInput).toBeVisible();
- await expectCommunityBranchFramePosition(page, inviteInputFrame);
- const inviteInputFrameBox = await inviteInputFrame.boundingBox();
- expect(inviteInputFrameBox?.width).toBeGreaterThan(700);
- await expect(inviteInputFrame).toHaveClass(/buzz-card-textured/);
- const inviteInputFrameStyles = await inviteInputFrame.evaluate((element) => {
- const styles = window.getComputedStyle(element);
- return {
- backgroundColor: styles.backgroundColor,
- borderRadius: styles.borderRadius,
- };
- });
- expect(inviteInputFrameStyles.backgroundColor).toBe("rgba(0, 0, 0, 0)");
- expect(inviteInputFrameStyles.borderRadius).toBe("0px");
- await expect
- .poll(() =>
- inviteInput.evaluate((element) => {
- const styles = window.getComputedStyle(element);
- return {
- color: styles.color,
- fontFamily: styles.fontFamily,
- fontSize: styles.fontSize,
- };
- }),
- )
- .toMatchObject({
- color: "rgb(113, 113, 6)",
- fontSize: "36px",
- });
- expect(
- (
- await inviteInput.evaluate((element) =>
- window.getComputedStyle(element).fontFamily.toLowerCase(),
- )
- ).includes("mono"),
- ).toBe(true);
- await expect(page.getByTestId("invite-redeem-submit")).toHaveText("Next");
- await expect(page.getByTestId("invite-redeem-submit")).toBeDisabled();
- const inviteFrame = page.getByTestId("invite-redeem-input-frame");
- const initialInviteFrameBox = await inviteFrame.boundingBox();
- expect(initialInviteFrameBox).not.toBeNull();
- const invalidInviteTip = page.getByTestId("invalid-invite-tip");
- await expect(invalidInviteTip).toHaveAttribute("aria-hidden", "true");
- await expect(invalidInviteTip).toHaveCSS("opacity", "0");
-
- await inviteInput.fill("awefi");
- await expect(page.getByLabel("Relay URL")).toHaveCount(0);
- await expect(page.getByTestId("invite-redeem-submit")).toBeDisabled();
- await expect(invalidInviteTip).toBeVisible();
- await expect(invalidInviteTip).toHaveAttribute("aria-hidden", "false");
- await expect(invalidInviteTip).toHaveCSS("opacity", "1");
- await expect(invalidInviteTip).toHaveCSS("color", "rgb(113, 113, 6)");
- await expect(invalidInviteTip).toHaveCSS("text-align", "center");
- const invalidInviteFrameBox = await inviteFrame.boundingBox();
- expect(invalidInviteFrameBox?.y).toBe(initialInviteFrameBox?.y);
-
- await inviteInput.fill("https://default.example.com/invite/abc123");
- await expect(page.getByLabel("Relay URL")).toHaveCount(0);
+ await accessInput.fill("https://default.example.com/invite/abc123");
await expect(page.getByTestId("invite-redeem-submit")).toBeEnabled();
- await expect(invalidInviteTip).toHaveAttribute("aria-hidden", "true");
- await expect(invalidInviteTip).toHaveCSS("opacity", "0");
});
test("first-community owner can connect an existing hosted community", async ({
@@ -775,9 +659,7 @@ test("first-community owner can connect an existing hosted community", async ({
);
await page.goto("/");
- await page
- .getByRole("button", { name: "Create or connect to my own community" })
- .click();
+ await page.getByTestId("community-choice-create").click();
await expect(page.getByText("North Star")).toBeVisible();
await page.getByRole("button", { name: "Connect", exact: true }).click();
await expect(
@@ -803,7 +685,7 @@ test("first-community owner can connect an existing hosted community", async ({
).toBeVisible();
await expect(page.getByText("North Star")).toBeVisible();
await expect(
- page.getByRole("heading", { name: "Request access to community" }),
+ page.getByRole("heading", { name: "Join a community" }),
).toHaveCount(0);
await expect
.poll(() =>
@@ -835,9 +717,7 @@ test("first-community owner can create and connect a hosted community", async ({
);
await page.goto("/");
- await page
- .getByRole("button", { name: "Create or connect to my own community" })
- .click();
+ await page.getByTestId("community-choice-create").click();
await page.getByRole("button", { name: "Sign in to continue" }).click();
await expect(
page.getByRole("heading", { name: "Finish connecting Buzz" }),
@@ -913,9 +793,7 @@ test("hosted community address line stays within the card for a long name", asyn
await page.setViewportSize({ width: 800, height: 720 });
await page.goto("/");
- await page
- .getByRole("button", { name: "Create or connect to my own community" })
- .click();
+ await page.getByTestId("community-choice-create").click();
await page.getByRole("button", { name: "Sign in to continue" }).click();
await expect(
page.getByRole("heading", { name: "Finish connecting Buzz" }),
@@ -984,9 +862,7 @@ test("first-community reports a created community without a relay address", asyn
);
await page.goto("/");
- await page
- .getByRole("button", { name: "Create or connect to my own community" })
- .click();
+ await page.getByTestId("community-choice-create").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();
@@ -1017,9 +893,7 @@ test("first-community X cancels a pending sign-in", async ({ page }) => {
);
await page.goto("/");
- await page
- .getByRole("button", { name: "Create or connect to my own community" })
- .click();
+ await page.getByTestId("community-choice-create").click();
await page.getByRole("button", { name: "Sign in to continue" }).click();
await expect(page.getByText("Waiting for your browser…")).toBeVisible();
await expect(
@@ -1027,7 +901,7 @@ 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 or connect to my own community" }),
+ page.getByRole("button", { name: /Create a community/ }),
).toBeVisible();
await expect
.poll(() => page.evaluate(() => window.__BUZZ_E2E_COMMANDS__ ?? []))
@@ -1061,9 +935,7 @@ test("first-community owner can replace a mismatched account identity", async ({
);
await page.goto("/");
- await page
- .getByRole("button", { name: "Create or connect to my own community" })
- .click();
+ await page.getByTestId("community-choice-create").click();
await expect(
page.getByRole("heading", {
name: "This account uses a different Buzz identity",
@@ -1113,9 +985,7 @@ test("first-community explains when the local identity belongs to another accoun
);
await page.goto("/");
- await page
- .getByRole("button", { name: "Create or connect to my own community" })
- .click();
+ await page.getByTestId("community-choice-create").click();
await page
.getByRole("button", { name: "Use this device's identity" })
.click();
@@ -1156,13 +1026,9 @@ test("back clears Builderlab auth before returning to first-community choices",
);
await page.goto("/");
- await page
- .getByRole("button", { name: "Create or connect to my own community" })
- .click();
+ await page.getByTestId("community-choice-create").click();
await page.getByRole("button", { name: "Back" }).click();
- await page
- .getByRole("button", { name: "Create or connect to my own community" })
- .click();
+ await page.getByTestId("community-choice-create").click();
await expect(page.getByRole("button", { name: "Continue" })).toBeVisible();
});
@@ -1187,14 +1053,11 @@ test("first-community shows the scenario cards for localhost", async ({
page.getByRole("button", { name: "Join default community" }),
).toHaveCount(0);
await expect(
- page.getByRole("button", { name: "Add me to a community" }),
- ).toBeVisible();
- await expect(
- page.getByRole("button", { name: "I have an invite link" }),
+ page.getByRole("button", { name: /Join a community/ }),
).toBeVisible();
await expect(
page.getByRole("button", {
- name: "Create or connect to my own community",
+ name: /Create a community/,
}),
).toBeVisible();
@@ -1222,11 +1085,11 @@ test("first-community direct join reaches profile", async ({ page }) => {
});
await page.goto("/");
- await page.getByRole("button", { name: "Add me to a community" }).click();
+ await page.getByRole("button", { name: /Join a community/ }).click();
await page
- .getByTestId("welcome-join-community-url")
+ .getByTestId("invite-redeem-input")
.fill("wss://onboarding.communities.buzz.xyz");
- await page.getByRole("button", { name: "Join community" }).click();
+ await page.getByTestId("invite-redeem-submit").click();
await expect(
page.getByRole("heading", { name: "Build your profile" }),
@@ -1278,16 +1141,16 @@ test("first-community direct join cancel returns to request access", async ({
);
await page.goto("/");
- await page.getByRole("button", { name: "Add me to a community" }).click();
+ await page.getByRole("button", { name: /Join a community/ }).click();
await page
- .getByTestId("welcome-join-community-url")
+ .getByTestId("invite-redeem-input")
.fill("wss://onboarding.communities.buzz.xyz");
- await page.getByRole("button", { name: "Join community" }).click();
+ await page.getByTestId("invite-redeem-submit").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" }),
+ page.getByRole("heading", { name: "Join a community" }),
).toBeVisible();
await expect(page.getByTestId("community-change-overlay")).toHaveCount(0);
await expect(page.getByText("Create an identity key")).toHaveCount(0);
@@ -1658,7 +1521,7 @@ test("connected first-community profile step offers equal-width Next and Back co
await backButton.click();
await expect(
- page.getByRole("heading", { name: "Request access to community" }),
+ page.getByRole("heading", { name: "Join a community" }),
).toBeVisible();
await expect
.poll(() =>