Polish community onboarding flow (#2048)

This commit is contained in:
cynfria
2026-07-17 15:38:26 -07:00
committed by GitHub
parent 51c2d1ca62
commit 2ea71de24c
10 changed files with 750 additions and 282 deletions
+40 -5
View File
@@ -19,7 +19,10 @@ import { useAppOnboardingState } from "@/features/onboarding/hooks";
import { useMachineOnboardingState } from "@/features/onboarding/machineOnboarding";
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
import { CommunityOnboardingFlow } from "@/features/onboarding/ui/CommunityOnboardingFlow";
import { MachineOnboardingFlow } from "@/features/onboarding/ui/MachineOnboardingFlow";
import {
MachineOnboardingFlow,
type MachineOnboardingPage,
} from "@/features/onboarding/ui/MachineOnboardingFlow";
import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow";
import { PendingInviteGate } from "@/features/onboarding/ui/PendingInviteGate";
import { KeyringLockedScreen } from "@/features/onboarding/ui/KeyringLockedScreen";
@@ -249,7 +252,13 @@ function AppReady({
);
}
function CommunityApp({ sharedIdentity }: { sharedIdentity: boolean }) {
function CommunityApp({
onBackToMachineConfig,
sharedIdentity,
}: {
onBackToMachineConfig: () => void;
sharedIdentity: boolean;
}) {
const {
activeCommunity,
reinitKey,
@@ -325,7 +334,12 @@ function CommunityApp({ sharedIdentity }: { sharedIdentity: boolean }) {
// Show welcome setup for first-run users with no communities
if (community.needsSetup) {
return <WelcomeSetup defaultRelayUrl={community.defaultRelayUrl} />;
return (
<WelcomeSetup
defaultRelayUrl={community.defaultRelayUrl}
onBack={onBackToMachineConfig}
/>
);
}
// Surface apply failures so the user can retry or change community.
@@ -391,6 +405,21 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) {
hasConfiguredCommunity: activeCommunity !== null,
isSharedIdentity: sharedIdentity,
});
const [machineInitialPage, setMachineInitialPage] =
useState<MachineOnboardingPage>();
const reopenMachineConfig = useCallback(() => {
setMachineInitialPage("config");
machine.reopen();
}, [machine.reopen]);
const completeMachineOnboarding = useCallback(
(pubkey?: string) => {
setMachineInitialPage(undefined);
machine.complete(pubkey);
},
[machine.complete],
);
// Deep links are captured here — above the machine-onboarding gate — not in
// CommunityApp. The Rust side queues them; draining into the persisted
@@ -413,7 +442,12 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) {
if (machine.stage === "relaunch-required") return <RelaunchRequiredScreen />;
if (machine.stage === "blocking") return <AppLoadingGate />;
if (machine.stage === "ready") {
return <CommunityApp sharedIdentity={sharedIdentity} />;
return (
<CommunityApp
onBackToMachineConfig={reopenMachineConfig}
sharedIdentity={sharedIdentity}
/>
);
}
// A community deep link that arrived before machine onboarding finished is
@@ -428,8 +462,9 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) {
return (
<>
<MachineOnboardingFlow
complete={machine.complete}
complete={completeMachineOnboarding}
identityLost={machine.identityLost}
initialPage={machineInitialPage}
queryClient={machine.queryClient}
/>
{shouldAcknowledgeDeepLink ? <PendingInviteGate /> : null}
@@ -1,13 +1,22 @@
import * as React from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { Check, Copy } from "lucide-react";
import { Check, Copy, Info } from "lucide-react";
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
import { InviteRedeemForm } from "@/features/onboarding/ui/InviteRedeemForm";
import {
ONBOARDING_KEY_FRAME_CLASS,
ONBOARDING_KEY_ROW_CLASS,
ONBOARDING_KEY_TEXT_CLASS,
} from "@/features/onboarding/ui/NsecMaskedDisplay";
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 { Button } from "@/shared/ui/button";
@@ -21,6 +30,7 @@ type WelcomeTransitionMode = "initial" | OnboardingTransitionDirection;
type WelcomeSetupProps = {
defaultRelayUrl: string;
initialTransitionMode?: WelcomeTransitionMode;
onBack: () => void;
};
const CREATE_COMMUNITY_URL = "https://buzz.xyz";
@@ -28,6 +38,8 @@ const LOCAL_DEV_RELAY_URLS = new Set([
"ws://localhost:3000",
"ws://127.0.0.1:3000",
]);
const COMMUNITY_OPTION_CARD_CLASS =
"flex min-h-24 w-full max-w-[352px] items-center justify-center rounded-xl bg-white/75 px-6 py-4 text-center text-sm font-normal leading-6 text-foreground transition-colors duration-150 ease-out hover:bg-white/85 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-foreground/35";
function isLocalDevRelayUrl(relayUrl: string) {
return LOCAL_DEV_RELAY_URLS.has(relayUrl.trim().replace(/\/$/, ""));
@@ -36,6 +48,7 @@ function isLocalDevRelayUrl(relayUrl: string) {
export function WelcomeSetup({
defaultRelayUrl,
initialTransitionMode = "initial",
onBack,
}: WelcomeSetupProps) {
const [page, setPage] = React.useState<WelcomeSetupPage>("welcome");
const [transitionMode, setTransitionMode] =
@@ -65,13 +78,6 @@ export function WelcomeSetup({
setPage(nextPage);
}, []);
const handleDefaultCommunity = React.useCallback(() => {
communityOnboarding.start({
source: "first-community",
relayUrl: defaultRelayUrl,
});
}, [communityOnboarding, defaultRelayUrl]);
const handleInviteRedeem = React.useCallback(
(relayWsUrl: string, code: string, policyReceipt?: string) => {
communityOnboarding.start({
@@ -91,146 +97,178 @@ export function WelcomeSetup({
return (
<div
className="buzz-onboarding-neutral-theme buzz-startup-shell flex items-center justify-center bg-background px-4 py-8 text-foreground"
className="buzz-onboarding-neutral-theme buzz-startup-shell flex min-h-dvh items-start justify-center overflow-y-auto bg-background px-4 pb-36 pt-[106px] text-foreground"
data-system-color-scheme={systemColorScheme}
>
<StartupWindowDragRegion />
<OnboardingChrome current={5} />
<div className="relative flex w-full max-w-[760px] flex-col items-center text-center">
{page === "welcome" ? (
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
direction={transitionDirection}
effect={welcomeEffect}
transitionKey={`welcome-${welcomeEffect}-${transitionDirection}`}
>
<div className="w-full max-w-[440px]">
<h1 className="text-title font-normal">
Join or create a community
</h1>
<p className="mt-3 text-sm leading-6 text-foreground/80">
Request access to an existing community or create a new one.
</p>
</div>
<div className="mt-14 flex w-full flex-wrap items-stretch justify-center gap-4">
{isLocalDevRelayUrl(defaultRelayUrl) ? null : (
<OnboardingFooterProvider>
<div className="relative flex w-full max-w-4xl flex-col items-center text-center">
{page === "welcome" ? (
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
direction={transitionDirection}
effect={welcomeEffect}
transitionKey={`welcome-${welcomeEffect}-${transitionDirection}`}
>
<div className="w-full max-w-[760px]">
<h1 className="text-title font-normal">
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.
</p>
</div>
<div className="mt-28 flex w-full flex-col items-center gap-6">
<button
className="flex min-h-32 w-44 items-center justify-center rounded-2xl bg-white/85 p-4 text-sm font-medium text-foreground shadow-[0_0_45px_18px_rgba(255,255,255,0.65)] transition-shadow hover:shadow-[0_0_55px_25px_rgba(255,255,255,0.85)]"
onClick={handleDefaultCommunity}
className={COMMUNITY_OPTION_CARD_CLASS}
onClick={() => showPage("join")}
type="button"
>
Join default community
Add me to a community
</button>
<button
className={COMMUNITY_OPTION_CARD_CLASS}
onClick={() => showPage("invite")}
type="button"
>
I have an invite link
</button>
<button
className={COMMUNITY_OPTION_CARD_CLASS}
onClick={() => void openUrl(CREATE_COMMUNITY_URL)}
type="button"
>
<span className="max-w-44">I want to create a community</span>
</button>
)}
<button
className="flex min-h-32 w-44 items-center justify-center rounded-2xl bg-white/85 p-4 text-sm font-medium text-foreground shadow-[0_0_45px_18px_rgba(255,255,255,0.65)] transition-shadow hover:shadow-[0_0_55px_25px_rgba(255,255,255,0.85)]"
onClick={() => showPage("join")}
type="button"
>
Join a community
</button>
<button
className="flex min-h-32 w-44 items-center justify-center rounded-2xl bg-white/85 p-4 text-sm font-medium text-foreground shadow-[0_0_45px_18px_rgba(255,255,255,0.65)] transition-shadow hover:shadow-[0_0_55px_25px_rgba(255,255,255,0.85)]"
onClick={() => showPage("invite")}
type="button"
>
I have an invite link
</button>
<button
className="flex min-h-32 w-44 items-center justify-center rounded-2xl bg-white/85 p-4 text-sm font-medium text-foreground shadow-[0_0_45px_18px_rgba(255,255,255,0.65)] transition-shadow hover:shadow-[0_0_55px_25px_rgba(255,255,255,0.85)]"
onClick={() => void openUrl(CREATE_COMMUNITY_URL)}
type="button"
>
Create a community
</button>
</div>
</OnboardingSlideTransition>
) : page === "join" ? (
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
direction={transitionDirection}
transitionKey={`join-${transitionDirection}`}
>
<div className="w-full max-w-[440px]">
<h1 className="text-title font-normal">Join a community</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Send your public key to a community owner. Keep Buzz open; once
they add you, their invite link will continue setup here.
</p>
<div className="mt-8 space-y-2 text-left">
<p className="text-xs font-medium text-muted-foreground">
Your public key (npub)
</p>
<div className="flex items-center gap-2">
<code
className="min-w-0 flex-1 break-all rounded-xl border border-border/70 bg-muted/30 px-3 py-2.5 font-mono text-xs"
data-testid="welcome-join-npub"
>
{npub || "Loading…"}
</code>
<Button
aria-label="Copy npub"
disabled={!npub}
onClick={() => {
void navigator.clipboard.writeText(npub).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
});
}}
size="icon"
type="button"
variant="outline"
>
{copied ? <Check /> : <Copy />}
</Button>
</div>
{identityError ? (
<p className="text-sm text-destructive">{identityError}</p>
) : (
<p className="text-xs leading-5 text-muted-foreground">
This is safe to share. It does not reveal your private key.
</p>
)}
</div>
<Button
className="mt-8 h-10 w-full"
onClick={() => showPage("welcome")}
type="button"
variant="ghost"
>
Back
</Button>
</div>
</OnboardingSlideTransition>
) : (
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
direction={transitionDirection}
transitionKey={`invite-${transitionDirection}`}
>
<div className="w-full max-w-[440px]">
<h1 className="text-title font-normal">I have an invite link</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Keep this page open, then click the invite link you received.
Buzz will continue automatically. You can also paste it below.
</p>
</div>
<div className="mt-8 w-full">
<InviteRedeemForm
defaultRelayUrl={
isLocalDevRelayUrl(defaultRelayUrl)
? undefined
: defaultRelayUrl
}
error={null}
isRedeeming={false}
onCancel={() => showPage("welcome")}
onRedeem={handleInviteRedeem}
/>
</div>
</OnboardingSlideTransition>
)}
</div>
<OnboardingFooter>
<Button
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
data-testid="welcome-setup-back"
onClick={onBack}
type="button"
variant="ghost"
>
Back
</Button>
</OnboardingFooter>
</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}`}
>
<div className="w-full max-w-[500px]">
<h1 className="text-title font-normal">
Request access to community
</h1>
<p className="mt-3 text-sm leading-6 text-foreground/80">
Ask the community host to send you an invite link or add you
directly using your public key.
</p>
</div>
<div className="flex w-full flex-1 items-center justify-center pb-4 pt-12">
<div className="w-full max-w-4xl">
<div
className={ONBOARDING_KEY_FRAME_CLASS}
data-testid="welcome-join-npub-frame"
>
<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-muted-foreground hover:text-foreground"
disabled={!npub}
onClick={() => {
void navigator.clipboard.writeText(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>
{identityError ? (
<p className="mt-4 text-sm text-destructive">
{identityError}
</p>
) : (
<p className="mx-auto mt-6 flex max-w-[440px] items-start justify-center gap-1.5 text-center text-xs leading-5 text-[var(--buzz-onboarding-backup-ink)]">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
This is safe to share. It does not reveal your private
key.
</span>
</p>
)}
</div>
</div>
<OnboardingFooter>
<Button
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
onClick={() => showPage("welcome")}
type="button"
variant="ghost"
>
Back
</Button>
</OnboardingFooter>
</OnboardingSlideTransition>
) : (
<OnboardingSlideTransition
className="flex min-h-[calc(100dvh-15.625rem)] w-full flex-col items-center text-center"
direction={transitionDirection}
transitionKey={`invite-${transitionDirection}`}
>
<div className="w-full max-w-[500px]">
<h1 className="text-title font-normal">
Enter your invite link
</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.
</p>
</div>
<div className="flex w-full flex-1 items-center justify-center pb-4 pt-12">
<InviteRedeemForm
defaultRelayUrl={
isLocalDevRelayUrl(defaultRelayUrl)
? undefined
: defaultRelayUrl
}
error={null}
isRedeeming={false}
onCancel={() => showPage("welcome")}
onRedeem={handleInviteRedeem}
variant="onboarding-spotlight"
/>
</div>
</OnboardingSlideTransition>
)}
</div>
</OnboardingFooterProvider>
</div>
);
}
@@ -28,6 +28,13 @@ export function readMachineOnboardingCompletion(pubkey: string | null) {
);
}
function clearMachineOnboardingCompletion(pubkey: string | null) {
if (typeof window === "undefined" || !pubkey) return;
window.localStorage.removeItem(
completionKey(MACHINE_ONBOARDING_COMPLETION_STORAGE_KEY, pubkey),
);
}
function forceMachineOnboarding() {
if (!import.meta.env.DEV || typeof window === "undefined") return false;
return (
@@ -150,6 +157,12 @@ export function useMachineOnboardingState({
[currentPubkey],
);
const reopen = React.useCallback(() => {
clearMachineOnboardingCompletion(currentPubkey);
setCompletedPubkey((pubkey) => (pubkey === currentPubkey ? null : pubkey));
setEvaluatedPubkey(currentPubkey);
}, [currentPubkey]);
const relaunchRequired =
((bootedLost && !identityLost) || (bootedLocked && !identityLocked)) &&
identityQuery.status === "success";
@@ -189,6 +202,7 @@ export function useMachineOnboardingState({
currentPubkey,
identityLost,
queryClient,
reopen,
stage,
};
}
@@ -10,7 +10,10 @@ import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
} from "./OnboardingSlideTransition";
import { NsecMaskedDisplay } from "./NsecMaskedDisplay";
import {
NsecMaskedDisplay,
ONBOARDING_KEY_FRAME_CLASS,
} from "./NsecMaskedDisplay";
/**
* Pure helper so the disabled logic can be unit-tested without a DOM.
@@ -123,7 +126,7 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
</div>
) : nsec ? (
// Translucent white card frames the key with equal padding.
<div className="w-full min-w-0 rounded-xl bg-white/50 px-8 py-6">
<div className={ONBOARDING_KEY_FRAME_CLASS}>
<NsecMaskedDisplay nsec={nsec} variant="bare" />
</div>
) : (
@@ -19,13 +19,17 @@ import { listPersonas } from "@/shared/api/tauriPersonas";
import type { AgentPersona } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import {
ONBOARDING_PRIMARY_CTA_CLASS,
OnboardingChrome,
} from "./OnboardingChrome";
import { OnboardingFooter, OnboardingFooterProvider } from "./OnboardingFooter";
import {
ONBOARDING_KEY_FRAME_CLASS,
ONBOARDING_KEY_ROW_CLASS,
ONBOARDING_KEY_TEXT_CLASS,
} from "./NsecMaskedDisplay";
const STARTER_PERSONA_ANIMATIONS: Record<string, string> = {
Fizz: "/onboarding/starter-team/fizz.png",
@@ -55,7 +59,7 @@ function AvatarCircle({
return (
<button
aria-label={hasAvatar ? "Change your avatar" : "Add an avatar"}
className="group mx-auto block rounded-full"
className="group block shrink-0 rounded-full"
data-testid="community-avatar-open"
onClick={onClick}
type="button"
@@ -74,7 +78,7 @@ function AvatarCircle({
label={previewName}
/>
) : (
<span className="flex h-28 w-28 items-center justify-center rounded-full bg-white/60 text-foreground/60 shadow-[0_0_35px_12px_rgba(255,255,255,0.5)] transition-colors group-hover:bg-white/80">
<span className="flex h-28 w-28 items-center justify-center rounded-full text-[var(--buzz-onboarding-backup-ink)] transition-colors group-hover:bg-white/25">
<Plus className="h-8 w-8" aria-hidden="true" />
</span>
)}
@@ -97,6 +101,7 @@ export function CommunityOnboardingFlow({
[],
);
const [isPending, setIsPending] = React.useState(false);
const nameInputRef = React.useRef<HTMLInputElement | null>(null);
React.useEffect(() => {
if (transaction?.stage !== "team-intro") return;
@@ -149,6 +154,16 @@ export function CommunityOnboardingFlow({
}
}, [finish, isPending, queryClient, relayUrl, update]);
const isProfileStage = transaction?.stage === "profile";
const isTeamStage =
transaction?.stage === "team-intro" || transaction?.stage === "finalizing";
React.useLayoutEffect(() => {
if (isProfileStage && !isAvatarEditorOpen) {
nameInputRef.current?.focus();
}
}, [isAvatarEditorOpen, isProfileStage]);
if (!transaction) return null;
const saveProfile = async () => {
@@ -167,13 +182,14 @@ export function CommunityOnboardingFlow({
}
};
const isProfileStage = transaction.stage === "profile";
const isTeamStage =
transaction.stage === "team-intro" || transaction.stage === "finalizing";
return (
<div
className="buzz-onboarding-neutral-theme buzz-startup-shell flex max-h-dvh items-start justify-center overflow-y-auto px-4 pb-28 pt-[106px] text-foreground"
className={cn(
"buzz-onboarding-neutral-theme buzz-startup-shell flex h-dvh justify-center overflow-y-auto px-4 text-foreground",
isProfileStage || isTeamStage
? "items-start pb-36 pt-[106px]"
: "items-stretch",
)}
data-testid="community-onboarding-flow"
>
<StartupWindowDragRegion />
@@ -183,9 +199,17 @@ export function CommunityOnboardingFlow({
<OnboardingFooterProvider>
<div
className={cn(
"relative my-auto w-full text-center",
isTeamStage ? "max-w-[760px]" : "max-w-[560px]",
"relative flex w-full flex-col justify-center text-center",
isProfileStage || isTeamStage
? "min-h-[calc(100dvh-15.625rem)]"
: "min-h-dvh py-8",
isProfileStage
? "max-w-4xl"
: isTeamStage
? "max-w-[760px]"
: "max-w-[560px]",
)}
data-testid="community-onboarding-body"
>
{transaction.stage === "claiming" ||
transaction.stage === "connecting" ? (
@@ -217,7 +241,10 @@ export function CommunityOnboardingFlow({
</>
) : isProfileStage ? (
isAvatarEditorOpen ? (
<div className="relative rounded-3xl bg-white/85 px-6 py-8 shadow-[0_0_80px_50px_rgba(255,255,255,0.85)]">
<div
className={cn("relative", ONBOARDING_KEY_FRAME_CLASS)}
data-testid="community-avatar-editor-key-frame"
>
<Button
aria-label="Close avatar editor"
className="absolute -right-3 -top-3 h-9 w-9 rounded-full"
@@ -242,49 +269,80 @@ export function CommunityOnboardingFlow({
</div>
) : (
<>
<h1 className="text-title font-normal">Build your profile</h1>
<p className="mx-auto mt-3 max-w-[380px] text-sm leading-6 text-foreground/80">
Add a name and avatar. Theyll show up on your messages,
reactions, and agent handoffs.
</p>
<div className="mt-12">
<AvatarCircle
avatarUrl={avatarUrl}
onClick={() => setIsAvatarEditorOpen(true)}
previewName={displayName.trim() || "Your profile"}
/>
</div>
<div className="mx-auto mt-8 w-full max-w-[300px] text-left">
<label
className="text-sm font-medium"
htmlFor="community-display-name"
>
Your name
</label>
<Input
aria-label="Community display name"
autoFocus
className="mt-1.5 h-10 rounded-full bg-white/90 px-4"
id="community-display-name"
onChange={(event) => setDisplayName(event.target.value)}
placeholder="First and last name"
value={displayName}
/>
</div>
{transaction.error ? (
<p className="mt-4 text-sm text-destructive">
{transaction.error}
<div data-testid="community-profile-main">
<h1 className="text-title font-normal">Build your profile</h1>
<p className="mx-auto mt-3 max-w-[380px] text-sm leading-6 text-foreground/80">
Add a name and avatar. Theyll show up on your messages,
reactions, and agent handoffs.
</p>
) : null}
<div className="mt-10 w-full max-w-4xl">
<div
className={ONBOARDING_KEY_FRAME_CLASS}
data-testid="community-profile-key-frame"
>
<div className={ONBOARDING_KEY_ROW_CLASS}>
<AvatarCircle
avatarUrl={avatarUrl}
onClick={() => setIsAvatarEditorOpen(true)}
previewName={displayName.trim() || "Your profile"}
/>
<label
className="min-w-0 flex-1"
htmlFor="community-display-name"
>
<span className="sr-only">Your name</span>
<input
aria-label="Community display name"
autoCapitalize="words"
autoComplete="name"
autoCorrect="off"
className={cn(
ONBOARDING_KEY_TEXT_CLASS,
"border-0 bg-transparent p-0 shadow-none outline-none placeholder:text-[var(--buzz-onboarding-backup-ink)] placeholder:opacity-40 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
)}
data-testid="community-profile-name-key"
disabled={isPending || isUploadingAvatar}
id="community-display-name"
onChange={(event) =>
setDisplayName(event.target.value)
}
placeholder="First and last name"
ref={nameInputRef}
spellCheck={false}
type="text"
value={displayName}
/>
</label>
</div>
</div>
</div>
{transaction.error ? (
<p className="mt-4 text-sm text-destructive">
{transaction.error}
</p>
) : null}
</div>
<OnboardingFooter>
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="community-profile-next"
disabled={
!displayName.trim() || isPending || isUploadingAvatar
}
onClick={() => void saveProfile()}
type="button"
>
Continue
Next
</Button>
<Button
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
data-testid="community-profile-back"
disabled={isPending || isUploadingAvatar}
onClick={clear}
type="button"
variant="ghost"
>
Back
</Button>
</OnboardingFooter>
</>
@@ -11,10 +11,18 @@ import {
isJoinPolicyDiscoveryCandidate,
type JoinPolicy,
} from "@/shared/api/invites";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Spinner } from "@/shared/ui/spinner";
import { JoinPolicyNotice } from "./JoinPolicyNotice";
import {
ONBOARDING_KEY_FRAME_CLASS,
ONBOARDING_KEY_ROW_CLASS,
ONBOARDING_KEY_TEXT_CLASS,
} from "./NsecMaskedDisplay";
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
import { OnboardingFooter } from "./OnboardingFooter";
const POLICY_DISCOVERY_DELAY_MS = 250;
const POLICY_REVEAL_EASE = [0.23, 1, 0.32, 1] as const;
@@ -31,6 +39,7 @@ type InviteRedeemFormProps = {
isRedeeming: boolean;
onCancel: () => void;
onRedeem: (relayWsUrl: string, code: string, policyReceipt?: string) => void;
variant?: "default" | "onboarding-spotlight";
};
export function InviteRedeemForm({
@@ -39,7 +48,9 @@ export function InviteRedeemForm({
isRedeeming,
onCancel,
onRedeem,
variant = "default",
}: InviteRedeemFormProps) {
const formId = React.useId();
const [inviteInput, setInviteInput] = React.useState("");
const [bareCodeRelayUrl, setBareCodeRelayUrl] = React.useState(
defaultRelayUrl ?? "",
@@ -96,6 +107,7 @@ export function InviteRedeemForm({
parsed !== null &&
("relayWsUrl" in parsed ||
(isBareCode && bareCodeRelayUrl.trim().length > 0));
const isOnboardingSpotlight = variant === "onboarding-spotlight";
const handleSubmit = React.useCallback(
async (event: React.FormEvent) => {
@@ -164,40 +176,147 @@ export function InviteRedeemForm({
],
);
const handleInviteInputChange = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
setInviteInput(event.target.value);
setJoinPolicy(null);
setPolicyInvite(null);
setAgeConfirmed(false);
setAgreementConfirmed(false);
setPolicyError(null);
};
const handleRelayInputChange = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
setBareCodeRelayUrl(event.target.value);
setJoinPolicy(null);
setPolicyInvite(null);
setAgeConfirmed(false);
setAgreementConfirmed(false);
setPolicyError(null);
};
const submitButton = (
<Button
className={
isOnboardingSpotlight ? ONBOARDING_PRIMARY_CTA_CLASS : "h-10 w-full"
}
data-testid="invite-redeem-submit"
disabled={
!canSubmit ||
isRedeeming ||
isLoadingPolicy ||
Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed) ||
Boolean(
joinPolicy &&
(joinPolicy.termsMarkdown || joinPolicy.privacyMarkdown) &&
!agreementConfirmed,
)
}
form={formId}
type="submit"
>
{isRedeeming || isLoadingPolicy ? (
<Spinner
aria-label={isRedeeming ? "Redeeming invite" : "Loading policy"}
className="h-4 w-4 border-2"
/>
) : isOnboardingSpotlight ? (
"Next"
) : joinPolicy ? (
"Accept and redeem invite"
) : (
"Redeem invite"
)}
</Button>
);
const cancelButton = (
<Button
className={
isOnboardingSpotlight
? "h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
: "h-10 w-full text-muted-foreground hover:text-accent-foreground"
}
disabled={isRedeeming}
onClick={onCancel}
type="button"
variant="ghost"
>
{isOnboardingSpotlight ? "Back" : "Cancel"}
</Button>
);
return (
<form className="flex w-full flex-col gap-3" onSubmit={handleSubmit}>
<div className="space-y-1.5 text-left">
<form
className={cn(
"flex w-full flex-col",
isOnboardingSpotlight ? "items-center gap-4" : "gap-3",
)}
id={formId}
onSubmit={handleSubmit}
>
{isOnboardingSpotlight ? (
<label
className="text-sm font-medium text-foreground"
className={cn("w-full max-w-4xl", ONBOARDING_KEY_FRAME_CLASS)}
data-testid="invite-redeem-input-frame"
htmlFor="invite-input"
>
Invite link or code
<span className="sr-only">Invite link or code</span>
<span className={ONBOARDING_KEY_ROW_CLASS}>
<input
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
className={cn(
ONBOARDING_KEY_TEXT_CLASS,
"block border-0 bg-transparent p-0 text-center shadow-none outline-none placeholder:text-[var(--buzz-onboarding-backup-ink)] placeholder:opacity-40 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
)}
data-testid="invite-redeem-input"
disabled={isRedeeming}
id="invite-input"
onChange={handleInviteInputChange}
placeholder="https://relay.example.com/invite/abc123"
spellCheck={false}
type="text"
value={inviteInput}
/>
</span>
</label>
<Input
autoComplete="off"
autoCorrect="off"
autoFocus
className="h-10 bg-background"
data-testid="invite-redeem-input"
disabled={isRedeeming}
id="invite-input"
onChange={(event) => {
setInviteInput(event.target.value);
setJoinPolicy(null);
setPolicyInvite(null);
setAgeConfirmed(false);
setAgreementConfirmed(false);
setPolicyError(null);
}}
placeholder="https://relay.example.com/invite/abc123 or paste a code"
spellCheck={false}
type="text"
value={inviteInput}
/>
</div>
) : (
<div className="space-y-1.5 text-left">
<label
className="text-sm font-medium text-foreground"
htmlFor="invite-input"
>
Invite link or code
</label>
<Input
autoComplete="off"
autoCorrect="off"
autoFocus
className="h-10 bg-background"
data-testid="invite-redeem-input"
disabled={isRedeeming}
id="invite-input"
onChange={handleInviteInputChange}
placeholder="https://relay.example.com/invite/abc123 or paste a code"
spellCheck={false}
type="text"
value={inviteInput}
/>
</div>
)}
{needsRelayField ? (
<div className="space-y-1.5 text-left">
<div
className={cn(
"space-y-1.5 text-left",
isOnboardingSpotlight && "w-full max-w-[500px]",
)}
>
<label
className="text-sm font-medium text-foreground"
htmlFor="invite-relay-url"
@@ -208,14 +327,7 @@ export function InviteRedeemForm({
className="h-10 bg-background"
disabled={isRedeeming}
id="invite-relay-url"
onChange={(event) => {
setBareCodeRelayUrl(event.target.value);
setJoinPolicy(null);
setPolicyInvite(null);
setAgeConfirmed(false);
setAgreementConfirmed(false);
setPolicyError(null);
}}
onChange={handleRelayInputChange}
placeholder="wss://relay.example.com"
type="text"
value={bareCodeRelayUrl}
@@ -286,43 +398,17 @@ export function InviteRedeemForm({
) : null}
</AnimatePresence>
<Button
className="h-10 w-full"
data-testid="invite-redeem-submit"
disabled={
!canSubmit ||
isRedeeming ||
isLoadingPolicy ||
Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed) ||
Boolean(
joinPolicy &&
(joinPolicy.termsMarkdown || joinPolicy.privacyMarkdown) &&
!agreementConfirmed,
)
}
type="submit"
>
{isRedeeming || isLoadingPolicy ? (
<Spinner
aria-label={isRedeeming ? "Redeeming invite" : "Loading policy"}
className="h-4 w-4 border-2"
/>
) : joinPolicy ? (
"Accept and redeem invite"
) : (
"Redeem invite"
)}
</Button>
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground"
disabled={isRedeeming}
onClick={onCancel}
type="button"
variant="ghost"
>
Cancel
</Button>
{isOnboardingSpotlight ? (
<OnboardingFooter>
{submitButton}
{cancelButton}
</OnboardingFooter>
) : (
<>
{submitButton}
{cancelButton}
</>
)}
</form>
);
}
@@ -24,19 +24,26 @@ import { OnboardingFooterProvider } from "./OnboardingFooter";
import { OnboardingSlideTransition } from "./OnboardingSlideTransition";
import { SetupStep } from "./SetupStep";
type MachinePage = "identity" | "key-import" | "backup" | "setup" | "config";
export type MachineOnboardingPage =
| "identity"
| "key-import"
| "backup"
| "setup"
| "config";
export function MachineOnboardingFlow({
complete,
identityLost,
initialPage,
queryClient,
}: {
complete: (pubkey?: string) => void;
identityLost: boolean;
initialPage?: MachineOnboardingPage;
queryClient: QueryClient;
}) {
const [page, setPage] = React.useState<MachinePage>(
identityLost ? "key-import" : "identity",
const [page, setPage] = React.useState<MachineOnboardingPage>(
identityLost ? "key-import" : (initialPage ?? "identity"),
);
const [error, setError] = React.useState<string | null>(null);
const [isPending, setIsPending] = React.useState(false);
@@ -8,6 +8,11 @@ type NsecMaskedDisplayProps = {
variant?: "boxed" | "bare";
};
export const ONBOARDING_KEY_FRAME_CLASS =
"w-full min-w-0 rounded-xl bg-white/50 px-8 py-6";
export const ONBOARDING_KEY_ROW_CLASS = "flex min-w-0 items-center gap-4";
export const ONBOARDING_KEY_TEXT_CLASS = "buzz-onboarding-key-text";
/**
* Masked nsec display with reveal toggle and copy button.
*
@@ -67,7 +72,11 @@ export function NsecMaskedDisplay({
}
>
<div
className={`flex min-w-0 items-center ${isBare ? "gap-4" : "gap-2 px-3 py-2"}`}
className={
isBare
? ONBOARDING_KEY_ROW_CLASS
: "flex min-w-0 items-center gap-2 px-3 py-2"
}
>
{/* Wrapping element is a block inside the flex item, not the flex item
itself: WebKit (WKWebView) does not wrap a long unbroken string when
@@ -75,10 +84,8 @@ export function NsecMaskedDisplay({
overflow-wrap. A plain block wraps reliably in every engine. */}
<div className="min-w-0 flex-1">
<p
className={`w-full break-all [overflow-wrap:anywhere] font-mono ${
isBare
? "text-nsec-key text-[var(--buzz-onboarding-backup-ink)]"
: "text-xs leading-5"
className={`${
isBare ? ONBOARDING_KEY_TEXT_CLASS : "text-xs leading-5"
} ${
isRevealed
? `select-text ${isBare ? "" : "text-foreground"}`
@@ -273,6 +273,12 @@
--buzz-onboarding-emoji-picker-input: 255, 255, 255;
}
.buzz-onboarding-key-text {
@apply w-full break-all [overflow-wrap:anywhere] font-mono text-nsec-key;
color: var(--buzz-onboarding-backup-ink);
}
.dark .buzz-onboarding-neutral-theme:not(.buzz-startup-shell) {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
+225 -11
View File
@@ -63,6 +63,8 @@ async function setRelayConnectionState(
}
const HOME_SEEN_STORAGE_KEY_PREFIX = "buzz-home-feed-seen.v1:";
const COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY =
"buzz-community-onboarding-transaction.v1";
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
const BLANK_TYLER_IDENTITY = {
...TEST_IDENTITIES.tyler,
@@ -106,6 +108,21 @@ 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();
@@ -576,18 +593,17 @@ test("first-community choices expose npub and invite input", async ({
await page.goto("/");
await expect(
page.getByRole("button", { name: "Join default community" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Join a community" }),
page.getByRole("button", { name: "Add me to a community" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "I have an invite link" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Create a community" }),
page.getByRole("button", { name: "I want to create a community" }),
).toBeVisible();
await page.getByRole("button", { name: "Create a community" }).click();
await page
.getByRole("button", { name: "I want to create a community" })
.click();
await expect
.poll(() =>
page.evaluate(() => {
@@ -605,20 +621,97 @@ test("first-community choices expose npub and invite input", async ({
)
.toMatchObject({ url: "https://buzz.xyz" });
await page.getByRole("button", { name: "Join a community" }).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);
const joinKeyFrameStyles = await joinKeyFrame.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
backgroundColor: styles.backgroundColor,
borderRadius: styles.borderRadius,
};
});
expect(joinKeyFrameStyles.backgroundColor).toMatch(/(0\.5\)|\/ 0\.5\))/);
expect(joinKeyFrameStyles.borderRadius).toBe("12px");
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: "Back" }).click();
await page.getByRole("button", { name: "I have an invite link" }).click();
await expect(
page.getByRole("heading", { name: "I have an invite link" }),
page.getByRole("heading", { name: "Enter your invite link" }),
).toBeVisible();
await expect(page.getByTestId("invite-redeem-input")).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);
const inviteInputFrameStyles = await inviteInputFrame.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
backgroundColor: styles.backgroundColor,
borderRadius: styles.borderRadius,
};
});
expect(inviteInputFrameStyles.backgroundColor).toMatch(/(0\.5\)|\/ 0\.5\))/);
expect(inviteInputFrameStyles.borderRadius).toBe("12px");
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();
});
test("first-community hides the default option for localhost", async ({
test("first-community shows the scenario cards for localhost", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
@@ -639,8 +732,129 @@ test("first-community hides the default option for localhost", async ({
page.getByRole("button", { name: "Join default community" }),
).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Join a community" }),
page.getByRole("button", { name: "Add me to a community" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "I have an invite link" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "I want to create a community" }),
).toBeVisible();
await page.getByTestId("welcome-setup-back").click();
await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
await expect(
page.getByRole("heading", {
name: "Configure your default model settings",
}),
).toBeVisible();
});
test("first-community profile step uses onboarding Next and Back controls", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript(
({ pubkey, transactionStorageKey }) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
const timestamp = new Date().toISOString();
window.localStorage.setItem(
transactionStorageKey,
JSON.stringify({
id: "txn-profile-step",
source: "first-community",
stage: "profile",
relayUrl: "wss://default.example.com",
communityName: "Default",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
},
{
pubkey: BLANK_TYLER_IDENTITY.pubkey,
transactionStorageKey: COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY,
},
);
await installMockBridge(page, undefined, {
relayWsUrl: "wss://default.example.com",
skipOnboardingSeed: true,
skipCommunitySeed: true,
});
await page.goto("/");
await expect(page.getByTestId("community-onboarding-flow")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Build your profile" }),
).toBeVisible();
const profileMain = page.getByTestId("community-profile-main");
const profileMainBox = await profileMain.boundingBox();
const viewport = page.viewportSize();
if (!profileMainBox || !viewport) {
throw new Error("Could not measure community profile body position");
}
const chromeOffset = 106;
const footerOffset = 144;
const profileMainCenterY = profileMainBox.y + profileMainBox.height / 2;
const centeredInUsableLaneY =
chromeOffset + (viewport.height - chromeOffset - footerOffset) / 2;
expect(Math.abs(profileMainCenterY - centeredInUsableLaneY)).toBeLessThan(32);
const keyFrame = page.getByTestId("community-profile-key-frame");
const nameKey = page.getByTestId("community-profile-name-key");
await expect(keyFrame).toBeVisible();
await expect(nameKey).toBeVisible();
const keyFrameBox = await keyFrame.boundingBox();
expect(keyFrameBox?.width).toBeGreaterThan(700);
const keyFrameStyles = await keyFrame.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
backgroundColor: styles.backgroundColor,
borderRadius: styles.borderRadius,
};
});
expect(keyFrameStyles.backgroundColor).toMatch(/(0\.5\)|\/ 0\.5\))/);
expect(keyFrameStyles.borderRadius).toBe("12px");
await expect
.poll(() =>
nameKey.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 nameKey.evaluate((element) =>
window.getComputedStyle(element).fontFamily.toLowerCase(),
)
).includes("mono"),
).toBe(true);
await expect(page.getByTestId("community-profile-next")).toHaveText("Next");
await expect(page.getByTestId("community-profile-next")).toBeDisabled();
await expect(page.getByTestId("community-profile-back")).toHaveText("Back");
await page.getByTestId("community-profile-back").click();
await expect(
page.getByRole("button", { name: "Add me to a community" }),
).toBeVisible();
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY,
),
)
.toBeNull();
});
test("identity fallback text does not count as a real onboarding name", async ({