17bd50cc4e
B2 of the Matrix protocol-gaps roadmap, gate-green (688 tests): - Enable QR verification methods (show/scan/reciprocate) in initMatrix. - Extend DeviceVerification: the Ready step offers your own QR (byte-mode encode via qrcode), a camera 'Scan their QR code' flow, and an emoji fallback; the Started step routes reciprocate → a confirm step (useVerifierShowReciprocateQr) or SAS as before. - New QrScanner component: getUserMedia + jsQR, handing the raw binaryData bytes to request.scanQRCode (BarcodeDetector is string-only, so can't be used). - Adds qrcode + jsqr (small, pure-JS, client-only); build-verified under rolldown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
441 lines
13 KiB
TypeScript
441 lines
13 KiB
TypeScript
import {
|
|
ShowQrCodeCallbacks,
|
|
ShowSasCallbacks,
|
|
VerificationPhase,
|
|
VerificationRequest,
|
|
Verifier,
|
|
} from 'matrix-js-sdk/lib/crypto-api';
|
|
import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { VerificationMethod } from 'matrix-js-sdk/lib/types';
|
|
import QRCode from 'qrcode';
|
|
import {
|
|
Box,
|
|
Button,
|
|
config,
|
|
Dialog,
|
|
Header,
|
|
Icon,
|
|
IconButton,
|
|
Icons,
|
|
Overlay,
|
|
OverlayBackdrop,
|
|
OverlayCenter,
|
|
Spinner,
|
|
Text,
|
|
} from 'folds';
|
|
import FocusTrap from 'focus-trap-react';
|
|
import {
|
|
useVerificationRequestPhase,
|
|
useVerificationRequestReceived,
|
|
useVerifierCancel,
|
|
useVerifierShowReciprocateQr,
|
|
useVerifierShowSas,
|
|
} from '../hooks/useVerificationRequest';
|
|
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
|
import { ContainerColor } from '../styles/ContainerColor.css';
|
|
import { useModalStyle } from '../hooks/useModalStyle';
|
|
import { QrScanner } from './QrScanner';
|
|
|
|
const DialogHeaderStyles: CSSProperties = {
|
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
|
borderBottomWidth: config.borderWidth.B300,
|
|
};
|
|
|
|
type WaitingMessageProps = {
|
|
message: string;
|
|
};
|
|
function WaitingMessage({ message }: WaitingMessageProps) {
|
|
return (
|
|
<Box alignItems="Center" gap="200">
|
|
<Spinner variant="Secondary" size="200" />
|
|
<Text size="T300">{message}</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
type VerificationUnexpectedProps = { message: string; onClose: () => void };
|
|
function VerificationUnexpected({ message, onClose }: VerificationUnexpectedProps) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<Box direction="Column" gap="400">
|
|
<Text>{message}</Text>
|
|
<Button variant="Secondary" fill="Soft" onClick={onClose}>
|
|
<Text size="B400">{t('Organisms.DeviceVerification.close')}</Text>
|
|
</Button>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function VerificationWaitAccept() {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<Box direction="Column" gap="400">
|
|
<Text>{t('Organisms.DeviceVerification.please_accept')}</Text>
|
|
<WaitingMessage message={t('Organisms.DeviceVerification.waiting_accept')} />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
type VerificationAcceptProps = {
|
|
onAccept: () => Promise<void>;
|
|
};
|
|
function VerificationAccept({ onAccept }: VerificationAcceptProps) {
|
|
const { t } = useTranslation();
|
|
const [acceptState, accept] = useAsyncCallback(onAccept);
|
|
|
|
const accepting = acceptState.status === AsyncStatus.Loading;
|
|
return (
|
|
<Box direction="Column" gap="400">
|
|
<Text>{t('Organisms.DeviceVerification.click_accept')}</Text>
|
|
<Button
|
|
variant="Primary"
|
|
fill="Solid"
|
|
onClick={accept}
|
|
before={accepting && <Spinner size="100" variant="Primary" fill="Solid" />}
|
|
disabled={accepting}
|
|
>
|
|
<Text size="B400">{t('Organisms.DeviceVerification.accept')}</Text>
|
|
</Button>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
|
const { t } = useTranslation();
|
|
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
|
|
|
|
const confirming =
|
|
confirmState.status === AsyncStatus.Loading || confirmState.status === AsyncStatus.Success;
|
|
|
|
return (
|
|
<Box direction="Column" gap="400">
|
|
<Text>{t('Organisms.DeviceVerification.confirm_emoji')}</Text>
|
|
<Box
|
|
className={ContainerColor({ variant: 'SurfaceVariant' })}
|
|
style={{
|
|
borderRadius: config.radii.R400,
|
|
padding: config.space.S500,
|
|
}}
|
|
gap="700"
|
|
wrap="Wrap"
|
|
justifyContent="Center"
|
|
>
|
|
{sasData.sas.emoji?.map(([emoji, name], index) => (
|
|
<Box
|
|
key={`${emoji}${name}${index}`}
|
|
direction="Column"
|
|
gap="100"
|
|
justifyContent="Center"
|
|
alignItems="Center"
|
|
>
|
|
<Text size="H1">{emoji}</Text>
|
|
<Text size="T200">{name}</Text>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
<Box direction="Column" gap="200">
|
|
<Button
|
|
variant="Primary"
|
|
fill="Soft"
|
|
onClick={confirm}
|
|
disabled={confirming}
|
|
before={confirming && <Spinner size="100" variant="Primary" />}
|
|
>
|
|
<Text size="B400">{t('Organisms.DeviceVerification.they_match')}</Text>
|
|
</Button>
|
|
<Button
|
|
variant="Primary"
|
|
fill="Soft"
|
|
onClick={() => sasData.mismatch()}
|
|
disabled={confirming}
|
|
>
|
|
<Text size="B400">{t('Organisms.DeviceVerification.do_not_match')}</Text>
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
type SasVerificationProps = {
|
|
verifier: Verifier;
|
|
onCancel: () => void;
|
|
};
|
|
function SasVerification({ verifier, onCancel }: SasVerificationProps) {
|
|
const { t } = useTranslation();
|
|
const [sasData, setSasData] = useState<ShowSasCallbacks>();
|
|
|
|
useVerifierShowSas(verifier, setSasData);
|
|
useVerifierCancel(verifier, onCancel);
|
|
|
|
useEffect(() => {
|
|
verifier.verify();
|
|
}, [verifier]);
|
|
|
|
if (sasData) {
|
|
return <CompareEmoji sasData={sasData} />;
|
|
}
|
|
|
|
return (
|
|
<Box direction="Column" gap="400">
|
|
<WaitingMessage message={t('Organisms.DeviceVerification.starting_emoji')} />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
type VerificationDoneProps = {
|
|
onExit: () => void;
|
|
};
|
|
function VerificationDone({ onExit }: VerificationDoneProps) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<Box direction="Column" gap="400">
|
|
<div>
|
|
<Text>{t('Organisms.DeviceVerification.device_verified')}</Text>
|
|
</div>
|
|
<Button variant="Primary" fill="Solid" onClick={onExit}>
|
|
<Text size="B400">{t('Organisms.DeviceVerification.okay')}</Text>
|
|
</Button>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
type VerificationCanceledProps = {
|
|
onClose: () => void;
|
|
};
|
|
function VerificationCanceled({ onClose }: VerificationCanceledProps) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<Box direction="Column" gap="400">
|
|
<Text>{t('Organisms.DeviceVerification.verification_canceled')}</Text>
|
|
<Button variant="Secondary" fill="Soft" onClick={onClose}>
|
|
<Text size="B400">{t('Organisms.DeviceVerification.close')}</Text>
|
|
</Button>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function QrCodeImage({ data }: { data: Uint8ClampedArray }) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
// Byte-mode so the raw verification bytes round-trip (a string value would
|
|
// mangle high bytes via UTF-8).
|
|
QRCode.toCanvas(canvas, [{ data: new Uint8Array(data), mode: 'byte' }], {
|
|
width: 220,
|
|
margin: 2,
|
|
color: { dark: '#000000', light: '#ffffff' },
|
|
}).catch(() => undefined);
|
|
}, [data]);
|
|
return (
|
|
<Box justifyContent="Center">
|
|
<canvas ref={canvasRef} style={{ borderRadius: config.radii.R300 }} />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
type VerificationReadyProps = {
|
|
request: VerificationRequest;
|
|
onStartSas: () => void;
|
|
onScanned: (bytes: Uint8ClampedArray) => void;
|
|
};
|
|
function VerificationReady({ request, onStartSas, onScanned }: VerificationReadyProps) {
|
|
const [myQr, setMyQr] = useState<Uint8ClampedArray>();
|
|
const [scanning, setScanning] = useState(false);
|
|
const canShowMine = request.otherPartySupportsMethod(VerificationMethod.ScanQrCode);
|
|
const canScanTheirs = request.otherPartySupportsMethod(VerificationMethod.ShowQrCode);
|
|
|
|
useEffect(() => {
|
|
if (!canShowMine) return;
|
|
request
|
|
.generateQRCode()
|
|
.then((bytes) => {
|
|
if (bytes) setMyQr(bytes);
|
|
})
|
|
.catch(() => undefined);
|
|
}, [request, canShowMine]);
|
|
|
|
if (scanning) {
|
|
return <QrScanner onScan={onScanned} onCancel={() => setScanning(false)} />;
|
|
}
|
|
|
|
return (
|
|
<Box direction="Column" gap="400">
|
|
{myQr && (
|
|
<Box direction="Column" gap="200">
|
|
<Text size="T300">Scan this code with your other device to verify.</Text>
|
|
<QrCodeImage data={myQr} />
|
|
</Box>
|
|
)}
|
|
<Box direction="Column" gap="200">
|
|
{canScanTheirs && (
|
|
<Button variant="Primary" fill="Solid" onClick={() => setScanning(true)}>
|
|
<Text size="B400">Scan their QR code</Text>
|
|
</Button>
|
|
)}
|
|
<Button variant="Secondary" fill="Soft" onClick={onStartSas}>
|
|
<Text size="B400">Verify with emoji instead</Text>
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
type ReciprocateVerificationProps = {
|
|
verifier: Verifier;
|
|
onCancel: () => void;
|
|
};
|
|
function ReciprocateVerification({ verifier, onCancel }: ReciprocateVerificationProps) {
|
|
const [qrCallbacks, setQrCallbacks] = useState<ShowQrCodeCallbacks>();
|
|
const [confirmState, confirm] = useAsyncCallback(
|
|
useCallback(async () => qrCallbacks?.confirm(), [qrCallbacks]),
|
|
);
|
|
|
|
useVerifierShowReciprocateQr(verifier, setQrCallbacks);
|
|
useVerifierCancel(verifier, onCancel);
|
|
|
|
const confirming =
|
|
confirmState.status === AsyncStatus.Loading || confirmState.status === AsyncStatus.Success;
|
|
|
|
// The showing side gets ShowReciprocateQr callbacks after the other device
|
|
// scans; the scanning side never does (it already called verify()) and just
|
|
// waits for completion.
|
|
if (!qrCallbacks) {
|
|
return (
|
|
<Box direction="Column" gap="400">
|
|
<WaitingMessage message="Verifying…" />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box direction="Column" gap="400">
|
|
<Text>The other device scanned this code. Confirm it now shows as verified.</Text>
|
|
<Box direction="Column" gap="200">
|
|
<Button variant="Primary" fill="Soft" onClick={confirm} disabled={confirming}>
|
|
<Text size="B400">Confirm</Text>
|
|
</Button>
|
|
<Button
|
|
variant="Primary"
|
|
fill="Soft"
|
|
onClick={() => qrCallbacks.cancel()}
|
|
disabled={confirming}
|
|
>
|
|
<Text size="B400">Cancel</Text>
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
type DeviceVerificationProps = {
|
|
request: VerificationRequest;
|
|
onExit: () => void;
|
|
};
|
|
export function DeviceVerification({ request, onExit }: DeviceVerificationProps) {
|
|
const phase = useVerificationRequestPhase(request);
|
|
const modalStyle = useModalStyle(480);
|
|
|
|
const handleCancel = useCallback(() => {
|
|
if (request.phase !== VerificationPhase.Done && request.phase !== VerificationPhase.Cancelled) {
|
|
request.cancel();
|
|
}
|
|
onExit();
|
|
}, [request, onExit]);
|
|
|
|
const handleAccept = useCallback(() => request.accept(), [request]);
|
|
const handleStart = useCallback(async () => {
|
|
await request.startVerification(VerificationMethod.Sas);
|
|
}, [request]);
|
|
const handleScanned = useCallback(
|
|
async (bytes: Uint8ClampedArray) => {
|
|
try {
|
|
const verifier = await request.scanQRCode(bytes);
|
|
await verifier.verify();
|
|
} catch {
|
|
// A bad/mismatched scan cancels the request; the Cancelled phase renders.
|
|
}
|
|
},
|
|
[request],
|
|
);
|
|
|
|
return (
|
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
|
<OverlayCenter>
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
clickOutsideDeactivates: false,
|
|
escapeDeactivates: false,
|
|
}}
|
|
>
|
|
<Dialog variant="Surface" style={modalStyle}>
|
|
<Header style={DialogHeaderStyles} variant="Surface" size="500">
|
|
<Box grow="Yes">
|
|
<Text as="h2" size="H4">
|
|
Device Verification
|
|
</Text>
|
|
</Box>
|
|
<IconButton
|
|
size="300"
|
|
radii="300"
|
|
onClick={handleCancel}
|
|
aria-label="Cancel verification"
|
|
>
|
|
<Icon src={Icons.Cross} />
|
|
</IconButton>
|
|
</Header>
|
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
|
{phase === VerificationPhase.Requested &&
|
|
(request.initiatedByMe ? (
|
|
<VerificationWaitAccept />
|
|
) : (
|
|
<VerificationAccept onAccept={handleAccept} />
|
|
))}
|
|
{phase === VerificationPhase.Ready && (
|
|
<VerificationReady
|
|
request={request}
|
|
onStartSas={handleStart}
|
|
onScanned={handleScanned}
|
|
/>
|
|
)}
|
|
{phase === VerificationPhase.Started &&
|
|
(request.verifier ? (
|
|
request.chosenMethod === VerificationMethod.Reciprocate ? (
|
|
<ReciprocateVerification verifier={request.verifier} onCancel={handleCancel} />
|
|
) : (
|
|
<SasVerification verifier={request.verifier} onCancel={handleCancel} />
|
|
)
|
|
) : (
|
|
<VerificationUnexpected
|
|
message="Unexpected Error! Verification is started but verifier is missing."
|
|
onClose={handleCancel}
|
|
/>
|
|
))}
|
|
{phase === VerificationPhase.Done && <VerificationDone onExit={onExit} />}
|
|
{phase === VerificationPhase.Cancelled && (
|
|
<VerificationCanceled onClose={handleCancel} />
|
|
)}
|
|
</Box>
|
|
</Dialog>
|
|
</FocusTrap>
|
|
</OverlayCenter>
|
|
</Overlay>
|
|
);
|
|
}
|
|
|
|
export function ReceiveSelfDeviceVerification() {
|
|
const [request, setRequest] = useState<VerificationRequest>();
|
|
|
|
useVerificationRequestReceived(setRequest);
|
|
|
|
const handleExit = useCallback(() => {
|
|
setRequest(undefined);
|
|
}, []);
|
|
|
|
if (!request) return null;
|
|
|
|
return <DeviceVerification request={request} onExit={handleExit} />;
|
|
}
|