mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish community onboarding flow (#2048)
This commit is contained in:
+40
-5
@@ -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 you’d 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. They’ll 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. They’ll 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%;
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
Reference in New Issue
Block a user