feat(crypto): QR-code device verification (alongside emoji SAS)
CI / Build & Quality Checks (push) Successful in 11m7s
CI / Trigger Desktop Build (push) Successful in 7s

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>
This commit is contained in:
2026-07-03 11:30:23 -04:00
parent 82e52e1bc7
commit 17bd50cc4e
7 changed files with 467 additions and 39 deletions
+2
View File
@@ -675,6 +675,8 @@ Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view,
## Outstanding verification backlog ## 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. **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. **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.
+3 -3
View File
@@ -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] **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. - [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). - [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).
- [ ] **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] **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):** **Phase C (large — each its own planning session):**
+214 -1
View File
@@ -49,6 +49,7 @@
"immer": "11.1.8", "immer": "11.1.8",
"is-hotkey": "0.2.0", "is-hotkey": "0.2.0",
"jotai": "2.20.0", "jotai": "2.20.0",
"jsqr": "1.4.0",
"katex": "0.16.11", "katex": "0.16.11",
"linkify-react": "4.3.3", "linkify-react": "4.3.3",
"linkifyjs": "4.3.3", "linkifyjs": "4.3.3",
@@ -57,6 +58,7 @@
"millify": "6.1.0", "millify": "6.1.0",
"pdfjs-dist": "5.7.284", "pdfjs-dist": "5.7.284",
"prismjs": "1.30.0", "prismjs": "1.30.0",
"qrcode": "1.5.4",
"qrcode.react": "4.2.0", "qrcode.react": "4.2.0",
"react": "19.2.6", "react": "19.2.6",
"react-aria": "3.48.0", "react-aria": "3.48.0",
@@ -87,6 +89,7 @@
"@types/katex": "0.16.8", "@types/katex": "0.16.8",
"@types/node": "25.9.1", "@types/node": "25.9.1",
"@types/prismjs": "1.26.6", "@types/prismjs": "1.26.6",
"@types/qrcode": "1.5.6",
"@types/react": "19.2.15", "@types/react": "19.2.15",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/react-google-recaptcha": "2.1.9", "@types/react-google-recaptcha": "2.1.9",
@@ -3990,6 +3993,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/react": {
"version": "19.2.15", "version": "19.2.15",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
@@ -5171,6 +5184,15 @@
"node": ">=6" "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": { "node_modules/camelize": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", "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": { "node_modules/dedent": {
"version": "1.7.2", "version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -6108,6 +6139,12 @@
"node": ">=8" "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": { "node_modules/direction": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz", "resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz",
@@ -9057,6 +9094,12 @@
"node": ">=0.10.0" "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": { "node_modules/jsx-ast-utils": {
"version": "3.3.5", "version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", "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" "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": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "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", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -10652,6 +10703,15 @@
"pathe": "^2.0.1" "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": { "node_modules/possible-typed-array-names": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@@ -10759,6 +10819,23 @@
"node": ">=6" "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": { "node_modules/qrcode.react": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", "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" "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": { "node_modules/raf-schd": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
@@ -11188,6 +11383,12 @@
"node": "*" "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": { "node_modules/resize-observer-polyfill": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@@ -11518,6 +11719,12 @@
"node": ">=20.0.0" "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": { "node_modules/set-cookie-parser": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "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" "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": { "node_modules/which-typed-array": {
"version": "1.1.20", "version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
+3
View File
@@ -74,6 +74,7 @@
"immer": "11.1.8", "immer": "11.1.8",
"is-hotkey": "0.2.0", "is-hotkey": "0.2.0",
"jotai": "2.20.0", "jotai": "2.20.0",
"jsqr": "1.4.0",
"katex": "0.16.11", "katex": "0.16.11",
"linkify-react": "4.3.3", "linkify-react": "4.3.3",
"linkifyjs": "4.3.3", "linkifyjs": "4.3.3",
@@ -82,6 +83,7 @@
"millify": "6.1.0", "millify": "6.1.0",
"pdfjs-dist": "5.7.284", "pdfjs-dist": "5.7.284",
"prismjs": "1.30.0", "prismjs": "1.30.0",
"qrcode": "1.5.4",
"qrcode.react": "4.2.0", "qrcode.react": "4.2.0",
"react": "19.2.6", "react": "19.2.6",
"react-aria": "3.48.0", "react-aria": "3.48.0",
@@ -112,6 +114,7 @@
"@types/katex": "0.16.8", "@types/katex": "0.16.8",
"@types/node": "25.9.1", "@types/node": "25.9.1",
"@types/prismjs": "1.26.6", "@types/prismjs": "1.26.6",
"@types/qrcode": "1.5.6",
"@types/react": "19.2.15", "@types/react": "19.2.15",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/react-google-recaptcha": "2.1.9", "@types/react-google-recaptcha": "2.1.9",
+142 -34
View File
@@ -1,12 +1,14 @@
import { import {
ShowQrCodeCallbacks,
ShowSasCallbacks, ShowSasCallbacks,
VerificationPhase, VerificationPhase,
VerificationRequest, VerificationRequest,
Verifier, Verifier,
} from 'matrix-js-sdk/lib/crypto-api'; } 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 { useTranslation } from 'react-i18next';
import { VerificationMethod } from 'matrix-js-sdk/lib/types'; import { VerificationMethod } from 'matrix-js-sdk/lib/types';
import QRCode from 'qrcode';
import { import {
Box, Box,
Button, Button,
@@ -27,11 +29,13 @@ import {
useVerificationRequestPhase, useVerificationRequestPhase,
useVerificationRequestReceived, useVerificationRequestReceived,
useVerifierCancel, useVerifierCancel,
useVerifierShowReciprocateQr,
useVerifierShowSas, useVerifierShowSas,
} from '../hooks/useVerificationRequest'; } from '../hooks/useVerificationRequest';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { ContainerColor } from '../styles/ContainerColor.css'; import { ContainerColor } from '../styles/ContainerColor.css';
import { useModalStyle } from '../hooks/useModalStyle'; import { useModalStyle } from '../hooks/useModalStyle';
import { QrScanner } from './QrScanner';
const DialogHeaderStyles: CSSProperties = { const DialogHeaderStyles: CSSProperties = {
padding: `0 ${config.space.S200} 0 ${config.space.S400}`, padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -97,32 +101,6 @@ function VerificationAccept({ onAccept }: VerificationAcceptProps) {
); );
} }
function VerificationWaitStart() {
const { t } = useTranslation();
return (
<Box direction="Column" gap="400">
<Text>{t('Organisms.DeviceVerification.request_accepted')}</Text>
<WaitingMessage message={t('Organisms.DeviceVerification.waiting_response')} />
</Box>
);
}
type VerificationStartProps = {
onStart: () => Promise<void>;
};
function AutoVerificationStart({ onStart }: VerificationStartProps) {
const { t } = useTranslation();
useEffect(() => {
onStart();
}, [onStart]);
return (
<Box direction="Column" gap="400">
<WaitingMessage message={t('Organisms.DeviceVerification.starting_emoji')} />
</Box>
);
}
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) { function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData])); const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
@@ -237,6 +215,120 @@ function VerificationCanceled({ onClose }: VerificationCanceledProps) {
); );
} }
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 = { type DeviceVerificationProps = {
request: VerificationRequest; request: VerificationRequest;
onExit: () => void; onExit: () => void;
@@ -256,6 +348,17 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
const handleStart = useCallback(async () => { const handleStart = useCallback(async () => {
await request.startVerification(VerificationMethod.Sas); await request.startVerification(VerificationMethod.Sas);
}, [request]); }, [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 ( return (
<Overlay open backdrop={<OverlayBackdrop />}> <Overlay open backdrop={<OverlayBackdrop />}>
@@ -290,15 +393,20 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
) : ( ) : (
<VerificationAccept onAccept={handleAccept} /> <VerificationAccept onAccept={handleAccept} />
))} ))}
{phase === VerificationPhase.Ready && {phase === VerificationPhase.Ready && (
(request.initiatedByMe ? ( <VerificationReady
<AutoVerificationStart onStart={handleStart} /> request={request}
) : ( onStartSas={handleStart}
<VerificationWaitStart /> onScanned={handleScanned}
))} />
)}
{phase === VerificationPhase.Started && {phase === VerificationPhase.Started &&
(request.verifier ? ( (request.verifier ? (
<SasVerification verifier={request.verifier} onCancel={handleCancel} /> request.chosenMethod === VerificationMethod.Reciprocate ? (
<ReciprocateVerification verifier={request.verifier} onCancel={handleCancel} />
) : (
<SasVerification verifier={request.verifier} onCancel={handleCancel} />
)
) : ( ) : (
<VerificationUnexpected <VerificationUnexpected
message="Unexpected Error! Verification is started but verifier is missing." message="Unexpected Error! Verification is started but verifier is missing."
+101
View File
@@ -0,0 +1,101 @@
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>
);
}
+2 -1
View File
@@ -38,7 +38,8 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
deviceId: session.deviceId, deviceId: session.deviceId,
timelineSupport: true, timelineSupport: true,
cryptoCallbacks: cryptoCallbacks as any, 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 tokenRefreshFunction: oidcRefresher
? (refreshToken) => oidcRefresher.doRefreshAccessToken(refreshToken) ? (refreshToken) => oidcRefresher.doRefreshAccessToken(refreshToken)
: undefined, : undefined,