Compare commits

..

28 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
jared 21276a47fc fix(audit): low-tail cleanup — session/logout/unread/presence/forward
CI / Build & Quality Checks (push) Successful in 10m45s
CI / Trigger Desktop Build (push) Successful in 14s
Clears the clean 🟡 remainders from the feature audit (gate-green, 677 tests):
- F3: getFallbackSession prefers the session-blob/legacy source with the later
  expiresAt (a downgrade→upgrade could boot on a stale blob's dead token).
- F6: server-forced logout (SessionLoggedOut) now mirrors logoutClient —
  pushSessionToSW() + best-effort revokeOidcTokens for OIDC sessions (the search
  plaintext wipe was already added).
- N5: deleteUnreadInfo parent fallback `?? roomId` → `?? []` (latently spread the
  roomId string into chars).
- P10: useUserPresence re-seeds when the User object appears after first render.
- forward: strip m.mentions so forwarding doesn't re-ping the original mentions.

Left open: F5 (OIDC expiry not reachable in persistTokens), N6/H10/D7 (minor /
runtime-verify). See LOTUS_TODO.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:57:09 -04:00
jared b7788cc79c docs: mark D6 Windows rich-toast AUMID fixed + add runtime test
CI / Build & Quality Checks (push) Successful in 10m41s
CI / Trigger Desktop Build (push) Successful in 7s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:32:31 -04:00
jared 13d08c3fd7 docs: mark H5 invite-QR fixed (local generation)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:19:42 -04:00
jared a899d7d3a8 fix(privacy): generate invite QR locally instead of api.qrserver.com (H5)
The Share Room QR was fetched from the third-party api.qrserver.com, leaking
which rooms a user shares (and failing offline / under strict CSP). Now rendered
locally via qrcode.react (QRCodeSVG) — no network request, works offline. Added a
white quiet-zone container so the code scans on any theme; dropped the qrError
fallback (local generation can't fail the same way). Removed api.qrserver.com
from the prod CSP img-src (matrix repo). Build verified (rolldown interop OK).
Verification steps added to LOTUS_TESTING.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:19:22 -04:00
jared dcd8201e16 fix(wave-3): audit fixes — ACL guards, presence, moderation, theming perf
Wave-3 bug-hunt fixes (findings in LOTUS_TODO), reviewed + gate-green:
- 🔴 ACL editor [H1–H4]: block saving an empty allow-list (was a one-click
  federation brick), warn on self-ban (case-insensitive glob match of
  mx.getDomain() vs allow/deny), accept real globs (1.2.3.*, *.evil.*), and
  gate Save behind a confirm dialog.
- 🔴 [P1] room context menu no longer acts on the wrong room after a live
  reorder (key by roomId, not list index). 🔴 [P2] status writes no longer
  force presence to online over Invisible/DND (shared presenceStateFromSetting).
- 🟠 [P3] timed mutes restored on boot; [P4] custom-status auto-clear now fires
  (always-mounted StatusExpiryMonitor); [P5] timezone also PUT to the m.tz
  profile field so it's visible to others; [H6] RoomInsights single-pass
  min/max (was Math.min(...spread) stack overflow); [H7/H8] mod-log labels.
- 🟡 [P6/P7] favorites collapse+filter, [P8] charCount reset, [P9] DM preview
  refresh on decrypt; theming [T-P1] lazy decorations, [T-P2] drop the redundant
  always-on body animation, [T-P4] live useReducedMotion, [T-P5] decoration key.
- NATIVE-CINNY LAW: notification presets + Powers permissions use folds icons.

DEFERRED: [H5] invite-QR is fetched from api.qrserver.com (third-party leak);
local generation needs a bundled QR lib (not added). tsc/eslint/prettier clean,
build OK, 677 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:40:07 -04:00
jared 41149db685 fix(ui): NATIVE-CINNY LAW — replace emoji with folds icons in settings
- Notification profile presets (P5-27) used literal emoji (🎮/💼/🌙) instead of
  folds Icons → Gaming=Ball, Work=Monitor, Sleep=BellMute.
- Permissions "Powers" list used / text emoji for has/no-power → folds
  Icons.Check / Icons.Cross (colored via the row).

Reviewed the rest of the UI: seasonal-theme picker emoji kept (folds has no
holiday-icon equivalents; a distinctly-Lotus visual feature), soundboard clip
emoji kept (user-chosen clip identity), URL-preview brand glyphs + upstream
device-verification emoji + keyboard key-symbols left as-is.

(Also records the F2 URL-preview decision: keep default-on.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:21:00 -04:00
jared 668bdaad7d fix(wave-2): audit fixes — account-data races, search-cache wipe, export, media
Web fixes from the Wave-2 bug-hunt (findings in LOTUS_TODO):
- F1 (security): wipe the decrypted-plaintext search index on SERVER-FORCED
  logout too (token expiry / remote sign-out) — only manual logout did before.
  F4: the delete no longer reports success while onblocked (waits, 3s cap).
- M1/M2 (data-loss): useBookmarks + useUserNotes account-data writes are now
  serialized at MODULE scope (single queue + latestRef per client, echo-driven),
  fixing the cross-instance lost-update clobber (useBookmarks mounts per message
  row, so a per-instance queue was insufficient — caught in review).
- M6: room-history export gets a 200-page cap + Cancel + unmount-abort +
  correct date-range early-break (raw paginated ts). M4: image compression
  skips PNG (was flattening transparency to black), bakes EXIF orientation via
  createImageBitmap, .jpg-renames, and falls back to the original on decode
  failure instead of dropping the file. M5: MediaGallery lightbox opens the
  right item (shared thumb guard). M8: audio speed survives async decrypt.
- Desktop web wiring: D2 badge sums leaf rooms only (space double-count, like
  the favicon fix); D3 useTauriDnd re-hydrates from get_tray_dnd on mount; D5
  updater has a terminal state.

Reviewed; M7 reverted (past-time clamp is an intentional, tested contract).
tsc/eslint/prettier clean, build OK, 678 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:56:27 -04:00
jared ee6bdd8241 fix(call): Wave-1 audit fixes (calls host side)
- C-H1: forceState only on FIRST join; on EC reconnect re-arm the fork handlers
  (resendForkState — deafen+quality only) instead of clobbering live mic/video/
  deafen back to the join-time snapshot.
- C-H2: AFK auto-mute reads the fork's io.lotus.call_state VAD of the LOCAL
  published track instead of getUserMedia on the browser DEFAULT mic (which could
  measure silence while the user spoke on another device → auto-mute an active
  speaker). Fails safe (never mutes) when call_state is null OR empty.
- C-H3: control observer re-binds after EC re-renders (body subtree:true + 100ms
  debounce) with an early-return so unchanged state doesn't re-render.
- C-M3 setQuality join-gated; C-M4 hangup 4s fallback dispose (idempotent);
  C-M5 PTT no longer silently un-deafens; C-M6 screenshare-audio mute resets on
  stop; C-L4 deafen key works in the iframe; C-L6 setState-after-unmount guards.

Reviewed (C-H2 [] fail-safe + C-H3 re-render guard applied). tsc/eslint/prettier
clean, build OK, 677 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:20:07 -04:00
jared 0bbdd7ce94 fix(notifications/threads): Wave-1 audit fixes (🔴 + web 🟠)
- T1 (🔴): markThreadAsRead no longer receipts the thread ROOT (a 2nd instance
  of the read-marker-corruption regression — opening a thread whose root is old
  re-lit the whole room). Extracted to a pure threadReceipt.ts + 5 regression
  tests.
- N1 (🔴): favicon/tab-title unread count now sums only leaf rooms (was double-
  counting every ancestor-space aggregate in roomToUnread).
- N2 (🔴): notifications/sounds dedupe on the event id, not the unread count —
  fixes "read a DM, next message never notifies again".
- T4 (🟠): the thread notification path no longer re-gates on the room count, so
  an explicit per-thread "All replies" override in a Mentions-only room fires.
- N3 (🟠): getUnreadInfos skips phantom {0,0} entries (muted-thread-only rooms no
  longer light the nav row / pollute unread filters).
- N4 (🟠): the Receipt handler recomputes unread instead of blanket-DELETE, so a
  threaded receipt can't wipe a room's valid main-timeline badge.
- T2 (🟠): thread "Jump to Latest" re-anchors the virtual window (was landing on
  a stale mid/old event).

Gates: tsc/eslint/prettier clean, build OK, 678 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:10:32 -04:00
jared 7c85ad177f docs(audit): Wave-1 bug-hunt findings (notifications/threads/calls/EC fork)
4 parallel deep-audit agents over the Tier-1 high-risk areas. Findings only (no
source changes). Top 🔴: markThreadAsRead corrupts the main read marker via a
thread-root receipt (a SECOND instance of the P6 read-receipt regression, likely
a live cause of "unread won't clear"); favicon/title count double-counts space
aggregates; deliverNotification dedupe cache never cleared on read → missed
notifications/sounds. Plus 🟠 (thread "All" override defeated, phantom
muted-thread dot, receipt-DELETE badge race, thread jump-to-latest, call
forceState-on-reconnect clobber, AFK wrong-mic auto-mute, stale control observer)
and a long 🟡 tail. Recorded in LOTUS_TODO for prioritized fix passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 19:25:57 -04:00
jared bbf0800c19 fix(ci): disable lines-between-class-members + prefer-arrow-callback for test files
CI / Build & Quality Checks (push) Successful in 10m46s
CI / Trigger Desktop Build (push) Successful in 12s
CI check:eslint failed with 28 errors in two test files: callSounds.test.ts
(lines-between-class-members on mock classes) and lotusDenoiseUtils.test.ts
(prefer-arrow-callback on `function AudioWorkletNode(){}` constructor mocks —
arrows aren't constructable, so auto-fixing would break the test). Both are
stylistic false-positives for test code; relax them in the existing test-file
override next to max-classes-per-file. `npm run check:eslint` now exits 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 18:27:01 -04:00
jared abd0753148 fix(notifications): safe thread receipts on mark-read (fixes read-receipt regression)
CI / Build & Quality Checks (push) Failing after 35m56s
CI / Trigger Desktop Build (push) Has been skipped
The prior thread-receipt change (8192da5a) broke read receipts globally. Exact
cause: markAsRead used `thread.lastReply() ?? thread.rootEvent`. When a thread's
replies weren't loaded (lastReply() null — common on room open), it sent a
receipt for the thread ROOT. Since roots are "in the main timeline",
threadIdForReceipt() makes that a MAIN receipt at an old event; when the root
isn't in the loaded timeline the SDK's backward-guard falls back to timestamp
and applies it, moving the main read receipt onto an event we don't have, so
getEventReadUpTo() returns null and roomHaveUnread() reports the room unread —
re-broken on every mark-read, amplified by the bulk mark-all-orphan-rooms-read
callers.

Fix: main unthreaded receipt unchanged; the thread loop now sends a threaded
receipt ONLY for a genuine loaded thread reply (thread.lastReply()), never the
root — if replies aren't loaded, skip. New notifications.test.ts locks the
regression (null lastReply → no root receipt) + the main/threaded/no-op cases.

Gates: tsc/eslint/prettier clean, build OK, 672 tests (7 new).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:09:28 -04:00
jared 8192da5a12 fix(notifications): clear thread receipts on mark-read; cap avatar-decoration refetch
CI / Build & Quality Checks (push) Successful in 10m41s
CI / Trigger Desktop Build (push) Successful in 29s
Two federated-room bugs surfaced by the desktop build:

1. markAsRead only sent one unthreaded receipt at the main-timeline tail. With
   threadSupport enabled, thread replies leave the main timeline, so a reply
   newer than that tail was never covered — its per-thread notification count
   (which the room dot sums) lingered, so the unread dot never cleared even
   after reading. It also early-returned when the main timeline was already
   read. Now also send a threaded receipt at each unread thread's latest reply.

2. useAvatarDecoration never cached non-404 failures, so every avatar mount
   re-requested io.lotus.avatar_decoration for federated users whose homeserver
   403s/502s the field — a refetch storm that spammed the console and hammered
   our homeserver's federation. Now cache definitive rejections (400/403/404)
   and give up after ~2 transient (429/5xx) attempts per session.

Gates: tsc/eslint/prettier clean, build OK, 665 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:31:10 -04:00
jared 6dc478e989 fix(desktop): Custom Window Chrome toggle breaks the timeline (P5-47)
CI / Build & Quality Checks (push) Successful in 10m35s
CI / Trigger Desktop Build (push) Successful in 9s
Toggling custom chrome expanded the screen and sent the message feed
auto-scrolling into the past. Two causes:
- DesktopChrome used height:100vh while html/#root use 100dvh; in the Tauri
  webview 100vh can exceed the visible height after decorations are stripped,
  making the timeline's scroll container taller than the viewport → the virtual
  paginator runs away paginating backwards. Switched to 100dvh.
- Toggling live reflowed the whole app while the timeline was mounted. The
  setting now persists + reloads so the layout is rebuilt cleanly (description
  updated: "reloads to apply").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:16:52 -04:00
jared 049472e25f feat(crypto) + docs: request persistent storage; consolidate docs to 3
CI / Build & Quality Checks (push) Successful in 10m54s
CI / Trigger Desktop Build (push) Successful in 12s
- index.tsx: request navigator.storage.persist() for logged-in sessions so the
  browser can't evict the IndexedDB rust-crypto store (eviction while the
  localStorage session survives resurrects the device with a blank store → the
  KE-1 "one time key already exists" upload storm). Guarded, checks persisted()
  first, best-effort.
- Docs: remove HANDOFF_ELEMENT_CALL_FORK.md, LOTUS_E2EE_INVESTIGATION.md, and
  LOTUS_BUGS.md. Port their live content into the three kept docs — verification
  backlog → LOTUS_TESTING; open bugs + E2EE (KE-1..4) + an Element Call fork
  operational reference (publish steps + io.lotus action catalog) → LOTUS_TODO.
  Fix all dangling references (README, code comments, cross-doc links). Full
  history of the removed docs remains in git.

Gates: tsc/eslint/prettier clean, build OK, 665 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:28:09 -04:00
106 changed files with 3906 additions and 2725 deletions
-686
View File
@@ -1,686 +0,0 @@
# HANDOFF — Forking & Self-Building Element Call ("Lotus Call")
> **Audience:** a fresh Claude/engineer session with **no prior context** on this
> project. Read this top-to-bottom before touching anything. This document is the
> single source of truth for the Element Call (EC) fork initiative.
>
> **Status:** **PHASE 02 IMPLEMENTED (build-verified, not yet live-tested)**
> (2026-06-30). The fork exists, builds, is published, and cinny consumes it
> (Phase 0/1). **All 7 Phase-2 EC features are implemented on the fork's `lotus`
> branch**, each additive + flag-gated, build+typecheck-clean, per-feature
> reviewed (+ a holistic multi-agent review), and pushed. **None are live-tested
> yet** — every one needs the `LOTUS_TESTING.md` §D sweep, and the **cinny host
> side must be wired** (set flags / send actions / handle call_state) — see §12.
> See **§9** Phase 0/1 results, **§10** cutover, **§11** Phase-2 seams, **§12**
> Phase-2 status + cinny integration checklist. Created 2026-06 from `LotusGuild/cinny`.
---
## 9. Phase 0 Results (verified 2026-06-29)
**Decisions taken with the user:** scope = Phase 0 recon; consumption model =
**private npm package** (§5 option 1). Recommended registry = **Gitea's built-in
npm registry** (`code.lotusguild.org`) — zero new infra.
### 9.1 Version → tag → commit mapping (LOCKED)
| Source | Value |
| :--------------------------------------------------- | :----------------------------------------- |
| cinny `package.json` pin | `@element-hq/element-call-embedded@0.20.1` |
| Bundle self-report (`VITE_APP_VERSION`/`appVersion`) | `embedded-v0.20.1` |
| npm registry `gitHead` for 0.20.1 | `2d74c48151d9edc01c65a22a91478aac81bf24d0` |
| GitHub tag `v0.20.1` → commit | `2d74c48…`**same commit** |
**Fork from upstream tag `v0.20.1` (commit `2d74c48`).** The embedded package
version equals the element-call release tag; repo `package.json` version is
`0.0.0` and the real version is stamped at publish time from the tag.
### 9.2 The shipped npm dist is a CLEAN upstream build
No `lotus`/`denoise`/`rnnoise` strings anywhere in
`node_modules/@element-hq/element-call-embedded/dist`. **All Lotus customization
(denoise shim) is injected at cinny build time, not baked into the package** — so
swapping the source does not disturb cinny's denoise injection layer. The
ringtone/reaction assets (`baduntss`, `cat`, `clap`, `call_declined`, …) are
upstream EC's own, not ours.
### 9.3 Build toolchain & mechanism
- **Node `24`** (`.node-version`), **pnpm `10.33.0`** (`packageManager` field,
via corepack).
- Build: **`pnpm run build:embedded`** = `vite build --config
vite-embedded.config.ts` with `NODE_OPTIONS=--max-old-space-size=16384`.
- Output dir is **repo-root `dist/`**; CI stages it into **`embedded/web/dist`**
(the `embedded/web/` dir holds the publish template: `package.json`, README,
both LICENSE files).
- Publish workflow upstream = `.github/workflows/publish-embedded-packages.yaml`:
builds → `npm version <tag> --no-git-tag-version` → `npm publish --provenance
--access public` to npmjs as `@element-hq/element-call-embedded`. (Also
Android/Maven + iOS/SwiftPM — irrelevant; we are web-only.)
### 9.4 Build reproduction — PARITY CONFIRMED
Cloned `element-call@v0.20.1` to `/root/code/element-call` (shallow), built with
isolated Node 24 / pnpm 10.33.0 (system Node 20 / cinny untouched). Result vs the
shipped npm dist:
- **137 of 147 files byte-identical** (same Vite content-hash): all CSS, fonts,
wasm, audio, JSON locale files, and `IndexedDBWorker`.
- **Only 5 JS chunks differ** (`index`, `pako.esm`, `polyfill-force`,
`rust-crypto`, `spa`) — **cause isolated to the version define**: our local
build baked `appVersion:\`dev\``(because`VITE_APP_VERSION`was unset) vs the
npm build's`appVersion:\`embedded-v0.20.1\``. `index.html` is identical modulo
the hashed asset filenames. **Benign** — our CI sets the version from the git
tag, so a tagged CI build will match.
### 9.5 Fork CI (drafted)
`.gitea/workflows/ci.yml` is staged in the clone (models cinny's
`.gitea/workflows/ci.yml` + upstream's publish flow). Linux-only (`ubuntu-latest`)
— the Windows worker is for cinny-desktop/Tauri, not the EC web bundle. Build job
on PR/push to `lotus`; publish job on `v*` tag → `@lotusguild/element-call-embedded`
to the Gitea npm registry (needs `secrets.GITEA_NPM_TOKEN`).
### 9.6 Phase 1 — DONE (2026-06-29)
1. ✅ **Fork repo live:** `code.lotusguild.org/LotusGuild/element-call` (public,
AGPL), default branch `lotus`, full history (7018 commits) + tag `v0.20.1`.
Branch `lotus` = `v0.20.1` + 2-file diff (CI workflow + embedded package
rename).
2. ✅ **Package published:** `@lotusguild/element-call-embedded@0.20.1` on the
Gitea npm registry (published manually from the version-faithful build while
the admin token was available). **Publicly readable** (unauth `npm install`
works → devs/CI need no token to consume; only publishing needs one).
3. ✅ **cinny wired & built clean** (Node 24): `.npmrc` scope line +
`package.json` dep + `vite.config.js` `viteStaticCopy` src. `npm install`
swapped the package (resolved from Gitea), `npm run build` succeeded,
`dist/public/element-call/` populated, bundle reports `appVersion:
embedded-v0.20.1`, **denoise shim injected + all denoise assets copied**
(injection layer unchanged). **These cinny edits are staged in the working
tree, NOT committed/pushed** — pushing triggers CI → desktop → deploy, so it's
gated on the §D live test (see §10).
### 9.8 Reproducibility note (important)
A from-source rebuild is **NOT byte-identical** to upstream's npm tarball.
137/147 files match exactly (CSS, fonts, wasm, audio, worker); the 5 JS chunks
(`index`, `pako.esm`, `polyfill-force`, `rust-crypto`, `spa`) differ because the
rolldown/oxc **minifier mangles export names differently** across build
environments (and the version-define is one input). This is normal and benign —
the code is functionally equivalent. **Do not chase byte-parity; the §D live call
test is the real parity gate.**
### 9.9 Remaining follow-ups (not blocking the cutover)
- **CI publishing:** `.gitea/workflows/ci.yml` publishes on a `v*` tag but needs
(a) a Gitea Actions runner for `LotusGuild/element-call`, and (b) a **durable**
`GITEA_NPM_TOKEN` repo secret with package read/write (the admin token used for
the manual publish is being deleted, so it was deliberately NOT baked in). Until
then, publishing is manual (`npm version <tag>` in `embedded/web` →
`npm publish`).
- Decide rebase cadence vs upstream (0.20.2 / 0.20.3 already out — see §9.1).
### 9.7 Ready-to-apply artifacts (staged 2026-06-29)
**Fork side — already committed** on branch `lotus` in `/root/code/element-call`
(remote `lotus` = `code.lotusguild.org/LotusGuild/element-call.git`, push deferred
until the repo exists). Minimal 2-file diff vs tag `v0.20.1`:
`.gitea/workflows/ci.yml` (new) + `embedded/web/package.json` (rename to
`@lotusguild/element-call-embedded`). Push with:
`git push -u lotus lotus && git push lotus v0.20.1` (and tag `v0.20.1` on our side
to trigger the first publish, or push our own `v0.20.1` tag).
**cinny side — NOT yet applied** (applying before the package is published breaks
`npm ci`). Exactly 3 edits + a lockfile regen:
1. `.npmrc` — append the scoped-registry line:
```
@lotusguild:registry=https://code.lotusguild.org/api/packages/LotusGuild/npm/
```
(CI/auth: `//code.lotusguild.org/api/packages/LotusGuild/npm/:_authToken=${GITEA_NPM_TOKEN}`
— inject via env in CI, do not commit a plaintext token.)
2. `package.json:104` —
`"@element-hq/element-call-embedded": "0.20.1"` →
`"@lotusguild/element-call-embedded": "0.20.1"`.
3. `vite.config.js:25` — `viteStaticCopy` src:
`node_modules/@element-hq/element-call-embedded/dist` →
`node_modules/@lotusguild/element-call-embedded/dist`.
**`stripBase: 4` stays unchanged** — `node_modules/@lotusguild/element-call-embedded/dist`
is still exactly 4 leading segments. (Update the comment's path reference too.)
4. `package-lock.json` — regenerated by `npm install`, not hand-edited (drops the
`registry.npmjs.org/@element-hq/...` resolved URL for the Gitea one).
The denoise injection (`lotusDenoise()` in `vite.config.js`) is **unchanged** — it
keys off `dist/public/element-call/index.html`, which our fork's bundle still
produces identically (verified: `index.html` byte-identical modulo asset hashes).
---
## 0. TL;DR / The Goal
We embed **Element Call** (the Matrix group-VoIP/video app) inside Lotus Chat to
power voice/video channels. Today we consume Element's **pre-compiled npm
bundle** and can only steer it from the outside (a limited widget API + fragile
same-origin DOM hacks). Several in-call problems are **unfixable from outside**
because they live in EC's compiled JS.
**We want true ownership: fork `element-hq/element-call`, build it from source
ourselves, host our build, and replace the npm bundle with our fork.** Then
every in-call behavior becomes editable code.
**This requires standing up a brand-new repo and build pipeline for our EC fork.**
---
## 1. Why fork? (What we cannot fix today)
These came out of live testing and are documented in `LOTUS_BUGS.md` →
"Known Element Call iframe limitations":
| Issue | What's wrong | Why outside-fixes fail |
| :----------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **A6** — avatar decorations in-call | Our profile-decoration overlays don't appear on in-call video tiles | The video grid is rendered by EC's React app inside the iframe. We can only inject overlay DOM (fragile) — we can't make it a first-class part of the tile. |
| **A5** — focus camera / fullscreen during screenshare | Can't reliably spotlight a participant's camera while someone screenshares | EC's **layout logic** (screenshare priority, spotlight) is compiled JS we don't control. We currently DOM-click tiles as a hack. |
| **A7** — mic dead after EC's "Reconnect" | After EC's own mid-call reconnect, the local mic isn't re-published | EC's reconnect/track-republish path is internal. (Partly entangled with our denoise shim — see §6.) |
| Native theming | EC's UI doesn't match Lotus design; we inject CSS hacks | Real theming needs source-level component/token changes. |
| Decorations, custom controls, custom layouts, branding | all blocked | all require source access |
**Bottom line:** the iframe is **same-origin** (we self-host it), so we can read
and even write its DOM — but we **do not own its source**, so we can't change its
**behavior/logic**, only poke at its rendered output. Forking removes that wall.
---
## 2. How EC is integrated TODAY (the current architecture)
Understand this fully before changing it — the fork must slot into the same
integration seams.
### 2.1 Where the EC bundle comes from
- npm package: **`@element-hq/element-call-embedded`**, pinned to **`0.20.1`** in
`cinny/package.json` (line ~104).
- It ships a **pre-built `dist/`**. At cinny build time,
`vite-plugin-static-copy` copies that `dist/` flat into
**`public/element-call/`** (see `cinny/vite.config.js`, the `copyFiles`
target with `rename: { stripBase: 4 }` — note the stripBase gotcha documented
there; getting this wrong 404s the widget).
- It is **NOT committed** to git (`git ls-files public/element-call` → 0). It's a
build artifact materialized from `node_modules`.
### 2.2 How EC is loaded & controlled
- The widget iframe `src` is **same-origin**:
`${BASE_URL}/public/element-call/index.html?<params>` (see
`cinny/src/app/plugins/call/CallEmbed.ts`, `getWidget()` /
`getIframe()`). Sandbox: `allow-forms allow-scripts allow-same-origin
allow-popups allow-modals allow-downloads`; `allow="microphone; camera;
display-capture; autoplay; clipboard-write;"`.
- **Control surface #1 — the official widget API** (`matrix-widget-api`):
`ClientWidgetApi` + a custom `CallWidgetDriver`. This is the robust,
version-stable channel (theme change, hangup, capabilities, timeline events).
Files: `plugins/call/CallEmbed.ts`, `plugins/call/CallWidgetDriver.ts`,
`plugins/call/utils.ts` (capabilities), `plugins/call/CallControl.ts`.
- **Control surface #2 — same-origin DOM poking** (fragile, version-coupled):
reading `iframe.contentDocument` to detect speakers/mute state and
`.click()`-ing tiles to focus a camera. Files:
`hooks/useCallSpeakers.ts` (reads `[data-muted]`, `[data-video-fit]`),
`plugins/call/CallControl.ts` (`focusCameraParticipant` — tile selectors).
**These selectors break on every EC version bump.** A fork lets us replace
these hacks with real APIs/props.
- **Control surface #3 — URL params + build-time injection** for our denoise
shim (see §6).
### 2.3 Full file inventory (everything that touches EC in cinny)
Plugin / core:
- `src/app/plugins/call/CallEmbed.ts` — iframe creation, widget API wiring, theme sync, hangup, load watchdog/self-heal, denoise URL params.
- `src/app/plugins/call/CallControl.ts` — control state + **DOM-poking** (`focusCameraParticipant`, spotlight).
- `src/app/plugins/call/CallControl.tsx` _(call-status variant)_ and `features/call-status/CallControl.tsx`.
- `src/app/plugins/call/CallWidgetDriver.ts` — widget driver (capabilities, event relay).
- `src/app/plugins/call/utils.ts` — widget capabilities set.
- `src/app/plugins/call/hooks.ts`, `index.ts` — plugin exports/hooks.
- `src/app/state/callEmbed.ts` — jotai atoms for the active embed.
React / UI:
- `src/app/components/CallEmbedProvider.tsx` — the big one: incoming-call ring/banner, RTCNotification + **RTCDecline** listeners, PiP, mute badges, fullscreen, ringtones.
- `src/app/features/call/CallView.tsx` — prescreen lobby vs joined (the iframe placement target), load-error recovery UI.
- `src/app/features/call/CallControls.tsx` — in-call control bar (mic/cam/deafen/screenshare/fullscreen/more/PiP).
- `src/app/features/call/CallMemberCard.tsx` — **lobby** participant roster (this is where `AvatarDecoration` works today; in-call grid is EC's).
- `src/app/features/call/PrescreenControls.tsx` — join controls.
- `src/app/features/call-status/*` — `CallStatus.tsx`, `MemberGlance.tsx` (the "Focus camera" menu lives here), `LiveChip.tsx`.
- `src/app/features/room-nav/RoomNavItem.tsx`, `features/room/Room.tsx`, `features/room/RoomViewHeader.tsx`, `pages/client/space/Space.tsx`, `pages/CallStatusRenderer.tsx`, `pages/Router.tsx` — call entry points / status surfacing.
Hooks:
- `src/app/hooks/useCallEmbed.ts`, `useCall.ts`, `useCallSpeakers.ts` (DOM-poking), `useCallJoinLeaveSounds.ts`, `useAfkAutoMute.ts`.
Build:
- `cinny/vite.config.js` — `copyFiles` (EC dist copy) + `lotusDenoise()` plugin (denoise asset copy + index.html shim injection, in `closeBundle`).
Utils:
- `src/app/utils/ringtones.ts`, `utils/denoisePipeline.ts`, `utils/lotusDenoiseUtils.ts`.
---
## 3. Hosting / infra context (the OTHER repo)
There are **two repos**:
1. **`LotusGuild/cinny`** (`/root/code/cinny`) — this Lotus Chat fork. Consumes EC.
2. **`LotusGuild/matrix`** (`/root/code/matrix`) — the **infra/homeserver** repo.
Subdirs: `livekit/` (the SFU EC talks to), `deploy/`, `draupnir/`,
`hookshot/`, `landing/`, `matrixbot/`, `systemd/`. Gitea remote
`code.lotusguild.org/LotusGuild/matrix`, branch `main`.
EC needs a **LiveKit SFU** + the **livekit-jwt-service**; those live in
`matrix/livekit/`. A self-hosted EC build must be configured to point at our
homeserver (`matrix.lotusguild.org` / synapse) and our LiveKit. EC's runtime
`config.json` (homeserver, livekit URL, feature flags) is part of what we'll own
once we build it ourselves.
Deployment today: `chat.lotusguild.org` (the cinny web build, which embeds EC at
`/public/element-call/`). cinny-desktop (`LotusGuild/cinny-desktop`, a Tauri
wrapper, bumped by cinny CI) embeds the same.
---
## 4. The plan (proposed — confirm with the user before executing)
### Decision: **YES, create a new repo.** `LotusGuild/element-call`
Rationale: EC is a large standalone app (React + LiveKit client SDK + matrixRTC +
its own Vite build + heavy deps). Keep it out of cinny so cinny's build stays
clean — cinny keeps consuming a **built EC `dist/`**, exactly as today, just
sourced from **our fork** instead of npm.
### Phase 0 — Recon (no code)
- Fork `github.com/element-hq/element-call` → `LotusGuild/element-call` on Gitea.
- Pin to the upstream tag matching **0.20.1** (`element-call-embedded` 0.20.1's
corresponding `element-call` release) so behavior matches what's shipping now.
Verify the embedded-package version ↔ element-call repo tag mapping.
- Read EC's own build docs: it builds the "embedded" widget bundle (the thing
currently published as `@element-hq/element-call-embedded`). Reproduce that
build locally and confirm the output matches `public/element-call/` today.
- **License:** element-call is **AGPL-3.0**, same as Lotus Chat — compatible.
Our fork must remain AGPL and publish source.
### Phase 1 — Reproduce current behavior from our fork (parity, no features)
- Build our fork's embedded bundle; wire cinny to consume it instead of the npm
package (see §5 for the consumption options). Smoke-test: a call works exactly
as today (web + desktop), denoise shim still injects, widget API + theme still
work. **No behavior change yet** — this de-risks the swap.
### Phase 2 — Replace the outside hacks with source-level features
Tackle the §1 issues in EC's source:
- **A6:** render avatar decorations as part of the video-tile component
(read decoration data we pass in via widget data / URL param / a small bridge).
- **A5:** fix focus/spotlight + screenshare-coexistence in EC's layout code;
expose a clean widget action so cinny can trigger it (kill the DOM `.click()`).
- **A7:** fix mic re-publish on reconnect; reconcile with our denoise shim (§6) —
ideally move denoise INTO the fork as a real audio-processing step instead of a
`getUserMedia` monkeypatch.
- Native Lotus theming/branding at the source (kill the injected-CSS hacks).
- Then retire the DOM-poking in `useCallSpeakers.ts` / `CallControl.ts` in favor
of real widget messages.
### Phase 3 — Maintenance posture
- Decide rebase cadence vs. upstream element-call releases. Keep customizations
isolated (feature flags / minimal-diff patches) to ease rebasing.
- CI in the new repo builds + publishes the embedded dist as a versioned
artifact; cinny CI consumes a pinned version.
---
## 5. How cinny should consume the fork (pick one — decide with user)
1. **Private npm package** (mirror the current model): our fork's CI publishes
`@lotusguild/element-call-embedded` to a registry; cinny depends on it and
`viteStaticCopy` keeps working almost unchanged. _Cleanest swap; needs a
registry._
2. **Git submodule + build in cinny CI:** add the fork as a submodule, build it
during cinny's build, copy its `dist/` to `public/element-call/`. _No
registry; heavier cinny CI._
3. **CI artifact copy:** fork CI uploads a `dist` tarball; cinny CI downloads a
pinned version at build. _Decoupled; needs artifact plumbing._
**Recommendation: Option 1** — it changes the least in cinny (just swap the
package name in `package.json` + the `viteStaticCopy` src path) and preserves the
clean cinny/EC separation.
---
## 6. The denoise shim — critical interaction (don't break this)
Lotus ships ML noise suppression by **injecting a same-origin pre-init shim into
EC's `index.html` at build time** (cinny `vite.config.js` → `lotusDenoise()`,
`closeBundle`). The shim monkeypatches `getUserMedia` **before EC captures the
mic** and routes audio through RNNoise/Speex/DTLN AudioWorklets, then EC/LiveKit
publishes the processed track. It's activated via URL params
(`lotusDenoise=ml&lotusModel=…&lotusGate=…`) set in `CallEmbed.ts`.
- Assets copied to `public/element-call/denoise/` at build (sapphi RNNoise/Speex/
gate worklets + `@workadventure/noise-suppression` DTLN tree).
- Related: `utils/denoisePipeline.ts`, `utils/lotusDenoiseUtils.ts`,
`settings/general/DenoiseTester.tsx`, `VoiceMessageRecorder.tsx`.
- **Known issues:** denoise quality is still poor (tracked separately); and the
mic-after-reconnect bug (A7) is suspected to involve the shim's getUserMedia
patch handing back a stale processed stream when EC re-acquires the mic.
**Once we own the fork, the right move is to make denoise a first-class
audio-processing stage inside EC** (not an index.html monkeypatch) — more robust,
survives reconnects, and removes the build-time injection hack. Until then, the
fork's `index.html` must remain injectable the same way, or the shim must be
re-homed into the fork.
---
## 7. Doc-accuracy notes / corrections for the new session
- `LOTUS_TODO.md` (~line 533) calls EC a **"cross-origin iframe"** — **outdated.**
EC is **same-origin** today (self-hosted under our domain;
`iframe.sandbox` includes `allow-same-origin`; we read `contentDocument`), and
**as of 2026-06-29 we own the fork's source** (`@lotusguild/element-call-embedded`).
The _practical_ point it made still holds _until we ship the audio-inject API_:
**LiveKit's `LocalAudioTrack` lives in EC's module scope**, not on `window`, so
cinny can't reach it even same-origin — which is why the in-call soundboard had
to be local-playback-only. **The fork removes this wall:** EC can expose a real
`io.lotus.inject_audio` widget action (Phase 2) that mixes into the published
track from inside its own module scope.
- `LOTUS_FEATURES.md` documents the EC upgrade history (0.16.3 → 0.19.4 →
0.20.1), the dark-mode CSS injection, and AFK auto-mute — all relevant prior
art for what the fork must preserve.
- `LOTUS_TESTING.md` §D is the **EC regression sweep** to re-run after the fork
swap (Phase 1 parity check).
---
## 8. First actions for the new session
1. Read this file, then skim §2.3's files in `cinny` to internalize the seams.
2. Confirm with the user: new repo name, consumption model (§5), rebase cadence.
3. Phase 0: fork element-call, map 0.20.1 ↔ element-call tag, reproduce the
embedded build locally, diff against `public/element-call/`.
4. Phase 1: wire cinny to the fork, run `LOTUS_TESTING.md` §D parity sweep.
5. Only then start Phase 2 features (A5/A6/A7, theming, denoise-in-source).
**Cross-references:** `LOTUS_BUGS.md` (EC limitations + verify queue),
`LOTUS_TODO.md` (denoise/soundboard constraints), `LOTUS_FEATURES.md` (EC history),
`LOTUS_TESTING.md` §D (regression sweep). Infra: `/root/code/matrix` (`livekit/`,
`deploy/`).
---
## 10. Live cutover — the remaining steps (Phase 1 finish)
The fork is published and cinny builds against it locally (§9.6). What's left to
go live:
1. **Run `LOTUS_TESTING.md` §D** against a local cinny build (`npm run build` is
already proven; serve `dist/` or `npm run dev`). Verify a real call: join,
mic/cam, screenshare, theme sync, denoise on, widget hangup — web first.
2. **Commit the cinny edits** (currently staged, uncommitted in the working tree):
`.npmrc`, `package.json`, `package-lock.json`, `vite.config.js`. Suggested
message: `chore(call): consume self-built @lotusguild/element-call-embedded`.
3. **Push to `lotus`** → cinny CI builds, then `trigger-desktop` bumps
cinny-desktop → Tauri release. Re-run §D on **cinny-desktop** (the path where
the old `stripBase` bug bit — verify the widget loads, not a 404).
4. Only then start **Phase 2** (A5/A6/A7, theming, denoise-in-source).
---
## 11. Phase 2 — implementation seams (mapped 2026-06-29)
The exact integration points for each Phase 2 item, found by reading the EC fork
- cinny source. **All of these are media-path / in-call features that cannot be
functionally verified without a live Matrix + LiveKit call** — implement each as
a minimal, **feature-flagged, additive** diff (no behavior change unless cinny
opts in), build-verify the fork (`pnpm build:embedded`, ~15s) AND cinny
(`npm run build`), then gate shipping on `LOTUS_TESTING.md` §D.
**Shared widget channel (the backbone for #2/#3/#4/#7):**
- EC→cinny: `widget.api.transport.send("io.lotus.<x>", data)` (see
`element-call/src/widget.ts`).
- cinny→EC actions: add the action name to the `lazyActions` allow-list in
`widget.ts` (the array at ~L101) and handle it in EC; cinny sends via
`this.call.transport.send(...)`.
- cinny receives EC→cinny actions via the existing `listenAction(type, cb)`
helper in `plugins/call/CallEmbed.ts:626` (auto-replies `{}` so the transport
doesn't time out — same pattern as `io.element.device_mute`).
**#2 mute/speaker events** — Source: subscribe to `vm.userMedia$`
(`CallViewModel`), per member `speaking$` + `audioEnabled$`
(`state/media/UserMediaViewModel.ts:47-48`); aggregate and
`transport.send("io.lotus.call_state", {participants:[{id,speaking,audioEnabled}]})`.
Mount in `room/InCallView.tsx` via `useEffect` guarded by `widget !== null`.
cinny: `listenAction("io.lotus.call_state")` in `CallEmbed.ts`, feed
`hooks/useCallSpeakers.ts` → delete its `contentDocument` `[data-muted]` /
`[data-video-fit]` scrape. _Additive, low risk._
**#4 spotlight/focus** — EC: add `io.lotus.focus_participant` to the `lazyActions`
list (`widget.ts`), drive `vm`'s spotlight (`spotlightSpeaker$` /
`spotlight$` in `CallViewModel.ts:898/1001`) to pin a given identity, coexisting
with `hasRemoteScreenShares$` (L1008). cinny: replace
`CallControl.ts` `focusCameraParticipant` `.click()` walk with
`transport.send("io.lotus.focus_participant", {userId})`. _Additive, low risk._
**#3 audio-inject** — EC: add `io.lotus.inject_audio` action; mix an
`AudioBufferSourceNode` into the published mic track. The local publish path is
`state/CallViewModel/localMember/Publisher.ts` + `LocalMember.ts` (LiveKit
`localParticipant`); create a `MediaStreamAudioDestinationNode`, mix mic + clip,
`replaceTrack`. cinny soundboard calls the action instead of local-only playback.
_Medium; touches publish path → live-test carefully._
**#1 denoise-in-source** — replace the cinny `lotusDenoise()` `getUserMedia`
monkeypatch with a real processing stage in EC's mic capture
(`Publisher.ts`/`LocalMember.ts`; note EC has a `TrackProcessorContext` +
`BlurBackgroundTransformer` precedent in `livekit/`). EC re-runs it on every
(re)publish → fixes A7. Remove `vite.config.js` `lotusDenoise()` + URL params in
`CallEmbed.ts`; move `denoise/` assets into the fork. _Highest value, highest
risk — most live testing._
**#5 theming** — add a Lotus/TDS theme in EC's theme system (`src/useTheme.ts` +
EC theme tokens / CSS); driven by the existing `setTheme()` channel cinny already
calls (`CallEmbed.ts:277`). Bake transparent background. Delete cinny's
`applyStyles()` injection + `background:none !important`. _Medium._
**#6 in-call decorations** — render the decoration APNG in EC's tile component
(`tile/GridTile.tsx`); pass slugs via widget member data. cinny already has the
decoration data + `AvatarDecoration` (lobby `CallMemberCard.tsx`). _Medium-Large._
**#7 quality controls** — set audio `maxBitrate` via
`RTCRtpSender.setParameters` and screenshare `getDisplayMedia` constraints in
EC's publish path (`Publisher.ts`); configurable via `config.json` / a widget
message. Keep the server `voice-limit-guard` as enforcement. _Medium._
**Rollback:** revert the 4 cinny files (restores `@element-hq/...@0.20.1` from
npmjs). The fork repo/package can stay; nothing else depends on it until pushed.
### Local repro/build environment (this session, 2026-06-29)
- Upstream cloned + our `lotus` branch at `/root/code/element-call` (remote
`lotus` → Gitea; origin → github upstream, now un-shallowed/full history).
- Isolated **Node 24.18.0** lives in the session scratchpad (system Node is 20);
cinny's `.node-version` is `24.13.1`, so use Node 24 to build cinny too.
- Build the embedded bundle: in `/root/code/element-call`, with Node 24 + pnpm
10.33.0 on PATH, `VITE_APP_VERSION=embedded-v0.20.1 pnpm run build:embedded`
→ output in `dist/`; stage to `embedded/web/dist` before publishing.
---
## 12. Phase 2 — IMPLEMENTED on the fork (2026-06-30)
All 7 EC features are on the `lotus` branch of `LotusGuild/element-call`, each
**additive + feature-flagged** (a vanilla call with no `lotus*` params / no Lotus
actions behaves exactly like upstream), build + `tsc` clean, per-feature reviewed
(fixes applied) and holistically reviewed. **Not yet live-tested** — all need the
`LOTUS_TESTING.md` §D sweep.
Fork modules live under `element-call/src/lotus/*`; mounts are `useEffect`s in
`src/room/InCallView.tsx`. Custom widget actions are in `src/lotus/lotusActions.ts`
(toWidget ones allow-listed in `src/widget.ts`).
| # | Feature | Enable via | EC module |
| :--- | :------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------ | ---------------------------------------------------- |
| 2 | Speaker/mute/camera state → host | URL `lotusCallState=1` | `lotusCallState.ts` (sends `io.lotus.call_state`) |
| 4 | Focus/spotlight a participant (works during screenshare) | action `io.lotus.focus_participant {userId | null}` | `lotusFocus.ts` + `CallViewModel` spotlight override |
| 3 | Soundboard audio-inject (heard by peers) | URL `lotusAudioInject=1` + action `io.lotus.inject_audio {url,volume?}` | `lotusAudioInject.ts` |
| 7 | Audio/screenshare quality caps | action `io.lotus.set_quality {audioMaxBitrate?,screenshareMaxBitrate?,screenshareMaxFramerate?}` | `lotusQuality.ts` |
| 5 | Transparent bg + Lotus theme | URL `lotusTransparent=1` / `lotusTheme=1` | `useTheme.ts` + `index.css` |
| 6 | In-call avatar decorations | action `io.lotus.decorations {decorations:{userId:url}}` | `lotusDecorations.ts` + `MediaView.tsx` |
| 1 | ML denoise in-source (fixes A7) | URL **`lotusDenoiseSource=1`** (+`lotusModel`,`lotusGate`,`lotusGateThreshold`,`lotusDenoiseBase`) — deliberately NOT the existing `lotusDenoise=ml` (that drives the host shim; reusing it would double-process) | `lotusDenoise.ts` + `lotusDenoiseProcessor.ts` |
| P6-2 | Deafen / screenshare-audio-mute at the LiveKit source | action `io.lotus.set_deafen {deafened,screenshareAudioMuted}` — sets remote `RemoteParticipant.setVolume(0/1)` per source (Microphone + ScreenShareAudio), persists to late joiners via `RoomEvent.ParticipantConnected` | `lotusDeafen.ts` |
### 12.4 P6-2 — deafen action (retires cinny's iframe-DOM `.muted` hack)
`io.lotus.set_deafen` (fork commit, folded into unpublished **`0.20.1-lotus.2`**) replaces
cinny's `CallControl.setSound`/`applyScreenshareAudioMuted` DOM `<audio>.muted` poking —
which broke silently on EC re-render / late tracks. **Two-phase rollout:**
1. **DONE (this batch):** fork action implemented; cinny's `CallControl` now ALSO sends
`io.lotus.set_deafen` (gated on join via `forceState`) alongside the retained DOM hack.
Against the current pinned bundle (`lotus.1`, no handler) the action is immediately
error-replied and swallowed by `.catch` — inert, no timeout.
2. **TODO — needs YOU to publish, then me:** publish the fork (`0.20.1-lotus.2`) to npm →
I bump cinny's pin `0.20.1-lotus.1` → `lotus.2`, `npm install`, then DELETE the DOM
`.muted` code from `CallControl.ts` (the hack is fully retired only here).
**Known divergence to confirm:** deafen silences remote Microphone + ScreenShareAudio, but
NOT injected/soundboard audio (`Track.Source.Unknown` — livekit-client's `setVolume` type
only accepts Microphone|ScreenShareAudio). So a deafened user still hears host-triggered
soundboard clips. Defensible (short, host-gated); confirm it's the desired UX.
**Security hardening applied** (holistic audit): `lotusDenoiseBase` forced
same-origin before `audioWorklet.addModule` (was an arbitrary-code-load vector
via a crafted link); audio-inject gated behind `lotusAudioInject=1`; decoration
roster capped. Only `https`/`blob` URLs accepted for inject/decoration assets.
### 12.1 cinny host integration checklist (REQUIRED to light these up)
> ✅ **STATUS (2026-06): COMPLETE.** All items below are shipped. call_state,
> focus_participant, decorations, and transparent background are active; the
> in-source denoise cutover is done (flag `lotusDenoiseSource=1`, **all four**
> models in-source); and the two formerly-dormant capabilities now have cinny
> UI — **soundboard** (`io.lotus.inject_audio`, P5-15) and **quality controls +
> room permissions** (`io.lotus.set_quality` + `io.lotus.room_quality`, P5-31,
> with server-side enforcement in `LotusGuild/matrix`). See `LOTUS_FEATURES.md`
> → "Element Call — Self-Built Fork". The checklist is kept below as the record
> of what was wired. (One open denoise item tracked separately: the "Series
> Suppression" native-NS toggle is not wired to the real call path.)
The EC side is additive and dormant until cinny opts in. Host work (in
`src/app/plugins/call/CallEmbed.ts` unless noted) — **done**:
> ⚠️ **CRITICAL TIMING (protocol audit F1):** only send `io.lotus.*` **toWidget**
> actions (#3 focus, #6 decorations, #7 quality, audio-inject) **after** the call
> is joined (`CallEmbed.onCallJoined` / `this.joined`). Those actions are
> allow-listed at EC app-init (so `preventDefault` suppresses the auto-error)
> but their handlers only mount with `InCallView` (post-join). Sending earlier
> leaves the host's `transport.send` pending until the **10s timeout**. Queue and
> flush on join, or no-op before join.
>
> Also: **F3 (RESOLVED)** — all four models (`rnnoise`/`speex`/`dtln`/
> `deepfilternet`) are now implemented in-source in `lotusDenoiseProcessor.ts`;
> the picker offers all four. **F4** — cinny no longer forwards a native-NS flag
> in the `ml` branch (the "Series Suppression" toggle is currently a no-op in
> real calls — open item). **F7** — no widget _capability_ changes needed;
> custom actions bypass capability checks.
1. **Set the URL flags** on the widget iframe params (the `URLSearchParams` in
`CallEmbed`): `lotusCallState=1`, `lotusTransparent=1`/`lotusTheme=1`,
`lotusAudioInject=1` as desired. (Denoise sets `lotusDenoiseSource=1` + `lotusModel`/`lotusGate`/`lotusGateThreshold` in the `ml` tier.)
2. **Ack `io.lotus.call_state`**: add `listenAction('io.lotus.call_state', …)` —
without a reply the fork's sends time out every 250ms. Feed the payload into
`useCallSpeakers` and RETIRE its `contentDocument` DOM scrape.
3. **Send actions** via `this.call.transport.send(...)`:
`io.lotus.focus_participant` (replace `CallControl.focusCameraParticipant`s
`.click()`), `io.lotus.inject_audio` (from the soundboard), `io.lotus.set_quality`
(from quality settings), `io.lotus.decorations` (push the MSC4133 decoration
map; resolve mxc→https first).
4. **#1 denoise cutover**: once verified, STOP injecting the `lotusDenoise()`
shim in `cinny/vite.config.js` and remove the `index.html` injection — the
fork now does denoise in-source. Keep shipping the `denoise/` assets (the
fork loads `./denoise/…` at runtime) until those move into the fork build.
5. Re-run `LOTUS_TESTING.md` §D for each feature; only then ship.
### 12.2 Holistic multi-agent review — outstanding follow-ups (non-blocking)
Four aspect-agents reviewed the whole fork. Criticals were fixed in-branch (the
denoise restart-silence/A7 bug; the `lotusDenoiseBase` code-load vector;
audio-inject opt-in gate; #6 rendering in the wrong component; #7 simulcast cap).
Remaining, deliberately deferred:
- **Denoise H2 (double-processing):** if cinny is set to `lotusDenoise=ml` while
ALSO still injecting its build-time `getUserMedia` shim, audio is denoised
twice. The #1 cutover MUST remove the cinny-side injection (it currently has
none injected into the iframe — keep it that way). Hard requirement, not code.
- **Denoise M1 (perf):** in-source uses non-SIMD `rnnoise.wasm`; the reference
preferred SIMD with detection. Perf-only; add SIMD detection later.
- **dtln/deepfilternet (F3): RESOLVED** — all four models
(rnnoise/speex/dtln/deepfilternet) are now implemented in
`lotusDenoiseProcessor.ts` (faithful port of cinny's `build/lotus-denoise.js`
pipeline). This also fixed a real bug (the gate worklet name was `noiseGate`;
correct is the hyphenated `noise-gate`) and added per-model sample rates
(DTLN 16 kHz, others 48 kHz), context `resume()`, and SIMD wasm selection.
Still needs live §D testing per model, and depends on cinny shipping the
DTLN (`denoise/workadventure/`) + DeepFilterNet (`denoise/deepfilternet/`)
asset trees (it already does).
- **Rebase-fragility (build agent MED):** the `CallViewModel` spotlight override
edits hot upstream lines (renamed `spotlightSpeaker$`→`autoSpotlightSpeaker$`).
For cheaper future rebases, refactor it into a `src/lotus/lotusSpotlight.ts`
wrapper that takes the upstream stream and returns the overridden one, leaving
upstream's definition byte-identical (a single import + two token swaps).
- **Denoise asset coupling (build agent HIGH):** the fork loads `./denoise/*`
shipped by cinny, not by the fork build (documented in the processor). Add an
integration smoke-check that `GET …/element-call/denoise/rnnoise.wasm` == 200,
and pin the `@sapphi-red/web-noise-suppressor` version both repos expect.
- **Unconditional effect registration (build agent LOW):** focus/audio-inject/
quality/decorations register widget handlers on every embedded call (true
no-ops for a non-Lotus host). Intentional; gate behind a coarse `lotus=1` flag
if strict zero-footprint is desired.
- **Privacy (security agent):** decoration/inject URLs accept any `https`; ideally
restrict to the homeserver media origin host-side. Call-state exposes
userId/deviceId/speaking to the (trusted, same-origin) host — documented.
**Nothing here blocks the §D live test — but every feature still needs it.**
### 12.3 Safe rollout when prod is the only test environment
Every Phase-2 feature is now **dormant by default** — with the flags cinny sets
today, the fork behaves identically to the parity build (`#1` was decoupled onto
`lotusDenoiseSource=1` so it no longer collides with the host's `lotusDenoise=ml`
shim). This enables a low-risk incremental rollout even without a staging env:
1. **Ship dormant first.** Publish the `lotus` branch (e.g. `0.20.1-lotus.1`),
bump cinny's pin, deploy. With no Lotus flags set / no Lotus actions sent,
this is upstream-equivalent (only inert, holistically-reviewed code runs).
"Testing" here = confirm a normal call still works.
2. **Enable ONE feature at a time**, each independently revertable:
- URL-flag features (#2 `lotusCallState`, #5 `lotusTransparent`/`lotusTheme`,
#1 `lotusDenoiseSource`): add the flag in `CallEmbed.getWidget`, deploy,
test that one feature, roll back just that flag if needed.
- Action features (#3,#4,#6,#7): wire the host send + (for #2) the
`listenAction` ack, gated on join (§12.1 F1).
3. **#1 denoise cutover is a coordinated 2-step** (do together): set
`lotusDenoiseSource=1` AND remove the `lotusDenoise()` shim injection +
`lotusDenoise=ml` param in cinny — otherwise audio is denoised twice.
Roll back = revert both.
4. Baseline is always upstream-equivalent, so any single feature can be disabled
by flipping its flag/send off without touching the rest.
**Blocker to step 1:** publishing the `lotus` branch needs a Gitea npm token
(the admin token used for the `0.20.1` parity publish was deleted). Either
provide a token for a manual `npm publish`, or stand up the Gitea Actions runner
- `GITEA_NPM_TOKEN` secret so a `v0.20.1-lotus.1` tag auto-publishes.
-188
View File
@@ -1,188 +0,0 @@
# Lotus Chat — Open Bugs & Technical Debt
**Only OPEN and awaiting-verification items live here.** Resolved findings
(fixed-and-verified, false-positives, won't-fix) have been removed to keep this
actionable — the full history is in git. Items fixed in code but not yet
verified in a real environment are in **Needs Verification** below and have
step-by-step checks in [`LOTUS_TESTING.md`](./LOTUS_TESTING.md).
> Design rules for any fix here: follow the **Native-Cinny Law** and **TDS
> Design Law** in [`LOTUS_TODO.md`](./LOTUS_TODO.md).
---
## ⚠️ Needs Verification — fixed in code, awaiting live testing
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
| ID | Item | File / area | Test |
| :--- | :-------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
| #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 |
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
| #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 |
| #12 | PiP "All muted" badge re-fixed (was firing on any single mute) | `hooks/useCallSpeakers.ts` | G1 |
| N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 |
| N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 |
| N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 |
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
| N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room |
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
| P3-8 | Thread Panel (side drawer, chips, threaded receipts, thread composer) | `features/room/thread/*`, `RoomTimeline/RoomInput` | 6-step checklist in LOTUS_TODO §P3-8 |
| P4-4 | KaTeX math (`$…$`, `$$…$$`, data-mx-maths; lazy chunk) | `utils/mathParse.ts`, `components/math/` | send `$x^2$`, `$$\int f$$`, `$5 and $10` (stays text), math inside code block (stays text) |
| P4-8 | Encrypted-search cache (opt-in toggle, clear button, logout wipe) | `utils/searchCache.ts`, message-search | enable in search panel → search → reload → coverage persists; logout wipes |
| N97a | Session blob migration + cross-tab logout sync | `state/sessions.ts`, `useSessionSync` | login on old build → new build migrates; logout in tab A → tab B drops to auth |
| P4-1 | Slack-style thread notifications (participating default, All/Mentions/Mute, badge math) | `utils/threadNotifications.ts`, `ClientNonUIFeatures`, `roomToUnread` | 6-step checklist in LOTUS_TODO §P4-1 |
| AW-1 | Scheduled-message cancel no longer ghost-sends (error row on failure) | `ScheduledMessagesTray.tsx` | schedule → cancel with network cut → item stays + error; retry works |
| AW-2 | Emoji lazy-load (search/autocomplete/recents fill in; board opens fast) | `plugins/emoji.ts` + consumers | first emoji-board open of a session: grid+search populate; reactions still label |
| AW-3 | SW precache (repeat-visit near-instant; deploys still picked up immediately) | `sw.ts`, `vite.config.js` | load app twice (2nd = cached assets); deploy → reload picks new version |
| AW-4 | Desktop CSP tighten + Escape/panel fixes + thread Jump to Latest | `tauri.conf.json`, Room/ThreadPanel | desktop: boots, avatars/media load, VT323 font renders, location maps embed, calls connect, deep links work |
| P3-4 | Accessibility compliance pass (collapsed-msg SR sender, form/overlay labels, typing announce, focus-return, `?` help, jsx-a11y CI gate) | `message/*`, `RoomViewTyping`, `features/shortcuts/*`, `eslint.config.mjs` | LOTUS_TESTING §P — axe-core + VoiceOver/NVDA on the golden path |
| P6-1 | Desktop Linux parity (no-sleep in calls, launcher badge), autostart toggle, tray Do-Not-Disturb | `native/power.rs`, `lib.rs`, `useTauriDnd`, `General.tsx` | Linux desktop: no display sleep during a call; tray DND silences notifications; launch-on-login persists; Unity badge (Ubuntu); DND toggle polarity |
| P6-2 | EC deafen/screenshare-audio-mute via `io.lotus.set_deafen` (retires the `<audio>.muted` iframe hack) | fork `lotusDeafen.ts`, cinny `CallControl.ts` | AFTER publish+pin-bump: deafen silences remote audio + survives a reconnect / new screenshare / late joiner (the cases the DOM hack failed); screenshare-audio-mute toggles independently |
| P6-3 | Forward-to-multiple-rooms (multi-select + partial-failure summary) + live bookmark previews (edits/redactions, snapshot fallback) | `ForwardMessageDialog.tsx`+`forwardContent.ts`, `BookmarksPanel.tsx` | forward one msg to 3 rooms (incl. 1 you cannot post to = partial summary); bookmark then edit shows edited; redact shows deleted; leave room shows snapshot |
| P6-4 | HSTS + Permissions-Policy on prod nginx (+ contrib examples) | `matrix/cinny/nginx.conf`, `contrib/nginx`, `contrib/caddy` | after `nginx -s reload`: `curl -sI https://chat.lotusguild.org` shows HSTS + Permissions-Policy; a call (cam/mic/screenshare) + location share still work |
**Verified working in live testing (2026-06):** A2, B1B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.
---
## 🧩 Element Call source-level items — now actionable via the fork
> 🔱 **[EC-FORK]** **UPDATE 2026-06-30: Phase 2 IMPLEMENTED.** We own and
> self-build Element Call (`LotusGuild/element-call` →
> `@lotusguild/element-call-embedded@0.20.1-lotus.1`, cinny wired). A5/A6/A7
> below are **fixed in the fork** — they are now ⚠️ awaiting **live
> verification** (`LOTUS_TESTING.md` §D2), not open work. See
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md) §10. Delete each
> row once verified live.
The in-call participant grid is rendered **inside EC's app** — now editable source
(previously a prebuilt npm bundle we could only style around). Status of the items
from testing:
- **A5 — "Focus camera": ⚠️ FIXED in fork, awaiting verify (D2-3).** cinny now
sends an `io.lotus.focus_participant` widget action that pins a participant in
EC's layout (coexisting with / overriding the screenshare spotlight); the old
`.click()`-the-tile DOM hack in `CallControl.ts` is deleted.
- **A6 — avatar decorations in-call: ⚠️ FIXED in fork, awaiting verify (D2-4).**
cinny pushes `io.lotus.decorations` (per-user APNG URLs) and the fork renders
them on EC's participant video-tile avatars — not just our pre-join lobby roster.
- **A7 — mic dead after EC's "Reconnect": ⚠️ FIXED in fork, awaiting verify
(D2-1).** Denoise moved into EC's mic-capture/publish pipeline as a first-class
LiveKit `TrackProcessor` (flag `lotusDenoiseSource=1`); EC re-runs it on every
(re)publish, so reconnects keep denoise alive natively. The build-time
`getUserMedia`/`index.html` injection (the root cause) is removed. **Highest
blast radius — everyone's mic; verify D2-1 carefully.**
---
## 🔴 Open — Actionable
### 🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED · 👤 SENIOR ENGINEER
> 🧰 **Investigation kit ready (2026-07):** [`LOTUS_E2EE_INVESTIGATION.md`](./LOTUS_E2EE_INVESTIGATION.md)
> has the per-KE capture runbook (console signatures, synapse-side queries, the
> KE-1→KE-2 causality decision tree, ranked remediations), and the client now
> ships a **Crypto Diagnostics** capture helper (Settings) — run it during the
> next affected call and download the report before starting any fix.
> **Observed live in prod 2026-06-30** on `chat.lotusguild.org` during a 2-person
> **Element Call** (E2EE enabled). These span **client rust-crypto (via
> `matrix-js-sdk@41.6.0-rc.0`) ↔ Synapse ↔ Element Call's MatrixRTC E2EE** and are
> very likely **interrelated** (see KE-1 → KE-2). Do **not** spot-fix — they need
> a dedicated cross-system planning session with the homeserver owner. Capture
> full client console + a synapse-side trace for the same call before starting.
> **None of these are caused by the EC fork work** (the issues reproduce on the
> old build; the local mic/denoise path is unrelated to key distribution).
- **KE-1 — One-time-key (OTK) upload conflict storm (CRITICAL, root-cause candidate).**
`POST /_matrix/client/v3/keys/upload` returns `400 M_UNKNOWN: One time key
signed_curve25519:AAAAAAAAAGQ already exists. Old key: {…} new key: {…}`
firing **continuously** (many/sec). The client repeatedly tries to publish an
OTK at a key id the server already holds **with a different value**, i.e. the
rust-crypto key store and Synapse have **diverged OTK state**. Impact: floods
the crypto outgoing-request loop and is the prime suspect for the downstream
missing-key failures (no fresh OTKs ⇒ no new Olm sessions ⇒ undecryptable
to-device key events). _Investigate:_ device/key-store reset-or-restore
mismatch, OTK id-counter desync, RC-SDK (`41.6.0-rc.0`) regression, or a
Synapse OTK bug. Repro signature: grep console for `already exists`.
**Extreme — planning session.**
**Update 2026-07 (investigation §6):** upstream `matrix-rust-sdk#5200` (still
OPEN) confirms the mechanism — on the 400, `mark_request_as_sent()` never fires
so the SDK re-issues the identical upload forever. **`41.7.0` does NOT fix it**
(crypto-wasm 17→18.3.1 has no OTK/upload change; 18.3.x was to-device security
only) — the SDK-pin lever is closed. Root cause = **store↔server OTK
divergence**; the leading **web-specific** trigger is that cinny never calls
**`navigator.storage.persist()`**, so the IndexedDB crypto store is evictable
while the `localStorage` session/device-id survives → device resurrects with a
blank store → re-uploads OTKs the server still holds. **Actionable preventive
fix (buildable now, no call needed):** request persistent storage on login
(+ optional multi-tab guard + 400-loop→recovery-prompt). Healing an already-
diverged device still needs a clean **logout+login** (not just "clear
storage"). See `LOTUS_E2EE_INVESTIGATION.md` §6.
- **KE-2 — Element Call media keys not arriving/decrypting → audio & video cut out (CRITICAL).**
`MissingKey: missing key at index N for participant @user`, `skipping decryption
due to missing key`, `MissingKey: key set not found for @user at index 0`, and
rust-crypto `WARN … Received an unexpected encrypted to-device event …
event_type="io.element.call.encryption_keys"`. EC distributes per-participant
media keys as **encrypted to-device `io.element.call.encryption_keys`** events;
these aren't being received/decrypted in order, so remote LiveKit audio/video
can't be decrypted — **this is the "friend's audio cuts out occasionally"
symptom.** Almost certainly downstream of **KE-1** (broken Olm sessions). Spans
EC's MatrixRTC E2EE + rust-crypto to-device + Synapse. **Extreme — planning
session.**
- **KE-3 — Timeline decryption error: missing `algorithm` field (HIGH).**
`Error decrypting event (… type=m.room.encrypted …): DecryptionError[msg:
missing field 'algorithm' at line 1 column 138 …]`. A malformed/legacy
encrypted event (or a serialization mismatch in the RC SDK) that rust-crypto
can't parse. Lower frequency than KE-1/2 but a distinct decode-path failure —
capture the offending event id (`$SASBBzoqj…` seen) and inspect its raw content.
- **KE-4 — MatrixRTC delayed-event / membership timeouts (MEDIUM-HIGH, reliability).**
`[MembershipManager] Network local timeout error while sending event, immediate
retry … AbortError: Restart delayed event timed out before the HS responded`,
with repeated `org.matrix.msc4157.update_delayed_event`. MSC4140/4157
delayed-event reliability against `matrix.lotusguild.org` — can cause stale/ghost
call membership and missed leave events. May be partly **homeserver
responsiveness**; correlate with synapse latency/load. Include in the same
planning session since it shares the call-reliability + HS-interaction surface.
### Security & Privacy
- **N97 — Access token stored in plaintext `localStorage`** (`state/sessions.ts`), vulnerable to XSS; device ID likewise. Architectural — needs a token-protection / session-storage redesign.
- ~~**Session writes are non-atomic and not cross-tab synced**~~ — **done (2026-07):** atomic single-key `cinny_session_v1` blob (legacy-key migration + dual-write) + `subscribeSessionChanges`/`useSessionSync` cross-tab reload. (The plaintext-token concern in N97 above is the remaining, separate architectural item.)
- **Persisted PII without encryption:** user status message + expiry (`settings/account/Profile.tsx`), unsent composer drafts (`room/RoomInput.tsx`). Leak risk on shared devices.
### PWA / Offline / Notifications
- **N107 — SW has no `push` handler** — Web Push delivery is entirely non-functional. Needs a `push` listener + a Matrix push-gateway integration.
- **No app-asset caching strategy** (`src/sw.ts`) — no offline capability.
- ~~**`manifest: false`** may block PWA install~~ — **verified OK (2026-06):** `index.html` links `/manifest.json`, which exists in `public/` and is copied to `dist/`; VitePWA intentionally doesn't generate one. Not a bug.
### Dependencies & Build
- ~~**`matrix-js-sdk` pinned to a Release Candidate**~~ — **done (2026-07):** moved to `41.7.0` stable (crypto-wasm 18.3.1 security bump). Deep-audit dep triage: all 16 npm advisories are dev-only/unreachable/dead-dep — zero shipped exposure; dead `dompurify` removed. `@atlaskit`/build-tool pins remain review-worthy but low priority.
- **Build-time overhead:** `lotusDenoise` does heavy sequential `fs` work in `closeBundle`; `viteStaticCopy` config is complex with redundant renames — could be streamlined.
### Code Hygiene / DevEx
- **Automated test suite — 561+ tests across 65+ modules, a hard CI gate.** `npm test` runs Node's built-in runner via `tsx` (not vitest — Vite 8 is ahead of vitest's range) and **blocks the build job on failure**. Broad pure-logic coverage: utils (common, regex, sanitize/XSS, time, matrix, matrix-uia, mimeTypes, sort, accentColor, findAndReplace, AsyncSearch, ASCIILexicalTable, keyboard, room, matrix-crypto, featureCheck, syntaxHighlight, imageCompression, user-agent, callSounds), state (settings, sessions, recentSearches, upload, typingMembers, lists, room-list, toast, scheduledMessages, backupRestore, callEmbed/callPreferences, spaceRooms, …), plugins (matrix-to, call/utils, via-servers, bad-words, recent-emoji, custom-emoji, markdown block/inline/utils), OIDC (cs-api, useParsedLoginFlows, oidcState), lotus/avatarDecorations, message-search, search filters. Prevention work has caught + fixed **4 real bugs** (`findAndReplace` infinite-loop; `getSettings` crash-on-load when storage is blocked; `isMacOS` never matching modern Macs; `isMLDenoiseSupported` throwing `ReferenceError` instead of returning false on browsers lacking the `AudioWorkletNode` binding). **Next:** component/integration tests (the untestable-under-tsx DOM/React surface).
- **Extensive `as any` casts** across `src/` — gradual typing cleanup.
- **`types/matrix/` mirrors SDK types** instead of importing them — drift risk.
- ~~**Hardcoded CDN URL** should move to an env var~~ — **done:** `avatarDecorations.ts` already honors a `VITE_DECORATION_CDN` env override (lines 14-16); the in-repo literal is only the default. Nothing left.
- **`patch-folds.mjs` edits `node_modules` directly** — consider `patch-package`.
- **Infra docs:** `contrib/nginx` lacks security headers (HSTS/CSP) + uses rewrites over `try_files`; `contrib/caddy` has a placeholder path. CI/CD (`prod-deploy.yml`): sequential deploy, aggressive 1-min Netlify timeout, `package-manager-cache: false`.
- **README:** keep the fork-sync version + logo path current. (`CONTRIBUTING.md` is intentionally left as upstream Cinny's — not a Lotus concern.)
- **Architecture notes (low priority):** deep `features/` + `hooks/` nesting, many small coupled hooks, possible dead CSS/components, `SpacingVariant` / `DropTarget` recipe simplification.
- **Git workflow (forward-looking):** keep commits scoped — past monolithic "fix all bugs" commits and inconsistent prefixes hurt `git bisect`.
### Big Projects
- ~~**#5 — Seasonal themes & chat-background redesign.**~~ **DONE (2026-06/07):** 11 seasonal/holiday overlays shipped and later toned down + given a settings preview grid; all 19 chat backgrounds redesigned (Carbon + Aurora kept per user preference), one design sprint each, GPU-friendly CSS with `prefers-reduced-motion` + pause toggle. Remaining polish rides normal bug flow, not a "big project."
-504
View File
@@ -1,504 +0,0 @@
# Lotus Chat — E2EE Investigation Runbook (KE-1 → KE-4)
> **Scope:** evidence-gathering only. Do **not** apply fixes from this document
> without a cross-system planning session (client rust-crypto ↔ Synapse ↔
> Element Call MatrixRTC). Symptom source: `LOTUS_BUGS.md` §"Encryption / E2EE"
> (KE-1..KE-4), observed live 2026-06-30 on `chat.lotusguild.org` during a
> 2-person Element Call.
>
> **Client:** Lotus Cinny fork, `matrix-js-sdk@41.6.0-rc.0`, rust-crypto.
> **Server:** Synapse `1.155.0` on **LXC 151** (`10.10.10.29`), PostgreSQL 17.9
> on **LXC 109** (`10.10.10.44`). Facts below are copy-pasteable against that
> deployment (paths/IPs from `/root/code/matrix/README.md`).
---
## 0. Deployment facts used by this runbook
From the matrix infra README (`/root/code/matrix/README.md`):
| Thing | Value |
| ------------------------ | ------------------------------------------------------------- |
| Synapse host | LXC **151**, `10.10.10.29` (Synapse 1.155.0) |
| Synapse log | `/var/log/matrix-synapse/homeserver.log` |
| Synapse config | `/etc/matrix-synapse/homeserver.yaml` (+ `conf.d/`) |
| Synapse HTTP | `10.10.10.29:8008` |
| PostgreSQL host | LXC **109**, `10.10.10.44` (PG 17.9), db `synapse` |
| synapse-admin UI | `http://10.10.10.29:8080` |
| LiveKit / lk-jwt / guard | LXC 151: LiveKit `:7880/:7881`, guard `:8070`, lk-jwt `:8071` |
| SSH path to Synapse | `ssh root@10.10.10.4` then `pct enter 151` |
| SSH path to PG | `ssh root@10.10.10.4` then `pct enter 109` |
**Getting a psql shell** (run on LXC 109, or from 151 over the network):
```bash
# On LXC 109:
sudo -u postgres psql synapse
# From LXC 151 (pg_hba allows 10.10.10.29):
psql "host=10.10.10.44 user=synapse dbname=synapse"
```
**Tailing Synapse during a call** (on LXC 151):
```bash
tail -F /var/log/matrix-synapse/homeserver.log | tee /tmp/lotus-call-$(date +%s).log
```
Synapse E2EE/to-device logging is chatty at `INFO`; if a category is silent,
temporarily raise it in `/etc/matrix-synapse/conf.d/log.yaml` (or the
`log_config` file referenced by `homeserver.yaml`):
```yaml
loggers:
synapse.rest.client.keys: { level: DEBUG }
synapse.handlers.e2e_keys: { level: DEBUG }
synapse.storage.databases.main.end_to_end_keys: { level: DEBUG }
synapse.handlers.devicemessage: { level: DEBUG } # to-device
```
Then `systemctl reload matrix-synapse` (reload re-reads log config without a
full restart). **Revert to `INFO` after the capture** — DEBUG is very verbose.
---
## 1. Per-KE evidence matrix
Client greps assume Chrome/Firefox DevTools console (filter box or, better,
"Preserve log" + save-as). The **Crypto Diagnostics** card (Settings →
Developer Tools) auto-captures every signature below into a downloadable JSON —
use it as the primary client artifact and DevTools as the raw backup.
### KE-1 — OTK upload conflict storm (root-cause candidate)
- **Console signature (grep):**
- `already exists`
- full: `POST /_matrix/client/v3/keys/upload … 400 M_UNKNOWN: One time key signed_curve25519:<id> already exists. Old key: {…} new key: {…}`
- **Capture client-side:**
- Timestamp (first occurrence + rate — "N/sec"), **device id**, **user id**.
- DevTools → **Network** → filter `keys/upload`: for a failing call save the
**request body** (the `one_time_keys` map — note the exact `signed_curve25519:<id>`)
and the **response body** (the `Old key` / `new key` JSON). This diff is the
smoking gun: same key-id, different value ⇒ store vs server divergence.
- Whether it self-heals or loops forever (KE-1 loops).
- **Synapse log grep (LXC 151):**
```bash
grep -E "keys/upload|One time key .* already exists|OneTimeKey" \
/var/log/matrix-synapse/homeserver.log | grep "<user_id>"
```
- **Synapse SQL (LXC 109) — what the server thinks it holds:**
```sql
-- Current OTK inventory for the device (compare key_id set against the
-- request body the client keeps retrying).
SELECT algorithm, key_id, ts_added_ms
FROM e2e_one_time_keys_json
WHERE user_id = '@user:matrix.lotusguild.org'
AND device_id = '<DEVICE_ID>'
ORDER BY algorithm, key_id;
-- Server's advertised counts (this is what /sync tells the client it has,
-- and drives whether the client decides to upload more).
SELECT algorithm, count(*) FROM e2e_one_time_keys_json
WHERE user_id = '@user:matrix.lotusguild.org' AND device_id = '<DEVICE_ID>'
GROUP BY algorithm;
-- Fallback key state (used when OTKs are exhausted).
SELECT algorithm, key_id, used, ts_added_ms
FROM e2e_fallback_keys_json
WHERE user_id = '@user:matrix.lotusguild.org' AND device_id = '<DEVICE_ID>';
```
> Table names are Synapse 1.155 (`e2e_one_time_keys_json`,
> `e2e_fallback_keys_json`). If a name is absent, list with `\dt e2e*` in psql.
- **Confirms:** if the offending `key_id` (from the 400) is **present** in
`e2e_one_time_keys_json` with a **different** stored value than the client's
request body → OTK state has diverged (rust-crypto store vs Synapse). That is
the KE-1 root condition.
### KE-2 — EC media keys not arriving/decrypting (audio/video cutouts)
- **Console signature (grep):**
- `MissingKey`
- `missing key at index` (e.g. `MissingKey: missing key at index N for participant @user`)
- `key set not found`
- `io.element.call.encryption_keys` (rust-crypto: `WARN … Received an unexpected encrypted to-device event … event_type="io.element.call.encryption_keys"`)
- **Capture client-side:**
- Timestamp windows where a participant's audio/video cut out, and the
`@participant` + `index N` from the message.
- The `io.element.call.encryption_keys` warnings (these are the media-key
to-device events failing to decrypt) with their timestamps.
- Own device id + user id (to correlate with the sender's Olm session).
- **Synapse log grep (LXC 151) — to-device delivery of the media keys:**
```bash
grep -E "io.element.call.encryption_keys|m.room.encrypted|/sendToDevice|to_device" \
/var/log/matrix-synapse/homeserver.log | grep -E "<user_id>|<participant_id>"
```
- **Synapse SQL (LXC 109) — undelivered / queued to-device events:**
```sql
-- Backlog of to-device messages queued for the affected device. A growing
-- count here = the HS has the media-key events but the device isn't draining
-- them via /sync (or they were sent to a stale device id).
SELECT user_id, device_id, count(*) AS pending
FROM device_inbox
WHERE user_id = '@user:matrix.lotusguild.org'
GROUP BY user_id, device_id;
-- Cross-check the device id the sender is targeting actually exists / is current.
SELECT device_id, display_name, last_seen, ts
FROM devices WHERE user_id = '@user:matrix.lotusguild.org';
```
- **Confirms:** to-device events present but undecryptable (client shows the
`io.element.call.encryption_keys` "unexpected encrypted" warning) ⇒ there is
**no valid Olm session** to decrypt them — the expected downstream of KE-1.
### KE-3 — Timeline decryption error: missing `algorithm` field
- **Console signature (grep):**
- `DecryptionError`
- full: `Error decrypting event (… type=m.room.encrypted …): DecryptionError[msg: missing field 'algorithm' at line 1 column 138 …]`
- **Capture client-side:**
- The **event id** (`$SASBBzoqj…` was one) and the **room id**.
- Pull the raw event JSON via DevTools or the Developer Tools account-data/event
viewer, or directly:
```
GET https://matrix.lotusguild.org/_matrix/client/v3/rooms/<roomId>/event/<eventId>
```
Inspect `content` — confirm whether `algorithm` (should be
`m.megolm.v1.aes-sha2`) is truly absent vs a serialization mismatch.
- **Synapse log grep (LXC 151):**
```bash
grep -E "<eventId>" /var/log/matrix-synapse/homeserver.log
```
- **Synapse SQL (LXC 109) — the stored event content as the HS holds it:**
```sql
SELECT ej.event_id, e.type, e.sender, e.origin_server_ts,
(ej.json::json -> 'content' -> 'algorithm') AS algorithm
FROM event_json ej
JOIN events e USING (event_id)
WHERE ej.event_id = '$SASBBzoqj...';
```
- **Confirms:** if the stored `content.algorithm` is **NULL/absent** on the HS →
a malformed/legacy event was persisted (sender-side or federation). If it is
**present** on the HS but the client throws → an RC-SDK deserialization bug.
This distinction decides whether KE-3 is a data problem or a client problem.
### KE-4 — MatrixRTC delayed-event / membership timeouts
- **Console signature (grep):**
- `update_delayed_event` (`org.matrix.msc4157.update_delayed_event`)
- `delayed event` / `Restart delayed event timed out`
- full: `[MembershipManager] Network local timeout error while sending event, immediate retry … AbortError: Restart delayed event timed out before the HS responded`
- **Capture client-side:**
- Timestamps of each timeout; whether they correlate with call join/leave or
with general sync slowness.
- DevTools → Network: the `…/delayed_events…` / `update_delayed_event`
requests — their **HTTP status and latency** (timed-out vs slow-200).
- **Synapse log grep (LXC 151):**
```bash
grep -E "delayed_event|msc4140|msc4157|update_delayed" \
/var/log/matrix-synapse/homeserver.log | grep "<user_id>"
# HS responsiveness in the same window (KE-4 may be pure latency):
grep -E "Processed request|/sync" /var/log/matrix-synapse/homeserver.log | tail -50
```
- **Server-side corroboration (Grafana, `dashboard.lotusguild.org`):** Synapse
p99 response time (excl. `/sync`), event-processing lag, DB query latency for
the call window. High latency here ⇒ KE-4 is (partly) homeserver
responsiveness, not a client bug.
- **Confirms:** timeouts that line up with HS latency spikes → reliability/load;
timeouts with a healthy HS → client MembershipManager retry logic.
---
## 2. Causality hypothesis
```
KE-1 OTK upload conflict storm
(rust-crypto store ↔ Synapse OTK state DIVERGED; server rejects re-uploads)
│ no fresh OTKs can be published/claimed
No new Olm (1:1) sessions can be established with this device
KE-2 EC media-key to-device events (io.element.call.encryption_keys)
arrive but cannot be decrypted ⇒ MissingKey at index N
⇒ friend's audio/video cuts out
```
KE-3 (missing `algorithm`) and KE-4 (delayed-event timeouts) are **likely
independent** of the KE-1→KE-2 chain: KE-3 is a decode/serialization path,
KE-4 is a MatrixRTC-vs-HS reliability path. Confirm/refute independence with the
decision tree below.
### Decision tree — which capture confirms/refutes each link
```
Q1. Does the KE-1 offending key_id from the 400 response exist in
e2e_one_time_keys_json with a DIFFERENT value than the client request body?
├─ YES → OTK divergence CONFIRMED (KE-1 root). Go to Q2.
└─ NO → Not divergence. Check: are OTK counts at 0 with fallback key `used=true`?
├─ YES → OTK exhaustion, not divergence — different remediation.
└─ NO → Suspect RC-SDK 41.6.0-rc.0 upload-loop regression (see §3).
Q2. During the same call, are io.element.call.encryption_keys to-device events
present in device_inbox / Synapse to-device logs for our device id?
├─ YES + client shows "unexpected encrypted"/MissingKey
│ → KE-1 ⇒ KE-2 LINK CONFIRMED (events delivered, no Olm session to open them).
├─ YES + client decrypts fine, but LiveKit still silent
│ → KE-2 is downstream of LiveKit/SFU, NOT KE-1. Decouple from crypto.
└─ NO (nothing queued/targeted our device)
→ media keys never sent to us: stale device id / membership (see KE-4)
→ KE-2 is a device-targeting problem, weakly linked to KE-1.
Q3. KE-3: is content.algorithm NULL in event_json on the HS?
├─ YES → malformed persisted event (sender/federation). Independent of KE-1.
└─ NO → client-side RC-SDK deserialization bug. Independent of KE-1.
Q4. KE-4: do delayed-event timeouts coincide with Synapse p99 latency spikes
(Grafana) in the same minute?
├─ YES → homeserver responsiveness/load. Independent of KE-1..KE-3.
└─ NO → client MembershipManager retry behavior. Independent.
```
---
## 3. Ranked remediation options (with blast radius)
> Ordered least-destructive → most-destructive. **Do not run any of these as a
> "fix" before the planning session** — they are listed so evidence collection
> can be paired with a recovery plan. Confirm the root condition (Q1/Q2) first.
1. **Per-device logout + re-login of the affected device** _(lowest blast radius)_
- **What:** log the one glitching device out and back in. Forces a fresh
device id, fresh device keys, and a clean OTK batch — sidesteps a diverged
OTK store without touching other sessions.
- **Blast radius:** that device only. Other sessions/devices untouched.
- **Cost:** the new device must be re-verified (cross-signing) and will need
to restore room keys from **key backup** to read old encrypted history.
- **Confirms/uses:** if KE-1 stops after this, OTK-store divergence (Q1) was
the cause.
2. **Client crypto-store reset (`clearLoginData` path)** _(medium)_
- **What:** `clearLoginData()` in `src/client/initMatrix.ts` (coordinator's
file — do not edit) **deletes ALL IndexedDB databases** (incl.
`web-sync-store` and the rust-crypto store `crypto-store`), **unregisters
service workers**, **clears all Cache Storage**, and **`localStorage.clear()`**,
then reloads. `clearCacheAndReload()` is lighter — it only calls
`mx.store.deleteAllData()` (sync cache) and does **not** wipe crypto.
- **Blast radius:** this browser profile only, but total: you are logged out,
lose all cached sync state, drafts, settings, and **the local
megolm/room-key store**.
- **⚠️ Message-history / backup implication:** wiping `crypto-store` destroys
locally-held **room keys (megolm inbound sessions)**. Any history **not
backed up to server-side Key Backup** becomes **permanently undecryptable
on this device**. Before doing this: verify Key Backup is enabled and the
recovery key / passphrase is available (Settings → Security), or the user
loses readable history. Cross-signing must be re-established too.
- **Use when:** the rust-crypto store itself is corrupt/diverged and option 1
didn't clear it.
3. **SDK pin change off the RC** _(medium — codebase change, needs rebuild)_
- **Current pin:** `package.json` → `"matrix-js-sdk": "41.6.0-rc.0"` (a
release candidate).
- **Finding (npm / GitHub changelog, checked 2026-07):** stable **`41.6.0`**
was released **2026-05-26**. Its only changelog line is _"Throw sane error
on completeLoginOnNewDevice IdP rejection"_ — **no OTK / keys-upload / Olm /
to-device fix** relative to the RC. Later stable lines exist
(`41.7.0`, `41.8.0`; `41.7.0-rc.3` / `41.9.0-rc.0` seen as pre-releases).
Nearby crypto-relevant entries: `41.5.0` _"Enable encrypted history sharing
by default"_; `41.4.0` key-backup handling. **No changelog entry directly
addresses the KE-1 OTK-conflict symptom** in the immediate range — so
moving RC→`41.6.0` stable is a low-risk hygiene step but is **not expected
to fix KE-1 by itself**. Before pinning, re-read the CHANGELOG for any
`41.7.x`/`41.8.x` OTK/one-time-key/olm entry that post-dates this note.
- **Blast radius:** all users after the next `cinny-build.sh` deploy. Test the
rust-crypto IndexedDB schema — a downgrade triggers the `IDB_VERSION_CONFLICT`
path in `initMatrix.ts`.
4. **Synapse-side OTK row surgery** _(LAST RESORT — highest danger)_
- **What:** deleting/rewriting rows in `e2e_one_time_keys_json` (and/or
`e2e_fallback_keys_json`, `device_inbox`) for the affected device to force
the client to re-upload a clean batch.
- **⚠️ Danger:** direct writes to Synapse crypto tables can **desync every
device of that user**, break Olm sessions **for everyone who has claimed one
of those keys**, and are easy to get wrong (wrong `key_id`, cache not
invalidated). Synapse caches OTK counts — a raw DELETE without a restart can
leave the advertised count wrong, **worsening** the KE-1 loop.
- **Guardrails if ever done (planning session + HS owner only):** full
`pg_dump` of `synapse` first; do it during **zero active calls**; delete only
the exact diverged `key_id` for the exact `device_id`; `systemctl restart
matrix-synapse` to flush caches; then log the device out/in (option 1) so it
republishes. **Never** run this speculatively.
---
## 4. "Capture session" checklist (run during the next call)
Do these **in order**. Aim to have client + server capturing the **same call**.
1. **Prep server tail (LXC 151):** SSH in, start
`tail -F /var/log/matrix-synapse/homeserver.log | tee /tmp/lotus-call-$(date +%s).log`.
(Optionally raise the `synapse.rest.client.keys` / `handlers.e2e_keys` /
`handlers.devicemessage` loggers to DEBUG per §0 and `systemctl reload
matrix-synapse` — remember to revert after.)
2. **Prep client:** open Lotus Chat → Settings → Developer Tools → **enable
Developer Tools** so the **Crypto Diagnostics** card is visible; note its
entry count starts at (or reset by reload to) 0.
3. **Open DevTools** (F12) → Console: enable **Preserve log**; Network tab:
enable **Preserve log** + **Record**. Note your **device id** and **user id**
(Settings → Devices / Developer Tools → Copy access token page shows ids).
4. **Note wall-clock start time** (ISO/UTC) on both machines so logs align.
5. **Join the Element Call** with the second participant; reproduce the fault
(wait for the audio/video cutouts and let KE-1 storm run ~3060s).
6. **When a fault occurs, note the wall-clock timestamp** and which symptom
(audio cut / video freeze / etc.) — this bounds the log window.
7. **Client artifacts:** in the Crypto Diagnostics card click **Download report**
(`lotus-crypto-diag-<ts>.json`); in DevTools Network, save the failing
`keys/upload` request+response (right-click → Save/Copy), and the raw HAR
(Network → Save all as HAR) for the call window.
8. **Grab KE-3 event id / KE-2 participant+index** from the console (or the
diag JSON `entries[]`) for the SQL lookups.
9. **Server artifacts:** stop the tail; run the per-KE greps and SQL from §1
against the noted device id / user id / event id, saving output alongside the
client JSON. Screenshot the Grafana Synapse latency panels for the window
(for KE-4).
10. **Bundle & label:** put client JSON + HAR + server log slice + SQL output in
one folder named with the call's UTC start time. Revert any DEBUG log config
(`systemctl reload matrix-synapse`). Hand off to the planning session — **do
not apply §3 remediations yet.**
---
## 5. Client diagnostics helper (this kit)
- **`src/app/utils/cryptoDiagLog.ts`** — capture-only console instrumentation.
- `installCryptoDiagLog()` — idempotent; wraps `console.warn`/`console.error`
with pass-through wrappers (originals always called) that ring-buffer (max
**200**) any line matching the KE signatures. No network, no timers.
- `getCryptoDiagEntries()` — snapshot copy of the buffer (`{ ts, level, ke,
signature, message }`, most-recent-last).
- `buildCryptoDiagReport(mx)` — JSON string: SDK version, device id, user id,
sync state, `cryptoReady` (`mx.getCrypto()` presence), per-KE counts, and the
entry buffer. No tokens/PII beyond those ids; captured log lines are retained
verbatim as evidence.
- **Signatures → KE mapping:** `already exists`→KE-1; `missing key at index` /
`io.element.call.encryption_keys` / `MissingKey`→KE-2; `DecryptionError`→KE-3;
`update_delayed_event` / `delayed event`→KE-4.
- **`src/app/features/settings/developer/CryptoDiagnostics.tsx`** — a folds
`SequenceCard`/`SettingTile` card (mirrors `developer-tools/DevelopTools.tsx`)
showing the live matched-entry count (Badge) and a **Download report** button
(Blob → `lotus-crypto-diag-<ts>.json`, same download idiom as
`room-settings/ExportRoomHistory.tsx`).
### Recommended mount points (coordinator)
- **Install call:** call `installCryptoDiagLog()` **as early as possible during
boot** so it captures crypto errors from first sync — ideally at the top of
the client entry module or inside `ClientRoot` before/around `initClient`
(e.g. `src/app/pages/client/ClientRoot.tsx`). It is idempotent, side-effect
only, and needs no `mx`, so a module-scope call at app entry is safe. (Do
**not** put it in `initMatrix.ts` — that file is off-limits.)
- **Settings card:** render `<CryptoDiagnostics />` inside the Developer Tools
page — in `src/app/features/settings/developer-tools/DevelopTools.tsx`, add it
to the `Box direction="Column" gap="700"` list (guarded by the existing
`developerTools` flag), right after the "Access Token" card. It pulls `mx`
from `useMatrixClient()` itself, so it just needs to be placed in the tree.
---
## 6. 2026-07 investigation update — 41.7.0 delta + web-specific root cause
New findings this session (code-read + upstream issue triage). These **sharpen
KE-1's root cause and close the "just upgrade the SDK" lever**.
### 6.1 The 41.7.0 upgrade does NOT fix KE-1 (lever closed)
We are now on **`matrix-js-sdk@41.7.0`** → **`@matrix-org/matrix-sdk-crypto-wasm@18.3.1`**
(was `41.6.0-rc.0` when KE-1/2 were observed). Checked both changelogs:
- 41.7.0's only crypto line is the **security bump to crypto-wasm 18.3.1**. No
OTK / keys-upload / Olm-session change.
- crypto-wasm 17.0 → 18.3.1: **no entry** for one-time-keys, keys/upload,
"already exists", or upload conflicts. The 18.3.x work was **to-device
security hardening** (vodozemac 0.10; sender-spoofing check via
`sender_device_keys`; MSC4147 validation) — unrelated to the OTK loop.
- Upstream **`matrix-rust-sdk#5200`** ("OlmMachine constantly tries to upload
keys when restoring session") is **still OPEN** (as of mid-2025). The loop
mechanism is confirmed there: on the 400, `mark_request_as_sent()` never
fires, so the keys stay "unshared" and the SDK re-issues the identical failing
upload every cycle → the storm.
⇒ **Remediation option 3 (SDK pin) is exhausted for KE-1.** Do not expect a
version bump to help; the fix is store-hygiene, below.
### 6.2 Confirmed root cause + the web-specific trigger we can act on
Upstream `#5200` + `#1415` pin the root condition to **rust-crypto store ↔
server OTK divergence**, from one of:
1. **Crypto store reset/restore without deregistering the device server-side**
— the store forgets OTKs it already published; the server still holds them.
2. **Unsafe concurrent access to the crypto store** — e.g. the **same session
open in multiple browser tabs**, each running its own OlmMachine against the
one IndexedDB crypto store.
3. A store that isn't durably persisted, so a restore can't track what was sent.
**Cinny is a web client and hits two of these by construction (verified in code):**
- **No `navigator.storage.persist()` anywhere** (`grep` clean). The rust-crypto
IndexedDB store is therefore **evictable under storage pressure** — while the
**access token + device id live in `localStorage`** (N97), which browsers evict
_less_ aggressively. Partial eviction ⇒ the device **resurrects with a blank
crypto store but the SAME device id** ⇒ it re-uploads OTKs the server still
holds ⇒ the **exact KE-1 "already exists" divergence**, with **no user action**
and no visible cause. This is the leading hypothesis for a self-hosted web
deployment.
- **No multi-tab crypto guard** (no `navigator.locks` / `BroadcastChannel`
leader election in `src/`). `initMatrix.ts` calls `mx.initRustCrypto()` with no
single-writer coordination, so 2+ tabs = concurrent store access = trigger #2.
### 6.3 Concrete PREVENTIVE client mitigations (new — buildable, don't need a call)
Ordered by value/effort. These reduce the _recurrence_ of KE-1; they don't heal
an already-diverged device (that still needs remediation option 1: clean
logout+login).
1. **Request persistent storage on login — `navigator.storage.persist()`**
_(cheapest, highest value)_. Idempotent, side-effect only, no behavior change
if the browser denies it. Directly prevents the eviction-induced divergence in
6.2. Best placed at app entry alongside the other module-scope calls (NOT in
`initMatrix.ts`, which is off-limits) — e.g. a one-liner in `ClientRoot`/app
bootstrap: `if (navigator.storage?.persist) navigator.storage.persist();`
Optionally surface `navigator.storage.persisted()` in the Crypto Diagnostics
card so a capture records whether the store was evictable.
2. **Multi-tab guard** _(medium)_. Detect a second tab of the same session
(BroadcastChannel or the Web Locks API) and either (a) warn "Lotus is open in
another tab — encryption may misbehave", or (b) make secondary tabs read-only
for crypto. Prevents trigger #2.
3. **Loop detection → recovery prompt** _(medium)_. Watch for repeated
`keys/upload` 400 `M_UNKNOWN … already exists` (the client sees the rejection);
after N in a window, stop hammering and surface a "Reset encryption on this
device (log out & back in)" prompt instead of looping silently.
### 6.4 Secondary KE-2 hypothesis to test in the capture
crypto-wasm **18.3.0 tightened Olm to-device validation** (sender-spoof check +
MSC4147). It's therefore possible KE-2's `WARN … unexpected encrypted to-device
event … io.element.call.encryption_keys` is **partly** the new validation
rejecting EC's media-key events, not _only_ the missing-Olm-session downstream of
KE-1. **Capture discriminator:** if KE-2 still occurs in a call where OTK counts
are healthy and no KE-1 storm is present (Q1 = NO), suspect the to-device
validation path (EC ↔ rust-crypto 18.3.x), not KE-1. If KE-2 only ever co-occurs
with the KE-1 storm, the original KE-1⇒KE-2 chain stands.
### 6.5 What to do now vs. at capture
- **Now (no call needed):** ship 6.3.1 (`persist()`) — it's safe and preventive.
Consider 6.3.3 (loop detection) as a follow-up.
- **At the next glitchy call:** run the §4 capture; answer Q1 (divergence?) and
6.4's discriminator. For any _currently_ stuck device, remediation option 1
(clean **logout + login**, not just "clear storage" — clearing storage without
`mx.logout()` leaves the server device + its OTKs and can re-trigger the
divergence).
+2 -2
View File
@@ -330,7 +330,7 @@ Users can set a custom background color for `@mention` chips that highlight thei
> pre-built npm bundle. Several in-call behaviors below are now first-class > pre-built npm bundle. Several in-call behaviors below are now first-class
> source changes rather than DOM/widget hacks. Background, plan, and the Phase-2 > source changes rather than DOM/widget hacks. Background, plan, and the Phase-2
> work list are in > work list are in
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md). > the Element Call fork reference in [`LOTUS_TODO.md`](./LOTUS_TODO.md).
### Element Call — Self-Built Fork (`0.20.1-lotus.1`) ### Element Call — Self-Built Fork (`0.20.1-lotus.1`)
@@ -1235,7 +1235,7 @@ The session persists as ONE atomic `cinny_session_v1` JSON write (previously ~10
### Crypto Diagnostics (E2EE investigation kit) ### Crypto Diagnostics (E2EE investigation kit)
**Settings → Developer Tools → Crypto Diagnostics**: a capture-only ring buffer (max 200) hooks `console.warn/error` for E2EE failure signatures (OTK upload conflicts, missing call media keys, decryption errors, delayed-event timeouts) and downloads a JSON report — the evidence input for the KE-1→4 investigation. Companion runbook: [`LOTUS_E2EE_INVESTIGATION.md`](./LOTUS_E2EE_INVESTIGATION.md). `utils/cryptoDiagLog.ts`, `features/settings/developer/CryptoDiagnostics.tsx`. **Settings → Developer Tools → Crypto Diagnostics**: a capture-only ring buffer (max 200) hooks `console.warn/error` for E2EE failure signatures (OTK upload conflicts, missing call media keys, decryption errors, delayed-event timeouts) and downloads a JSON report — the evidence input for the KE-1→4 investigation. Companion diagnosis: the Encryption / E2EE section of [`LOTUS_TODO.md`](./LOTUS_TODO.md). `utils/cryptoDiagLog.ts`, `features/settings/developer/CryptoDiagnostics.tsx`.
--- ---
+67 -3
View File
@@ -328,7 +328,7 @@ _(Requires the guard deployed on LXC 151 — auto-deploys on a `matrix` repo pus
# Backlog of previously-fixed-but-unverified items # Backlog of previously-fixed-but-unverified items
> Sections AD above are **this session's** work. Everything below was fixed in earlier waves and is still flagged **⚠️ UNTESTED** in `LOTUS_BUGS.md` / `LOTUS_TODO.md`. They're grouped by what kind of environment you need (mobile, desktop, screen reader, etc.) so you can knock out a whole category at once. None of these are urgent the way AD are; do them as you have the right device handy. > Sections AD above are **this session's** work. Everything below was fixed in earlier waves and is still flagged **⚠️ UNTESTED** (see the outstanding-verification backlog below / `LOTUS_TODO.md`). They're grouped by what kind of environment you need (mobile, desktop, screen reader, etc.) so you can knock out a whole category at once. None of these are urgent the way AD are; do them as you have the right device handy.
## E. Mobile / responsive (needs a real phone, or devtools device emulation) ## E. Mobile / responsive (needs a real phone, or devtools device emulation)
@@ -575,7 +575,7 @@ Log into **matrix.lotusguild.org** (password) and **matrix.org**.
## O. July 2026 batch — threads, notifications, math, search cache, audit wave ## O. July 2026 batch — threads, notifications, math, search cache, audit wave
Everything landed after the OIDC work. These mirror the checklists in `LOTUS_TODO.md` (§P3-8, §P4-1) and the Needs-Verification rows in `LOTUS_BUGS.md` (P3-8/P4-1/P4-4/P4-8/N97a/AW-1…4). **⚠️ Threads change the main timeline** — thread replies no longer render inline; that's intended (see O1). Everything landed after the OIDC work. These mirror the checklists in `LOTUS_TODO.md` (§P3-8, §P4-1) and the outstanding-verification backlog below (P3-8/P4-1/P4-4/P4-8/N97a/AW-1…4). **⚠️ Threads change the main timeline** — thread replies no longer render inline; that's intended (see O1).
### O1. Thread Panel (P3-8) — 👥 2 people help for live replies ### O1. Thread Panel (P3-8) — 👥 2 people help for live replies
@@ -626,7 +626,7 @@ The webview CSP was tightened and the full native module set now compiles. Smoke
### O8. E2EE / call-key cluster (KE-1→4) — 👥 2 people, during a real call ### O8. E2EE / call-key cluster (KE-1→4) — 👥 2 people, during a real call
We shipped the diagnostics kit + a **Crypto Diagnostics** card (**Settings → Developer Tools**). During your next call that glitches (audio cutouts, "Unable to decrypt"), open it and **Download report**, and note whether the symptoms even still occur now that we're on **matrix-js-sdk 41.7.0** (crypto-wasm 18.3.1). Send me the report + `LOTUS_E2EE_INVESTIGATION.md` is the runbook. We shipped the diagnostics kit + a **Crypto Diagnostics** card (**Settings → Developer Tools**). During your next call that glitches (audio cutouts, "Unable to decrypt"), open it and **Download report**, and note whether the symptoms even still occur now that we're on **matrix-js-sdk 41.7.0** (crypto-wasm 18.3.1). Send me the report; the KE-1..4 diagnosis + capture guidance is in `LOTUS_TODO.md` (Encryption / E2EE), with the full original runbook in git history.
--- ---
@@ -670,3 +670,67 @@ Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view,
4. **A4** (in-call banner) + **A3** (ringtone) — newest call logic, hardest to reproduce. 4. **A4** (in-call banner) + **A3** (ringtone) — newest call logic, hardest to reproduce.
5. **D** (EC control sweep) — guards against the fork breaking calls. 5. **D** (EC control sweep) — guards against the fork breaking calls.
6. Everything else. 6. Everything else.
---
## 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.)
**Unread dot on federated rooms + avatar-decoration console storm (2026-07):**
- **Read receipts (regression guard — highest priority):** open several rooms and open the Home/Direct tabs (which mark all orphan rooms read on mount) → rooms **stay read**, unread dots clear and don't come back. (A prior attempt sent a receipt for the thread _root_ when a thread's replies weren't loaded, which the SDK treats as a main receipt at an old event and re-unread every room on every mark-read. Fixed + locked by `notifications.test.ts`.)
- **Thread dot:** a room with an unread reply in a thread whose replies are loaded → its dot clears on read; for a thread not yet loaded, the dot clears once you open/load the thread. (mark-as-read now sends a threaded receipt only for a genuine loaded reply, never the root.)
- With DevTools console open on federated rooms, the `io.lotus.avatar_decoration` `403`/`502` (and federated media) errors should **not** repeat on every scroll/mount — each failing user is now requested at most ~twice per session, so the storm (and its homeserver load) is gone.
**Custom Window Chrome (Beta) fix (2026-07):** on the desktop build, Settings → General → toggle **Custom Window Chrome** — it should reload and come up with the Lotus title bar and a normal, stable feed (no screen-expand / auto-scroll-into-the-past). Toggle back off → reloads to the native frame.
_Ported from the retired `LOTUS_BUGS.md` (2026-07). Compact index of shipped-but-not-live-tested items; the detailed steps are in the lettered sections above._
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
| ID | Item | File / area | Test |
| :--- | :-------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
| #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 |
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
| #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 |
| #12 | PiP "All muted" badge re-fixed (was firing on any single mute) | `hooks/useCallSpeakers.ts` | G1 |
| N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 |
| N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 |
| N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 |
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
| N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room |
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
| P3-8 | Thread Panel (side drawer, chips, threaded receipts, thread composer) | `features/room/thread/*`, `RoomTimeline/RoomInput` | 6-step checklist in LOTUS_TODO §P3-8 |
| P4-4 | KaTeX math (`$…$`, `$$…$$`, data-mx-maths; lazy chunk) | `utils/mathParse.ts`, `components/math/` | send `$x^2$`, `$$\int f$$`, `$5 and $10` (stays text), math inside code block (stays text) |
| P4-8 | Encrypted-search cache (opt-in toggle, clear button, logout wipe) | `utils/searchCache.ts`, message-search | enable in search panel → search → reload → coverage persists; logout wipes |
| N97a | Session blob migration + cross-tab logout sync | `state/sessions.ts`, `useSessionSync` | login on old build → new build migrates; logout in tab A → tab B drops to auth |
| P4-1 | Slack-style thread notifications (participating default, All/Mentions/Mute, badge math) | `utils/threadNotifications.ts`, `ClientNonUIFeatures`, `roomToUnread` | 6-step checklist in LOTUS_TODO §P4-1 |
| AW-1 | Scheduled-message cancel no longer ghost-sends (error row on failure) | `ScheduledMessagesTray.tsx` | schedule → cancel with network cut → item stays + error; retry works |
| AW-2 | Emoji lazy-load (search/autocomplete/recents fill in; board opens fast) | `plugins/emoji.ts` + consumers | first emoji-board open of a session: grid+search populate; reactions still label |
| AW-3 | SW precache (repeat-visit near-instant; deploys still picked up immediately) | `sw.ts`, `vite.config.js` | load app twice (2nd = cached assets); deploy → reload picks new version |
| AW-4 | Desktop CSP tighten + Escape/panel fixes + thread Jump to Latest | `tauri.conf.json`, Room/ThreadPanel | desktop: boots, avatars/media load, VT323 font renders, location maps embed, calls connect, deep links work |
| P3-4 | Accessibility compliance pass (collapsed-msg SR sender, form/overlay labels, typing announce, focus-return, `?` help, jsx-a11y CI gate) | `message/*`, `RoomViewTyping`, `features/shortcuts/*`, `eslint.config.mjs` | LOTUS_TESTING §P — axe-core + VoiceOver/NVDA on the golden path |
| P6-1 | Desktop Linux parity (no-sleep in calls, launcher badge), autostart toggle, tray Do-Not-Disturb | `native/power.rs`, `lib.rs`, `useTauriDnd`, `General.tsx` | Linux desktop: no display sleep during a call; tray DND silences notifications; launch-on-login persists; Unity badge (Ubuntu); DND toggle polarity |
| P6-2 | EC deafen/screenshare-audio-mute via `io.lotus.set_deafen` (retires the `<audio>.muted` iframe hack) | fork `lotusDeafen.ts`, cinny `CallControl.ts` | AFTER publish+pin-bump: deafen silences remote audio + survives a reconnect / new screenshare / late joiner (the cases the DOM hack failed); screenshare-audio-mute toggles independently |
| P6-3 | Forward-to-multiple-rooms (multi-select + partial-failure summary) + live bookmark previews (edits/redactions, snapshot fallback) | `ForwardMessageDialog.tsx`+`forwardContent.ts`, `BookmarksPanel.tsx` | forward one msg to 3 rooms (incl. 1 you cannot post to = partial summary); bookmark then edit shows edited; redact shows deleted; leave room shows snapshot |
| P6-4 | HSTS + Permissions-Policy on prod nginx (+ contrib examples) | `matrix/cinny/nginx.conf`, `contrib/nginx`, `contrib/caddy` | after `nginx -s reload`: `curl -sI https://chat.lotusguild.org` shows HSTS + Permissions-Policy; a call (cam/mic/screenshare) + location share still work |
**Verified working in live testing (2026-06):** A2, B1B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.
---
+198 -778
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -180,10 +180,10 @@ avatar decorations on EC video tiles, and a native transparent background.
(`io.lotus.inject_audio` → in-call soundboard) and quality controls (`io.lotus.inject_audio` → in-call soundboard) and quality controls
(`io.lotus.set_quality`). (`io.lotus.set_quality`).
The full plan and integration map is in The fork's `io.lotus.*` action catalog + the publish procedure are in
**[`HANDOFF_ELEMENT_CALL_FORK.md`](HANDOFF_ELEMENT_CALL_FORK.md)**; infra/hosting + **[`LOTUS_TODO.md`](LOTUS_TODO.md)** ("Element Call fork — operational reference");
build-pipeline notes live in the `LotusGuild/matrix` repo README. Search the docs infra/hosting + build-pipeline notes live in the `LotusGuild/matrix` repo README.
for the **`[EC-FORK]`** tag to find every related note. Search the docs for the **`[EC-FORK]`** tag to find every related note.
### Build ### Build
+7 -1
View File
@@ -144,10 +144,16 @@ export default [
}, },
}, },
{ {
// Test files commonly define several small mock/fake classes. // Test files commonly define several small mock/fake classes and named
// function expressions used as constructor mocks (e.g.
// `setGlobal('AudioWorkletNode', function AudioWorkletNode(){})`), which must
// NOT be rewritten to arrows (arrows aren't constructable). Relax the
// stylistic class/callback rules here.
files: ['**/*.test.ts', '**/*.test.tsx'], files: ['**/*.test.ts', '**/*.test.tsx'],
rules: { rules: {
'max-classes-per-file': 'off', 'max-classes-per-file': 'off',
'lines-between-class-members': 'off',
'prefer-arrow-callback': 'off',
}, },
}, },
]; ];
+224 -1
View File
@@ -49,6 +49,7 @@
"immer": "11.1.8", "immer": "11.1.8",
"is-hotkey": "0.2.0", "is-hotkey": "0.2.0",
"jotai": "2.20.0", "jotai": "2.20.0",
"jsqr": "1.4.0",
"katex": "0.16.11", "katex": "0.16.11",
"linkify-react": "4.3.3", "linkify-react": "4.3.3",
"linkifyjs": "4.3.3", "linkifyjs": "4.3.3",
@@ -57,6 +58,8 @@
"millify": "6.1.0", "millify": "6.1.0",
"pdfjs-dist": "5.7.284", "pdfjs-dist": "5.7.284",
"prismjs": "1.30.0", "prismjs": "1.30.0",
"qrcode": "1.5.4",
"qrcode.react": "4.2.0",
"react": "19.2.6", "react": "19.2.6",
"react-aria": "3.48.0", "react-aria": "3.48.0",
"react-blurhash": "0.3.0", "react-blurhash": "0.3.0",
@@ -86,6 +89,7 @@
"@types/katex": "0.16.8", "@types/katex": "0.16.8",
"@types/node": "25.9.1", "@types/node": "25.9.1",
"@types/prismjs": "1.26.6", "@types/prismjs": "1.26.6",
"@types/qrcode": "1.5.6",
"@types/react": "19.2.15", "@types/react": "19.2.15",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/react-google-recaptcha": "2.1.9", "@types/react-google-recaptcha": "2.1.9",
@@ -3989,6 +3993,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.15", "version": "19.2.15",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
@@ -5170,6 +5184,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/camelize": { "node_modules/camelize": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
@@ -5964,6 +5987,15 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dedent": { "node_modules/dedent": {
"version": "1.7.2", "version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -6107,6 +6139,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/direction": { "node_modules/direction": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz", "resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz",
@@ -9056,6 +9094,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
"license": "Apache-2.0"
},
"node_modules/jsx-ast-utils": { "node_modules/jsx-ast-utils": {
"version": "3.3.5", "version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -10499,6 +10543,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -10536,7 +10589,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -10651,6 +10703,15 @@
"pathe": "^2.0.1" "pathe": "^2.0.1"
} }
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@@ -10758,6 +10819,150 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/raf-schd": { "node_modules/raf-schd": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
@@ -11178,6 +11383,12 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resize-observer-polyfill": { "node_modules/resize-observer-polyfill": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@@ -11508,6 +11719,12 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-cookie-parser": { "node_modules/set-cookie-parser": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@@ -12973,6 +13190,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": { "node_modules/which-typed-array": {
"version": "1.1.20", "version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
+4
View File
@@ -74,6 +74,7 @@
"immer": "11.1.8", "immer": "11.1.8",
"is-hotkey": "0.2.0", "is-hotkey": "0.2.0",
"jotai": "2.20.0", "jotai": "2.20.0",
"jsqr": "1.4.0",
"katex": "0.16.11", "katex": "0.16.11",
"linkify-react": "4.3.3", "linkify-react": "4.3.3",
"linkifyjs": "4.3.3", "linkifyjs": "4.3.3",
@@ -82,6 +83,8 @@
"millify": "6.1.0", "millify": "6.1.0",
"pdfjs-dist": "5.7.284", "pdfjs-dist": "5.7.284",
"prismjs": "1.30.0", "prismjs": "1.30.0",
"qrcode": "1.5.4",
"qrcode.react": "4.2.0",
"react": "19.2.6", "react": "19.2.6",
"react-aria": "3.48.0", "react-aria": "3.48.0",
"react-blurhash": "0.3.0", "react-blurhash": "0.3.0",
@@ -111,6 +114,7 @@
"@types/katex": "0.16.8", "@types/katex": "0.16.8",
"@types/node": "25.9.1", "@types/node": "25.9.1",
"@types/prismjs": "1.26.6", "@types/prismjs": "1.26.6",
"@types/qrcode": "1.5.6",
"@types/react": "19.2.15", "@types/react": "19.2.15",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/react-google-recaptcha": "2.1.9", "@types/react-google-recaptcha": "2.1.9",
+17 -2
View File
@@ -56,6 +56,7 @@ import { useRoomNavigate } from '../hooks/useRoomNavigate';
import { getChatBg } from '../features/lotus/chatBackground'; import { getChatBg } from '../features/lotus/chatBackground';
import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls'; import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls';
import { useTheme, ThemeKind } from '../hooks/useTheme'; import { useTheme, ThemeKind } from '../hooks/useTheme';
import { useReducedMotion } from '../hooks/useReducedMotion';
import { useSetting } from '../state/hooks/settings'; import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings'; import { settingsAtom } from '../state/settings';
import { getStateEvent, getStateEvents, getMemberDisplayName } from '../utils/room'; import { getStateEvent, getStateEvents, getMemberDisplayName } from '../utils/room';
@@ -413,6 +414,16 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const dm = callInfo ? directs.has(callInfo.room.roomId) : false; const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
const startCall = useCallStart(dm); const startCall = useCallStart(dm);
// C-L6: handleTimelineEvent awaits decryption before calling setState; guard
// against the component unmounting during that await.
const mountedRef = useRef(true);
useEffect(
() => () => {
mountedRef.current = false;
},
[],
);
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback( const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
async (event, room, toStartOfTimeline, removed, data) => { async (event, room, toStartOfTimeline, removed, data) => {
// only process rtc notification reference events. // only process rtc notification reference events.
@@ -427,6 +438,9 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
await event.getDecryptionPromise(); await event.getDecryptionPromise();
} }
// C-L6: bail if we unmounted while awaiting decryption above.
if (!mountedRef.current) return;
// Caller-side: a participant declined a call we're hosting in this room. // Caller-side: a participant declined a call we're hosting in this room.
// Without this the caller's UI keeps "ringing" until the notification // Without this the caller's UI keeps "ringing" until the notification
// lifetime expires, with no indication the callee said no. // lifetime expires, with no indication the callee said no.
@@ -706,9 +720,10 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const theme = useTheme(); const theme = useTheme();
const isDark = theme.kind === ThemeKind.Dark; const isDark = theme.kind === ThemeKind.Dark;
const [chatBackground] = useSetting(settingsAtom, 'chatBackground'); const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
const reduced = useReducedMotion();
const wallpaperStyle = React.useMemo( const wallpaperStyle = React.useMemo(
() => getChatBg(chatBackground, isDark), () => getChatBg(chatBackground, isDark, reduced),
[chatBackground, isDark], [chatBackground, isDark, reduced],
); );
const [pipIsFullscreen, setPipIsFullscreen] = useState(false); const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
+142 -34
View File
@@ -1,12 +1,14 @@
import { import {
ShowQrCodeCallbacks,
ShowSasCallbacks, ShowSasCallbacks,
VerificationPhase, VerificationPhase,
VerificationRequest, VerificationRequest,
Verifier, Verifier,
} from 'matrix-js-sdk/lib/crypto-api'; } from 'matrix-js-sdk/lib/crypto-api';
import React, { CSSProperties, useCallback, useEffect, useState } from 'react'; import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { VerificationMethod } from 'matrix-js-sdk/lib/types'; import { VerificationMethod } from 'matrix-js-sdk/lib/types';
import QRCode from 'qrcode';
import { import {
Box, Box,
Button, Button,
@@ -27,11 +29,13 @@ import {
useVerificationRequestPhase, useVerificationRequestPhase,
useVerificationRequestReceived, useVerificationRequestReceived,
useVerifierCancel, useVerifierCancel,
useVerifierShowReciprocateQr,
useVerifierShowSas, useVerifierShowSas,
} from '../hooks/useVerificationRequest'; } from '../hooks/useVerificationRequest';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { ContainerColor } from '../styles/ContainerColor.css'; import { ContainerColor } from '../styles/ContainerColor.css';
import { useModalStyle } from '../hooks/useModalStyle'; import { useModalStyle } from '../hooks/useModalStyle';
import { QrScanner } from './QrScanner';
const DialogHeaderStyles: CSSProperties = { const DialogHeaderStyles: CSSProperties = {
padding: `0 ${config.space.S200} 0 ${config.space.S400}`, padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -97,32 +101,6 @@ function VerificationAccept({ onAccept }: VerificationAcceptProps) {
); );
} }
function VerificationWaitStart() {
const { t } = useTranslation();
return (
<Box direction="Column" gap="400">
<Text>{t('Organisms.DeviceVerification.request_accepted')}</Text>
<WaitingMessage message={t('Organisms.DeviceVerification.waiting_response')} />
</Box>
);
}
type VerificationStartProps = {
onStart: () => Promise<void>;
};
function AutoVerificationStart({ onStart }: VerificationStartProps) {
const { t } = useTranslation();
useEffect(() => {
onStart();
}, [onStart]);
return (
<Box direction="Column" gap="400">
<WaitingMessage message={t('Organisms.DeviceVerification.starting_emoji')} />
</Box>
);
}
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) { function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData])); const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
@@ -237,6 +215,120 @@ function VerificationCanceled({ onClose }: VerificationCanceledProps) {
); );
} }
function QrCodeImage({ data }: { data: Uint8ClampedArray }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// Byte-mode so the raw verification bytes round-trip (a string value would
// mangle high bytes via UTF-8).
QRCode.toCanvas(canvas, [{ data: new Uint8Array(data), mode: 'byte' }], {
width: 220,
margin: 2,
color: { dark: '#000000', light: '#ffffff' },
}).catch(() => undefined);
}, [data]);
return (
<Box justifyContent="Center">
<canvas ref={canvasRef} style={{ borderRadius: config.radii.R300 }} />
</Box>
);
}
type VerificationReadyProps = {
request: VerificationRequest;
onStartSas: () => void;
onScanned: (bytes: Uint8ClampedArray) => void;
};
function VerificationReady({ request, onStartSas, onScanned }: VerificationReadyProps) {
const [myQr, setMyQr] = useState<Uint8ClampedArray>();
const [scanning, setScanning] = useState(false);
const canShowMine = request.otherPartySupportsMethod(VerificationMethod.ScanQrCode);
const canScanTheirs = request.otherPartySupportsMethod(VerificationMethod.ShowQrCode);
useEffect(() => {
if (!canShowMine) return;
request
.generateQRCode()
.then((bytes) => {
if (bytes) setMyQr(bytes);
})
.catch(() => undefined);
}, [request, canShowMine]);
if (scanning) {
return <QrScanner onScan={onScanned} onCancel={() => setScanning(false)} />;
}
return (
<Box direction="Column" gap="400">
{myQr && (
<Box direction="Column" gap="200">
<Text size="T300">Scan this code with your other device to verify.</Text>
<QrCodeImage data={myQr} />
</Box>
)}
<Box direction="Column" gap="200">
{canScanTheirs && (
<Button variant="Primary" fill="Solid" onClick={() => setScanning(true)}>
<Text size="B400">Scan their QR code</Text>
</Button>
)}
<Button variant="Secondary" fill="Soft" onClick={onStartSas}>
<Text size="B400">Verify with emoji instead</Text>
</Button>
</Box>
</Box>
);
}
type ReciprocateVerificationProps = {
verifier: Verifier;
onCancel: () => void;
};
function ReciprocateVerification({ verifier, onCancel }: ReciprocateVerificationProps) {
const [qrCallbacks, setQrCallbacks] = useState<ShowQrCodeCallbacks>();
const [confirmState, confirm] = useAsyncCallback(
useCallback(async () => qrCallbacks?.confirm(), [qrCallbacks]),
);
useVerifierShowReciprocateQr(verifier, setQrCallbacks);
useVerifierCancel(verifier, onCancel);
const confirming =
confirmState.status === AsyncStatus.Loading || confirmState.status === AsyncStatus.Success;
// The showing side gets ShowReciprocateQr callbacks after the other device
// scans; the scanning side never does (it already called verify()) and just
// waits for completion.
if (!qrCallbacks) {
return (
<Box direction="Column" gap="400">
<WaitingMessage message="Verifying…" />
</Box>
);
}
return (
<Box direction="Column" gap="400">
<Text>The other device scanned this code. Confirm it now shows as verified.</Text>
<Box direction="Column" gap="200">
<Button variant="Primary" fill="Soft" onClick={confirm} disabled={confirming}>
<Text size="B400">Confirm</Text>
</Button>
<Button
variant="Primary"
fill="Soft"
onClick={() => qrCallbacks.cancel()}
disabled={confirming}
>
<Text size="B400">Cancel</Text>
</Button>
</Box>
</Box>
);
}
type DeviceVerificationProps = { type DeviceVerificationProps = {
request: VerificationRequest; request: VerificationRequest;
onExit: () => void; onExit: () => void;
@@ -256,6 +348,17 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
const handleStart = useCallback(async () => { const handleStart = useCallback(async () => {
await request.startVerification(VerificationMethod.Sas); await request.startVerification(VerificationMethod.Sas);
}, [request]); }, [request]);
const handleScanned = useCallback(
async (bytes: Uint8ClampedArray) => {
try {
const verifier = await request.scanQRCode(bytes);
await verifier.verify();
} catch {
// A bad/mismatched scan cancels the request; the Cancelled phase renders.
}
},
[request],
);
return ( return (
<Overlay open backdrop={<OverlayBackdrop />}> <Overlay open backdrop={<OverlayBackdrop />}>
@@ -290,15 +393,20 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
) : ( ) : (
<VerificationAccept onAccept={handleAccept} /> <VerificationAccept onAccept={handleAccept} />
))} ))}
{phase === VerificationPhase.Ready && {phase === VerificationPhase.Ready && (
(request.initiatedByMe ? ( <VerificationReady
<AutoVerificationStart onStart={handleStart} /> request={request}
) : ( onStartSas={handleStart}
<VerificationWaitStart /> onScanned={handleScanned}
))} />
)}
{phase === VerificationPhase.Started && {phase === VerificationPhase.Started &&
(request.verifier ? ( (request.verifier ? (
<SasVerification verifier={request.verifier} onCancel={handleCancel} /> request.chosenMethod === VerificationMethod.Reciprocate ? (
<ReciprocateVerification verifier={request.verifier} onCancel={handleCancel} />
) : (
<SasVerification verifier={request.verifier} onCancel={handleCancel} />
)
) : ( ) : (
<VerificationUnexpected <VerificationUnexpected
message="Unexpected Error! Verification is started but verifier is missing." message="Unexpected Error! Verification is started but verifier is missing."
@@ -13,9 +13,9 @@ import {
color, color,
Spinner, Spinner,
} from 'folds'; } from 'folds';
import FileSaver from 'file-saver';
import to from 'await-to-js'; import to from 'await-to-js';
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk'; import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
import { useSaveFile } from '../hooks/useSaveFile';
import { useModalStyle } from '../hooks/useModalStyle'; import { useModalStyle } from '../hooks/useModalStyle';
import { PasswordInput } from './password-input'; import { PasswordInput } from './password-input';
import { ContainerColor } from '../styles/ContainerColor.css'; import { ContainerColor } from '../styles/ContainerColor.css';
@@ -230,6 +230,7 @@ type RecoveryKeyDisplayProps = {
}; };
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) { function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
const saveFile = useSaveFile();
const handleCopy = () => { const handleCopy = () => {
copyToClipboard(recoveryKey); copyToClipboard(recoveryKey);
@@ -239,7 +240,7 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
const blob = new Blob([recoveryKey], { const blob = new Blob([recoveryKey], {
type: 'text/plain;charset=us-ascii', type: 'text/plain;charset=us-ascii',
}); });
FileSaver.saveAs(blob, 'recovery-key.txt'); saveFile(blob, 'recovery-key.txt');
}; };
const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*'); const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*');
+3 -2
View File
@@ -19,7 +19,7 @@ import {
config, config,
} from 'folds'; } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import FileSaver from 'file-saver'; import { useSaveFile } from '../../hooks/useSaveFile';
import * as css from './PdfViewer.css'; import * as css from './PdfViewer.css';
import { AsyncStatus } from '../../hooks/useAsyncCallback'; import { AsyncStatus } from '../../hooks/useAsyncCallback';
import { useZoom } from '../../hooks/useZoom'; import { useZoom } from '../../hooks/useZoom';
@@ -36,6 +36,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
({ className, name, src, requestClose, ...props }, ref) => { ({ className, name, src, requestClose, ...props }, ref) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const saveFile = useSaveFile();
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2); const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const [pdfJSState, loadPdfJS] = usePdfJSLoader(); const [pdfJSState, loadPdfJS] = usePdfJSLoader();
@@ -76,7 +77,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
}, [docState, pageNo, zoom]); }, [docState, pageNo, zoom]);
const handleDownload = () => { const handleDownload = () => {
FileSaver.saveAs(src, name); saveFile(src, name);
}; };
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => { const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+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 { PdfViewer } from './Pdf-viewer';
import { TextViewer } from './text-viewer'; import { TextViewer } from './text-viewer';
import { testMatrixTo } from '../plugins/matrix-to'; import { testMatrixTo } from '../plugins/matrix-to';
import { IImageContent } from '../../types/matrix/common'; import { IAudioContent, IFileContent, IImageContent } from '../../types/matrix/common';
// Audio is frequently sent as m.file (bridges/other clients, or when the browser
// reported a non-audio/* mime on upload). Detect that so we can play it inline
// like m.audio instead of showing only a download button.
const AUDIO_EXT_MIME: Record<string, string> = {
mp3: 'audio/mpeg',
m4a: 'audio/mp4',
aac: 'audio/aac',
oga: 'audio/ogg',
ogg: 'audio/ogg',
opus: 'audio/ogg',
wav: 'audio/wav',
flac: 'audio/flac',
weba: 'audio/webm',
};
const resolveInlineAudioMime = (content: IFileContent): string | undefined => {
const mime = content.info?.mimetype;
if (typeof mime === 'string' && mime.startsWith('audio')) return mime;
const name = content.filename ?? content.body ?? '';
const ext = name.split('.').pop()?.toLowerCase();
return ext ? AUDIO_EXT_MIME[ext] : undefined;
};
type RenderMessageContentProps = { type RenderMessageContentProps = {
displayName: string; displayName: string;
@@ -276,6 +298,29 @@ export function RenderMessageContent({
} }
if (msgType === MsgType.File) { if (msgType === MsgType.File) {
// If an m.file is actually audio, play it inline (like m.audio) instead of
// only offering a download. MAudio falls back to renderFile if playback fails.
const audioMime = resolveInlineAudioMime(getContent<IFileContent>());
if (audioMime) {
const fileContent = getContent<IFileContent>();
const audioContent = {
...fileContent,
info: { ...(fileContent.info ?? {}), mimetype: audioMime },
} as unknown as IAudioContent;
return (
<>
<MAudio
content={audioContent}
renderAsFile={renderFile}
renderAudioContent={(props) => (
<AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} />
)}
outlined={outlineAttachment}
/>
{renderCaption()}
</>
);
}
return renderFile(); return renderFile();
} }
@@ -31,6 +31,10 @@ export function AvatarDecoration({
> >
{children} {children}
<img <img
// Force a fresh element per slug so a recycled node whose previous slug
// 404'd (and was hidden in onError) can't leak `display:none` onto a
// valid decoration.
key={slug}
src={decorationUrl(slug)} src={decorationUrl(slug)}
style={{ style={{
position: 'absolute', position: 'absolute',
@@ -48,6 +52,9 @@ export function AvatarDecoration({
aria-hidden="true" aria-hidden="true"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onLoad={(e) => {
(e.currentTarget as HTMLImageElement).style.removeProperty('display');
}}
onError={(e) => { onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none'; (e.currentTarget as HTMLImageElement).style.display = 'none';
}} }}
@@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import FileSaver from 'file-saver';
import classNames from 'classnames'; import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds'; import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
import { useSaveFile } from '../../hooks/useSaveFile';
import * as css from './ImageViewer.css'; import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom'; import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan'; import { usePan } from '../../hooks/usePan';
@@ -17,12 +17,13 @@ export type ImageViewerProps = {
export const ImageViewer = as<'div', ImageViewerProps>( export const ImageViewer = as<'div', ImageViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => { ({ className, alt, src, requestClose, ...props }, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const saveFile = useSaveFile();
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2); const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1); const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
const handleDownload = async () => { const handleDownload = async () => {
const fileContent = await downloadMedia(src); const fileContent = await downloadMedia(src);
FileSaver.saveAs(fileContent, alt); saveFile(fileContent, alt);
}; };
return ( return (
+10 -6
View File
@@ -1,8 +1,8 @@
import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds'; import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds';
import React, { ReactNode, useCallback } from 'react'; import React, { ReactNode, useCallback } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FileSaver from 'file-saver';
import { mimeTypeToExt } from '../../utils/mimeTypes'; import { mimeTypeToExt } from '../../utils/mimeTypes';
import { useSaveFile } from '../../hooks/useSaveFile';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
@@ -24,6 +24,7 @@ type FileDownloadButtonProps = {
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) { export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const saveFile = useSaveFile();
const [downloadState, download] = useAsyncCallback( const [downloadState, download] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
@@ -34,18 +35,19 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);
const fileURL = URL.createObjectURL(fileContent); const fileURL = URL.createObjectURL(fileContent);
FileSaver.saveAs(fileURL, filename); saveFile(fileURL, filename);
return fileURL; return fileURL;
}, [mx, url, useAuthentication, mimeType, encInfo, filename]), }, [mx, url, useAuthentication, mimeType, encInfo, filename, saveFile]),
); );
const downloading = downloadState.status === AsyncStatus.Loading; const downloading = downloadState.status === AsyncStatus.Loading;
const hasError = downloadState.status === AsyncStatus.Error; const hasError = downloadState.status === AsyncStatus.Error;
const succeeded = downloadState.status === AsyncStatus.Success;
return ( return (
<IconButton <IconButton
disabled={downloading} disabled={downloading}
onClick={download} onClick={download}
variant={hasError ? 'Critical' : 'SurfaceVariant'} variant={hasError ? 'Critical' : succeeded ? 'Success' : 'SurfaceVariant'}
size="300" size="300"
radii="300" radii="300"
aria-label={ aria-label={
@@ -53,13 +55,15 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
? 'Downloading...' ? 'Downloading...'
: hasError : hasError
? 'Download failed, click to retry' ? 'Download failed, click to retry'
: 'Download file' : succeeded
? 'Downloaded — click to download again'
: 'Download file'
} }
> >
{downloading ? ( {downloading ? (
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} /> <Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
) : ( ) : (
<Icon size="100" src={Icons.Download} /> <Icon size="100" src={succeeded ? Icons.Check : Icons.Download} />
)} )}
</IconButton> </IconButton>
); );
@@ -99,9 +99,21 @@ export function AudioContent({
const [playbackSpeed, setPlaybackSpeed] = useState<0.75 | 1 | 1.5 | 2>(1); const [playbackSpeed, setPlaybackSpeed] = useState<0.75 | 1 | 1.5 | 2>(1);
useEffect(() => { useEffect(() => {
if (audioRef.current) { const audio = audioRef.current;
audioRef.current.playbackRate = playbackSpeed; if (!audio) return undefined;
} const applyRate = () => {
audio.playbackRate = playbackSpeed;
};
// Apply immediately, and re-apply whenever the media element (re)loads a new
// source — e.g. after async decrypt swaps in the blob URL — since the browser
// resets playbackRate to 1 on load, discarding the user's speed choice.
applyRate();
audio.addEventListener('loadedmetadata', applyRate);
audio.addEventListener('play', applyRate);
return () => {
audio.removeEventListener('loadedmetadata', applyRate);
audio.removeEventListener('play', applyRate);
};
}, [playbackSpeed]); }, [playbackSpeed]);
const SPEED_STEPS: Array<0.75 | 1 | 1.5 | 2> = [0.75, 1, 1.5, 2]; const SPEED_STEPS: Array<0.75 | 1 | 1.5 | 2> = [0.75, 1, 1.5, 2];
@@ -14,10 +14,10 @@ import {
TooltipProvider, TooltipProvider,
as, as,
} from 'folds'; } from 'folds';
import FileSaver from 'file-saver';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { IFileInfo } from '../../../../types/matrix/common'; import { IFileInfo } from '../../../../types/matrix/common';
import { useSaveFile } from '../../../hooks/useSaveFile';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { bytesToSize } from '../../../utils/common'; import { bytesToSize } from '../../../utils/common';
@@ -252,6 +252,7 @@ export type DownloadFileProps = {
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) { export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const saveFile = useSaveFile();
const [downloadState, download] = useAsyncCallback( const [downloadState, download] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
@@ -262,9 +263,9 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);
const fileURL = URL.createObjectURL(fileContent); const fileURL = URL.createObjectURL(fileContent);
FileSaver.saveAs(fileURL, body); saveFile(fileURL, body);
return fileURL; return fileURL;
}, [mx, url, useAuthentication, mimeType, encInfo, body]), }, [mx, url, useAuthentication, mimeType, encInfo, body, saveFile]),
); );
return downloadState.status === AsyncStatus.Error ? ( return downloadState.status === AsyncStatus.Error ? (
@@ -277,7 +278,7 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
size="400" size="400"
onClick={() => onClick={() =>
downloadState.status === AsyncStatus.Success downloadState.status === AsyncStatus.Success
? FileSaver.saveAs(downloadState.data, body) ? saveFile(downloadState.data, body)
: download() : download()
} }
disabled={downloadState.status === AsyncStatus.Loading} disabled={downloadState.status === AsyncStatus.Loading}
@@ -1,6 +1,7 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { useReducedMotion } from '../../hooks/useReducedMotion';
import { zIndices } from '../../styles/zIndex'; import { zIndices } from '../../styles/zIndex';
import { SeasonTheme } from './types'; import { SeasonTheme } from './types';
import { getActiveSeason } from './seasonSchedule'; import { getActiveSeason } from './seasonSchedule';
@@ -94,8 +95,7 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
export function SeasonalEffect() { export function SeasonalEffect() {
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const reduced = const reduced = useReducedMotion();
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const theme = useMemo<SeasonTheme | null>(() => { const theme = useMemo<SeasonTheme | null>(() => {
const override = settings.seasonalThemeOverride ?? 'auto'; const override = settings.seasonalThemeOverride ?? 'auto';
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Box, Box,
Button, Button,
@@ -21,6 +21,7 @@ import { uniqueShortcode } from '../../plugins/soundboard/utils';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { import {
getAudioDurationMs,
playClipLocally, playClipLocally,
resolveClipObjectUrl, resolveClipObjectUrl,
SOUNDBOARD_ACCEPT, SOUNDBOARD_ACCEPT,
@@ -29,6 +30,49 @@ import {
} from '../../utils/soundboardClips'; } from '../../utils/soundboardClips';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
// Injected once: the little "now playing" equalizer bars animation.
const EQ_STYLE_ID = 'lotus-soundboard-eq-keyframes';
function ensureEqKeyframes() {
if (typeof document === 'undefined' || document.getElementById(EQ_STYLE_ID)) return;
const style = document.createElement('style');
style.id = EQ_STYLE_ID;
style.textContent = `
@keyframes lotusSbEq { 0%,100% { transform: scaleY(0.3); } 50% { transform: scaleY(1); } }
@media (prefers-reduced-motion: reduce) { @keyframes lotusSbEq { 0%,100% { transform: scaleY(0.6); } } }
`;
document.head.appendChild(style);
}
function PlayingBars() {
return (
<Box alignItems="Center" gap="100" style={{ height: toRem(14) }} aria-hidden>
{[0, 1, 2].map((i) => (
<span
key={i}
style={{
display: 'inline-block',
width: toRem(3),
height: toRem(14),
borderRadius: toRem(2),
background: color.Primary.Main,
transformOrigin: 'center bottom',
animation: `lotusSbEq 0.7s ease-in-out ${i * 0.15}s infinite`,
}}
/>
))}
</Box>
);
}
// Short clip length shown while adjusting a sound: "3.2s", or "1:04" if ≥ 60s.
const formatClipSeconds = (seconds: number): string => {
if (!Number.isFinite(seconds) || seconds < 0) return '';
if (seconds < 60) return `${seconds.toFixed(1)}s`;
const m = Math.floor(seconds / 60);
const s = Math.round(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
};
type ClipDraft = { type ClipDraft = {
url: string; url: string;
body: string; body: string;
@@ -59,9 +103,20 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [emojiFor, setEmojiFor] = useState<string>(); // shortcode currently picking an emoji const [emojiFor, setEmojiFor] = useState<string>(); // shortcode currently picking an emoji
const [busyPreview, setBusyPreview] = useState<string>(); const [busyPreview, setBusyPreview] = useState<string>();
const [playingKey, setPlayingKey] = useState<string>();
const [durations, setDurations] = useState<Map<string, number>>(new Map()); // shortcode -> seconds
const audioElRef = useRef<HTMLAudioElement | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const emojiAnchorRef = useRef<HTMLElement | null>(null); const emojiAnchorRef = useRef<HTMLElement | null>(null);
useEffect(() => {
ensureEqKeyframes();
return () => {
audioElRef.current?.pause();
audioElRef.current = null;
};
}, []);
const existing = useMemo(() => pack.getClips(), [pack]); const existing = useMemo(() => pack.getClips(), [pack]);
const clipCount = existing.filter((c) => !deleted.has(c.shortcode)).length + uploads.length; const clipCount = existing.filter((c) => !deleted.has(c.shortcode)).length + uploads.length;
@@ -78,19 +133,47 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
}); });
}; };
const stopPlayback = useCallback(() => {
audioElRef.current?.pause();
audioElRef.current = null;
setPlayingKey(undefined);
}, []);
const preview = useCallback( const preview = useCallback(
async (id: string, mxc: string, volume: number) => { async (id: string, mxc: string, volume: number) => {
// Clicking the clip that's already playing stops it (toggle).
if (audioElRef.current && playingKey === id) {
stopPlayback();
return;
}
stopPlayback(); // stop any other clip first
setBusyPreview(id); setBusyPreview(id);
try { try {
const url = await resolveClipObjectUrl(mx, mxc); const url = await resolveClipObjectUrl(mx, mxc);
playClipLocally(url, volume / 100); const audio = playClipLocally(url, volume / 100);
if (audio) {
audioElRef.current = audio;
setPlayingKey(id);
audio.addEventListener('loadedmetadata', () => {
if (Number.isFinite(audio.duration)) {
setDurations((prev) => new Map(prev).set(id, audio.duration));
}
});
const clear = () => {
if (audioElRef.current === audio) audioElRef.current = null;
setPlayingKey((k) => (k === id ? undefined : k));
};
audio.addEventListener('ended', clear);
audio.addEventListener('pause', clear);
audio.addEventListener('error', clear);
}
} catch { } catch {
/* ignore preview errors */ /* ignore preview errors */
} finally { } finally {
setBusyPreview(undefined); setBusyPreview(undefined);
} }
}, },
[mx], [mx, playingKey, stopPlayback],
); );
const handleFiles = useCallback( const handleFiles = useCallback(
@@ -112,6 +195,8 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
throw new Error(`"${file.name}" is too large (max 1 MB).`); throw new Error(`"${file.name}" is too large (max 1 MB).`);
} }
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const durationMs = await getAudioDurationMs(file);
// eslint-disable-next-line no-await-in-loop
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' }); const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
const mxc = res.content_uri; const mxc = res.content_uri;
if (!mxc) throw new Error('Upload failed.'); if (!mxc) throw new Error('Upload failed.');
@@ -126,7 +211,7 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
body: name, body: name,
emoji: '', emoji: '',
volume: 100, volume: 100,
info: { mimetype: file.type || undefined, size: file.size }, info: { mimetype: file.type || undefined, size: file.size, duration: durationMs },
}, },
]); ]);
} }
@@ -182,6 +267,9 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
setDraft(key, patch, base); setDraft(key, patch, base);
} }
}; };
const isPlaying = playingKey === key;
const clipSeconds =
durations.get(key) ?? (base.info?.duration != null ? base.info.duration / 1000 : undefined);
return ( return (
<Box <Box
key={key} key={key}
@@ -197,12 +285,16 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
<IconButton <IconButton
size="300" size="300"
radii="300" radii="300"
variant="Secondary" variant={isPlaying ? 'Primary' : 'Secondary'}
disabled={busyPreview === key} disabled={busyPreview === key}
onClick={() => preview(key, base.url, rowVolume)} onClick={() => preview(key, base.url, rowVolume)}
aria-label={`Preview ${rowBody}`} aria-label={isPlaying ? `Stop ${rowBody}` : `Preview ${rowBody}`}
> >
{busyPreview === key ? <Spinner size="100" /> : <Icon size="100" src={Icons.Play} />} {busyPreview === key ? (
<Spinner size="100" />
) : (
<Icon size="100" src={isPlaying ? Icons.Pause : Icons.Play} filled={isPlaying} />
)}
</IconButton> </IconButton>
<IconButton <IconButton
size="300" size="300"
@@ -227,7 +319,24 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
aria-label="Clip name" aria-label="Clip name"
/> />
</Box> </Box>
<Box alignItems="Center" gap="100" shrink="No" style={{ width: toRem(120) }}> <Box
alignItems="Center"
justifyContent="End"
gap="100"
shrink="No"
style={{ width: toRem(52) }}
>
{isPlaying ? (
<PlayingBars />
) : (
clipSeconds !== undefined && (
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap' }}>
{formatClipSeconds(clipSeconds)}
</Text>
)
)}
</Box>
<Box alignItems="Center" gap="100" shrink="No" style={{ width: toRem(148) }}>
<Icon size="50" src={Icons.VolumeHigh} /> <Icon size="50" src={Icons.VolumeHigh} />
<input <input
type="range" type="range"
@@ -237,9 +346,12 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
defaultValue={rowVolume} defaultValue={rowVolume}
disabled={!canEdit || markedDeleted} disabled={!canEdit || markedDeleted}
onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })} onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })}
style={{ flexGrow: 1 }} style={{ flexGrow: 1, minWidth: 0 }}
aria-label="Clip volume" aria-label="Clip volume"
/> />
<Text size="T200" priority="300" style={{ width: toRem(30), textAlign: 'right' }}>
{rowVolume}%
</Text>
</Box> </Box>
{canEdit && !isUpload && ( {canEdit && !isUpload && (
<IconButton <IconButton
@@ -308,7 +420,13 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
{existing.map((c) => {existing.map((c) =>
renderRow( renderRow(
c.shortcode, c.shortcode,
{ url: c.url, body: c.body ?? c.shortcode, emoji: c.emoji ?? '', volume: c.volume }, {
url: c.url,
body: c.body ?? c.shortcode,
emoji: c.emoji ?? '',
volume: c.volume,
info: c.info,
},
false, false,
deleted.has(c.shortcode), deleted.has(c.shortcode),
), ),
+31 -1
View File
@@ -1,4 +1,5 @@
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react'; import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
import { useSetAtom } from 'jotai';
import { import {
Box, Box,
Button, Button,
@@ -32,6 +33,7 @@ import {
import { CallEmbed, useCallControlState } from '../../plugins/call'; import { CallEmbed, useCallControlState } from '../../plugins/call';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { callEmbedAtom } from '../../state/callEmbed';
import { useResizeObserver } from '../../hooks/useResizeObserver'; import { useResizeObserver } from '../../hooks/useResizeObserver';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
@@ -48,6 +50,7 @@ type CallControlsProps = {
export function CallControls({ callEmbed }: CallControlsProps) { export function CallControls({ callEmbed }: CallControlsProps) {
const controlRef = useRef<HTMLDivElement>(null); const controlRef = useRef<HTMLDivElement>(null);
const callEmbedRef = useCallEmbedRef(); const callEmbedRef = useCallEmbedRef();
const setCallEmbed = useSetAtom(callEmbedAtom);
const [compact, setCompact] = useState(document.body.clientWidth < 500); const [compact, setCompact] = useState(document.body.clientWidth < 500);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
@@ -175,22 +178,28 @@ export function CallControls({ callEmbed }: CallControlsProps) {
}; };
if (isEditable(target)) return; if (isEditable(target)) return;
e.preventDefault(); e.preventDefault();
// C-M5: mark PTT active BEFORE unmuting so the mic echo (onMediaState)
// doesn't treat this transient unmute as a user-initiated undeafen.
callEmbed.control.pttActive = true;
if (!microphoneRef.current) callEmbed.control.setMicrophone(true); if (!microphoneRef.current) callEmbed.control.setMicrophone(true);
pttActiveRef.current = true; pttActiveRef.current = true;
setPttActive(true); setPttActive(true);
}; };
const onKeyUp = (e: KeyboardEvent) => { const onKeyUp = (e: KeyboardEvent) => {
if (e.code !== pttKey) return; if (e.code !== pttKey) return;
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false); callEmbed.control.setMicrophone(false);
pttActiveRef.current = false; pttActiveRef.current = false;
setPttActive(false); setPttActive(false);
}; };
const onBlur = () => { const onBlur = () => {
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false); callEmbed.control.setMicrophone(false);
pttActiveRef.current = false; pttActiveRef.current = false;
setPttActive(false); setPttActive(false);
}; };
const onFocus = () => { const onFocus = () => {
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false); callEmbed.control.setMicrophone(false);
pttActiveRef.current = false; pttActiveRef.current = false;
setPttActive(false); setPttActive(false);
@@ -215,6 +224,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
iframeWindow?.removeEventListener('focus', onFocus); iframeWindow?.removeEventListener('focus', onFocus);
// BUG-8: if callEmbed changes while PTT is active, release mic on cleanup // BUG-8: if callEmbed changes while PTT is active, release mic on cleanup
if (pttActiveRef.current) { if (pttActiveRef.current) {
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false); callEmbed.control.setMicrophone(false);
pttActiveRef.current = false; pttActiveRef.current = false;
setPttActive(false); setPttActive(false);
@@ -242,8 +252,15 @@ export function CallControls({ callEmbed }: CallControlsProps) {
e.preventDefault(); e.preventDefault();
callEmbed.control.toggleSound(); callEmbed.control.toggleSound();
}; };
// C-L4: also bind the EC iframe window so the deafen key works when focus is
// inside the iframe (mirrors the PTT binding above).
const iframeWindow = callEmbed.iframe.contentWindow;
window.addEventListener('keydown', onKeyDown); window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown); iframeWindow?.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('keydown', onKeyDown);
iframeWindow?.removeEventListener('keydown', onKeyDown);
};
}, [callEmbed, deafenKey]); }, [callEmbed, deafenKey]);
const [hangupState, hangup] = useAsyncCallback( const [hangupState, hangup] = useAsyncCallback(
@@ -252,6 +269,19 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const exiting = const exiting =
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success; hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
// C-M4: the normal teardown relies on EC echoing a Close/Hangup action after
// it ACKs HangupCall (useCallHangupEvent -> clears callEmbedAtom -> dispose).
// If EC ACKs but never echoes, the End button would spin forever. Fall back to
// disposing the embed a few seconds after a successful hangup send, unless it
// was already torn down by the normal path.
useEffect(() => {
if (hangupState.status !== AsyncStatus.Success) return undefined;
const id = setTimeout(() => {
if (!callEmbed.disposed) setCallEmbed(undefined);
}, 4000);
return () => clearTimeout(id);
}, [hangupState.status, callEmbed, setCallEmbed]);
const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', ''); const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', '');
return ( return (
+30 -4
View File
@@ -1,4 +1,4 @@
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react'; import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Box, Box,
Icon, Icon,
@@ -64,6 +64,16 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
const [playingKey, setPlayingKey] = useState<string>(); // host-side spam guard const [playingKey, setPlayingKey] = useState<string>(); // host-side spam guard
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
// C-L6: the play() flow schedules a 30s safety timeout that clears playingKey;
// guard those setState calls against the component unmounting first.
const mountedRef = useRef(true);
useEffect(
() => () => {
mountedRef.current = false;
},
[],
);
const groups = useMemo( const groups = useMemo(
() => () =>
packs packs
@@ -86,7 +96,10 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
if (playingKey) return; // one at a time (fork also enforces this) if (playingKey) return; // one at a time (fork also enforces this)
setPlayingKey(flat.key); setPlayingKey(flat.key);
setError(undefined); setError(undefined);
const done = () => setPlayingKey((k) => (k === flat.key ? undefined : k)); const done = () => {
if (!mountedRef.current) return;
setPlayingKey((k) => (k === flat.key ? undefined : k));
};
try { try {
const url = await resolveClipObjectUrl(mx, flat.clip.url); const url = await resolveClipObjectUrl(mx, flat.clip.url);
const vol = (flat.clip.volume / 100) * master; const vol = (flat.clip.volume / 100) * master;
@@ -183,7 +196,8 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
aria-label={`Play ${clip.name}`} aria-label={`Play ${clip.name}`}
style={{ style={{
width: toRem(76), width: toRem(76),
height: toRem(76), minHeight: toRem(76),
height: 'auto',
padding: config.space.S100, padding: config.space.S100,
borderRadius: config.radii.R400, borderRadius: config.radii.R400,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
@@ -202,7 +216,19 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
clip.emoji || '🔊' clip.emoji || '🔊'
)} )}
</Text> </Text>
<Text size="T200" truncate style={{ maxWidth: '100%' }}> <Text
size="T200"
style={{
maxWidth: '100%',
textAlign: 'center',
wordBreak: 'break-word',
lineHeight: 1.15,
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{clip.name} {clip.name}
</Text> </Text>
</Box> </Box>
@@ -0,0 +1,80 @@
import React, { useCallback } from 'react';
import { Box, Button, color, Spinner, Text } from 'folds';
import { MatrixError } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
import { RetentionContent, RETENTION_PRESETS } from '../../../utils/retention';
type RoomRetentionProps = {
permissions: RoomPermissionsAPI;
};
export function RoomRetention({ permissions }: RoomRetentionProps) {
const mx = useMatrixClient();
const room = useRoom();
const canEdit = permissions.stateEvent(StateEvent.RoomRetention, mx.getSafeUserId());
const event = useStateEvent(room, StateEvent.RoomRetention);
const currentMs = event?.getContent<RetentionContent>().max_lifetime ?? 0;
const [submitState, submit] = useAsyncCallback(
useCallback(
async (ms: number) => {
const content: RetentionContent = ms > 0 ? { max_lifetime: ms } : {};
// Lotus custom-state convention: cast the type key (RoomRetention isn't a
// typed key in the SDK's StateEvents map).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await mx.sendStateEvent(room.roomId, StateEvent.RoomRetention as any, content);
},
[mx, room.roomId],
),
);
const submitting = submitState.status === AsyncStatus.Loading;
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Message Retention"
description="Messages older than this window disappear from the timeline. Each member can opt in to permanently delete their own expired messages in Settings → General; full server-side deletion also requires homeserver retention to be configured."
>
<Box gap="200" alignItems="Center" style={{ flexWrap: 'wrap' }}>
{RETENTION_PRESETS.map((preset) => {
const active = currentMs === preset.ms;
return (
<Button
key={preset.label}
type="button"
size="300"
variant={active ? 'Primary' : 'Secondary'}
fill={active ? 'Solid' : 'Soft'}
radii="300"
disabled={!canEdit || submitting}
onClick={() => submit(preset.ms)}
>
<Text size="B300">{preset.label}</Text>
</Button>
);
})}
{submitting && <Spinner size="100" variant="Secondary" />}
</Box>
{submitState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T200">
{(submitState.error as MatrixError).message}
</Text>
)}
</SettingTile>
</SequenceCard>
);
}
@@ -1,5 +1,6 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { Box, Button, color, config, Icon, Icons, Text } from 'folds'; import { Box, Button, config, Icon, Icons, Text } from 'folds';
import { QRCodeSVG } from 'qrcode.react';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css'; import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
@@ -12,11 +13,9 @@ export function RoomShareInvite() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const room = useRoom(); const room = useRoom();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [qrError, setQrError] = useState(false);
const domain = mx.getDomain() ?? undefined; const domain = mx.getDomain() ?? undefined;
const inviteUrl = getMatrixToRoom(room.roomId, domain ? [domain] : undefined); const inviteUrl = getMatrixToRoom(room.roomId, domain ? [domain] : undefined);
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(inviteUrl)}`;
const handleCopy = useCallback(() => { const handleCopy = useCallback(() => {
navigator.clipboard.writeText(inviteUrl).then(() => { navigator.clipboard.writeText(inviteUrl).then(() => {
@@ -64,35 +63,19 @@ export function RoomShareInvite() {
</Box> </Box>
</Box> </Box>
<Box justifyContent="Center"> <Box justifyContent="Center">
{qrError ? ( {/* Generated locally (qrcode.react) — no third-party service, works
<Box offline + under strict CSP. White padded quiet-zone so the
direction="Column" default black-on-white code scans on any theme. */}
alignItems="Center" <Box
justifyContent="Center" style={{
gap="100" padding: config.space.S200,
style={{ background: '#ffffff',
width: 160, borderRadius: config.radii.R300,
height: 160, lineHeight: 0,
borderRadius: config.radii.R300, }}
background: color.SurfaceVariant.Container, >
}} <QRCodeSVG value={inviteUrl} size={160} level="M" title="Room invite QR code" />
> </Box>
<Icon size="400" src={Icons.Warning} />
<Text size="T200" priority="300" align="Center">
QR code unavailable
</Text>
</Box>
) : (
<img
src={qrSrc}
alt="QR code for room invite link"
width={160}
height={160}
loading="lazy"
onError={() => setQrError(true)}
style={{ display: 'block', borderRadius: config.radii.R300 }}
/>
)}
</Box> </Box>
</Box> </Box>
</CutoutCard> </CutoutCard>
@@ -5,6 +5,7 @@ export * from './RoomJoinRules';
export * from './RoomProfile'; export * from './RoomProfile';
export * from './RoomPublish'; export * from './RoomPublish';
export * from './RoomQuality'; export * from './RoomQuality';
export * from './RoomRetention';
export * from './RoomShareInvite'; export * from './RoomShareInvite';
export * from './RoomUpgrade'; export * from './RoomUpgrade';
export * from './RoomVoiceLimit'; export * from './RoomVoiceLimit';
@@ -5,6 +5,8 @@ import {
Button, Button,
Chip, Chip,
Text, Text,
Icon,
Icons,
RectCords, RectCords,
PopOut, PopOut,
Menu, Menu,
@@ -75,15 +77,16 @@ function PeekPermissions({ powerLevels, power, permissionGroups, children }: Pee
const hasPower = requiredPower <= power; const hasPower = requiredPower <= power;
return ( return (
<Text <Box
key={itemIndex} key={itemIndex}
size="T200" as="span"
style={{ alignItems="Center"
color: hasPower ? undefined : color.Critical.Main, gap="100"
}} style={{ color: hasPower ? undefined : color.Critical.Main }}
> >
{hasPower ? '✅' : '❌'} {item.name} <Icon size="50" src={hasPower ? Icons.Check : Icons.Cross} />
</Text> <Text size="T200">{item.name}</Text>
</Box>
); );
})} })}
</div> </div>
+5 -4
View File
@@ -137,12 +137,13 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
export const getChatBg = ( export const getChatBg = (
bg: ChatBackground, bg: ChatBackground,
isDark: boolean, isDark: boolean,
pauseAnimations?: boolean, // Whether to strip animation (user "pause animations" setting OR OS
// prefers-reduced-motion). Supplied by the caller — e.g. via useReducedMotion —
// so this function stays pure and SSR-safe (no matchMedia read at call time).
suppressAnimation?: boolean,
): CSSProperties => { ): CSSProperties => {
const style = isDark ? DARK[bg] : LIGHT[bg]; const style = isDark ? DARK[bg] : LIGHT[bg];
const reducedMotion = if (suppressAnimation && style.animation) {
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if ((pauseAnimations || reducedMotion) && style.animation) {
const { animation: _anim, ...rest } = style; const { animation: _anim, ...rest } = style;
return rest; return rest;
} }
+85 -19
View File
@@ -1,5 +1,5 @@
import React, { MouseEventHandler, forwardRef, useCallback, useRef, useState } from 'react'; import React, { MouseEventHandler, forwardRef, useCallback, useRef, useState } from 'react';
import { Room } from 'matrix-js-sdk'; import { MatrixClient, Room } from 'matrix-js-sdk';
import { import {
Avatar, Avatar,
Box, Box,
@@ -38,6 +38,7 @@ import { nameInitials } from '../../utils/common';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomUnread } from '../../state/hooks/unread'; import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { markedUnreadAtom, setMarkedUnread } from '../../state/room/markedUnread';
import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels'; import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels';
import { markAsRead } from '../../utils/notifications'; import { markAsRead } from '../../utils/notifications';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
@@ -263,27 +264,46 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
} }
// localStorage key for timed mute timers // localStorage key for timed mute timers
const MUTE_TIMERS_KEY = 'io.lotus.mute_timers'; export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
type MuteTimerEntry = { roomId: string; unmuteAt: number }; // setTimeout's delay is a signed 32-bit int; larger values overflow and fire
// immediately. Clamp long delays to this max (~24.8 days).
export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
function loadMuteTimers(): MuteTimerEntry[] { export type MuteTimerEntry = { roomId: string; unmuteAt: number };
export function loadMuteTimers(): MuteTimerEntry[] {
try { try {
return JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]'); const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
return Array.isArray(parsed) ? parsed : [];
} catch { } catch {
return []; return [];
} }
} }
function saveMuteTimers(timers: MuteTimerEntry[]): void { export function saveMuteTimers(timers: MuteTimerEntry[]): void {
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers)); localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
} }
// Reverse a timed mute: restore the room's notification mode to Unset and drop
// its persisted timer. Shared by the in-session timer and the boot-time restore.
export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void> {
const { setRoomNotificationPreference } =
await import('../../hooks/useRoomsNotificationPreferences');
await setRoomNotificationPreference(
mx,
roomId,
RoomNotificationMode.Unset,
RoomNotificationMode.Mute,
).catch(() => {});
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
}
function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void { function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
const unmuteAt = Date.now() + durationMs; const unmuteAt = Date.now() + durationMs;
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId); const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
saveMuteTimers([...existing, { roomId, unmuteAt }]); saveMuteTimers([...existing, { roomId, unmuteAt }]);
setTimeout(onUnmute, durationMs); setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
} }
type RoomNavItemMenuProps = { type RoomNavItemMenuProps = {
@@ -310,18 +330,39 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const isServerNotice = room.getType() === 'm.server_notice'; const isServerNotice = room.getType() === 'm.server_notice';
const isFavorite = !!room.tags?.['m.favourite']; const isFavorite = !!room.tags?.['m.favourite'];
const isLowPriority = !!room.tags?.['m.lowpriority'];
const handleToggleFavorite = () => { const handleToggleFavorite = () => {
if (isFavorite) { if (isFavorite) {
mx.deleteRoomTag(room.roomId, 'm.favourite'); mx.deleteRoomTag(room.roomId, 'm.favourite');
} else { } else {
// Favourite and low-priority are mutually exclusive.
if (isLowPriority) mx.deleteRoomTag(room.roomId, 'm.lowpriority');
mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 }); mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 });
} }
requestClose(); requestClose();
}; };
const handleToggleLowPriority = () => {
if (isLowPriority) {
mx.deleteRoomTag(room.roomId, 'm.lowpriority');
} else {
if (isFavorite) mx.deleteRoomTag(room.roomId, 'm.favourite');
mx.setRoomTag(room.roomId, 'm.lowpriority', { order: 0.5 });
}
requestClose();
};
const markedUnread = useAtomValue(markedUnreadAtom).has(room.roomId);
const handleMarkAsRead = () => { const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity); markAsRead(mx, room.roomId, hideActivity);
if (markedUnread) setMarkedUnread(mx, room.roomId, false).catch(() => undefined);
requestClose();
};
const handleMarkAsUnread = () => {
setMarkedUnread(mx, room.roomId, true).catch(() => undefined);
requestClose(); requestClose();
}; };
@@ -338,13 +379,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
).catch(() => {}); ).catch(() => {});
if (durationMs !== null) { if (durationMs !== null) {
scheduleMuteTimer(room.roomId, durationMs, () => { scheduleMuteTimer(room.roomId, durationMs, () => {
setRoomNotificationPreference( unmuteRoom(mx, room.roomId);
mx,
room.roomId,
RoomNotificationMode.Unset,
RoomNotificationMode.Mute,
).catch(() => {});
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== room.roomId));
}); });
} }
requestClose(); requestClose();
@@ -380,12 +415,23 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
size="300" size="300"
after={<Icon size="100" src={Icons.CheckTwice} />} after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300" radii="300"
disabled={!unread} disabled={!unread && !markedUnread}
> >
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate> <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read Mark as Read
</Text> </Text>
</MenuItem> </MenuItem>
<MenuItem
onClick={handleMarkAsUnread}
size="300"
after={<Icon size="100" src={Icons.MessageUnread} />}
radii="300"
disabled={!!unread || markedUnread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Unread
</Text>
</MenuItem>
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}> <RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
{(handleOpen, opened, changing) => ( {(handleOpen, opened, changing) => (
<MenuItem <MenuItem
@@ -480,6 +526,17 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
{isFavorite ? 'Remove from Favorites' : 'Add to Favorites'} {isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}
</Text> </Text>
</MenuItem> </MenuItem>
<MenuItem
onClick={handleToggleLowPriority}
size="300"
after={<Icon size="100" src={Icons.ChevronBottom} />}
radii="300"
aria-pressed={isLowPriority}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{isLowPriority ? 'Remove from Low Priority' : 'Add to Low Priority'}
</Text>
</MenuItem>
<MenuItem <MenuItem
onClick={handleInvite} onClick={handleInvite}
variant="Primary" variant="Primary"
@@ -597,6 +654,10 @@ function RoomNavItem_({
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [renameDialog, setRenameDialog] = useState(false); const [renameDialog, setRenameDialog] = useState(false);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
// MSC2867: an explicit "mark as unread" lights the row even with no unread
// count. `hasUnread` drives the bold name / icon emphasis below.
const markedUnread = useAtomValue(markedUnreadAtom).has(room.roomId);
const hasUnread = !!unread || markedUnread;
const typingMember = useRoomTypingMember(room.roomId).filter( const typingMember = useRoomTypingMember(room.roomId).filter(
(receipt) => receipt.userId !== mx.getUserId(), (receipt) => receipt.userId !== mx.getUserId(),
); );
@@ -679,7 +740,7 @@ function RoomNavItem_({
<NavItem <NavItem
variant="Background" variant="Background"
radii="400" radii="400"
highlight={unread !== undefined} highlight={hasUnread}
aria-selected={selected} aria-selected={selected}
data-hover={!!menuAnchor} data-hover={!!menuAnchor}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
@@ -708,7 +769,7 @@ function RoomNavItem_({
) : ( ) : (
<RoomIcon <RoomIcon
style={{ style={{
opacity: unread ? config.opacity.P500 : config.opacity.P300, opacity: hasUnread ? config.opacity.P500 : config.opacity.P300,
}} }}
filled={selected} filled={selected}
size="100" size="100"
@@ -719,7 +780,7 @@ function RoomNavItem_({
</Avatar> </Avatar>
<Box as="span" direction="Column" grow="Yes" style={{ minWidth: 0 }}> <Box as="span" direction="Column" grow="Yes" style={{ minWidth: 0 }}>
<Box as="span" grow="Yes" alignItems="Center" gap="100"> <Box as="span" grow="Yes" alignItems="Center" gap="100">
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate> <Text priority={hasUnread ? '500' : '300'} as="span" size="Inherit" truncate>
{roomName} {roomName}
</Text> </Text>
{hasLocalName && ( {hasLocalName && (
@@ -760,7 +821,7 @@ function RoomNavItem_({
</Box> </Box>
)} )}
</Box> </Box>
{!optionsVisible && !unread && !selected && typingMember.length > 0 && ( {!optionsVisible && !hasUnread && !selected && typingMember.length > 0 && (
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined> <Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
<TypingIndicator size="300" disableAnimation /> <TypingIndicator size="300" disableAnimation />
</Badge> </Badge>
@@ -770,6 +831,11 @@ function RoomNavItem_({
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} /> <UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
</UnreadBadgeCenter> </UnreadBadgeCenter>
)} )}
{!optionsVisible && !unread && markedUnread && (
<UnreadBadgeCenter>
<UnreadBadge highlight={false} count={0} />
</UnreadBadgeCenter>
)}
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && ( {!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
<Icon <Icon
size="50" size="50"
@@ -1,5 +1,5 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Spinner, Text } from 'folds'; import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Text } from 'folds';
import { EventType } from 'matrix-js-sdk'; import { EventType } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../components/page'; import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -16,6 +16,12 @@ const FORMAT_LABELS: Record<ExportFormat, string> = {
html: 'HTML', html: 'HTML',
}; };
const PAGE_LIMIT = 100;
// Hard cap on back-pagination requests. Without a fromDate, "export all" would
// otherwise decrypt and hold every message in the room, hammering the server and
// risking an OOM/freeze with no way to stop. 200 pages × 100 ≈ 20,000 events.
const MAX_EXPORT_PAGES = 200;
type ExportRoomHistoryProps = { type ExportRoomHistoryProps = {
requestClose: () => void; requestClose: () => void;
}; };
@@ -30,11 +36,28 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
const [toDate, setToDate] = useState(''); const [toDate, setToDate] = useState('');
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [exportCount, setExportCount] = useState(0); const [exportCount, setExportCount] = useState(0);
const [notice, setNotice] = useState('');
const cancelledRef = useRef(false);
const handleCancel = useCallback(() => {
cancelledRef.current = true;
}, []);
// Stop an in-flight export if the panel unmounts (closing settings mid-export
// would otherwise keep paginating + decrypting in the background).
useEffect(
() => () => {
cancelledRef.current = true;
},
[],
);
const handleExport = useCallback(async () => { const handleExport = useCallback(async () => {
if (exporting) return; if (exporting) return;
cancelledRef.current = false;
setExporting(true); setExporting(true);
setExportCount(0); setExportCount(0);
setNotice('');
try { try {
const fromTs = fromDate ? new Date(`${fromDate}T00:00:00`).getTime() : null; const fromTs = fromDate ? new Date(`${fromDate}T00:00:00`).getTime() : null;
@@ -55,6 +78,14 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
const seen = new Set<string>(); const seen = new Set<string>();
const timeline = room.getLiveTimeline(); const timeline = room.getLiveTimeline();
let canLoadMore = true; let canLoadMore = true;
// Track the oldest collected timestamp incrementally so the fromTs check
// doesn't rescan the whole `collected` array on every pagination step.
let oldestTs = Number.POSITIVE_INFINITY;
// Oldest RAW message ts paginated (tracked BEFORE the fromTs filter). The
// date-range early-break must use this — oldestTs only ever holds collected
// events (all >= fromTs), so it can never fall below fromTs and the export
// would over-paginate to the page cap and show a misleading "truncated".
let oldestRawTs = Number.POSITIVE_INFINITY;
const addEvents = async (events: ReturnType<typeof timeline.getEvents>) => { const addEvents = async (events: ReturnType<typeof timeline.getEvents>) => {
for (const ev of events) { for (const ev of events) {
@@ -70,12 +101,14 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
if (ev.getType() !== EventType.RoomMessage) continue; if (ev.getType() !== EventType.RoomMessage) continue;
if (ev.isDecryptionFailure()) continue; if (ev.isDecryptionFailure()) continue;
const ts = ev.getTs(); const ts = ev.getTs();
if (ts < oldestRawTs) oldestRawTs = ts;
if (fromTs !== null && ts < fromTs) continue; if (fromTs !== null && ts < fromTs) continue;
if (toTs !== null && ts > toTs) continue; if (toTs !== null && ts > toTs) continue;
const content = ev.getContent(); const content = ev.getContent();
const body: string = content.body ?? ''; const body: string = content.body ?? '';
const msgtype: string = content.msgtype ?? ''; const msgtype: string = content.msgtype ?? '';
if (!body) continue; if (!body) continue;
if (ts < oldestTs) oldestTs = ts;
collected.push({ collected.push({
ts, ts,
sender: ev.getSender() ?? '', sender: ev.getSender() ?? '',
@@ -89,25 +122,40 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
await addEvents(timeline.getEvents()); await addEvents(timeline.getEvents());
// Paginate backwards until start or date range exceeded // Paginate backwards until start, date range exceeded, cap hit, or cancel
let pageCount = 0;
let truncated = false;
let cancelled = false;
while (canLoadMore) { while (canLoadMore) {
// If we have a fromTs, check whether the oldest collected event is already if (cancelledRef.current) {
// before it — if so we don't need to paginate further. cancelled = true;
if (fromTs !== null && collected.length > 0) { break;
const oldestTs = Math.min(...collected.map((r) => r.ts));
if (oldestTs < fromTs) break;
} }
// If we've paginated back past the fromTs boundary, there's nothing more
// in range to fetch (use the raw paginated ts, not the collected one).
if (fromTs !== null && oldestRawTs < fromTs) break;
// Hard cap so "export all" can't run away and OOM the tab.
if (pageCount >= MAX_EXPORT_PAGES) {
truncated = true;
break;
}
pageCount += 1;
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
canLoadMore = await mx.paginateEventTimeline(timeline, { canLoadMore = await mx.paginateEventTimeline(timeline, {
backwards: true, backwards: true,
limit: 100, limit: PAGE_LIMIT,
}); });
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await addEvents(timeline.getEvents()); await addEvents(timeline.getEvents());
} }
if (cancelled) {
setNotice(`Export cancelled after ${collected.length} messages.`);
return;
}
// Sort chronologically (oldest first) // Sort chronologically (oldest first)
collected.sort((a, b) => a.ts - b.ts); collected.sort((a, b) => a.ts - b.ts);
@@ -191,6 +239,12 @@ ${msgRows}
a.download = `export-${safeRoomName}-${dateStr}.${ext}`; a.download = `export-${safeRoomName}-${dateStr}.${ext}`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
if (truncated) {
setNotice(
`Export truncated to ${collected.length} messages (reached the ${MAX_EXPORT_PAGES}-page limit). Narrow the date range to export older history.`,
);
}
} finally { } finally {
setExporting(false); setExporting(false);
} }
@@ -297,24 +351,35 @@ ${msgRows}
? `Exporting… ${exportCount} messages` ? `Exporting… ${exportCount} messages`
: 'Export will download automatically.'} : 'Export will download automatically.'}
</Text> </Text>
<Button {exporting ? (
size="400" <Button
variant="Primary" size="400"
fill="Solid" variant="Critical"
radii="300" fill="Soft"
disabled={exporting} radii="300"
onClick={handleExport} onClick={handleCancel}
before={ before={<Icon src={Icons.Cross} size="100" />}
exporting ? ( >
<Spinner size="200" /> <Text size="B400">Cancel</Text>
) : ( </Button>
<Icon src={Icons.Download} size="100" /> ) : (
) <Button
} size="400"
> variant="Primary"
<Text size="B400">{exporting ? 'Exporting…' : 'Export'}</Text> fill="Solid"
</Button> radii="300"
onClick={handleExport}
before={<Icon src={Icons.Download} size="100" />}
>
<Text size="B400">Export</Text>
</Button>
)}
</Box> </Box>
{notice && (
<Text size="T200" priority="400">
{notice}
</Text>
)}
</SequenceCard> </SequenceCard>
</Box> </Box>
</Box> </Box>
@@ -46,7 +46,9 @@ function isGlob(entity: string): boolean {
} }
function recommendationLabel(rec: string): string { function recommendationLabel(rec: string): string {
if (rec === 'm.ban') return 'Ban'; // `m.ban` is the stable value; `org.matrix.mjolnir.ban` is the legacy
// (pre-stabilization) recommendation still emitted by older bots.
if (rec === 'm.ban' || rec === 'org.matrix.mjolnir.ban') return 'Ban';
return rec; return rec;
} }
@@ -103,9 +105,11 @@ function PolicyEntryRow({ entry }: { entry: PolicyEntry }) {
<Text size="T200">glob</Text> <Text size="T200">glob</Text>
</Badge> </Badge>
)} )}
<Badge variant="Critical" fill="Soft" radii="Pill"> {entry.recommendation && (
<Text size="T200">{recommendationLabel(entry.recommendation)}</Text> <Badge variant="Critical" fill="Soft" radii="Pill">
</Badge> <Text size="T200">{recommendationLabel(entry.recommendation)}</Text>
</Badge>
)}
</Box> </Box>
{entry.reason && ( {entry.reason && (
<Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}> <Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}>
@@ -67,6 +67,7 @@ function describeEvent(mx: ReturnType<typeof useMatrixClient>, ev: MatrixEvent):
if (membership === 'join') { if (membership === 'join') {
if ( if (
prevMembership === 'invite' || prevMembership === 'invite' ||
prevMembership === 'knock' ||
prevMembership === undefined || prevMembership === undefined ||
prevMembership === null prevMembership === null
) { ) {
@@ -115,6 +116,19 @@ function describeEvent(mx: ReturnType<typeof useMatrixClient>, ev: MatrixEvent):
filter: 'members', filter: 'members',
}; };
} }
// sender !== stateKey and the target was only invited → the inviter (or a
// moderator) retracted the invite; this is not a kick.
if (prevMembership === 'invite') {
return {
text: (
<>
<strong>{senderName}</strong> withdrew the invite to <strong>{targetName}</strong>
</>
),
iconSrc: Icons.User,
filter: 'members',
};
}
return { return {
text: ( text: (
<> <>
@@ -115,10 +115,16 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
const totalMessages = [...msgCounts.values()].reduce((a, b) => a + b, 0); const totalMessages = [...msgCounts.values()].reduce((a, b) => a + b, 0);
const uniqueParticipants = msgCounts.size; const uniqueParticipants = msgCounts.size;
const msgEvents = events.filter((ev) => ev.getType() === EventType.RoomMessage); // Single-pass min/max — `Math.min(...allTs)` spreads one arg per message and
const allTs = msgEvents.map((ev) => ev.getTs()); // overflows the call stack (RangeError) on a large paginated timeline.
const oldestTs = allTs.length > 0 ? Math.min(...allTs) : null; let oldestTs: number | null = null;
const newestTs = allTs.length > 0 ? Math.max(...allTs) : null; let newestTs: number | null = null;
for (const ev of events) {
if (ev.getType() !== EventType.RoomMessage) continue;
const ts = ev.getTs();
if (oldestTs === null || ts < oldestTs) oldestTs = ts;
if (newestTs === null || ts > newestTs) newestTs = ts;
}
return { return {
top5, top5,
+170 -15
View File
@@ -3,16 +3,22 @@ import {
Box, Box,
Button, Button,
Checkbox, Checkbox,
Dialog,
Header,
Icon, Icon,
IconButton, IconButton,
Icons, Icons,
Input, Input,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll, Scroll,
Spinner, Spinner,
Text, Text,
color, color,
config, config,
} from 'folds'; } from 'folds';
import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../components/page'; import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoom } from '../../hooks/useRoom'; import { useRoom } from '../../hooks/useRoom';
@@ -24,6 +30,8 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { SequenceCard } from '../../components/sequence-card'; import { SequenceCard } from '../../components/sequence-card';
import { SequenceCardStyle } from '../common-settings/styles.css'; import { SequenceCardStyle } from '../common-settings/styles.css';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
@@ -42,20 +50,52 @@ const DEFAULT_ACL: ServerAclContent = {
// ── Validation ──────────────────────────────────────────────────────────────── // ── Validation ────────────────────────────────────────────────────────────────
/** /**
* Validate a server name or wildcard pattern. * Validate a server-name glob for an ACL entry.
* Allowed forms: *
* - plain hostname / IP: letters, digits, hyphens, dots * Matrix ACL `allow`/`deny` entries are globs where `*` (any run of chars) and
* - wildcard prefix: *.example.com (asterisk only at the very start) * `?` (single char) may appear ANYWHERE — e.g. `*`, `*.example.com`,
* The Matrix spec allows `*` on its own (match-all wildcard). * `1.2.3.*`, `10.0.0.?`, `*.evil.*`, `*bad*`. We therefore validate the *glob*
* rather than a concrete hostname:
* - reject empty / whitespace-only
* - allow only hostname/IP chars plus the wildcards `*` and `?`
* (letters, digits, dots, hyphens, colons for ports/IPv6 — NO underscore)
* - reject consecutive/leading/trailing dots (`...`, `.foo`, `foo.`)
* - reject entries with no alphanumeric or wildcard char (bare `-`, lone `:`)
*/ */
function isValidServerPattern(value: string): boolean { function isValidServerPattern(value: string): boolean {
if (value === '*') return true; const v = value.trim();
// Strip leading wildcard if (!v) return false;
const rest = value.startsWith('*.') ? value.slice(2) : value; // Only hostname/IP glob chars — wildcards may appear at any position.
// Must not be empty after stripping wildcard if (!/^[A-Za-z0-9.:*?-]+$/.test(v)) return false;
if (!rest) return false; // Structural rules for the dotted parts.
// Remaining part: only letters, digits, dots, hyphens, colons (for IPv6/ports) if (v.startsWith('.') || v.endsWith('.') || v.includes('..')) return false;
return /^[A-Za-z0-9.:_-]+$/.test(rest); // Must carry actual signal — reject pure punctuation like `-`, `:` or `-.-`.
if (!/[A-Za-z0-9*?]/.test(v)) return false;
return true;
}
/**
* Convert an ACL glob (`*` = any run, `?` = single char) to an anchored RegExp,
* escaping every other regex metacharacter. Used only for local self-ban
* detection — never sent to the server.
*/
function globToRegExp(glob: string): RegExp {
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
// Case-INsensitive: Synapse's glob_to_regex uses IGNORECASE and hostnames are
// case-insensitive, so a deny like `MATRIX.foo.org` must still be detected as
// self-banning `matrix.foo.org` (otherwise the warning is a false negative).
return new RegExp(`^${pattern}$`, 'i');
}
function matchesAnyGlob(domain: string, globs: string[]): boolean {
return globs.some((glob) => {
try {
return globToRegExp(glob).test(domain);
} catch {
return false;
}
});
} }
// ── Server list sub-component ───────────────────────────────────────────────── // ── Server list sub-component ─────────────────────────────────────────────────
@@ -78,7 +118,7 @@ function ServerList({ label, entries, canEdit, onAdd, onRemove }: ServerListProp
if (!value) return; if (!value) return;
if (!isValidServerPattern(value)) { if (!isValidServerPattern(value)) {
setError('Invalid server pattern. Use a hostname or *.example.com'); setError('Invalid pattern. Use a hostname, IP, or glob (e.g. *.evil.com, 1.2.3.*, 10.0.0.?)');
return; return;
} }
setError(undefined); setError(undefined);
@@ -181,6 +221,7 @@ type RoomServerACLProps = {
export function RoomServerACL({ requestClose }: RoomServerACLProps) { export function RoomServerACL({ requestClose }: RoomServerACLProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const room = useRoom(); const room = useRoom();
const modalStyle = useModalStyle(480);
// Power level checks // Power level checks
const powerLevels = usePowerLevels(room); const powerLevels = usePowerLevels(room);
@@ -221,6 +262,26 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
const saveError = const saveError =
saveState.status === AsyncStatus.Error ? 'Failed to save ACL. Please try again.' : undefined; saveState.status === AsyncStatus.Error ? 'Failed to save ACL. Please try again.' : undefined;
// ── Save guards ───────────────────────────────────────────────────────────
// #1 Empty allow list denies EVERY server (allow: [] is not "allow all") and
// partitions the room from all federation irreversibly — block the save.
const emptyAllow = allowList.length === 0;
// #2 Self-ban: the local homeserver must match at least one allow glob and no
// deny glob, otherwise applying this ACL removes our own server from the room.
const localDomain = mx.getDomain() ?? '';
const selfBanned =
localDomain.length > 0 &&
(!matchesAnyGlob(localDomain, allowList) || matchesAnyGlob(localDomain, denyList));
// #4 Gate the destructive write behind a confirmation dialog.
const [prompt, setPrompt] = useState(false);
const handleConfirmSave = () => {
setPrompt(false);
save();
};
// Required power level for this state event // Required power level for this state event
const requiredPL = readPowerLevel.state(powerLevels, StateEvent.RoomServerAcl); const requiredPL = readPowerLevel.state(powerLevels, StateEvent.RoomServerAcl);
const myPL = readPowerLevel.user(powerLevels, myUserId); const myPL = readPowerLevel.user(powerLevels, myUserId);
@@ -242,8 +303,8 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
variant="Primary" variant="Primary"
fill="Solid" fill="Solid"
radii="300" radii="300"
disabled={saving || !isDirty} disabled={saving || !isDirty || emptyAllow}
onClick={() => save()} onClick={() => setPrompt(true)}
before={saving ? <Spinner size="200" /> : <Icon src={Icons.Check} size="100" />} before={saving ? <Spinner size="200" /> : <Icon src={Icons.Check} size="100" />}
> >
<Text size="B400">{saving ? 'Saving…' : 'Save Changes'}</Text> <Text size="B400">{saving ? 'Saving…' : 'Save Changes'}</Text>
@@ -290,6 +351,24 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
</Text> </Text>
)} )}
{/* #1 Empty allow list guard — blocks save */}
{canEdit && emptyAllow && (
<Text size="T300" style={{ color: color.Critical.Main }}>
The allow list is empty. An empty allow list denies every server and partitions
this room from all federation permanently. Add at least one entry (use
&quot;*&quot; to allow all servers).
</Text>
)}
{/* #2 Self-ban warning — save allowed but confirmation required */}
{canEdit && !emptyAllow && selfBanned && (
<Text size="T300" style={{ color: color.Warning.Main }}>
Warning: your own homeserver ({localDomain}) is not permitted by this ACL.
Applying it will remove your server from the room and you may lose the ability to
moderate it.
</Text>
)}
{/* Allow IP literals toggle */} {/* Allow IP literals toggle */}
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">IP Address Access</Text> <Text size="L400">IP Address Access</Text>
@@ -352,6 +431,82 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
</PageContent> </PageContent>
</Scroll> </Scroll>
</Box> </Box>
{/* #4 Confirmation dialog — surfaces the empty-allow (#1) and self-ban (#2)
warnings and keeps a safe save one extra click. */}
{prompt && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setPrompt(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog
variant="Surface"
aria-labelledby="server-acl-confirm-title"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4" id="server-acl-confirm-title">
Apply Server ACL
</Text>
</Box>
<IconButton
size="300"
onClick={() => setPrompt(false)}
radii="300"
aria-label="Cancel"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text priority="400">
Server ACL changes take effect immediately and control which servers can
participate in this room. This cannot be undone by other servers once they are
removed.
</Text>
{emptyAllow && (
<Text size="T300" style={{ color: color.Critical.Main }}>
The allow list is empty this would deny every server and partition the
room from all federation permanently.
</Text>
)}
{!emptyAllow && selfBanned && (
<Text size="T300" style={{ color: color.Warning.Main }}>
Warning: your own homeserver ({localDomain}) is not permitted by this ACL.
Applying it will remove your server from the room and you may lose the
ability to moderate it.
</Text>
)}
</Box>
<Button
type="submit"
variant={selfBanned ? 'Critical' : 'Primary'}
onClick={handleConfirmSave}
disabled={emptyAllow}
>
<Text size="B400">Apply ACL</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
</Page> </Page>
); );
} }
@@ -12,6 +12,7 @@ import {
RoomPublishedAddresses, RoomPublishedAddresses,
RoomPublish, RoomPublish,
RoomQuality, RoomQuality,
RoomRetention,
RoomShareInvite, RoomShareInvite,
RoomUpgrade, RoomUpgrade,
RoomVoiceLimit, RoomVoiceLimit,
@@ -56,6 +57,10 @@ export function General({ requestClose }: GeneralProps) {
<RoomEncryption permissions={permissions} /> <RoomEncryption permissions={permissions} />
<RoomPublish permissions={permissions} /> <RoomPublish permissions={permissions} />
</Box> </Box>
<Box direction="Column" gap="100">
<Text size="L400">Message Retention</Text>
<RoomRetention permissions={permissions} />
</Box>
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Voice</Text> <Text size="L400">Voice</Text>
<RoomVoiceLimit permissions={permissions} /> <RoomVoiceLimit permissions={permissions} />
+17 -4
View File
@@ -133,6 +133,18 @@ function getSenderName(room: Room, userId: string): string {
return room.getMember(userId)?.name ?? userId.split(':')[0]?.slice(1) ?? userId; return room.getMember(userId)?.name ?? userId.split(':')[0]?.slice(1) ?? userId;
} }
// Resolve the thumbnail/display MXC for an image/video event, mirroring the
// grid's preference order (encrypted thumb > file > thumbnail_url > url). Both
// the grid and the lightbox must use this so their positional indices stay in
// lockstep — otherwise a tile skipped for lack of a thumb would shift the
// lightbox and open the wrong media.
function getThumbMxc(mEvent: MatrixEvent): string | undefined {
const c = mEvent.getContent();
const isEnc = !!c.file;
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
return isEnc ? (info?.thumbnail_file?.url ?? c.file?.url) : (info?.thumbnail_url ?? c.url);
}
// ── Lightbox ────────────────────────────────────────────────────────────────── // ── Lightbox ──────────────────────────────────────────────────────────────────
type LightboxItem = { type LightboxItem = {
@@ -585,7 +597,10 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const lightboxItems: LightboxItem[] = events const lightboxItems: LightboxItem[] = events
.filter((ev) => { .filter((ev) => {
const c = ev.getContent(); const c = ev.getContent();
return c.msgtype === MsgType.Image || c.msgtype === MsgType.Video; if (c.msgtype !== MsgType.Image && c.msgtype !== MsgType.Video) return false;
// Match the grid's guard exactly: tiles without a thumb are not rendered,
// so they must not occupy a lightbox slot either.
return !!getThumbMxc(ev);
}) })
.map((ev) => { .map((ev) => {
const c = ev.getContent(); const c = ev.getContent();
@@ -712,9 +727,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const info: (IImageInfo & IThumbnailContent) | undefined = c.info; const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
// Prefer thumbnail_file (encrypted thumb) > file > thumbnail_url > url // Prefer thumbnail_file (encrypted thumb) > file > thumbnail_url > url
const thumbMxc: string | undefined = isEnc const thumbMxc: string | undefined = getThumbMxc(mEvent);
? (info?.thumbnail_file?.url ?? c.file?.url)
: (info?.thumbnail_url ?? c.url);
const thumbEnc: IEncryptedFile | undefined = isEnc const thumbEnc: IEncryptedFile | undefined = isEnc
? (info?.thumbnail_file ?? c.file) ? (info?.thumbnail_file ?? c.file)
: undefined; : undefined;
+37 -11
View File
@@ -7,6 +7,8 @@ import { RoomView } from './RoomView';
import { MembersDrawer } from './MembersDrawer'; import { MembersDrawer } from './MembersDrawer';
import { MediaGallery } from './MediaGallery'; import { MediaGallery } from './MediaGallery';
import { mediaGalleryAtom } from '../../state/mediaGallery'; import { mediaGalleryAtom } from '../../state/mediaGallery';
import { WidgetsPanel } from './widgets/WidgetsPanel';
import { widgetsPanelAtom } from '../../state/widgetsPanel';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
@@ -39,6 +41,8 @@ export function Room() {
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId)); const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId));
const galleryOpen = useAtomValue(mediaGalleryAtom); const galleryOpen = useAtomValue(mediaGalleryAtom);
const setGalleryOpen = useSetAtom(mediaGalleryAtom); const setGalleryOpen = useSetAtom(mediaGalleryAtom);
const widgetsOpen = useAtomValue(widgetsPanelAtom);
const setWidgetsOpen = useSetAtom(widgetsPanelAtom);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const powerLevels = usePowerLevels(room); const powerLevels = usePowerLevels(room);
@@ -64,30 +68,40 @@ export function Room() {
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0; const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
// Thread panel and media gallery are mutually exclusive on every screen size: // The content panels (thread / media gallery / widgets) are mutually exclusive
// opening one closes the other. Detect the just-opened transition so whichever // on every screen size: opening one closes the others. Detect the just-opened
// was opened most recently wins. // transition so whichever was opened most recently wins.
const prevThreadRef = useRef(activeThreadId); const prevThreadRef = useRef(activeThreadId);
const prevGalleryRef = useRef(galleryOpen); const prevGalleryRef = useRef(galleryOpen);
const prevWidgetsRef = useRef(widgetsOpen);
useEffect(() => { useEffect(() => {
const threadJustOpened = Boolean(activeThreadId) && !prevThreadRef.current; const threadJustOpened = Boolean(activeThreadId) && !prevThreadRef.current;
const galleryJustOpened = galleryOpen && !prevGalleryRef.current; const galleryJustOpened = galleryOpen && !prevGalleryRef.current;
if (threadJustOpened && galleryOpen) { const widgetsJustOpened = widgetsOpen && !prevWidgetsRef.current;
setGalleryOpen(false); if (threadJustOpened) {
} else if (galleryJustOpened && activeThreadId) { if (galleryOpen) setGalleryOpen(false);
setActiveThreadId(null); if (widgetsOpen) setWidgetsOpen(false);
} else if (galleryJustOpened) {
if (activeThreadId) setActiveThreadId(null);
if (widgetsOpen) setWidgetsOpen(false);
} else if (widgetsJustOpened) {
if (activeThreadId) setActiveThreadId(null);
if (galleryOpen) setGalleryOpen(false);
} }
prevThreadRef.current = activeThreadId; prevThreadRef.current = activeThreadId;
prevGalleryRef.current = galleryOpen; prevGalleryRef.current = galleryOpen;
}, [activeThreadId, galleryOpen, setGalleryOpen, setActiveThreadId]); prevWidgetsRef.current = widgetsOpen;
}, [activeThreadId, galleryOpen, widgetsOpen, setGalleryOpen, setActiveThreadId, setWidgetsOpen]);
// On non-desktop screens at most one right-side panel may show, priority // On non-desktop screens at most one right-side panel may show, priority
// thread > gallery > members. On desktop thread + members may coexist while // thread > gallery > widgets > members. On desktop thread + members may coexist
// thread + gallery stay mutually exclusive (via the effect above). // while the content panels stay mutually exclusive (via the effect above).
const isDesktop = screenSize === ScreenSize.Desktop; const isDesktop = screenSize === ScreenSize.Desktop;
const showThreadPanel = !callView && Boolean(activeThreadId); const showThreadPanel = !callView && Boolean(activeThreadId);
const showGallery = !callView && galleryOpen && (isDesktop || !activeThreadId); const showGallery = !callView && galleryOpen && (isDesktop || !activeThreadId);
const showMembers = !callView && isDrawer && (isDesktop || (!activeThreadId && !galleryOpen)); const showWidgets = !callView && widgetsOpen && (isDesktop || (!activeThreadId && !galleryOpen));
const showMembers =
!callView && isDrawer && (isDesktop || (!activeThreadId && !galleryOpen && !widgetsOpen));
return ( return (
<PowerLevelsContextProvider value={powerLevels}> <PowerLevelsContextProvider value={powerLevels}>
@@ -125,6 +139,18 @@ export function Room() {
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} /> <MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
</> </>
)} )}
{showWidgets && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<WidgetsPanel
key={room.roomId}
room={room}
requestClose={() => setWidgetsOpen(false)}
/>
</>
)}
{showThreadPanel && activeThreadId && ( {showThreadPanel && activeThreadId && (
<> <>
{screenSize === ScreenSize.Desktop && ( {screenSize === ScreenSize.Desktop && (
+10 -4
View File
@@ -456,12 +456,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (compressionResult) { if (compressionResult) {
const originalFile = fileItem.originalFile as File; const originalFile = fileItem.originalFile as File;
const compressedFile = new File([compressionResult.blob], originalFile.name, { // compressImage re-encodes as JPEG; swap the extension so the file
type: 'image/jpeg', // name and MIME type agree (avoids e.g. a JPEG named "photo.png").
const compressedType = compressionResult.type;
const compressedName = `${originalFile.name.replace(/\.[^./\\]+$/, '')}.jpg`;
const compressedFile = new File([compressionResult.blob], compressedName, {
type: compressedType,
}); });
const uploadRes = await mx.uploadContent(compressedFile, { const uploadRes = await mx.uploadContent(compressedFile, {
name: originalFile.name, name: compressedName,
type: 'image/jpeg', type: compressedType,
}); });
const compressedMxc = (uploadRes as { content_uri: string }).content_uri; const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
if (compressedMxc) { if (compressedMxc) {
@@ -538,6 +542,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
} }
resetEditor(editor); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
setCharCount(0);
sendTypingStatus(false); sendTypingStatus(false);
return; return;
} }
@@ -579,6 +584,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
mx.sendMessage(roomId, threadRootId ?? null, content as any); mx.sendMessage(roomId, threadRootId ?? null, content as any);
resetEditor(editor); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
setCharCount(0);
localStorage.removeItem(`draft-msg-${draftKey}`); localStorage.removeItem(`draft-msg-${draftKey}`);
setReplyDraft(undefined); setReplyDraft(undefined);
sendTypingStatus(false); sendTypingStatus(false);
+12
View File
@@ -109,6 +109,8 @@ import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
import { ThreadSummary } from './thread/ThreadSummary'; import { ThreadSummary } from './thread/ThreadSummary';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room'; import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
import { useStateEvent } from '../../hooks/useStateEvent';
import { RetentionContent, isExpired } from '../../utils/retention';
import { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange'; import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
import { RenderMessageContent } from '../../components/RenderMessageContent'; import { RenderMessageContent } from '../../components/RenderMessageContent';
@@ -468,6 +470,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
// MSC1763 retention: messages older than this window are hidden from the
// timeline (unless "show hidden events" is on). Reactive so a policy change
// re-renders. `undefined` = no policy.
const retentionEvent = useStateEvent(room, StateEvent.RoomRetention);
const retentionMs = retentionEvent?.getContent<RetentionContent>().max_lifetime;
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal'); const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
@@ -2043,6 +2050,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (eventSender && ignoredUsersSet.has(eventSender)) { if (eventSender && ignoredUsersSet.has(eventSender)) {
return null; return null;
} }
// MSC1763: hide messages past the room's retention window (disappearing
// messages). Power users can still inspect via "show hidden events".
if (retentionMs && !showHiddenEvents && isExpired(mEvent.getTs(), retentionMs, Date.now())) {
return null;
}
if (mEvent.isRedacted() && !showHiddenEvents) { if (mEvent.isRedacted() && !showHiddenEvents) {
// eslint-disable-next-line @typescript-eslint/no-shadow // eslint-disable-next-line @typescript-eslint/no-shadow
const t = mEvent.getType(); const t = mEvent.getType();
+6 -3
View File
@@ -19,6 +19,7 @@ import { Page } from '../../components/page';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { useTheme, ThemeKind } from '../../hooks/useTheme'; import { useTheme, ThemeKind } from '../../hooks/useTheme';
import { useReducedMotion } from '../../hooks/useReducedMotion';
import { getChatBg } from '../lotus/chatBackground'; import { getChatBg } from '../lotus/chatBackground';
import { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
import { editableActiveElement } from '../../utils/dom'; import { editableActiveElement } from '../../utils/dom';
@@ -65,6 +66,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations'); const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
const theme = useTheme(); const theme = useTheme();
const isDark = theme.kind === ThemeKind.Dark; const isDark = theme.kind === ThemeKind.Dark;
const reduced = useReducedMotion();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
@@ -102,10 +104,11 @@ export function RoomView({ eventId }: { eventId?: string }) {
// Background.Container color. SidebarNav mirrors it onto document.body separately // Background.Container color. SidebarNav mirrors it onto document.body separately
// so the glassmorphism sidebar can blur through it. // so the glassmorphism sidebar can blur through it.
const chatBgStyle = useMemo(() => { const chatBgStyle = useMemo(() => {
if (chatBackground !== 'none') return getChatBg(chatBackground, isDark, pauseAnimations); if (chatBackground !== 'none')
if (lotusTerminal) return getChatBg('tactical', isDark, pauseAnimations); return getChatBg(chatBackground, isDark, pauseAnimations || reduced);
if (lotusTerminal) return getChatBg('tactical', isDark, pauseAnimations || reduced);
return {}; return {};
}, [chatBackground, lotusTerminal, isDark, pauseAnimations]); }, [chatBackground, lotusTerminal, isDark, pauseAnimations, reduced]);
return ( return (
<Page ref={roomViewRef} style={chatBgStyle}> <Page ref={roomViewRef} style={chatBgStyle}>
+25
View File
@@ -74,6 +74,7 @@ import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useLivekitSupport } from '../../hooks/useLivekitSupport'; import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc'; import { webRTCSupported } from '../../utils/rtc';
import { mediaGalleryAtom } from '../../state/mediaGallery'; import { mediaGalleryAtom } from '../../state/mediaGallery';
import { widgetsPanelAtom } from '../../state/widgetsPanel';
import { usePendingKnocks } from '../../hooks/usePendingKnocks'; import { usePendingKnocks } from '../../hooks/usePendingKnocks';
import { bookmarksPanelAtom } from '../../state/bookmarksPanel'; import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
@@ -489,6 +490,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [galleryOpen, setGalleryOpen] = useAtom(mediaGalleryAtom); const [galleryOpen, setGalleryOpen] = useAtom(mediaGalleryAtom);
const [widgetsOpen, setWidgetsOpen] = useAtom(widgetsPanelAtom);
const pendingKnocks = usePendingKnocks(room); const pendingKnocks = usePendingKnocks(room);
const handleSearchClick = () => { const handleSearchClick = () => {
@@ -725,6 +727,29 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
)} )}
</TooltipProvider> </TooltipProvider>
)} )}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{widgetsOpen ? 'Hide Widgets' : 'Widgets'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
onClick={() => setWidgetsOpen(!widgetsOpen)}
aria-label="Toggle widgets"
aria-pressed={widgetsOpen}
>
<Icon size="400" src={Icons.Category} filled={widgetsOpen} />
</IconButton>
)}
</TooltipProvider>
)}
{screenSize === ScreenSize.Desktop && ( {screenSize === ScreenSize.Desktop && (
<TooltipProvider <TooltipProvider
position="Bottom" position="Bottom"
@@ -29,6 +29,9 @@ export function buildForwardContent(
} }
delete content['m.relates_to']; delete content['m.relates_to'];
// Drop intentional mentions so forwarding a message doesn't re-ping the
// originally-mentioned users (they're not in the destination room's context).
delete content['m.mentions'];
if (typeof content.body === 'string') { if (typeof content.body === 'string') {
content.body = trimReplyFromBody(content.body); content.body = trimReplyFromBody(content.body);
} }
@@ -460,12 +460,17 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
}, [scrollToBottomCount]); }, [scrollToBottomCount]);
const handleJumpToBottom = useCallback(() => { const handleJumpToBottom = useCallback(() => {
// Re-anchor the virtual window at the thread tail first. While scrolled up,
// live replies deliberately don't extend the window, so without this the chip
// would scroll to the bottom of the STALE window (a mid/old event) instead of
// the newest reply. Mirrors the main timeline's handleJumpToLatest.
setTimeline(getInitialThreadTimeline(thread, getLinkedTimelines(thread.liveTimeline)));
scrollToBottomRef.current.count += 1; scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = true; scrollToBottomRef.current.smooth = true;
// Flip atBottom so the layout effect re-runs (count re-read) and live // Flip atBottom so the layout effect re-runs (count re-read) and live
// events resume sticking to the bottom. // events resume sticking to the bottom.
setAtBottom(true); setAtBottom(true);
}, []); }, [thread]);
// Scroll in-place editor into view. // Scroll in-place editor into view.
useEffect(() => { useEffect(() => {
@@ -0,0 +1,55 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ReceiptType } from 'matrix-js-sdk';
import { markThreadAsRead } from './threadReceipt';
// The regression this guards: sending a receipt for the thread ROOT (when
// replies aren't loaded, lastReply() is null / equals the root) becomes a MAIN
// receipt at an old event and drags the room's read marker backwards. It must
// only ever receipt a genuine loaded reply.
const evt = (id: string, sending = false) => ({ getId: () => id, isSending: () => sending }) as any;
const setup = (lastReply: any) => {
const calls: Array<{ eventId: string; type: ReceiptType }> = [];
const thread = { id: '$root', lastReply: () => lastReply } as any;
const mx = {
sendReadReceipt: async (e: any, type: ReceiptType) => {
calls.push({ eventId: e.getId(), type });
return {};
},
} as any;
return { mx, thread, calls };
};
test('REGRESSION: no loaded reply (lastReply null) → NO receipt (never the root)', async () => {
const { mx, thread, calls } = setup(null);
await markThreadAsRead(mx, thread, false);
assert.equal(calls.length, 0);
});
test('REGRESSION: lastReply IS the root → NO receipt', async () => {
const { mx, thread, calls } = setup(evt('$root'));
await markThreadAsRead(mx, thread, false);
assert.equal(calls.length, 0);
});
test('genuine loaded reply → threaded receipt at that reply', async () => {
const { mx, thread, calls } = setup(evt('$reply'));
await markThreadAsRead(mx, thread, false);
assert.equal(calls.length, 1);
assert.equal(calls[0].eventId, '$reply');
assert.equal(calls[0].type, ReceiptType.Read);
});
test('sending reply is skipped', async () => {
const { mx, thread, calls } = setup(evt('$reply', true));
await markThreadAsRead(mx, thread, false);
assert.equal(calls.length, 0);
});
test('private flag uses ReadPrivate', async () => {
const { mx, thread, calls } = setup(evt('$reply'));
await markThreadAsRead(mx, thread, true);
assert.equal(calls[0].type, ReceiptType.ReadPrivate);
});
@@ -0,0 +1,28 @@
import { MatrixClient, ReceiptType, Thread } from 'matrix-js-sdk';
/**
* Send a threaded read receipt for a thread, clearing its per-thread unread
* count.
*
* CRITICAL: never receipt the thread ROOT. A thread's liveTimeline is
* `[root, reply1, …]`, so the latest event IS the root when replies aren't
* loaded yet (common the thread panel fires this on mount before replies
* fetch). The root is "in the main timeline", so a receipt for it is written by
* the SDK with `thread_id:"main"` at the old root, dragging the room's MAIN read
* marker backwards (`getEventReadUpTo` an old/unloaded event) and re-lighting
* the whole room. We only receipt a genuine loaded reply (`thread.lastReply()`);
* if none is loaded we bail (the per-thread count clears when the reply loads
* and this runs again). Mirrors the root guard in `utils/notifications.ts`.
*
* Pure (no React/CSS) so it can be unit-tested see `threadReceipt.test.ts`.
*/
export const markThreadAsRead = async (
mx: MatrixClient,
thread: Thread,
privateReceipt: boolean,
): Promise<void> => {
const lastReply = thread.lastReply();
if (!lastReply || lastReply.isSending() || lastReply.getId() === thread.id) return;
await mx.sendReadReceipt(lastReply, privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read);
};
+3 -30
View File
@@ -4,7 +4,6 @@ import {
EventTimeline, EventTimeline,
MatrixClient, MatrixClient,
MatrixEvent, MatrixEvent,
ReceiptType,
Room, Room,
RoomEvent, RoomEvent,
RoomEventHandlerMap, RoomEventHandlerMap,
@@ -146,32 +145,6 @@ export const useThreadPendingEvents = (
return pending; return pending;
}; };
/** // markThreadAsRead moved to ./threadReceipt (pure + unit-tested); re-exported
* Send a threaded read receipt up to the latest confirmed event in the thread. // here for existing import sites.
* export { markThreadAsRead } from './threadReceipt';
* The receipt is threaded by default (scoped to this thread), which clears the
* per-thread unread count. Mirrors the latest-valid-event scan in
* `utils/notifications.ts`.
*/
export const markThreadAsRead = async (
mx: MatrixClient,
thread: Thread,
privateReceipt: boolean,
): Promise<void> => {
const events = thread.liveTimeline.getEvents();
let latestEvent: MatrixEvent | undefined;
for (let i = events.length - 1; i >= 0; i -= 1) {
const evt = events[i];
if (evt && !evt.isSending()) {
latestEvent = evt;
break;
}
}
if (!latestEvent) return;
await mx.sendReadReceipt(
latestEvent,
privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read,
);
};
@@ -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)}`;
+32 -34
View File
@@ -35,6 +35,9 @@ import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile'; import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { UserAvatar } from '../../../components/user-avatar'; import { UserAvatar } from '../../../components/user-avatar';
@@ -319,8 +322,8 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
); );
} }
const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`; export const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`; export const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
const CLEAR_AFTER_OPTIONS = [ const CLEAR_AFTER_OPTIONS = [
{ label: 'Never', value: '0' }, { label: 'Never', value: '0' },
@@ -347,6 +350,8 @@ function ProfileStatus() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const userId = mx.getUserId()!; const userId = mx.getUserId()!;
const presence = useUserPresence(userId); const presence = useUserPresence(userId);
const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus');
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
const [statusMsg, setStatusMsg] = useState<string>( const [statusMsg, setStatusMsg] = useState<string>(
presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '', presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '',
@@ -357,12 +362,6 @@ function ProfileStatus() {
const [clearAfter, setClearAfter] = useState('0'); const [clearAfter, setClearAfter] = useState('0');
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>(); const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
// Initialise expiry from localStorage so timer survives page reload
const [expiryTs, setExpiryTs] = useState<number>(() => {
const stored = localStorage.getItem(STATUS_EXPIRY_KEY(userId));
return stored ? parseInt(stored, 10) : 0;
});
// Sync input when another device changes the status. // Sync input when another device changes the status.
// Skipped while the user has unsaved local edits to avoid clobbering // Skipped while the user has unsaved local edits to avoid clobbering
// mid-flight input (e.g. an emoji being inserted). // mid-flight input (e.g. an emoji being inserted).
@@ -373,32 +372,16 @@ function ProfileStatus() {
} }
}, [presence?.status, userId]); }, [presence?.status, userId]);
// Drive the auto-clear timer off expiryTs so re-saving cancels the old timer
useEffect(() => {
if (!expiryTs) return undefined;
const remaining = expiryTs - Date.now();
const clearStatus = () => {
localStorage.removeItem(STATUS_MSG_KEY(userId));
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
setExpiryTs(0);
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined);
};
if (remaining <= 0) {
clearStatus();
return undefined;
}
const timer = window.setTimeout(clearStatus, remaining);
return () => clearTimeout(timer);
}, [expiryTs, userId, mx]);
const [saveState, saveStatus] = useAsyncCallback( const [saveState, saveStatus] = useAsyncCallback(
useCallback( useCallback(
(msg: string) => (msg: string) =>
mx.setPresence({ mx.setPresence({
presence: 'online', // Derive presence from the user's chosen setting so writing a status
// never overrides Invisible/DND/Idle (e.g. outing an Invisible user).
presence: presenceStateFromSetting(presenceStatus, hidePresence),
status_msg: msg, status_msg: msg,
}), }),
[mx], [mx, presenceStatus, hidePresence],
), ),
); );
const saving = saveState.status === AsyncStatus.Loading; const saving = saveState.status === AsyncStatus.Loading;
@@ -429,12 +412,12 @@ function ProfileStatus() {
const delayMs = getMsFromOption(clearAfter); const delayMs = getMsFromOption(clearAfter);
if (msg && delayMs > 0) { if (msg && delayMs > 0) {
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
// (ClientNonUIFeatures) fires the auto-clear even when Settings is closed.
const ts = Date.now() + delayMs; const ts = Date.now() + delayMs;
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(ts)); localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(ts));
setExpiryTs(ts);
} else { } else {
localStorage.removeItem(STATUS_EXPIRY_KEY(userId)); localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
setExpiryTs(0);
} }
}; };
@@ -443,8 +426,11 @@ function ProfileStatus() {
setStatusMsg(''); setStatusMsg('');
localStorage.removeItem(STATUS_MSG_KEY(userId)); localStorage.removeItem(STATUS_MSG_KEY(userId));
localStorage.removeItem(STATUS_EXPIRY_KEY(userId)); localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
setExpiryTs(0); // Preserve the user's chosen presence when clearing the status message.
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined); mx.setPresence({
presence: presenceStateFromSetting(presenceStatus, hidePresence),
status_msg: '',
}).catch(() => undefined);
}; };
const hasChanges = statusMsg !== (presence?.status ?? ''); const hasChanges = statusMsg !== (presence?.status ?? '');
@@ -751,10 +737,22 @@ function ProfileTimezone() {
const [saveState, saveTimezone] = useAsyncCallback( const [saveState, saveTimezone] = useAsyncCallback(
useCallback( useCallback(
(value: string) => (value: string) =>
(mx as any).setAccountData('im.lotus.timezone', { timezone: value }).then(() => { Promise.all([
// Self-fallback: account data is readable by useExtendedProfile for the
// own user even on servers without extended-profile (m.tz) support.
(mx as any).setAccountData('im.lotus.timezone', { timezone: value }),
// Mirror the pronouns write path so OTHER users can read the timezone
// via the m.tz profile field. Best-effort: standard Synapse rejects
// unknown profile fields, so a failure here must not fail the save.
mx.http
.authedRequest(Method.Put, `/profile/${encodeURIComponent(userId)}/m.tz`, undefined, {
'm.tz': value,
})
.catch(() => undefined),
]).then(() => {
setSavedTimezone(value); setSavedTimezone(value);
}), }),
[mx], [mx, userId],
), ),
); );
const saving = saveState.status === AsyncStatus.Loading; const saving = saveState.status === AsyncStatus.Loading;
@@ -50,7 +50,7 @@ function DecorationPreviewCell({
<img <img
src={`${DECORATION_CDN}/${slug}.png`} src={`${DECORATION_CDN}/${slug}.png`}
alt={name} alt={name}
loading="eager" loading="lazy"
decoding="async" decoding="async"
style={{ style={{
position: 'absolute', position: 'absolute',
@@ -1,6 +1,6 @@
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react'; import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
import { Box, Button, color, Icon, Icons, Spinner, Text, toRem } from 'folds'; import { Box, Button, color, Icon, Icons, Spinner, Text, toRem } from 'folds';
import FileSaver from 'file-saver'; import { useSaveFile } from '../../../hooks/useSaveFile';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
@@ -15,6 +15,7 @@ import { useFilePicker } from '../../../hooks/useFilePicker';
function ExportKeys() { function ExportKeys() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const alive = useAlive(); const alive = useAlive();
const saveFile = useSaveFile();
const [exportState, exportKeys] = useAsyncCallback<void, Error, [string]>( const [exportState, exportKeys] = useAsyncCallback<void, Error, [string]>(
useCallback( useCallback(
@@ -28,9 +29,9 @@ function ExportKeys() {
const blob = new Blob([encKeys], { const blob = new Blob([encKeys], {
type: 'text/plain;charset=us-ascii', type: 'text/plain;charset=us-ascii',
}); });
FileSaver.saveAs(blob, 'lotus-keys.txt'); saveFile(blob, 'lotus-keys.txt');
}, },
[mx], [mx, saveFile],
), ),
); );
+31 -3
View File
@@ -105,6 +105,7 @@ import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri'; import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
import { customWindowChromeAtom } from '../../../state/customWindowChrome'; import { customWindowChromeAtom } from '../../../state/customWindowChrome';
import { useDateFormatItems } from '../../../hooks/useDateFormat'; import { useDateFormatItems } from '../../../hooks/useDateFormat';
import { useReducedMotion } from '../../../hooks/useReducedMotion';
import { playCallJoinSound } from '../../../utils/callSounds'; import { playCallJoinSound } from '../../../utils/callSounds';
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones'; import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
import { DenoiseTester } from './DenoiseTester'; import { DenoiseTester } from './DenoiseTester';
@@ -118,12 +119,21 @@ import { SettingsSelect } from '../../../components/settings-select/SettingsSele
function DesktopChromeSetting() { function DesktopChromeSetting() {
const [customChrome, setCustomChrome] = useAtom(customWindowChromeAtom); const [customChrome, setCustomChrome] = useAtom(customWindowChromeAtom);
if (!isTauriEnv()) return null; if (!isTauriEnv()) return null;
// Persist the flag, then reload so the window layout is rebuilt from scratch.
// Toggling live reflows the whole app while the room timeline is mounted, which
// resizes its virtualized scroll container and triggers runaway back-pagination
// (the "screen expands + auto-scrolls into the past" bug). A reload applies the
// chrome cleanly against a fresh, correct layout.
const handleToggle = (value: boolean) => {
setCustomChrome(value);
window.location.reload();
};
return ( return (
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile <SettingTile
title="Custom Window Chrome (Beta)" title="Custom Window Chrome (Beta)"
description="Replace the system title bar with a Lotus-styled one. Desktop only — toggles instantly." description="Replace the system title bar with a Lotus-styled one. Desktop only — reloads to apply."
after={<Switch variant="Primary" value={customChrome} onChange={setCustomChrome} />} after={<Switch variant="Primary" value={customChrome} onChange={handleToggle} />}
/> />
</SequenceCard> </SequenceCard>
); );
@@ -2045,6 +2055,7 @@ function ChatBgGrid() {
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations'); const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
const theme = useTheme(); const theme = useTheme();
const isDark = theme.kind === ThemeKind.Dark; const isDark = theme.kind === ThemeKind.Dark;
const reduced = useReducedMotion();
return ( return (
<Box <Box
@@ -2070,7 +2081,7 @@ function ChatBgGrid() {
style={{ style={{
width: toRem(76), width: toRem(76),
height: toRem(50), height: toRem(50),
...getChatBg(opt.value as ChatBackground, isDark, pauseAnimations), ...getChatBg(opt.value as ChatBackground, isDark, pauseAnimations || reduced),
}} }}
/> />
<Text <Text
@@ -2240,6 +2251,10 @@ function Messages() {
const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview'); const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const [enforceRetentionLocally, setEnforceRetentionLocally] = useSetting(
settingsAtom,
'enforceRetentionLocally',
);
return ( return (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
@@ -2337,6 +2352,19 @@ function Messages() {
} }
/> />
</SequenceCard> </SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Enforce Message Retention"
description="Permanently delete your own messages once a room's retention window (Room Settings → Message Retention) has passed. Off by default; only affects your own messages."
after={
<Switch
variant="Primary"
value={enforceRetentionLocally}
onChange={setEnforceRetentionLocally}
/>
}
/>
</SequenceCard>
</Box> </Box>
); );
} }
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { useAtomValue, useSetAtom } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import { Box, Button, Text, IconButton, Icon, Icons, Scroll, config, toRem } from 'folds'; import { Box, Button, Text, IconButton, Icon, Icons, IconSrc, Scroll, config, toRem } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page'; import { Page, PageContent, PageHeader } from '../../../components/page';
import { SystemNotification } from './SystemNotification'; import { SystemNotification } from './SystemNotification';
import { AllMessagesNotifications } from './AllMessages'; import { AllMessagesNotifications } from './AllMessages';
@@ -14,13 +14,13 @@ import { settingsAtom, Settings } from '../../../state/settings';
const PRESETS: Array<{ const PRESETS: Array<{
label: string; label: string;
emoji: string; icon: IconSrc;
description: string; description: string;
patch: Partial<Settings>; patch: Partial<Settings>;
}> = [ }> = [
{ {
label: 'Gaming', label: 'Gaming',
emoji: '🎮', icon: Icons.Ball,
description: 'Notifications on, sounds off', description: 'Notifications on, sounds off',
patch: { patch: {
showNotifications: true, showNotifications: true,
@@ -32,7 +32,7 @@ const PRESETS: Array<{
}, },
{ {
label: 'Work', label: 'Work',
emoji: '💼', icon: Icons.Monitor,
description: 'All notifications and sounds on', description: 'All notifications and sounds on',
patch: { patch: {
showNotifications: true, showNotifications: true,
@@ -44,7 +44,7 @@ const PRESETS: Array<{
}, },
{ {
label: 'Sleep', label: 'Sleep',
emoji: '🌙', icon: Icons.BellMute,
description: 'All notifications off', description: 'All notifications off',
patch: { patch: {
showNotifications: false, showNotifications: false,
@@ -83,7 +83,7 @@ function NotificationPresets() {
}} }}
> >
<Box direction="Column" alignItems="Center" gap="100"> <Box direction="Column" alignItems="Center" gap="100">
<span style={{ fontSize: toRem(24) }}>{preset.emoji}</span> <Icon size="400" src={preset.icon} />
<Text size="T300" style={{ fontWeight: config.fontWeight.W600 }}> <Text size="T300" style={{ fontWeight: config.fontWeight.W600 }}>
{preset.label} {preset.label}
</Text> </Text>
+11 -3
View File
@@ -171,7 +171,11 @@ function ToastCard({ toast }: ToastCardProps) {
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') handleCardClick(); if (e.key === 'Enter' || e.key === ' ') handleCardClick();
}} }}
aria-label={`Notification from ${toast.displayName} in ${toast.roomName}`} aria-label={
toast.roomName
? `Notification from ${toast.displayName} in ${toast.roomName}`
: `${toast.displayName}: ${toast.body}`
}
> >
<span style={{ position: 'absolute', top: config.space.S100, right: config.space.S100 }}> <span style={{ position: 'absolute', top: config.space.S100, right: config.space.S100 }}>
<IconButton <IconButton
@@ -187,7 +191,11 @@ function ToastCard({ toast }: ToastCardProps) {
</IconButton> </IconButton>
</span> </span>
<div style={rowStyle}> <div style={rowStyle}>
{toast.avatarUrl ? ( {toast.iconSrc ? (
<div style={initialsStyle} aria-hidden="true">
<Icon size="100" src={toast.iconSrc} />
</div>
) : toast.avatarUrl ? (
<img src={toast.avatarUrl} alt="" style={avatarStyle} aria-hidden="true" /> <img src={toast.avatarUrl} alt="" style={avatarStyle} aria-hidden="true" />
) : ( ) : (
<div style={initialsStyle} aria-hidden="true"> <div style={initialsStyle} aria-hidden="true">
@@ -197,7 +205,7 @@ function ToastCard({ toast }: ToastCardProps) {
<span style={nameStyle}>{toast.displayName}</span> <span style={nameStyle}>{toast.displayName}</span>
</div> </div>
<div style={bodyStyle}>{toast.body}</div> <div style={bodyStyle}>{toast.body}</div>
<div style={roomNameStyle}>{toast.roomName}</div> {toast.roomName && <div style={roomNameStyle}>{toast.roomName}</div>}
</div> </div>
); );
} }
+58 -56
View File
@@ -4,84 +4,86 @@ import { CallEmbed, useCallControlState } from '../plugins/call';
import { useSetting } from '../state/hooks/settings'; import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings'; import { settingsAtom } from '../state/settings';
import { toastQueueAtom } from '../state/toast'; import { toastQueueAtom } from '../state/toast';
import { useMatrixClient } from './useMatrixClient';
const SILENCE_RMS_THRESHOLD = 0.008;
const CHECK_INTERVAL_MS = 500; const CHECK_INTERVAL_MS = 500;
/** /**
* Monitors microphone audio while in a call. If the mic stays unmuted but * Monitors microphone activity while in a call. If the mic stays unmuted but
* silent for longer than the configured timeout, the mic is muted and a toast * the user is not speaking for longer than the configured timeout, the mic is
* is shown. * muted and a toast is shown.
* *
* The level-monitoring capture (`getUserMedia`) is opened ONLY while the mic is * [C-H2] Activity is read from the EC fork's `io.lotus.call_state` stream
* unmuted there is nothing to auto-mute once you are already muted, so * (getLotusParticipants) i.e. the VAD state of the user's ACTUAL published
* holding the capture would keep the OS recording indicator lit even though the * track on their SELECTED input device. The previous implementation opened its
* UI shows you as muted (N95). Muting therefore releases our stream; unmuting * own `getUserMedia({ audio: true })`, which captured the browser DEFAULT mic
* re-acquires it. The AudioContext + stream are also torn down on unmount. * (not necessarily the device EC publishes from): it could measure silence
* while the user spoke on a different device (auto-muting an active speaker) and
* lit a second OS microphone indicator. Sourcing from the fork removes both
* problems and needs no extra capture.
*
* If the fork hasn't reported call-state yet (getLotusParticipants() === null
* e.g. plain EC, or immediately after join), we cannot tell whether the user is
* publishing, so we fail SAFE and never auto-mute during that window.
*/ */
export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void { export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
const mx = useMatrixClient();
const [enabled] = useSetting(settingsAtom, 'afkAutoMute'); const [enabled] = useSetting(settingsAtom, 'afkAutoMute');
const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes'); const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
const setToast = useSetAtom(toastQueueAtom); const setToast = useSetAtom(toastQueueAtom);
const { microphone } = useCallControlState(callEmbed?.control); const { microphone } = useCallControlState(callEmbed?.control);
useEffect(() => { useEffect(() => {
// Only capture while in a call, enabled, AND unmuted (see N95 note above). // Only monitor while in a call, enabled, AND unmuted — there is nothing to
// auto-mute once you are already muted.
if (!callEmbed || !enabled || !microphone) return undefined; if (!callEmbed || !enabled || !microphone) return undefined;
let stream: MediaStream | undefined; const localUserId = mx.getSafeUserId();
let audioCtx: AudioContext | undefined; const timeoutMs = timeoutMinutes * 60 * 1000;
let intervalId: ReturnType<typeof setInterval> | undefined;
let silenceStart: number | null = null; let silenceStart: number | null = null;
let active = true; let active = true;
const timeoutMs = timeoutMinutes * 60 * 1000;
navigator.mediaDevices // undefined = fork hasn't reported call-state yet (can't tell — fail safe).
.getUserMedia({ audio: true, video: false }) const isLocalSpeaking = (): boolean | undefined => {
.then((s) => { const participants = callEmbed.getLotusParticipants();
if (!active) { // null = fork not reported; [] = malformed/spurious payload (CallEmbed
s.getTracks().forEach((t) => t.stop()); // stores [] for a non-array). You are ALWAYS present in your own joined
return; // call, so an empty list means "no usable data", NOT "silent" — matching
} // useCallSpeakers / useRemoteAllMuted. Treating [] as silent would let the
stream = s; // timer mute an active speaker. Fail safe on both.
audioCtx = new AudioContext(); if (participants === null || participants.length === 0) return undefined;
const source = audioCtx.createMediaStreamSource(stream); return participants.some((p) => p.userId === localUserId && p.audioEnabled && p.speaking);
const analyser = audioCtx.createAnalyser(); };
analyser.fftSize = 256;
source.connect(analyser);
const buffer = new Float32Array(analyser.fftSize);
intervalId = setInterval(() => { const intervalId = setInterval(() => {
if (!active) return; if (!active) return;
analyser.getFloatTimeDomainData(buffer); const speaking = isLocalSpeaking();
const rms = Math.sqrt(buffer.reduce((sum, v) => sum + v * v, 0) / buffer.length);
if (rms > SILENCE_RMS_THRESHOLD) { if (speaking === undefined) {
// Audio detected — reset the silence timer. // No usable signal — don't risk muting an active speaker.
silenceStart = null; silenceStart = null;
} else if (silenceStart === null) { } else if (speaking) {
// Mic is unmuted (effect only runs while unmuted) but silent — start the timer. // Voice detected on the published track — reset the silence timer.
silenceStart = Date.now(); silenceStart = null;
} else if (Date.now() - silenceStart >= timeoutMs) { } else if (silenceStart === null) {
callEmbed.control.setMicrophone(false); // Mic is unmuted (effect only runs while unmuted) but silent — start the timer.
setToast({ silenceStart = Date.now();
id: `afk-mute-${Date.now()}`, } else if (Date.now() - silenceStart >= timeoutMs) {
displayName: 'Lotus Chat', callEmbed.control.setMicrophone(false);
body: 'Your microphone was muted after inactivity.', setToast({
roomName: 'Voice call', id: `afk-mute-${Date.now()}`,
roomId: callEmbed.roomId, displayName: 'Lotus Chat',
}); body: 'Your microphone was muted after inactivity.',
silenceStart = null; roomName: 'Voice call',
} roomId: callEmbed.roomId,
}, CHECK_INTERVAL_MS); });
}) silenceStart = null;
.catch(() => undefined); }
}, CHECK_INTERVAL_MS);
return () => { return () => {
active = false; active = false;
if (intervalId !== undefined) clearInterval(intervalId); clearInterval(intervalId);
stream?.getTracks().forEach((t) => t.stop());
audioCtx?.close().catch(() => undefined);
}; };
}, [callEmbed, enabled, timeoutMinutes, setToast, microphone]); }, [callEmbed, enabled, timeoutMinutes, setToast, microphone, mx]);
} }
+18 -8
View File
@@ -9,6 +9,9 @@ const PROFILE_FIELD = 'io.lotus.avatar_decoration';
const cache = new Map<string, string | null>(); const cache = new Map<string, string | null>();
// Callbacks waiting for a userId's result // Callbacks waiting for a userId's result
const pending = new Map<string, Array<(val: string | null) => void>>(); const pending = new Map<string, Array<(val: string | null) => void>>();
// Transient-failure attempt counts (userId → n) so a flaky federated lookup
// can retry a couple of times, then gives up for the session.
const failures = new Map<string, number>();
function fetchDecoration( function fetchDecoration(
authedRequest: (method: Method, path: string) => Promise<Record<string, string>>, authedRequest: (method: Method, path: string) => Promise<Record<string, string>>,
@@ -33,16 +36,23 @@ function fetchDecoration(
return val; return val;
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
// A 404 (M_NOT_FOUND) means the field is genuinely unset → cache "no
// decoration". A transient failure (429 rate-limit, 5xx, network) must
// NOT be cached: doing so permanently hides the user's decoration for the
// whole session. This matters most for the member list and timeline, which
// mount many avatars at once and can trip homeserver rate limits — a
// single 429 in that burst would otherwise wipe the decoration until a
// full reload. Leaving the cache unset lets the next mount retry.
const status = err instanceof MatrixError ? err.httpStatus : undefined; const status = err instanceof MatrixError ? err.httpStatus : undefined;
if (status === 404) { // Definitive rejections — the field is unset (404) or the server won't
// serve it (400/403). This is the common case for FEDERATED users whose
// homeserver doesn't support extended profiles / rejects the field. Cache
// "no decoration" so we never refetch: otherwise every avatar mount
// re-requests and floods our homeserver with failing federated profile
// lookups (the 403/502 console storm + real HS load).
if (status === 404 || status === 403 || status === 400) {
cache.set(userId, null); cache.set(userId, null);
} else {
// Transient (429 rate-limit / 5xx / network). Allow a couple of retries
// — a single 429 in a member-list burst shouldn't permanently hide a
// decoration — then give up for the session so a persistently-failing
// federated link (e.g. a 502'ing remote server) can't loop forever.
const attempts = (failures.get(userId) ?? 0) + 1;
failures.set(userId, attempts);
if (attempts >= 2) cache.set(userId, null);
} }
return null; return null;
}) })
+92 -32
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { MatrixClient } from 'matrix-js-sdk'; import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
export type Bookmark = { export type Bookmark = {
roomId: string; roomId: string;
@@ -25,6 +24,75 @@ function readBookmarks(mx: MatrixClient): Bookmark[] {
); );
} }
// Module-scoped serialization state.
//
// useBookmarks() is mounted once per message row (dozens of live instances), so
// a per-instance latest/queue would only serialize writes within a single row —
// bookmarking message A then message B from different rows (before the server
// echo lands) would let B compute from a stale snapshot and clobber A
// (setAccountData replaces the whole content, no server merge). We therefore
// keep a single shared latest ref + write queue, keyed off the active client.
type BookmarksModuleState = {
mx: MatrixClient;
latest: Bookmark[];
writeQueue: Promise<unknown>;
listeners: Set<(list: Bookmark[]) => void>;
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
};
let moduleState: BookmarksModuleState | null = null;
// Lazily initialize the shared state for the given client. On a client change
// (login/logout swaps the MatrixClient) we tear down the old subscription and
// re-initialize against the new client so we never leak or double-subscribe.
function ensureModuleState(mx: MatrixClient): BookmarksModuleState {
if (moduleState && moduleState.mx === mx) {
return moduleState;
}
if (moduleState) {
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
}
const state: BookmarksModuleState = {
mx,
latest: readBookmarks(mx),
writeQueue: Promise.resolve(),
listeners: new Set(),
// Reassigned below once `state` is captured.
onAccountData: () => undefined,
};
state.onAccountData = (evt) => {
if (evt.getType() === BOOKMARKS_KEY) {
const list = evt.getContent<BookmarksContent>()?.bookmarks ?? [];
state.latest = list;
state.listeners.forEach((listener) => listener(list));
}
};
mx.on(ClientEvent.AccountData, state.onAccountData);
moduleState = state;
return state;
}
function enqueueBookmarkWrite(
mx: MatrixClient,
compute: (current: Bookmark[]) => Bookmark[],
): Promise<void> {
const state = ensureModuleState(mx);
const run = state.writeQueue.then(async () => {
const next = compute(state.latest);
state.latest = next;
state.listeners.forEach((listener) => listener(next));
await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next });
});
// Keep the chain alive even if one write rejects, but propagate the
// rejection to this caller so it can react (e.g. retry).
state.writeQueue = run.catch(() => undefined);
return run;
}
export function useBookmarks(): { export function useBookmarks(): {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
addBookmark: (b: Bookmark) => Promise<void>; addBookmark: (b: Bookmark) => Promise<void>;
@@ -32,45 +100,37 @@ export function useBookmarks(): {
isBookmarked: (eventId: string) => boolean; isBookmarked: (eventId: string) => boolean;
} { } {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [bookmarks, setBookmarks] = useState<Bookmark[]>(() => readBookmarks(mx)); const [bookmarks, setBookmarks] = useState<Bookmark[]>(() => ensureModuleState(mx).latest);
useAccountDataCallback( // Subscribe to the shared module state. A single AccountData listener is
mx, // installed per client (in ensureModuleState); each hook instance only
useCallback( // registers a local setter and unregisters it on unmount / client change.
(evt) => {
if (evt.getType() === BOOKMARKS_KEY) {
setBookmarks(evt.getContent<BookmarksContent>()?.bookmarks ?? []);
}
},
[setBookmarks],
),
);
// Re-read on mx change
useEffect(() => { useEffect(() => {
setBookmarks(readBookmarks(mx)); const state = ensureModuleState(mx);
setBookmarks(state.latest);
state.listeners.add(setBookmarks);
return () => {
state.listeners.delete(setBookmarks);
};
}, [mx]); }, [mx]);
const addBookmark = useCallback( const addBookmark = useCallback(
async (b: Bookmark) => { (b: Bookmark) =>
const current = readBookmarks(mx); enqueueBookmarkWrite(mx, (current) => {
// Avoid duplicates // Avoid duplicates
const filtered = current.filter((bk) => bk.eventId !== b.eventId); const filtered = current.filter((bk) => bk.eventId !== b.eventId);
let next = [b, ...filtered]; let next = [b, ...filtered];
if (next.length > MAX_BOOKMARKS) { if (next.length > MAX_BOOKMARKS) {
next = next.slice(0, MAX_BOOKMARKS); next = next.slice(0, MAX_BOOKMARKS);
} }
await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next }); return next;
}, }),
[mx], [mx],
); );
const removeBookmark = useCallback( const removeBookmark = useCallback(
async (eventId: string) => { (eventId: string) =>
const current = readBookmarks(mx); enqueueBookmarkWrite(mx, (current) => current.filter((bk) => bk.eventId !== eventId)),
const next = current.filter((bk) => bk.eventId !== eventId);
await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next });
},
[mx], [mx],
); );
+19
View File
@@ -6,6 +6,25 @@ import { settingsAtom } from '../state/settings';
const IDLE_TIMEOUT_MS = 10 * 60 * 1000; const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
const ACTIVITY_THROTTLE_MS = 1000; const ACTIVITY_THROTTLE_MS = 1000;
export type PresenceSetting = 'auto' | 'online' | 'idle' | 'dnd' | 'invisible';
export type PresenceState = 'online' | 'unavailable' | 'offline';
/**
* Single source of truth for mapping the user's presence preference to the
* Matrix presence value: auto/online 'online', idle/dnd 'unavailable',
* invisible (or the hidePresence override) 'offline'. Shared with the Profile
* status writer so setting/clearing a status message never overrides the user's
* chosen presence (e.g. outing an Invisible user as online).
*/
export function presenceStateFromSetting(
presenceStatus: PresenceSetting,
hidePresence: boolean,
): PresenceState {
if (hidePresence || presenceStatus === 'invisible') return 'offline';
if (presenceStatus === 'idle' || presenceStatus === 'dnd') return 'unavailable';
return 'online';
}
export function usePresenceUpdater() { export function usePresenceUpdater() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [hidePresence] = useSetting(settingsAtom, 'hidePresence'); const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useState } from 'react';
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
const readReducedMotion = (): boolean =>
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia(REDUCED_MOTION_QUERY).matches;
/**
* Reactively tracks the OS `prefers-reduced-motion: reduce` setting.
*
* Unlike a one-off `window.matchMedia(...).matches` read, this subscribes to the
* media query's `change` event, so toggling the OS setting mid-session updates
* the returned value (and any animation gated on it) without a page reload.
* SSR/undefined-safe: returns `false` when `window`/`matchMedia` is unavailable.
*/
export function useReducedMotion(): boolean {
const [reduced, setReduced] = useState<boolean>(readReducedMotion);
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return undefined;
}
const mql = window.matchMedia(REDUCED_MOTION_QUERY);
const onChange = (event: MediaQueryListEvent) => setReduced(event.matches);
// Re-sync in case the setting changed between the initial render and this effect.
setReduced(mql.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, []);
return reduced;
}
+87 -54
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { MatrixClient } from 'matrix-js-sdk'; import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
export type Reminder = { export type Reminder = {
roomId: string; roomId: string;
@@ -23,6 +22,75 @@ function readReminders(mx: MatrixClient): Reminder[] {
); );
} }
// Module-scoped serialization state.
//
// The latest snapshot and the write queue must be shared across every hook
// instance: ReminderMonitor (auto-removes fired reminders) and RemindMeDialog
// (adds reminders) mount separate hooks, and a per-instance queue would let a
// remove and an add race across instances and clobber each other (setAccountData
// replaces the whole content, no server merge). We therefore keep a single
// shared queue + latest ref, keyed off the active MatrixClient.
type ReminderModuleState = {
mx: MatrixClient;
latest: Reminder[];
writeQueue: Promise<unknown>;
listeners: Set<(list: Reminder[]) => void>;
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
};
let moduleState: ReminderModuleState | null = null;
// Lazily initialize the shared state for the given client. On a client change
// (login/logout swaps the MatrixClient) we tear down the old subscription and
// re-initialize against the new client so we never leak or double-subscribe.
function ensureModuleState(mx: MatrixClient): ReminderModuleState {
if (moduleState && moduleState.mx === mx) {
return moduleState;
}
if (moduleState) {
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
}
const state: ReminderModuleState = {
mx,
latest: readReminders(mx),
writeQueue: Promise.resolve(),
listeners: new Set(),
// Reassigned below once `state` is captured.
onAccountData: () => undefined,
};
state.onAccountData = (evt) => {
if (evt.getType() === REMINDERS_KEY) {
const list = evt.getContent<RemindersContent>()?.reminders ?? [];
state.latest = list;
state.listeners.forEach((listener) => listener(list));
}
};
mx.on(ClientEvent.AccountData, state.onAccountData);
moduleState = state;
return state;
}
function enqueueReminderWrite(
mx: MatrixClient,
compute: (current: Reminder[]) => Reminder[],
): Promise<void> {
const state = ensureModuleState(mx);
const run = state.writeQueue.then(async () => {
const next = compute(state.latest);
state.latest = next;
state.listeners.forEach((listener) => listener(next));
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
});
// Keep the chain alive even if one write rejects, but propagate the
// rejection to this caller so it can react (e.g. retry).
state.writeQueue = run.catch(() => undefined);
return run;
}
export function useReminders(): { export function useReminders(): {
reminders: Reminder[]; reminders: Reminder[];
addReminder: (r: Reminder) => Promise<void>; addReminder: (r: Reminder) => Promise<void>;
@@ -30,69 +98,34 @@ export function useReminders(): {
getReminders: () => Reminder[]; getReminders: () => Reminder[];
} { } {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [reminders, setReminders] = useState<Reminder[]>(() => readReminders(mx)); const [reminders, setReminders] = useState<Reminder[]>(() => ensureModuleState(mx).latest);
// Authoritative local snapshot used to compute mutations. Reading // Subscribe to the shared module state. A single AccountData listener is
// mx.getAccountData() per-mutation is racy: two quick add/remove calls both // installed per client (in ensureModuleState); each hook instance only
// read the same stale baseline and the second write clobbers the first // registers a local setter and unregisters it on unmount / client change.
// (N113). We instead mutate from this ref, kept in sync with server echoes.
const latestRef = useRef<Reminder[]>(reminders);
// Serialize writes so overlapping setAccountData calls can't land out of
// order on the server (last-write-wins would otherwise drop data).
const writeQueueRef = useRef<Promise<unknown>>(Promise.resolve());
const applyServerState = useCallback((list: Reminder[]) => {
latestRef.current = list;
setReminders(list);
}, []);
useAccountDataCallback(
mx,
useCallback(
(evt) => {
if (evt.getType() === REMINDERS_KEY) {
applyServerState(evt.getContent<RemindersContent>()?.reminders ?? []);
}
},
[applyServerState],
),
);
// Re-read on mx change
useEffect(() => { useEffect(() => {
applyServerState(readReminders(mx)); const state = ensureModuleState(mx);
}, [mx, applyServerState]); setReminders(state.latest);
state.listeners.add(setReminders);
const enqueueWrite = useCallback( return () => {
(compute: (current: Reminder[]) => Reminder[]): Promise<void> => { state.listeners.delete(setReminders);
const run = writeQueueRef.current.then(async () => { };
const next = compute(latestRef.current); }, [mx]);
latestRef.current = next;
setReminders(next);
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
});
// Keep the chain alive even if one write rejects, but propagate the
// rejection to this caller so it can react (e.g. retry).
writeQueueRef.current = run.catch(() => undefined);
return run;
},
[mx],
);
const addReminder = useCallback( const addReminder = useCallback(
(r: Reminder) => enqueueWrite((current) => [...current, r]), (r: Reminder) => enqueueReminderWrite(mx, (current) => [...current, r]),
[enqueueWrite], [mx],
); );
const removeReminder = useCallback( const removeReminder = useCallback(
(eventId: string, timestamp: number) => (eventId: string, timestamp: number) =>
enqueueWrite((current) => enqueueReminderWrite(mx, (current) =>
current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)), current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)),
), ),
[enqueueWrite], [mx],
); );
const getReminders = useCallback(() => reminders, [reminders]); const getReminders = useCallback(() => ensureModuleState(mx).latest, [mx]);
return { reminders, addReminder, removeReminder, getReminders }; return { reminders, addReminder, removeReminder, getReminders };
} }
+17 -1
View File
@@ -1,4 +1,11 @@
import { MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import {
MatrixEvent,
MatrixEventEvent,
MatrixEventHandlerMap,
Room,
RoomEvent,
RoomEventHandlerMap,
} from 'matrix-js-sdk';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { settingsAtom } from '../state/settings'; import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings'; import { useSetting } from '../state/hooks/settings';
@@ -45,11 +52,20 @@ export const useRoomLatestRenderedEvent = (room: Room) => {
const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = () => { const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = () => {
setLatestEvent(getLatestEvent()); setLatestEvent(getLatestEvent());
}; };
// An E2EE message often arrives as an undecrypted placeholder and is decrypted
// shortly after — decryption does NOT re-fire RoomEvent.Timeline, so without this
// the DM preview stays stale ("Encrypted message") until the next timeline event.
const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (event) => {
if (event.getRoomId() !== room.roomId) return;
setLatestEvent(getLatestEvent());
};
setLatestEvent(getLatestEvent()); setLatestEvent(getLatestEvent());
room.on(RoomEvent.Timeline, handleTimelineEvent); room.on(RoomEvent.Timeline, handleTimelineEvent);
room.client.on(MatrixEventEvent.Decrypted, handleDecrypted);
return () => { return () => {
room.removeListener(RoomEvent.Timeline, handleTimelineEvent); room.removeListener(RoomEvent.Timeline, handleTimelineEvent);
room.client.removeListener(MatrixEventEvent.Decrypted, handleDecrypted);
}; };
}, [room, hideMembershipEvents, hideNickAvatarEvents, showHiddenEvents]); }, [room, hideMembershipEvents, hideNickAvatarEvents, showHiddenEvents]);
+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],
);
};
+15 -1
View File
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { manualDndAtom } from '../state/manualDnd'; import { manualDndAtom } from '../state/manualDnd';
import { useTauriEvent } from './useTauri'; import { tauriInvoke, useTauriEvent } from './useTauri';
/** Detail shape of the `lotus-dnd-changed` event emitted by the native side. */ /** Detail shape of the `lotus-dnd-changed` event emitted by the native side. */
type DndChangedDetail = { type DndChangedDetail = {
@@ -18,4 +19,17 @@ export function useTauriDnd(): void {
const setDnd = useSetAtom(manualDndAtom); const setDnd = useSetAtom(manualDndAtom);
useTauriEvent<DndChangedDetail>('lotus-dnd-changed', ({ active }) => setDnd(active)); useTauriEvent<DndChangedDetail>('lotus-dnd-changed', ({ active }) => setDnd(active));
// Re-hydrate on mount. The tray CheckMenuItem persists its checkstate, but
// `manualDndAtom` is in-memory and resets to false on every reload (the
// custom-chrome toggle, logout). Without this the tray could show DND ON while
// notifications resume firing. Query the native tray state (`get_tray_dnd`) so
// they stay in sync. No-op in the browser.
useEffect(() => {
tauriInvoke()?.('get_tray_dnd')
.then((active) => {
if (typeof active === 'boolean') setDnd(active);
})
.catch(() => undefined);
}, [setDnd]);
} }
@@ -18,6 +18,10 @@ export function useTauriNotificationBadge() {
let totalHighlights = 0; let totalHighlights = 0;
roomToUnread.forEach((unread) => { roomToUnread.forEach((unread) => {
// Sum only leaf rooms (from === null); roomToUnread also holds per-ancestor
// space aggregates (from = Set), so counting all entries double-counts a
// space-nested room. Mirrors the favicon fix in ClientNonUIFeatures.
if (unread.from !== null) return;
totalHighlights += unread.highlight; totalHighlights += unread.highlight;
}); });
+4
View File
@@ -38,6 +38,10 @@ export function useTauriUpdater() {
setStatus({ state: 'installing' }); setStatus({ state: 'installing' });
try { try {
await invoke('install_update'); await invoke('install_update');
// On a successful install the native side calls app.restart(), so this
// resolve is only reached when nothing was installed (no update found) —
// don't leave the UI stuck on "installing".
setStatus({ state: 'up-to-date' });
} catch (e) { } catch (e) {
setStatus({ state: 'error', message: String(e) }); setStatus({ state: 'error', message: String(e) });
} }
+90 -22
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { MatrixClient } from 'matrix-js-sdk'; import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
const NOTES_KEY = 'io.lotus.user_notes'; const NOTES_KEY = 'io.lotus.user_notes';
export const USER_NOTE_MAX_LENGTH = 500; export const USER_NOTE_MAX_LENGTH = 500;
@@ -12,39 +11,108 @@ function readNotes(mx: MatrixClient): UserNotesContent {
return (mx.getAccountData(NOTES_KEY as any)?.getContent() as UserNotesContent | undefined) ?? {}; return (mx.getAccountData(NOTES_KEY as any)?.getContent() as UserNotesContent | undefined) ?? {};
} }
// Module-scoped serialization state.
//
// useUserNotes() can be mounted by many components at once, so a per-instance
// latest/queue would only serialize writes within one instance. Notes for
// different users saved from different instances (before the server echo lands)
// would each compute from a stale snapshot and clobber each other, since
// setAccountData replaces the whole record with no server merge. We therefore
// keep a single shared latest record + write queue, keyed off the active client.
type UserNotesModuleState = {
mx: MatrixClient;
latest: UserNotesContent;
writeQueue: Promise<unknown>;
listeners: Set<(record: UserNotesContent) => void>;
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
};
let moduleState: UserNotesModuleState | null = null;
// Lazily initialize the shared state for the given client. On a client change
// (login/logout swaps the MatrixClient) we tear down the old subscription and
// re-initialize against the new client so we never leak or double-subscribe.
function ensureModuleState(mx: MatrixClient): UserNotesModuleState {
if (moduleState && moduleState.mx === mx) {
return moduleState;
}
if (moduleState) {
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
}
const state: UserNotesModuleState = {
mx,
latest: readNotes(mx),
writeQueue: Promise.resolve(),
listeners: new Set(),
// Reassigned below once `state` is captured.
onAccountData: () => undefined,
};
state.onAccountData = (evt) => {
if (evt.getType() === NOTES_KEY) {
const record = evt.getContent<UserNotesContent>() ?? {};
state.latest = record;
state.listeners.forEach((listener) => listener(record));
}
};
mx.on(ClientEvent.AccountData, state.onAccountData);
moduleState = state;
return state;
}
function enqueueNotesWrite(
mx: MatrixClient,
compute: (current: UserNotesContent) => UserNotesContent,
): Promise<void> {
const state = ensureModuleState(mx);
const run = state.writeQueue.then(async () => {
const next = compute(state.latest);
state.latest = next;
state.listeners.forEach((listener) => listener(next));
await (mx as any).setAccountData(NOTES_KEY, next);
});
// Keep the chain alive even if one write rejects, but propagate the
// rejection to this caller so it can react (e.g. retry).
state.writeQueue = run.catch(() => undefined);
return run;
}
export function useUserNotes(): { export function useUserNotes(): {
getNote: (userId: string) => string; getNote: (userId: string) => string;
setNote: (userId: string, note: string) => Promise<void>; setNote: (userId: string, note: string) => Promise<void>;
} { } {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [notes, setNotes] = useState<UserNotesContent>(() => readNotes(mx)); const [notes, setNotes] = useState<UserNotesContent>(() => ensureModuleState(mx).latest);
useAccountDataCallback(
mx,
useCallback((evt) => {
if (evt.getType() === NOTES_KEY) {
setNotes(evt.getContent<UserNotesContent>() ?? {});
}
}, []),
);
// Subscribe to the shared module state. A single AccountData listener is
// installed per client (in ensureModuleState); each hook instance only
// registers a local setter and unregisters it on unmount / client change.
useEffect(() => { useEffect(() => {
setNotes(readNotes(mx)); const state = ensureModuleState(mx);
setNotes(state.latest);
state.listeners.add(setNotes);
return () => {
state.listeners.delete(setNotes);
};
}, [mx]); }, [mx]);
const getNote = useCallback((userId: string) => notes[userId] ?? '', [notes]); const getNote = useCallback((userId: string) => notes[userId] ?? '', [notes]);
const setNote = useCallback( const setNote = useCallback(
async (userId: string, note: string) => { (userId: string, note: string) => {
const current = readNotes(mx);
const updated = { ...current };
const trimmed = note.trim().slice(0, USER_NOTE_MAX_LENGTH); const trimmed = note.trim().slice(0, USER_NOTE_MAX_LENGTH);
if (trimmed) { return enqueueNotesWrite(mx, (current) => {
updated[userId] = trimmed; const updated = { ...current };
} else { if (trimmed) {
delete updated[userId]; updated[userId] = trimmed;
} } else {
await (mx as any).setAccountData(NOTES_KEY, updated); delete updated[userId];
}
return updated;
});
}, },
[mx], [mx],
); );
+4
View File
@@ -29,6 +29,10 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined)); const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
useEffect(() => { useEffect(() => {
// Re-seed when the User object appears/changes after first render — the
// useState initializer only ran if `user` already existed at mount, so a
// late-arriving user would otherwise show no presence until the next event.
if (user) setPresence(getUserPresence(user));
// Subscribe on mx (MatrixClient) rather than on individual User objects. // Subscribe on mx (MatrixClient) rather than on individual User objects.
// User objects have a default 10-listener limit; the same user can appear // User objects have a default 10-listener limit; the same user can appear
// in many components simultaneously (avatars, member list, etc.) and // in many components simultaneously (avatars, member list, etc.) and
+5 -1
View File
@@ -110,7 +110,11 @@ function DesktopChrome({ children }: { children: ReactNode }) {
<div <div
style={ style={
useChrome useChrome
? { display: 'flex', flexDirection: 'column', height: '100vh' } ? // Match html/#root (100dvh), NOT 100vh — in the Tauri webview 100vh
// can exceed the visible height after decorations are stripped, which
// makes the timeline's scroll container taller than the viewport and
// sends the virtual paginator into a runaway back-pagination loop.
{ display: 'flex', flexDirection: 'column', height: '100dvh' }
: { display: 'contents' } : { display: 'contents' }
} }
> >
+176 -14
View File
@@ -11,7 +11,7 @@ import {
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import { focusAssistActiveAtom } from '../../state/focusAssist'; import { focusAssistActiveAtom } from '../../state/focusAssist';
import { manualDndAtom } from '../../state/manualDnd'; import { manualDndAtom } from '../../state/manualDnd';
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread'; import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import LogoSVG from '../../../../public/res/lotus.png'; import LogoSVG from '../../../../public/res/lotus.png';
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png'; import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
import LogoHighlightSVG from '../../../../public/res/lotus-highlight.png'; import LogoHighlightSVG from '../../../../public/res/lotus-highlight.png';
@@ -32,15 +32,23 @@ import {
getUnreadInfo, getUnreadInfo,
isNotificationEvent, isNotificationEvent,
} from '../../utils/room'; } from '../../utils/room';
import { NotificationType, UnreadInfo } from '../../../types/matrix/room'; import { NotificationType } from '../../../types/matrix/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { usePresenceUpdater } from '../../hooks/usePresenceUpdater'; import { presenceStateFromSetting, usePresenceUpdater } from '../../hooks/usePresenceUpdater';
import {
MAX_MUTE_TIMEOUT_MS,
MuteTimerEntry,
loadMuteTimers,
unmuteRoom,
} from '../../features/room-nav/RoomNavItem';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate'; import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { toastQueueAtom } from '../../state/toast'; import { toastQueueAtom } from '../../state/toast';
import { useReminders } from '../../hooks/useReminders'; import { useReminders } from '../../hooks/useReminders';
import { getRoomRetentionMs, isExpired } from '../../utils/retention';
import { useTauriUpdater } from '../../hooks/useTauriUpdater'; import { useTauriUpdater } from '../../hooks/useTauriUpdater';
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures'; import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts'; import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts';
@@ -96,6 +104,11 @@ function FaviconUpdater() {
let totalNotif = 0; let totalNotif = 0;
let totalHighlight = 0; let totalHighlight = 0;
roomToUnread.forEach((unread) => { roomToUnread.forEach((unread) => {
// roomToUnread holds BOTH leaf rooms and per-ancestor space aggregates
// (leaves have `from === null`, aggregates a Set). Sum only leaves —
// otherwise a space-nested room is counted once as the leaf and again in
// every ancestor space, inflating the tab title / favicon count.
if (unread.from !== null) return;
totalNotif += unread.total; totalNotif += unread.total;
totalHighlight += unread.highlight; totalHighlight += unread.highlight;
}); });
@@ -230,9 +243,95 @@ function PresenceUpdater() {
return null; return null;
} }
// Restores timed-mute timers persisted by RoomNavItem across reloads. Bare
// setTimeouts don't survive a page reload, so without this a scheduled unmute is
// lost and the room stays muted forever. On boot: unmute anything already
// past-due and re-arm a timer for each future entry (clamped to setTimeout's max).
function MuteTimerRestore() {
const mx = useMatrixClient();
useEffect(() => {
const timers = loadMuteTimers();
if (timers.length === 0) return undefined;
const now = Date.now();
const pastDue: MuteTimerEntry[] = [];
const future: MuteTimerEntry[] = [];
timers.forEach((entry) => (entry.unmuteAt <= now ? pastDue : future).push(entry));
pastDue.forEach((entry) => {
unmuteRoom(mx, entry.roomId);
});
const handles = future.map((entry) =>
setTimeout(
() => {
unmuteRoom(mx, entry.roomId);
},
Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS),
),
);
return () => {
handles.forEach(clearTimeout);
};
}, [mx]);
return null;
}
// Fires the custom-status auto-clear even when Settings→Profile is closed. The
// expiry setTimeout used to live in ProfileStatus, which unmounts on close, so
// the status never cleared. This always-mounted watcher polls the persisted
// expiry key and clears (preserving the user's chosen presence) when due.
function StatusExpiryMonitor() {
const mx = useMatrixClient();
const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus');
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
// Read latest settings via refs so the poll interval isn't torn down/restarted
// (resetting its countdown) whenever the presence setting changes.
const presenceStatusRef = useRef(presenceStatus);
presenceStatusRef.current = presenceStatus;
const hidePresenceRef = useRef(hidePresence);
hidePresenceRef.current = hidePresence;
useEffect(() => {
const userId = mx.getUserId();
if (!userId) return undefined;
const expiryKey = STATUS_EXPIRY_KEY(userId);
const msgKey = STATUS_MSG_KEY(userId);
const check = () => {
const stored = localStorage.getItem(expiryKey);
if (!stored) return;
const ts = parseInt(stored, 10);
if (!ts || Date.now() < ts) return;
localStorage.removeItem(msgKey);
localStorage.removeItem(expiryKey);
mx.setPresence({
presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current),
status_msg: '',
}).catch(() => undefined);
};
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;
}
function MessageNotifications() { function MessageNotifications() {
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map()); const lastNotifiedEventRef = useRef<Map<string, string>>(new Map());
// Per-thread dedupe: threadId -> last notified eventId. // Per-thread dedupe: threadId -> last notified eventId.
const lastNotifiedThreadRef = useRef<Map<string, string>>(new Map()); const lastNotifiedThreadRef = useRef<Map<string, string>>(new Map());
const mx = useMatrixClient(); const mx = useMatrixClient();
@@ -367,17 +466,21 @@ function MessageNotifications() {
const eventId = mEvent.getId(); const eventId = mEvent.getId();
if (!sender || !eventId) return; if (!sender || !eventId) return;
const unreadInfo = getUnreadInfo(room); // Dedupe on the event id (per room): the same event can re-fire (decryption,
const cachedUnreadInfo = unreadCacheRef.current.get(room.roomId); // edit, thread repopulation). This replaces the old unread-COUNT dedupe,
unreadCacheRef.current.set(room.roomId, unreadInfo); // which suppressed a genuinely-new message whenever its post-read count
// matched the previously-notified count — i.e. "read a DM, next message
// never notifies/sounds" (the common one-at-a-time cadence).
if (lastNotifiedEventRef.current.get(room.roomId) === eventId) return;
if (unreadInfo.total === 0) return; // Main-timeline path respects push rules: don't notify when the room has no
if ( // notification count (e.g. a non-mention in a Mentions-only room). The
cachedUnreadInfo && // thread path is already gated by shouldNotifyThreadReply, so it must NOT
unreadEqual(unreadInfoToUnread(cachedUnreadInfo), unreadInfoToUnread(unreadInfo)) // re-gate on the room count — otherwise an explicit per-thread "All replies"
) { // override in a Mentions-only room is silently dropped.
return; if (!threadId && getUnreadInfo(room).total === 0) return;
}
lastNotifiedEventRef.current.set(room.roomId, eventId);
const quietActive = const quietActive =
focusAssistActive || focusAssistActive ||
@@ -585,6 +688,62 @@ function ReminderMonitor() {
return null; return null;
} }
// MSC1763: opt-in local enforcement of room retention. When enabled, permanently
// redacts the user's OWN messages once a room's retention window passes. Own-only
// (no redact PL needed); scoped to loaded live-timeline events; dedupes in-flight
// redactions and retries on the next tick. Default-off, so nothing auto-deletes
// unless the user turns it on.
function RetentionSweeper() {
const mx = useMatrixClient();
const [enforceRetentionLocally] = useSetting(settingsAtom, 'enforceRetentionLocally');
const enabledRef = useRef(enforceRetentionLocally);
enabledRef.current = enforceRetentionLocally;
const redactingRef = useRef<Set<string>>(new Set());
useEffect(() => {
const check = () => {
if (!enabledRef.current) return;
const myId = mx.getUserId();
if (!myId) return;
const now = Date.now();
mx.getRooms().forEach((room) => {
const maxLifetime = getRoomRetentionMs(room);
if (!maxLifetime) return;
room
.getLiveTimeline()
.getEvents()
.forEach((ev) => {
const evId = ev.getId();
if (!evId || ev.getSender() !== myId) return;
if (ev.isState() || ev.isRedacted() || ev.isSending()) return;
const t = ev.getType();
// Only actual messages — never our membership/topic/reactions.
if (t !== 'm.room.message' && t !== 'm.room.encrypted' && t !== 'm.sticker') return;
if (!isExpired(ev.getTs(), maxLifetime, now)) return;
if (redactingRef.current.has(evId)) return;
redactingRef.current.add(evId);
mx.redactEvent(room.roomId, evId, undefined, { reason: 'expired' }).catch(() => {
redactingRef.current.delete(evId);
});
});
});
};
check();
const interval = setInterval(check, 30_000);
const onVisible = () => {
if (document.visibilityState === 'visible') check();
};
document.addEventListener('visibilitychange', onVisible);
return () => {
clearInterval(interval);
document.removeEventListener('visibilitychange', onVisible);
};
}, [mx]);
return null;
}
const TAURI_UPDATE_CHECK_INTERVAL = 12 * 60 * 60_000; // 12 hours const TAURI_UPDATE_CHECK_INTERVAL = 12 * 60 * 60_000; // 12 hours
const TAURI_UPDATE_LAST_CHECK_KEY = 'lotus.tauriUpdateLastCheck'; const TAURI_UPDATE_LAST_CHECK_KEY = 'lotus.tauriUpdateLastCheck';
@@ -666,9 +825,12 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<PageZoomFeature /> <PageZoomFeature />
<FaviconUpdater /> <FaviconUpdater />
<PresenceUpdater /> <PresenceUpdater />
<MuteTimerRestore />
<StatusExpiryMonitor />
<InviteNotifications /> <InviteNotifications />
<MessageNotifications /> <MessageNotifications />
<ReminderMonitor /> <ReminderMonitor />
<RetentionSweeper />
<TauriUpdateFeature /> <TauriUpdateFeature />
<TauriDesktopFeatures /> <TauriDesktopFeatures />
<LotusDenoiseFeature /> <LotusDenoiseFeature />
+18
View File
@@ -31,6 +31,7 @@ import {
logoutClient, logoutClient,
startClient, startClient,
} from '../../../client/initMatrix'; } from '../../../client/initMatrix';
import { deleteSearchCacheDatabase } from '../../utils/searchCache';
import { SplashScreen } from '../../components/splash-screen'; import { SplashScreen } from '../../components/splash-screen';
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader'; import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
import { CapabilitiesProvider } from '../../hooks/useCapabilities'; import { CapabilitiesProvider } from '../../hooks/useCapabilities';
@@ -43,6 +44,8 @@ import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus'; import { SyncStatus } from './SyncStatus';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession, removeFallbackSession } from '../../state/sessions'; import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
import { pushSessionToSW } from '../../../sw-session';
import { revokeOidcTokens } from '../../../client/oidcLogout';
import { useSessionSync } from '../../hooks/useSessionSync'; import { useSessionSync } from '../../hooks/useSessionSync';
import { installCryptoDiagLog } from '../../utils/cryptoDiagLog'; import { installCryptoDiagLog } from '../../utils/cryptoDiagLog';
import { AutoDiscovery } from './AutoDiscovery'; import { AutoDiscovery } from './AutoDiscovery';
@@ -142,8 +145,23 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) {
const useLogoutListener = (mx?: MatrixClient) => { const useLogoutListener = (mx?: MatrixClient) => {
useEffect(() => { useEffect(() => {
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => { const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
// Clear the SW's cached bearer token so it stops attaching the now-revoked
// token to media fetches (mirrors the manual logoutClient path).
pushSessionToSW();
mx?.stopClient(); mx?.stopClient();
// Best-effort issuer revocation for OIDC sessions (the token is already
// server-revoked here, but revoke the refresh token too). Before we drop
// the stored session below.
const loggedOutSession = getFallbackSession();
if (loggedOutSession?.oidc) {
await revokeOidcTokens(loggedOutSession).catch(() => undefined);
}
await mx?.clearStores(); await mx?.clearStores();
// The opt-in local search index holds DECRYPTED message plaintext. Wipe it
// on server-forced logout too (token expiry / remote sign-out / password
// change) — the manual logout path already does, but this path didn't, so
// the plaintext survived on disk (and persist() makes it non-evictable).
await deleteSearchCacheDatabase();
// Remove only the session credential keys — NOT settings, drafts, and // Remove only the session credential keys — NOT settings, drafts, and
// other preferences (N98). The SDK's IndexedDB stores are cleared above; // other preferences (N98). The SDK's IndexedDB stores are cleared above;
// window.localStorage.clear() is reserved for the explicit reset path. // window.localStorage.clear() is reserved for the explicit reset path.
+18 -7
View File
@@ -24,6 +24,7 @@ import { CreateTab } from './sidebar/CreateTab';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { useTheme, ThemeKind } from '../../hooks/useTheme'; import { useTheme, ThemeKind } from '../../hooks/useTheme';
import { useReducedMotion } from '../../hooks/useReducedMotion';
import { getChatBg } from '../../features/lotus/chatBackground'; import { getChatBg } from '../../features/lotus/chatBackground';
export function SidebarNav() { export function SidebarNav() {
@@ -34,6 +35,7 @@ export function SidebarNav() {
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations'); const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
const theme = useTheme(); const theme = useTheme();
const isDark = theme.kind === ThemeKind.Dark; const isDark = theme.kind === ThemeKind.Dark;
const reduced = useReducedMotion();
// backdrop-filter only blurs content directly behind the element in the z-axis. // backdrop-filter only blurs content directly behind the element in the z-axis.
// The sidebar is a flex sibling of the room view, so nothing sits behind it by default. // The sidebar is a flex sibling of the room view, so nothing sits behind it by default.
@@ -53,17 +55,26 @@ export function SidebarNav() {
} }
const effectiveBg = chatBackground !== 'none' ? chatBackground : 'tactical'; const effectiveBg = chatBackground !== 'none' ? chatBackground : 'tactical';
const bgStyle = getChatBg(effectiveBg, isDark, pauseAnimations); const bgStyle = getChatBg(effectiveBg, isDark, pauseAnimations || reduced);
style.backgroundImage = (bgStyle.backgroundImage as string | undefined) ?? ''; style.backgroundImage = (bgStyle.backgroundImage as string | undefined) ?? '';
style.backgroundColor = (bgStyle.backgroundColor as string | undefined) ?? ''; style.backgroundColor = (bgStyle.backgroundColor as string | undefined) ?? '';
style.backgroundSize = (bgStyle.backgroundSize as string | undefined) ?? ''; style.backgroundSize = (bgStyle.backgroundSize as string | undefined) ?? '';
style.backgroundPosition = (bgStyle.backgroundPosition as string | undefined) ?? ''; style.backgroundPosition = (bgStyle.backgroundPosition as string | undefined) ?? '';
style.animation = (bgStyle.animation as string | undefined) ?? ''; // The animated body mirror (animation + will-change) exists solely so the
// Promote animated backgrounds to their own compositor layer so the browser // glassmorphism sidebar can blur through document.body. When glass is OFF nothing
// doesn't repaint the overlaid text/UI content on every animation frame. // samples this layer, yet SidebarNav is always mounted, so writing an animated bg +
if (bgStyle.animation) { // will-change here would leave a permanent invisible animated compositor layer
style.willChange = 'background-position, background-size'; // app-wide. Only mirror the animation when glass is on; the static background above
// (needed by lotusTerminal / non-animated cases) is still written regardless.
if (glassmorphismSidebar) {
style.animation = (bgStyle.animation as string | undefined) ?? '';
if (bgStyle.animation) {
style.willChange = 'background-position, background-size';
} else {
style.removeProperty('will-change');
}
} else { } else {
style.removeProperty('animation');
style.removeProperty('will-change'); style.removeProperty('will-change');
} }
@@ -75,7 +86,7 @@ export function SidebarNav() {
style.removeProperty('animation'); style.removeProperty('animation');
style.removeProperty('will-change'); style.removeProperty('will-change');
}; };
}, [glassmorphismSidebar, chatBackground, lotusTerminal, isDark, pauseAnimations]); }, [glassmorphismSidebar, chatBackground, lotusTerminal, isDark, pauseAnimations, reduced]);
return ( return (
<Sidebar className={classNames(glassmorphismSidebar && SidebarGlass)}> <Sidebar className={classNames(glassmorphismSidebar && SidebarGlass)}>
+1 -5
View File
@@ -321,11 +321,7 @@ export function Direct() {
const selected = selectedRoomId === roomId; const selected = selectedRoomId === roomId;
return ( return (
<VirtualTile <VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}>
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<RoomNavItem <RoomNavItem
room={room} room={room}
selected={selected} selected={selected}
+98 -20
View File
@@ -223,6 +223,7 @@ const factoryRoomIdByUnread =
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room'); const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite'); const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
const LOW_PRIORITY_CATEGORY_ID = makeNavCategoryId('home', 'lowpriority');
export function Home() { export function Home() {
const mx = useMatrixClient(); const mx = useMatrixClient();
useNavToActivePathMapper('home'); useNavToActivePathMapper('home');
@@ -261,29 +262,66 @@ export function Home() {
const roomToUnread = useAtomValue(roomToUnreadAtom); const roomToUnread = useAtomValue(roomToUnreadAtom);
const [sortMenuAnchor, setSortMenuAnchor] = useState<RectCords>(); const [sortMenuAnchor, setSortMenuAnchor] = useState<RectCords>();
const { favoriteRooms, otherRooms } = useMemo(() => { const { favoriteRooms, lowPriorityRooms, otherRooms } = useMemo(() => {
const favs: string[] = []; const favs: string[] = [];
const low: string[] = [];
const others: string[] = []; const others: string[] = [];
rooms.forEach((rId) => { rooms.forEach((rId) => {
const room = mx.getRoom(rId); const room = mx.getRoom(rId);
if (room?.tags?.['m.favourite']) { if (room?.tags?.['m.favourite']) {
favs.push(rId); favs.push(rId);
} else if (room?.tags?.['m.lowpriority']) {
low.push(rId);
} else { } else {
others.push(rId); others.push(rId);
} }
}); });
return { favoriteRooms: favs, otherRooms: others }; return { favoriteRooms: favs, lowPriorityRooms: low, otherRooms: others };
}, [mx, rooms]); }, [mx, rooms]);
const sortedFavoriteRooms = useMemo( const sortedFavoriteRooms = useMemo(() => {
() => const isClosed = closedCategories.has(FAVORITES_CATEGORY_ID);
Array.from(favoriteRooms).sort( const items = Array.from(favoriteRooms).sort(
closedCategories.has(FAVORITES_CATEGORY_ID) isClosed ? factoryRoomIdByActivity(mx) : factoryRoomIdByAtoZ(mx),
? factoryRoomIdByActivity(mx) );
: factoryRoomIdByAtoZ(mx), if (isClosed) {
), return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId);
[mx, favoriteRooms, closedCategories], }
); return items;
}, [mx, favoriteRooms, closedCategories, roomsWithUnreadSet, selectedRoomId]);
const filteredFavoriteRooms = useMemo(() => {
if (!filterQuery.trim()) return sortedFavoriteRooms;
const query = filterQuery.toLowerCase();
const localNames = getLocalRoomNamesContent(mx);
return sortedFavoriteRooms.filter((rId) => {
const localName = localNames.rooms[rId];
const matrixName = mx.getRoom(rId)?.name ?? '';
return (localName ?? matrixName).toLowerCase().includes(query);
});
}, [mx, sortedFavoriteRooms, filterQuery]);
const sortedLowPriorityRooms = useMemo(() => {
const isClosed = closedCategories.has(LOW_PRIORITY_CATEGORY_ID);
const items = Array.from(lowPriorityRooms).sort(
isClosed ? factoryRoomIdByActivity(mx) : factoryRoomIdByAtoZ(mx),
);
if (isClosed) {
return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId);
}
return items;
}, [mx, lowPriorityRooms, closedCategories, roomsWithUnreadSet, selectedRoomId]);
const filteredLowPriorityRooms = useMemo(() => {
if (!filterQuery.trim()) return sortedLowPriorityRooms;
const query = filterQuery.toLowerCase();
const localNames = getLocalRoomNamesContent(mx);
return sortedLowPriorityRooms.filter((rId) => {
const localName = localNames.rooms[rId];
const matrixName = mx.getRoom(rId)?.name ?? '';
return (localName ?? matrixName).toLowerCase().includes(query);
});
}, [mx, sortedLowPriorityRooms, filterQuery]);
const sortedRooms = useMemo(() => { const sortedRooms = useMemo(() => {
const isClosed = closedCategories.has(DEFAULT_CATEGORY_ID); const isClosed = closedCategories.has(DEFAULT_CATEGORY_ID);
@@ -324,7 +362,7 @@ export function Home() {
}, [mx, sortedRooms, filterQuery]); }, [mx, sortedRooms, filterQuery]);
const favVirtualizer = useVirtualizer({ const favVirtualizer = useVirtualizer({
count: sortedFavoriteRooms.length, count: filteredFavoriteRooms.length,
getScrollElement: () => scrollRef.current, getScrollElement: () => scrollRef.current,
estimateSize: () => 38, estimateSize: () => 38,
overscan: 10, overscan: 10,
@@ -337,6 +375,13 @@ export function Home() {
overscan: 10, overscan: 10,
}); });
const lowVirtualizer = useVirtualizer({
count: filteredLowPriorityRooms.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 38,
overscan: 10,
});
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId), closedCategories.has(categoryId),
); );
@@ -453,7 +498,7 @@ export function Home() {
/> />
</Box> </Box>
</NavCategory> </NavCategory>
{sortedFavoriteRooms.length > 0 && ( {favoriteRooms.length > 0 && (
<NavCategory> <NavCategory>
<NavCategoryHeader> <NavCategoryHeader>
<RoomNavCategoryButton <RoomNavCategoryButton
@@ -466,13 +511,13 @@ export function Home() {
</NavCategoryHeader> </NavCategoryHeader>
<div style={{ position: 'relative', height: favVirtualizer.getTotalSize() }}> <div style={{ position: 'relative', height: favVirtualizer.getTotalSize() }}>
{favVirtualizer.getVirtualItems().map((vItem) => { {favVirtualizer.getVirtualItems().map((vItem) => {
const roomId = sortedFavoriteRooms[vItem.index]; const roomId = filteredFavoriteRooms[vItem.index];
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
if (!room) return null; if (!room) return null;
return ( return (
<VirtualTile <VirtualTile
virtualItem={vItem} virtualItem={vItem}
key={vItem.index} key={roomId}
ref={favVirtualizer.measureElement} ref={favVirtualizer.measureElement}
> >
<RoomNavItem <RoomNavItem
@@ -611,11 +656,7 @@ export function Home() {
const selected = selectedRoomId === roomId; const selected = selectedRoomId === roomId;
return ( return (
<VirtualTile <VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}>
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<RoomNavItem <RoomNavItem
room={room} room={room}
selected={selected} selected={selected}
@@ -630,6 +671,43 @@ export function Home() {
})} })}
</div> </div>
</NavCategory> </NavCategory>
{lowPriorityRooms.length > 0 && (
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(LOW_PRIORITY_CATEGORY_ID)}
data-category-id={LOW_PRIORITY_CATEGORY_ID}
onClick={handleCategoryClick}
>
Low Priority
</RoomNavCategoryButton>
</NavCategoryHeader>
<div style={{ position: 'relative', height: lowVirtualizer.getTotalSize() }}>
{lowVirtualizer.getVirtualItems().map((vItem) => {
const roomId = filteredLowPriorityRooms[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
return (
<VirtualTile
virtualItem={vItem}
key={roomId}
ref={lowVirtualizer.measureElement}
>
<RoomNavItem
room={room}
selected={selectedRoomId === roomId}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId,
)}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
)}
</Box> </Box>
</PageNavContent> </PageNavContent>
)} )}
+80 -6
View File
@@ -29,12 +29,26 @@ export class CallControl extends EventEmitter implements CallControlState {
private controlMutationObserver: MutationObserver; private controlMutationObserver: MutationObserver;
// C-H3: coalesces bursts of body-subtree mutations into a single debounced
// re-observe pass so a busy EC re-render doesn't thrash the control observer.
private bodyMutationTimer?: ReturnType<typeof setTimeout>;
private _pipMode = false; private _pipMode = false;
// C-M3: last quality payload requested via setQuality(). Held so we can (re)send
// it once joined (io.lotus.set_quality must not be sent before call-join — a
// pre-join send pends to a 10s widget timeout, mirroring the deafen gate).
private lastQuality: LotusQualityPayload | null = null;
// C-M5: set true by CallControls while a push-to-talk key is held. A PTT hold
// unmutes the mic transiently, and onMediaState() must NOT treat that as a
// user-initiated unmute that auto-undeafens the user.
public pttActive = false;
// P6-2: mirrors CallEmbed.joined. Set true from forceState(), which CallEmbed // P6-2: mirrors CallEmbed.joined. Set true from forceState(), which CallEmbed
// invokes only from onCallJoined(). Gates io.lotus.set_deafen so we never send // invokes only from onCallJoined(). Gates io.lotus.set_deafen so we never send
// before the fork's widget handler mounts (pre-join sends pend to a 10s // before the fork's widget handler mounts (pre-join sends pend to a 10s
// timeout — HANDOFF_ELEMENT_CALL_FORK.md §12.1 F1). // timeout — io.lotus toWidget actions must only be sent after call-join).
private joined = false; private joined = false;
private get document(): Document | undefined { private get document(): Document | undefined {
@@ -153,19 +167,43 @@ export class CallControl extends EventEmitter implements CallControlState {
// this.joined was still false, so it was gated — this is the first send.) // this.joined was still false, so it was gated — this is the first send.)
this.joined = true; this.joined = true;
this.sendDeafenState(); this.sendDeafenState();
this.sendQuality();
}
/**
* C-H1 / C-M3: re-push the sticky fork-side state (deafen + quality) after an
* EC reconnect. Unlike forceState() this does NOT touch mic/video, so a
* reconnect can't clobber the user's live media state it only re-arms the
* fork handlers that remount on reconnect.
*/
public resendForkState(): void {
this.sendDeafenState();
this.sendQuality();
} }
public startObserving() { public startObserving() {
if (!this.document) return; if (!this.document) return;
// C-H3: watch the whole body subtree (not just direct children) so we
// re-bind the control observer when EC re-renders its controls deeper in the
// tree. Debounced via onBodyMutation() to avoid thrashing on busy renders.
this.bodyMutationObserver.observe(this.document.body, { this.bodyMutationObserver.observe(this.document.body, {
childList: true, childList: true,
subtree: false, // only direct children of body subtree: true,
}); });
this.onBodyMutation(); this.applyBodyMutation();
} }
private onBodyMutation() { private onBodyMutation() {
// C-H3: coalesce a burst of subtree mutations into one debounced pass.
if (this.bodyMutationTimer !== undefined) return;
this.bodyMutationTimer = setTimeout(() => {
this.bodyMutationTimer = undefined;
this.applyBodyMutation();
}, 100);
}
private applyBodyMutation() {
if (!this.document) return; if (!this.document) return;
this.document.body.style.setProperty('background', 'none', 'important'); this.document.body.style.setProperty('background', 'none', 'important');
@@ -266,22 +304,43 @@ export class CallControl extends EventEmitter implements CallControlState {
this.state = state; this.state = state;
this.emitStateUpdate(); this.emitStateUpdate();
if (this.microphone && !this.sound) { // C-M5: auto-undeafen when the mic turns on, but NOT for a transient
// push-to-talk unmute — a PTT tap while deafened must not silently
// un-deafen the user.
if (this.microphone && !this.sound && !this.pttActive) {
this.toggleSound(); this.toggleSound();
} }
} }
private onControlMutation() { private onControlMutation() {
const wasScreensharing = this.screenshare;
const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary'; const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary';
const spotlight: boolean = this.spotlightButton?.checked ?? false; const spotlight: boolean = this.spotlightButton?.checked ?? false;
// C-M6: when a screenshare stops, clear the screenshare-audio mute so a
// later screenshare doesn't start pre-muted.
const screenshareAudioMuted =
wasScreensharing && !screenshare ? false : this.screenshareAudioMuted;
// C-H3: the body observer now watches subtree:true, so this fires on any DOM
// churn in EC's controls. Only re-emit (→ re-render every consumer) when one
// of the values this method derives actually changed — microphone/video/sound
// are copied unchanged from the current state here.
if (
this.state.screenshare === screenshare &&
this.state.spotlight === spotlight &&
this.state.screenshareAudioMuted === screenshareAudioMuted
) {
return;
}
this.state = new CallControlState( this.state = new CallControlState(
this.microphone, this.microphone,
this.video, this.video,
this.sound, this.sound,
screenshare, screenshare,
spotlight, spotlight,
this.screenshareAudioMuted, screenshareAudioMuted,
); );
this.emitStateUpdate(); this.emitStateUpdate();
} }
@@ -423,10 +482,25 @@ export class CallControl extends EventEmitter implements CallControlState {
* clamped fork-side, so out-of-range input can't brick the encoder. * clamped fork-side, so out-of-range input can't brick the encoder.
*/ */
public setQuality(settings: LotusQualityPayload): void { public setQuality(settings: LotusQualityPayload): void {
this.call.transport.send('io.lotus.set_quality', settings).catch(() => undefined); // C-M3: remember the request and only send once joined; sendQuality() gates
// on this.joined so a pre-join call is a no-op that we replay on join.
this.lastQuality = settings;
this.sendQuality();
}
// C-M3: push the last-requested quality to the fork. Gated on this.joined so
// we never send io.lotus.set_quality before the fork's handler mounts (a
// pre-join send would pend to a 10s widget timeout).
private sendQuality(): void {
if (!this.joined || !this.lastQuality) return;
this.call.transport.send('io.lotus.set_quality', this.lastQuality).catch(() => undefined);
} }
public dispose() { public dispose() {
if (this.bodyMutationTimer !== undefined) {
clearTimeout(this.bodyMutationTimer);
this.bodyMutationTimer = undefined;
}
this.bodyMutationObserver.disconnect(); this.bodyMutationObserver.disconnect();
this.controlMutationObserver.disconnect(); this.controlMutationObserver.disconnect();
} }
+17 -1
View File
@@ -57,6 +57,10 @@ export class CallEmbed {
public joined = false; public joined = false;
// C-M4: set once dispose() has run so the hangup fallback timer can tell
// whether the embed was already torn down by the normal Close/Hangup echo.
public disposed = false;
// [lotus #2] Latest per-participant state from io.lotus.call_state, or null // [lotus #2] Latest per-participant state from io.lotus.call_state, or null
// until the fork sends the first one. When non-null, the speaker/mute hooks // until the fork sends the first one. When non-null, the speaker/mute hooks
// read it instead of scraping the EC iframe DOM. // read it instead of scraping the EC iframe DOM.
@@ -403,6 +407,8 @@ export class CallEmbed {
* @param opts * @param opts
*/ */
public dispose(): void { public dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.disposables.forEach((disposable) => { this.disposables.forEach((disposable) => {
disposable(); disposable();
}); });
@@ -501,9 +507,19 @@ export class CallEmbed {
private onCallJoined(): void { private onCallJoined(): void {
this.settleLoad(); this.settleLoad();
this.joined = true;
this.applyStyles(); this.applyStyles();
this.control.startObserving(); this.control.startObserving();
// C-H1: EC fires JoinCall again on an EC reconnect (this action has no
// once-guard). forceState() would reset live mic/video/deafen back to the
// join-time snapshot, so only run it on the FIRST join. On a rejoin we just
// re-apply styles/observers (above) and re-push the sticky fork state
// (deafen/quality), leaving the user's live media state untouched.
if (this.joined) {
this.control.resendForkState();
return;
}
this.joined = true;
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live // EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
this.control.forceState(this.initialState); this.control.forceState(this.initialState);
} }
+2
View File
@@ -3,6 +3,7 @@ import { allInvitesAtom, useBindAllInvitesAtom } from '../room-list/inviteList';
import { allRoomsAtom, useBindAllRoomsAtom } from '../room-list/roomList'; import { allRoomsAtom, useBindAllRoomsAtom } from '../room-list/roomList';
import { mDirectAtom, useBindMDirectAtom } from '../mDirectList'; import { mDirectAtom, useBindMDirectAtom } from '../mDirectList';
import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread'; import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread';
import { markedUnreadAtom, useBindMarkedUnreadAtom } from '../room/markedUnread';
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents'; import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers'; import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications'; import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications';
@@ -14,6 +15,7 @@ export const useBindAtoms = (mx: MatrixClient) => {
useBindRoomToParentsAtom(mx, roomToParentsAtom); useBindRoomToParentsAtom(mx, roomToParentsAtom);
useBindThreadNotificationsAtom(mx, threadNotificationsAtom); useBindThreadNotificationsAtom(mx, threadNotificationsAtom);
useBindRoomToUnreadAtom(mx, roomToUnreadAtom); useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
useBindMarkedUnreadAtom(mx, markedUnreadAtom);
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom); useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
}; };
+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); assert.equal(before, after);
}); });
test('PUT of { total: 0, highlight: 0 } removes the room (collapses to DELETE)', () => {
const store = createStore();
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 3, 1) });
// A phantom zero-count PUT (e.g. UnreadNotifications after the server zeroes
// counts) must clear the entry, not leave a stuck dot.
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 0, 0) });
assert.equal(get(store).has('!r:s'), false);
});
test('PUT of { 0, 0 } on an absent room is a no-op (same map reference)', () => {
const store = createStore();
const before = get(store);
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 0, 0) });
assert.equal(before, get(store));
assert.equal(get(store).has('!r:s'), false);
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// roomToUnreadAtom: PUT with parent aggregation // roomToUnreadAtom: PUT with parent aggregation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+33 -1
View File
@@ -82,7 +82,9 @@ const deleteUnreadInfo = (roomToUnread: RoomToUnread, allParents: Set<string>, r
allParents.forEach((parentId) => { allParents.forEach((parentId) => {
const oldParentUnread = roomToUnread.get(parentId); const oldParentUnread = roomToUnread.get(parentId);
if (!oldParentUnread) return; if (!oldParentUnread) return;
const newFrom = new Set([...(oldParentUnread.from ?? roomId)]); // `from` is always a Set for parent aggregates; the fallback must be an
// iterable of ids, NOT the roomId string (which would spread into chars).
const newFrom = new Set([...(oldParentUnread.from ?? [])]);
newFrom.delete(roomId); newFrom.delete(roomId);
if (newFrom.size === 0) { if (newFrom.size === 0) {
roomToUnread.delete(parentId); roomToUnread.delete(parentId);
@@ -136,6 +138,27 @@ export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefin
} }
if (action.type === 'PUT') { if (action.type === 'PUT') {
const { unreadInfo } = action; const { unreadInfo } = action;
// A { total: 0, highlight: 0 } entry is still a *present* map key, and the
// nav dot lights on any present entry — so a phantom zero-count PUT (e.g.
// the UnreadNotifications listener firing once the server zeroes counts)
// would leave a stuck dot. Collapse it to a DELETE so a fully-read room
// actually clears. Done before the unreadEqual short-circuit so an
// already-stuck { 0, 0 } gets removed too.
if (unreadInfo.total === 0 && unreadInfo.highlight === 0) {
if (get(baseRoomToUnread).has(unreadInfo.roomId)) {
set(
baseRoomToUnread,
produce(get(baseRoomToUnread), (draftRoomToUnread) =>
deleteUnreadInfo(
draftRoomToUnread,
getAllParents(get(roomToParentsAtom), unreadInfo.roomId),
unreadInfo.roomId,
),
),
);
}
return;
}
const currentUnread = get(baseRoomToUnread).get(unreadInfo.roomId); const currentUnread = get(baseRoomToUnread).get(unreadInfo.roomId);
if (currentUnread && unreadEqual(currentUnread, unreadInfoToUnread(unreadInfo))) { if (currentUnread && unreadEqual(currentUnread, unreadInfoToUnread(unreadInfo))) {
// Do not update if unread data has not changes // Do not update if unread data has not changes
@@ -253,6 +276,15 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
), ),
); );
if (isMyReceipt) { if (isMyReceipt) {
// 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 }); setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
} }
}; };
+15 -1
View File
@@ -264,7 +264,21 @@ export const removeFallbackSession = () => {
// the next setFallbackSession then persists the blob. When both exist the blob // the next setFallbackSession then persists the blob. When both exist the blob
// wins by construction. // wins by construction.
export const getFallbackSession = (): Session | undefined => { export const getFallbackSession = (): Session | undefined => {
const persisted = readSessionBlob() ?? readLegacyKeys(); const blob = readSessionBlob();
const legacy = readLegacyKeys();
// Prefer the atomic blob, EXCEPT when the legacy keys carry a later expiry: a
// pre-blob build's token refresh writes only the legacy keys, so a
// downgrade→upgrade can leave a stale blob newer than fresh legacy keys →
// booting on a dead token. Whichever has the later expiresAt wins.
let persisted = blob ?? legacy;
if (
blob &&
legacy &&
typeof legacy.expiresAt === 'number' &&
(typeof blob.expiresAt !== 'number' || legacy.expiresAt > blob.expiresAt)
) {
persisted = legacy;
}
if (!persisted) return undefined; if (!persisted) return undefined;
return sessionFromPersisted(persisted); return sessionFromPersisted(persisted);
}; };
+4
View File
@@ -183,6 +183,9 @@ export interface Settings {
urlPreview: boolean; urlPreview: boolean;
encUrlPreview: boolean; encUrlPreview: boolean;
showHiddenEvents: boolean; showHiddenEvents: boolean;
// [MSC1763] Opt-in: permanently redact your OWN messages once a room's
// retention window passes (default off — nothing auto-deletes by surprise).
enforceRetentionLocally: boolean;
legacyUsernameColor: boolean; legacyUsernameColor: boolean;
showNotifications: boolean; showNotifications: boolean;
@@ -288,6 +291,7 @@ const defaultSettings: Settings = {
urlPreview: true, urlPreview: true,
encUrlPreview: true, encUrlPreview: true,
showHiddenEvents: false, showHiddenEvents: false,
enforceRetentionLocally: false,
legacyUsernameColor: false, legacyUsernameColor: false,
showNotifications: true, showNotifications: true,
+13 -1
View File
@@ -1,7 +1,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createStore } from 'jotai'; import { createStore } from 'jotai';
import { toastQueueAtom, dismissToastAtom, ToastNotif } from './toast'; import { toastQueueAtom, dismissToastAtom, ToastNotif, createDownloadToast } from './toast';
// The queue lives in an unexported baseAtom; we drive the two write-only setters // The queue lives in an unexported baseAtom; we drive the two write-only setters
// (toastQueueAtom append + null no-op guard, dismissToastAtom remove-by-id) // (toastQueueAtom append + null no-op guard, dismissToastAtom remove-by-id)
@@ -85,3 +85,15 @@ test('dismissToastAtom for an unknown id is a no-op', () => {
['a'], ['a'],
); );
}); });
test('createDownloadToast: filename in body, no room navigation, unique ids', () => {
const a = createDownloadToast('photo.jpg');
assert.equal(a.displayName, 'Downloaded');
assert.equal(a.body, 'photo.jpg');
// roomId empty + an onClick present → clicking dismisses without navigating to a room.
assert.equal(a.roomId, '');
assert.equal(a.roomName, '');
assert.equal(typeof a.onClick, 'function');
const b = createDownloadToast('photo.jpg');
assert.notEqual(a.id, b.id);
});
+15
View File
@@ -1,8 +1,10 @@
import { atom } from 'jotai'; import { atom } from 'jotai';
import type { IconSrc } from 'folds';
export type ToastNotif = { export type ToastNotif = {
id: string; id: string;
avatarUrl?: string; avatarUrl?: string;
iconSrc?: IconSrc; // folds Icon src for a "system" toast (shown instead of an avatar/initials)
displayName: string; displayName: string;
body: string; body: string;
roomName: string; roomName: string;
@@ -12,6 +14,19 @@ export type ToastNotif = {
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
}; };
// Build a "download complete" system toast. Kept folds-free here (the icon src is
// passed in) so this stays a pure, testable builder. roomId is empty + onClick is
// set so a click only dismisses (never navigates to a room).
export const createDownloadToast = (filename: string, iconSrc?: IconSrc): ToastNotif => ({
id: `download-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
displayName: 'Downloaded',
body: filename,
roomName: '',
roomId: '',
iconSrc,
onClick: () => undefined,
});
const baseAtom = atom<ToastNotif[]>([]); const baseAtom = atom<ToastNotif[]>([]);
// Write-only setter used in ClientNonUIFeatures // Write-only setter used in ClientNonUIFeatures
+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);
+1 -1
View File
@@ -5,7 +5,7 @@ import pkg from '../../../package.json';
// //
// Installs pass-through wrappers around `console.warn` / `console.error` that // Installs pass-through wrappers around `console.warn` / `console.error` that
// ring-buffer any log line matching the KE-1..KE-4 bug-cluster signatures // ring-buffer any log line matching the KE-1..KE-4 bug-cluster signatures
// (see LOTUS_E2EE_INVESTIGATION.md). It NEVER swallows a log call — the // (E2EE KE-1..4 capture; see LOTUS_TODO.md). It NEVER swallows a log call — the
// original console method is always invoked — and it performs NO network I/O. // original console method is always invoked — and it performs NO network I/O.
// The report metadata is limited to SDK version / device id / user id / sync // The report metadata is limited to SDK version / device id / user id / sync
// state; the captured log lines themselves are intentional evidence and may // state; the captured log lines themselves are intentional evidence and may
+37 -22
View File
@@ -1,5 +1,7 @@
export type CompressionResult = { export type CompressionResult = {
blob: Blob; blob: Blob;
/** MIME type of the produced blob (currently always image/jpeg). */
type: string;
originalSize: number; originalSize: number;
compressedSize: number; compressedSize: number;
width: number; width: number;
@@ -17,22 +19,47 @@ export function isCompressible(file: File | Blob): boolean {
return isCompressibleType(file.type); return isCompressibleType(file.type);
} }
const JPEG_OUTPUT_TYPE = 'image/jpeg';
/** /**
* Compress an image file via canvas.toBlob JPEG at the given quality. * Compress an image file via canvas.toBlob JPEG at the given quality.
* Returns null if the browser cannot render the image (e.g. unsupported codec). * Returns null if the browser cannot render the image (e.g. unsupported codec)
* or if the source is left untouched to avoid data loss (see below).
*
* PNG is skipped entirely: it may carry an alpha channel, and re-encoding to
* JPEG composites transparency onto an opaque (black) background, corrupting the
* image. Returning null makes callers fall back to uploading the lossless
* original. The image is decoded with `imageOrientation: 'from-image'` so any
* EXIF orientation is baked into the pixels instead of being silently dropped.
*/ */
export async function compressImage( export async function compressImage(
file: File | Blob, file: File | Blob,
quality = 0.82, quality = 0.82,
): Promise<CompressionResult | null> { ): Promise<CompressionResult | null> {
if (!isCompressibleType(file.type)) return null; if (!isCompressibleType(file.type)) return null;
// Skip PNG (potential alpha) — re-encoding to JPEG would flatten transparency.
if (file.type === 'image/png') return null;
const img = await loadImage(file); let bitmap: ImageBitmap;
try {
bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
} catch {
// Corrupt/unsupported source: fall back to uploading the lossless original
// (the caller uses the original file on a null result) rather than rejecting,
// which would drop the file entirely from the Promise.allSettled upload.
return null;
}
const { width, height } = bitmap;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth; canvas.width = width;
canvas.height = img.naturalHeight; canvas.height = height;
const ctx = canvas.getContext('2d')!; const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0); if (!ctx) {
bitmap.close();
return null;
}
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
return new Promise((resolve) => { return new Promise((resolve) => {
canvas.toBlob( canvas.toBlob(
@@ -43,31 +70,19 @@ export async function compressImage(
} }
resolve({ resolve({
blob, blob,
type: JPEG_OUTPUT_TYPE,
originalSize: file.size, originalSize: file.size,
compressedSize: blob.size, compressedSize: blob.size,
width: img.naturalWidth, width,
height: img.naturalHeight, height,
}); });
}, },
'image/jpeg', JPEG_OUTPUT_TYPE,
quality, quality,
); );
}); });
} }
function loadImage(file: File | Blob): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = reject;
img.src = url;
});
}
export function formatFileSize(bytes: number): string { export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+157
View File
@@ -0,0 +1,157 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { NotificationCountType, ReceiptType } from 'matrix-js-sdk';
import { markAsRead } from './notifications';
// markAsRead sends an unthreaded read receipt at the latest main-timeline event,
// plus a THREADED receipt at each unread thread's latest loaded reply. The
// regression these tests guard against: a thread whose replies aren't loaded
// (lastReply() === null) must NOT produce a receipt for the thread root — that
// resolves to a MAIN receipt at an old event and permanently unreads the room.
type ReceiptCall = { eventId: string; receiptType: ReceiptType; unthreaded?: boolean };
const evt = (id: string, sending = false) => ({ getId: () => id, isSending: () => sending }) as any;
const thread = (id: string, lastReply: any) => ({ id, lastReply: () => lastReply }) as any;
type RoomOpts = {
timeline?: any[];
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,
getUserId: () => '@me:server',
sendReadReceipt: async (event: any, receiptType: ReceiptType, unthreaded?: boolean) => {
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, accountDataWrites };
};
test('main timeline: unthreaded receipt at the latest event', async () => {
const { mx, calls } = setup({ timeline: [evt('a'), evt('b'), evt('c')], readUpTo: 'a' });
await markAsRead(mx, '!r:server', false);
assert.equal(calls.length, 1);
assert.deepEqual(calls[0], { eventId: 'c', receiptType: ReceiptType.Read, unthreaded: true });
});
test('REGRESSION: an unread thread with unloaded replies (lastReply null) sends NO root receipt', async () => {
const t = thread('$root', null); // replies not loaded
const { mx, calls } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'a',
threads: [t],
threadUnread: { $root: 3 },
});
await markAsRead(mx, '!r:server', false);
// Only the main unthreaded receipt — never a receipt for the thread root.
assert.equal(calls.length, 1);
assert.equal(calls[0].eventId, 'b');
assert.equal(calls[0].unthreaded, true);
assert.ok(!calls.some((c) => c.eventId === '$root'));
});
test('unread thread with a loaded reply sends a threaded receipt at that reply', async () => {
const t = thread('$root', evt('$reply'));
const { mx, calls } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'a',
threads: [t],
threadUnread: { $root: 1 },
});
await markAsRead(mx, '!r:server', false);
const main = calls.find((c) => c.eventId === 'b');
const threaded = calls.find((c) => c.eventId === '$reply');
assert.ok(main && main.unthreaded === true);
assert.ok(threaded && threaded.unthreaded === false);
assert.equal(calls.length, 2);
});
test('main already read but a thread is unread: no main receipt, threaded receipt only', async () => {
const t = thread('$root', evt('$reply'));
const { mx, calls } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'b', // latest main event already read → getLatestValidEvent() null
threads: [t],
threadUnread: { $root: 2 },
});
await markAsRead(mx, '!r:server', false);
assert.equal(calls.length, 1);
assert.equal(calls[0].eventId, '$reply');
assert.equal(calls[0].unthreaded, false);
});
test('everything read: no receipts sent', async () => {
const t = thread('$root', evt('$reply'));
const { mx, calls } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'b',
threads: [t],
threadUnread: { $root: 0 }, // thread read too
});
await markAsRead(mx, '!r:server', false);
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({
timeline: [evt('a'), evt('b')],
readUpTo: 'b',
threads: [t],
threadUnread: { $root: 1 },
});
await markAsRead(mx, '!r:server', false);
assert.equal(calls.length, 0);
});
test('private receipt flag uses ReadPrivate', async () => {
const { mx, calls } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'a' });
await markAsRead(mx, '!r:server', true);
assert.equal(calls[0].receiptType, ReceiptType.ReadPrivate);
});
+45 -12
View File
@@ -1,11 +1,22 @@
import { MatrixClient, ReceiptType } from 'matrix-js-sdk'; import { MatrixClient, NotificationCountType, ReceiptType } from 'matrix-js-sdk';
import { getSettings } from '../state/settings'; import { getSettings } from '../state/settings';
import { readMarkedUnread, setMarkedUnread } from '../state/room/markedUnread';
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) { export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
const { privateReadReceipts } = getSettings(); const { privateReadReceipts } = getSettings();
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
if (!room) return; if (!room) return;
// Reading a room clears an explicit "mark as unread" (MSC2867). The binder's
// receipt-driven auto-clear does NOT fire when the room is already fully read
// (no receipt is sent below in that case), so clear it directly here.
if (readMarkedUnread(room)) {
setMarkedUnread(mx, roomId, false).catch(() => undefined);
}
const receiptType =
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read;
const timeline = room.getLiveTimeline().getEvents(); const timeline = room.getLiveTimeline().getEvents();
const readEventId = room.getEventReadUpTo(mx.getUserId()!); const readEventId = room.getEventReadUpTo(mx.getUserId()!);
@@ -17,17 +28,39 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip
} }
return null; return null;
}; };
if (timeline.length === 0) return;
const latestEvent = getLatestValidEvent();
if (latestEvent === null) return;
// Unthreaded receipt: with client threadSupport enabled the SDK would const latestEvent = timeline.length > 0 ? getLatestValidEvent() : null;
// otherwise scope this to the main timeline (thread_id: "main"), leaving if (latestEvent) {
// per-thread notification counts permanently unread. Unthreaded preserves // Unthreaded receipt: with client threadSupport enabled the SDK would
// the pre-threads wire behavior — one receipt clears everything. // otherwise scope this to the main timeline (thread_id: "main"). Unthreaded
await mx.sendReadReceipt( // clears the main timeline + every event up to this one.
latestEvent, await mx.sendReadReceipt(latestEvent, receiptType, true);
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read, }
true,
// Clear per-thread notification counts too — the room's unread dot sums them,
// so an unread thread reply keeps the dot lit even after the main timeline is
// read (threadSupport moves thread replies out of the main timeline, so the
// unthreaded receipt above doesn't necessarily cover them).
//
// CRITICAL: only send for a GENUINE loaded thread reply, via thread.lastReply().
// NEVER fall back to the thread root: a root event is "in the main timeline",
// so sendReadReceipt(root, false) resolves (via threadIdForReceipt) to a MAIN
// receipt at that old root event. If the root isn't in the loaded timeline it
// moves the main read receipt onto an event we don't have -> getEventReadUpTo()
// returns null -> the room is reported unread on every mark-read call (this was
// the P6 regression, amplified by the bulk mark-all-orphan-rooms-read callers).
// If a thread's replies aren't loaded (lastReply() null), just skip it.
const threads = room.getThreads();
await Promise.all(
threads.map((thread) => {
const unread =
room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
if (unread <= 0) return undefined;
const lastReply = thread.lastReply();
if (!lastReply || lastReply.isSending()) return undefined;
// Threaded receipt (unthreaded = false → the SDK scopes it to this thread
// via the reply's real threadRootId; it never touches the main marker).
return mx.sendReadReceipt(lastReply, receiptType, false).catch(() => undefined);
}),
); );
} }
+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;
+9 -1
View File
@@ -269,7 +269,15 @@ export const getUnreadInfos = (
if (roomHaveNotification(room) || roomHaveUnread(mx, room)) { if (roomHaveNotification(room) || roomHaveUnread(mx, room)) {
const mutedThreads = content ? getMutedThreads(content, room.roomId) : undefined; const mutedThreads = content ? getMutedThreads(content, room.roomId) : undefined;
unread.push(getUnreadInfo(room, mutedThreads)); const info = getUnreadInfo(room, mutedThreads);
// Skip a phantom {0,0} entry: a room whose ONLY unread is a muted thread has
// roomHaveNotification true (the server room total includes the muted
// thread's count), but getUnreadInfo subtracts it back to zero. Pushing it
// would still light the nav row + pollute "unread only" filters. Keep it
// only if there's real unread (count > 0) or a genuine unread marker.
if (info.total > 0 || info.highlight > 0 || roomHaveUnread(mx, room)) {
unread.push(info);
}
} }
return unread; return unread;
+2
View File
@@ -12,6 +12,8 @@ export async function scheduleMessage(
content: IContent, content: IContent,
sendAtMs: number, sendAtMs: number,
): Promise<string> { ): Promise<string> {
// A past/near target floors at 1000ms (send ~immediately) — an intentional,
// tested contract; the ScheduleMessageModal already guards ≥60s in the future.
const delayMs = Math.max(1000, Math.round(sendAtMs - Date.now())); const delayMs = Math.max(1000, Math.round(sendAtMs - Date.now()));
const txnId = `sched_${Date.now()}_${Math.random().toString(36).slice(2)}`; const txnId = `sched_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const path = `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`; const path = `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`;

Some files were not shown because too many files have changed in this diff Show More