Compare commits

..

13 Commits

Author SHA1 Message Date
jared 57da9a6ce8 feat(soundboard): clip duration, playing indicator, volume layout, name wrap
CI / Build & Quality Checks (push) Successful in 10m37s
CI / Trigger Desktop Build (push) Successful in 16s
Editor (SoundboardPackEditor): show each clip's length in seconds (stored on
upload via getAudioDurationMs, and captured on preview for existing clips); the
preview button now toggles play/stop with a 'now playing' equalizer indicator;
reworked the volume control into a fixed cell with a % readout so the slider's
max no longer collides with the delete button.

Call soundboard: clip names wrap (up to 3 lines, word-break) instead of being
truncated with an ellipsis; cards grow to fit.

TODO: logged the basic audio-editor / video->audio-extractor as a large project.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:44:09 -04:00
jared eb34b04708 feat(audio): play m.file audio messages inline like m.audio
Audio frequently arrives as m.file (bridges, other clients, or when the browser
reported a non-audio/* mime on upload) and only got a download button. Detect
audio in the m.file branch (by info.mimetype or filename extension) and render
the existing MAudio inline player, falling back to the file card otherwise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:44:09 -04:00
jared fd9e4a9802 feat(download): show a toast + button check when a file is saved
The desktop (Tauri) app has no native download UI, so FileSaver.saveAs saved
files silently — no visual or audio confirmation. Users re-clicked because
nothing said it worked (one report: 5 copies of the same file). Add a small
useSaveFile() hook that saves AND raises a 'Downloaded <filename>' toast, and
route every download call site through it (file attachments, image viewer, PDF
viewer, plus the recovery-key / key-backup exports). The file-message download
button also shows a green check on success.

Toast system extended with an optional iconSrc so system toasts render an icon
instead of an avatar/initials, and an empty roomName is no longer rendered.

Tests: createDownloadToast covered; 701/701 pass; typecheck + build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:30:57 -04:00
jared f12175e76f fix(unread): stop stuck/resurrecting read indicators
CI / Build & Quality Checks (push) Successful in 10m53s
CI / Trigger Desktop Build (push) Successful in 9s
handleReceipt recomputed unread from getUnreadNotificationCount, which is
server-computed and stale on the synchronous synthetic receipt echo (the SDK
only zeroes it immediately when the last event is our own message). Reading
someone else's message therefore PUT the stale non-zero count back -> dot stuck
or resurrected on the ack-sync ordering race. Restore upstream cinny's
optimistic DELETE on our own receipt; the UnreadNotifications listener re-asserts
the accurate badge on the server ack.

Also collapse a {total:0,highlight:0} PUT to a DELETE in the reducer (a present
map entry lights the dot via hasUnread=!!unread, so phantom {0,0} PUTs from the
UnreadNotifications listener left stuck dots).

Mark-as-Unread (MSC2867): clear the flag directly in markAsRead (opening an
already-read room sends no receipt, so the receipt-driven auto-clear never
fired), and gate the receipt auto-clear to main/unthreaded receipts so reading
one thread no longer wipes the whole-room flag.

Tests: 700/700 pass; typecheck + prod build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:07:21 -04:00
jared b5db617bd2 docs: log unread/read-receipt flakiness bug (investigating)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:49:52 -04:00
jared 4ecc173554 docs: record remaining spec/MSC gaps survey (buildable vs blocked)
CI / Build & Quality Checks (push) Successful in 10m53s
CI / Trigger Desktop Build (push) Successful in 6s
Full-surface protocol survey. Flags each remaining gap by what unblocks it:
buildable now (custom room tags/sections — the only substantive client-only one
left), needs infra (email/3PID invites → identity server; MSC4108/3814), and
blocked-until-Synapse-upgrade (live location 3489/3672, reaction redaction 3892,
room preview 3266, thread subs 4306). Space reordering already works (drag) — not
a gap. Corrected per user.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 13:51:47 -04:00
jared 44854a1529 docs: park Sliding Sync (evaluated — not viable for a safe rollout)
CI / Build & Quality Checks (push) Successful in 10m41s
CI / Trigger Desktop Build (push) Successful in 6s
Three research passes concluded ~10% confidence a full rollout wouldn't
break/regress (js-sdk SlidingSync is _internal_/experimental + labs-only at
Element, presence not delivered over sliding sync, no upstream Cinny reference,
and Cinny's nav is built from the full local room set — ~14 subsystems assume
completeness). Server side is GA. Parked; revisit on Rust SDK adoption or large
accounts. Full assessment in the plan history.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 13:45:44 -04:00
jared 43f4ceb45d feat(rooms): Room Widgets (MSC1236 im.vector.modular.widgets)
Phase C.1 of the protocol-gaps roadmap, gate-green (693 tests). Generalizes the
Element Call widget host into a general room-widget feature:
- StateEvent.Widget + widgetsPanelAtom + useRoomWidgets (WidgetParser).
- RoomWidgetView: sandboxed-iframe host via ClientWidgetApi with a conservative
  GeneralWidgetDriver (approves only benign display caps — no room-event
  send/read/to-device). Blocks same-origin widget URLs (sandbox breakout guard).
- WidgetsPanel: list / open / add / remove, PL-gated on im.vector.modular.widgets,
  https + non-same-origin URL validation. Mounted like the media gallery (header
  toggle + 3-way content-panel exclusivity + mobile full-screen overlay).
- Tested URL/capability/id helpers.

Requires the prod CSP frame-src widening (matrix repo) for external widgets.
v1 cuts (capability consent prompt, Jitsi/sticker types, user widgets) noted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 13:27:23 -04:00
jared 17bd50cc4e feat(crypto): QR-code device verification (alongside emoji SAS)
CI / Build & Quality Checks (push) Successful in 11m7s
CI / Trigger Desktop Build (push) Successful in 7s
B2 of the Matrix protocol-gaps roadmap, gate-green (688 tests):
- Enable QR verification methods (show/scan/reciprocate) in initMatrix.
- Extend DeviceVerification: the Ready step offers your own QR (byte-mode encode
  via qrcode), a camera 'Scan their QR code' flow, and an emoji fallback; the
  Started step routes reciprocate → a confirm step (useVerifierShowReciprocateQr)
  or SAS as before.
- New QrScanner component: getUserMedia + jsQR, handing the raw binaryData bytes
  to request.scanQRCode (BarcodeDetector is string-only, so can't be used).
- Adds qrcode + jsqr (small, pure-JS, client-only); build-verified under rolldown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:30:23 -04:00
jared 82e52e1bc7 feat(rooms): Disappearing Messages (MSC1763 m.room.retention)
B1 of the Matrix protocol-gaps roadmap, gate-green (688 tests):
- StateEvent.RoomRetention + a shared utils/retention.ts (presets, isExpired,
  getRoomRetentionMs) with tests.
- RoomRetention settings control (PL-gated preset buttons Off/1d/1w/1m) in Room
  Settings → General → Message Retention.
- Timeline hides events past the room's max_lifetime (gated behind Show Hidden
  Events, like redactions) — messages visually disappear, losslessly.
- Opt-in setting enforceRetentionLocally (default OFF) + a headless
  RetentionSweeper that permanently redacts the user's OWN expired messages
  (own-only, loaded-timeline scope, dedupe + retry). Nothing auto-deletes unless
  the user opts in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:23:14 -04:00
jared d46b91b1b8 feat(rooms): Mark as Unread (MSC2867) + Low Priority rooms
Two Matrix protocol gaps (Phase A), gate-green (683 tests):
- Mark as Unread: m.marked_unread room account data (+ com.famedly.marked_unread
  fallback), a new markedUnreadAtom binder that seeds from account data and
  clears on our own read receipt (MSC2867). RoomNavItem gains Mark as Unread /
  Read menu items and lights the row dot for a marked room. Tested.
- Low Priority: m.lowpriority room tag mirroring favourites — a context-menu
  toggle (mutually exclusive with Favorite) and a collapsed Low Priority
  category sorted to the bottom of the Home room list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 00:04:47 -04:00
jared 5b94a44eb3 docs: add Matrix Protocol Gaps backlog (audited spec/MSC gaps)
Six confirmed client-buildable gaps + server-gated items from a spec/MSC audit:
Mark as Unread (MSC2867), Low Priority rooms (m.lowpriority), Disappearing
Messages (MSC1763), QR Device Verification, Room Widgets (MSC1236), Sliding Sync
(MSC3575/4186). Phased build order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:53:33 -04:00
jared ca9abb5363 docs: condense LOTUS_TODO to open work only (1063→~230 lines)
CI / Build & Quality Checks (push) Successful in 10m37s
CI / Trigger Desktop Build (push) Successful in 7s
Removed resolved audit-wave finding tables and shipped-feature narratives (now
in LOTUS_FEATURES.md + git history); kept every open/blocked/deferred item, the
E2EE + Web Push backlog, and the reference tables (server caps, key files, EC
fork ops, CI/CD).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:23:03 -04:00
51 changed files with 2231 additions and 1128 deletions
+8
View File
@@ -675,6 +675,14 @@ Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view,
## 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.
**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.)
+204 -1018
View File
File diff suppressed because it is too large Load Diff
+214 -1
View File
@@ -49,6 +49,7 @@
"immer": "11.1.8",
"is-hotkey": "0.2.0",
"jotai": "2.20.0",
"jsqr": "1.4.0",
"katex": "0.16.11",
"linkify-react": "4.3.3",
"linkifyjs": "4.3.3",
@@ -57,6 +58,7 @@
"millify": "6.1.0",
"pdfjs-dist": "5.7.284",
"prismjs": "1.30.0",
"qrcode": "1.5.4",
"qrcode.react": "4.2.0",
"react": "19.2.6",
"react-aria": "3.48.0",
@@ -87,6 +89,7 @@
"@types/katex": "0.16.8",
"@types/node": "25.9.1",
"@types/prismjs": "1.26.6",
"@types/qrcode": "1.5.6",
"@types/react": "19.2.15",
"@types/react-dom": "19.2.3",
"@types/react-google-recaptcha": "2.1.9",
@@ -3990,6 +3993,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.15",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
@@ -5171,6 +5184,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/camelize": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
@@ -5965,6 +5987,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dedent": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -6108,6 +6139,12 @@
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/direction": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz",
@@ -9057,6 +9094,12 @@
"node": ">=0.10.0"
}
},
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
"license": "Apache-2.0"
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -10500,6 +10543,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -10537,7 +10589,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -10652,6 +10703,15 @@
"pathe": "^2.0.1"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@@ -10759,6 +10819,23 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
@@ -10768,6 +10845,124 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/raf-schd": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
@@ -11188,6 +11383,12 @@
"node": "*"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@@ -11518,6 +11719,12 @@
"node": ">=20.0.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@@ -12983,6 +13190,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": {
"version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
+3
View File
@@ -74,6 +74,7 @@
"immer": "11.1.8",
"is-hotkey": "0.2.0",
"jotai": "2.20.0",
"jsqr": "1.4.0",
"katex": "0.16.11",
"linkify-react": "4.3.3",
"linkifyjs": "4.3.3",
@@ -82,6 +83,7 @@
"millify": "6.1.0",
"pdfjs-dist": "5.7.284",
"prismjs": "1.30.0",
"qrcode": "1.5.4",
"qrcode.react": "4.2.0",
"react": "19.2.6",
"react-aria": "3.48.0",
@@ -112,6 +114,7 @@
"@types/katex": "0.16.8",
"@types/node": "25.9.1",
"@types/prismjs": "1.26.6",
"@types/qrcode": "1.5.6",
"@types/react": "19.2.15",
"@types/react-dom": "19.2.3",
"@types/react-google-recaptcha": "2.1.9",
+141 -33
View File
@@ -1,12 +1,14 @@
import {
ShowQrCodeCallbacks,
ShowSasCallbacks,
VerificationPhase,
VerificationRequest,
Verifier,
} from 'matrix-js-sdk/lib/crypto-api';
import React, { CSSProperties, useCallback, useEffect, useState } from 'react';
import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { VerificationMethod } from 'matrix-js-sdk/lib/types';
import QRCode from 'qrcode';
import {
Box,
Button,
@@ -27,11 +29,13 @@ import {
useVerificationRequestPhase,
useVerificationRequestReceived,
useVerifierCancel,
useVerifierShowReciprocateQr,
useVerifierShowSas,
} from '../hooks/useVerificationRequest';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { ContainerColor } from '../styles/ContainerColor.css';
import { useModalStyle } from '../hooks/useModalStyle';
import { QrScanner } from './QrScanner';
const DialogHeaderStyles: CSSProperties = {
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -97,32 +101,6 @@ function VerificationAccept({ onAccept }: VerificationAcceptProps) {
);
}
function VerificationWaitStart() {
const { t } = useTranslation();
return (
<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 }) {
const { t } = useTranslation();
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
@@ -237,6 +215,120 @@ function VerificationCanceled({ onClose }: VerificationCanceledProps) {
);
}
function QrCodeImage({ data }: { data: Uint8ClampedArray }) {
const canvasRef = useRef<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 = {
request: VerificationRequest;
onExit: () => void;
@@ -256,6 +348,17 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
const handleStart = useCallback(async () => {
await request.startVerification(VerificationMethod.Sas);
}, [request]);
const handleScanned = useCallback(
async (bytes: Uint8ClampedArray) => {
try {
const verifier = await request.scanQRCode(bytes);
await verifier.verify();
} catch {
// A bad/mismatched scan cancels the request; the Cancelled phase renders.
}
},
[request],
);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
@@ -290,15 +393,20 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
) : (
<VerificationAccept onAccept={handleAccept} />
))}
{phase === VerificationPhase.Ready &&
(request.initiatedByMe ? (
<AutoVerificationStart onStart={handleStart} />
) : (
<VerificationWaitStart />
))}
{phase === VerificationPhase.Ready && (
<VerificationReady
request={request}
onStartSas={handleStart}
onScanned={handleScanned}
/>
)}
{phase === VerificationPhase.Started &&
(request.verifier ? (
request.chosenMethod === VerificationMethod.Reciprocate ? (
<ReciprocateVerification verifier={request.verifier} onCancel={handleCancel} />
) : (
<SasVerification verifier={request.verifier} onCancel={handleCancel} />
)
) : (
<VerificationUnexpected
message="Unexpected Error! Verification is started but verifier is missing."
@@ -13,9 +13,9 @@ import {
color,
Spinner,
} from 'folds';
import FileSaver from 'file-saver';
import to from 'await-to-js';
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
import { useSaveFile } from '../hooks/useSaveFile';
import { useModalStyle } from '../hooks/useModalStyle';
import { PasswordInput } from './password-input';
import { ContainerColor } from '../styles/ContainerColor.css';
@@ -230,6 +230,7 @@ type RecoveryKeyDisplayProps = {
};
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
const [show, setShow] = useState(false);
const saveFile = useSaveFile();
const handleCopy = () => {
copyToClipboard(recoveryKey);
@@ -239,7 +240,7 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
const blob = new Blob([recoveryKey], {
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, '*');
+3 -2
View File
@@ -19,7 +19,7 @@ import {
config,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import FileSaver from 'file-saver';
import { useSaveFile } from '../../hooks/useSaveFile';
import * as css from './PdfViewer.css';
import { AsyncStatus } from '../../hooks/useAsyncCallback';
import { useZoom } from '../../hooks/useZoom';
@@ -36,6 +36,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
({ className, name, src, requestClose, ...props }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const saveFile = useSaveFile();
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const [pdfJSState, loadPdfJS] = usePdfJSLoader();
@@ -76,7 +77,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
}, [docState, pageNo, zoom]);
const handleDownload = () => {
FileSaver.saveAs(src, name);
saveFile(src, name);
};
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+101
View File
@@ -0,0 +1,101 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box, Button, color, config, Text } from 'folds';
import jsQR from 'jsqr';
type QrScannerProps = {
onScan: (bytes: Uint8ClampedArray) => void;
onCancel: () => void;
};
// Camera QR scanner. Decodes frames with jsQR and hands back the raw byte
// segment (`result.binaryData`) — Matrix QR verification needs the raw bytes,
// not a decoded string, so the string-only `BarcodeDetector` can't be used.
export function QrScanner({ onScan, onCancel }: QrScannerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [error, setError] = useState<string>();
const doneRef = useRef(false);
useEffect(() => {
let stream: MediaStream | undefined;
let raf = 0;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const tick = () => {
const video = videoRef.current;
if (!doneRef.current && video && ctx && video.readyState === video.HAVE_ENOUGH_DATA) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
const result = jsQR(image.data, image.width, image.height);
if (result && result.binaryData.length > 0) {
doneRef.current = true;
onScan(new Uint8ClampedArray(result.binaryData));
return;
}
}
raf = requestAnimationFrame(tick);
};
(async () => {
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
raf = requestAnimationFrame(tick);
} catch {
setError(
'Could not access the camera. Grant camera permission, or verify with emojis instead.',
);
}
})();
return () => {
doneRef.current = true;
cancelAnimationFrame(raf);
stream?.getTracks().forEach((track) => track.stop());
};
}, [onScan]);
if (error) {
return (
<Box direction="Column" gap="400">
<Text style={{ color: color.Critical.Main }} size="T300">
{error}
</Text>
<Button variant="Secondary" fill="Soft" onClick={onCancel}>
<Text size="B400">Back</Text>
</Button>
</Box>
);
}
return (
<Box direction="Column" gap="400" alignItems="Center">
<Text size="T300" align="Center">
Point your camera at the QR code shown on your other device.
</Text>
<video
ref={videoRef}
muted
playsInline
style={{
width: '100%',
maxWidth: 280,
borderRadius: config.radii.R400,
background: '#000',
}}
>
<track kind="captions" />
</video>
<Button variant="Secondary" fill="Soft" onClick={onCancel}>
<Text size="B400">Cancel</Text>
</Button>
</Box>
);
}
+46 -1
View File
@@ -31,7 +31,29 @@ import { ImageViewer } from './image-viewer';
import { PdfViewer } from './Pdf-viewer';
import { TextViewer } from './text-viewer';
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 = {
displayName: string;
@@ -276,6 +298,29 @@ export function RenderMessageContent({
}
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();
}
@@ -1,8 +1,8 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import FileSaver from 'file-saver';
import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
import { useSaveFile } from '../../hooks/useSaveFile';
import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan';
@@ -17,12 +17,13 @@ export type ImageViewerProps = {
export const ImageViewer = as<'div', ImageViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => {
const { t } = useTranslation();
const saveFile = useSaveFile();
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
const handleDownload = async () => {
const fileContent = await downloadMedia(src);
FileSaver.saveAs(fileContent, alt);
saveFile(fileContent, alt);
};
return (
+9 -5
View File
@@ -1,8 +1,8 @@
import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds';
import React, { ReactNode, useCallback } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FileSaver from 'file-saver';
import { mimeTypeToExt } from '../../utils/mimeTypes';
import { useSaveFile } from '../../hooks/useSaveFile';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
@@ -24,6 +24,7 @@ type FileDownloadButtonProps = {
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const saveFile = useSaveFile();
const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
@@ -34,18 +35,19 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
: await downloadMedia(mediaUrl);
const fileURL = URL.createObjectURL(fileContent);
FileSaver.saveAs(fileURL, filename);
saveFile(fileURL, filename);
return fileURL;
}, [mx, url, useAuthentication, mimeType, encInfo, filename]),
}, [mx, url, useAuthentication, mimeType, encInfo, filename, saveFile]),
);
const downloading = downloadState.status === AsyncStatus.Loading;
const hasError = downloadState.status === AsyncStatus.Error;
const succeeded = downloadState.status === AsyncStatus.Success;
return (
<IconButton
disabled={downloading}
onClick={download}
variant={hasError ? 'Critical' : 'SurfaceVariant'}
variant={hasError ? 'Critical' : succeeded ? 'Success' : 'SurfaceVariant'}
size="300"
radii="300"
aria-label={
@@ -53,13 +55,15 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
? 'Downloading...'
: hasError
? 'Download failed, click to retry'
: succeeded
? 'Downloaded — click to download again'
: 'Download file'
}
>
{downloading ? (
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
) : (
<Icon size="100" src={Icons.Download} />
<Icon size="100" src={succeeded ? Icons.Check : Icons.Download} />
)}
</IconButton>
);
@@ -14,10 +14,10 @@ import {
TooltipProvider,
as,
} from 'folds';
import FileSaver from 'file-saver';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FocusTrap from 'focus-trap-react';
import { IFileInfo } from '../../../../types/matrix/common';
import { useSaveFile } from '../../../hooks/useSaveFile';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { bytesToSize } from '../../../utils/common';
@@ -252,6 +252,7 @@ export type DownloadFileProps = {
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const saveFile = useSaveFile();
const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
@@ -262,9 +263,9 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
: await downloadMedia(mediaUrl);
const fileURL = URL.createObjectURL(fileContent);
FileSaver.saveAs(fileURL, body);
saveFile(fileURL, body);
return fileURL;
}, [mx, url, useAuthentication, mimeType, encInfo, body]),
}, [mx, url, useAuthentication, mimeType, encInfo, body, saveFile]),
);
return downloadState.status === AsyncStatus.Error ? (
@@ -277,7 +278,7 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
size="400"
onClick={() =>
downloadState.status === AsyncStatus.Success
? FileSaver.saveAs(downloadState.data, body)
? saveFile(downloadState.data, body)
: download()
}
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 {
Box,
Button,
@@ -21,6 +21,7 @@ import { uniqueShortcode } from '../../plugins/soundboard/utils';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import {
getAudioDurationMs,
playClipLocally,
resolveClipObjectUrl,
SOUNDBOARD_ACCEPT,
@@ -29,6 +30,49 @@ import {
} from '../../utils/soundboardClips';
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 = {
url: string;
body: string;
@@ -59,9 +103,20 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
const [error, setError] = useState<string>();
const [emojiFor, setEmojiFor] = useState<string>(); // shortcode currently picking an emoji
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 emojiAnchorRef = useRef<HTMLElement | null>(null);
useEffect(() => {
ensureEqKeyframes();
return () => {
audioElRef.current?.pause();
audioElRef.current = null;
};
}, []);
const existing = useMemo(() => pack.getClips(), [pack]);
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(
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);
try {
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 {
/* ignore preview errors */
} finally {
setBusyPreview(undefined);
}
},
[mx],
[mx, playingKey, stopPlayback],
);
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).`);
}
// 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 mxc = res.content_uri;
if (!mxc) throw new Error('Upload failed.');
@@ -126,7 +211,7 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
body: name,
emoji: '',
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);
}
};
const isPlaying = playingKey === key;
const clipSeconds =
durations.get(key) ?? (base.info?.duration != null ? base.info.duration / 1000 : undefined);
return (
<Box
key={key}
@@ -197,12 +285,16 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
<IconButton
size="300"
radii="300"
variant="Secondary"
variant={isPlaying ? 'Primary' : 'Secondary'}
disabled={busyPreview === key}
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
size="300"
@@ -227,7 +319,24 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
aria-label="Clip name"
/>
</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} />
<input
type="range"
@@ -237,9 +346,12 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
defaultValue={rowVolume}
disabled={!canEdit || markedDeleted}
onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })}
style={{ flexGrow: 1 }}
style={{ flexGrow: 1, minWidth: 0 }}
aria-label="Clip volume"
/>
<Text size="T200" priority="300" style={{ width: toRem(30), textAlign: 'right' }}>
{rowVolume}%
</Text>
</Box>
{canEdit && !isUpload && (
<IconButton
@@ -308,7 +420,13 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
{existing.map((c) =>
renderRow(
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,
deleted.has(c.shortcode),
),
+15 -2
View File
@@ -196,7 +196,8 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
aria-label={`Play ${clip.name}`}
style={{
width: toRem(76),
height: toRem(76),
minHeight: toRem(76),
height: 'auto',
padding: config.space.S100,
borderRadius: config.radii.R400,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
@@ -215,7 +216,19 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
clip.emoji || '🔊'
)}
</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}
</Text>
</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 './RoomPublish';
export * from './RoomQuality';
export * from './RoomRetention';
export * from './RoomShareInvite';
export * from './RoomUpgrade';
export * from './RoomVoiceLimit';
+58 -5
View File
@@ -38,6 +38,7 @@ import { nameInitials } from '../../utils/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { markedUnreadAtom, setMarkedUnread } from '../../state/room/markedUnread';
import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels';
import { markAsRead } from '../../utils/notifications';
import { UseStateProvider } from '../../components/UseStateProvider';
@@ -329,18 +330,39 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const isServerNotice = room.getType() === 'm.server_notice';
const isFavorite = !!room.tags?.['m.favourite'];
const isLowPriority = !!room.tags?.['m.lowpriority'];
const handleToggleFavorite = () => {
if (isFavorite) {
mx.deleteRoomTag(room.roomId, 'm.favourite');
} 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 });
}
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 = () => {
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();
};
@@ -393,12 +415,23 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
disabled={!unread && !markedUnread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</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}>
{(handleOpen, opened, changing) => (
<MenuItem
@@ -493,6 +526,17 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
{isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}
</Text>
</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
onClick={handleInvite}
variant="Primary"
@@ -610,6 +654,10 @@ function RoomNavItem_({
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [renameDialog, setRenameDialog] = useState(false);
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(
(receipt) => receipt.userId !== mx.getUserId(),
);
@@ -692,7 +740,7 @@ function RoomNavItem_({
<NavItem
variant="Background"
radii="400"
highlight={unread !== undefined}
highlight={hasUnread}
aria-selected={selected}
data-hover={!!menuAnchor}
onContextMenu={handleContextMenu}
@@ -721,7 +769,7 @@ function RoomNavItem_({
) : (
<RoomIcon
style={{
opacity: unread ? config.opacity.P500 : config.opacity.P300,
opacity: hasUnread ? config.opacity.P500 : config.opacity.P300,
}}
filled={selected}
size="100"
@@ -732,7 +780,7 @@ function RoomNavItem_({
</Avatar>
<Box as="span" direction="Column" grow="Yes" style={{ minWidth: 0 }}>
<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}
</Text>
{hasLocalName && (
@@ -773,7 +821,7 @@ function RoomNavItem_({
</Box>
)}
</Box>
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
{!optionsVisible && !hasUnread && !selected && typingMember.length > 0 && (
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
<TypingIndicator size="300" disableAnimation />
</Badge>
@@ -783,6 +831,11 @@ function RoomNavItem_({
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
</UnreadBadgeCenter>
)}
{!optionsVisible && !unread && markedUnread && (
<UnreadBadgeCenter>
<UnreadBadge highlight={false} count={0} />
</UnreadBadgeCenter>
)}
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
<Icon
size="50"
@@ -12,6 +12,7 @@ import {
RoomPublishedAddresses,
RoomPublish,
RoomQuality,
RoomRetention,
RoomShareInvite,
RoomUpgrade,
RoomVoiceLimit,
@@ -56,6 +57,10 @@ export function General({ requestClose }: GeneralProps) {
<RoomEncryption permissions={permissions} />
<RoomPublish permissions={permissions} />
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Message Retention</Text>
<RoomRetention permissions={permissions} />
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Voice</Text>
<RoomVoiceLimit permissions={permissions} />
+37 -11
View File
@@ -7,6 +7,8 @@ import { RoomView } from './RoomView';
import { MembersDrawer } from './MembersDrawer';
import { MediaGallery } from './MediaGallery';
import { mediaGalleryAtom } from '../../state/mediaGallery';
import { WidgetsPanel } from './widgets/WidgetsPanel';
import { widgetsPanelAtom } from '../../state/widgetsPanel';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
@@ -39,6 +41,8 @@ export function Room() {
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId));
const galleryOpen = useAtomValue(mediaGalleryAtom);
const setGalleryOpen = useSetAtom(mediaGalleryAtom);
const widgetsOpen = useAtomValue(widgetsPanelAtom);
const setWidgetsOpen = useSetAtom(widgetsPanelAtom);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext();
const powerLevels = usePowerLevels(room);
@@ -64,30 +68,40 @@ export function Room() {
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
// Thread panel and media gallery are mutually exclusive on every screen size:
// opening one closes the other. Detect the just-opened transition so whichever
// was opened most recently wins.
// The content panels (thread / media gallery / widgets) are mutually exclusive
// on every screen size: opening one closes the others. Detect the just-opened
// transition so whichever was opened most recently wins.
const prevThreadRef = useRef(activeThreadId);
const prevGalleryRef = useRef(galleryOpen);
const prevWidgetsRef = useRef(widgetsOpen);
useEffect(() => {
const threadJustOpened = Boolean(activeThreadId) && !prevThreadRef.current;
const galleryJustOpened = galleryOpen && !prevGalleryRef.current;
if (threadJustOpened && galleryOpen) {
setGalleryOpen(false);
} else if (galleryJustOpened && activeThreadId) {
setActiveThreadId(null);
const widgetsJustOpened = widgetsOpen && !prevWidgetsRef.current;
if (threadJustOpened) {
if (galleryOpen) setGalleryOpen(false);
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;
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
// thread > gallery > members. On desktop thread + members may coexist while
// thread + gallery stay mutually exclusive (via the effect above).
// thread > gallery > widgets > members. On desktop thread + members may coexist
// while the content panels stay mutually exclusive (via the effect above).
const isDesktop = screenSize === ScreenSize.Desktop;
const showThreadPanel = !callView && Boolean(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 (
<PowerLevelsContextProvider value={powerLevels}>
@@ -125,6 +139,18 @@ export function Room() {
<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 && (
<>
{screenSize === ScreenSize.Desktop && (
+12
View File
@@ -109,6 +109,8 @@ import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
import { ThreadSummary } from './thread/ThreadSummary';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
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 { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
import { RenderMessageContent } from '../../components/RenderMessageContent';
@@ -468,6 +470,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
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 [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
@@ -2043,6 +2050,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (eventSender && ignoredUsersSet.has(eventSender)) {
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) {
// eslint-disable-next-line @typescript-eslint/no-shadow
const t = mEvent.getType();
+25
View File
@@ -74,6 +74,7 @@ import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
import { mediaGalleryAtom } from '../../state/mediaGallery';
import { widgetsPanelAtom } from '../../state/widgetsPanel';
import { usePendingKnocks } from '../../hooks/usePendingKnocks';
import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
@@ -489,6 +490,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [galleryOpen, setGalleryOpen] = useAtom(mediaGalleryAtom);
const [widgetsOpen, setWidgetsOpen] = useAtom(widgetsPanelAtom);
const pendingKnocks = usePendingKnocks(room);
const handleSearchClick = () => {
@@ -725,6 +727,29 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
)}
</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 && (
<TooltipProvider
position="Bottom"
@@ -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&apos;t be loaded because its URL is on this app&apos;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 apps 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 { 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 { SettingTile } from '../../../components/setting-tile';
import { SequenceCardStyle } from '../styles.css';
@@ -15,6 +15,7 @@ import { useFilePicker } from '../../../hooks/useFilePicker';
function ExportKeys() {
const mx = useMatrixClient();
const alive = useAlive();
const saveFile = useSaveFile();
const [exportState, exportKeys] = useAsyncCallback<void, Error, [string]>(
useCallback(
@@ -28,9 +29,9 @@ function ExportKeys() {
const blob = new Blob([encKeys], {
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 [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const [enforceRetentionLocally, setEnforceRetentionLocally] = useSetting(
settingsAtom,
'enforceRetentionLocally',
);
return (
<Box direction="Column" gap="100">
@@ -2348,6 +2352,19 @@ function Messages() {
}
/>
</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>
);
}
+11 -3
View File
@@ -171,7 +171,11 @@ function ToastCard({ toast }: ToastCardProps) {
onKeyDown={(e) => {
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 }}>
<IconButton
@@ -187,7 +191,11 @@ function ToastCard({ toast }: ToastCardProps) {
</IconButton>
</span>
<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" />
) : (
<div style={initialsStyle} aria-hidden="true">
@@ -197,7 +205,7 @@ function ToastCard({ toast }: ToastCardProps) {
<span style={nameStyle}>{toast.displayName}</span>
</div>
<div style={bodyStyle}>{toast.body}</div>
<div style={roomNameStyle}>{toast.roomName}</div>
{toast.roomName && <div style={roomNameStyle}>{toast.roomName}</div>}
</div>
);
}
+24
View File
@@ -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],
);
};
@@ -48,6 +48,7 @@ import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/accou
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { toastQueueAtom } from '../../state/toast';
import { useReminders } from '../../hooks/useReminders';
import { getRoomRetentionMs, isExpired } from '../../utils/retention';
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts';
@@ -687,6 +688,62 @@ function ReminderMonitor() {
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_LAST_CHECK_KEY = 'lotus.tauriUpdateLastCheck';
@@ -773,6 +830,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<InviteNotifications />
<MessageNotifications />
<ReminderMonitor />
<RetentionSweeper />
<TauriUpdateFeature />
<TauriDesktopFeatures />
<LotusDenoiseFeature />
+72 -2
View File
@@ -223,6 +223,7 @@ const factoryRoomIdByUnread =
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
const LOW_PRIORITY_CATEGORY_ID = makeNavCategoryId('home', 'lowpriority');
export function Home() {
const mx = useMatrixClient();
useNavToActivePathMapper('home');
@@ -261,18 +262,21 @@ export function Home() {
const roomToUnread = useAtomValue(roomToUnreadAtom);
const [sortMenuAnchor, setSortMenuAnchor] = useState<RectCords>();
const { favoriteRooms, otherRooms } = useMemo(() => {
const { favoriteRooms, lowPriorityRooms, otherRooms } = useMemo(() => {
const favs: string[] = [];
const low: string[] = [];
const others: string[] = [];
rooms.forEach((rId) => {
const room = mx.getRoom(rId);
if (room?.tags?.['m.favourite']) {
favs.push(rId);
} else if (room?.tags?.['m.lowpriority']) {
low.push(rId);
} else {
others.push(rId);
}
});
return { favoriteRooms: favs, otherRooms: others };
return { favoriteRooms: favs, lowPriorityRooms: low, otherRooms: others };
}, [mx, rooms]);
const sortedFavoriteRooms = useMemo(() => {
@@ -297,6 +301,28 @@ export function Home() {
});
}, [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 isClosed = closedCategories.has(DEFAULT_CATEGORY_ID);
let comparator: (a: string, b: string) => number;
@@ -349,6 +375,13 @@ export function Home() {
overscan: 10,
});
const lowVirtualizer = useVirtualizer({
count: filteredLowPriorityRooms.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 38,
overscan: 10,
});
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId),
);
@@ -638,6 +671,43 @@ export function Home() {
})}
</div>
</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>
</PageNavContent>
)}
+2
View File
@@ -3,6 +3,7 @@ import { allInvitesAtom, useBindAllInvitesAtom } from '../room-list/inviteList';
import { allRoomsAtom, useBindAllRoomsAtom } from '../room-list/roomList';
import { mDirectAtom, useBindMDirectAtom } from '../mDirectList';
import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread';
import { markedUnreadAtom, useBindMarkedUnreadAtom } from '../room/markedUnread';
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications';
@@ -14,6 +15,7 @@ export const useBindAtoms = (mx: MatrixClient) => {
useBindRoomToParentsAtom(mx, roomToParentsAtom);
useBindThreadNotificationsAtom(mx, threadNotificationsAtom);
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
useBindMarkedUnreadAtom(mx, markedUnreadAtom);
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
};
+87
View File
@@ -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'));
});
+116
View File
@@ -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]);
};
+17
View File
@@ -116,6 +116,23 @@ test('PUT with unchanged counts is skipped (same map reference)', () => {
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
// ---------------------------------------------------------------------------
+30 -14
View File
@@ -24,7 +24,6 @@ import {
getUnreadInfo,
getUnreadInfos,
isNotificationEvent,
roomHaveUnread,
} from '../../utils/room';
import { roomToParentsAtom } from './roomToParents';
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
@@ -139,6 +138,27 @@ export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefin
}
if (action.type === 'PUT') {
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);
if (currentUnread && unreadEqual(currentUnread, unreadInfoToUnread(unreadInfo))) {
// Do not update if unread data has not changes
@@ -256,20 +276,16 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
),
);
if (isMyReceipt) {
// Don't blanket-DELETE the room's unread on any receipt: a THREADED
// receipt (reading one thread) would wipe the room's still-valid
// main-timeline badge, and if the room was already read no
// UnreadNotifications PUT follows to restore it. Recompute instead —
// DELETE only when the room is genuinely fully read.
const info = getUnreadInfo(
room,
getMutedThreads(threadNotificationsRef.current, room.roomId),
);
if (info.total === 0 && info.highlight === 0 && !roomHaveUnread(mx, room)) {
// Optimistically clear on our own receipt (upstream cinny behavior).
// Do NOT recompute from getUnreadInfo here: getUnreadNotificationCount is
// server-computed and STALE on the synchronous synthetic receipt echo
// (the SDK only zeroes it immediately when the last live event is our own
// message), so recomputing PUTs the stale non-zero count back → the dot
// sticks / resurrects. The RoomEvent.UnreadNotifications listener below
// re-asserts the accurate badge (incl. restoring the main badge after a
// thread read) once the server acks, and a { 0, 0 } PUT collapses to a
// DELETE in the reducer.
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
} else {
setUnreadAtom({ type: 'PUT', unreadInfo: info });
}
}
};
mx.on(RoomEvent.Receipt, handleReceipt);
+4
View File
@@ -183,6 +183,9 @@ export interface Settings {
urlPreview: boolean;
encUrlPreview: 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;
showNotifications: boolean;
@@ -288,6 +291,7 @@ const defaultSettings: Settings = {
urlPreview: true,
encUrlPreview: true,
showHiddenEvents: false,
enforceRetentionLocally: false,
legacyUsernameColor: false,
showNotifications: true,
+13 -1
View File
@@ -1,7 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
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
// (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'],
);
});
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);
});
+15
View File
@@ -1,8 +1,10 @@
import { atom } from 'jotai';
import type { IconSrc } from 'folds';
export type ToastNotif = {
id: string;
avatarUrl?: string;
iconSrc?: IconSrc; // folds Icon src for a "system" toast (shown instead of an avatar/initials)
displayName: string;
body: 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
};
// 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[]>([]);
// Write-only setter used in ClientNonUIFeatures
+4
View File
@@ -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);
+32 -1
View File
@@ -20,16 +20,22 @@ type RoomOpts = {
readUpTo?: string | null;
threads?: any[];
threadUnread?: Record<string, number>;
markedUnread?: boolean;
};
const setup = (opts: RoomOpts) => {
const calls: ReceiptCall[] = [];
const accountDataWrites: Array<{ type: string; content: any }> = [];
const room = {
getLiveTimeline: () => ({ getEvents: () => opts.timeline ?? [] }),
getEventReadUpTo: () => opts.readUpTo ?? null,
getThreads: () => opts.threads ?? [],
getThreadUnreadNotificationCount: (threadId: string, _type: NotificationCountType) =>
opts.threadUnread?.[threadId] ?? 0,
getAccountData: (type: string) =>
opts.markedUnread && type === 'm.marked_unread'
? { getContent: () => ({ unread: true }) }
: undefined,
};
const mx = {
getRoom: () => room,
@@ -38,8 +44,12 @@ const setup = (opts: RoomOpts) => {
calls.push({ eventId: event.getId(), receiptType, unthreaded });
return {};
},
setRoomAccountData: async (_roomId: string, type: string, content: any) => {
accountDataWrites.push({ type, content });
return {};
},
} as any;
return { mx, calls };
return { mx, calls, accountDataWrites };
};
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);
});
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 () => {
const t = thread('$root', evt('$reply', true)); // isSending → skip
const { mx, calls } = setup({
+8
View File
@@ -1,11 +1,19 @@
import { MatrixClient, NotificationCountType, ReceiptType } from 'matrix-js-sdk';
import { getSettings } from '../state/settings';
import { readMarkedUnread, setMarkedUnread } from '../state/room/markedUnread';
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
const { privateReadReceipts } = getSettings();
const room = mx.getRoom(roomId);
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 =
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read;
+42
View File
@@ -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',
);
});
+32
View File
@@ -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;
+17
View File
@@ -53,3 +53,20 @@ export const playClipLocally = (
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;
});
+2 -1
View File
@@ -38,7 +38,8 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
deviceId: session.deviceId,
timelineSupport: true,
cryptoCallbacks: cryptoCallbacks as any,
verificationMethods: ['m.sas.v1'],
// SAS (emoji) + QR-code verification (show/scan/reciprocate).
verificationMethods: ['m.sas.v1', 'm.qr_code.show.v1', 'm.qr_code.scan.v1', 'm.reciprocate.v1'],
tokenRefreshFunction: oidcRefresher
? (refreshToken) => oidcRefresher.doRefreshAccessToken(refreshToken)
: undefined,
+2
View File
@@ -2,6 +2,8 @@ export enum AccountDataEvent {
PushRules = 'm.push_rules',
Direct = 'm.direct',
IgnoredUserList = 'm.ignored_user_list',
// [MSC2867] Per-room "mark as unread" flag (room account data).
MarkedUnread = 'm.marked_unread',
CinnySpaces = 'in.cinny.spaces',
+5
View File
@@ -27,8 +27,13 @@ export enum StateEvent {
RoomTopic = 'm.room.topic',
RoomAvatar = 'm.room.avatar',
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',
RoomHistoryVisibility = 'm.room.history_visibility',
// [MSC1763] Per-room message retention policy (disappearing messages).
RoomRetention = 'm.room.retention',
RoomGuestAccess = 'm.room.guest_access',
RoomServerAcl = 'm.room.server_acl',
RoomTombstone = 'm.room.tombstone',