Compare commits
14 Commits
b7788cc79c
..
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
| 57da9a6ce8 | |||
| eb34b04708 | |||
| fd9e4a9802 | |||
| f12175e76f | |||
| b5db617bd2 | |||
| 4ecc173554 | |||
| 44854a1529 | |||
| 43f4ceb45d | |||
| 17bd50cc4e | |||
| 82e52e1bc7 | |||
| d46b91b1b8 | |||
| 5b94a44eb3 | |||
| ca9abb5363 | |||
| 21276a47fc |
@@ -675,6 +675,14 @@ Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view,
|
|||||||
|
|
||||||
## Outstanding verification backlog
|
## Outstanding verification backlog
|
||||||
|
|
||||||
|
**Room Widgets (MSC1236, 2026-07 — needs the CSP `frame-src` widening + `nginx -s reload` first):** In a room, the header **Widgets** button (grid icon, desktop) opens a right-side panel. As an admin (PL to modify widgets): **Add Widget** with a name + an https URL (e.g. an Etherpad `https://…` or any embeddable page) → it appears in the list; click it → it renders in a sandboxed iframe in the panel; **Remove** clears it. A non-admin sees the list + can open widgets but has no Add/Remove. Check: a non-https or same-origin URL is rejected on Add with a clear message; the panel is a full-screen overlay on mobile and is mutually exclusive with the Thread/Gallery/Members panels; if a widget stays blank, the prod CSP `frame-src` still needs widening. Widgets get only benign display capabilities (they can't send/read room events in v1).
|
||||||
|
|
||||||
|
**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.)
|
||||||
|
|||||||
+208
-1009
File diff suppressed because it is too large
Load Diff
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."
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import {
|
|||||||
color,
|
color,
|
||||||
Spinner,
|
Spinner,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import to from 'await-to-js';
|
import to from 'await-to-js';
|
||||||
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
|
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
|
||||||
|
import { useSaveFile } from '../hooks/useSaveFile';
|
||||||
import { useModalStyle } from '../hooks/useModalStyle';
|
import { useModalStyle } from '../hooks/useModalStyle';
|
||||||
import { PasswordInput } from './password-input';
|
import { PasswordInput } from './password-input';
|
||||||
import { ContainerColor } from '../styles/ContainerColor.css';
|
import { ContainerColor } from '../styles/ContainerColor.css';
|
||||||
@@ -230,6 +230,7 @@ type RecoveryKeyDisplayProps = {
|
|||||||
};
|
};
|
||||||
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
|
const saveFile = useSaveFile();
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
copyToClipboard(recoveryKey);
|
copyToClipboard(recoveryKey);
|
||||||
@@ -239,7 +240,7 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
|||||||
const blob = new Blob([recoveryKey], {
|
const blob = new Blob([recoveryKey], {
|
||||||
type: 'text/plain;charset=us-ascii',
|
type: 'text/plain;charset=us-ascii',
|
||||||
});
|
});
|
||||||
FileSaver.saveAs(blob, 'recovery-key.txt');
|
saveFile(blob, 'recovery-key.txt');
|
||||||
};
|
};
|
||||||
|
|
||||||
const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*');
|
const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*');
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
config,
|
config,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import FileSaver from 'file-saver';
|
import { useSaveFile } from '../../hooks/useSaveFile';
|
||||||
import * as css from './PdfViewer.css';
|
import * as css from './PdfViewer.css';
|
||||||
import { AsyncStatus } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus } from '../../hooks/useAsyncCallback';
|
||||||
import { useZoom } from '../../hooks/useZoom';
|
import { useZoom } from '../../hooks/useZoom';
|
||||||
@@ -36,6 +36,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
|
|||||||
({ className, name, src, requestClose, ...props }, ref) => {
|
({ className, name, src, requestClose, ...props }, ref) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const saveFile = useSaveFile();
|
||||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||||
|
|
||||||
const [pdfJSState, loadPdfJS] = usePdfJSLoader();
|
const [pdfJSState, loadPdfJS] = usePdfJSLoader();
|
||||||
@@ -76,7 +77,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
|
|||||||
}, [docState, pageNo, zoom]);
|
}, [docState, pageNo, zoom]);
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
FileSaver.saveAs(src, name);
|
saveFile(src, name);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,7 +31,29 @@ import { ImageViewer } from './image-viewer';
|
|||||||
import { PdfViewer } from './Pdf-viewer';
|
import { PdfViewer } from './Pdf-viewer';
|
||||||
import { TextViewer } from './text-viewer';
|
import { TextViewer } from './text-viewer';
|
||||||
import { testMatrixTo } from '../plugins/matrix-to';
|
import { testMatrixTo } from '../plugins/matrix-to';
|
||||||
import { IImageContent } from '../../types/matrix/common';
|
import { IAudioContent, IFileContent, IImageContent } from '../../types/matrix/common';
|
||||||
|
|
||||||
|
// Audio is frequently sent as m.file (bridges/other clients, or when the browser
|
||||||
|
// reported a non-audio/* mime on upload). Detect that so we can play it inline
|
||||||
|
// like m.audio instead of showing only a download button.
|
||||||
|
const AUDIO_EXT_MIME: Record<string, string> = {
|
||||||
|
mp3: 'audio/mpeg',
|
||||||
|
m4a: 'audio/mp4',
|
||||||
|
aac: 'audio/aac',
|
||||||
|
oga: 'audio/ogg',
|
||||||
|
ogg: 'audio/ogg',
|
||||||
|
opus: 'audio/ogg',
|
||||||
|
wav: 'audio/wav',
|
||||||
|
flac: 'audio/flac',
|
||||||
|
weba: 'audio/webm',
|
||||||
|
};
|
||||||
|
const resolveInlineAudioMime = (content: IFileContent): string | undefined => {
|
||||||
|
const mime = content.info?.mimetype;
|
||||||
|
if (typeof mime === 'string' && mime.startsWith('audio')) return mime;
|
||||||
|
const name = content.filename ?? content.body ?? '';
|
||||||
|
const ext = name.split('.').pop()?.toLowerCase();
|
||||||
|
return ext ? AUDIO_EXT_MIME[ext] : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
type RenderMessageContentProps = {
|
type RenderMessageContentProps = {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -276,6 +298,29 @@ export function RenderMessageContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msgType === MsgType.File) {
|
if (msgType === MsgType.File) {
|
||||||
|
// If an m.file is actually audio, play it inline (like m.audio) instead of
|
||||||
|
// only offering a download. MAudio falls back to renderFile if playback fails.
|
||||||
|
const audioMime = resolveInlineAudioMime(getContent<IFileContent>());
|
||||||
|
if (audioMime) {
|
||||||
|
const fileContent = getContent<IFileContent>();
|
||||||
|
const audioContent = {
|
||||||
|
...fileContent,
|
||||||
|
info: { ...(fileContent.info ?? {}), mimetype: audioMime },
|
||||||
|
} as unknown as IAudioContent;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MAudio
|
||||||
|
content={audioContent}
|
||||||
|
renderAsFile={renderFile}
|
||||||
|
renderAudioContent={(props) => (
|
||||||
|
<AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} />
|
||||||
|
)}
|
||||||
|
outlined={outlineAttachment}
|
||||||
|
/>
|
||||||
|
{renderCaption()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
return renderFile();
|
return renderFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
||||||
|
import { useSaveFile } from '../../hooks/useSaveFile';
|
||||||
import * as css from './ImageViewer.css';
|
import * as css from './ImageViewer.css';
|
||||||
import { useZoom } from '../../hooks/useZoom';
|
import { useZoom } from '../../hooks/useZoom';
|
||||||
import { usePan } from '../../hooks/usePan';
|
import { usePan } from '../../hooks/usePan';
|
||||||
@@ -17,12 +17,13 @@ export type ImageViewerProps = {
|
|||||||
export const ImageViewer = as<'div', ImageViewerProps>(
|
export const ImageViewer = as<'div', ImageViewerProps>(
|
||||||
({ className, alt, src, requestClose, ...props }, ref) => {
|
({ className, alt, src, requestClose, ...props }, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const saveFile = useSaveFile();
|
||||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||||
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
const fileContent = await downloadMedia(src);
|
const fileContent = await downloadMedia(src);
|
||||||
FileSaver.saveAs(fileContent, alt);
|
saveFile(fileContent, alt);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds';
|
import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds';
|
||||||
import React, { ReactNode, useCallback } from 'react';
|
import React, { ReactNode, useCallback } from 'react';
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
||||||
|
import { useSaveFile } from '../../hooks/useSaveFile';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
@@ -24,6 +24,7 @@ type FileDownloadButtonProps = {
|
|||||||
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
|
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const saveFile = useSaveFile();
|
||||||
|
|
||||||
const [downloadState, download] = useAsyncCallback(
|
const [downloadState, download] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
@@ -34,18 +35,19 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
|
|||||||
: await downloadMedia(mediaUrl);
|
: await downloadMedia(mediaUrl);
|
||||||
|
|
||||||
const fileURL = URL.createObjectURL(fileContent);
|
const fileURL = URL.createObjectURL(fileContent);
|
||||||
FileSaver.saveAs(fileURL, filename);
|
saveFile(fileURL, filename);
|
||||||
return fileURL;
|
return fileURL;
|
||||||
}, [mx, url, useAuthentication, mimeType, encInfo, filename]),
|
}, [mx, url, useAuthentication, mimeType, encInfo, filename, saveFile]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const downloading = downloadState.status === AsyncStatus.Loading;
|
const downloading = downloadState.status === AsyncStatus.Loading;
|
||||||
const hasError = downloadState.status === AsyncStatus.Error;
|
const hasError = downloadState.status === AsyncStatus.Error;
|
||||||
|
const succeeded = downloadState.status === AsyncStatus.Success;
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
disabled={downloading}
|
disabled={downloading}
|
||||||
onClick={download}
|
onClick={download}
|
||||||
variant={hasError ? 'Critical' : 'SurfaceVariant'}
|
variant={hasError ? 'Critical' : succeeded ? 'Success' : 'SurfaceVariant'}
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
aria-label={
|
aria-label={
|
||||||
@@ -53,13 +55,15 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
|
|||||||
? 'Downloading...'
|
? 'Downloading...'
|
||||||
: hasError
|
: hasError
|
||||||
? 'Download failed, click to retry'
|
? 'Download failed, click to retry'
|
||||||
: 'Download file'
|
: succeeded
|
||||||
|
? 'Downloaded — click to download again'
|
||||||
|
: 'Download file'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{downloading ? (
|
{downloading ? (
|
||||||
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
|
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
|
||||||
) : (
|
) : (
|
||||||
<Icon size="100" src={Icons.Download} />
|
<Icon size="100" src={succeeded ? Icons.Check : Icons.Download} />
|
||||||
)}
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
as,
|
as,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { IFileInfo } from '../../../../types/matrix/common';
|
import { IFileInfo } from '../../../../types/matrix/common';
|
||||||
|
import { useSaveFile } from '../../../hooks/useSaveFile';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { bytesToSize } from '../../../utils/common';
|
import { bytesToSize } from '../../../utils/common';
|
||||||
@@ -252,6 +252,7 @@ export type DownloadFileProps = {
|
|||||||
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
|
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const saveFile = useSaveFile();
|
||||||
|
|
||||||
const [downloadState, download] = useAsyncCallback(
|
const [downloadState, download] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
@@ -262,9 +263,9 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
|
|||||||
: await downloadMedia(mediaUrl);
|
: await downloadMedia(mediaUrl);
|
||||||
|
|
||||||
const fileURL = URL.createObjectURL(fileContent);
|
const fileURL = URL.createObjectURL(fileContent);
|
||||||
FileSaver.saveAs(fileURL, body);
|
saveFile(fileURL, body);
|
||||||
return fileURL;
|
return fileURL;
|
||||||
}, [mx, url, useAuthentication, mimeType, encInfo, body]),
|
}, [mx, url, useAuthentication, mimeType, encInfo, body, saveFile]),
|
||||||
);
|
);
|
||||||
|
|
||||||
return downloadState.status === AsyncStatus.Error ? (
|
return downloadState.status === AsyncStatus.Error ? (
|
||||||
@@ -277,7 +278,7 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
|
|||||||
size="400"
|
size="400"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
downloadState.status === AsyncStatus.Success
|
downloadState.status === AsyncStatus.Success
|
||||||
? FileSaver.saveAs(downloadState.data, body)
|
? saveFile(downloadState.data, body)
|
||||||
: download()
|
: download()
|
||||||
}
|
}
|
||||||
disabled={downloadState.status === AsyncStatus.Loading}
|
disabled={downloadState.status === AsyncStatus.Loading}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -21,6 +21,7 @@ import { uniqueShortcode } from '../../plugins/soundboard/utils';
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import {
|
import {
|
||||||
|
getAudioDurationMs,
|
||||||
playClipLocally,
|
playClipLocally,
|
||||||
resolveClipObjectUrl,
|
resolveClipObjectUrl,
|
||||||
SOUNDBOARD_ACCEPT,
|
SOUNDBOARD_ACCEPT,
|
||||||
@@ -29,6 +30,49 @@ import {
|
|||||||
} from '../../utils/soundboardClips';
|
} from '../../utils/soundboardClips';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
// Injected once: the little "now playing" equalizer bars animation.
|
||||||
|
const EQ_STYLE_ID = 'lotus-soundboard-eq-keyframes';
|
||||||
|
function ensureEqKeyframes() {
|
||||||
|
if (typeof document === 'undefined' || document.getElementById(EQ_STYLE_ID)) return;
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = EQ_STYLE_ID;
|
||||||
|
style.textContent = `
|
||||||
|
@keyframes lotusSbEq { 0%,100% { transform: scaleY(0.3); } 50% { transform: scaleY(1); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { @keyframes lotusSbEq { 0%,100% { transform: scaleY(0.6); } } }
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayingBars() {
|
||||||
|
return (
|
||||||
|
<Box alignItems="Center" gap="100" style={{ height: toRem(14) }} aria-hidden>
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: toRem(3),
|
||||||
|
height: toRem(14),
|
||||||
|
borderRadius: toRem(2),
|
||||||
|
background: color.Primary.Main,
|
||||||
|
transformOrigin: 'center bottom',
|
||||||
|
animation: `lotusSbEq 0.7s ease-in-out ${i * 0.15}s infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short clip length shown while adjusting a sound: "3.2s", or "1:04" if ≥ 60s.
|
||||||
|
const formatClipSeconds = (seconds: number): string => {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) return '';
|
||||||
|
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = Math.round(seconds % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
type ClipDraft = {
|
type ClipDraft = {
|
||||||
url: string;
|
url: string;
|
||||||
body: string;
|
body: string;
|
||||||
@@ -59,9 +103,20 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [emojiFor, setEmojiFor] = useState<string>(); // shortcode currently picking an emoji
|
const [emojiFor, setEmojiFor] = useState<string>(); // shortcode currently picking an emoji
|
||||||
const [busyPreview, setBusyPreview] = useState<string>();
|
const [busyPreview, setBusyPreview] = useState<string>();
|
||||||
|
const [playingKey, setPlayingKey] = useState<string>();
|
||||||
|
const [durations, setDurations] = useState<Map<string, number>>(new Map()); // shortcode -> seconds
|
||||||
|
const audioElRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const emojiAnchorRef = useRef<HTMLElement | null>(null);
|
const emojiAnchorRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ensureEqKeyframes();
|
||||||
|
return () => {
|
||||||
|
audioElRef.current?.pause();
|
||||||
|
audioElRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const existing = useMemo(() => pack.getClips(), [pack]);
|
const existing = useMemo(() => pack.getClips(), [pack]);
|
||||||
const clipCount = existing.filter((c) => !deleted.has(c.shortcode)).length + uploads.length;
|
const clipCount = existing.filter((c) => !deleted.has(c.shortcode)).length + uploads.length;
|
||||||
|
|
||||||
@@ -78,19 +133,47 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stopPlayback = useCallback(() => {
|
||||||
|
audioElRef.current?.pause();
|
||||||
|
audioElRef.current = null;
|
||||||
|
setPlayingKey(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const preview = useCallback(
|
const preview = useCallback(
|
||||||
async (id: string, mxc: string, volume: number) => {
|
async (id: string, mxc: string, volume: number) => {
|
||||||
|
// Clicking the clip that's already playing stops it (toggle).
|
||||||
|
if (audioElRef.current && playingKey === id) {
|
||||||
|
stopPlayback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopPlayback(); // stop any other clip first
|
||||||
setBusyPreview(id);
|
setBusyPreview(id);
|
||||||
try {
|
try {
|
||||||
const url = await resolveClipObjectUrl(mx, mxc);
|
const url = await resolveClipObjectUrl(mx, mxc);
|
||||||
playClipLocally(url, volume / 100);
|
const audio = playClipLocally(url, volume / 100);
|
||||||
|
if (audio) {
|
||||||
|
audioElRef.current = audio;
|
||||||
|
setPlayingKey(id);
|
||||||
|
audio.addEventListener('loadedmetadata', () => {
|
||||||
|
if (Number.isFinite(audio.duration)) {
|
||||||
|
setDurations((prev) => new Map(prev).set(id, audio.duration));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const clear = () => {
|
||||||
|
if (audioElRef.current === audio) audioElRef.current = null;
|
||||||
|
setPlayingKey((k) => (k === id ? undefined : k));
|
||||||
|
};
|
||||||
|
audio.addEventListener('ended', clear);
|
||||||
|
audio.addEventListener('pause', clear);
|
||||||
|
audio.addEventListener('error', clear);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore preview errors */
|
/* ignore preview errors */
|
||||||
} finally {
|
} finally {
|
||||||
setBusyPreview(undefined);
|
setBusyPreview(undefined);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mx],
|
[mx, playingKey, stopPlayback],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFiles = useCallback(
|
const handleFiles = useCallback(
|
||||||
@@ -112,6 +195,8 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
throw new Error(`"${file.name}" is too large (max 1 MB).`);
|
throw new Error(`"${file.name}" is too large (max 1 MB).`);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const durationMs = await getAudioDurationMs(file);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
|
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
|
||||||
const mxc = res.content_uri;
|
const mxc = res.content_uri;
|
||||||
if (!mxc) throw new Error('Upload failed.');
|
if (!mxc) throw new Error('Upload failed.');
|
||||||
@@ -126,7 +211,7 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
body: name,
|
body: name,
|
||||||
emoji: '',
|
emoji: '',
|
||||||
volume: 100,
|
volume: 100,
|
||||||
info: { mimetype: file.type || undefined, size: file.size },
|
info: { mimetype: file.type || undefined, size: file.size, duration: durationMs },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -182,6 +267,9 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
setDraft(key, patch, base);
|
setDraft(key, patch, base);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const isPlaying = playingKey === key;
|
||||||
|
const clipSeconds =
|
||||||
|
durations.get(key) ?? (base.info?.duration != null ? base.info.duration / 1000 : undefined);
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={key}
|
key={key}
|
||||||
@@ -197,12 +285,16 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
<IconButton
|
<IconButton
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
variant="Secondary"
|
variant={isPlaying ? 'Primary' : 'Secondary'}
|
||||||
disabled={busyPreview === key}
|
disabled={busyPreview === key}
|
||||||
onClick={() => preview(key, base.url, rowVolume)}
|
onClick={() => preview(key, base.url, rowVolume)}
|
||||||
aria-label={`Preview ${rowBody}`}
|
aria-label={isPlaying ? `Stop ${rowBody}` : `Preview ${rowBody}`}
|
||||||
>
|
>
|
||||||
{busyPreview === key ? <Spinner size="100" /> : <Icon size="100" src={Icons.Play} />}
|
{busyPreview === key ? (
|
||||||
|
<Spinner size="100" />
|
||||||
|
) : (
|
||||||
|
<Icon size="100" src={isPlaying ? Icons.Pause : Icons.Play} filled={isPlaying} />
|
||||||
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="300"
|
size="300"
|
||||||
@@ -227,7 +319,24 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
aria-label="Clip name"
|
aria-label="Clip name"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box alignItems="Center" gap="100" shrink="No" style={{ width: toRem(120) }}>
|
<Box
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="End"
|
||||||
|
gap="100"
|
||||||
|
shrink="No"
|
||||||
|
style={{ width: toRem(52) }}
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<PlayingBars />
|
||||||
|
) : (
|
||||||
|
clipSeconds !== undefined && (
|
||||||
|
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{formatClipSeconds(clipSeconds)}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box alignItems="Center" gap="100" shrink="No" style={{ width: toRem(148) }}>
|
||||||
<Icon size="50" src={Icons.VolumeHigh} />
|
<Icon size="50" src={Icons.VolumeHigh} />
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -237,9 +346,12 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
defaultValue={rowVolume}
|
defaultValue={rowVolume}
|
||||||
disabled={!canEdit || markedDeleted}
|
disabled={!canEdit || markedDeleted}
|
||||||
onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })}
|
onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })}
|
||||||
style={{ flexGrow: 1 }}
|
style={{ flexGrow: 1, minWidth: 0 }}
|
||||||
aria-label="Clip volume"
|
aria-label="Clip volume"
|
||||||
/>
|
/>
|
||||||
|
<Text size="T200" priority="300" style={{ width: toRem(30), textAlign: 'right' }}>
|
||||||
|
{rowVolume}%
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{canEdit && !isUpload && (
|
{canEdit && !isUpload && (
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -308,7 +420,13 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
{existing.map((c) =>
|
{existing.map((c) =>
|
||||||
renderRow(
|
renderRow(
|
||||||
c.shortcode,
|
c.shortcode,
|
||||||
{ url: c.url, body: c.body ?? c.shortcode, emoji: c.emoji ?? '', volume: c.volume },
|
{
|
||||||
|
url: c.url,
|
||||||
|
body: c.body ?? c.shortcode,
|
||||||
|
emoji: c.emoji ?? '',
|
||||||
|
volume: c.volume,
|
||||||
|
info: c.info,
|
||||||
|
},
|
||||||
false,
|
false,
|
||||||
deleted.has(c.shortcode),
|
deleted.has(c.shortcode),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -196,7 +196,8 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
aria-label={`Play ${clip.name}`}
|
aria-label={`Play ${clip.name}`}
|
||||||
style={{
|
style={{
|
||||||
width: toRem(76),
|
width: toRem(76),
|
||||||
height: toRem(76),
|
minHeight: toRem(76),
|
||||||
|
height: 'auto',
|
||||||
padding: config.space.S100,
|
padding: config.space.S100,
|
||||||
borderRadius: config.radii.R400,
|
borderRadius: config.radii.R400,
|
||||||
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
@@ -215,7 +216,19 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
clip.emoji || '🔊'
|
clip.emoji || '🔊'
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T200" truncate style={{ maxWidth: '100%' }}>
|
<Text
|
||||||
|
size="T200"
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
textAlign: 'center',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
lineHeight: 1.15,
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 3,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{clip.name}
|
{clip.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</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} />
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { RoomView } from './RoomView';
|
|||||||
import { MembersDrawer } from './MembersDrawer';
|
import { MembersDrawer } from './MembersDrawer';
|
||||||
import { MediaGallery } from './MediaGallery';
|
import { MediaGallery } from './MediaGallery';
|
||||||
import { mediaGalleryAtom } from '../../state/mediaGallery';
|
import { mediaGalleryAtom } from '../../state/mediaGallery';
|
||||||
|
import { WidgetsPanel } from './widgets/WidgetsPanel';
|
||||||
|
import { widgetsPanelAtom } from '../../state/widgetsPanel';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
@@ -39,6 +41,8 @@ export function Room() {
|
|||||||
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId));
|
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId));
|
||||||
const galleryOpen = useAtomValue(mediaGalleryAtom);
|
const galleryOpen = useAtomValue(mediaGalleryAtom);
|
||||||
const setGalleryOpen = useSetAtom(mediaGalleryAtom);
|
const setGalleryOpen = useSetAtom(mediaGalleryAtom);
|
||||||
|
const widgetsOpen = useAtomValue(widgetsPanelAtom);
|
||||||
|
const setWidgetsOpen = useSetAtom(widgetsPanelAtom);
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
@@ -64,30 +68,40 @@ export function Room() {
|
|||||||
|
|
||||||
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
|
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
|
||||||
|
|
||||||
// Thread panel and media gallery are mutually exclusive on every screen size:
|
// The content panels (thread / media gallery / widgets) are mutually exclusive
|
||||||
// opening one closes the other. Detect the just-opened transition so whichever
|
// on every screen size: opening one closes the others. Detect the just-opened
|
||||||
// was opened most recently wins.
|
// transition so whichever was opened most recently wins.
|
||||||
const prevThreadRef = useRef(activeThreadId);
|
const prevThreadRef = useRef(activeThreadId);
|
||||||
const prevGalleryRef = useRef(galleryOpen);
|
const prevGalleryRef = useRef(galleryOpen);
|
||||||
|
const prevWidgetsRef = useRef(widgetsOpen);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const threadJustOpened = Boolean(activeThreadId) && !prevThreadRef.current;
|
const threadJustOpened = Boolean(activeThreadId) && !prevThreadRef.current;
|
||||||
const galleryJustOpened = galleryOpen && !prevGalleryRef.current;
|
const galleryJustOpened = galleryOpen && !prevGalleryRef.current;
|
||||||
if (threadJustOpened && galleryOpen) {
|
const widgetsJustOpened = widgetsOpen && !prevWidgetsRef.current;
|
||||||
setGalleryOpen(false);
|
if (threadJustOpened) {
|
||||||
} else if (galleryJustOpened && activeThreadId) {
|
if (galleryOpen) setGalleryOpen(false);
|
||||||
setActiveThreadId(null);
|
if (widgetsOpen) setWidgetsOpen(false);
|
||||||
|
} else if (galleryJustOpened) {
|
||||||
|
if (activeThreadId) setActiveThreadId(null);
|
||||||
|
if (widgetsOpen) setWidgetsOpen(false);
|
||||||
|
} else if (widgetsJustOpened) {
|
||||||
|
if (activeThreadId) setActiveThreadId(null);
|
||||||
|
if (galleryOpen) setGalleryOpen(false);
|
||||||
}
|
}
|
||||||
prevThreadRef.current = activeThreadId;
|
prevThreadRef.current = activeThreadId;
|
||||||
prevGalleryRef.current = galleryOpen;
|
prevGalleryRef.current = galleryOpen;
|
||||||
}, [activeThreadId, galleryOpen, setGalleryOpen, setActiveThreadId]);
|
prevWidgetsRef.current = widgetsOpen;
|
||||||
|
}, [activeThreadId, galleryOpen, widgetsOpen, setGalleryOpen, setActiveThreadId, setWidgetsOpen]);
|
||||||
|
|
||||||
// On non-desktop screens at most one right-side panel may show, priority
|
// On non-desktop screens at most one right-side panel may show, priority
|
||||||
// thread > gallery > members. On desktop thread + members may coexist while
|
// thread > gallery > widgets > members. On desktop thread + members may coexist
|
||||||
// thread + gallery stay mutually exclusive (via the effect above).
|
// while the content panels stay mutually exclusive (via the effect above).
|
||||||
const isDesktop = screenSize === ScreenSize.Desktop;
|
const isDesktop = screenSize === ScreenSize.Desktop;
|
||||||
const showThreadPanel = !callView && Boolean(activeThreadId);
|
const showThreadPanel = !callView && Boolean(activeThreadId);
|
||||||
const showGallery = !callView && galleryOpen && (isDesktop || !activeThreadId);
|
const showGallery = !callView && galleryOpen && (isDesktop || !activeThreadId);
|
||||||
const showMembers = !callView && isDrawer && (isDesktop || (!activeThreadId && !galleryOpen));
|
const showWidgets = !callView && widgetsOpen && (isDesktop || (!activeThreadId && !galleryOpen));
|
||||||
|
const showMembers =
|
||||||
|
!callView && isDrawer && (isDesktop || (!activeThreadId && !galleryOpen && !widgetsOpen));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PowerLevelsContextProvider value={powerLevels}>
|
<PowerLevelsContextProvider value={powerLevels}>
|
||||||
@@ -125,6 +139,18 @@ export function Room() {
|
|||||||
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
|
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{showWidgets && (
|
||||||
|
<>
|
||||||
|
{screenSize === ScreenSize.Desktop && (
|
||||||
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
|
)}
|
||||||
|
<WidgetsPanel
|
||||||
|
key={room.roomId}
|
||||||
|
room={room}
|
||||||
|
requestClose={() => setWidgetsOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{showThreadPanel && activeThreadId && (
|
{showThreadPanel && activeThreadId && (
|
||||||
<>
|
<>
|
||||||
{screenSize === ScreenSize.Desktop && (
|
{screenSize === ScreenSize.Desktop && (
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
|
|||||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||||
import { webRTCSupported } from '../../utils/rtc';
|
import { webRTCSupported } from '../../utils/rtc';
|
||||||
import { mediaGalleryAtom } from '../../state/mediaGallery';
|
import { mediaGalleryAtom } from '../../state/mediaGallery';
|
||||||
|
import { widgetsPanelAtom } from '../../state/widgetsPanel';
|
||||||
import { usePendingKnocks } from '../../hooks/usePendingKnocks';
|
import { usePendingKnocks } from '../../hooks/usePendingKnocks';
|
||||||
import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
|
import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
|
||||||
|
|
||||||
@@ -489,6 +490,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||||||
|
|
||||||
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||||
const [galleryOpen, setGalleryOpen] = useAtom(mediaGalleryAtom);
|
const [galleryOpen, setGalleryOpen] = useAtom(mediaGalleryAtom);
|
||||||
|
const [widgetsOpen, setWidgetsOpen] = useAtom(widgetsPanelAtom);
|
||||||
const pendingKnocks = usePendingKnocks(room);
|
const pendingKnocks = usePendingKnocks(room);
|
||||||
|
|
||||||
const handleSearchClick = () => {
|
const handleSearchClick = () => {
|
||||||
@@ -725,6 +727,29 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
{screenSize === ScreenSize.Desktop && (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Bottom"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>{widgetsOpen ? 'Hide Widgets' : 'Widgets'}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton
|
||||||
|
fill="None"
|
||||||
|
ref={triggerRef}
|
||||||
|
onClick={() => setWidgetsOpen(!widgetsOpen)}
|
||||||
|
aria-label="Toggle widgets"
|
||||||
|
aria-pressed={widgetsOpen}
|
||||||
|
>
|
||||||
|
<Icon size="400" src={Icons.Category} filled={widgetsOpen} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
{screenSize === ScreenSize.Desktop && (
|
{screenSize === ScreenSize.Desktop && (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export function buildForwardContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete content['m.relates_to'];
|
delete content['m.relates_to'];
|
||||||
|
// Drop intentional mentions so forwarding a message doesn't re-ping the
|
||||||
|
// originally-mentioned users (they're not in the destination room's context).
|
||||||
|
delete content['m.mentions'];
|
||||||
if (typeof content.body === 'string') {
|
if (typeof content.body === 'string') {
|
||||||
content.body = trimReplyFromBody(content.body);
|
content.body = trimReplyFromBody(content.body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { type Capability, WidgetDriver } from 'matrix-widget-api';
|
||||||
|
import { filterWidgetCapabilities } from './widgetUtils';
|
||||||
|
|
||||||
|
// A minimal, conservative WidgetDriver for general room widgets. It only narrows
|
||||||
|
// the capabilities a widget may hold (to a benign display-only subset — see
|
||||||
|
// widgetUtils). All data-access methods (sendEvent / readRoomState / sendToDevice
|
||||||
|
// / uploads …) are inherited from the base WidgetDriver and are never reached,
|
||||||
|
// because the capabilities that would gate them are denied here. A richer,
|
||||||
|
// consent-prompt-driven driver is a follow-up.
|
||||||
|
export class GeneralWidgetDriver extends WidgetDriver {
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> {
|
||||||
|
return filterWidgetCapabilities(requested);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Box, Icon, Icons, Text, color } from 'folds';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { ClientWidgetApi, Widget } from 'matrix-widget-api';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
|
||||||
|
import { isWidgetUrlSafe } from './widgetUtils';
|
||||||
|
|
||||||
|
type RoomWidgetViewProps = {
|
||||||
|
room: Room;
|
||||||
|
widget: Widget;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hosts one room widget in a sandboxed iframe via ClientWidgetApi (so widgets
|
||||||
|
// that wait on the client handshake load), with a conservative capability driver.
|
||||||
|
// Re-mounts only when the widget id or its (template) URL changes — not on every
|
||||||
|
// unrelated room-state update — so viewing a widget doesn't reload constantly.
|
||||||
|
export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const widgetRef = useRef(widget);
|
||||||
|
widgetRef.current = widget;
|
||||||
|
const [blocked, setBlocked] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return undefined;
|
||||||
|
const current = widgetRef.current;
|
||||||
|
|
||||||
|
const completeUrl = current.getCompleteUrl({
|
||||||
|
currentUserId: mx.getSafeUserId(),
|
||||||
|
widgetRoomId: room.roomId,
|
||||||
|
deviceId: mx.getDeviceId() ?? undefined,
|
||||||
|
baseUrl: mx.baseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Security: never render a same-origin widget with allow-same-origin (a
|
||||||
|
// same-origin frame could break out of the sandbox against our own origin).
|
||||||
|
if (!isWidgetUrlSafe(completeUrl, window.location.origin)) {
|
||||||
|
setBlocked(true);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
setBlocked(false);
|
||||||
|
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.title = current.name || 'Widget';
|
||||||
|
iframe.sandbox.value =
|
||||||
|
'allow-forms allow-scripts allow-same-origin allow-popups allow-downloads';
|
||||||
|
iframe.allow = 'autoplay; clipboard-write;';
|
||||||
|
iframe.src = completeUrl;
|
||||||
|
iframe.style.width = '100%';
|
||||||
|
iframe.style.height = '100%';
|
||||||
|
iframe.style.border = 'none';
|
||||||
|
container.append(iframe);
|
||||||
|
|
||||||
|
const clientApi = new ClientWidgetApi(current, iframe, new GeneralWidgetDriver());
|
||||||
|
clientApi.setViewedRoomId(room.roomId);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clientApi.stop();
|
||||||
|
iframe.remove();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [mx, room.roomId, widget.id, widget.templateUrl]);
|
||||||
|
|
||||||
|
if (blocked) {
|
||||||
|
return (
|
||||||
|
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
||||||
|
<Icon size="400" src={Icons.Warning} style={{ color: color.Warning.Main }} />
|
||||||
|
<Text size="T300" align="Center">
|
||||||
|
This widget can't be loaded because its URL is on this app's own origin.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Box ref={containerRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { config, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const WidgetsPanel = style({
|
||||||
|
width: toRem(360),
|
||||||
|
'@media': {
|
||||||
|
'(max-width: 750px)': {
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
width: '100%',
|
||||||
|
zIndex: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const WidgetsPanelHeader = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const WidgetsPanelContent = style({
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import React, { FormEventHandler, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Input,
|
||||||
|
Scroll,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
TooltipProvider,
|
||||||
|
color,
|
||||||
|
config,
|
||||||
|
} from 'folds';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import * as css from './WidgetsPanel.css';
|
||||||
|
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
||||||
|
import { RoomWidgetView } from './RoomWidgetView';
|
||||||
|
import { useRoomWidgets } from './useRoomWidgets';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
|
||||||
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
|
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||||
|
import { StateEvent } from '../../../../types/matrix/room';
|
||||||
|
import { generateWidgetId, validateWidgetUrl, WidgetUrlError } from './widgetUtils';
|
||||||
|
|
||||||
|
const urlErrorMessage = (err: WidgetUrlError): string => {
|
||||||
|
switch (err) {
|
||||||
|
case 'empty':
|
||||||
|
return 'Enter a widget URL.';
|
||||||
|
case 'not-https':
|
||||||
|
return 'Widget URLs must use https.';
|
||||||
|
case 'same-origin':
|
||||||
|
return 'That URL is not allowed (it is on this app’s own origin).';
|
||||||
|
default:
|
||||||
|
return 'That is not a valid URL.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type WidgetsPanelProps = {
|
||||||
|
room: Room;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const widgets = useRoomWidgets(room);
|
||||||
|
const powerLevels = usePowerLevelsContext();
|
||||||
|
const creators = useRoomCreators(room);
|
||||||
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
|
const canModify = permissions.stateEvent(StateEvent.Widget, mx.getSafeUserId());
|
||||||
|
|
||||||
|
const [viewingId, setViewingId] = useState<string | null>(null);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
const viewing = widgets.find((w) => w.id === viewingId) ?? null;
|
||||||
|
|
||||||
|
const handleAdd: FormEventHandler<HTMLFormElement> = async (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const target = evt.target as HTMLFormElement;
|
||||||
|
const nameInput = target.elements.namedItem('widgetName') as HTMLInputElement | null;
|
||||||
|
const urlInput = target.elements.namedItem('widgetUrl') as HTMLInputElement | null;
|
||||||
|
if (!urlInput) return;
|
||||||
|
const urlErr = validateWidgetUrl(urlInput.value, window.location.origin);
|
||||||
|
if (urlErr) {
|
||||||
|
setError(urlErrorMessage(urlErr));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(undefined);
|
||||||
|
setSaving(true);
|
||||||
|
const id = generateWidgetId();
|
||||||
|
const content = {
|
||||||
|
id,
|
||||||
|
type: 'm.custom',
|
||||||
|
url: urlInput.value.trim(),
|
||||||
|
name: nameInput?.value.trim() || 'Widget',
|
||||||
|
creatorUserId: mx.getSafeUserId(),
|
||||||
|
data: {},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await mx.sendStateEvent(room.roomId, StateEvent.Widget as any, content as any, id);
|
||||||
|
setAdding(false);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (id: string) => {
|
||||||
|
if (viewingId === id) setViewingId(null);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
mx.sendStateEvent(room.roomId, StateEvent.Widget as any, {} as any, id).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
className={classNames(css.WidgetsPanel, ContainerColor({ variant: 'Surface' }))}
|
||||||
|
direction="Column"
|
||||||
|
>
|
||||||
|
<Header className={css.WidgetsPanelHeader} variant="Background" size="600">
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Text size="H5" truncate>
|
||||||
|
Widgets
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||||
|
{room.name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<TooltipProvider
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>Close</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={triggerRef}
|
||||||
|
variant="Background"
|
||||||
|
aria-label="Close widgets"
|
||||||
|
onClick={requestClose}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Box grow="Yes" className={css.WidgetsPanelContent}>
|
||||||
|
{viewing ? (
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Box shrink="No" style={{ padding: config.space.S200 }}>
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => setViewingId(null)}
|
||||||
|
before={<Icon size="100" src={Icons.ArrowLeft} />}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
{viewing.name || 'Widget'}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<RoomWidgetView room={room} widget={viewing} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Scroll hideTrack visibility="Hover">
|
||||||
|
<Box direction="Column" gap="200" style={{ padding: config.space.S300 }}>
|
||||||
|
{widgets.length === 0 && (
|
||||||
|
<Text size="T200" style={{ opacity: 0.65 }}>
|
||||||
|
No widgets in this room yet.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{widgets.map((widget) => (
|
||||||
|
<Box key={widget.id} alignItems="Center" gap="200">
|
||||||
|
<Box
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
grow="Yes"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="200"
|
||||||
|
onClick={() => setViewingId(widget.id)}
|
||||||
|
style={{ cursor: 'pointer', minWidth: 0 }}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Category} />
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
{widget.name || widget.templateUrl}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{canModify && (
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Background"
|
||||||
|
aria-label={`Remove ${widget.name || 'widget'}`}
|
||||||
|
onClick={() => handleRemove(widget.id)}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Delete} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{canModify &&
|
||||||
|
(adding ? (
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
direction="Column"
|
||||||
|
gap="200"
|
||||||
|
onSubmit={handleAdd}
|
||||||
|
style={{ marginTop: config.space.S200 }}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
name="widgetName"
|
||||||
|
placeholder="Name (optional)"
|
||||||
|
variant="Secondary"
|
||||||
|
radii="300"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="widgetUrl"
|
||||||
|
placeholder="https://…"
|
||||||
|
variant="Secondary"
|
||||||
|
radii="300"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Box gap="200">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="300"
|
||||||
|
variant="Primary"
|
||||||
|
fill="Solid"
|
||||||
|
radii="300"
|
||||||
|
disabled={saving}
|
||||||
|
before={
|
||||||
|
saving ? <Spinner size="100" variant="Primary" fill="Solid" /> : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="B300">Add</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
setAdding(false);
|
||||||
|
setError(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => setAdding(true)}
|
||||||
|
before={<Icon size="100" src={Icons.Plus} />}
|
||||||
|
style={{ marginTop: config.space.S200 }}
|
||||||
|
>
|
||||||
|
<Text size="B300">Add Widget</Text>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
{error && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Widget, WidgetParser, IStateEvent } from 'matrix-widget-api';
|
||||||
|
import { StateEvent } from '../../../../types/matrix/room';
|
||||||
|
import { useRoomState } from '../../../hooks/useRoomState';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All valid `im.vector.modular.widgets` room widgets, reactive on room state.
|
||||||
|
* `WidgetParser` drops empty/removed (`{}`) and malformed entries.
|
||||||
|
*/
|
||||||
|
export const useRoomWidgets = (room: Room): Widget[] => {
|
||||||
|
const state = useRoomState(room);
|
||||||
|
return useMemo(() => {
|
||||||
|
const widgetEvents = state.get(StateEvent.Widget);
|
||||||
|
if (!widgetEvents) return [];
|
||||||
|
const stateEvents = Array.from(widgetEvents.values()).map(
|
||||||
|
(event) => event.getEffectiveEvent() as unknown as IStateEvent,
|
||||||
|
);
|
||||||
|
return WidgetParser.parseWidgetsFromRoomState(stateEvents);
|
||||||
|
}, [state]);
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { MatrixCapabilities, Capability } from 'matrix-widget-api';
|
||||||
|
import {
|
||||||
|
validateWidgetUrl,
|
||||||
|
isWidgetUrlSafe,
|
||||||
|
filterWidgetCapabilities,
|
||||||
|
generateWidgetId,
|
||||||
|
} from './widgetUtils';
|
||||||
|
|
||||||
|
const APP = 'https://chat.lotusguild.org';
|
||||||
|
|
||||||
|
test('validateWidgetUrl accepts a cross-origin https url', () => {
|
||||||
|
assert.equal(validateWidgetUrl('https://pad.example.org/p/room', APP), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateWidgetUrl rejects empty / invalid / http / same-origin', () => {
|
||||||
|
assert.equal(validateWidgetUrl(' ', APP), 'empty');
|
||||||
|
assert.equal(validateWidgetUrl('not a url', APP), 'invalid');
|
||||||
|
assert.equal(validateWidgetUrl('http://example.org', APP), 'not-https');
|
||||||
|
assert.equal(validateWidgetUrl('https://chat.lotusguild.org/evil', APP), 'same-origin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isWidgetUrlSafe rejects same-origin + garbage, accepts cross-origin', () => {
|
||||||
|
assert.equal(isWidgetUrlSafe('https://chat.lotusguild.org/x', APP), false);
|
||||||
|
assert.equal(isWidgetUrlSafe('https://other.example/x', APP), true);
|
||||||
|
assert.equal(isWidgetUrlSafe('garbage', APP), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterWidgetCapabilities keeps only the benign allowlist', () => {
|
||||||
|
const requested = new Set<Capability>([
|
||||||
|
MatrixCapabilities.AlwaysOnScreen,
|
||||||
|
'm.send.event:m.room.message',
|
||||||
|
'org.matrix.msc2762.receive.state_event:m.room.member',
|
||||||
|
MatrixCapabilities.Screenshots,
|
||||||
|
]);
|
||||||
|
const allowed = filterWidgetCapabilities(requested);
|
||||||
|
assert.ok(allowed.has(MatrixCapabilities.AlwaysOnScreen));
|
||||||
|
assert.ok(allowed.has(MatrixCapabilities.Screenshots));
|
||||||
|
assert.equal(allowed.has('m.send.event:m.room.message'), false);
|
||||||
|
assert.equal(allowed.size, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateWidgetId is prefixed and unique across calls', () => {
|
||||||
|
const a = generateWidgetId();
|
||||||
|
const b = generateWidgetId();
|
||||||
|
assert.match(a, /^lotus_/);
|
||||||
|
assert.notEqual(a, b);
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Capability, MatrixCapabilities } from 'matrix-widget-api';
|
||||||
|
|
||||||
|
// Conservative v1 capability policy: approve only benign display capabilities.
|
||||||
|
// Everything else (room-event send/receive, to-device, uploads, user-directory,
|
||||||
|
// delayed events, TURN servers) is denied — a random widget must not be able to
|
||||||
|
// act as the user or read room data without an explicit consent flow (follow-up).
|
||||||
|
export const ALLOWED_WIDGET_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
|
||||||
|
MatrixCapabilities.AlwaysOnScreen,
|
||||||
|
MatrixCapabilities.RequiresClient,
|
||||||
|
MatrixCapabilities.Screenshots,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const filterWidgetCapabilities = (requested: Set<Capability>): Set<Capability> =>
|
||||||
|
new Set([...requested].filter((cap) => ALLOWED_WIDGET_CAPABILITIES.has(cap)));
|
||||||
|
|
||||||
|
export type WidgetUrlError = 'empty' | 'invalid' | 'not-https' | 'same-origin';
|
||||||
|
|
||||||
|
// A widget URL to ADD must be https and NOT our own origin: a same-origin frame
|
||||||
|
// with allow-same-origin + allow-scripts can break out of the sandbox against us.
|
||||||
|
export const validateWidgetUrl = (raw: string, appOrigin: string): WidgetUrlError | undefined => {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return 'empty';
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(trimmed);
|
||||||
|
} catch {
|
||||||
|
return 'invalid';
|
||||||
|
}
|
||||||
|
if (url.protocol !== 'https:') return 'not-https';
|
||||||
|
if (url.origin === appOrigin) return 'same-origin';
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Is an already-resolved (complete) widget URL safe to render in a sandboxed
|
||||||
|
// iframe that carries allow-same-origin? Rejects same-origin URLs (breakout).
|
||||||
|
export const isWidgetUrlSafe = (completeUrl: string, appOrigin: string): boolean => {
|
||||||
|
try {
|
||||||
|
return new URL(completeUrl).origin !== appOrigin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateWidgetId = (): string =>
|
||||||
|
`lotus_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
||||||
import { Box, Button, color, Icon, Icons, Spinner, Text, toRem } from 'folds';
|
import { Box, Button, color, Icon, Icons, Spinner, Text, toRem } from 'folds';
|
||||||
import FileSaver from 'file-saver';
|
import { useSaveFile } from '../../../hooks/useSaveFile';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
@@ -15,6 +15,7 @@ import { useFilePicker } from '../../../hooks/useFilePicker';
|
|||||||
function ExportKeys() {
|
function ExportKeys() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const alive = useAlive();
|
const alive = useAlive();
|
||||||
|
const saveFile = useSaveFile();
|
||||||
|
|
||||||
const [exportState, exportKeys] = useAsyncCallback<void, Error, [string]>(
|
const [exportState, exportKeys] = useAsyncCallback<void, Error, [string]>(
|
||||||
useCallback(
|
useCallback(
|
||||||
@@ -28,9 +29,9 @@ function ExportKeys() {
|
|||||||
const blob = new Blob([encKeys], {
|
const blob = new Blob([encKeys], {
|
||||||
type: 'text/plain;charset=us-ascii',
|
type: 'text/plain;charset=us-ascii',
|
||||||
});
|
});
|
||||||
FileSaver.saveAs(blob, 'lotus-keys.txt');
|
saveFile(blob, 'lotus-keys.txt');
|
||||||
},
|
},
|
||||||
[mx],
|
[mx, saveFile],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,7 +171,11 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') handleCardClick();
|
if (e.key === 'Enter' || e.key === ' ') handleCardClick();
|
||||||
}}
|
}}
|
||||||
aria-label={`Notification from ${toast.displayName} in ${toast.roomName}`}
|
aria-label={
|
||||||
|
toast.roomName
|
||||||
|
? `Notification from ${toast.displayName} in ${toast.roomName}`
|
||||||
|
: `${toast.displayName}: ${toast.body}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span style={{ position: 'absolute', top: config.space.S100, right: config.space.S100 }}>
|
<span style={{ position: 'absolute', top: config.space.S100, right: config.space.S100 }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -187,7 +191,11 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
</span>
|
||||||
<div style={rowStyle}>
|
<div style={rowStyle}>
|
||||||
{toast.avatarUrl ? (
|
{toast.iconSrc ? (
|
||||||
|
<div style={initialsStyle} aria-hidden="true">
|
||||||
|
<Icon size="100" src={toast.iconSrc} />
|
||||||
|
</div>
|
||||||
|
) : toast.avatarUrl ? (
|
||||||
<img src={toast.avatarUrl} alt="" style={avatarStyle} aria-hidden="true" />
|
<img src={toast.avatarUrl} alt="" style={avatarStyle} aria-hidden="true" />
|
||||||
) : (
|
) : (
|
||||||
<div style={initialsStyle} aria-hidden="true">
|
<div style={initialsStyle} aria-hidden="true">
|
||||||
@@ -197,7 +205,7 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
<span style={nameStyle}>{toast.displayName}</span>
|
<span style={nameStyle}>{toast.displayName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={bodyStyle}>{toast.body}</div>
|
<div style={bodyStyle}>{toast.body}</div>
|
||||||
<div style={roomNameStyle}>{toast.roomName}</div>
|
{toast.roomName && <div style={roomNameStyle}>{toast.roomName}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { Icons } from 'folds';
|
||||||
|
import FileSaver from 'file-saver';
|
||||||
|
import { createDownloadToast, toastQueueAtom } from '../state/toast';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a blob/URL to disk AND surface a "Downloaded <filename>" toast.
|
||||||
|
*
|
||||||
|
* The desktop (Tauri) app has no native download UI, so `FileSaver.saveAs` saved
|
||||||
|
* files silently — users re-clicked because nothing confirmed success. This gives
|
||||||
|
* uniform, visible feedback across web + desktop for every download call site.
|
||||||
|
*/
|
||||||
|
export const useSaveFile = () => {
|
||||||
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
(data: Blob | string, filename: string) => {
|
||||||
|
FileSaver.saveAs(data, filename);
|
||||||
|
setToast(createDownloadToast(filename, Icons.Check));
|
||||||
|
},
|
||||||
|
[setToast],
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -29,6 +29,10 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
|
|||||||
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
|
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Re-seed when the User object appears/changes after first render — the
|
||||||
|
// useState initializer only ran if `user` already existed at mount, so a
|
||||||
|
// late-arriving user would otherwise show no presence until the next event.
|
||||||
|
if (user) setPresence(getUserPresence(user));
|
||||||
// Subscribe on mx (MatrixClient) rather than on individual User objects.
|
// Subscribe on mx (MatrixClient) rather than on individual User objects.
|
||||||
// User objects have a default 10-listener limit; the same user can appear
|
// User objects have a default 10-listener limit; the same user can appear
|
||||||
// in many components simultaneously (avatars, member list, etc.) and
|
// in many components simultaneously (avatars, member list, etc.) and
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ import { stopPropagation } from '../../utils/keyboard';
|
|||||||
import { SyncStatus } from './SyncStatus';
|
import { SyncStatus } from './SyncStatus';
|
||||||
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
||||||
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
|
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
|
||||||
|
import { pushSessionToSW } from '../../../sw-session';
|
||||||
|
import { revokeOidcTokens } from '../../../client/oidcLogout';
|
||||||
import { useSessionSync } from '../../hooks/useSessionSync';
|
import { useSessionSync } from '../../hooks/useSessionSync';
|
||||||
import { installCryptoDiagLog } from '../../utils/cryptoDiagLog';
|
import { installCryptoDiagLog } from '../../utils/cryptoDiagLog';
|
||||||
import { AutoDiscovery } from './AutoDiscovery';
|
import { AutoDiscovery } from './AutoDiscovery';
|
||||||
@@ -143,7 +145,17 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) {
|
|||||||
const useLogoutListener = (mx?: MatrixClient) => {
|
const useLogoutListener = (mx?: MatrixClient) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
|
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
|
||||||
|
// Clear the SW's cached bearer token so it stops attaching the now-revoked
|
||||||
|
// token to media fetches (mirrors the manual logoutClient path).
|
||||||
|
pushSessionToSW();
|
||||||
mx?.stopClient();
|
mx?.stopClient();
|
||||||
|
// Best-effort issuer revocation for OIDC sessions (the token is already
|
||||||
|
// server-revoked here, but revoke the refresh token too). Before we drop
|
||||||
|
// the stored session below.
|
||||||
|
const loggedOutSession = getFallbackSession();
|
||||||
|
if (loggedOutSession?.oidc) {
|
||||||
|
await revokeOidcTokens(loggedOutSession).catch(() => undefined);
|
||||||
|
}
|
||||||
await mx?.clearStores();
|
await mx?.clearStores();
|
||||||
// The opt-in local search index holds DECRYPTED message plaintext. Wipe it
|
// The opt-in local search index holds DECRYPTED message plaintext. Wipe it
|
||||||
// on server-forced logout too (token expiry / remote sign-out / password
|
// on server-forced logout too (token expiry / remote sign-out / password
|
||||||
|
|||||||
@@ -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,87 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { MatrixEvent } from 'matrix-js-sdk';
|
||||||
|
import { myMainReceiptPresent, 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// myMainReceiptPresent gates the auto-clear to main-timeline reads, so reading a
|
||||||
|
// single thread does not wipe the whole-room marked-unread flag.
|
||||||
|
test('myMainReceiptPresent: true for an unthreaded receipt (no thread_id)', () => {
|
||||||
|
const event = receiptEvent({ $abc: { 'm.read': { [ME]: { ts: 1 } } } });
|
||||||
|
assert.equal(myMainReceiptPresent(event, ME), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('myMainReceiptPresent: true for a thread_id "main" receipt', () => {
|
||||||
|
const event = receiptEvent({ $abc: { 'm.read': { [ME]: { ts: 1, thread_id: 'main' } } } });
|
||||||
|
assert.equal(myMainReceiptPresent(event, ME), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('myMainReceiptPresent: false for a thread-scoped receipt', () => {
|
||||||
|
const event = receiptEvent({ $abc: { 'm.read': { [ME]: { ts: 1, thread_id: '$root:server' } } } });
|
||||||
|
assert.equal(myMainReceiptPresent(event, ME), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('myMainReceiptPresent: false when only another user has a main receipt', () => {
|
||||||
|
const event = receiptEvent({ $abc: { 'm.read': { [OTHER]: { ts: 1 } } } });
|
||||||
|
assert.equal(myMainReceiptPresent(event, 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,116 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
export 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],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// True only when OUR receipt in this event is for the main timeline — either
|
||||||
|
// unthreaded (no thread_id) or thread_id "main". A receipt scoped to a specific
|
||||||
|
// thread (thread_id === <threadRootId>) must NOT clear the whole-room marked
|
||||||
|
// flag, since only that one thread was read.
|
||||||
|
export const myMainReceiptPresent = (event: MatrixEvent, userId: string): boolean => {
|
||||||
|
const content = event.getContent();
|
||||||
|
return Object.keys(content).some((eventId) =>
|
||||||
|
Object.keys(content[eventId] ?? {}).some((receiptType) => {
|
||||||
|
const receipt = content[eventId][receiptType]?.[userId];
|
||||||
|
if (!receipt) return false;
|
||||||
|
const threadId = (receipt as { thread_id?: string }).thread_id;
|
||||||
|
return threadId === undefined || threadId === 'main';
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
// MAIN-timeline read receipt lands for a room that's currently marked, clear
|
||||||
|
// it. Gated to main/unthreaded receipts so reading a single thread doesn't
|
||||||
|
// wipe the whole-room flag. (This also fires for receipts from our other
|
||||||
|
// devices; the local read path clears via markAsRead in notifications.ts.)
|
||||||
|
const onReceipt: RoomEventHandlerMap[RoomEvent.Receipt] = (event, room) => {
|
||||||
|
const myId = mx.getUserId();
|
||||||
|
if (!myId || !readMarkedUnread(room)) return;
|
||||||
|
if (myMainReceiptPresent(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]);
|
||||||
|
};
|
||||||
@@ -116,6 +116,23 @@ test('PUT with unchanged counts is skipped (same map reference)', () => {
|
|||||||
assert.equal(before, after);
|
assert.equal(before, after);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('PUT of { total: 0, highlight: 0 } removes the room (collapses to DELETE)', () => {
|
||||||
|
const store = createStore();
|
||||||
|
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 3, 1) });
|
||||||
|
// A phantom zero-count PUT (e.g. UnreadNotifications after the server zeroes
|
||||||
|
// counts) must clear the entry, not leave a stuck dot.
|
||||||
|
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 0, 0) });
|
||||||
|
assert.equal(get(store).has('!r:s'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUT of { 0, 0 } on an absent room is a no-op (same map reference)', () => {
|
||||||
|
const store = createStore();
|
||||||
|
const before = get(store);
|
||||||
|
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 0, 0) });
|
||||||
|
assert.equal(before, get(store));
|
||||||
|
assert.equal(get(store).has('!r:s'), false);
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// roomToUnreadAtom: PUT with parent aggregation
|
// roomToUnreadAtom: PUT with parent aggregation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
getUnreadInfo,
|
getUnreadInfo,
|
||||||
getUnreadInfos,
|
getUnreadInfos,
|
||||||
isNotificationEvent,
|
isNotificationEvent,
|
||||||
roomHaveUnread,
|
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { roomToParentsAtom } from './roomToParents';
|
import { roomToParentsAtom } from './roomToParents';
|
||||||
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
||||||
@@ -83,7 +82,9 @@ const deleteUnreadInfo = (roomToUnread: RoomToUnread, allParents: Set<string>, r
|
|||||||
allParents.forEach((parentId) => {
|
allParents.forEach((parentId) => {
|
||||||
const oldParentUnread = roomToUnread.get(parentId);
|
const oldParentUnread = roomToUnread.get(parentId);
|
||||||
if (!oldParentUnread) return;
|
if (!oldParentUnread) return;
|
||||||
const newFrom = new Set([...(oldParentUnread.from ?? roomId)]);
|
// `from` is always a Set for parent aggregates; the fallback must be an
|
||||||
|
// iterable of ids, NOT the roomId string (which would spread into chars).
|
||||||
|
const newFrom = new Set([...(oldParentUnread.from ?? [])]);
|
||||||
newFrom.delete(roomId);
|
newFrom.delete(roomId);
|
||||||
if (newFrom.size === 0) {
|
if (newFrom.size === 0) {
|
||||||
roomToUnread.delete(parentId);
|
roomToUnread.delete(parentId);
|
||||||
@@ -137,6 +138,27 @@ export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefin
|
|||||||
}
|
}
|
||||||
if (action.type === 'PUT') {
|
if (action.type === 'PUT') {
|
||||||
const { unreadInfo } = action;
|
const { unreadInfo } = action;
|
||||||
|
// A { total: 0, highlight: 0 } entry is still a *present* map key, and the
|
||||||
|
// nav dot lights on any present entry — so a phantom zero-count PUT (e.g.
|
||||||
|
// the UnreadNotifications listener firing once the server zeroes counts)
|
||||||
|
// would leave a stuck dot. Collapse it to a DELETE so a fully-read room
|
||||||
|
// actually clears. Done before the unreadEqual short-circuit so an
|
||||||
|
// already-stuck { 0, 0 } gets removed too.
|
||||||
|
if (unreadInfo.total === 0 && unreadInfo.highlight === 0) {
|
||||||
|
if (get(baseRoomToUnread).has(unreadInfo.roomId)) {
|
||||||
|
set(
|
||||||
|
baseRoomToUnread,
|
||||||
|
produce(get(baseRoomToUnread), (draftRoomToUnread) =>
|
||||||
|
deleteUnreadInfo(
|
||||||
|
draftRoomToUnread,
|
||||||
|
getAllParents(get(roomToParentsAtom), unreadInfo.roomId),
|
||||||
|
unreadInfo.roomId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const currentUnread = get(baseRoomToUnread).get(unreadInfo.roomId);
|
const currentUnread = get(baseRoomToUnread).get(unreadInfo.roomId);
|
||||||
if (currentUnread && unreadEqual(currentUnread, unreadInfoToUnread(unreadInfo))) {
|
if (currentUnread && unreadEqual(currentUnread, unreadInfoToUnread(unreadInfo))) {
|
||||||
// Do not update if unread data has not changes
|
// Do not update if unread data has not changes
|
||||||
@@ -254,20 +276,16 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (isMyReceipt) {
|
if (isMyReceipt) {
|
||||||
// Don't blanket-DELETE the room's unread on any receipt: a THREADED
|
// Optimistically clear on our own receipt (upstream cinny behavior).
|
||||||
// receipt (reading one thread) would wipe the room's still-valid
|
// Do NOT recompute from getUnreadInfo here: getUnreadNotificationCount is
|
||||||
// main-timeline badge, and if the room was already read no
|
// server-computed and STALE on the synchronous synthetic receipt echo
|
||||||
// UnreadNotifications PUT follows to restore it. Recompute instead —
|
// (the SDK only zeroes it immediately when the last live event is our own
|
||||||
// DELETE only when the room is genuinely fully read.
|
// message), so recomputing PUTs the stale non-zero count back → the dot
|
||||||
const info = getUnreadInfo(
|
// sticks / resurrects. The RoomEvent.UnreadNotifications listener below
|
||||||
room,
|
// re-asserts the accurate badge (incl. restoring the main badge after a
|
||||||
getMutedThreads(threadNotificationsRef.current, room.roomId),
|
// thread read) once the server acks, and a { 0, 0 } PUT collapses to a
|
||||||
);
|
// DELETE in the reducer.
|
||||||
if (info.total === 0 && info.highlight === 0 && !roomHaveUnread(mx, room)) {
|
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
|
||||||
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
|
|
||||||
} else {
|
|
||||||
setUnreadAtom({ type: 'PUT', unreadInfo: info });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
mx.on(RoomEvent.Receipt, handleReceipt);
|
mx.on(RoomEvent.Receipt, handleReceipt);
|
||||||
|
|||||||
@@ -264,7 +264,21 @@ export const removeFallbackSession = () => {
|
|||||||
// the next setFallbackSession then persists the blob. When both exist the blob
|
// the next setFallbackSession then persists the blob. When both exist the blob
|
||||||
// wins by construction.
|
// wins by construction.
|
||||||
export const getFallbackSession = (): Session | undefined => {
|
export const getFallbackSession = (): Session | undefined => {
|
||||||
const persisted = readSessionBlob() ?? readLegacyKeys();
|
const blob = readSessionBlob();
|
||||||
|
const legacy = readLegacyKeys();
|
||||||
|
// Prefer the atomic blob, EXCEPT when the legacy keys carry a later expiry: a
|
||||||
|
// pre-blob build's token refresh writes only the legacy keys, so a
|
||||||
|
// downgrade→upgrade can leave a stale blob newer than fresh legacy keys →
|
||||||
|
// booting on a dead token. Whichever has the later expiresAt wins.
|
||||||
|
let persisted = blob ?? legacy;
|
||||||
|
if (
|
||||||
|
blob &&
|
||||||
|
legacy &&
|
||||||
|
typeof legacy.expiresAt === 'number' &&
|
||||||
|
(typeof blob.expiresAt !== 'number' || legacy.expiresAt > blob.expiresAt)
|
||||||
|
) {
|
||||||
|
persisted = legacy;
|
||||||
|
}
|
||||||
if (!persisted) return undefined;
|
if (!persisted) return undefined;
|
||||||
return sessionFromPersisted(persisted);
|
return sessionFromPersisted(persisted);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createStore } from 'jotai';
|
import { createStore } from 'jotai';
|
||||||
import { toastQueueAtom, dismissToastAtom, ToastNotif } from './toast';
|
import { toastQueueAtom, dismissToastAtom, ToastNotif, createDownloadToast } from './toast';
|
||||||
|
|
||||||
// The queue lives in an unexported baseAtom; we drive the two write-only setters
|
// The queue lives in an unexported baseAtom; we drive the two write-only setters
|
||||||
// (toastQueueAtom append + null no-op guard, dismissToastAtom remove-by-id)
|
// (toastQueueAtom append + null no-op guard, dismissToastAtom remove-by-id)
|
||||||
@@ -85,3 +85,15 @@ test('dismissToastAtom for an unknown id is a no-op', () => {
|
|||||||
['a'],
|
['a'],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createDownloadToast: filename in body, no room navigation, unique ids', () => {
|
||||||
|
const a = createDownloadToast('photo.jpg');
|
||||||
|
assert.equal(a.displayName, 'Downloaded');
|
||||||
|
assert.equal(a.body, 'photo.jpg');
|
||||||
|
// roomId empty + an onClick present → clicking dismisses without navigating to a room.
|
||||||
|
assert.equal(a.roomId, '');
|
||||||
|
assert.equal(a.roomName, '');
|
||||||
|
assert.equal(typeof a.onClick, 'function');
|
||||||
|
const b = createDownloadToast('photo.jpg');
|
||||||
|
assert.notEqual(a.id, b.id);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { atom } from 'jotai';
|
import { atom } from 'jotai';
|
||||||
|
import type { IconSrc } from 'folds';
|
||||||
|
|
||||||
export type ToastNotif = {
|
export type ToastNotif = {
|
||||||
id: string;
|
id: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
iconSrc?: IconSrc; // folds Icon src for a "system" toast (shown instead of an avatar/initials)
|
||||||
displayName: string;
|
displayName: string;
|
||||||
body: string;
|
body: string;
|
||||||
roomName: string;
|
roomName: string;
|
||||||
@@ -12,6 +14,19 @@ export type ToastNotif = {
|
|||||||
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
|
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build a "download complete" system toast. Kept folds-free here (the icon src is
|
||||||
|
// passed in) so this stays a pure, testable builder. roomId is empty + onClick is
|
||||||
|
// set so a click only dismisses (never navigates to a room).
|
||||||
|
export const createDownloadToast = (filename: string, iconSrc?: IconSrc): ToastNotif => ({
|
||||||
|
id: `download-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
displayName: 'Downloaded',
|
||||||
|
body: filename,
|
||||||
|
roomName: '',
|
||||||
|
roomId: '',
|
||||||
|
iconSrc,
|
||||||
|
onClick: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
const baseAtom = atom<ToastNotif[]>([]);
|
const baseAtom = atom<ToastNotif[]>([]);
|
||||||
|
|
||||||
// Write-only setter used in ClientNonUIFeatures
|
// Write-only setter used in ClientNonUIFeatures
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
// Whether the room's Widgets side-panel is open (mirrors mediaGalleryAtom).
|
||||||
|
export const widgetsPanelAtom = atom<boolean>(false);
|
||||||
@@ -20,16 +20,22 @@ type RoomOpts = {
|
|||||||
readUpTo?: string | null;
|
readUpTo?: string | null;
|
||||||
threads?: any[];
|
threads?: any[];
|
||||||
threadUnread?: Record<string, number>;
|
threadUnread?: Record<string, number>;
|
||||||
|
markedUnread?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const setup = (opts: RoomOpts) => {
|
const setup = (opts: RoomOpts) => {
|
||||||
const calls: ReceiptCall[] = [];
|
const calls: ReceiptCall[] = [];
|
||||||
|
const accountDataWrites: Array<{ type: string; content: any }> = [];
|
||||||
const room = {
|
const room = {
|
||||||
getLiveTimeline: () => ({ getEvents: () => opts.timeline ?? [] }),
|
getLiveTimeline: () => ({ getEvents: () => opts.timeline ?? [] }),
|
||||||
getEventReadUpTo: () => opts.readUpTo ?? null,
|
getEventReadUpTo: () => opts.readUpTo ?? null,
|
||||||
getThreads: () => opts.threads ?? [],
|
getThreads: () => opts.threads ?? [],
|
||||||
getThreadUnreadNotificationCount: (threadId: string, _type: NotificationCountType) =>
|
getThreadUnreadNotificationCount: (threadId: string, _type: NotificationCountType) =>
|
||||||
opts.threadUnread?.[threadId] ?? 0,
|
opts.threadUnread?.[threadId] ?? 0,
|
||||||
|
getAccountData: (type: string) =>
|
||||||
|
opts.markedUnread && type === 'm.marked_unread'
|
||||||
|
? { getContent: () => ({ unread: true }) }
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
const mx = {
|
const mx = {
|
||||||
getRoom: () => room,
|
getRoom: () => room,
|
||||||
@@ -38,8 +44,12 @@ const setup = (opts: RoomOpts) => {
|
|||||||
calls.push({ eventId: event.getId(), receiptType, unthreaded });
|
calls.push({ eventId: event.getId(), receiptType, unthreaded });
|
||||||
return {};
|
return {};
|
||||||
},
|
},
|
||||||
|
setRoomAccountData: async (_roomId: string, type: string, content: any) => {
|
||||||
|
accountDataWrites.push({ type, content });
|
||||||
|
return {};
|
||||||
|
},
|
||||||
} as any;
|
} as any;
|
||||||
return { mx, calls };
|
return { mx, calls, accountDataWrites };
|
||||||
};
|
};
|
||||||
|
|
||||||
test('main timeline: unthreaded receipt at the latest event', async () => {
|
test('main timeline: unthreaded receipt at the latest event', async () => {
|
||||||
@@ -107,6 +117,27 @@ test('everything read: no receipts sent', async () => {
|
|||||||
assert.equal(calls.length, 0);
|
assert.equal(calls.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('marked-unread + already fully read: clears the flag even though no receipt is sent', async () => {
|
||||||
|
const { mx, calls, accountDataWrites } = setup({
|
||||||
|
timeline: [evt('a'), evt('b')],
|
||||||
|
readUpTo: 'b', // nothing newer → no receipt
|
||||||
|
markedUnread: true,
|
||||||
|
});
|
||||||
|
await markAsRead(mx, '!r:server', false);
|
||||||
|
assert.equal(calls.length, 0); // no receipt (the stuck-dot case)
|
||||||
|
// ...but the marked-unread flag is cleared directly (both keys, unread:false)
|
||||||
|
assert.ok(accountDataWrites.some((w) => w.type === 'm.marked_unread' && w.content.unread === false));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('not marked-unread: markAsRead does not touch account data', async () => {
|
||||||
|
const { mx, accountDataWrites } = setup({
|
||||||
|
timeline: [evt('a'), evt('b')],
|
||||||
|
readUpTo: 'a',
|
||||||
|
});
|
||||||
|
await markAsRead(mx, '!r:server', false);
|
||||||
|
assert.equal(accountDataWrites.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('sending thread reply is skipped', async () => {
|
test('sending thread reply is skipped', async () => {
|
||||||
const t = thread('$root', evt('$reply', true)); // isSending → skip
|
const t = thread('$root', evt('$reply', true)); // isSending → skip
|
||||||
const { mx, calls } = setup({
|
const { mx, calls } = setup({
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { MatrixClient, NotificationCountType, ReceiptType } from 'matrix-js-sdk';
|
import { MatrixClient, NotificationCountType, ReceiptType } from 'matrix-js-sdk';
|
||||||
import { getSettings } from '../state/settings';
|
import { getSettings } from '../state/settings';
|
||||||
|
import { readMarkedUnread, setMarkedUnread } from '../state/room/markedUnread';
|
||||||
|
|
||||||
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
|
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
|
||||||
const { privateReadReceipts } = getSettings();
|
const { privateReadReceipts } = getSettings();
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
|
|
||||||
|
// Reading a room clears an explicit "mark as unread" (MSC2867). The binder's
|
||||||
|
// receipt-driven auto-clear does NOT fire when the room is already fully read
|
||||||
|
// (no receipt is sent below in that case), so clear it directly here.
|
||||||
|
if (readMarkedUnread(room)) {
|
||||||
|
setMarkedUnread(mx, roomId, false).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
const receiptType =
|
const receiptType =
|
||||||
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read;
|
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -53,3 +53,20 @@ export const playClipLocally = (
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Read an audio file's duration in milliseconds from its metadata (no playback). */
|
||||||
|
export const getAudioDurationMs = (file: Blob): Promise<number | undefined> =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const audio = new Audio();
|
||||||
|
audio.preload = 'metadata';
|
||||||
|
const done = (ms: number | undefined) => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(ms);
|
||||||
|
};
|
||||||
|
audio.addEventListener('loadedmetadata', () =>
|
||||||
|
done(Number.isFinite(audio.duration) ? Math.round(audio.duration * 1000) : undefined),
|
||||||
|
);
|
||||||
|
audio.addEventListener('error', () => done(undefined));
|
||||||
|
audio.src = url;
|
||||||
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,13 @@ export enum StateEvent {
|
|||||||
RoomTopic = 'm.room.topic',
|
RoomTopic = 'm.room.topic',
|
||||||
RoomAvatar = 'm.room.avatar',
|
RoomAvatar = 'm.room.avatar',
|
||||||
RoomPinnedEvents = 'm.room.pinned_events',
|
RoomPinnedEvents = 'm.room.pinned_events',
|
||||||
|
// [MSC1236] Room widgets (embedded apps). One state event per widget,
|
||||||
|
// state_key = widget id; content is a matrix-widget-api IWidget.
|
||||||
|
Widget = 'im.vector.modular.widgets',
|
||||||
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