Simplify first-community onboarding choices (UI only) (#2194)

This commit is contained in:
morgmart
2026-07-20 19:19:52 -07:00
committed by GitHub
parent ee21da90bd
commit 6ab45d75de
6 changed files with 377 additions and 552 deletions
@@ -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 ? (
<div
className="flex items-start gap-3 rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-left"
@@ -426,6 +454,130 @@ export function HostedCommunityOnboarding({ onBack }: { onBack: () => void }) {
</Card>
);
const signInDialog = (
<Dialog
open={modalOpen}
onOpenChange={(open) => {
if (open) return;
if (action === "Signing in…") {
cancelSignInAndGoBack();
return;
}
if (!busy) goBack();
}}
>
<DialogContent
className="buzz-onboarding-neutral-theme max-w-[560px] text-foreground [&_button]:shadow-none"
closeButtonClassName={ONBOARDING_INK_ICON_CLASS}
data-system-color-scheme="light"
overlayClassName="bg-[rgb(var(--buzz-hosted-community-modal-overlay-bg)/0.25)]"
surface="textured"
>
<div className="mx-auto flex w-full max-w-sm flex-col items-center py-2 text-center">
<BuzzMark className="mb-5 h-auto w-9 text-foreground" />
{!auth ? (
<>
<DialogTitle className="text-xl font-medium text-foreground">
Set up your community
</DialogTitle>
<DialogDescription className="mt-2 text-sm leading-6 text-foreground">
Sign in to connect a community you already own or create a new
one. Well open Builderlab in your browser, then bring you back
to Buzz.
</DialogDescription>
{errorBox ? <div className="mt-5 w-full">{errorBox}</div> : null}
{action === "Signing in…" ? (
<Button
className={`mt-6 ${MODAL_PRIMARY_ACTION_CLASS}`}
disabled
>
<LoaderCircle className="h-4 w-4 animate-spin" />
Waiting for your browser
</Button>
) : (
<Button
className={`mt-6 ${MODAL_PRIMARY_ACTION_CLASS}`}
onClick={signIn}
>
Sign in to continue
</Button>
)}
{/* Quiet breadcrumb: Buzz itself is open source; this hosted
relay is the one account-backed piece of the flow. */}
<p className="mt-6 w-full border-t border-foreground/10 pt-4 text-xs leading-5 text-foreground/45">
Buzz is open source. Builderlab hosts the relay for this
account.
</p>
</>
) : !identity ? (
<>
<DialogTitle className="text-xl font-medium text-foreground">
Finish connecting Buzz
</DialogTitle>
<DialogDescription className="mt-2 text-sm leading-6 text-foreground">
Your Builderlab account
{auth.email ? ` (${auth.email})` : ""} is ready. Connect this
devices Buzz identity to finish setup. Your private key stays
on this device.
</DialogDescription>
{errorBox ? <div className="mt-5 w-full">{errorBox}</div> : null}
<Button
className={`mt-6 ${MODAL_PRIMARY_ACTION_CLASS}`}
disabled={busy}
onClick={() => void connectIdentity()}
>
{busy ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : null}
{busy ? action : "Connect and continue"}
</Button>
</>
) : (
<>
<DialogTitle className="text-xl font-medium text-foreground">
This account uses a different Buzz identity
</DialogTitle>
<DialogDescription className="mt-2 text-sm leading-6 text-foreground">
This account is connected to another Buzz identity. Reconnect
this device, or sign out to use a different email.
</DialogDescription>
<p className="mt-4 w-full break-all rounded-xl bg-[rgb(var(--buzz-hosted-community-identity-bg)/0.5)] px-4 py-3 text-left font-mono text-xs text-foreground">
Account: {identity.npub ?? boundPubkey}
<br />
This device: {localNpub ?? localPubkey}
</p>
{errorBox ? <div className="mt-5 w-full">{errorBox}</div> : null}
<div className="mt-6 flex flex-col items-stretch gap-2">
<Button
className={MODAL_PRIMARY_ACTION_CLASS}
disabled={busy}
onClick={() => void switchToDeviceIdentity()}
>
{busy ? action : "Use this device's identity"}
</Button>
<Button
className={MODAL_BACK_ACTION_CLASS}
disabled={busy}
onClick={() => void signOut()}
variant="ghost"
>
Sign in with a different email
</Button>
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
);
// 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 (
<div className="flex min-h-[calc(100dvh-15.625rem)] w-full max-w-[920px] flex-col items-center text-center">
<h1 className="max-w-[620px] text-title font-normal leading-[1.18] tracking-[-0.025em]">
@@ -619,127 +771,7 @@ export function HostedCommunityOnboarding({ onBack }: { onBack: () => void }) {
</OnboardingFooter>
) : null}
<Dialog
open={modalOpen}
onOpenChange={(open) => {
if (open) return;
if (action === "Signing in…") {
cancelSignInAndGoBack();
return;
}
if (!busy) goBack();
}}
>
<DialogContent
className="buzz-onboarding-neutral-theme max-w-[560px] text-foreground [&_button]:shadow-none"
closeButtonClassName={ONBOARDING_INK_ICON_CLASS}
data-system-color-scheme="light"
overlayClassName="bg-[rgb(var(--buzz-hosted-community-modal-overlay-bg)/0.25)]"
surface="textured"
>
<div className="mx-auto flex w-full max-w-sm flex-col items-center py-2 text-center">
<BuzzMark className="mb-5 h-auto w-9 text-foreground" />
{!auth ? (
<>
<DialogTitle className="text-xl font-medium text-foreground">
Set up your community
</DialogTitle>
<DialogDescription className="mt-2 text-sm leading-6 text-foreground">
Sign in to connect a community you already own or create a new
one. Well open Builderlab in your browser, then bring you
back to Buzz.
</DialogDescription>
{errorBox ? (
<div className="mt-5 w-full">{errorBox}</div>
) : null}
{action === "Signing in…" ? (
<Button
className={`mt-6 ${MODAL_PRIMARY_ACTION_CLASS}`}
disabled
>
<LoaderCircle className="h-4 w-4 animate-spin" />
Waiting for your browser
</Button>
) : (
<Button
className={`mt-6 ${MODAL_PRIMARY_ACTION_CLASS}`}
onClick={signIn}
>
Sign in to continue
</Button>
)}
{/* Quiet breadcrumb: Buzz itself is open source; this hosted
relay is the one account-backed piece of the flow. */}
<p className="mt-6 w-full border-t border-foreground/10 pt-4 text-xs leading-5 text-foreground/45">
Buzz is open source. Builderlab hosts the relay for this
account.
</p>
</>
) : !identity ? (
<>
<DialogTitle className="text-xl font-medium text-foreground">
Finish connecting Buzz
</DialogTitle>
<DialogDescription className="mt-2 text-sm leading-6 text-foreground">
Your Builderlab account
{auth.email ? ` (${auth.email})` : ""} is ready. Connect this
devices Buzz identity to finish setup. Your private key stays
on this device.
</DialogDescription>
{errorBox ? (
<div className="mt-5 w-full">{errorBox}</div>
) : null}
<Button
className={`mt-6 ${MODAL_PRIMARY_ACTION_CLASS}`}
disabled={busy}
onClick={() => void connectIdentity()}
>
{busy ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : null}
{busy ? action : "Connect and continue"}
</Button>
</>
) : (
<>
<DialogTitle className="text-xl font-medium text-foreground">
This account uses a different Buzz identity
</DialogTitle>
<DialogDescription className="mt-2 text-sm leading-6 text-foreground">
This account is connected to another Buzz identity. Reconnect
this device, or sign out to use a different email.
</DialogDescription>
<p className="mt-4 w-full break-all rounded-xl bg-[rgb(var(--buzz-hosted-community-identity-bg)/0.5)] px-4 py-3 text-left font-mono text-xs text-foreground">
Account: {identity.npub ?? boundPubkey}
<br />
This device: {localNpub ?? localPubkey}
</p>
{errorBox ? (
<div className="mt-5 w-full">{errorBox}</div>
) : null}
<div className="mt-6 flex flex-col items-stretch gap-2">
<Button
className={MODAL_PRIMARY_ACTION_CLASS}
disabled={busy}
onClick={() => void switchToDeviceIdentity()}
>
{busy ? action : "Use this device's identity"}
</Button>
<Button
className={MODAL_BACK_ACTION_CLASS}
disabled={busy}
onClick={() => void signOut()}
variant="ghost"
>
Sign in with a different email
</Button>
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
{signInDialog}
</div>
);
}
@@ -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 {
ONBOARDING_KEY_ROW_CLASS,
ONBOARDING_KEY_TEXT_CLASS,
} from "@/features/onboarding/ui/NsecMaskedDisplay";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
} from "@/features/onboarding/ui/OnboardingSlideTransition";
import { OnboardingChrome } from "@/features/onboarding/ui/OnboardingChrome";
import {
OnboardingFooter,
OnboardingFooterProvider,
} from "@/features/onboarding/ui/OnboardingFooter";
import { getIdentity } from "@/shared/api/tauriIdentity";
import { pubkeyToNpub } from "@/shared/lib/nostrUtils";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
} from "@/features/onboarding/ui/OnboardingSlideTransition";
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<WelcomeSetupPage>(initialPage);
const [transitionMode, setTransitionMode] =
React.useState<WelcomeTransitionMode>(initialTransitionMode);
const [npub, setNpub] = React.useState("");
const [identityError, setIdentityError] = React.useState<string | null>(null);
const [relayUrl, setRelayUrl] = React.useState("");
const [relayUrlError, setRelayUrlError] = React.useState<string | null>(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;
}
setRelayUrlError(null);
communityOnboarding.start({
source: "first-community",
firstCommunityPage: "join",
relayUrl: normalizedRelayUrl,
});
setPage(nextPage);
},
[communityOnboarding, relayUrl],
[],
);
const handleInviteRedeem = React.useCallback(
(relayWsUrl: string, code: string, policyReceipt?: string) => {
const startConnection = React.useCallback(
(relayUrl: string) => {
communityOnboarding.start({
source: "first-community",
firstCommunityPage: "join",
relayUrl: relayWsUrl,
firstCommunityPage: page === "member" ? "member" : "join",
relayUrl,
});
},
[communityOnboarding, page],
);
const redeemInvite = React.useCallback(
(relayUrl: string, code: string, policyReceipt?: string) => {
communityOnboarding.start({
source: "first-community",
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
</h1>
<p className="mt-3 text-sm leading-6 text-foreground/80">
Choose how youd 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.
</p>
</div>
<div className="flex w-full flex-1 translate-y-16 flex-col items-center justify-center gap-20 py-8">
@@ -155,8 +115,12 @@ export function WelcomeSetup({
className={COMMUNITY_OPTION_CARD_CLASS}
variant="textured"
>
<button onClick={() => showPage("join")} type="button">
Add me to a community
<button
data-testid="community-choice-join"
onClick={() => showPage("join")}
type="button"
>
Join a community
</button>
</Card>
<Card
@@ -164,8 +128,12 @@ export function WelcomeSetup({
className={COMMUNITY_OPTION_CARD_CLASS}
variant="textured"
>
<button onClick={() => showPage("invite")} type="button">
I have an invite link
<button
data-testid="community-choice-create"
onClick={() => setIsHostedSignInOpen(true)}
type="button"
>
Create a community
</button>
</Card>
<Card
@@ -173,10 +141,12 @@ export function WelcomeSetup({
className={COMMUNITY_OPTION_CARD_CLASS}
variant="textured"
>
<button onClick={() => showPage("owned")} type="button">
<span className="max-w-52">
Create or connect to my own community
</span>
<button
data-testid="community-choice-existing"
onClick={() => showPage("existing")}
type="button"
>
I already have a community
</button>
</Card>
</div>
@@ -192,146 +162,53 @@ export function WelcomeSetup({
</Button>
</OnboardingFooter>
</OnboardingSlideTransition>
) : page === "owned" ? (
) : page === "existing" ? (
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
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={`owned-${transitionDirection}`}
>
<HostedCommunityOnboarding onBack={() => showPage("welcome")} />
</OnboardingSlideTransition>
) : page === "join" ? (
<OnboardingSlideTransition
className="flex min-h-[calc(100dvh-15.625rem)] w-full flex-col items-center text-center"
direction={transitionDirection}
transitionKey={`join-${transitionDirection}`}
transitionKey={`existing-${transitionDirection}`}
>
<div className="w-full max-w-[760px]">
<h1
aria-label="Request access to community"
className="text-title font-normal"
>
Request access to a community
<h1 className="text-title font-normal">
Reconnect to your community
</h1>
<p className="mt-3 text-sm leading-6 text-foreground/80">
Tell us your role so we can find the fastest way back in.
</p>
</div>
<div className="flex w-full flex-1 items-center justify-center">
<div className="w-full max-w-[920px] space-y-16">
<section
aria-labelledby="welcome-join-key-step"
className="translate-y-12"
<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="existing-choice-owner"
onClick={() => setIsHostedSignInOpen(true)}
type="button"
>
<h2
className="relative z-10 mb-8 text-sm font-normal"
id="welcome-join-key-step"
>
Step 1: Ask your community host to send you an invite link
or add you using your public key
</h2>
<Card
className={WIDE_TEXTURE_CARD_CLASS}
data-testid="welcome-join-npub-frame"
variant="textured"
>
<div className={WIDE_TEXTURE_CONTENT_CLASS}>
<div className={ONBOARDING_KEY_ROW_CLASS}>
<div className="min-w-0 flex-1">
<code
className={`${ONBOARDING_KEY_TEXT_CLASS} block`}
data-testid="welcome-join-npub"
>
{npub || "Loading…"}
</code>
</div>
<Button
aria-label="Copy npub"
className="h-10 w-10 shrink-0 text-[var(--buzz-onboarding-backup-ink)] hover:bg-transparent hover:text-foreground"
disabled={!npub}
onClick={() => {
void writeTextToClipboard(npub).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
});
}}
size="icon"
type="button"
variant="ghost"
>
{copied ? (
<Check
className="h-6 w-6 text-primary"
aria-hidden="true"
/>
) : (
<Copy className="h-6 w-6" aria-hidden="true" />
)}
</Button>
</div>
</div>
</Card>
{identityError ? (
<p className="mt-4 text-sm text-destructive">
{identityError}
</p>
) : null}
</section>
<form
aria-labelledby="welcome-join-url-step"
className="mx-auto w-full"
id="welcome-join-form"
onSubmit={handleJoin}
I own the community
</button>
</Card>
<Card
asChild
className={COMMUNITY_OPTION_CARD_CLASS}
variant="textured"
>
<button
data-testid="existing-choice-member"
onClick={() => showPage("member")}
type="button"
>
<h2
className="relative z-10 mb-8 text-sm font-normal"
id="welcome-join-url-step"
>
Step 2: Paste in your community URL
</h2>
<Card
className={`${WIDE_TEXTURE_CARD_CLASS} [--buzz-card-textured-min-height:128px]`}
variant="textured"
>
<div
className={WIDE_TEXTURE_CONTENT_CLASS}
style={HORIZONTAL_INPUT_OVERFLOW_FADE}
>
<Input
aria-label="Community URL"
autoCapitalize="none"
autoCorrect="off"
className="h-auto rounded-none border-0 bg-transparent p-0 text-center font-mono !text-4xl text-[color:var(--buzz-onboarding-backup-ink)] shadow-none placeholder:text-[color:var(--buzz-onboarding-backup-ink)] placeholder:opacity-10 focus-visible:ring-0"
data-testid="welcome-join-community-url"
id="welcome-join-community-url"
onChange={(event) => {
setRelayUrl(event.target.value);
setRelayUrlError(null);
}}
placeholder="Enter community URL"
spellCheck={false}
type="url"
value={relayUrl}
/>
</div>
</Card>
{relayUrlError ? (
<p className="mt-3 text-sm text-destructive">
{relayUrlError}
</p>
) : null}
</form>
</div>
Im a member or admin
</button>
</Card>
</div>
<OnboardingFooter>
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
disabled={!relayUrl.trim()}
form="welcome-join-form"
type="submit"
>
<span aria-hidden>Next</span>
<span className="sr-only">Join community</span>
</Button>
<Button
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
data-testid="existing-back"
onClick={() => showPage("welcome")}
type="button"
variant="ghost"
@@ -340,32 +217,57 @@ export function WelcomeSetup({
</Button>
</OnboardingFooter>
</OnboardingSlideTransition>
) : page === "owned" ? (
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
direction={transitionDirection}
transitionKey={`owned-${transitionDirection}`}
>
<HostedCommunityOnboarding onBack={() => showPage("welcome")} />
</OnboardingSlideTransition>
) : (
<OnboardingSlideTransition
className="flex min-h-[calc(100dvh-15.625rem)] w-full flex-col items-center text-center"
direction={transitionDirection}
transitionKey={`invite-${transitionDirection}`}
transitionKey={`${page}-${transitionDirection}`}
>
<div className="w-full max-w-[500px]">
<div className="w-full max-w-[620px]">
<h1 className="text-title font-normal">
Enter your invite link
{page === "member"
? "Reconnect to your community"
: "Join a community"}
</h1>
<p className="mt-3 text-sm leading-6 text-foreground/80">
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."}
</p>
</div>
<div className="flex w-full flex-1 items-center justify-center">
<InviteRedeemForm
error={null}
isRedeeming={false}
onCancel={() => showPage("welcome")}
onRedeem={handleInviteRedeem}
onCancel={() =>
showPage(page === "member" ? "existing" : "welcome")
}
onConnect={startConnection}
onRedeem={redeemInvite}
placeholder="Invite link or community URL"
variant="onboarding-spotlight"
/>
</div>
</OnboardingSlideTransition>
)}
{isHostedSignInOpen && page !== "owned" ? (
<HostedCommunityOnboarding
onBack={() => setIsHostedSignInOpen(false)}
onReady={() => {
setIsHostedSignInOpen(false);
showPage("owned");
}}
stageHidden
/>
) : null}
</div>
</OnboardingFooterProvider>
</div>
@@ -26,7 +26,7 @@ export type CommunityOnboardingStage =
*/
| "entering";
export type FirstCommunityPage = "join" | "owned";
export type FirstCommunityPage = "join" | "member" | "owned";
export type CommunityOnboardingTransaction = {
id: string;
@@ -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
</p>
) : null}
+4
View File
@@ -10491,6 +10491,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.
+55 -192
View File
@@ -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("heading", { name: "Reconnect to your community" }),
).toBeVisible();
await expect(
page.getByRole("button", {
name: "Create or connect to my own 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: "Im 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),
await page.getByRole("button", { name: "Im 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",
);
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 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(() =>