Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17bd50cc4e | |||
| 82e52e1bc7 | |||
| d46b91b1b8 | |||
| 5b94a44eb3 |
@@ -675,6 +675,12 @@ 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.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
**Windows rich toast (D6, 2026-07 — desktop/Windows build only):** get a message notification while the desktop app is backgrounded → the toast is attributed to **Lotus Chat** (not "PowerShell"/generic) and shows an inline **reply box + Send**; typing a reply + Send **posts it to that room**; clicking the toast body **opens the room**. Previously these silently fell back to a plain toast (no reply/click). If it still falls back, check that a `Lotus Chat.lnk` exists in the Start-Menu Programs folder.
|
**Windows rich toast (D6, 2026-07 — desktop/Windows build only):** get a message notification while the desktop app is backgrounded → the toast is attributed to **Lotus Chat** (not "PowerShell"/generic) and shows an inline **reply box + Send**; typing a reply + Send **posts it to that room**; clicking the toast body **opens the room**. Previously these silently fell back to a plain toast (no reply/click). If it still falls back, check that a `Lotus Chat.lnk` exists in the Start-Menu Programs folder.
|
||||||
|
|
||||||
**Invite QR is now generated LOCALLY (2026-07):** Room settings → Share Room → the QR code renders (a black-on-white SVG in a white box) with **no network request** to `api.qrserver.com` (check DevTools Network — there should be no external QR fetch, and it should work offline / behind strict CSP). **Scan it** with a phone camera / Matrix app → it opens the correct `matrix.to` room-invite link. (`api.qrserver.com` was removed from the prod CSP img-src, so a regression would make the QR blank rather than silently phone home.)
|
**Invite QR is now generated LOCALLY (2026-07):** Room settings → Share Room → the QR code renders (a black-on-white SVG in a white box) with **no network request** to `api.qrserver.com` (check DevTools Network — there should be no external QR fetch, and it should work offline / behind strict CSP). **Scan it** with a phone camera / Matrix app → it opens the correct `matrix.to` room-invite link. (`api.qrserver.com` was removed from the prod CSP img-src, so a regression would make the QR blank rather than silently phone home.)
|
||||||
|
|||||||
@@ -91,6 +91,29 @@ Observed live in prod 2026-06-30 during a 2-person **Element Call** (E2EE). Thes
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🌐 Matrix Protocol Gaps
|
||||||
|
|
||||||
|
Genuine Matrix client-spec / MSC features Lotus does **not** yet implement (audited 2026-07 against the codebase — almost everything else is built: pinning, stickers+picker, room directory, mutual rooms MSC2666, blurhash, key backup/recovery/SSSS, SAS verification, ignore list, invite spam-filter, voice messages, polls, threads, spaces, OIDC, extended profiles, delayed events, authed media). Build each **fully** — spec-correct events, native-Cinny folds UI, tests. Order = clean wins first.
|
||||||
|
|
||||||
|
**Phase A ✅ (2026-07, gate-green 683 tests):**
|
||||||
|
|
||||||
|
- [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 ✅ (2026-07, gate-green 688 tests):**
|
||||||
|
|
||||||
|
- [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):**
|
||||||
|
|
||||||
|
- [ ] **Room Widgets — MSC1236 + widget API.** No general widget UI exists (only the PL entry `im.vector.modular.widgets`; the EC call widget is hardcoded). Read `im.vector.modular.widgets`/`m.widget` state, add an Add/Manage panel + sandboxed iframe renderer via `matrix-widget-api` — **extend the existing EC widget plumbing** (`plugins/call/CallEmbed.ts`). Enables Etherpad/notes/dashboards/integrations.
|
||||||
|
- [ ] **Sliding Sync — MSC3575 / simplified MSC4186.** Lotus is on **legacy full `/sync`** though the server advertises `simplified_msc3575`. matrix-js-sdk ships `SlidingSync`; migration → near-instant cold start + low memory + huge-account scale. Touches the sync/room-list/spaces/unread core — behind a feature flag with a legacy fallback. **Plan separately before touching.**
|
||||||
|
|
||||||
|
**Server-gated / advanced (capture, don't build yet):** QR sign-in for a new device (**MSC4108** rendezvous — needs an HS-side endpoint); dehydrated devices (**MSC3814** — offline key delivery, also helps the E2EE KE cluster); E2EE history key sharing on invite (**MSC3061** `shared_history`, niche); voice broadcast (Element MSC3888, low value — skip).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 📋 Open Feature Backlog
|
## 📋 Open Feature Backlog
|
||||||
|
|
||||||
### [ ] P4-4 · Math / LaTeX Rendering (LOW PRIORITY)
|
### [ ] P4-4 · Math / LaTeX Rendering (LOW PRIORITY)
|
||||||
|
|||||||
Generated
+214
-1
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { Box, Button, color, Spinner, Text } from 'folds';
|
||||||
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
|
import { StateEvent } from '../../../../types/matrix/room';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||||
|
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||||
|
import { RetentionContent, RETENTION_PRESETS } from '../../../utils/retention';
|
||||||
|
|
||||||
|
type RoomRetentionProps = {
|
||||||
|
permissions: RoomPermissionsAPI;
|
||||||
|
};
|
||||||
|
export function RoomRetention({ permissions }: RoomRetentionProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
|
||||||
|
const canEdit = permissions.stateEvent(StateEvent.RoomRetention, mx.getSafeUserId());
|
||||||
|
|
||||||
|
const event = useStateEvent(room, StateEvent.RoomRetention);
|
||||||
|
const currentMs = event?.getContent<RetentionContent>().max_lifetime ?? 0;
|
||||||
|
|
||||||
|
const [submitState, submit] = useAsyncCallback(
|
||||||
|
useCallback(
|
||||||
|
async (ms: number) => {
|
||||||
|
const content: RetentionContent = ms > 0 ? { max_lifetime: ms } : {};
|
||||||
|
// Lotus custom-state convention: cast the type key (RoomRetention isn't a
|
||||||
|
// typed key in the SDK's StateEvents map).
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await mx.sendStateEvent(room.roomId, StateEvent.RoomRetention as any, content);
|
||||||
|
},
|
||||||
|
[mx, room.roomId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const submitting = submitState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Message Retention"
|
||||||
|
description="Messages older than this window disappear from the timeline. Each member can opt in to permanently delete their own expired messages in Settings → General; full server-side deletion also requires homeserver retention to be configured."
|
||||||
|
>
|
||||||
|
<Box gap="200" alignItems="Center" style={{ flexWrap: 'wrap' }}>
|
||||||
|
{RETENTION_PRESETS.map((preset) => {
|
||||||
|
const active = currentMs === preset.ms;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={preset.label}
|
||||||
|
type="button"
|
||||||
|
size="300"
|
||||||
|
variant={active ? 'Primary' : 'Secondary'}
|
||||||
|
fill={active ? 'Solid' : 'Soft'}
|
||||||
|
radii="300"
|
||||||
|
disabled={!canEdit || submitting}
|
||||||
|
onClick={() => submit(preset.ms)}
|
||||||
|
>
|
||||||
|
<Text size="B300">{preset.label}</Text>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{submitting && <Spinner size="100" variant="Secondary" />}
|
||||||
|
</Box>
|
||||||
|
{submitState.status === AsyncStatus.Error && (
|
||||||
|
<Text style={{ color: color.Critical.Main }} size="T200">
|
||||||
|
{(submitState.error as MatrixError).message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</SettingTile>
|
||||||
|
</SequenceCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ export * from './RoomJoinRules';
|
|||||||
export * from './RoomProfile';
|
export * from './RoomProfile';
|
||||||
export * from './RoomPublish';
|
export * from './RoomPublish';
|
||||||
export * from './RoomQuality';
|
export * from './RoomQuality';
|
||||||
|
export * from './RoomRetention';
|
||||||
export * from './RoomShareInvite';
|
export * from './RoomShareInvite';
|
||||||
export * from './RoomUpgrade';
|
export * from './RoomUpgrade';
|
||||||
export * from './RoomVoiceLimit';
|
export * from './RoomVoiceLimit';
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { nameInitials } from '../../utils/common';
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRoomUnread } from '../../state/hooks/unread';
|
import { useRoomUnread } from '../../state/hooks/unread';
|
||||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||||
|
import { markedUnreadAtom, setMarkedUnread } from '../../state/room/markedUnread';
|
||||||
import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels';
|
import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels';
|
||||||
import { markAsRead } from '../../utils/notifications';
|
import { markAsRead } from '../../utils/notifications';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
@@ -329,18 +330,39 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
const isServerNotice = room.getType() === 'm.server_notice';
|
const isServerNotice = room.getType() === 'm.server_notice';
|
||||||
|
|
||||||
const isFavorite = !!room.tags?.['m.favourite'];
|
const isFavorite = !!room.tags?.['m.favourite'];
|
||||||
|
const isLowPriority = !!room.tags?.['m.lowpriority'];
|
||||||
|
|
||||||
const handleToggleFavorite = () => {
|
const handleToggleFavorite = () => {
|
||||||
if (isFavorite) {
|
if (isFavorite) {
|
||||||
mx.deleteRoomTag(room.roomId, 'm.favourite');
|
mx.deleteRoomTag(room.roomId, 'm.favourite');
|
||||||
} else {
|
} else {
|
||||||
|
// Favourite and low-priority are mutually exclusive.
|
||||||
|
if (isLowPriority) mx.deleteRoomTag(room.roomId, 'm.lowpriority');
|
||||||
mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 });
|
mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 });
|
||||||
}
|
}
|
||||||
requestClose();
|
requestClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleLowPriority = () => {
|
||||||
|
if (isLowPriority) {
|
||||||
|
mx.deleteRoomTag(room.roomId, 'm.lowpriority');
|
||||||
|
} else {
|
||||||
|
if (isFavorite) mx.deleteRoomTag(room.roomId, 'm.favourite');
|
||||||
|
mx.setRoomTag(room.roomId, 'm.lowpriority', { order: 0.5 });
|
||||||
|
}
|
||||||
|
requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const markedUnread = useAtomValue(markedUnreadAtom).has(room.roomId);
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
const handleMarkAsRead = () => {
|
||||||
markAsRead(mx, room.roomId, hideActivity);
|
markAsRead(mx, room.roomId, hideActivity);
|
||||||
|
if (markedUnread) setMarkedUnread(mx, room.roomId, false).catch(() => undefined);
|
||||||
|
requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAsUnread = () => {
|
||||||
|
setMarkedUnread(mx, room.roomId, true).catch(() => undefined);
|
||||||
requestClose();
|
requestClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -393,12 +415,23 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
size="300"
|
size="300"
|
||||||
after={<Icon size="100" src={Icons.CheckTwice} />}
|
after={<Icon size="100" src={Icons.CheckTwice} />}
|
||||||
radii="300"
|
radii="300"
|
||||||
disabled={!unread}
|
disabled={!unread && !markedUnread}
|
||||||
>
|
>
|
||||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
Mark as Read
|
Mark as Read
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleMarkAsUnread}
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.MessageUnread} />}
|
||||||
|
radii="300"
|
||||||
|
disabled={!!unread || markedUnread}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
Mark as Unread
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
|
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
|
||||||
{(handleOpen, opened, changing) => (
|
{(handleOpen, opened, changing) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@@ -493,6 +526,17 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
{isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}
|
{isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleToggleLowPriority}
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.ChevronBottom} />}
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={isLowPriority}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
{isLowPriority ? 'Remove from Low Priority' : 'Add to Low Priority'}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={handleInvite}
|
onClick={handleInvite}
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
@@ -610,6 +654,10 @@ function RoomNavItem_({
|
|||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||||
const [renameDialog, setRenameDialog] = useState(false);
|
const [renameDialog, setRenameDialog] = useState(false);
|
||||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||||
|
// MSC2867: an explicit "mark as unread" lights the row even with no unread
|
||||||
|
// count. `hasUnread` drives the bold name / icon emphasis below.
|
||||||
|
const markedUnread = useAtomValue(markedUnreadAtom).has(room.roomId);
|
||||||
|
const hasUnread = !!unread || markedUnread;
|
||||||
const typingMember = useRoomTypingMember(room.roomId).filter(
|
const typingMember = useRoomTypingMember(room.roomId).filter(
|
||||||
(receipt) => receipt.userId !== mx.getUserId(),
|
(receipt) => receipt.userId !== mx.getUserId(),
|
||||||
);
|
);
|
||||||
@@ -692,7 +740,7 @@ function RoomNavItem_({
|
|||||||
<NavItem
|
<NavItem
|
||||||
variant="Background"
|
variant="Background"
|
||||||
radii="400"
|
radii="400"
|
||||||
highlight={unread !== undefined}
|
highlight={hasUnread}
|
||||||
aria-selected={selected}
|
aria-selected={selected}
|
||||||
data-hover={!!menuAnchor}
|
data-hover={!!menuAnchor}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
@@ -721,7 +769,7 @@ function RoomNavItem_({
|
|||||||
) : (
|
) : (
|
||||||
<RoomIcon
|
<RoomIcon
|
||||||
style={{
|
style={{
|
||||||
opacity: unread ? config.opacity.P500 : config.opacity.P300,
|
opacity: hasUnread ? config.opacity.P500 : config.opacity.P300,
|
||||||
}}
|
}}
|
||||||
filled={selected}
|
filled={selected}
|
||||||
size="100"
|
size="100"
|
||||||
@@ -732,7 +780,7 @@ function RoomNavItem_({
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
<Box as="span" direction="Column" grow="Yes" style={{ minWidth: 0 }}>
|
<Box as="span" direction="Column" grow="Yes" style={{ minWidth: 0 }}>
|
||||||
<Box as="span" grow="Yes" alignItems="Center" gap="100">
|
<Box as="span" grow="Yes" alignItems="Center" gap="100">
|
||||||
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
|
<Text priority={hasUnread ? '500' : '300'} as="span" size="Inherit" truncate>
|
||||||
{roomName}
|
{roomName}
|
||||||
</Text>
|
</Text>
|
||||||
{hasLocalName && (
|
{hasLocalName && (
|
||||||
@@ -773,7 +821,7 @@ function RoomNavItem_({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
|
{!optionsVisible && !hasUnread && !selected && typingMember.length > 0 && (
|
||||||
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
||||||
<TypingIndicator size="300" disableAnimation />
|
<TypingIndicator size="300" disableAnimation />
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -783,6 +831,11 @@ function RoomNavItem_({
|
|||||||
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
|
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
|
||||||
</UnreadBadgeCenter>
|
</UnreadBadgeCenter>
|
||||||
)}
|
)}
|
||||||
|
{!optionsVisible && !unread && markedUnread && (
|
||||||
|
<UnreadBadgeCenter>
|
||||||
|
<UnreadBadge highlight={false} count={0} />
|
||||||
|
</UnreadBadgeCenter>
|
||||||
|
)}
|
||||||
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
|
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
|
||||||
<Icon
|
<Icon
|
||||||
size="50"
|
size="50"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
RoomPublishedAddresses,
|
RoomPublishedAddresses,
|
||||||
RoomPublish,
|
RoomPublish,
|
||||||
RoomQuality,
|
RoomQuality,
|
||||||
|
RoomRetention,
|
||||||
RoomShareInvite,
|
RoomShareInvite,
|
||||||
RoomUpgrade,
|
RoomUpgrade,
|
||||||
RoomVoiceLimit,
|
RoomVoiceLimit,
|
||||||
@@ -56,6 +57,10 @@ export function General({ requestClose }: GeneralProps) {
|
|||||||
<RoomEncryption permissions={permissions} />
|
<RoomEncryption permissions={permissions} />
|
||||||
<RoomPublish permissions={permissions} />
|
<RoomPublish permissions={permissions} />
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Message Retention</Text>
|
||||||
|
<RoomRetention permissions={permissions} />
|
||||||
|
</Box>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Voice</Text>
|
<Text size="L400">Voice</Text>
|
||||||
<RoomVoiceLimit permissions={permissions} />
|
<RoomVoiceLimit permissions={permissions} />
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
|||||||
import { ThreadSummary } from './thread/ThreadSummary';
|
import { ThreadSummary } from './thread/ThreadSummary';
|
||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||||
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
|
import { RetentionContent, isExpired } from '../../utils/retention';
|
||||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
||||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||||
@@ -468,6 +470,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||||
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
||||||
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
||||||
|
// MSC1763 retention: messages older than this window are hidden from the
|
||||||
|
// timeline (unless "show hidden events" is on). Reactive so a policy change
|
||||||
|
// re-renders. `undefined` = no policy.
|
||||||
|
const retentionEvent = useStateEvent(room, StateEvent.RoomRetention);
|
||||||
|
const retentionMs = retentionEvent?.getContent<RetentionContent>().max_lifetime;
|
||||||
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
|
|
||||||
@@ -2043,6 +2050,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
if (eventSender && ignoredUsersSet.has(eventSender)) {
|
if (eventSender && ignoredUsersSet.has(eventSender)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// MSC1763: hide messages past the room's retention window (disappearing
|
||||||
|
// messages). Power users can still inspect via "show hidden events".
|
||||||
|
if (retentionMs && !showHiddenEvents && isExpired(mEvent.getTs(), retentionMs, Date.now())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (mEvent.isRedacted() && !showHiddenEvents) {
|
if (mEvent.isRedacted() && !showHiddenEvents) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||||
const t = mEvent.getType();
|
const t = mEvent.getType();
|
||||||
|
|||||||
@@ -2251,6 +2251,10 @@ function Messages() {
|
|||||||
const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
|
const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||||
const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||||
const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
||||||
|
const [enforceRetentionLocally, setEnforceRetentionLocally] = useSetting(
|
||||||
|
settingsAtom,
|
||||||
|
'enforceRetentionLocally',
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
@@ -2348,6 +2352,19 @@ function Messages() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Enforce Message Retention"
|
||||||
|
description="Permanently delete your own messages once a room's retention window (Room Settings → Message Retention) has passed. Off by default; only affects your own messages."
|
||||||
|
after={
|
||||||
|
<Switch
|
||||||
|
variant="Primary"
|
||||||
|
value={enforceRetentionLocally}
|
||||||
|
onChange={setEnforceRetentionLocally}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/accou
|
|||||||
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
||||||
import { toastQueueAtom } from '../../state/toast';
|
import { toastQueueAtom } from '../../state/toast';
|
||||||
import { useReminders } from '../../hooks/useReminders';
|
import { useReminders } from '../../hooks/useReminders';
|
||||||
|
import { getRoomRetentionMs, isExpired } from '../../utils/retention';
|
||||||
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
|
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
|
||||||
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
|
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
|
||||||
import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts';
|
import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts';
|
||||||
@@ -687,6 +688,62 @@ function ReminderMonitor() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MSC1763: opt-in local enforcement of room retention. When enabled, permanently
|
||||||
|
// redacts the user's OWN messages once a room's retention window passes. Own-only
|
||||||
|
// (no redact PL needed); scoped to loaded live-timeline events; dedupes in-flight
|
||||||
|
// redactions and retries on the next tick. Default-off, so nothing auto-deletes
|
||||||
|
// unless the user turns it on.
|
||||||
|
function RetentionSweeper() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [enforceRetentionLocally] = useSetting(settingsAtom, 'enforceRetentionLocally');
|
||||||
|
const enabledRef = useRef(enforceRetentionLocally);
|
||||||
|
enabledRef.current = enforceRetentionLocally;
|
||||||
|
const redactingRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const check = () => {
|
||||||
|
if (!enabledRef.current) return;
|
||||||
|
const myId = mx.getUserId();
|
||||||
|
if (!myId) return;
|
||||||
|
const now = Date.now();
|
||||||
|
mx.getRooms().forEach((room) => {
|
||||||
|
const maxLifetime = getRoomRetentionMs(room);
|
||||||
|
if (!maxLifetime) return;
|
||||||
|
room
|
||||||
|
.getLiveTimeline()
|
||||||
|
.getEvents()
|
||||||
|
.forEach((ev) => {
|
||||||
|
const evId = ev.getId();
|
||||||
|
if (!evId || ev.getSender() !== myId) return;
|
||||||
|
if (ev.isState() || ev.isRedacted() || ev.isSending()) return;
|
||||||
|
const t = ev.getType();
|
||||||
|
// Only actual messages — never our membership/topic/reactions.
|
||||||
|
if (t !== 'm.room.message' && t !== 'm.room.encrypted' && t !== 'm.sticker') return;
|
||||||
|
if (!isExpired(ev.getTs(), maxLifetime, now)) return;
|
||||||
|
if (redactingRef.current.has(evId)) return;
|
||||||
|
redactingRef.current.add(evId);
|
||||||
|
mx.redactEvent(room.roomId, evId, undefined, { reason: 'expired' }).catch(() => {
|
||||||
|
redactingRef.current.delete(evId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
check();
|
||||||
|
const interval = setInterval(check, 30_000);
|
||||||
|
const onVisible = () => {
|
||||||
|
if (document.visibilityState === 'visible') check();
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', onVisible);
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
document.removeEventListener('visibilitychange', onVisible);
|
||||||
|
};
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const TAURI_UPDATE_CHECK_INTERVAL = 12 * 60 * 60_000; // 12 hours
|
const TAURI_UPDATE_CHECK_INTERVAL = 12 * 60 * 60_000; // 12 hours
|
||||||
const TAURI_UPDATE_LAST_CHECK_KEY = 'lotus.tauriUpdateLastCheck';
|
const TAURI_UPDATE_LAST_CHECK_KEY = 'lotus.tauriUpdateLastCheck';
|
||||||
|
|
||||||
@@ -773,6 +830,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<InviteNotifications />
|
<InviteNotifications />
|
||||||
<MessageNotifications />
|
<MessageNotifications />
|
||||||
<ReminderMonitor />
|
<ReminderMonitor />
|
||||||
|
<RetentionSweeper />
|
||||||
<TauriUpdateFeature />
|
<TauriUpdateFeature />
|
||||||
<TauriDesktopFeatures />
|
<TauriDesktopFeatures />
|
||||||
<LotusDenoiseFeature />
|
<LotusDenoiseFeature />
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ const factoryRoomIdByUnread =
|
|||||||
|
|
||||||
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
|
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
|
||||||
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
|
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
|
||||||
|
const LOW_PRIORITY_CATEGORY_ID = makeNavCategoryId('home', 'lowpriority');
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
useNavToActivePathMapper('home');
|
useNavToActivePathMapper('home');
|
||||||
@@ -261,18 +262,21 @@ export function Home() {
|
|||||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||||
const [sortMenuAnchor, setSortMenuAnchor] = useState<RectCords>();
|
const [sortMenuAnchor, setSortMenuAnchor] = useState<RectCords>();
|
||||||
|
|
||||||
const { favoriteRooms, otherRooms } = useMemo(() => {
|
const { favoriteRooms, lowPriorityRooms, otherRooms } = useMemo(() => {
|
||||||
const favs: string[] = [];
|
const favs: string[] = [];
|
||||||
|
const low: string[] = [];
|
||||||
const others: string[] = [];
|
const others: string[] = [];
|
||||||
rooms.forEach((rId) => {
|
rooms.forEach((rId) => {
|
||||||
const room = mx.getRoom(rId);
|
const room = mx.getRoom(rId);
|
||||||
if (room?.tags?.['m.favourite']) {
|
if (room?.tags?.['m.favourite']) {
|
||||||
favs.push(rId);
|
favs.push(rId);
|
||||||
|
} else if (room?.tags?.['m.lowpriority']) {
|
||||||
|
low.push(rId);
|
||||||
} else {
|
} else {
|
||||||
others.push(rId);
|
others.push(rId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return { favoriteRooms: favs, otherRooms: others };
|
return { favoriteRooms: favs, lowPriorityRooms: low, otherRooms: others };
|
||||||
}, [mx, rooms]);
|
}, [mx, rooms]);
|
||||||
|
|
||||||
const sortedFavoriteRooms = useMemo(() => {
|
const sortedFavoriteRooms = useMemo(() => {
|
||||||
@@ -297,6 +301,28 @@ export function Home() {
|
|||||||
});
|
});
|
||||||
}, [mx, sortedFavoriteRooms, filterQuery]);
|
}, [mx, sortedFavoriteRooms, filterQuery]);
|
||||||
|
|
||||||
|
const sortedLowPriorityRooms = useMemo(() => {
|
||||||
|
const isClosed = closedCategories.has(LOW_PRIORITY_CATEGORY_ID);
|
||||||
|
const items = Array.from(lowPriorityRooms).sort(
|
||||||
|
isClosed ? factoryRoomIdByActivity(mx) : factoryRoomIdByAtoZ(mx),
|
||||||
|
);
|
||||||
|
if (isClosed) {
|
||||||
|
return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [mx, lowPriorityRooms, closedCategories, roomsWithUnreadSet, selectedRoomId]);
|
||||||
|
|
||||||
|
const filteredLowPriorityRooms = useMemo(() => {
|
||||||
|
if (!filterQuery.trim()) return sortedLowPriorityRooms;
|
||||||
|
const query = filterQuery.toLowerCase();
|
||||||
|
const localNames = getLocalRoomNamesContent(mx);
|
||||||
|
return sortedLowPriorityRooms.filter((rId) => {
|
||||||
|
const localName = localNames.rooms[rId];
|
||||||
|
const matrixName = mx.getRoom(rId)?.name ?? '';
|
||||||
|
return (localName ?? matrixName).toLowerCase().includes(query);
|
||||||
|
});
|
||||||
|
}, [mx, sortedLowPriorityRooms, filterQuery]);
|
||||||
|
|
||||||
const sortedRooms = useMemo(() => {
|
const sortedRooms = useMemo(() => {
|
||||||
const isClosed = closedCategories.has(DEFAULT_CATEGORY_ID);
|
const isClosed = closedCategories.has(DEFAULT_CATEGORY_ID);
|
||||||
let comparator: (a: string, b: string) => number;
|
let comparator: (a: string, b: string) => number;
|
||||||
@@ -349,6 +375,13 @@ export function Home() {
|
|||||||
overscan: 10,
|
overscan: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const lowVirtualizer = useVirtualizer({
|
||||||
|
count: filteredLowPriorityRooms.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => 38,
|
||||||
|
overscan: 10,
|
||||||
|
});
|
||||||
|
|
||||||
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
|
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
|
||||||
closedCategories.has(categoryId),
|
closedCategories.has(categoryId),
|
||||||
);
|
);
|
||||||
@@ -638,6 +671,43 @@ export function Home() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
|
{lowPriorityRooms.length > 0 && (
|
||||||
|
<NavCategory>
|
||||||
|
<NavCategoryHeader>
|
||||||
|
<RoomNavCategoryButton
|
||||||
|
closed={closedCategories.has(LOW_PRIORITY_CATEGORY_ID)}
|
||||||
|
data-category-id={LOW_PRIORITY_CATEGORY_ID}
|
||||||
|
onClick={handleCategoryClick}
|
||||||
|
>
|
||||||
|
Low Priority
|
||||||
|
</RoomNavCategoryButton>
|
||||||
|
</NavCategoryHeader>
|
||||||
|
<div style={{ position: 'relative', height: lowVirtualizer.getTotalSize() }}>
|
||||||
|
{lowVirtualizer.getVirtualItems().map((vItem) => {
|
||||||
|
const roomId = filteredLowPriorityRooms[vItem.index];
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) return null;
|
||||||
|
return (
|
||||||
|
<VirtualTile
|
||||||
|
virtualItem={vItem}
|
||||||
|
key={roomId}
|
||||||
|
ref={lowVirtualizer.measureElement}
|
||||||
|
>
|
||||||
|
<RoomNavItem
|
||||||
|
room={room}
|
||||||
|
selected={selectedRoomId === roomId}
|
||||||
|
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
||||||
|
notificationMode={getRoomNotificationMode(
|
||||||
|
notificationPreferences,
|
||||||
|
room.roomId,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</VirtualTile>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</NavCategory>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</PageNavContent>
|
</PageNavContent>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { allInvitesAtom, useBindAllInvitesAtom } from '../room-list/inviteList';
|
|||||||
import { allRoomsAtom, useBindAllRoomsAtom } from '../room-list/roomList';
|
import { allRoomsAtom, useBindAllRoomsAtom } from '../room-list/roomList';
|
||||||
import { mDirectAtom, useBindMDirectAtom } from '../mDirectList';
|
import { mDirectAtom, useBindMDirectAtom } from '../mDirectList';
|
||||||
import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread';
|
import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread';
|
||||||
|
import { markedUnreadAtom, useBindMarkedUnreadAtom } from '../room/markedUnread';
|
||||||
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
|
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
|
||||||
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
|
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
|
||||||
import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications';
|
import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications';
|
||||||
@@ -14,6 +15,7 @@ export const useBindAtoms = (mx: MatrixClient) => {
|
|||||||
useBindRoomToParentsAtom(mx, roomToParentsAtom);
|
useBindRoomToParentsAtom(mx, roomToParentsAtom);
|
||||||
useBindThreadNotificationsAtom(mx, threadNotificationsAtom);
|
useBindThreadNotificationsAtom(mx, threadNotificationsAtom);
|
||||||
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
|
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
|
||||||
|
useBindMarkedUnreadAtom(mx, markedUnreadAtom);
|
||||||
|
|
||||||
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
|
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { MatrixEvent } from 'matrix-js-sdk';
|
||||||
|
import { receiptIsMine, setMarkedUnread } from './markedUnread';
|
||||||
|
|
||||||
|
// MSC2867 mark-as-unread: reading a room (our own receipt) clears the flag, so
|
||||||
|
// `receiptIsMine` must detect only OUR receipt and ignore others'. And a write
|
||||||
|
// must land on BOTH the stable `m.marked_unread` and the unstable
|
||||||
|
// `com.famedly.marked_unread` key so it round-trips across servers/clients.
|
||||||
|
|
||||||
|
const ME = '@me:server';
|
||||||
|
const OTHER = '@friend:server';
|
||||||
|
|
||||||
|
const receiptEvent = (content: object): MatrixEvent =>
|
||||||
|
({ getContent: () => content }) as MatrixEvent;
|
||||||
|
|
||||||
|
test('receiptIsMine: true when the receipt content carries our user id', () => {
|
||||||
|
const event = receiptEvent({
|
||||||
|
$abc: { 'm.read': { [ME]: { ts: 1 } } },
|
||||||
|
});
|
||||||
|
assert.equal(receiptIsMine(event, ME), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('receiptIsMine: false when only another user has a receipt', () => {
|
||||||
|
const event = receiptEvent({
|
||||||
|
$abc: { 'm.read': { [OTHER]: { ts: 1 } } },
|
||||||
|
});
|
||||||
|
assert.equal(receiptIsMine(event, ME), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('receiptIsMine: tolerates empty / malformed content', () => {
|
||||||
|
assert.equal(receiptIsMine(receiptEvent({}), ME), false);
|
||||||
|
assert.equal(receiptIsMine(receiptEvent({ $x: {} }), ME), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setMarkedUnread writes both the stable and unstable keys with the flag', async () => {
|
||||||
|
const calls: Array<{ type: string; content: unknown }> = [];
|
||||||
|
const mx = {
|
||||||
|
setRoomAccountData: (_roomId: string, type: string, content: unknown) => {
|
||||||
|
calls.push({ type, content });
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
await setMarkedUnread(mx, '!room:server', true);
|
||||||
|
|
||||||
|
const types = calls.map((c) => c.type).sort();
|
||||||
|
assert.deepEqual(types, ['com.famedly.marked_unread', 'm.marked_unread']);
|
||||||
|
assert.ok(calls.every((c) => (c.content as { unread: boolean }).unread === true));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setMarkedUnread(false) clears both keys and does not reject if the unstable write fails', async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const mx = {
|
||||||
|
setRoomAccountData: (_roomId: string, type: string) => {
|
||||||
|
seen.push(type);
|
||||||
|
// Simulate an older server rejecting the unstable key — must not reject.
|
||||||
|
if (type === 'com.famedly.marked_unread') return Promise.reject(new Error('unknown type'));
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
await assert.doesNotReject(() => setMarkedUnread(mx, '!room:server', false));
|
||||||
|
assert.ok(seen.includes('m.marked_unread'));
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { atom, useSetAtom } from 'jotai';
|
||||||
|
import { MatrixClient, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
||||||
|
|
||||||
|
// MSC2867 — "mark a room as unread". A per-room account-data flag `{ unread }`.
|
||||||
|
// Stable type `m.marked_unread`; servers/clients predating the stabilization use
|
||||||
|
// the unstable `com.famedly.marked_unread`. We read either and write both so the
|
||||||
|
// flag round-trips across the ecosystem.
|
||||||
|
const UNSTABLE_MARKED_UNREAD = 'com.famedly.marked_unread';
|
||||||
|
|
||||||
|
const readMarkedUnread = (room: Room): boolean => {
|
||||||
|
const stable = room.getAccountData(AccountDataEvent.MarkedUnread)?.getContent()?.unread;
|
||||||
|
if (typeof stable === 'boolean') return stable;
|
||||||
|
return room.getAccountData(UNSTABLE_MARKED_UNREAD)?.getContent()?.unread === true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Set of room ids the user has explicitly marked as unread. */
|
||||||
|
export const markedUnreadAtom = atom<Set<string>>(new Set<string>());
|
||||||
|
|
||||||
|
/** Write (or clear) the marked-unread flag on both the stable + unstable keys. */
|
||||||
|
export const setMarkedUnread = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
unread: boolean,
|
||||||
|
): Promise<unknown> =>
|
||||||
|
Promise.all([
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
mx.setRoomAccountData(roomId, AccountDataEvent.MarkedUnread as any, { unread }),
|
||||||
|
// Best-effort mirror for older servers; never fail the primary write on it.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
mx.setRoomAccountData(roomId, UNSTABLE_MARKED_UNREAD as any, { unread }).catch(() => undefined),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const receiptIsMine = (event: MatrixEvent, userId: string): boolean => {
|
||||||
|
const content = event.getContent();
|
||||||
|
return Object.keys(content).some((eventId) =>
|
||||||
|
Object.keys(content[eventId] ?? {}).some(
|
||||||
|
(receiptType) => content[eventId][receiptType]?.[userId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useBindMarkedUnreadAtom = (mx: MatrixClient, anAtom: typeof markedUnreadAtom) => {
|
||||||
|
const setAtom = useSetAtom(anAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const seed = new Set<string>();
|
||||||
|
mx.getRooms().forEach((room) => {
|
||||||
|
if (readMarkedUnread(room)) seed.add(room.roomId);
|
||||||
|
});
|
||||||
|
setAtom(seed);
|
||||||
|
|
||||||
|
const syncRoom = (room: Room) => {
|
||||||
|
const marked = readMarkedUnread(room);
|
||||||
|
setAtom((prev) => {
|
||||||
|
if (marked === prev.has(room.roomId)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (marked) next.add(room.roomId);
|
||||||
|
else next.delete(room.roomId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAccountData: RoomEventHandlerMap[RoomEvent.AccountData] = (_event, room) => {
|
||||||
|
syncRoom(room);
|
||||||
|
};
|
||||||
|
// Reading a room clears its marked-unread flag (MSC2867): when our own read
|
||||||
|
// receipt lands for a room that's currently marked, clear it.
|
||||||
|
const onReceipt: RoomEventHandlerMap[RoomEvent.Receipt] = (event, room) => {
|
||||||
|
const myId = mx.getUserId();
|
||||||
|
if (!myId || !readMarkedUnread(room)) return;
|
||||||
|
if (receiptIsMine(event, myId)) {
|
||||||
|
setMarkedUnread(mx, room.roomId, false).catch(() => undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onMembership: RoomEventHandlerMap[RoomEvent.MyMembership] = (room) => {
|
||||||
|
if (room.getMyMembership() !== 'join') {
|
||||||
|
setAtom((prev) => {
|
||||||
|
if (!prev.has(room.roomId)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(room.roomId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mx.on(RoomEvent.AccountData, onAccountData);
|
||||||
|
mx.on(RoomEvent.Receipt, onReceipt);
|
||||||
|
mx.on(RoomEvent.MyMembership, onMembership);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(RoomEvent.AccountData, onAccountData);
|
||||||
|
mx.removeListener(RoomEvent.Receipt, onReceipt);
|
||||||
|
mx.removeListener(RoomEvent.MyMembership, onMembership);
|
||||||
|
};
|
||||||
|
}, [mx, setAtom]);
|
||||||
|
};
|
||||||
@@ -183,6 +183,9 @@ export interface Settings {
|
|||||||
urlPreview: boolean;
|
urlPreview: boolean;
|
||||||
encUrlPreview: boolean;
|
encUrlPreview: boolean;
|
||||||
showHiddenEvents: boolean;
|
showHiddenEvents: boolean;
|
||||||
|
// [MSC1763] Opt-in: permanently redact your OWN messages once a room's
|
||||||
|
// retention window passes (default off — nothing auto-deletes by surprise).
|
||||||
|
enforceRetentionLocally: boolean;
|
||||||
legacyUsernameColor: boolean;
|
legacyUsernameColor: boolean;
|
||||||
|
|
||||||
showNotifications: boolean;
|
showNotifications: boolean;
|
||||||
@@ -288,6 +291,7 @@ const defaultSettings: Settings = {
|
|||||||
urlPreview: true,
|
urlPreview: true,
|
||||||
encUrlPreview: true,
|
encUrlPreview: true,
|
||||||
showHiddenEvents: false,
|
showHiddenEvents: false,
|
||||||
|
enforceRetentionLocally: false,
|
||||||
legacyUsernameColor: false,
|
legacyUsernameColor: false,
|
||||||
|
|
||||||
showNotifications: true,
|
showNotifications: true,
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { isExpired, RETENTION_PRESETS, RETENTION_MIN_MS } from './retention';
|
||||||
|
|
||||||
|
// MSC1763 retention: `isExpired` decides whether a message is past the room's
|
||||||
|
// retention window. It must be strict (> window, not >=) and a disabled policy
|
||||||
|
// (0) must never expire anything.
|
||||||
|
|
||||||
|
const HOUR = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
test('isExpired: an event older than the window is expired', () => {
|
||||||
|
const now = 10 * HOUR;
|
||||||
|
assert.equal(isExpired(now - 2 * HOUR, HOUR, now), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isExpired: an event within the window is NOT expired', () => {
|
||||||
|
const now = 10 * HOUR;
|
||||||
|
assert.equal(isExpired(now - HOUR / 2, HOUR, now), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isExpired: exactly at the boundary is NOT expired (strict >)', () => {
|
||||||
|
const now = 10 * HOUR;
|
||||||
|
assert.equal(isExpired(now - HOUR, HOUR, now), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isExpired: a disabled policy (0 / negative) never expires', () => {
|
||||||
|
const now = 10 * HOUR;
|
||||||
|
assert.equal(isExpired(now - 100 * HOUR, 0, now), false);
|
||||||
|
assert.equal(isExpired(0, -1, now), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('presets: Off is 0 and the rest are strictly increasing, all >= the floor', () => {
|
||||||
|
assert.equal(RETENTION_PRESETS[0].ms, 0);
|
||||||
|
const nonZero = RETENTION_PRESETS.slice(1).map((p) => p.ms);
|
||||||
|
for (let i = 1; i < nonZero.length; i += 1) {
|
||||||
|
assert.ok(nonZero[i] > nonZero[i - 1], 'presets increase');
|
||||||
|
}
|
||||||
|
assert.ok(
|
||||||
|
nonZero.every((ms) => ms >= RETENTION_MIN_MS),
|
||||||
|
'all presets above the floor',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
|
|
||||||
|
// MSC1763 — per-room message retention (`m.room.retention`). `max_lifetime` is a
|
||||||
|
// duration in milliseconds after which a message is considered expired.
|
||||||
|
export type RetentionContent = {
|
||||||
|
max_lifetime?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Floor to avoid foot-guns (an admin fat-fingering a tiny value nuking a room).
|
||||||
|
export const RETENTION_MIN_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
export type RetentionPreset = { label: string; ms: number };
|
||||||
|
export const RETENTION_PRESETS: RetentionPreset[] = [
|
||||||
|
{ label: 'Off', ms: 0 },
|
||||||
|
{ label: '1 Day', ms: DAY_MS },
|
||||||
|
{ label: '1 Week', ms: 7 * DAY_MS },
|
||||||
|
{ label: '1 Month', ms: 30 * DAY_MS },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The room's active retention window in ms, or `undefined` when unset/disabled. */
|
||||||
|
export const getRoomRetentionMs = (room: Room): number | undefined => {
|
||||||
|
const event = room.currentState.getStateEvents(StateEvent.RoomRetention, '');
|
||||||
|
const ms = event?.getContent<RetentionContent>()?.max_lifetime;
|
||||||
|
return typeof ms === 'number' && ms > 0 ? ms : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True when an event at `tsMs` has passed the `maxLifetimeMs` retention window. */
|
||||||
|
export const isExpired = (tsMs: number, maxLifetimeMs: number, nowMs: number): boolean =>
|
||||||
|
maxLifetimeMs > 0 && nowMs - tsMs > maxLifetimeMs;
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export enum AccountDataEvent {
|
|||||||
PushRules = 'm.push_rules',
|
PushRules = 'm.push_rules',
|
||||||
Direct = 'm.direct',
|
Direct = 'm.direct',
|
||||||
IgnoredUserList = 'm.ignored_user_list',
|
IgnoredUserList = 'm.ignored_user_list',
|
||||||
|
// [MSC2867] Per-room "mark as unread" flag (room account data).
|
||||||
|
MarkedUnread = 'm.marked_unread',
|
||||||
|
|
||||||
CinnySpaces = 'in.cinny.spaces',
|
CinnySpaces = 'in.cinny.spaces',
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export enum StateEvent {
|
|||||||
RoomPinnedEvents = 'm.room.pinned_events',
|
RoomPinnedEvents = 'm.room.pinned_events',
|
||||||
RoomEncryption = 'm.room.encryption',
|
RoomEncryption = 'm.room.encryption',
|
||||||
RoomHistoryVisibility = 'm.room.history_visibility',
|
RoomHistoryVisibility = 'm.room.history_visibility',
|
||||||
|
// [MSC1763] Per-room message retention policy (disappearing messages).
|
||||||
|
RoomRetention = 'm.room.retention',
|
||||||
RoomGuestAccess = 'm.room.guest_access',
|
RoomGuestAccess = 'm.room.guest_access',
|
||||||
RoomServerAcl = 'm.room.server_acl',
|
RoomServerAcl = 'm.room.server_acl',
|
||||||
RoomTombstone = 'm.room.tombstone',
|
RoomTombstone = 'm.room.tombstone',
|
||||||
|
|||||||
Reference in New Issue
Block a user