mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Improve desktop mobile pairing flow (#5024)
## Summary - add stable three-step guidance to desktop mobile pairing - move code confirmation inline and show animated completion states - preserve pairing reset behavior and reduced-motion support ## Test plan - `pnpm --dir desktop check` - `pnpm --dir desktop exec tsc --noEmit` - `pnpm --dir desktop exec playwright test tests/e2e/mobile-pairing-qr.spec.ts --project=smoke` - pre-push desktop suite: 4,387 tests passed --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Fizz <50a12680c76f1a52c0b7af8dbb17e02c583227c290fb93b9a3defb456114223f@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -4,11 +4,10 @@ import {
|
||||
Copy,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
TriangleAlert,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -16,15 +15,9 @@ import {
|
||||
confirmPairingSas,
|
||||
startPairing,
|
||||
} from "@/shared/api/tauri";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { StyledQrCode } from "@/shared/ui/styled-qr-code";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup";
|
||||
import { SettingsSectionHeader } from "./SettingsSectionHeader";
|
||||
import { writeTextToClipboard } from "@/shared/lib/clipboard";
|
||||
@@ -39,6 +32,8 @@ type PairingStep =
|
||||
| "done"
|
||||
| "error";
|
||||
|
||||
const PAIRING_CODE_DIGIT_POSITIONS = [0, 1, 2, 3, 4, 5] as const;
|
||||
|
||||
function pairingErrorMessage(error: unknown) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
@@ -58,114 +53,189 @@ function isPairingSessionTimeout(message: string) {
|
||||
return message.toLowerCase().includes("session timed out");
|
||||
}
|
||||
|
||||
function PairingStatusDialog({
|
||||
onClose,
|
||||
function PairingStepIndicator({
|
||||
complete,
|
||||
label,
|
||||
testId,
|
||||
}: {
|
||||
complete: boolean;
|
||||
label: string;
|
||||
testId: string;
|
||||
}) {
|
||||
const shouldReduceMotion = useReducedMotion() ?? false;
|
||||
const hiddenState = shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { filter: "blur(2px)", opacity: 0, scale: 0.25 };
|
||||
const visibleState = shouldReduceMotion
|
||||
? { opacity: 1 }
|
||||
: { filter: "blur(0px)", opacity: 1, scale: 1 };
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"relative flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-base font-semibold transition-[background-color,color] duration-[250ms] ease-in-out motion-reduce:transition-none",
|
||||
complete
|
||||
? "bg-green-600 text-white"
|
||||
: "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
data-completed={complete ? "true" : "false"}
|
||||
data-testid={testId}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.span
|
||||
animate={visibleState}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
data-state={complete ? "complete" : "pending"}
|
||||
exit={hiddenState}
|
||||
initial={hiddenState}
|
||||
key={complete ? "complete" : "pending"}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.25, ease: "easeInOut" }
|
||||
}
|
||||
>
|
||||
{complete ? <Check className="h-6 w-6" /> : label}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PairingSteps({ step }: { step: PairingStep }) {
|
||||
const hasScanned =
|
||||
step === "sas" || step === "transferring" || step === "done";
|
||||
const hasConfirmed = step === "transferring" || step === "done";
|
||||
const isPaired = step === "done";
|
||||
|
||||
return (
|
||||
<ol
|
||||
className="flex min-h-[266px] min-w-0 flex-1 flex-col justify-center gap-6 py-2"
|
||||
data-testid="mobile-pairing-steps"
|
||||
>
|
||||
<li className="flex min-w-0 items-start gap-4">
|
||||
<PairingStepIndicator
|
||||
complete={hasScanned}
|
||||
label="1"
|
||||
testId="mobile-pairing-scan-step-indicator"
|
||||
/>
|
||||
<div className="min-w-0 pt-0.5">
|
||||
<p className="text-base font-medium">Scan QR code</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground/80">
|
||||
Open Buzz on your mobile device and scan the code shown here.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex min-w-0 items-start gap-4">
|
||||
<PairingStepIndicator
|
||||
complete={hasConfirmed}
|
||||
label="2"
|
||||
testId="mobile-pairing-confirm-step-indicator"
|
||||
/>
|
||||
<div className="min-w-0 pt-0.5">
|
||||
<p className="text-base font-medium">Confirm mobile code</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground/80">
|
||||
Check that the six-digit code matches on both devices, then confirm
|
||||
it.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li
|
||||
className="flex min-w-0 items-start gap-4"
|
||||
data-testid="mobile-pairing-final-step"
|
||||
>
|
||||
<PairingStepIndicator
|
||||
complete={isPaired}
|
||||
label="3"
|
||||
testId="mobile-pairing-final-step-indicator"
|
||||
/>
|
||||
<div aria-live="polite" className="min-w-0 pt-0.5">
|
||||
<p className="text-base font-medium">
|
||||
{isPaired ? "Paired" : "Pair your mobile app"}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground/80">
|
||||
{isPaired
|
||||
? "Your mobile app is now connected to this relay."
|
||||
: "Your mobile app will connect after you confirm the code."}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function PairingCodeConfirmation({
|
||||
onConfirm,
|
||||
onDeny,
|
||||
sasCode,
|
||||
step,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
onDeny: () => void;
|
||||
sasCode: string | null;
|
||||
step: PairingStep;
|
||||
sasCode: string;
|
||||
}) {
|
||||
const open = step === "sas" || step === "transferring" || step === "done";
|
||||
const formattedCode = `${sasCode.slice(0, 3)} ${sasCode.slice(3, 6)}`;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) onClose();
|
||||
}}
|
||||
open={open}
|
||||
<div
|
||||
className="grid h-[266px] w-full grid-rows-[auto_1fr_auto] text-center"
|
||||
data-testid="mobile-pairing-code-confirmation"
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-md gap-0 overflow-hidden border-0 px-6 pb-6 pt-6"
|
||||
data-testid="mobile-pairing-dialog"
|
||||
<p
|
||||
className="self-start text-base font-medium"
|
||||
data-testid="pairing-sas-title"
|
||||
>
|
||||
<div className="flex max-h-[85vh] flex-col">
|
||||
<DialogHeader className="shrink-0 pb-5 pr-8">
|
||||
<DialogTitle>Pair mobile device</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "sas"
|
||||
? "Verify the security code matches your mobile device."
|
||||
: step === "done"
|
||||
? "Your mobile device is now paired."
|
||||
: "Securely sending your identity to the mobile app."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pt-4">
|
||||
{step === "sas" && sasCode ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col items-center gap-3 py-4">
|
||||
<ShieldCheck className="h-10 w-10 text-primary" />
|
||||
<p className="text-sm font-medium">
|
||||
Verify this code matches your mobile device
|
||||
</p>
|
||||
<div className="rounded-xl border-2 border-primary/30 bg-primary/5 px-8 py-4">
|
||||
<p
|
||||
className="font-mono text-4xl font-bold tracking-[0.3em]"
|
||||
data-testid="pairing-sas-code"
|
||||
>
|
||||
{sasCode.slice(0, 3)} {sasCode.slice(3)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
You are about to transfer your Buzz identity to another
|
||||
device. Only confirm if you initiated this pairing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
data-testid="deny-sas"
|
||||
onClick={onDeny}
|
||||
variant="outline"
|
||||
>
|
||||
<X className="mr-1.5 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
data-testid="confirm-sas"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<Check className="mr-1.5 h-4 w-4" />
|
||||
Codes match
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : step === "transferring" ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
||||
<LoaderCircle
|
||||
aria-hidden="true"
|
||||
className="h-6 w-6 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sending identity to mobile device...
|
||||
</p>
|
||||
</div>
|
||||
) : step === "done" ? (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-3 py-8"
|
||||
data-testid="mobile-pairing-done"
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<Check className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">Mobile device paired</p>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Your mobile app is now connected to this relay.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
Confirm mobile code
|
||||
</p>
|
||||
<fieldset
|
||||
className="flex w-full self-center justify-center gap-[6px]"
|
||||
data-testid="pairing-sas-code"
|
||||
>
|
||||
<legend className="sr-only">Confirmation code {formattedCode}</legend>
|
||||
{PAIRING_CODE_DIGIT_POSITIONS.map((position) => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
// The cell box is px-frozen on purpose. The digits stay
|
||||
// rem-based (`text-2xl`) so they scale with Cmd +/- zoom, but a
|
||||
// rem-sized cell (`w-10`) plus rem gaps grew the six cells past
|
||||
// this fixed 266px column at 150% text scale and overlapped the
|
||||
// step guidance. Freezing the box keeps the code block the same
|
||||
// width at every zoom level; a zoomed digit still fits inside.
|
||||
"flex w-[40px] shrink-0 items-center justify-center rounded-xl border border-input/60 bg-background py-3 font-mono text-2xl font-semibold text-foreground",
|
||||
position === 3 && "ml-[8px]",
|
||||
)}
|
||||
data-testid={`pairing-sas-code-digit-${position + 1}`}
|
||||
key={position}
|
||||
>
|
||||
{sasCode[position] ?? ""}
|
||||
</span>
|
||||
))}
|
||||
</fieldset>
|
||||
<div
|
||||
className="flex w-[240px] flex-col gap-2 self-end justify-self-center"
|
||||
data-testid="pairing-sas-actions"
|
||||
>
|
||||
<Button
|
||||
className="w-full"
|
||||
data-testid="confirm-sas"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<Check />
|
||||
Codes match
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full"
|
||||
data-testid="deny-sas"
|
||||
onClick={onDeny}
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -312,21 +382,6 @@ export function MobilePairingCard({
|
||||
setStep("error");
|
||||
}
|
||||
|
||||
function handleStatusDialogClose() {
|
||||
pairingActiveRef.current = false;
|
||||
if (stepRef.current === "done") {
|
||||
setStep("idle");
|
||||
setQrUri(null);
|
||||
setSasCode(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
cancelPairing().catch(() => {});
|
||||
setError("Pairing was canceled.");
|
||||
setStep("error");
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="min-w-0" data-testid="settings-mobile">
|
||||
<SettingsSectionHeader
|
||||
@@ -341,105 +396,156 @@ export function MobilePairingCard({
|
||||
/>
|
||||
|
||||
<SettingsOptionGroup
|
||||
className="mx-auto w-fit max-w-full"
|
||||
className="w-full [container-type:inline-size]"
|
||||
data-testid="mobile-pairing-card"
|
||||
>
|
||||
<SettingsOptionRow className="flex-col items-stretch justify-start gap-3 p-4">
|
||||
<div
|
||||
className="flex min-h-[266px] w-[266px] shrink-0 items-center justify-center rounded-lg border border-border/70 bg-white p-3"
|
||||
data-testid="mobile-pairing-qr-container"
|
||||
>
|
||||
{step === "qr" && qrUri ? (
|
||||
<StyledQrCode
|
||||
animate
|
||||
centerImageSrc="/app-icon@2x.png"
|
||||
data-testid="mobile-pairing-qr"
|
||||
size={240}
|
||||
title="Mobile pairing QR code"
|
||||
value={qrUri}
|
||||
/>
|
||||
) : step === "expired" ? (
|
||||
<div className="flex max-w-52 origin-center animate-in flex-col items-center gap-3 text-center fade-in-0 zoom-in-95 duration-[250ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:animate-none">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pairing code expired.
|
||||
</p>
|
||||
<Button
|
||||
data-testid="regenerate-pairing-button"
|
||||
onClick={beginPairing}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw className="mr-1.5 h-4 w-4" />
|
||||
Generate new pairing code
|
||||
</Button>
|
||||
</div>
|
||||
) : step === "error" ? (
|
||||
<div className="flex max-w-52 flex-col items-center gap-3 text-center">
|
||||
<TriangleAlert className="h-6 w-6 text-destructive" />
|
||||
<p className="text-sm text-destructive">
|
||||
{error ?? "Pairing session ended."}
|
||||
</p>
|
||||
<Button
|
||||
data-testid="retry-pairing-button"
|
||||
onClick={beginPairing}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : step === "idle" ? (
|
||||
currentPubkey ? (
|
||||
<Button
|
||||
data-testid="start-pairing-button"
|
||||
onClick={beginPairing}
|
||||
type="button"
|
||||
>
|
||||
Start pairing
|
||||
</Button>
|
||||
) : (
|
||||
<p className="max-w-44 text-center text-sm text-muted-foreground">
|
||||
Sign in to generate a mobile pairing code.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<LoaderCircle
|
||||
aria-hidden="true"
|
||||
className="h-6 w-6 animate-spin text-muted-foreground"
|
||||
data-testid="pairing-loading-spinner"
|
||||
{/* Persistent polite live region. The pairing steps swap the QR view
|
||||
for the inline code confirmation asynchronously, and a screen
|
||||
reader would otherwise get no signal that a code is now waiting.
|
||||
This stays mounted for every step so the announcement is reliable
|
||||
(a region added at the same time as its text often isn't spoken)
|
||||
and is visually hidden, so it changes nothing on screen. */}
|
||||
<p aria-live="polite" className="sr-only" data-testid="pairing-status">
|
||||
{step === "sas" && sasCode
|
||||
? `Verification code ${sasCode.slice(0, 3)} ${sasCode.slice(3, 6)} ready. Check that it matches on your mobile device, then confirm the codes match.`
|
||||
: step === "transferring"
|
||||
? "Codes confirmed. Pairing your mobile device."
|
||||
: step === "done"
|
||||
? "Your mobile app is now paired."
|
||||
: ""}
|
||||
</p>
|
||||
<SettingsOptionRow
|
||||
// Side-by-side is gated on the *card's* own width, not the viewport.
|
||||
// `sm:` fires at an 800px viewport, but the settings sidebar and
|
||||
// content padding leave the card only ~443px there — the QR column
|
||||
// and gap ate all of it, collapsing the step guidance to ~1px and
|
||||
// stretching the card past 1000px tall. 46rem is the narrowest card
|
||||
// width where the steps column still gets a readable ~294px.
|
||||
className="flex-col items-stretch justify-start gap-14 px-6 pb-4 pt-15 [@container(min-width:46rem)]:flex-row [@container(min-width:46rem)]:items-start [@container(min-width:46rem)]:px-15"
|
||||
data-testid="mobile-pairing-layout"
|
||||
>
|
||||
<div className="flex w-[266px] max-w-full shrink-0 flex-col gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[266px] w-[266px] max-w-full items-center justify-center rounded-lg border",
|
||||
step === "sas" || step === "transferring" || step === "done"
|
||||
? "border-transparent bg-transparent p-0"
|
||||
: "border-border/70 bg-background p-3",
|
||||
)}
|
||||
data-testid="mobile-pairing-qr-container"
|
||||
>
|
||||
{step === "sas" && sasCode ? (
|
||||
<PairingCodeConfirmation
|
||||
onConfirm={() => void handleConfirmSas()}
|
||||
onDeny={handleDenySas}
|
||||
sasCode={sasCode}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Starting pairing...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
) : step === "transferring" ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 text-center">
|
||||
<LoaderCircle
|
||||
aria-hidden="true"
|
||||
className="h-6 w-6 animate-spin text-muted-foreground"
|
||||
data-testid="pairing-transfer-spinner"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pairing mobile device...
|
||||
</p>
|
||||
</div>
|
||||
) : step === "qr" && qrUri ? (
|
||||
<StyledQrCode
|
||||
animate
|
||||
centerImageSrc="/app-icon@2x.png"
|
||||
data-testid="mobile-pairing-qr"
|
||||
size={240}
|
||||
title="Mobile pairing QR code"
|
||||
value={qrUri}
|
||||
/>
|
||||
) : step === "expired" ? (
|
||||
<div className="flex max-w-52 origin-center animate-in flex-col items-center gap-3 text-center fade-in-0 zoom-in-95 duration-[250ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:animate-none">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pairing code expired.
|
||||
</p>
|
||||
<Button
|
||||
data-testid="regenerate-pairing-button"
|
||||
onClick={beginPairing}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw className="mr-1.5 h-4 w-4" />
|
||||
Generate new pairing code
|
||||
</Button>
|
||||
</div>
|
||||
) : step === "error" ? (
|
||||
<div className="flex max-w-52 flex-col items-center gap-3 text-center">
|
||||
<TriangleAlert className="h-6 w-6 text-destructive" />
|
||||
<p className="text-sm text-destructive">
|
||||
{error ?? "Pairing session ended."}
|
||||
</p>
|
||||
<Button
|
||||
data-testid="retry-pairing-button"
|
||||
onClick={beginPairing}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : step === "idle" ? (
|
||||
currentPubkey ? (
|
||||
<Button
|
||||
data-testid="start-pairing-button"
|
||||
onClick={beginPairing}
|
||||
type="button"
|
||||
>
|
||||
Start pairing
|
||||
</Button>
|
||||
) : (
|
||||
<p className="max-w-44 text-center text-sm text-muted-foreground">
|
||||
Sign in to generate a mobile pairing code.
|
||||
</p>
|
||||
)
|
||||
) : step === "done" ? (
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<Check className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<p className="text-base font-medium">Paired</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<LoaderCircle
|
||||
aria-hidden="true"
|
||||
className="h-6 w-6 animate-spin text-muted-foreground"
|
||||
data-testid="pairing-loading-spinner"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Starting pairing...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-8" data-testid="mobile-pairing-copy-slot">
|
||||
{step === "qr" && qrUri ? (
|
||||
<Button
|
||||
className="h-8 w-full origin-top animate-in fade-in-0 zoom-in-95 duration-[250ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:animate-none"
|
||||
data-testid="copy-pairing-code"
|
||||
onClick={handleCopy}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Copy className="mr-1.5 h-4 w-4" />
|
||||
Copy pairing code
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{step === "qr" && qrUri ? (
|
||||
<Button
|
||||
className="w-full origin-top animate-in fade-in-0 zoom-in-95 duration-[250ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:animate-none"
|
||||
data-testid="copy-pairing-code"
|
||||
onClick={handleCopy}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Copy className="mr-1.5 h-4 w-4" />
|
||||
Copy pairing code
|
||||
</Button>
|
||||
) : null}
|
||||
<PairingSteps step={step} />
|
||||
</SettingsOptionRow>
|
||||
</SettingsOptionGroup>
|
||||
|
||||
<PairingStatusDialog
|
||||
onClose={handleStatusDialogClose}
|
||||
onConfirm={() => void handleConfirmSas()}
|
||||
onDeny={handleDenySas}
|
||||
sasCode={sasCode}
|
||||
step={step}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,50 @@ test("mobile pairing starts on demand and reveals the QR code", async ({
|
||||
|
||||
const section = page.getByTestId("settings-mobile");
|
||||
const card = page.getByTestId("mobile-pairing-card");
|
||||
const layout = card.getByTestId("mobile-pairing-layout");
|
||||
const qrContainer = page.getByTestId("mobile-pairing-qr-container");
|
||||
const steps = card.getByTestId("mobile-pairing-steps");
|
||||
const scanStepIndicator = card.getByTestId(
|
||||
"mobile-pairing-scan-step-indicator",
|
||||
);
|
||||
const confirmStepIndicator = card.getByTestId(
|
||||
"mobile-pairing-confirm-step-indicator",
|
||||
);
|
||||
const finalStep = card.getByTestId("mobile-pairing-final-step");
|
||||
const startButton = card.getByTestId("start-pairing-button");
|
||||
await expect(card).toBeVisible();
|
||||
await expect(startButton).toHaveText("Start pairing");
|
||||
await expect(steps.getByText("Scan QR code", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
steps.getByText("Confirm mobile code", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
finalStep.getByText("Pair your mobile app", { exact: true }),
|
||||
).toBeVisible();
|
||||
const finalStepIndicator = finalStep.getByTestId(
|
||||
"mobile-pairing-final-step-indicator",
|
||||
);
|
||||
await expect(finalStepIndicator).toHaveText("3");
|
||||
await expect(scanStepIndicator).toHaveAttribute("data-completed", "false");
|
||||
await expect(confirmStepIndicator).toHaveAttribute("data-completed", "false");
|
||||
await expect(finalStepIndicator).toHaveCSS("width", "48px");
|
||||
await expect(finalStepIndicator).toHaveCSS("height", "48px");
|
||||
expect(
|
||||
await finalStepIndicator.evaluate((element) =>
|
||||
Number.parseFloat(getComputedStyle(element).borderRadius),
|
||||
),
|
||||
).toBeGreaterThanOrEqual(24);
|
||||
const indicatorBackground = await finalStepIndicator.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
const primaryActionBackground = await startButton.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
expect(indicatorBackground).not.toBe(primaryActionBackground);
|
||||
await expect(layout).toHaveCSS("padding-top", "60px");
|
||||
await expect(layout).toHaveCSS("padding-right", "60px");
|
||||
await expect(layout).toHaveCSS("padding-left", "60px");
|
||||
await expect(layout).toHaveCSS("column-gap", "56px");
|
||||
await expect(page.getByTestId("mobile-pairing-qr")).toHaveCount(0);
|
||||
await expect(page.getByTestId("copy-pairing-code")).toHaveCount(0);
|
||||
expect(
|
||||
@@ -64,8 +104,20 @@ test("mobile pairing starts on demand and reveals the QR code", async ({
|
||||
|
||||
const sectionBox = await section.boundingBox();
|
||||
const cardBox = await card.boundingBox();
|
||||
const initialStepsBox = await steps.boundingBox();
|
||||
expect(sectionBox).not.toBeNull();
|
||||
expect(cardBox).not.toBeNull();
|
||||
expect(initialStepsBox).not.toBeNull();
|
||||
const initialQrBox = await qrContainer.boundingBox();
|
||||
expect(initialQrBox).not.toBeNull();
|
||||
const qrTopSpace = (initialQrBox?.y ?? 0) - (cardBox?.y ?? 0);
|
||||
const qrLeftSpace = (initialQrBox?.x ?? 0) - (cardBox?.x ?? 0);
|
||||
const qrBottomSpace =
|
||||
(cardBox?.y ?? 0) +
|
||||
(cardBox?.height ?? 0) -
|
||||
((initialQrBox?.y ?? 0) + (initialQrBox?.height ?? 0));
|
||||
expect(Math.abs(qrTopSpace - qrLeftSpace)).toBeLessThan(0.5);
|
||||
expect(Math.abs(qrTopSpace - qrBottomSpace)).toBeLessThan(0.5);
|
||||
const sectionCenter = (sectionBox?.x ?? 0) + (sectionBox?.width ?? 0) / 2;
|
||||
const cardCenter = (cardBox?.x ?? 0) + (cardBox?.width ?? 0) / 2;
|
||||
expect(Math.abs(sectionCenter - cardCenter)).toBeLessThan(0.5);
|
||||
@@ -118,8 +170,19 @@ test("mobile pairing starts on demand and reveals the QR code", async ({
|
||||
await waitForAnimations(page);
|
||||
const qrBox = await qrContainer.boundingBox();
|
||||
const copyBox = await copyButton.boundingBox();
|
||||
const stepsBox = await steps.boundingBox();
|
||||
const qrCardBox = await card.boundingBox();
|
||||
expect(qrBox).not.toBeNull();
|
||||
expect(copyBox).not.toBeNull();
|
||||
expect(stepsBox).not.toBeNull();
|
||||
expect(qrCardBox).not.toBeNull();
|
||||
expect(qrBox?.x ?? 0).toBeLessThan(stepsBox?.x ?? 0);
|
||||
expect(Math.abs((stepsBox?.y ?? 0) - (initialStepsBox?.y ?? 0))).toBeLessThan(
|
||||
0.5,
|
||||
);
|
||||
expect(
|
||||
Math.abs((qrCardBox?.height ?? 0) - (cardBox?.height ?? 0)),
|
||||
).toBeLessThan(0.5);
|
||||
expect(copyBox?.y ?? 0).toBeGreaterThan(
|
||||
(qrBox?.y ?? 0) + (qrBox?.height ?? 0),
|
||||
);
|
||||
@@ -158,6 +221,178 @@ test("mobile pairing starts on demand and reveals the QR code", async ({
|
||||
await qrCode.screenshot({ path: `${SCREENSHOT_DIR}/pairing-qr.png` });
|
||||
});
|
||||
|
||||
test("pairing completion updates the final step and resets after leaving", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("open-settings").click();
|
||||
await page.getByTestId("profile-popover-settings").click();
|
||||
await page.getByTestId("settings-nav-mobile").click();
|
||||
|
||||
const card = page.getByTestId("mobile-pairing-card");
|
||||
const finalStep = card.getByTestId("mobile-pairing-final-step");
|
||||
const scanStepIndicator = card.getByTestId(
|
||||
"mobile-pairing-scan-step-indicator",
|
||||
);
|
||||
const confirmStepIndicator = card.getByTestId(
|
||||
"mobile-pairing-confirm-step-indicator",
|
||||
);
|
||||
await card.getByTestId("start-pairing-button").click();
|
||||
await expect(page.getByTestId("mobile-pairing-qr")).toBeVisible();
|
||||
|
||||
await emitPairingEvent(page, "pairing-sas-received", { sas: "123456" });
|
||||
const confirmation = card.getByTestId("mobile-pairing-code-confirmation");
|
||||
await expect(scanStepIndicator).toHaveAttribute("data-completed", "true");
|
||||
await expect(scanStepIndicator.locator('[data-state="complete"]')).toHaveCSS(
|
||||
"opacity",
|
||||
"1",
|
||||
);
|
||||
await expect(scanStepIndicator.locator("svg")).toHaveCount(1);
|
||||
await expect(confirmStepIndicator).toHaveAttribute("data-completed", "false");
|
||||
await expect(page.getByTestId("mobile-pairing-dialog")).toHaveCount(0);
|
||||
await expect(page.getByTestId("mobile-pairing-qr")).toHaveCount(0);
|
||||
const confirmationCode = confirmation.getByTestId("pairing-sas-code");
|
||||
await expect(confirmationCode).toHaveAccessibleName(
|
||||
"Confirmation code 123 456",
|
||||
);
|
||||
await expect(
|
||||
confirmationCode.locator('[data-testid^="pairing-sas-code-digit-"]'),
|
||||
).toHaveCount(6);
|
||||
await expect(
|
||||
confirmationCode.getByTestId("pairing-sas-code-digit-1"),
|
||||
).toHaveCSS("border-radius", "12px");
|
||||
await expect(
|
||||
confirmationCode.getByTestId("pairing-sas-code-digit-1"),
|
||||
).toHaveCSS("box-shadow", "none");
|
||||
const firstCodeDigit = confirmationCode.getByTestId(
|
||||
"pairing-sas-code-digit-1",
|
||||
);
|
||||
const firstCodeDigitBox = await firstCodeDigit.boundingBox();
|
||||
expect(firstCodeDigitBox).not.toBeNull();
|
||||
expect(firstCodeDigitBox?.width ?? 0).toBeGreaterThan(32);
|
||||
expect(firstCodeDigitBox?.height ?? 0).toBeGreaterThan(48);
|
||||
const secondCodeDigitBox = await confirmationCode
|
||||
.getByTestId("pairing-sas-code-digit-2")
|
||||
.boundingBox();
|
||||
const thirdCodeDigitBox = await confirmationCode
|
||||
.getByTestId("pairing-sas-code-digit-3")
|
||||
.boundingBox();
|
||||
const fourthCodeDigitBox = await confirmationCode
|
||||
.getByTestId("pairing-sas-code-digit-4")
|
||||
.boundingBox();
|
||||
expect(secondCodeDigitBox).not.toBeNull();
|
||||
expect(thirdCodeDigitBox).not.toBeNull();
|
||||
expect(fourthCodeDigitBox).not.toBeNull();
|
||||
const regularDigitGap =
|
||||
(thirdCodeDigitBox?.x ?? 0) -
|
||||
((secondCodeDigitBox?.x ?? 0) + (secondCodeDigitBox?.width ?? 0));
|
||||
const groupedDigitGap =
|
||||
(fourthCodeDigitBox?.x ?? 0) -
|
||||
((thirdCodeDigitBox?.x ?? 0) + (thirdCodeDigitBox?.width ?? 0));
|
||||
expect(groupedDigitGap).toBeGreaterThan(regularDigitGap);
|
||||
await expect(card.getByTestId("mobile-pairing-qr-container")).toHaveCSS(
|
||||
"border-color",
|
||||
"rgba(0, 0, 0, 0)",
|
||||
);
|
||||
const confirmButton = confirmation.getByTestId("confirm-sas");
|
||||
const cancelButton = confirmation.getByTestId("deny-sas");
|
||||
const confirmationBox = await confirmation.boundingBox();
|
||||
const confirmationTitleBox = await confirmation
|
||||
.getByTestId("pairing-sas-title")
|
||||
.boundingBox();
|
||||
const confirmationCodeBox = await confirmationCode.boundingBox();
|
||||
const confirmationActionsBox = await confirmation
|
||||
.getByTestId("pairing-sas-actions")
|
||||
.boundingBox();
|
||||
expect(confirmationBox).not.toBeNull();
|
||||
expect(confirmationTitleBox).not.toBeNull();
|
||||
expect(confirmationCodeBox).not.toBeNull();
|
||||
expect(confirmationActionsBox).not.toBeNull();
|
||||
expect(
|
||||
Math.abs((confirmationTitleBox?.y ?? 0) - (confirmationBox?.y ?? 0)),
|
||||
).toBeLessThan(0.5);
|
||||
const codeCenter =
|
||||
(confirmationCodeBox?.y ?? 0) + (confirmationCodeBox?.height ?? 0) / 2;
|
||||
const availableCodeCenter =
|
||||
((confirmationTitleBox?.y ?? 0) +
|
||||
(confirmationTitleBox?.height ?? 0) +
|
||||
(confirmationActionsBox?.y ?? 0)) /
|
||||
2;
|
||||
expect(Math.abs(availableCodeCenter - codeCenter)).toBeLessThan(0.5);
|
||||
expect(
|
||||
Math.abs(
|
||||
(confirmationActionsBox?.y ?? 0) +
|
||||
(confirmationActionsBox?.height ?? 0) -
|
||||
((confirmationBox?.y ?? 0) + (confirmationBox?.height ?? 0)),
|
||||
),
|
||||
).toBeLessThan(0.5);
|
||||
await expect(confirmButton).toHaveCSS("height", "36px");
|
||||
await expect(cancelButton).toHaveCSS("height", "36px");
|
||||
await expect(confirmButton.locator("svg")).toHaveCount(1);
|
||||
await expect(cancelButton.locator("svg")).toHaveCount(0);
|
||||
const confirmBox = await confirmButton.boundingBox();
|
||||
const cancelBox = await cancelButton.boundingBox();
|
||||
expect(confirmBox).not.toBeNull();
|
||||
expect(cancelBox).not.toBeNull();
|
||||
expect(confirmBox?.y ?? 0).toBeLessThan(cancelBox?.y ?? 0);
|
||||
await expect(
|
||||
confirmation.getByText(/Only confirm if you started this pairing/),
|
||||
).toHaveCount(0);
|
||||
await expect(confirmation.getByTestId("pairing-sas-title")).toHaveCSS(
|
||||
"font-size",
|
||||
"16px",
|
||||
);
|
||||
|
||||
mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
await waitForAnimations(page);
|
||||
await card.screenshot({
|
||||
path: `${SCREENSHOT_DIR}/pairing-code-confirmation.png`,
|
||||
});
|
||||
|
||||
await confirmation.getByTestId("confirm-sas").click();
|
||||
await expect(card.getByText("Pairing mobile device...")).toBeVisible();
|
||||
await expect(confirmStepIndicator).toHaveAttribute("data-completed", "true");
|
||||
await expect(
|
||||
confirmStepIndicator.locator('[data-state="complete"]'),
|
||||
).toHaveCSS("opacity", "1");
|
||||
await expect(confirmStepIndicator.locator("svg")).toHaveCount(1);
|
||||
|
||||
await emitPairingEvent(page, "pairing-complete");
|
||||
|
||||
await expect(finalStep.getByText("Paired", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
finalStep.getByText("Your mobile app is now connected to this relay."),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
finalStep.getByTestId("mobile-pairing-final-step-indicator").locator("svg"),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
finalStep.getByTestId("mobile-pairing-final-step-indicator"),
|
||||
).toHaveAttribute("data-completed", "true");
|
||||
const pairedSurface = card.getByTestId("mobile-pairing-qr-container");
|
||||
await expect(
|
||||
pairedSurface.getByText("Paired", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
pairedSurface.getByText("Your mobile app is now connected to this relay."),
|
||||
).toHaveCount(0);
|
||||
|
||||
await waitForAnimations(page);
|
||||
await card.screenshot({ path: `${SCREENSHOT_DIR}/pairing-complete.png` });
|
||||
|
||||
await page.getByTestId("settings-nav-updates").click();
|
||||
await page.getByTestId("settings-nav-mobile").click();
|
||||
|
||||
const restartedCard = page.getByTestId("mobile-pairing-card");
|
||||
await expect(restartedCard.getByTestId("start-pairing-button")).toBeVisible();
|
||||
await expect(
|
||||
restartedCard
|
||||
.getByTestId("mobile-pairing-final-step")
|
||||
.getByText("Pair your mobile app", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-pairing-qr")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("late pairing events are ignored after canceling", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("open-settings").click();
|
||||
@@ -169,17 +404,48 @@ test("late pairing events are ignored after canceling", async ({ page }) => {
|
||||
await expect(page.getByTestId("mobile-pairing-qr")).toBeVisible();
|
||||
|
||||
await emitPairingEvent(page, "pairing-sas-received", { sas: "123456" });
|
||||
const dialog = page.getByTestId("mobile-pairing-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Close" }).click();
|
||||
const confirmation = card.getByTestId("mobile-pairing-code-confirmation");
|
||||
await expect(confirmation).toBeVisible();
|
||||
await confirmation.getByTestId("deny-sas").click();
|
||||
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(card.getByText("Pairing was canceled.")).toBeVisible();
|
||||
await expect(confirmation).toHaveCount(0);
|
||||
await expect(
|
||||
card.getByText("The codes didn't match. Pairing was canceled."),
|
||||
).toBeVisible();
|
||||
|
||||
await emitPairingEvent(page, "pairing-complete");
|
||||
await emitPairingEvent(page, "pairing-sas-received", { sas: "654321" });
|
||||
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.getByTestId("mobile-pairing-done")).toHaveCount(0);
|
||||
await expect(card.getByText("Pairing was canceled.")).toBeVisible();
|
||||
await expect(confirmation).toHaveCount(0);
|
||||
await expect(
|
||||
card
|
||||
.getByTestId("mobile-pairing-final-step")
|
||||
.getByText("Paired", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
card.getByText("The codes didn't match. Pairing was canceled."),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("step completion respects reduced motion", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.goto("/");
|
||||
await page.getByTestId("open-settings").click();
|
||||
await page.getByTestId("profile-popover-settings").click();
|
||||
await page.getByTestId("settings-nav-mobile").click();
|
||||
|
||||
const card = page.getByTestId("mobile-pairing-card");
|
||||
const scanStepIndicator = card.getByTestId(
|
||||
"mobile-pairing-scan-step-indicator",
|
||||
);
|
||||
await card.getByTestId("start-pairing-button").click();
|
||||
await expect(page.getByTestId("mobile-pairing-qr")).toBeVisible();
|
||||
|
||||
await emitPairingEvent(page, "pairing-sas-received", { sas: "123456" });
|
||||
|
||||
await expect(scanStepIndicator).toHaveAttribute("data-completed", "true");
|
||||
await expect(scanStepIndicator).toHaveCSS("transition-property", "none");
|
||||
const completedContent = scanStepIndicator.locator('[data-state="complete"]');
|
||||
await expect(completedContent).toHaveCSS("opacity", "1");
|
||||
await expect(completedContent).toHaveCSS("transform", "none");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user