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>
102 lines
3.1 KiB
TypeScript
102 lines
3.1 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { Box, Button, color, config, Text } from 'folds';
|
|
import jsQR from 'jsqr';
|
|
|
|
type QrScannerProps = {
|
|
onScan: (bytes: Uint8ClampedArray) => void;
|
|
onCancel: () => void;
|
|
};
|
|
|
|
// Camera QR scanner. Decodes frames with jsQR and hands back the raw byte
|
|
// segment (`result.binaryData`) — Matrix QR verification needs the raw bytes,
|
|
// not a decoded string, so the string-only `BarcodeDetector` can't be used.
|
|
export function QrScanner({ onScan, onCancel }: QrScannerProps) {
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const [error, setError] = useState<string>();
|
|
const doneRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
let stream: MediaStream | undefined;
|
|
let raf = 0;
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
|
|
const tick = () => {
|
|
const video = videoRef.current;
|
|
if (!doneRef.current && video && ctx && video.readyState === video.HAVE_ENOUGH_DATA) {
|
|
canvas.width = video.videoWidth;
|
|
canvas.height = video.videoHeight;
|
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
const result = jsQR(image.data, image.width, image.height);
|
|
if (result && result.binaryData.length > 0) {
|
|
doneRef.current = true;
|
|
onScan(new Uint8ClampedArray(result.binaryData));
|
|
return;
|
|
}
|
|
}
|
|
raf = requestAnimationFrame(tick);
|
|
};
|
|
|
|
(async () => {
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: 'environment' },
|
|
});
|
|
if (videoRef.current) {
|
|
videoRef.current.srcObject = stream;
|
|
await videoRef.current.play();
|
|
}
|
|
raf = requestAnimationFrame(tick);
|
|
} catch {
|
|
setError(
|
|
'Could not access the camera. Grant camera permission, or verify with emojis instead.',
|
|
);
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
doneRef.current = true;
|
|
cancelAnimationFrame(raf);
|
|
stream?.getTracks().forEach((track) => track.stop());
|
|
};
|
|
}, [onScan]);
|
|
|
|
if (error) {
|
|
return (
|
|
<Box direction="Column" gap="400">
|
|
<Text style={{ color: color.Critical.Main }} size="T300">
|
|
{error}
|
|
</Text>
|
|
<Button variant="Secondary" fill="Soft" onClick={onCancel}>
|
|
<Text size="B400">Back</Text>
|
|
</Button>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box direction="Column" gap="400" alignItems="Center">
|
|
<Text size="T300" align="Center">
|
|
Point your camera at the QR code shown on your other device.
|
|
</Text>
|
|
<video
|
|
ref={videoRef}
|
|
muted
|
|
playsInline
|
|
style={{
|
|
width: '100%',
|
|
maxWidth: 280,
|
|
borderRadius: config.radii.R400,
|
|
background: '#000',
|
|
}}
|
|
>
|
|
<track kind="captions" />
|
|
</video>
|
|
<Button variant="Secondary" fill="Soft" onClick={onCancel}>
|
|
<Text size="B400">Cancel</Text>
|
|
</Button>
|
|
</Box>
|
|
);
|
|
}
|