diff --git a/LOTUS_TESTING.md b/LOTUS_TESTING.md index dfa0441d5..0c41a5323 100644 --- a/LOTUS_TESTING.md +++ b/LOTUS_TESTING.md @@ -675,6 +675,8 @@ Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view, ## Outstanding verification backlog +**QR Device Verification (2026-07):** With two logged-in Lotus sessions (or Lotus + Element), start a device verification. On the **Ready** step you now see your own QR code plus a **"Scan their QR code"** button and a **"Verify with emoji instead"** fallback. Have one device **scan** the other's code (grant camera permission) → the showing device asks you to **Confirm**, and both reach **verified**. Check: emoji-SAS still works unchanged; denying camera shows a graceful "verify with emojis instead" message; a deliberately-wrong scan cancels cleanly. Desktop (WebView2) auto-grants the camera; web needs the Permissions-Policy camera allowance (already set). + **Disappearing Messages (MSC1763 `m.room.retention`, 2026-07):** In Room Settings → General → **Message Retention**, an admin picks Off / 1 Day / 1 Week / 1 Month (non-admins see the buttons disabled). After setting e.g. 1 Day, messages older than a day **vanish from the timeline** for everyone in Lotus (toggle Settings → General → **Show Hidden Events** to reveal them again). Setting back to **Off** restores them. Separately, each user can enable Settings → General → **Enforce Message Retention** (default OFF) → their OWN expired messages then get **permanently redacted** within ~30 s (verify: OTHER people's messages are NEVER redacted by this; only your own). Note true server-side purge also needs Synapse `retention:` configured. **Mark as Unread + Low Priority (MSC2867 / m.lowpriority, 2026-07):** Right-click a room in the sidebar → **Mark as Unread** puts a dot on the row (bold name) even with no new messages; opening/reading the room clears it, and it syncs to another device. **Mark as Read** on a marked room clears it too. Right-click → **Add to Low Priority** moves the room into a collapsed "Low Priority" category at the bottom of the room list (and removes it from Favorites if it was there, and vice-versa); **Remove from Low Priority** returns it to Rooms. diff --git a/LOTUS_TODO.md b/LOTUS_TODO.md index 07f9bc733..89119fba8 100644 --- a/LOTUS_TODO.md +++ b/LOTUS_TODO.md @@ -100,10 +100,10 @@ Genuine Matrix client-spec / MSC features Lotus does **not** yet implement (audi - [x] **Mark as Unread — MSC2867 `m.marked_unread`.** Room account data `{ unread: true }` (+ unstable `com.famedly.marked_unread`) via `mx.setRoomAccountData`; clear on read. Context-menu item in `RoomNavItem` + light the existing unread dot; integrate `state/room/roomToUnread.ts`. - [x] **Low Priority rooms — `m.lowpriority` tag.** Mirror the favourite impl (`RoomNavItem.tsx:331-337` `setRoomTag/deleteRoomTag` + the favourites category in `home/Home.tsx`): context-menu toggle + a collapsed "Low Priority" category sorted to the bottom, excluded from normal unread nudging. -**Phase B:** +**Phase B ✅ (2026-07, gate-green 688 tests):** -- [ ] **Disappearing Messages — MSC1763 `m.room.retention`.** PL-gated room-settings `SettingTile` to set `{ max_lifetime }`; retention badge; a client-side sweep hides/self-redacts own expired events (pattern like the mute-timer restore in `ClientNonUIFeatures.tsx`). True server deletion also wants Synapse `retention:` (LXC 151). -- [ ] **QR Device Verification — reciprocate QR.** Add the QR path beside emoji-SAS in `components/DeviceVerification.tsx`: render with `qrcode.react` (already a dep), scan via `BarcodeDetector` (fallback `jsQR`); uses the SDK `VerificationRequest` QR/reciprocate support. +- [x] **Disappearing Messages — MSC1763 `m.room.retention`.** PL-gated room-settings `SettingTile` to set `{ max_lifetime }`; retention badge; a client-side sweep hides/self-redacts own expired events (pattern like the mute-timer restore in `ClientNonUIFeatures.tsx`). True server deletion also wants Synapse `retention:` (LXC 151). +- [x] **QR Device Verification — reciprocate QR.** Add the QR path beside emoji-SAS in `components/DeviceVerification.tsx`: render with `qrcode.react` (already a dep), scan via `BarcodeDetector` (fallback `jsQR`); uses the SDK `VerificationRequest` QR/reciprocate support. **Phase C (large — each its own planning session):** diff --git a/package-lock.json b/package-lock.json index 2edf42049..cbc0207b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "immer": "11.1.8", "is-hotkey": "0.2.0", "jotai": "2.20.0", + "jsqr": "1.4.0", "katex": "0.16.11", "linkify-react": "4.3.3", "linkifyjs": "4.3.3", @@ -57,6 +58,7 @@ "millify": "6.1.0", "pdfjs-dist": "5.7.284", "prismjs": "1.30.0", + "qrcode": "1.5.4", "qrcode.react": "4.2.0", "react": "19.2.6", "react-aria": "3.48.0", @@ -87,6 +89,7 @@ "@types/katex": "0.16.8", "@types/node": "25.9.1", "@types/prismjs": "1.26.6", + "@types/qrcode": "1.5.6", "@types/react": "19.2.15", "@types/react-dom": "19.2.3", "@types/react-google-recaptcha": "2.1.9", @@ -3990,6 +3993,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.15", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", @@ -5171,6 +5184,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelize": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", @@ -5965,6 +5987,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -6108,6 +6139,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/direction": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz", @@ -9057,6 +9094,12 @@ "node": ">=0.10.0" } }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "license": "Apache-2.0" + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -10500,6 +10543,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -10537,7 +10589,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "engines": { "node": ">=8" } @@ -10652,6 +10703,15 @@ "pathe": "^2.0.1" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", @@ -10759,6 +10819,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qrcode.react": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", @@ -10768,6 +10845,124 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/raf-schd": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", @@ -11188,6 +11383,12 @@ "node": "*" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -11518,6 +11719,12 @@ "node": ">=20.0.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -12983,6 +13190,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", diff --git a/package.json b/package.json index e9b7a9cbf..866493a44 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "immer": "11.1.8", "is-hotkey": "0.2.0", "jotai": "2.20.0", + "jsqr": "1.4.0", "katex": "0.16.11", "linkify-react": "4.3.3", "linkifyjs": "4.3.3", @@ -82,6 +83,7 @@ "millify": "6.1.0", "pdfjs-dist": "5.7.284", "prismjs": "1.30.0", + "qrcode": "1.5.4", "qrcode.react": "4.2.0", "react": "19.2.6", "react-aria": "3.48.0", @@ -112,6 +114,7 @@ "@types/katex": "0.16.8", "@types/node": "25.9.1", "@types/prismjs": "1.26.6", + "@types/qrcode": "1.5.6", "@types/react": "19.2.15", "@types/react-dom": "19.2.3", "@types/react-google-recaptcha": "2.1.9", diff --git a/src/app/components/DeviceVerification.tsx b/src/app/components/DeviceVerification.tsx index f0f327b02..f2a348711 100644 --- a/src/app/components/DeviceVerification.tsx +++ b/src/app/components/DeviceVerification.tsx @@ -1,12 +1,14 @@ import { + ShowQrCodeCallbacks, ShowSasCallbacks, VerificationPhase, VerificationRequest, Verifier, } from 'matrix-js-sdk/lib/crypto-api'; -import React, { CSSProperties, useCallback, useEffect, useState } from 'react'; +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, @@ -27,11 +29,13 @@ 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}`, @@ -97,32 +101,6 @@ function VerificationAccept({ onAccept }: VerificationAcceptProps) { ); } -function VerificationWaitStart() { - const { t } = useTranslation(); - return ( - - {t('Organisms.DeviceVerification.request_accepted')} - - - ); -} - -type VerificationStartProps = { - onStart: () => Promise; -}; -function AutoVerificationStart({ onStart }: VerificationStartProps) { - const { t } = useTranslation(); - useEffect(() => { - onStart(); - }, [onStart]); - - return ( - - - - ); -} - function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) { const { t } = useTranslation(); const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData])); @@ -237,6 +215,120 @@ function VerificationCanceled({ onClose }: VerificationCanceledProps) { ); } +function QrCodeImage({ data }: { data: Uint8ClampedArray }) { + const canvasRef = useRef(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 ( + + + + ); +} + +type VerificationReadyProps = { + request: VerificationRequest; + onStartSas: () => void; + onScanned: (bytes: Uint8ClampedArray) => void; +}; +function VerificationReady({ request, onStartSas, onScanned }: VerificationReadyProps) { + const [myQr, setMyQr] = useState(); + 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 setScanning(false)} />; + } + + return ( + + {myQr && ( + + Scan this code with your other device to verify. + + + )} + + {canScanTheirs && ( + + )} + + + + ); +} + +type ReciprocateVerificationProps = { + verifier: Verifier; + onCancel: () => void; +}; +function ReciprocateVerification({ verifier, onCancel }: ReciprocateVerificationProps) { + const [qrCallbacks, setQrCallbacks] = useState(); + 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 ( + + + + ); + } + + return ( + + The other device scanned this code. Confirm it now shows as verified. + + + + + + ); +} + type DeviceVerificationProps = { request: VerificationRequest; onExit: () => void; @@ -256,6 +348,17 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps) 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 ( }> @@ -290,15 +393,20 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps) ) : ( ))} - {phase === VerificationPhase.Ready && - (request.initiatedByMe ? ( - - ) : ( - - ))} + {phase === VerificationPhase.Ready && ( + + )} {phase === VerificationPhase.Started && (request.verifier ? ( - + request.chosenMethod === VerificationMethod.Reciprocate ? ( + + ) : ( + + ) ) : ( 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(null); + const [error, setError] = useState(); + 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 ( + + + {error} + + + + ); + } + + return ( + + + Point your camera at the QR code shown on your other device. + + + + + ); +} diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 02d8fcd9e..9a8517a30 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -38,7 +38,8 @@ export const initClient = async (session: Session): Promise => { deviceId: session.deviceId, timelineSupport: true, cryptoCallbacks: cryptoCallbacks as any, - verificationMethods: ['m.sas.v1'], + // SAS (emoji) + QR-code verification (show/scan/reciprocate). + verificationMethods: ['m.sas.v1', 'm.qr_code.show.v1', 'm.qr_code.scan.v1', 'm.reciprocate.v1'], tokenRefreshFunction: oidcRefresher ? (refreshToken) => oidcRefresher.doRefreshAccessToken(refreshToken) : undefined,