From d91b5e166407e65fed6571e68c4d8862d11f184a Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 28 Aug 2026 09:15:56 +0100 Subject: [PATCH] feat(desktop): refine onboarding v3 workshop preview Signed-off-by: kenny lopez --- .../features/communities/ui/WelcomeSetup.tsx | 19 +- .../src/features/onboarding/ui/BackupStep.tsx | 410 +++++++++++------- .../features/onboarding/ui/BackupTestFlow.tsx | 43 +- .../onboarding/ui/DownloadKeyStep.tsx | 131 ++++-- .../onboarding/ui/EncryptedBackupCreator.tsx | 82 +++- .../onboarding/ui/InviteRedeemForm.tsx | 41 ++ .../onboarding/ui/OnboardingPreviewApp.tsx | 31 +- .../ui/OnboardingPreviewJourney.tsx | 207 +-------- .../src/shared/styles/globals/spoilers.css | 20 + desktop/src/shared/ui/SpoilerParticles.tsx | 37 ++ .../src/shared/ui/markdown/SpoilerInline.tsx | 47 +- desktop/tests/e2e/onboarding.spec.ts | 27 +- 12 files changed, 664 insertions(+), 431 deletions(-) diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx index 3ff00df51b..ee1f221389 100644 --- a/desktop/src/features/communities/ui/WelcomeSetup.tsx +++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx @@ -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" ? ( -
+ {page === "join" && membershipRequired ? ( +

- Joining a private community? + You’ll need to request access to this community

- 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.

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(null); const [nsec, setNsec] = React.useState(null); const [isRevealed, setIsRevealed] = React.useState(false); + const canUseSpoilerParticles = React.useMemo( + () => previewMode && supportsSpoilerParticles(), + [previewMode], + ); const cancelledRef = React.useRef(false); const copiedTimerRef = React.useRef(null); @@ -199,108 +211,167 @@ export function BackupStep({ >

- {previewMode ? "Back up your identity" : "Backup options"} + {previewMode ? "Create a private identity key" : "Backup options"}

-

- {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."} -

+ {previewMode ? ( +
+ +
+ ) : ( +

+ 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. +

+ )}
-
-
+ {previewMode ? ( +
- {storageTitle} - - {storageDescription} - -
- -
- - {previewMode - ? "Save in your password manager" - : "Saved in your password manager"} - - - Copy your identity key, then save it in a password manager like - 1Password. - - -
- -
- - {previewMode - ? "Create a locked backup" - : "Locked in a backup file"} - - - Create a backup file and choose a password you can remember. - You’ll need both to restore your account. - - +
+ + +

+ Keep it private and secure at all times. +

+
+
+ + +

+ Don’t share it with anyone. +

+
+
+ + +

+ Keep a backup because Buzz can’t reset or recover it. +

+
- - {copyError ? ( -

+

- Could not retrieve your private key: {copyError}. You can continue - and find it later in Settings > Profile > Identity. -

- ) : null} -
+
+ {storageTitle} + + {storageDescription} + +
+ +
+ + Saved in your password manager + + + Copy your identity key, then save it in a password manager + like 1Password. + + +
+ +
+ + Locked in a backup file + + + Create a backup file and choose a password you can remember. + You’ll need both to restore your account. + + +
+
+ + {copyError ? ( +

+ Could not retrieve your private key: {copyError}. You can + continue and find it later in Settings > Profile > + Identity. +

+ ) : null} +
+ )} + + {previewMode ? ( + + + + ) : null} ); } @@ -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({
-

- {isRevealed && nsec ? nsec : maskedKey} -

-
- - {previewMode ? ( +
+ {canUseSpoilerParticles ? null : ( - ) : null} + )}
+ {previewMode ? ( +
+ + +
+ ) : null} + {copyError ? (

) : null} -

- - - {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."} - -

- {previewMode ? ( -
- -
+ {!previewMode ? ( +

+ + + Never share your private key. Anyone with this key can + impersonate you and access everything in your account. + +

) : null}
)} - {previewMode && created ? ( + {previewMode && created && showPreviewBackupShortcut ? ( diff --git a/desktop/src/features/onboarding/ui/BackupTestFlow.tsx b/desktop/src/features/onboarding/ui/BackupTestFlow.tsx index 9370d5c061..1230c630c0 100644 --- a/desktop/src/features/onboarding/ui/BackupTestFlow.tsx +++ b/desktop/src/features/onboarding/ui/BackupTestFlow.tsx @@ -65,6 +65,16 @@ type BackupTestFlowProps = { onProgressChange: React.Dispatch>; /** 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" > Select your backup file diff --git a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx index d91bc92e61..6e3ebcd3d2 100644 --- a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx +++ b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx @@ -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(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. */}

- {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"}

- {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."}

@@ -89,7 +106,13 @@ export function DownloadKeyStep({
- + {showPreviewSaved ? ( +
+
+
+ +
+ ) : ( + + )}
-
- {hasCreated ? ( + {showPreviewSaved ? ( + + ) : ( +
+ )} + {hasCreated && !showPreviewSaved ? (
@@ -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 = ( setPage("landing")} + onBack={() => setPage("backup-options")} testId="onboarding-preview-identity-key" total={journey.totalSteps} > continueFromAccount("identity-key")} onOpenPasswordBackup={() => setPage("backup-password")} onShowOptions={() => { @@ -829,19 +833,21 @@ export function OnboardingPreviewApp() { optionsExpanded={false} previewMode returningFromSecurity={false} + showPreviewBackupShortcut={false} /> ); } else if (page === "backup-options") { content = ( setPage("identity-key")} + onBack={() => setPage("landing")} testId="onboarding-preview-backup-options" total={journey.totalSteps} > 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 = ( setPage("backup-options")} - onDone={() => setPage("backup-options")} + onBack={() => setPage("identity-key")} + onDone={() => setPage("identity-key")} + session={backupSession} /> ); } else if (page === "sign-in") { diff --git a/desktop/src/features/onboarding/ui/OnboardingPreviewJourney.tsx b/desktop/src/features/onboarding/ui/OnboardingPreviewJourney.tsx index 741e38e2d5..0d9ac5b6ae 100644 --- a/desktop/src/features/onboarding/ui/OnboardingPreviewJourney.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingPreviewJourney.tsx @@ -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 ( - - ); -} - -type BackupPhase = "password" | "saved" | "verify" | "verified"; - export function BackupPasswordPreview({ onBack, onDone, + session, }: { onBack: () => void; onDone: () => void; + session: EncryptedBackupSession; }) { - const [phase, setPhase] = React.useState("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 ( setPhase("password")} + onBack={testRequested ? () => setTestRequested(false) : onBack} security testId="onboarding-preview-backup-password" > - -
-

{heading}

-

- {description} -

-
-
- {phase === "password" ? ( -
- - -

Buzz identity backup

-

- Protected with your backup password and ready to save. -

-
-
- ) : phase === "saved" ? ( - - - - - - - Buzz identity backup saved - - - Keep the file and password somewhere safe. - - - - ) : phase === "verify" ? ( -
- -

Buzz identity backup

-

- Selected and ready to unlock. -

-
- -
- ) : ( - - - - )} -
- - {phase === "password" ? ( - - ) : phase === "saved" ? ( - <> - - - - ) : phase === "verify" ? ( - - ) : ( - - )} - -
+ setTestRequested(true)} + previewMode + previewTestRequested={testRequested} + session={session} + />
); } @@ -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" ? ( -
+ {route === "join" && membershipRequired ? ( +

- {previewVariant === "v3" - ? "Need to request access to a community?" - : "Joining a private community?"} + You’ll need to request access to this community

- {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.

diff --git a/desktop/src/shared/styles/globals/spoilers.css b/desktop/src/shared/styles/globals/spoilers.css index ec6c2e5451..3e5795ff6c 100644 --- a/desktop/src/shared/styles/globals/spoilers.css +++ b/desktop/src/shared/styles/globals/spoilers.css @@ -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; diff --git a/desktop/src/shared/ui/SpoilerParticles.tsx b/desktop/src/shared/ui/SpoilerParticles.tsx index 0033a175b4..b7633520a2 100644 --- a/desktop/src/shared/ui/SpoilerParticles.tsx +++ b/desktop/src/shared/ui/SpoilerParticles.tsx @@ -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, diff --git a/desktop/src/shared/ui/markdown/SpoilerInline.tsx b/desktop/src/shared/ui/markdown/SpoilerInline.tsx index 3b1519742d..5d8975c809 100644 --- a/desktop/src/shared/ui/markdown/SpoilerInline.tsx +++ b/desktop/src/shared/ui/markdown/SpoilerInline.tsx @@ -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(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) => { - if (revealed) return; + if (visuallyRevealed) return; event.stopPropagation(); }, - [revealed], + [visuallyRevealed], ); const handleClickCapture = React.useCallback( (event: React.MouseEvent) => { - if (revealed) return; + if (visuallyRevealed) return; event.preventDefault(); event.stopPropagation(); toggleRevealed(); }, - [revealed, toggleRevealed], + [toggleRevealed, visuallyRevealed], ); const handleClick = React.useCallback( (event: React.MouseEvent) => { - 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) => { + 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({
- +
- + {children}
@@ -134,12 +157,12 @@ export function SpoilerInline({ - + - + {children} diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index b085e7dab6..e9b5b7aa20 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -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(); });