feat(desktop): refine onboarding v3 workshop preview

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-28 09:15:56 +01:00
parent f7a3dca3fc
commit d91b5e1664
12 changed files with 664 additions and 431 deletions

View File

@@ -43,6 +43,7 @@ export function WelcomeSetup({
// behind the modal never changes out from under the user.
const [isHostedSignInOpen, setIsHostedSignInOpen] = React.useState(false);
const [copiedNpub, setCopiedNpub] = React.useState(false);
const [membershipRequired, setMembershipRequired] = React.useState(false);
const communityOnboarding = useCommunityOnboarding();
const identityQuery = useIdentityQuery();
const systemColorScheme = useSystemColorScheme();
@@ -57,6 +58,7 @@ export function WelcomeSetup({
const showPage = React.useCallback(
(nextPage: WelcomeSetupPage, direction?: OnboardingTransitionDirection) => {
setMembershipRequired(false);
setTransitionMode(
direction ?? (nextPage === "welcome" ? "backward" : "forward"),
);
@@ -262,19 +264,24 @@ export function WelcomeSetup({
showPage(page === "member" ? "existing" : "welcome")
}
onConnect={startConnection}
onMembershipRequirementChange={
page === "join" ? setMembershipRequired : undefined
}
onRedeem={redeemInvite}
placeholder="Invite link or community URL"
variant="onboarding-spotlight"
/>
{page === "join" ? (
<div className="w-full max-w-[560px] text-left">
{page === "join" && membershipRequired ? (
<div
aria-live="polite"
className="w-full max-w-[560px] text-left"
>
<p className="text-sm font-medium text-foreground">
Joining a private community?
You’ll need to request access to this community
</p>
<p className="mt-2 text-sm leading-6 text-foreground/75">
Some communities need the owner to add you before you can
join. Copy your public ID and send it to the community
owner.
This community requires an admin to add you before you can
join. Copy your public ID and send it to them.
</p>
<div className="mt-4 flex items-center gap-3 rounded-xl border border-foreground/10 bg-background/35 px-4 py-3">
<code

View File

@@ -1,9 +1,11 @@
import {
Check,
CircleSlash2,
Copy,
Download,
Eye,
EyeOff,
HardDriveDownload,
Info,
ShieldCheck,
} from "lucide-react";
@@ -17,6 +19,8 @@ import { writeTextToClipboard } from "@/shared/lib/clipboard";
import { Button } from "@/shared/ui/button";
import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo";
import { Card } from "@/shared/ui/card";
import { SpoilerInline } from "@/shared/ui/markdown/SpoilerInline";
import { supportsSpoilerParticles } from "@/shared/ui/SpoilerParticles";
import { Spinner } from "@/shared/ui/spinner";
import {
ONBOARDING_PRIMARY_CTA_CLASS,
@@ -63,8 +67,10 @@ type BackupStepProps = {
onOpenPasswordBackup: () => void;
onShowOptions: () => void;
optionsExpanded: boolean;
lockedBackupCreated?: boolean;
previewMode?: boolean;
returningFromSecurity: boolean;
showPreviewBackupShortcut?: boolean;
};
/**
@@ -80,8 +86,10 @@ export function BackupStep({
onOpenPasswordBackup,
onShowOptions,
optionsExpanded,
lockedBackupCreated = false,
previewMode = false,
returningFromSecurity,
showPreviewBackupShortcut = true,
}: BackupStepProps) {
const reduceMotion = useReducedMotion() ?? false;
const [created, setCreated] = React.useState(introPlayed || reduceMotion);
@@ -91,6 +99,10 @@ export function BackupStep({
const [copyError, setCopyError] = React.useState<string | null>(null);
const [nsec, setNsec] = React.useState<string | null>(null);
const [isRevealed, setIsRevealed] = React.useState(false);
const canUseSpoilerParticles = React.useMemo(
() => previewMode && supportsSpoilerParticles(),
[previewMode],
);
const cancelledRef = React.useRef(false);
const copiedTimerRef = React.useRef<number | null>(null);
@@ -199,108 +211,167 @@ export function BackupStep({
>
<div className="flex w-full max-w-140 shrink-0 flex-col text-center">
<h1 className="text-title font-normal text-foreground">
{previewMode ? "Back up your identity" : "Backup options"}
{previewMode ? "Create a private identity key" : "Backup options"}
</h1>
<p className="mt-5 text-sm leading-6 text-foreground/75">
{previewMode
? "Buzz keeps your identity key protected on this device, but you should make a separate backup in case you lose access."
: "Your identity key works like a password for your Buzz account. Keep a copy somewhere safe. You can create a backup file and lock it with a password you can remember."}
</p>
{previewMode ? (
<div className="mt-3">
<IdentityKeyHelpDialog inline previewMode />
</div>
) : (
<p className="mt-5 text-sm leading-6 text-foreground/75">
Your identity key works like a password for your Buzz account.
Keep a copy somewhere safe. You can create a backup file and lock
it with a password you can remember.
</p>
)}
</div>
<div className="flex w-full max-w-260 flex-1 flex-col justify-center py-10">
<div
className="grid w-full grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3"
data-testid="backup-options"
>
{previewMode ? (
<div className="flex w-full max-w-[560px] flex-1 items-center py-10">
<div
className={cn(BACKUP_OPTION_CLASS, "md:col-span-2 lg:col-span-1")}
data-testid="backup-option-panel"
className="w-full space-y-6"
data-testid="onboarding-preview-key-guidance"
>
<span className="text-lg font-medium">{storageTitle}</span>
<span className="mt-3 block text-sm leading-6 text-foreground/65">
{storageDescription}
</span>
</div>
<div
className={BACKUP_OPTION_CLASS}
data-testid="backup-option-panel"
>
<span className="text-lg font-medium">
{previewMode
? "Save in your password manager"
: "Saved in your password manager"}
</span>
<span className="mt-3 block text-sm leading-6 text-foreground/65">
Copy your identity key, then save it in a password manager like
1Password.
</span>
<Button
className={cn(
ONBOARDING_SECONDARY_CTA_CLASS,
"mt-5 w-fit gap-2 px-5",
)}
data-testid="backup-copy-key"
disabled={copyState === "copying"}
onClick={() => void copyKeyToClipboard()}
type="button"
variant="ghost"
>
{copyState === "copying" ? (
<Spinner className="h-4 w-4 border-2" />
) : copyState === "copied" ? (
<Check className="h-4 w-4" aria-hidden="true" />
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
)}
{copyState === "copying"
? "Copying…"
: copyState === "copied"
? "Copied to clipboard"
: "Copy to clipboard"}
</Button>
</div>
<div
className={BACKUP_OPTION_CLASS}
data-testid="backup-option-panel"
>
<span className="text-lg font-medium">
{previewMode
? "Create a locked backup"
: "Locked in a backup file"}
</span>
<span className="mt-3 block text-sm leading-6 text-foreground/65">
Create a backup file and choose a password you can remember.
You’ll need both to restore your account.
</span>
<Button
className={cn(
ONBOARDING_SECONDARY_CTA_CLASS,
"mt-5 w-fit gap-2 px-5",
)}
data-testid="backup-option-password"
onClick={onOpenPasswordBackup}
type="button"
variant="ghost"
>
<ShieldCheck className="h-5 w-5" aria-hidden="true" />
Create locked backup
</Button>
<div className="flex min-h-14 items-center gap-4 text-left">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-foreground/10 text-foreground/80">
<ShieldCheck className="h-5 w-5" aria-hidden="true" />
</span>
<p className="text-base leading-6 text-foreground">
Keep it private and secure at all times.
</p>
</div>
<div className="flex min-h-14 items-center gap-4 text-left">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-foreground/10 text-foreground/80">
<CircleSlash2 className="h-5 w-5" aria-hidden="true" />
</span>
<p className="text-base leading-6 text-foreground">
Don’t share it with anyone.
</p>
</div>
<div className="flex min-h-14 items-center gap-4 text-left">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-foreground/10 text-foreground/80">
<HardDriveDownload className="h-5 w-5" aria-hidden="true" />
</span>
<p className="text-base leading-6 text-foreground">
Keep a backup because Buzz can’t reset or recover it.
</p>
</div>
</div>
</div>
{copyError ? (
<p
className="mt-4 text-center text-sm text-destructive"
data-testid="backup-copy-error"
) : (
<div className="flex w-full max-w-260 flex-1 flex-col justify-center py-10">
<div
className="grid w-full grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3"
data-testid="backup-options"
>
Could not retrieve your private key: {copyError}. You can continue
and find it later in Settings &gt; Profile &gt; Identity.
</p>
) : null}
</div>
<div
className={cn(
BACKUP_OPTION_CLASS,
"md:col-span-2 lg:col-span-1",
)}
data-testid="backup-option-panel"
>
<span className="text-lg font-medium">{storageTitle}</span>
<span className="mt-3 block text-sm leading-6 text-foreground/65">
{storageDescription}
</span>
</div>
<div
className={BACKUP_OPTION_CLASS}
data-testid="backup-option-panel"
>
<span className="text-lg font-medium">
Saved in your password manager
</span>
<span className="mt-3 block text-sm leading-6 text-foreground/65">
Copy your identity key, then save it in a password manager
like 1Password.
</span>
<Button
className={cn(
ONBOARDING_SECONDARY_CTA_CLASS,
"mt-5 w-fit gap-2 px-5",
)}
data-testid="backup-copy-key"
disabled={copyState === "copying"}
onClick={() => void copyKeyToClipboard()}
type="button"
variant="ghost"
>
{copyState === "copying" ? (
<Spinner className="h-4 w-4 border-2" />
) : copyState === "copied" ? (
<Check className="h-4 w-4" aria-hidden="true" />
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
)}
{copyState === "copying"
? "Copying…"
: copyState === "copied"
? "Copied to clipboard"
: "Copy to clipboard"}
</Button>
</div>
<div
className={BACKUP_OPTION_CLASS}
data-testid="backup-option-panel"
>
<span className="text-lg font-medium">
Locked in a backup file
</span>
<span className="mt-3 block text-sm leading-6 text-foreground/65">
Create a backup file and choose a password you can remember.
You’ll need both to restore your account.
</span>
<Button
className={cn(
ONBOARDING_SECONDARY_CTA_CLASS,
"mt-5 w-fit gap-2 px-5",
)}
data-testid="backup-option-password"
onClick={onOpenPasswordBackup}
type="button"
variant="ghost"
>
{lockedBackupCreated ? (
<Check className="h-5 w-5" aria-hidden="true" />
) : (
<ShieldCheck className="h-5 w-5" aria-hidden="true" />
)}
{lockedBackupCreated
? "Created locked backup"
: "Create locked backup"}
</Button>
</div>
</div>
{copyError ? (
<p
className="mt-4 text-center text-sm text-destructive"
data-testid="backup-copy-error"
>
Could not retrieve your private key: {copyError}. You can
continue and find it later in Settings &gt; Profile &gt;
Identity.
</p>
) : null}
</div>
)}
{previewMode ? (
<OnboardingFooter>
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-preview-private-key-next"
onClick={onNext}
type="button"
>
I understand, generate key
</Button>
</OnboardingFooter>
) : null}
</OnboardingSlideTransition>
);
}
@@ -320,7 +391,7 @@ export function BackupStep({
key={created ? "created" : "creating"}
>
{created && previewMode
? "Your identity key is ready"
? "Your private identity key"
: created
? "Your unique identity key has been created"
: "Creating your identity key"}
@@ -333,10 +404,7 @@ export function BackupStep({
)}
>
{previewMode ? (
<>
This works as the login and password for your Buzz account. Use
it to access Buzz from any device.
</>
"Keep this key private. Anyone who has it can access your account."
) : (
<>
{introStorageDescription} You can continue now, or{" "}
@@ -379,60 +447,104 @@ export function BackupStep({
<Card className="px-8 py-6" variant="textured">
<div className="mx-auto flex w-full min-w-0 max-w-[832px] items-center gap-4">
<div className="min-w-0 flex-1">
<p
className={cn(
ONBOARDING_KEY_TEXT_CLASS,
isRevealed && nsec
? "select-text"
: "select-none blur-[4px]",
)}
data-testid="backup-key-value"
>
{isRevealed && nsec ? nsec : maskedKey}
</p>
</div>
<Button
aria-label={
isRevealed ? "Hide private key" : "Reveal private key"
}
className="h-10 w-10 shrink-0 text-muted-foreground hover:text-foreground"
data-testid="backup-key-reveal-toggle"
onClick={() => void toggleReveal()}
size="icon"
type="button"
variant="ghost"
>
{isRevealed ? (
<EyeOff className="h-6 w-6" aria-hidden="true" />
{canUseSpoilerParticles ? (
<div
className="buzz-onboarding-private-key-spoiler"
data-testid="backup-key-spoiler"
>
<SpoilerInline block revealOnHover>
<p
className={cn(
ONBOARDING_KEY_TEXT_CLASS,
"select-text",
)}
data-testid="backup-key-value"
>
{PREVIEW_KEY_TEXT}
</p>
</SpoilerInline>
</div>
) : (
<Eye className="h-6 w-6" aria-hidden="true" />
<p
className={cn(
ONBOARDING_KEY_TEXT_CLASS,
isRevealed && nsec
? "select-text"
: "select-none blur-[4px]",
)}
data-testid="backup-key-value"
>
{isRevealed && nsec ? nsec : maskedKey}
</p>
)}
</Button>
{previewMode ? (
</div>
{canUseSpoilerParticles ? null : (
<Button
aria-label={
copyState === "copied"
? "Private key copied"
: "Copy private key"
isRevealed ? "Hide private key" : "Reveal private key"
}
className="h-10 w-10 shrink-0 text-muted-foreground hover:text-foreground"
data-testid="backup-key-copy"
disabled={copyState === "copying"}
onClick={() => void copyKeyToClipboard()}
data-testid="backup-key-reveal-toggle"
onClick={() => void toggleReveal()}
size="icon"
type="button"
variant="ghost"
>
{copyState === "copied" ? (
<Check className="h-5 w-5" aria-hidden="true" />
{isRevealed ? (
<EyeOff className="h-6 w-6" aria-hidden="true" />
) : (
<Copy className="h-5 w-5" aria-hidden="true" />
<Eye className="h-6 w-6" aria-hidden="true" />
)}
</Button>
) : null}
)}
</div>
</Card>
{previewMode ? (
<div
className="mt-8 flex flex-wrap items-center justify-center gap-3"
data-testid="backup-options"
>
<Button
className={cn(ONBOARDING_SECONDARY_CTA_CLASS, "gap-2 px-5")}
data-testid="backup-copy-key"
disabled={copyState === "copying"}
onClick={() => void copyKeyToClipboard()}
type="button"
variant="ghost"
>
{copyState === "copying" ? (
<Spinner className="h-4 w-4 border-2" />
) : copyState === "copied" ? (
<Check className="h-4 w-4" aria-hidden="true" />
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
)}
{copyState === "copying"
? "Copying…"
: copyState === "copied"
? "Copied to clipboard"
: "Copy to clipboard"}
</Button>
<Button
className={cn(ONBOARDING_SECONDARY_CTA_CLASS, "gap-2 px-5")}
data-testid="backup-option-password"
onClick={onOpenPasswordBackup}
type="button"
variant="ghost"
>
{lockedBackupCreated ? (
<Check className="h-5 w-5" aria-hidden="true" />
) : (
<ShieldCheck className="h-5 w-5" aria-hidden="true" />
)}
{lockedBackupCreated
? "Created locked backup"
: "Create locked backup"}
</Button>
</div>
) : null}
{copyError ? (
<p
className="mt-4 text-center text-sm text-destructive"
@@ -444,25 +556,21 @@ export function BackupStep({
</p>
) : null}
<p className="mx-auto mt-5 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>
{previewMode
? "Keep this key private. Anyone who has it can access your account. Your public identity is available anytime in Buzz Settings."
: "Never share your private key. Anyone with this key can impersonate you and access everything in your account."}
</span>
</p>
{previewMode ? (
<div className="mt-3">
<IdentityKeyHelpDialog inline previewMode />
</div>
{!previewMode ? (
<p className="mx-auto mt-5 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>
Never share your private key. Anyone with this key can
impersonate you and access everything in your account.
</span>
</p>
) : null}
</div>
</div>
)}
<OnboardingFooter className={REVEAL_ANIMATION_CLASS}>
{previewMode && created ? (
{previewMode && created && showPreviewBackupShortcut ? (
<Button
className={cn(ONBOARDING_SECONDARY_CTA_CLASS, "gap-2")}
data-testid="backup-options-link"
@@ -481,7 +589,7 @@ export function BackupStep({
onClick={onNext}
type="button"
>
Next
{previewMode ? "I’ve saved my key" : "Next"}
</Button>
</OnboardingFooter>
</OnboardingSlideTransition>

View File

@@ -65,6 +65,16 @@ type BackupTestFlowProps = {
onProgressChange: React.Dispatch<React.SetStateAction<BackupTestProgress>>;
/** Fired once when the user completes the test successfully. */
onVerified?: () => void;
/** Replace native verification and identity reads in the dev-only preview. */
previewMode?: boolean;
};
const PREVIEW_BACKUP_FILENAME = "Buzz identity backup.ncryptsec";
const PREVIEW_PRIVATE_KEY = `nsec1${"q".repeat(58)}`;
const PREVIEW_VERIFICATION: BackupVerification = {
matchesCurrentIdentity: true,
npub: `npub1${"q".repeat(58)}`,
pubkey: "e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34",
};
const BURST_EMOJIS = ["🎉", "✨", "🐝", "🍯", "🔑", "💛"] as const;
@@ -204,6 +214,7 @@ export function BackupTestFlow({
progress,
onProgressChange,
onVerified,
previewMode = false,
}: BackupTestFlowProps) {
const reduceMotion = useReducedMotion() ?? false;
const { stage, fileName, ncryptsec, result } = progress;
@@ -318,7 +329,9 @@ export function BackupTestFlow({
// the typed password never lingers in the field.
setAttempt("");
try {
const verified = await verifyNcryptsecBackup(ncryptsec, password);
const verified = previewMode
? PREVIEW_VERIFICATION
: await verifyNcryptsecBackup(ncryptsec, password);
if (!mountedRef.current || requestId !== requestRef.current) return;
onProgressChange((prev) => ({
...prev,
@@ -335,7 +348,14 @@ export function BackupTestFlow({
if (mountedRef.current && requestId === requestRef.current)
setIsVerifying(false);
}
}, [attempt, isVerifying, ncryptsec, onProgressChange, onVerified]);
}, [
attempt,
isVerifying,
ncryptsec,
onProgressChange,
onVerified,
previewMode,
]);
const toggleSuccessNsec = React.useCallback(async () => {
if (isSuccessNsecRevealed) {
@@ -349,7 +369,7 @@ export function BackupTestFlow({
setIsLoadingSuccessNsec(true);
setSuccessNsecError(null);
try {
const value = await getNsec();
const value = previewMode ? PREVIEW_PRIVATE_KEY : await getNsec();
if (!mountedRef.current) return;
setSuccessNsec(value);
setIsSuccessNsecRevealed(true);
@@ -361,7 +381,7 @@ export function BackupTestFlow({
} finally {
if (mountedRef.current) setIsLoadingSuccessNsec(false);
}
}, [isSuccessNsecRevealed, successNsec]);
}, [isSuccessNsecRevealed, previewMode, successNsec]);
const isSpotlight = variant === "spotlight";
@@ -527,7 +547,20 @@ export function BackupTestFlow({
: "h-9 px-6 text-primary-foreground",
)}
data-testid="backup-test-dropzone"
onClick={() => fileInputRef.current?.click()}
onClick={() => {
if (previewMode && expectedNcryptsec) {
setError(null);
setAttempt("");
onProgressChange({
stage: "password",
fileName: PREVIEW_BACKUP_FILENAME,
ncryptsec: expectedNcryptsec,
result: null,
});
return;
}
fileInputRef.current?.click();
}}
type="button"
>
<span className="font-medium text-sm">Select your backup file</span>

View File

@@ -1,3 +1,4 @@
import { Check } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
@@ -21,6 +22,12 @@ type DownloadKeyStepProps = {
/** Backup state owned by the parent flow across the creation and test views. */
session: EncryptedBackupSession;
onBack: () => void;
/** Use in-memory backup operations inside the dev-only workshop preview. */
previewMode?: boolean;
/** Whether the workshop user explicitly chose to test their saved backup. */
previewTestRequested?: boolean;
/** Opens the existing guided test from the preview's saved confirmation. */
onPreviewTestRequest?: () => void;
};
/**
@@ -32,6 +39,9 @@ export function DownloadKeyStep({
direction,
session,
onBack,
previewMode = false,
previewTestRequested = false,
onPreviewTestRequest,
}: DownloadKeyStepProps) {
const reduceMotion = useReducedMotion() ?? false;
// Once the encrypted payload is saved, the creator advances to its guided
@@ -39,6 +49,7 @@ export function DownloadKeyStep({
const hasCreated = session.created;
const hasVerifiedBackup = session.verified;
const hasSelectedBackup = session.test.stage === "password";
const showPreviewSaved = previewMode && hasCreated && !previewTestRequested;
const [primaryActionSlot, setPrimaryActionSlot] =
React.useState<HTMLElement | null>(null);
@@ -54,33 +65,39 @@ export function DownloadKeyStep({
className="flex w-full max-w-[500px] shrink-0 flex-col text-center"
initial={reduceMotion ? false : { opacity: 0, y: 10 }}
key={
hasVerifiedBackup
? "success-heading"
: hasCreated
? "test-heading"
: "password-heading"
showPreviewSaved
? "saved-heading"
: hasVerifiedBackup
? "success-heading"
: hasCreated
? "test-heading"
: "password-heading"
}
transition={{ duration: reduceMotion ? 0 : 0.3, ease: "easeOut" }}
>
{/* Plain string concat: cn()'s tailwind-merge misreads the custom
text-title size token as conflicting with text-foreground. */}
<h1 className="text-title font-normal text-foreground">
{hasVerifiedBackup
? "Your backup is verified"
: hasSelectedBackup
? "That’s your backup file"
: hasCreated
? "Optionally, test your backup"
: "Backup your key with a password"}
{showPreviewSaved
? "Your backup is ready"
: hasVerifiedBackup
? "Your backup is verified"
: hasSelectedBackup
? "That’s your backup file"
: hasCreated
? "Optionally, test your backup"
: "Backup your key with a password"}
</h1>
<p className="mt-5 text-sm leading-6 text-foreground/80">
{hasVerifiedBackup
? "Your file and password can restore your identity."
: hasSelectedBackup
? "Now enter your password to prove you can unlock it."
: hasCreated
? "Learn how your backup works. Drop the file you just saved and unlock it with your password."
: "Keep the downloaded file private — you need both it and your password to restore your identity. Save the backup password somewhere safe; Buzz cannot reset it if lost."}
{showPreviewSaved
? "Keep the backup file and password somewhere safe. You’ll need both to restore your identity."
: hasVerifiedBackup
? "Your file and password can restore your identity."
: hasSelectedBackup
? "Now enter your password to prove you can unlock it."
: hasCreated
? "Learn how your backup works. Drop the file you just saved and unlock it with your password."
: "Keep the downloaded file private — you need both it and your password to restore your identity. Save the backup password somewhere safe; Buzz cannot reset it if lost."}
</p>
</motion.div>
@@ -89,7 +106,13 @@ export function DownloadKeyStep({
<motion.div
animate={{ opacity: 1, y: 0 }}
initial={reduceMotion ? false : { opacity: 0, y: 12 }}
key={hasCreated ? "test-panel" : "password-panel"}
key={
showPreviewSaved
? "saved-panel"
: hasCreated
? "test-panel"
: "password-panel"
}
transition={{
delay: reduceMotion ? 0 : 0.12,
duration: reduceMotion ? 0 : 0.4,
@@ -97,30 +120,66 @@ export function DownloadKeyStep({
}}
>
<div
className="mx-auto w-full max-w-140 px-6 py-5"
className="mx-auto flex w-full max-w-140 justify-center px-6 py-5"
data-testid="backup-password-panel"
>
<EncryptedBackupCreator
createButtonClassName={ONBOARDING_SECURITY_PRIMARY_CTA_CLASS}
createButtonPortal={primaryActionSlot}
session={session}
variant="spotlight"
verifyButtonPortal={primaryActionSlot}
/>
{showPreviewSaved ? (
<div className="flex flex-col items-center gap-5">
<div
className="flex size-20 items-center justify-center rounded-full bg-white text-black/80"
data-testid="onboarding-preview-backup-saved"
>
<Check
aria-hidden="true"
className="size-10"
strokeWidth={3}
/>
</div>
<Button
className={ONBOARDING_SECONDARY_CTA_CLASS}
data-testid="onboarding-preview-test-backup"
onClick={onPreviewTestRequest}
type="button"
variant="ghost"
>
Test your backup
</Button>
</div>
) : (
<EncryptedBackupCreator
createButtonClassName={ONBOARDING_SECURITY_PRIMARY_CTA_CLASS}
createButtonPortal={primaryActionSlot}
previewMode={previewMode}
session={session}
variant="spotlight"
verifyButtonPortal={primaryActionSlot}
/>
)}
</div>
</motion.div>
</div>
</div>
<OnboardingFooter>
<div
className="flex justify-center"
data-testid={
hasCreated ? "onboarding-verify-slot" : "onboarding-create-slot"
}
ref={setPrimaryActionSlot}
/>
{hasCreated ? (
{showPreviewSaved ? (
<Button
className={ONBOARDING_SECURITY_PRIMARY_CTA_CLASS}
data-testid="onboarding-preview-backup-done"
onClick={onBack}
type="button"
>
Done
</Button>
) : (
<div
className="flex justify-center"
data-testid={
hasCreated ? "onboarding-verify-slot" : "onboarding-create-slot"
}
ref={setPrimaryActionSlot}
/>
)}
{hasCreated && !showPreviewSaved ? (
<Button
className={
hasVerifiedBackup

View File

@@ -57,6 +57,21 @@ const SEPARATOR_OPTIONS = [
const DEFAULT_SEPARATOR = SEPARATOR_OPTIONS[0].value;
const PREVIEW_BACKUP_FILENAME = "Buzz identity backup.ncryptsec";
const PREVIEW_NCRYPTSEC = `ncryptsec1${"q".repeat(120)}`;
const PREVIEW_PASSPHRASE_WORDS = [
"honey",
"harbor",
"lantern",
"meadow",
"copper",
"willow",
"sunrise",
"orbit",
"river",
"garden",
] as const;
/**
* Pause after the last keystroke before the background KDF starts, so typing
* past the minimum length doesn't launch an encryption per character.
@@ -257,6 +272,8 @@ type EncryptedBackupCreatorProps = {
guidedTest?: boolean;
/** Fired once when the user completes the backup test successfully. */
onVerified?: () => void;
/** Replace native identity and file operations inside the dev-only preview. */
previewMode?: boolean;
};
/**
@@ -272,11 +289,13 @@ function PassphraseGeneratorPopover({
disabled = false,
onRequestGenerate,
onGenerated,
previewMode = false,
securityTheme = false,
}: {
disabled?: boolean;
onRequestGenerate?: () => void;
onGenerated: (value: string) => void;
previewMode?: boolean;
securityTheme?: boolean;
}) {
const [open, setOpen] = React.useState(false);
@@ -302,21 +321,26 @@ function PassphraseGeneratorPopover({
};
}, []);
const generate = React.useCallback(async (wordCount: number, sep: string) => {
setError(null);
try {
const passphrase = await generateBackupPassphrase({
words: wordCount,
separator: sep,
});
if (mountedRef.current) onGeneratedRef.current(passphrase);
} catch (err) {
if (!mountedRef.current) return;
setError(
err instanceof Error ? err.message : "Failed to generate a password.",
);
}
}, []);
const generate = React.useCallback(
async (wordCount: number, sep: string) => {
setError(null);
try {
const passphrase = previewMode
? PREVIEW_PASSPHRASE_WORDS.slice(0, wordCount).join(sep)
: await generateBackupPassphrase({
words: wordCount,
separator: sep,
});
if (mountedRef.current) onGeneratedRef.current(passphrase);
} catch (err) {
if (!mountedRef.current) return;
setError(
err instanceof Error ? err.message : "Failed to generate a password.",
);
}
},
[previewMode],
);
// Fill the password field on every open and whenever a control changes.
React.useEffect(() => {
@@ -453,6 +477,7 @@ export function EncryptedBackupCreator({
onSaved,
guidedTest = true,
onVerified,
previewMode = false,
}: EncryptedBackupCreatorProps) {
// Hosts without a longer-lived session get a private one (settings card).
const fallbackSession = useEncryptedBackupSession();
@@ -489,7 +514,10 @@ export function EncryptedBackupCreator({
const start = () => {
if (cancelled) return;
dispatch({ type: "encrypt-started", requestId });
void createNcryptsecBackup(pendingPassphrase)
const createBackup = previewMode
? Promise.resolve(PREVIEW_NCRYPTSEC)
: createNcryptsecBackup(pendingPassphrase);
void createBackup
.then((ncryptsec) =>
dispatch({ type: "encrypt-succeeded", requestId, ncryptsec }),
)
@@ -512,7 +540,13 @@ export function EncryptedBackupCreator({
cancelled = true;
window.clearTimeout(timer);
};
}, [dispatch, pendingPassphrase, skipDebounce, state.nextRequestId]);
}, [
dispatch,
pendingPassphrase,
previewMode,
skipDebounce,
state.nextRequestId,
]);
// Download commit: fires once per committed blob, whether the commit was
// instant (encryption already done) or resolved a queued download. The flow
@@ -530,7 +564,10 @@ export function EncryptedBackupCreator({
savedForRef.current = null;
dispatch({ type: "back-to-password" });
};
void saveNcryptsecCopy(ncryptsec)
const saveBackup = previewMode
? Promise.resolve(PREVIEW_BACKUP_FILENAME)
: saveNcryptsecCopy(ncryptsec);
void saveBackup
.then((path) => {
if (path) {
setSavedPath(path);
@@ -554,6 +591,7 @@ export function EncryptedBackupCreator({
dispatch,
onCreated,
onSaved,
previewMode,
savedForRef,
setSavedPath,
state.ncryptsec,
@@ -564,7 +602,9 @@ export function EncryptedBackupCreator({
setIsSaving(true);
setSaveError(null);
try {
const path = await saveNcryptsecCopy(state.ncryptsec);
const path = previewMode
? PREVIEW_BACKUP_FILENAME
: await saveNcryptsecCopy(state.ncryptsec);
if (mountedRef.current && path) {
setSavedPath(path);
onSaved?.(path);
@@ -577,7 +617,7 @@ export function EncryptedBackupCreator({
} finally {
if (mountedRef.current) setIsSaving(false);
}
}, [isSaving, onSaved, setSavedPath, state.ncryptsec]);
}, [isSaving, onSaved, previewMode, setSavedPath, state.ncryptsec]);
const { setVerified, test, setTest } = session;
const handleVerified = React.useCallback(() => {
@@ -604,6 +644,7 @@ export function EncryptedBackupCreator({
onSaveCopy={() => void handleSaveCopy()}
onVerified={handleVerified}
progress={test}
previewMode={previewMode}
saveError={saveError}
variant={variant}
verifyButtonPortal={verifyButtonPortal}
@@ -739,6 +780,7 @@ export function EncryptedBackupCreator({
// A generated password must be visible so the user can save it.
setIsRevealed(true);
}}
previewMode={previewMode}
securityTheme={variant === "spotlight"}
/>
{issue ? (

View File

@@ -12,6 +12,7 @@ import {
isJoinPolicyDiscoveryCandidate,
type JoinPolicy,
} from "@/shared/api/invites";
import { relayRequiresMembership } from "@/shared/api/relayMembers";
import { normalizeRelayUrl } from "@/features/communities/relayProbe";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
@@ -55,6 +56,7 @@ type InviteRedeemFormProps = {
isRedeeming: boolean;
onCancel: () => void;
onConnect?: (relayWsUrl: string) => void;
onMembershipRequirementChange?: (required: boolean) => void;
onRedeem: (relayWsUrl: string, code: string, policyReceipt?: string) => void;
placeholder?: string;
/** Render the production form without discovery or relay requests. */
@@ -69,6 +71,7 @@ export function InviteRedeemForm({
isRedeeming,
onCancel,
onConnect,
onMembershipRequirementChange,
onRedeem,
placeholder,
previewMode = false,
@@ -141,6 +144,42 @@ export function InviteRedeemForm({
};
}, [bareCodeRelayUrl, normalizedRelayUrl, parsedInvite, previewMode]);
React.useEffect(() => {
if (!onMembershipRequirementChange) return;
onMembershipRequirementChange(false);
if (!normalizedRelayUrl || parsedInvite?.code) return;
let cancelled = false;
const timeoutId = window.setTimeout(() => {
if (previewMode) {
// The workshop preview stays offline while still demonstrating the
// conditional state a membership-gated community would produce.
if (!cancelled) onMembershipRequirementChange(true);
return;
}
void relayRequiresMembership(normalizedRelayUrl)
.then((required) => {
if (!cancelled) onMembershipRequirementChange(required);
})
.catch(() => {
// Discovery is best-effort. The connection flow remains the
// authoritative fallback when relay information is unavailable.
});
}, POLICY_DISCOVERY_DELAY_MS);
return () => {
cancelled = true;
window.clearTimeout(timeoutId);
};
}, [
normalizedRelayUrl,
onMembershipRequirementChange,
parsedInvite?.code,
previewMode,
]);
const canSubmit =
previewMode ||
(parsedInvite !== null &&
@@ -274,6 +313,7 @@ export function InviteRedeemForm({
event: React.ChangeEvent<HTMLInputElement>,
) => {
setInviteInput(event.target.value);
onMembershipRequirementChange?.(false);
setJoinPolicy(null);
setPolicyTarget(null);
setAgeConfirmed(false);
@@ -285,6 +325,7 @@ export function InviteRedeemForm({
event: React.ChangeEvent<HTMLInputElement>,
) => {
setBareCodeRelayUrl(event.target.value);
onMembershipRequirementChange?.(false);
setJoinPolicy(null);
setPolicyTarget(null);
setAgeConfirmed(false);

View File

@@ -14,6 +14,10 @@ import {
type OnboardingPreviewVariant,
} from "../onboardingPreview";
import { BackupStep } from "./BackupStep";
import {
resetEncryptedBackupSession,
useEncryptedBackupSession,
} from "./EncryptedBackupCreator";
import { JoinPolicyNotice } from "./JoinPolicyNotice";
import { LandingBees } from "./LandingBees";
import { NostrKeyImportForm } from "./NostrKeyImportForm";
@@ -94,16 +98,13 @@ function Landing({
</div>
<OnboardingFooter className="max-w-none">
<Button
className="h-auto flex-col gap-0.5 text-foreground/70 hover:text-foreground"
className="h-auto text-foreground/70 hover:text-foreground"
data-testid="onboarding-preview-without-email"
onClick={() => onNavigate("identity-key")}
onClick={() => onNavigate("backup-options")}
type="button"
variant="link"
>
<span>Sign up without email</span>
<span className="text-2xs font-normal text-foreground/50 no-underline">
Create a Nostr identity
</span>
Sign up without email
</Button>
</OnboardingFooter>
</OnboardingSlideTransition>
@@ -759,6 +760,7 @@ export function OnboardingPreviewApp() {
const [communityName, setCommunityName] = React.useState("Block Community");
const [displayName, setDisplayName] = React.useState("");
const [avatarUrl, setAvatarUrl] = React.useState("");
const backupSession = useEncryptedBackupSession();
const journey = ONBOARDING_PREVIEW_JOURNEYS[variant];
React.useEffect(() => {
@@ -771,6 +773,7 @@ export function OnboardingPreviewApp() {
const restart = React.useCallback(() => {
resetAvatarPresentations();
resetEncryptedBackupSession(backupSession);
setPage("landing");
setPasswordResetEmail("");
setRun((current) => current + 1);
@@ -780,7 +783,7 @@ export function OnboardingPreviewApp() {
setCommunityName("Block Community");
setDisplayName("");
setAvatarUrl("");
}, []);
}, [backupSession]);
const changeVariant = React.useCallback(
(nextVariant: OnboardingPreviewVariant) => {
@@ -814,12 +817,13 @@ export function OnboardingPreviewApp() {
} else if (page === "identity-key") {
content = (
<OnboardingPreviewStep
onBack={() => setPage("landing")}
onBack={() => setPage("backup-options")}
testId="onboarding-preview-identity-key"
total={journey.totalSteps}
>
<BackupStep
direction="forward"
lockedBackupCreated={backupSession.created}
onNext={() => continueFromAccount("identity-key")}
onOpenPasswordBackup={() => setPage("backup-password")}
onShowOptions={() => {
@@ -829,19 +833,21 @@ export function OnboardingPreviewApp() {
optionsExpanded={false}
previewMode
returningFromSecurity={false}
showPreviewBackupShortcut={false}
/>
</OnboardingPreviewStep>
);
} else if (page === "backup-options") {
content = (
<OnboardingPreviewStep
onBack={() => setPage("identity-key")}
onBack={() => setPage("landing")}
testId="onboarding-preview-backup-options"
total={journey.totalSteps}
>
<BackupStep
direction="forward"
onNext={() => setPage(journey.afterAccount)}
lockedBackupCreated={backupSession.created}
onNext={() => setPage("identity-key")}
onOpenPasswordBackup={() => setPage("backup-password")}
onShowOptions={() => setPage("backup-options")}
optionsExpanded
@@ -853,8 +859,9 @@ export function OnboardingPreviewApp() {
} else if (page === "backup-password") {
content = (
<BackupPasswordPreview
onBack={() => setPage("backup-options")}
onDone={() => setPage("backup-options")}
onBack={() => setPage("identity-key")}
onDone={() => setPage("identity-key")}
session={backupSession}
/>
);
} else if (page === "sign-in") {

View File

@@ -5,13 +5,11 @@ import { HostedCommunityOnboarding } from "@/features/communities/ui/HostedCommu
import { pubkeyToNpub } from "@/shared/lib/nostrUtils";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import { Input } from "@/shared/ui/input";
import { InviteRedeemForm } from "./InviteRedeemForm";
import { CommunityProfileStage } from "./CommunityProfileStage";
import {
ONBOARDING_PRIMARY_CTA_CLASS,
ONBOARDING_SECONDARY_CTA_CLASS,
} from "./OnboardingChrome";
import { DownloadKeyStep } from "./DownloadKeyStep";
import type { EncryptedBackupSession } from "./EncryptedBackupCreator";
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
import { OnboardingFooter } from "./OnboardingFooter";
import { OnboardingPreviewStep } from "./OnboardingPreviewShell";
import { OnboardingSlideTransition } from "./OnboardingSlideTransition";
@@ -25,189 +23,31 @@ const PREVIEW_PUBLIC_ID = pubkeyToNpub(
"e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34",
);
function LabeledField({
id,
label,
onChange,
placeholder,
type = "text",
value,
}: {
id: string;
label: string;
onChange: (value: string) => void;
placeholder: string;
type?: "password" | "text" | "url";
value: string;
}) {
return (
<label className="block w-full text-left" htmlFor={id}>
<span className="mb-2 block pl-3 text-sm font-medium text-foreground">
{label}
</span>
<Input
autoComplete="off"
className={FIELD_CLASS}
id={id}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
type={type}
value={value}
/>
</label>
);
}
type BackupPhase = "password" | "saved" | "verify" | "verified";
export function BackupPasswordPreview({
onBack,
onDone,
session,
}: {
onBack: () => void;
onDone: () => void;
session: EncryptedBackupSession;
}) {
const [phase, setPhase] = React.useState<BackupPhase>("password");
const [password, setPassword] = React.useState("");
const [verificationPassword, setVerificationPassword] = React.useState("");
const heading =
phase === "verified"
? "Your backup is verified"
: phase === "verify"
? "That’s your backup file"
: phase === "saved"
? "Optionally, test your backup"
: "Backup your key with a password";
const description =
phase === "verified"
? "Your file and password can restore your identity."
: phase === "verify"
? "Now enter your password to prove you can unlock it."
: phase === "saved"
? "Learn how your backup works. Choose the file you just saved and unlock it with your password."
: "Keep the downloaded file private — you need both it and your password to restore your identity. Save the backup password somewhere safe; Buzz cannot reset it if lost.";
const [testRequested, setTestRequested] = React.useState(false);
return (
<OnboardingPreviewStep
onBack={phase === "password" ? onBack : () => setPhase("password")}
onBack={testRequested ? () => setTestRequested(false) : onBack}
security
testId="onboarding-preview-backup-password"
>
<OnboardingSlideTransition
className="flex min-h-0 w-full flex-col items-center"
transitionKey={`preview-backup-${phase}`}
>
<div className="w-full max-w-[500px] shrink-0 text-center">
<h1 className="text-title font-normal text-foreground">{heading}</h1>
<p className="mt-5 text-sm leading-6 text-foreground/80">
{description}
</p>
</div>
<div className="flex w-full max-w-[560px] flex-1 items-center justify-center py-10">
{phase === "password" ? (
<div className="w-full space-y-5">
<LabeledField
id="preview-backup-password"
label="Backup password"
onChange={setPassword}
placeholder="Create a backup password"
type="password"
value={password}
/>
<Card className="px-5 py-4 text-left" variant="textured">
<p className="text-sm font-medium">Buzz identity backup</p>
<p className="mt-1 text-xs leading-5 text-foreground/65">
Protected with your backup password and ready to save.
</p>
</Card>
</div>
) : phase === "saved" ? (
<Card
className="flex w-full items-center gap-4 px-6 py-5 text-left"
variant="textured"
>
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-green-600 text-white">
<Check className="h-5 w-5" aria-hidden />
</span>
<span>
<span className="block text-sm font-medium">
Buzz identity backup saved
</span>
<span className="mt-1 block text-xs text-foreground/65">
Keep the file and password somewhere safe.
</span>
</span>
</Card>
) : phase === "verify" ? (
<div className="w-full space-y-5">
<Card className="px-5 py-4 text-left" variant="textured">
<p className="text-sm font-medium">Buzz identity backup</p>
<p className="mt-1 text-xs text-foreground/65">
Selected and ready to unlock.
</p>
</Card>
<LabeledField
id="preview-backup-verification-password"
label="Backup password"
onChange={setVerificationPassword}
placeholder="Enter your backup password"
type="password"
value={verificationPassword}
/>
</div>
) : (
<span className="flex h-20 w-20 items-center justify-center rounded-full bg-green-600 text-white shadow-sm">
<Check className="h-10 w-10" aria-hidden />
</span>
)}
</div>
<OnboardingFooter>
{phase === "password" ? (
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="preview-create-locked-backup"
disabled={!password}
onClick={() => setPhase("saved")}
>
Create locked backup
</Button>
) : phase === "saved" ? (
<>
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="preview-test-backup"
onClick={() => setPhase("verify")}
>
Test backup
</Button>
<Button
className={ONBOARDING_SECONDARY_CTA_CLASS}
onClick={onDone}
variant="ghost"
>
Skip for now
</Button>
</>
) : phase === "verify" ? (
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="preview-verify-backup"
disabled={!verificationPassword}
onClick={() => setPhase("verified")}
>
Verify backup
</Button>
) : (
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="preview-finish-backup"
onClick={onDone}
>
Finish
</Button>
)}
</OnboardingFooter>
</OnboardingSlideTransition>
<DownloadKeyStep
direction="forward"
onBack={onDone}
onPreviewTestRequest={() => setTestRequested(true)}
previewMode
previewTestRequested={testRequested}
session={session}
/>
</OnboardingPreviewStep>
);
}
@@ -403,6 +243,7 @@ export function CommunityEntryPreview({
total?: number;
}) {
const [copiedPublicId, setCopiedPublicId] = React.useState(false);
const [membershipRequired, setMembershipRequired] = React.useState(false);
const heading =
route === "existing" ? "Reconnect to your community" : "Join a community";
const continueToCommunity = () => onContinue("Block Community");
@@ -434,22 +275,22 @@ export function CommunityEntryPreview({
isRedeeming={false}
onCancel={onBack}
onConnect={continueToCommunity}
onMembershipRequirementChange={
route === "join" ? setMembershipRequired : undefined
}
onRedeem={continueToCommunity}
placeholder="Invite link or community URL"
previewMode
variant="onboarding-spotlight"
/>
{route === "join" ? (
<div className="w-full max-w-[560px] text-left">
{route === "join" && membershipRequired ? (
<div aria-live="polite" className="w-full max-w-[560px] text-left">
<p className="text-sm font-medium text-foreground">
{previewVariant === "v3"
? "Need to request access to a community?"
: "Joining a private community?"}
You’ll need to request access to this community
</p>
<p className="mt-2 text-sm leading-6 text-foreground/75">
{previewVariant === "v3"
? "Some communities require an admin to add you before you can join. Copy your public ID and send it to them."
: "Some communities need the owner to add you before you can join. Copy your public ID and send it to the community owner."}
This community requires an admin to add you before you can join.
Copy your public ID and send it to them.
</p>
<div className="mt-4 flex items-center gap-3 rounded-xl border border-foreground/10 bg-background/35 px-4 py-3">
<code className="min-w-0 flex-1 truncate font-mono text-xs text-foreground/80">

View File

@@ -95,6 +95,26 @@
opacity: 0;
}
/* The onboarding key spoiler sits on a white security card even when the
surrounding workshop uses the dark theme. Keep the standard dark particles
visible and the local surface seamless. */
.buzz-onboarding-private-key-spoiler .buzz-spoiler {
--buzz-spoiler-accent: #333;
background: transparent;
}
.buzz-onboarding-private-key-spoiler .buzz-spoiler[data-revealed="true"],
.buzz-onboarding-private-key-spoiler .buzz-spoiler:hover,
.buzz-onboarding-private-key-spoiler .buzz-spoiler:focus-visible {
background: transparent;
}
.buzz-onboarding-private-key-spoiler .buzz-spoiler:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
.ProseMirror .buzz-spoiler {
background: transparent;
color: transparent;

View File

@@ -53,6 +53,43 @@ const RANDOM_SEED = 4011505;
const V_MIN = 2;
const V_MAX = 12;
/**
* The particle renderer targets APIs available in current Tauri webviews, but
* some managed or unusually old Windows/Linux runtimes can lag behind. Hosts
* with a non-particle concealment treatment can use this check to fall back
* before hidden content is mounted behind an unusable canvas.
*/
export function supportsSpoilerParticles(): boolean {
if (typeof window === "undefined" || typeof document === "undefined")
return false;
if (
typeof window.PointerEvent === "undefined" ||
typeof window.ResizeObserver === "undefined" ||
typeof window.MutationObserver === "undefined" ||
typeof window.IntersectionObserver === "undefined" ||
typeof window.matchMedia !== "function" ||
typeof window.requestAnimationFrame !== "function" ||
typeof window.cancelAnimationFrame !== "function" ||
typeof document.createRange !== "function"
) {
return false;
}
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
if (
typeof motionQuery.addEventListener !== "function" ||
typeof motionQuery.removeEventListener !== "function"
) {
return false;
}
try {
return document.createElement("canvas").getContext("2d") !== null;
} catch {
return false;
}
}
export function SpoilerParticles({
active,
contentRef,

View File

@@ -15,14 +15,19 @@ export function SpoilerInline({
block = false,
children,
interactive = true,
revealOnHover = false,
}: {
block?: boolean;
children?: React.ReactNode;
interactive?: boolean;
/** Temporarily reveal for mouse/trackpad hover while preserving click and keyboard access. */
revealOnHover?: boolean;
}) {
const [revealed, setRevealed] = React.useState(false);
const [hovered, setHovered] = React.useState(false);
const contentRef = React.useRef<HTMLElement | null>(null);
const isBlock = block || hasBlockMedia(React.Children.toArray(children));
const visuallyRevealed = revealed || hovered;
const setContentElement = React.useCallback((node: HTMLElement | null) => {
contentRef.current = node;
@@ -34,28 +39,33 @@ export function SpoilerInline({
const handlePointerDownCapture = React.useCallback(
(event: React.PointerEvent<HTMLElement>) => {
if (revealed) return;
if (visuallyRevealed) return;
event.stopPropagation();
},
[revealed],
[visuallyRevealed],
);
const handleClickCapture = React.useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (revealed) return;
if (visuallyRevealed) return;
event.preventDefault();
event.stopPropagation();
toggleRevealed();
},
[revealed, toggleRevealed],
[toggleRevealed, visuallyRevealed],
);
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (revealed && isBlock && event.target !== event.currentTarget) return;
// Hover is a temporary reveal: clicking or selecting while the pointer
// is inside must not pin the spoiler open after the pointer leaves.
// Touch and keyboard still toggle `revealed` because they have no hover.
if (revealOnHover && hovered) return;
if (visuallyRevealed && isBlock && event.target !== event.currentTarget)
return;
toggleRevealed();
},
[isBlock, revealed, toggleRevealed],
[hovered, isBlock, revealOnHover, toggleRevealed, visuallyRevealed],
);
const handleKeyDown = React.useCallback(
@@ -67,12 +77,25 @@ export function SpoilerInline({
[toggleRevealed],
);
const handlePointerEnter = React.useCallback(
(event: React.PointerEvent<HTMLElement>) => {
if (revealOnHover && event.pointerType === "mouse") setHovered(true);
},
[revealOnHover],
);
const handlePointerLeave = React.useCallback(() => {
if (revealOnHover) setHovered(false);
}, [revealOnHover]);
const revealProps = {
"aria-label": revealed ? "Hide spoiler" : "Reveal spoiler",
"aria-pressed": revealed,
onClick: handleClick,
onClickCapture: handleClickCapture,
onKeyDown: handleKeyDown,
onPointerEnter: handlePointerEnter,
onPointerLeave: handlePointerLeave,
onPointerDownCapture: handlePointerDownCapture,
role: "button",
tabIndex: 0,
@@ -117,12 +140,12 @@ export function SpoilerInline({
<div
{...revealProps}
className="buzz-spoiler buzz-spoiler--block"
data-revealed={revealed ? "true" : "false"}
data-revealed={visuallyRevealed ? "true" : "false"}
data-spoiler=""
>
<SpoilerParticles active={!revealed} contentRef={contentRef} />
<SpoilerParticles active={!visuallyRevealed} contentRef={contentRef} />
<div className="buzz-spoiler__content" ref={setContentElement}>
<SpoilerHiddenContext.Provider value={!revealed}>
<SpoilerHiddenContext.Provider value={!visuallyRevealed}>
{children}
</SpoilerHiddenContext.Provider>
</div>
@@ -134,12 +157,12 @@ export function SpoilerInline({
<span
{...revealProps}
className="buzz-spoiler"
data-revealed={revealed ? "true" : "false"}
data-revealed={visuallyRevealed ? "true" : "false"}
data-spoiler=""
>
<SpoilerParticles active={!revealed} contentRef={contentRef} />
<SpoilerParticles active={!visuallyRevealed} contentRef={contentRef} />
<span className="buzz-spoiler__content" ref={setContentElement}>
<SpoilerHiddenContext.Provider value={!revealed}>
<SpoilerHiddenContext.Provider value={!visuallyRevealed}>
{children}
</SpoilerHiddenContext.Provider>
</span>

View File

@@ -1107,11 +1107,15 @@ test("first-community choices route join, create, owner, and member intents", as
"true",
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(page, undefined, {
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
});
await installMockBridge(
page,
{ relayRequiresMembership: true },
{
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
);
await page.goto("/");
await expect(
@@ -1158,12 +1162,23 @@ test("first-community choices route join, create, owner, and member intents", as
await expect(
page.getByRole("heading", { name: "Join a community" }),
).toBeVisible();
await expect(page.getByText("Joining a private community?")).toBeVisible();
const accessRequiredHeading = page.getByText(
"You’ll need to request access to this community",
);
await expect(accessRequiredHeading).toHaveCount(0);
await accessInput.fill("https://default.example.com");
await expect(accessRequiredHeading).toBeVisible();
await expect(
page.getByText(
"This community requires an admin to add you before you can join. Copy your public ID and send it to them.",
),
).toBeVisible();
await expect(page.getByTestId("welcome-join-npub")).toBeVisible();
await expect(
page.getByRole("button", { name: "Copy public ID" }),
).toBeVisible();
await accessInput.fill("https://default.example.com/invite/abc123");
await expect(accessRequiredHeading).toHaveCount(0);
await expect(page.getByTestId("invite-redeem-submit")).toBeEnabled();
});