mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
feat(desktop): refine onboarding v3 workshop preview
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 > Profile > 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 > Profile >
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user