Compare commits

...

54 Commits

Author SHA1 Message Date
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
jared 81904372bc docs(e2ee): investigation update — 41.7.0 delta + web-specific KE-1 root cause
CI / Build & Quality Checks (push) Successful in 10m49s
CI / Trigger Desktop Build (push) Successful in 21s
Code-read + upstream-issue triage this session:
- 41.7.0 / crypto-wasm 18.3.1 does NOT fix KE-1 (no OTK/upload change; #5200
  still open) — the SDK-pin remediation lever is closed.
- Confirmed root cause = rust-crypto store <-> Synapse OTK divergence; the
  leading web trigger is that cinny never requests persistent storage, so the
  IndexedDB crypto store is evictable while the localStorage session survives.
- New buildable preventive mitigation: navigator.storage.persist() on login
  (+ multi-tab guard, 400-loop recovery prompt). Added as §6 with a secondary
  KE-2 to-device-validation hypothesis and capture discriminators.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:14:46 -04:00
jared c82ab5c7f5 chore(contrib): security headers in example nginx/caddy configs (P6-4)
Add HSTS + Permissions-Policy + the standard X-Frame/X-Content/Referrer set to
the contrib nginx (443 block) and caddy examples; fix the caddy SPA try_files
fallback (stray space). Generic (no homeserver-specific CSP). The real prod
config lives in the matrix repo. P6-4 trimmed to headers only — patch-package /
types-drift / build-config skipped (see LOTUS_TODO).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:41:08 -04:00
jared ebcd8ec926 feat(ux): forward to multiple rooms + live bookmark previews (P6-3)
Forward: checkbox multi-select room picker + "Send to N rooms" batch send
(Promise.allSettled). Full success auto-closes; partial failure keeps the dialog
open with a "Forwarded to X/N — failed: …" summary and prunes the selection to
only the failures (retry won't duplicate to already-sent rooms). Content builder
extracted to a unit-tested forwardContent.ts (edit-forwarding, reply-strip,
undecryptable-refused; 4 tests).

Bookmarks: BookmarksPanel resolves each saved message's live event (useRoomEvent)
so previews reflect edits and show a deleted indicator for redactions; the stored
snapshot stays as the fallback while loading, on fetch failure, or after leaving
the room. Stored bookmark shape unchanged.

Gates: tsc/eslint/prettier clean, build OK, 665 tests. Reviewed (dup-resend on
retry + Checkbox readOnly fixed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:30:33 -04:00
jared 4ff07ea2bd feat(call): send io.lotus.set_deafen to the fork (P6-2 phase 1)
CallControl now sends the new io.lotus.set_deafen action (join-gated via
forceState) on every deafen / screenshare-audio-mute toggle + on join, ALONGSIDE
the retained iframe-DOM .muted hack (transitional). Against the current pinned
bundle the action is immediately error-replied + swallowed by .catch — inert, no
timeout. Reordered toggleSound() to commit state before setSound() so the sent
deafen value isn't inverted.

Phase 2 (after the fork is published): bump the pin lotus.1 -> lotus.2 and delete
the DOM hack. Docs: HANDOFF §12.4, LOTUS_TODO P6-2, LOTUS_BUGS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:12:08 -04:00
jared 804caa5130 feat(desktop): tray Do-Not-Disturb + Launch-on-login toggle (P6-1 web)
- useTauriDnd + manualDndAtom: the native tray "Do Not Disturb" toggle
  (lotus-dnd-changed event) OR's into the notification quiet-gate in
  ClientNonUIFeatures (both invite + message notifiers), alongside Focus Assist.
- AutostartSetting in Settings → General (desktop-only): reads/sets
  plugin:autostart via invoke. Mirrors the window-chrome setting.
- Docs: LOTUS_FEATURES desktop section (Linux parity + DND + autostart),
  LOTUS_TODO P6-1 → [~], LOTUS_BUGS verification row.

Gates: tsc/eslint/prettier clean, build OK, 661 tests. Native side committed on
cinny-desktop:main (CI-compile-pending).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:31:09 -04:00
jared 625f0c2386 docs(todo): add P6 post-audit batches (desktop parity, EC DOM-hack retirement, web UX, hygiene)
macOS explicitly out of scope; Linux is the parity target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:13:29 -04:00
jared 4d7a05c0f1 fix(a11y): review-wave fixes (P3-4)
CI / Build & Quality Checks (push) Successful in 11m3s
CI / Trigger Desktop Build (push) Successful in 22s
- `?` shortcut now stopImmediatePropagation so RoomView's type-to-focus handler
  doesn't steal focus into the composer behind the dialog (and swallow Escape) —
  CONFIRMED review finding.
- Typing live region stays mounted (empty when idle) so the FIRST "X is typing"
  is reliably announced (a status region added with its text isn't always read).
- Removed a stray empty `{}` JSX expression in MediaGallery (leftover from an
  auto-fix).

Reviewer verified the rest: collapsed-message labels, focus-return
classification (4 dialogs fixed, popouts correctly left), and all aria fixes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:57:32 -04:00
jared b5e7bcc0b8 chore: prettier-normalize page/style.css.ts (pre-existing debt)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:50:32 -04:00
jared bca371ad38 feat(a11y): label the moderation reason input (P3-4)
Missed from the form-labels commit — aria-label on the shared kick/ban/invite
reason input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:46:35 -04:00
jared 899a14c119 docs: P3-4 accessibility — features section, TODO/BUGS, LOTUS_TESTING §P
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:45:22 -04:00
jared 6728a1274d chore(a11y): enforce a curated jsx-a11y lint gate in CI (P3-4)
Enables ARIA-correctness rules (aria-props/proptypes/role/unsupported-elements,
role-has/supports-aria-props, no-redundant-roles, anchor/heading-has-content)
+ label-has-associated-control as errors — a regression gate for accessible
names + valid ARIA. control-has-associated-label deliberately NOT enabled (the
repo's <Text as="label" htmlFor> component pattern defeats its static analysis);
the real gaps it surfaced were fixed directly. Also disable max-classes-per-file
for test files (mock classes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:45:22 -04:00
jared 21dda93d1b feat(a11y): focus return, typing announcement, shortcuts help (P3-4)
- Focus returns to the trigger when closing 4 genuine dialogs (room-topic
  viewer, reaction viewer, header topic, Search) — 20 inline popouts/menus
  correctly left as-is (returning focus to a hover target would be wrong).
- Typing indicator announced via a visually-hidden role="status" region;
  the visual text is aria-hidden to avoid double announcement.
- New keyboard-shortcuts help dialog (press ?, ignored while typing),
  mounted in ClientNonUIFeatures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:45:22 -04:00
jared 4380041014 feat(a11y): label form controls + overlays (P3-4)
Accessible names for ~15 controls that lacked them: invite/join/create-room/
account-data/image-pack/private-note/power-level inputs (visible <label htmlFor>
where a label exists, else aria-label); the two range sliders (night-light
intensity, noise-gate threshold); the soundboard file input; media <video>
elements; and the Media Gallery (region) + Search (dialog) overlays. Hidden
notification/preview <audio> marked aria-hidden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:45:21 -04:00
jared 8729ccfcf5 feat(a11y): message semantics for screen readers (P3-4)
- Each message is role="article"; collapsed messages (consecutive from one
  sender) now carry an aria-label with sender + time — previously a screen
  reader heard only the body with no attribution (the biggest a11y gap).
  Pure messageAriaLabel() reuses the existing time utils (+3 tests).
- Editing a message announces "Editing message from <sender>" (ariaLabel
  threaded MessageEditor → CustomEditor; the main composer is unaffected).
- System emoji get role="img" + aria-label from the shortcode; custom
  emoticons always have an accessible name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:45:21 -04:00
jared 8ab1ec254b docs(testing): add July batch — threads, per-thread notifs, math, search cache, session, audit wave, desktop CSP (§O)
Fills the gap where LOTUS_BUGS referenced test IDs (P3-8/P4-1/P4-4/P4-8/N97a/
AW-1..4) with no matching procedures in the testing guide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:15:48 -04:00
jared 23f715857c docs: mark P4-8 (search cache) + session-atomicity as shipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:09:50 -04:00
jared f589182709 docs: deep-audit wave dispositions in LOTUS_BUGS
CI / Build & Quality Checks (push) Successful in 10m57s
CI / Trigger Desktop Build (push) Successful in 7s
Dep triage recorded (zero shipped exposure; SDK now 41.7.0 stable; dompurify
removed); Needs Verification rows for the audit-wave fixes (scheduled-cancel,
emoji lazy-load, SW precache, desktop CSP smoke).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:19:50 -04:00
jared ef573376ac chore(deps): matrix-js-sdk 41.6.0-rc.0 → 41.7.0 stable
Off the release candidate onto stable: pulls matrix-sdk-crypto-wasm 18.3.1 (a
security update) + MSC4140 delayed-event auth fixes. Thread/receipt API
signatures spot-checked unchanged (sendEvent threadId overloads, sendReceipt
unthreaded arg). Gates green: tsc/build/658 tests. E2EE runtime behavior needs
the usual live smoke (send/receive in an encrypted room, call keys).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:19:21 -04:00
jared 34d9272790 feat(call): denoise asset smoke check at ML-tier call start
HEAD-checks the copied denoise worklet/wasm/model assets for the selected model
and console.warns a single line listing anything missing — a silent asset skew
between the EC fork's expectations and vite's copied files would otherwise
disable noise suppression with no signal. Fire-and-forget; never blocks call
setup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:19:16 -04:00
jared 96f7187031 perf(audit): emojibase lazy-split, SW precache, Prism subset, lazy images
- emojibase (~965 KB) is now fully lazy: plugins/emoji.ts loads compact data +
  shortcode maps via a memoized dynamic import (rejections reset the memo so a
  mid-deploy chunk 404 can retry); reaction labels degrade to the raw glyph
  until loaded. Consumers get FRESH array references on load (the module arrays
  populate in place — same-ref state updates would skip re-render and leave
  emoji search empty; reviewer-caught). Verified out of the eager graph.
- Service worker precaches hashed assets (workbox precacheAndRoute, 82 entries
  ~10.8 MB incl. the crypto wasm): repeat visits stop re-downloading the app.
  index.html is NOT precached — navigations stay network-first so deploys are
  picked up immediately; the media-auth fetch handler is untouched.
- ReactPrism: curated 21-language set — chunk 574 KB → 71 KB.
- Timeline inline images get loading="lazy".
- Removed dead dompurify (+types); sanitize-html is the real sanitizer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:19:16 -04:00
jared 664dcd4cd8 fix(audit): correctness wave — ghost sends, Escape coordination, panel exclusion
- ScheduledMessagesTray: cancel prunes local state ONLY on confirmed server
  cancel; failures keep the item + show an inline error (was: a failed cancel
  looked cancelled but still sent at the scheduled time).
- Escape semantics: the composer consumes Escape (preventDefault+stopPropagation)
  iff autocomplete is open or a reply draft is set; the thread panel and Room's
  markAsRead act only on unconsumed Escape, and markAsRead defers entirely while
  a thread panel is open (listener order made it fire before the panel closed).
- Room: thread panel / media gallery are mutually exclusive (most-recently-
  opened wins); on mobile at most one right panel renders (thread > gallery >
  members) instead of stacked fullscreen overlays.
- RemindMeDialog: busy-disabled presets (no more double-click duplicates),
  try/catch with inline error, close only on success.
- ThreadTimeline: "Jump to Latest" floating chip when scrolled up (RoomTimeline
  idiom).

From the 4-auditor deep-audit wave; reviewer-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:18:51 -04:00
jared 7f960b026b fix(build): complete the threadSummary rename — remove the old casing
CI / Build & Quality Checks (push) Successful in 10m44s
CI / Trigger Desktop Build (push) Successful in 7s
The deletions from the git-mv in 992d2b83 were unstaged by a concurrent
worktree operation before commit, so the pushed tree contained BOTH
threadSummary.ts and threadSummaryData.ts (and the Windows case-collision
persisted). This commit removes the stale originals; caseCollision.test.ts
would have failed CI on the incomplete state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 23:44:59 -04:00
jared 992d2b83b3 fix(build): rename threadSummary.ts — case-collision broke the Windows release
CI / Build & Quality Checks (push) Failing after 5m22s
CI / Trigger Desktop Build (push) Has been skipped
threadSummary.ts (pure helpers) and ThreadSummary.tsx (chip component) lived in
the same directory differing only by case. On the case-insensitive Windows
release runner, RoomTimeline's extensionless import of ./thread/ThreadSummary
resolved .ts BEFORE .tsx and matched the helper module → rolldown
MISSING_EXPORT "ThreadSummary" — invisible on every Linux/macOS build (and the
cause of the earlier masked pdf.worker failure). Helper module renamed to
threadSummaryData.ts (+ test), 3 importers updated.

Prevention: new caseCollision.test.ts walks src/ and fails on any same-directory
names differing only by case (extensionless compare, so Foo.tsx vs foo.ts is
caught) — verified it fails on the pre-rename tree. Runs in the hard CI gate.

Gates: tsc clean, eslint/prettier clean, build OK, 658/659 tests (1 IDB skip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 23:43:20 -04:00
jared a9505ca5b2 feat(soundboard): shared room/space packs (like emoji/stickers), grid picker, management
CI / Build & Quality Checks (push) Successful in 10m56s
CI / Trigger Desktop Build (push) Successful in 8s
Soundboard v2 — a near-parallel of the custom-emoji image-pack system for
in-call audio clips.

- Data model: 3-tier packs mirroring MSC2545 — room/space pack (state event
  io.lotus.soundboard, inherited by child rooms via parent-space aggregation),
  global refs (io.lotus.soundboard_rooms), and the personal pack
  (io.lotus.soundboard account data; the v1 flat-list content is migrated to the
  pack shape on read). New plugins/soundboard/ (readers, SoundboardPack, utils) +
  hooks/useSoundboardPacks (useRelevantSoundboardPacks = user U global U room,
  deduped). Unit-tested (migration + slug).
- Management: reusable SoundboardPackEditor (name + emoji + per-clip volume +
  delete + upload + batched save), power-level-gated for room packs like emoji
  packs; a Soundboard page wired into Room + Space settings.
- In-call: CallSoundboard rewritten as a Discord-style grid grouped by pack
  (emoji + name tiles), sourcing room+parent-space U personal clips; a Manage
  toggle embeds the editors; per-clip volume x master volume on playback.
- Spam guard: host gates on a playing key (fork enforces one clip at a time).
- Control bar: Mute-Screenshare moved next to the Screenshare button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:21:50 -04:00
jared dca51a41ef fix(forward): full-width search + deep-audit fixes for message forwarding
Audit of ForwardMessageDialog, fixes:
- Search input was intrinsic-width (sat in a default Row Box with no grow) —
  now a Column Box stretches it full-width, matching every other search input.
- Search field is auto-focused on open (FocusTrap initialFocus; was nothing).
- Edited messages now forward the LATEST edit (m.new_content via
  getEditedEvent) instead of the stale pre-edit body.
- Reply fallbacks stripped (trimReplyFromBody + <mx-reply> block) along with
  m.relates_to, so forwards stand alone instead of quoting the old room.
- Undecryptable events are refused with an inline error (previously forwarded
  m.bad.encrypted junk); send failures now show an error instead of silently
  resetting.
- sendEvent uses the typed threadId-aware overload (explicit null) instead of
  an untyped (mx as any) call relying on the SDK's legacy arg-sniffing.
- Room list + filter memoized (was re-sorting all rooms every keystroke).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 23:19:01 -04:00
jared 579449acc3 docs: Slack-style per-thread notifications (P4-1) across catalog/README/TODO/BUGS
CI / Build & Quality Checks (push) Successful in 10m44s
CI / Trigger Desktop Build (push) Successful in 7s
LOTUS_FEATURES: Notifications subsection under Threads (participating default,
per-thread All/Mentions/Mute, badge behavior). README: thread-notifications
bullet. LOTUS_TODO: P4-1 → [~] + 6-step live-QA checklist + caveats.
LOTUS_BUGS: verification row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:53:32 -04:00
jared 34592d9144 fix(build): copy-pdf-worker must never mask the real build error
closeBundle also runs when the build FAILED mid-render (dist/ absent); the
plugin's copyFileSync then threw ENOENT and vite reported THAT instead of the
actual render error — exactly what hid the real failure in the Windows desktop
CI run. Now: warn-and-skip on any error, mkdir the dest dir when copying.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:53:32 -04:00
jared 0adce52d37 fix(threads): review-wave fixes for per-thread notifications
- useRoomsListener now PREPENDS the emitting Room (was appended): the SDK emits
  RoomEvent.UnreadNotifications with VARIABLE arity (0/1/2 args), so a trailing
  extra arg landed in the wrong positional slot on the most common room-count
  sync path — room.isSpaceRoom() threw inside the SDK emit loop and the badge
  PUT never ran. Both consumers updated (CONFIRMED HIGH review finding).
- roomToUnread: SpaceChild RESET now passes the thread prefs so muted-thread
  subtraction survives space-child state changes.

Reviewer also verified: badge subtraction math exact (no double-subtraction),
encrypted thread replies caught by the timeline guard (m.relates_to is
cleartext), fresh prefs flow to handlers, single-owner wiring load-bearing.
Documented-acceptable: hasCurrentUserParticipated can lag until the server
bundle refreshes after your first reply; dedupe maps grow per-session only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:53:32 -04:00
jared 501d493ca4 feat(threads): Slack-style per-thread notifications (P4-1)
Default = Participating: thread replies notify only when you've posted in the
thread or are @mentioned; per-thread override to All / Mentions-only / Mute via
a bell menu in the thread panel header. Modes sync across devices in
io.lotus.thread_notifications account data (pruned on write: left rooms, >180d,
cap 200/room). Muted threads: no notifications/sounds, chip badge suppressed
(+BellMute glyph), and their counts are subtracted from the room's sidebar
badge (client-side; clamped ≥0).

Also fixes the thread notification path itself: thread replies are now owned by
exactly ONE handler (room-level ThreadEvent.NewReply via a new useRoomsListener
hook, with per-thread dedupe, panel-aware focus suppression, and per-thread OS
tag coalescing) — the existing RoomEvent.Timeline handlers in the notifier and
the unread binder are explicitly thread-guarded, eliminating the previously
un-gated/double path. Room badges now also refresh live on
RoomEvent.UnreadNotifications (surgical per-room PUT; fixes thread-badge lag).

Pure decision core shouldNotifyThreadReply (13-case matrix) + prune + unread
subtraction: +32 tests (648 total). E2EE caveat documented: mentions-only may
under-notify pre-decryption (same class as the existing path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:39:10 -04:00
jared ffb934fce6 docs: threads + July batch across catalog/README/TODO/BUGS
CI / Build & Quality Checks (push) Successful in 10m38s
CI / Trigger Desktop Build (push) Successful in 6s
- LOTUS_FEATURES: new Threads section (+TOC) — panel, summary chips, thread
  composer isolation, under-the-hood notes; entries for KaTeX math, opt-in
  encrypted-search cache, hardened session storage, Crypto Diagnostics.
- README: threads bullet (with the replies-move-to-panel release note), math,
  search-cache bullets.
- LOTUS_TODO: P3-8 → [~] implemented + 6-step live-QA checklist; P4-1 marked
  unblocked.
- LOTUS_BUGS: Needs Verification rows for P3-8 / P4-4 / P4-8 / session sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:58:42 -04:00
jared 440c1fe948 fix(threads): review-wave fixes — decryption re-render, receipt dedupe, chip perf
Two-reviewer audit of the thread stack; confirmed findings fixed:
- ThreadTimeline: wrap encrypted events in EncryptedContent so a live-arriving
  E2EE reply re-renders when its key decrypts (decryption emits neither
  RoomEvent.Timeline nor ThreadEvent.Update — previously stuck at "Unable to
  decrypt").
- ThreadPanel: mark-read deduped on the latest event id (RoomEvent.Timeline
  re-emits per backfilled event/edit/reaction; previously up to N receipt POSTs
  per panel open) + rejection handled with retry.
- RoomTimeline: ThreadSummary chips now mount only for events carrying thread
  data (each chip holds a room-level listener; one per rendered message would
  blow the SDK's 100-listener emitter cap) with a single room-level
  ThreadEvent.New tick for new-thread liveness.
- useThreadPendingEvents: keep a sent reply visible through the /send-response→
  /sync window (was flashing out of the pending strip before landing).
- ThreadTimeline: reseed the window on RoomEvent.TimelineReset (gappy sync left
  a detached timeline).

Documented-acceptable (reviewer-noted): thread typing shows as room typing (no
per-thread typing in the spec; Element matches), thread panel + members drawer
can be open together, scheduled-send is thread-unaware but unreachable there.

Gates: tsc clean, eslint 0 errors, build OK, 616/617 tests (1 IDB skip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:58:42 -04:00
jared aa62df9c75 feat(threads): Thread Panel — full side drawer (P3-8)
Right-side thread drawer (MembersDrawer pattern; mobile fullscreen):
- ThreadPanel: header + close/Escape, ThreadTimeline, its own RoomInput
  (threadRootId prop; drafts/replies/uploads isolated per roomId::threadId;
  schedule + slash-commands off in threads v1) and threaded mark-as-read.
- ThreadTimeline: lean reimplementation over thread.liveTimeline — copied
  useTimelinePagination pattern (/relations back-pagination + decryption),
  virtualized, root event emphasized + "N replies" divider, reactions/edits/
  redactions, and a pending strip (chronological local echo never enters the
  thread timelineSet — rendered from LocalEchoUpdated instead).
- ThreadSummary chips on root messages (server-aggregated bundle or live
  Thread; unread badge via getThreadUnreadNotificationCount) keep threads
  discoverable now that replies leave the main timeline.
- Reply-in-Thread menu + thread indicators open the panel; deep links to
  thread events redirect into it.
- State: roomIdToActiveThreadIdAtomFamily + getThreadDraftKey (+18 tests).

Gates: tsc clean, eslint 0 errors, build OK, 616/617 tests (1 IDB skip).
Awaiting live QA; release note: threaded replies no longer render inline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:45:20 -04:00
jared 15ac538a4b feat(threads): enable SDK threadSupport + unthreaded read receipts (P3-8 step 0)
threadSupport:true makes matrix-js-sdk partition m.thread relations into Thread
objects (replies leave the main timeline; roots stay). markAsRead now sends
UNTHREADED receipts so one receipt still clears room + thread notification
counts — without this, badges would stick unread. The thread panel + summary
chips land in the same push.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:28:58 -04:00
jared 39cfc23ebe docs: backlog housekeeping — stale items closed, Thread Panel design captured
CI / Build & Quality Checks (push) Successful in 10m44s
CI / Trigger Desktop Build (push) Successful in 11s
TODO: P4-7 already-implemented [x]; P4-6 mozilla test enablement verified live;
Audit-3 researched → deferred tracking MSC4427 (banner_url proposal, unmerged);
P3-8 Thread Panel now carries the complete SDK-evidence-backed build plan
(threadSupport side effects, local-echo gap, receipt fix, 4-agent partition) —
ready for its own session. BUGS: N127 removed, Big #5 (backgrounds/seasonal)
done, CDN env-var closed (VITE_DECORATION_CDN exists), test count updated, KE
section points at the new investigation kit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:19:02 -04:00
jared 7a8cadc6ec feat(diag): E2EE investigation kit for the KE-1→4 cluster
LOTUS_E2EE_INVESTIGATION.md: per-KE capture runbook (console signatures, synapse
log greps + SQL against the documented LXC deployment, the KE-1⇒KE-2 causality
decision tree, ranked remediations incl. what a crypto-store reset wipes; SDK
finding: stable 41.6.0 has no OTK fix over our RC pin). Client: capture-only
console ring buffer (cryptoDiagLog, KE-signature-matched, max 200) + a Crypto
Diagnostics card in Developer Tools with a download-report button. ClientRoot
installs the capture hook at module load and mounts useSessionSync (cross-tab
sessions, prior commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:19:02 -04:00
jared 91bd360125 fix(sessions): atomic session blob + cross-tab sync (N97 partial)
Session now persists as ONE atomic cinny_session_v1 JSON write (blob-wins read,
transparent migration from the ~10 legacy keys, dual-write kept one release for
rollback). subscribeSessionChanges + useSessionSync reload a tab whose session
was changed/removed by another tab (logout/login/token rotation). OIDC refresher
already routes through setFallbackSession, so rotations stay atomic. Tests 7→22.
Full token-protection redesign remains tracked in LOTUS_BUGS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:19:02 -04:00
jared 7da960ac8c feat(search): opt-in persistent index for encrypted-room search (P4-8)
Raw-IndexedDB cache (lotus-search-cache: messages keyed [roomId,eventId] +
per-room coverage) merged into local search with in-memory-wins dedupe. OPT-IN
(default off) via a standalone atom — stores decrypted text at rest, so it ships
with a privacy note, a Clear button, and an unconditional wipe on logout
(initMatrix). All IDB errors degrade to cache-miss. +8 tests (1 IDB skip in node).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:19:02 -04:00
jared ed51c39fe7 feat(messages): KaTeX math rendering (P4-4)
Renders LaTeX via spec data-mx-maths spans/divs (KaTeX render of the attr,
children as fallback) and conservative $…$ / $$…$$ text detection (escape-aware,
currency-guarded, never inside code/pre). KaTeX + CSS load lazily on first math
(ReactPrism pattern) — verified absent from the eager bundle. Sanitizer
unchanged by design (we render post-sanitize from attr/text; no incoming MathML
accepted). +14 unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:19:02 -04:00
169 changed files with 9927 additions and 3210 deletions
-666
View File
@@ -1,666 +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` |
**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.
-159
View File
@@ -1,159 +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 |
**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
### Calls / Audio
- ~~**N127 — ML denoise shim is never injected in `vite dev`.**~~ **RESOLVED (dissolved by the A7 denoise cutover).** `vite.config.js` no longer injects a getUserMedia shim at all — the forked Element Call runs ML denoise in-source as a LiveKit `TrackProcessor` (activated by `lotusDenoiseSource=1`), so there is no build-time injection that could be missing in dev. Nothing to fix.
### 🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED · 👤 SENIOR ENGINEER
> **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.**
- **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** (`state/sessions.ts`) — risks inconsistent state / races across tabs.
- **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** (`41.6.0-rc.0`); `@atlaskit` and build tools (`vite`, `typescript`, `eslint`) on unstable/experimental pins — review for stable versions; RC SDK is a tree-shaking/bundle-size risk.
- **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 — 545 tests across 62 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 (the decoration CDN is now single-sourced in `avatarDecorations.ts`, but the literal is still in-repo).
- **`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.** Current backgrounds are basic CSS; goal is high-fidelity, research-backed, GPU-accelerated designs (layered `oklch`, `backdrop-filter`, `contain:paint`) with WCAG-AA overlay contrast. Treat each as its own design sprint.
+96 -10
View File
@@ -18,15 +18,16 @@ Last updated: June 2026.
9. [Per-Message Read Receipts](#per-message-read-receipts) 9. [Per-Message Read Receipts](#per-message-read-receipts)
10. [Delivery Status Indicators](#delivery-status-indicators) 10. [Delivery Status Indicators](#delivery-status-indicators)
11. [Messaging Enhancements](#messaging-enhancements) 11. [Messaging Enhancements](#messaging-enhancements)
12. [Presence](#presence) 12. [Threads (P3-8)](#threads-p3-8)
13. [UX & Composer](#ux--composer) 13. [Presence](#presence)
14. [Room Customization](#room-customization) 14. [UX & Composer](#ux--composer)
15. [Moderation](#moderation) 15. [Room Customization](#room-customization)
16. [Notifications](#notifications) 16. [Moderation](#moderation)
17. [Server Integration](#server-integration) 17. [Notifications](#notifications)
18. [Infrastructure](#infrastructure) 18. [Server Integration](#server-integration)
19. [Desktop App Features](#desktop-app-features) 19. [Infrastructure](#infrastructure)
20. [Key Custom Files](#key-custom-files) 20. [Desktop App Features](#desktop-app-features)
21. [Key Custom Files](#key-custom-files)
--- ---
@@ -329,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`)
@@ -690,6 +691,24 @@ Context menu → **Forward** allows forwarding a message to any room the user is
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds) passed to the search API - The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds) passed to the search API
- A chip shows the active date range with an **×** button to clear it - A chip shows the active date range with an **×** button to clear it
### Encrypted Search Cache (P4-8, opt-in)
Persistent local index for encrypted-room search, so coverage survives page reloads instead of requiring re-pagination + re-decryption every session.
- Raw IndexedDB (`lotus-search-cache`): message rows keyed `[roomId, eventId]` + per-room coverage markers; merged into local search results with in-memory-wins dedupe
- **Opt-in, default OFF** (it stores decrypted text at rest): toggle + "Clear cached index" live in the search panel's Encrypted Rooms section, with the privacy note "Stores decrypted text on this device"
- Always wiped on logout; any IndexedDB error degrades to a cache-miss (never breaks search)
- Files: `src/app/utils/searchCache.ts`, `src/app/state/searchCacheEnabled.ts`, `features/message-search/useLocalMessageSearch.ts`
### Math / LaTeX Rendering (P4-4)
KaTeX-rendered math in messages, two paths:
- **Spec path (CS-API §11.5):** `<span/div data-mx-maths="…">` in `formatted_body` renders the attribute's LaTeX (block for div, inline for span); on render failure the element's child fallback content shows instead
- **Plain-text path:** `$…$` (inline) and `$$…$$` (block) with conservative rules — escape-aware (`\$`), currency-guarded (`$5 and $10` stays text), never inside `code`/`pre`
- KaTeX + its CSS load lazily on first math encountered — zero cost to the main bundle
- Files: `src/app/utils/mathParse.ts` (+14 tests), `components/math/KaTeX.tsx`, `plugins/react-custom-html-parser.tsx`
### Image / Video Captions ### Image / Video Captions
Images and videos can be sent with a caption. The caption and media are sent as a single event. Images and videos can be sent with a caption. The caption and media are sent as a single event.
@@ -765,6 +784,36 @@ Generic (non-domain-specific) cards display a Google S2 favicon. Empty or unpars
--- ---
## Threads (P3-8)
Full threaded-conversation support (`m.thread`, matrix-js-sdk `threadSupport`), Element-consistent.
### Thread Panel
A right-side drawer (mirrors the members drawer; fullscreen on mobile) with the thread's root message emphasized at top, an "N replies" divider, the full reply timeline (virtualized, back-paginates via `/relations`, decrypts E2EE threads), reactions/edits/redactions, and its own composer. Open it from **Reply in Thread** in the message menu, a reply's thread indicator, or a summary chip; close with **×** or Escape. Reading the panel sends threaded read receipts so per-thread unread counts clear.
### Summary Chips
Root messages in the main timeline show a **"N replies · time"** chip (server-aggregated `m.thread` bundle, or the live Thread once loaded) with an unread badge — threaded replies no longer render inline in the main timeline, so the chip is how conversations stay discoverable.
### Thread Composer
The panel embeds the full composer (uploads, emoji, stickers, GIFs, voice, location, polls) with drafts, reply state, and upload queues **isolated per thread** (`roomId::threadRootId` keys). Replies-to-replies produce spec-correct `m.thread` + `m.in_reply_to` (`is_falling_back: false`). Scheduling and slash commands are disabled inside threads (v1).
### Notifications (Slack-style, P4-1)
By default you're notified for a thread reply only when you **participate** in that thread (you've posted in it) or the reply **@mentions** you — other threads accumulate quietly behind their chip badges. Every thread can be overridden from the bell menu in the panel header: **Default (participating) / All replies / Mentions only / Mute**. Modes sync across your devices (`io.lotus.thread_notifications` account data, auto-pruned). Muting a thread silences notifications and sounds, removes the chip's unread badge (a small bell-mute glyph shows instead), and subtracts that thread from the room's sidebar unread badge (client-side — other Matrix clients on the account still count it).
### Under the Hood
- `threadSupport: true` (startClient) partitions thread events into SDK `Thread` timelines; markAsRead sends **unthreaded** receipts so room badges keep clearing
- Thread replies are notified via exactly one path (room-level `ThreadEvent.NewReply` w/ per-thread dedupe + panel-aware focus suppression); the main timeline notifier is thread-guarded, and room badges refresh live on `RoomEvent.UnreadNotifications`
- Pending sends render via a `LocalEchoUpdated` strip (chronological local echo never enters thread timelineSets)
- Deep links to thread events redirect into the panel
- Files: `features/room/thread/*`, `state/room/thread.ts`, `hooks/useThreadSummary.ts` (+35 tests across the stack)
---
## Presence ## Presence
### Discord-Style Presence Selector ### Discord-Style Presence Selector
@@ -856,6 +905,14 @@ Hook: `src/app/hooks/useUserNotes.ts`
## UX & Composer ## UX & Composer
### Forward to Multiple Rooms (P6-3)
The Forward Message dialog is a checkbox multi-select: pick any number of rooms (search + select persist across queries) and **"Send to N rooms"** forwards in one batch (`Promise.allSettled`). Full success auto-closes; a partial failure keeps the dialog open with a "Forwarded to X/N — failed: …" summary. The forwarded content (latest edit via `m.new_content`, reply-quote stripped, undecryptable refused) is built by the shared, unit-tested `forwardContent.ts`.
### Live Bookmark Previews (P6-3)
`BookmarksPanel` resolves each saved message's **live event** (`useRoomEvent`) so previews reflect **edits** and show a **deleted** indicator for redactions, instead of the save-time snapshot. The stored snapshot (`previewText`) remains the fallback while loading, on fetch failure, or when you've **left the room**.
### Message Length Counter ### Message Length Counter
A character count indicator is shown in the composer when `charCount > 0`. The counter resets to zero when switching rooms. A character count indicator is shown in the composer when `charCount > 0`. The counter resets to zero when switching rooms.
@@ -1130,6 +1187,18 @@ Three one-tap presets at the top of **Settings → Notifications** that apply a
--- ---
## Accessibility (P3-4)
WCAG 2.1 AA hardening of the golden path (find room → read → reply → send) for keyboard and screen-reader users.
- **Timeline for screen readers:** each message is `role="article"`; **collapsed messages announce their sender + time** (they drop the visible header, so AT would otherwise hear the body with no attribution). The timeline is a `role="log"` `aria-live="polite"` region so new messages are announced; emoji/emoticons carry text labels.
- **Live status:** typing indicators announce via a `role="status"` region; editing a message announces "Editing message from <sender>".
- **Forms & overlays:** all inputs have associated labels (visible `<label htmlFor>` or `aria-label`); the Media Gallery and Search overlays are named.
- **Focus management:** skip-to-content link + `nav`/`main` landmarks; genuine dialogs return focus to their trigger on close (inline popouts intentionally keep focus in context).
- **Keyboard-shortcuts help:** press <kbd>?</kbd> for a dialog of the existing shortcuts (Escape, type-to-focus composer, Enter/Shift+Enter send, message actions).
- **Regression gate:** a curated `eslint-plugin-jsx-a11y` rule set (ARIA correctness + label association) runs in CI. Files: `components/message/*`, `features/room/RoomViewTyping.tsx`, `features/shortcuts/*`, `utils/a11y.ts`, `eslint.config.mjs`.
- _Known limitation:_ list virtualization keeps far-scrolled history out of the a11y tree (perf trade-off); newly-arriving messages are announced.
## Infrastructure ## Infrastructure
### Authenticated Media ### Authenticated Media
@@ -1160,6 +1229,14 @@ The `useAuthentication` parameter was previously mispositioned, causing unauthen
The `encUrlPreview` setting defaults to `true` rather than `false`. A security advisory chip in **Settings → Privacy** explains the tradeoff (the homeserver can see which URLs are being previewed) so users can make an informed choice. The `encUrlPreview` setting defaults to `true` rather than `false`. A security advisory chip in **Settings → Privacy** explains the tradeoff (the homeserver can see which URLs are being previewed) so users can make an informed choice.
### Hardened Session Storage (N97 partial, 2026-07)
The session persists as ONE atomic `cinny_session_v1` JSON write (previously ~10 separate localStorage keys written non-atomically). Reads prefer the blob with transparent migration from the legacy keys (dual-written one release for rollback). Cross-tab sync: logging out or in from one tab reloads the others so no tab runs with stale credentials. `state/sessions.ts` (22 tests), `hooks/useSessionSync.ts`.
### 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 diagnosis: the Encryption / E2EE section of [`LOTUS_TODO.md`](./LOTUS_TODO.md). `utils/cryptoDiagLog.ts`, `features/settings/developer/CryptoDiagnostics.tsx`.
--- ---
## Desktop App Features ## Desktop App Features
@@ -1198,6 +1275,15 @@ Windows toasts with **click-to-open-room** and **inline quick reply** (WinRT `To
When Windows Focus Assist / Quiet Hours is active, Lotus suppresses its own notifications + sounds (reuses the quiet-hours gate). `useTauriFocusAssist` + `focusAssistActiveAtom``native/focus_assist.rs` (`SHQueryUserNotificationState`). When Windows Focus Assist / Quiet Hours is active, Lotus suppresses its own notifications + sounds (reuses the quiet-hours gate). `useTauriFocusAssist` + `focusAssistActiveAtom``native/focus_assist.rs` (`SHQueryUserNotificationState`).
### Linux parity + cross-platform extras (P6-1)
Rounds out the native app beyond Windows (macOS out of scope):
- **No-sleep during calls on Linux** — a D-Bus `org.freedesktop.ScreenSaver` inhibit (zbus) keeps the display awake mid-call, matching the Windows behavior. `native/power.rs`.
- **Launcher unread badge on Linux** — best-effort Unity `LauncherEntry` D-Bus signal (Ubuntu/Dash-to-Dock/KDE), mirroring the Windows taskbar badge.
- **Launch on login** — `tauri-plugin-autostart` + a **Settings → General "Launch on login"** toggle (desktop-only).
- **Tray "Do Not Disturb"** — a tray checkbox that silences Lotus notifications (feeds `manualDndAtom` into the same quiet-gate as Focus Assist). `useTauriDnd`.
### Custom Window Chrome (P5-47) ### Custom Window Chrome (P5-47)
Opt-in (Settings → General → **Custom Window Chrome**): replaces the OS title bar with a TDS-styled titlebar (min / max / close + drag region), runtime-reversible via `set_decorations`. `features/desktop/TitleBar.tsx` + `useTauriWindowChrome``native/chrome.rs`. Opt-in (Settings → General → **Custom Window Chrome**): replaces the OS title bar with a TDS-styled titlebar (min / max / close + drag region), runtime-reversible via `set_decorations`. `features/desktop/TitleBar.tsx` + `useTauriWindowChrome``native/chrome.rs`.
+153 -7
View File
@@ -1,6 +1,6 @@
# Lotus Chat — Manual Testing Guide # Lotus Chat — Manual Testing Guide
**Generated:** June 2026 **Generated:** June 2026 · **Updated:** July 2026 (added §O — threads, per-thread notifications, math, search cache, session hardening, audit wave, desktop CSP)
**Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first. **Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first.
> **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual. > **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual.
@@ -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)
@@ -573,10 +573,156 @@ Log into **matrix.lotusguild.org** (password) and **matrix.org**.
--- ---
## 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 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
1. Hover a message → **Reply in Thread** (message menu). The right-side **thread panel** opens with that message as the root.
2. Send text, an emoji, and a file upload into the thread; have the second person reply too.
3. Reply to a reply _inside_ the panel.
**Expected:** the panel shows the root at top + an "N replies" divider + the reply timeline (own composer at the bottom). Your sends appear immediately (pending → confirmed). A reply-to-a-reply is a proper thread reply. In the **main** timeline the replies do **not** appear inline — the root message instead shows a **"N replies · time"** chip. Clicking the chip (or a reply's thread indicator) opens the panel. **×** or **Escape** closes it; on mobile the panel is fullscreen. Scrolled up in a long thread → a **Jump to Latest** chip appears. Reload the page → the root/reply split persists; in an **encrypted** room the thread replies decrypt (not "Unable to decrypt").
### O2. Per-thread notifications (P4-1, Slack-style) — 👥 2 people
1. Have the second person reply in a thread **you have posted in** → expect a notification + sound.
2. Have them reply in a thread **you have never touched** and don't @mention you → expect **silence** (only the chip's unread badge updates).
3. Have them **@mention** you in any thread → expect a notification regardless of participation.
4. Open the panel's **bell menu** (header) → set the thread to **Mute** → expect no notifications, the chip's unread badge gone (bell-mute glyph shown), and the room's **sidebar badge drops** by that thread's count. Try **All** (every reply notifies) and **Mentions only** (only @mentions).
5. On a **second device**, confirm the same per-thread modes are set (they sync via account data).
6. Room-level **Mute** (room context menu) still silences everything, including thread overrides.
**Known caveat:** Mentions-only can under-notify in E2EE rooms (the decision runs before decryption). Muted-thread badge subtraction is Lotus-only.
### O3. Math / LaTeX (P4-4)
Send each and confirm rendering: `$x^2 + y^2$` (inline), `$$\int_0^1 f(x)\,dx$$` (block, centered), `$5 and $10 for lunch` (**stays plain text** — currency guard), and a code block containing `$x$` (**stays literal** inside the code block). **Expected:** the first two render as math (KaTeX); the last two are untouched. First math of the session may show the raw `$…$` for a beat while the KaTeX chunk lazy-loads, then renders.
### O4. Encrypted search cache (P4-8) — opt-in
In an **encrypted** room's message search, enable **"Persist search index on this device"** (Encrypted Rooms panel). Search, then **reload** and search the same term. **Expected:** coverage survives the reload (results without re-paginating everything). **Clear cached index** empties it. **Log out** → the cache is wiped (privacy). Toggling the setting OFF does **not** wipe (only Clear/logout do).
### O5. Session hardening (N97a) — cross-tab
1. Log in on a build that predates the change, then load this build → you stay logged in (legacy keys migrate to the `cinny_session_v1` blob; check DevTools → Application → Local Storage).
2. Open the app in **two tabs**; **log out** in tab A → tab B reloads to the auth screen within a moment. Log in again in one tab → the other reloads too.
### O6. Audit-wave correctness fixes (AW-1)
- **Scheduled-message cancel:** schedule a message, then cancel it **with the network cut** (DevTools offline) → the item **stays** with an inline error (it does **not** silently disappear and still send). Restore network, retry → cancels cleanly.
- **Escape coordination:** in a thread panel, open the mention autocomplete or set a reply draft, press **Escape** → it dismisses the autocomplete/reply **without** closing the panel. A bare Escape (nothing to dismiss) still marks the room read / closes the panel as before.
- **Panel exclusivity:** on mobile, opening a thread while the media gallery (or members drawer) is open shows only **one** right panel (thread wins), not stacked fullscreen overlays.
- **Emoji board (AW-2):** the **first** time you open the emoji board / autocomplete in a session, the grid **and search** populate with unicode emoji (they don't stay empty). Reactions still show a label.
### O7. Desktop (Tauri) — CSP tighten + native stack (AW-4) — 🖥️ desktop build only
The webview CSP was tightened and the full native module set now compiles. Smoke-test the desktop build:
1. App **boots**, avatars + media thumbnails load, the **VT323** terminal font renders (Lotus Terminal theme), a **location message** embeds its OpenStreetMap map, **calls** connect (EC iframe), **deep links** (`matrix:` / clicking a room link) navigate.
2. **Native features:** minimize to tray (notifications still arrive), a message notification is a **rich toast** (click opens the room; reply box sends), the taskbar **Jump List** lists recent rooms, in a call the taskbar thumbnail shows **Mute/Deafen/End**, Windows **Focus Assist** silences Lotus.
3. **Console** (desktop devtools) shows **no CSP violations** during normal use. If something visual/media is blocked, that's the CSP to loosen — note exactly what and where.
### 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; the KE-1..4 diagnosis + capture guidance is in `LOTUS_TODO.md` (Encryption / E2EE), with the full original runbook in git history.
---
## P. Accessibility (P3-4) — needs a browser + a screen reader
The compliance fixes are gate-verified in code; these confirm the runtime a11y behavior only a human + AT can check. Tools: browser DevTools "axe" extension / Lighthouse a11y, plus **VoiceOver** (macOS ⌘F5) or **NVDA** (Windows).
### P1. Keyboard-only golden path (no mouse)
Tab from page load: **skip-to-content** link appears first (Enter jumps to the timeline). Tab reaches the room list (rooms are focusable, active room announced), open a room (Enter), type a character → focus lands in the composer, send with Enter (or Shift+Enter per your `enterForNewline` setting). No keyboard trap; visible focus ring throughout.
### P2. `?` shortcuts dialog
Press **?** (Shift+/) with focus NOT in a text field → the keyboard-shortcuts dialog opens, is focus-trapped, Escape closes it and focus returns to where you were. Pressing `?` while typing in the composer/search inserts a literal `?` (does NOT open the dialog).
### P3. Screen-reader: reading messages
With VoiceOver/NVDA on, arrow through the timeline: each message is announced as an article with **sender name + time** — critically, this includes **collapsed messages** (consecutive messages from the same person), which previously announced only the body with no sender. Reactions, "edited", replies, and delivery status are announced with labels.
### P4. Screen-reader: live announcements
- **New message** arrives while you're reading → announced (polite).
- **Someone starts typing** → "X is typing" announced once (not spammed per keystroke).
- **Editing a message** → the edit box announces "Editing message from X".
### P5. Focus return from dialogs
Open then close (Escape or ×): the **room topic viewer**, a **reaction viewer** (click a reaction count), and **Search** → focus returns to the button/element you opened them from (not lost to `<body>`). Inline popouts (emoji picker, autocomplete, hover menus) intentionally keep focus in context — that's expected, not a bug.
### P6. axe / Lighthouse scan
Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view, Settings, and the login screen. Expect **no critical/serious** "missing accessible name" or "ARIA" violations on the golden path. Report any that appear (note: far-scrolled timeline history being virtualized out is a known, accepted limitation — not a finding).
---
## Priority if you're short on time ## Priority if you're short on time
1. **A4** (in-call banner) + **A3** (ringtone) — newest, most logic, hardest to reproduce. 1. **O1 + O2** (threads + per-thread notifications) — the largest new surface; the main-timeline change is user-visible.
2. **B1B3** (polls on a default theme) — the confirmed visual bug. 2. **O7** (desktop CSP smoke) — CI can't catch CSP breakage; a wrong directive silently breaks media/fonts/maps.
3. **D** (EC 0.20.1 control sweep) — guards against the upstream merge breaking calls. 3. **O5** (session cross-tab) + **O6** (scheduled-cancel ghost-send) — auth-critical + a real data-loss-class fix.
4. **A7** false-positive check (normal joins don't show the error overlay). 4. **A4** (in-call banner) + **A3** (ringtone) — newest call logic, hardest to reproduce.
5. Everything else. 5. **D** (EC control sweep) — guards against the fork breaking calls.
6. Everything else.
---
## Outstanding verification backlog
**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.
---
+142 -689
View File
@@ -3,734 +3,187 @@
**Repo:** `lotus` branch at `https://code.lotusguild.org/LotusGuild/cinny` **Repo:** `lotus` branch at `https://code.lotusguild.org/LotusGuild/cinny`
**Deploy:** push to `lotus` → CI → auto-deploy to `chat.lotusguild.org` (~11 min) **Deploy:** push to `lotus` → CI → auto-deploy to `chat.lotusguild.org` (~11 min)
> Completed features are documented in [LOTUS_FEATURES.md](./LOTUS_FEATURES.md). Manual test steps live in [LOTUS_TESTING.md](./LOTUS_TESTING.md). This file is **open work only** — resolved audit findings and shipped-feature write-ups were removed 2026-07 (full history in git).
Status legend: `[ ]` pending · `[~]` in progress / shipped-awaiting-QA · `[x]` done · `[BLOCKED]` server/upstream-gated · `[DEFERRED]`/`[DROPPED]`/`[WON'T FIX]` decided.
--- ---
## ⚠️ TDS DESIGN LAW — READ BEFORE TOUCHING ANY UI ## ⚠️ TDS DESIGN LAW — READ BEFORE TOUCHING ANY UI
> **ALL Lotus Terminal Design System (TDS) styling — colors, animations, glows, borders, fonts, spacing — MUST come exclusively from `/root/code/web_template/base.css` CSS variables.** > **ALL Lotus Terminal Design System (TDS) styling — colors, animations, glows, borders, fonts, spacing — MUST come exclusively from `/root/code/web_template/base.css` CSS variables.**
> Do NOT hardcode hex values. Do NOT invent new variable names. Do NOT deviate from the design tokens defined in that file. > Do NOT hardcode hex values. Do NOT invent new variable names. Canonical tokens: `--lt-accent-orange`, `--lt-accent-cyan`, `--lt-accent-green`, `--lt-glow-*`, `--lt-box-glow-*`, `--lt-border-color`, `--lt-font-mono`. Syntax-highlight token classes: `.tok-kw .tok-str .tok-num .tok-cmt .tok-fn`.
> The canonical variable reference: `--lt-accent-orange`, `--lt-accent-cyan`, `--lt-accent-green`, `--lt-glow-orange`, `--lt-box-glow-*`, `--lt-border-color`, etc. > Reference patterns: `/root/code/tinker_tickets/` (markdown.js, base.js, ticket.css). Applies to every task without exception.
> Reference implementation for code patterns: `/root/code/tinker_tickets/` (markdown.js, base.js, ticket.css) > New components must respect both TDS dark (`LotusTerminalTheme`) and TDS light (`LotusTerminalLightTheme`); non-TDS theme work uses vanilla-extract (match `src/lotus-terminal.css.ts`).
> This rule applies to EVERY task in this file without exception.
---
## 🧩 NATIVE-CINNY LAW — EVERY FEATURE MUST FEEL LIKE STOCK CINNY ## 🧩 NATIVE-CINNY LAW — EVERY FEATURE MUST FEEL LIKE STOCK CINNY
> **Every feature we implement must feel native to the upstream Cinny app — indistinguishable from something the Cinny team would have shipped.** Reference: <https://github.com/cinnyapp/cinny>. > **Every feature must feel native to upstream Cinny — indistinguishable from what the Cinny team would ship.** Reference: <https://github.com/cinnyapp/cinny>.
> >
> Concretely this means: > - **Use the `folds` design system, not bespoke UI** (`Button`, `Chip`, `IconButton`, `Menu`, `MenuItem`, `Dialog`, `Modal`, `Input`, `Switch`, `Badge`, `SettingTile`, `SequenceCard`, …) and folds tokens (`color.*`, `config.space.*`, `config.radii.*`). **Use folds `Icon`/`Icons`, never literal emoji, in UI chrome.** No hardcoded hex/`rgba()`, no invented CSS variables.
> > - **Match Cinny's existing patterns** — find the closest existing component/flow and mirror it before adding UI.
> - **Use the `folds` design system, not bespoke UI.** Build with folds primitives (`Button`, `Chip`, `IconButton`, `Menu`, `MenuItem`, `Dialog`, `Modal`, `Input`, `Switch`, `Badge`, `SettingTile`, `SequenceCard`, etc.) and folds tokens (`color.*`, `config.space.*`, `config.radii.*`, `config.borderWidth.*`). No hardcoded hex/`rgba()` for UI chrome, no invented/undefined CSS variables. > - **The ONE exception:** explicit **TDS** features, which follow the TDS Design Law above (opt-in, only in Lotus Terminal mode).
> - **Match Cinny's existing patterns.** Before adding UI, find the closest existing Cinny component/flow and mirror it (e.g. a new dropdown uses `Button`+`PopOut`+`Menu`+`MenuItem` like the rest; a new modal has a `Header` with a close `IconButton`; a new setting is a `SettingTile` inside a `SequenceCard`). Consistency with stock Cinny beats personal style.
> - **Lotus-custom additions should be unobtrusive** and fit Cinny's visual language, spacing, and interaction conventions — a stranger using Cinny should not be able to tell which features are ours.
>
> **The ONE exception:** explicit **Lotus Terminal Design System (TDS)** features, which intentionally have their own distinct look and follow the **TDS Design Law** above. TDS styling is opt-in (only active in Lotus Terminal mode); everything else must look and feel like native Cinny.
--- ---
Completed features are documented in [LOTUS_FEATURES.md](./LOTUS_FEATURES.md). ## ✅ Audit (2026-07) — closed out
A three-wave feature bug-hunt (~15 parallel agents, each batch independently reviewed) plus a low-tail cleanup. All confirmed 🔴/🟠 and the clean 🟡 tail are **fixed, reviewed, and gate-green**; details in git history + LOTUS_FEATURES. Only the minor items below remain open.
**Still open (low tail — all 🟡 minor):**
- **Calls host:** C-M1 deafen DOM-fallback leaks late-added `<audio>` tracks; C-M2 `.click()`-by-testid toggles no-op if EC renames — **both retire via EC-fork P6-2**. C-L1 AFK mic not released if EC elides the echo; C-L2 ringtone-preview global cross-cancel; C-L3 first ring after cold load can be silent (ctx not unlocked); C-L5 speaker-observer churn on membership change; C-L7 all-muted DOM miscount if EC label format differs; C-L8 PiP sw/nw resize anchor jitter at min size.
- **Threads:** T5 `participating` detection is server-bundle-only (`thread.hasCurrentUserParticipated`) → can under-notify a thread you just replied to; T6 room "Mentions & Keywords" not honored for participated/Default thread replies (over-notify); T7 account-data thread-mute write is a lost-update race.
- **Crypto/session:** F5 OIDC refresh drops `expiresAt` on persist (`persistTokens` can't reach the expiry without SDK-internal plumbing; refresh is reactive on 401).
- **Native/desktop:** D7 Unity badge `application://cinny.desktop` id may not match the installed `.desktop` basename — **runtime-verify** on the `.deb`/AppImage. H10 room-name setter fire-and-forget/silent length reject (trivial). N6 per-message read-receipt avatars may not refresh on membership change (emitter uncertain, low impact).
- **EC fork (EC1EC6 fixed on `element-call:lotus`, needs a republish):** re-apply `setTimeout` cleanup, remote-gated subscription → `allConnections$`, per-call decoration state leak, re-subscribe-every-render, focus-clear on missing `userId`. Rides with **P6-2 phase 2**.
--- ---
## ✅ Done — Awaiting Verification ## ✅ Shipped — Awaiting Live Verification
Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then they graduate to LOTUS_FEATURES.md. (Bug-side fixes awaiting verification live in LOTUS_BUGS.md.) Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then graduate to LOTUS_FEATURES.md. Includes the **desktop/native Tier A/B stack** (P5-35/36/41/42/43/44/46/47/48/49/55/56/57, P6-1 Linux parity) — all **CI-compile-verified, runtime-verify on Windows/Linux** — plus:
| Feature | Test guide | | Area | Test guide |
| :-------------------------------------------------------------------------------- | :---------------- | | :-------------------------------------------------------------------------- | :-------------------- |
| Full-Screen Camera Broadcasts (per-participant focus) | A5 / G2 | | Full-Screen Camera Broadcasts (per-participant focus) | A5 / G2 |
| Advanced search filters (sender/date/has-link/has:image·file·video/pinned/recent) | K2 / M1 / M2 / M4 | | Advanced search filters + virtualized infinite scroll | K2 / M1 / M2 / M4 |
| Custom Accent Color Picker (non-TDS themes) | M3 | | Custom Accent Color Picker (non-TDS) · 5 Color Theme Presets | M3 / M5 |
| 5 Color Theme Presets (Cyberpunk/Ocean/Blood Red/Matrix/Midnight) | M5 | | Intersection lazy media loading · context-aware thumbnails | H1 / H2 |
| Intersection-based lazy media loading | H1 | | Thread Panel (side drawer) + per-thread notification modes (P4-1) | (thread QA) |
| Context-aware thumbnail previews | H2 | | Encrypted message search indexing/caching (opt-in, default OFF) | search backlog |
| Desktop — proactive update notifications (Tauri) | J1 | | Remind Me Later · Mobile Bookmarks access | K1 / E5 |
| Remind Me Later | K1 | | In-Call Soundboard (P5-15) · Quality Controls (P5-31) · Permissions (P5-31) | D2-7 / D2-8 / D2-9 |
| Mobile Bookmarks access | E5 | | Desktop proactive update notifications (P5-40) | J1 |
| In-Call Soundboard (P5-15, uploadable clips → real call inject) | D2-7 | | OIDC/SSO login (P4-6, needs an MSC3861 server — pick mozilla.org on login) | OIDC |
| Call Quality Controls (P5-31, user + room-admin caps) | D2-8 | | Windows native WinRT toast quick-reply / click-to-open (D6, AUMID) | rich-toast (§backlog) |
| Call Permissions (P5-31, hard server-side screenshare/camera policy) | D2-9 |
--- ---
Legend: ## 🔴 Open — Actionable
- `[AUDIT REQUIRED]` — at least one assumption needs code/server verification before implementing ### 🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED
- `[SERVER CHECK]` — depends on a Synapse feature or MSC; verify on `matrix.lotusguild.org`
- `[LOW PRIORITY]` — implement after all higher-priority items
- `[EXTREME COMPLEXITY]` — multi-sprint, plan separately before touching
- `[BLOCKED]` — cannot build until a server upgrade, upstream MSC, or dependency resolves
- `[IMPROVE]` — feature exists in upstream Cinny; this task enhances it for Lotus Chat
Status: `[ ]` pending · `[~]` in progress · `[x]` completed Observed live in prod 2026-06-30 during a 2-person **Element Call** (E2EE). These span client rust-crypto (`matrix-js-sdk@41.7.0`) ↔ Synapse ↔ EC MatrixRTC E2EE and are **interrelated** — do NOT spot-fix. **Capture first:** run **Settings → Developer Tools → Crypto Diagnostics** during the next affected call + a synapse-side trace before any fix. (Full runbook was in `LOTUS_E2EE_INVESTIGATION.md`, now in git history.) None are caused by the EC fork work.
- **KE-1 — OTK upload conflict storm (CRITICAL, root-cause candidate).** `POST /keys/upload` returns `400 M_UNKNOWN: One time key … already exists` continuously — the rust-crypto store and Synapse have **diverged OTK state** (upstream `matrix-rust-sdk#5200`, OPEN: on the 400 the SDK never marks the request sent → re-uploads forever; **not** fixed in 41.7.0). Leading web trigger: cinny never calls **`navigator.storage.persist()`**, so the IndexedDB crypto store is evictable while the `localStorage` session survives → device resurrects with a blank store. **Buildable preventive fix (no call needed):** request persistent storage on login (+ optional multi-tab guard + a 400-loop→recovery prompt). Healing an already-diverged device still needs a clean logout+login.
- **KE-2 — EC media keys not arriving/decrypting → audio/video cut out (CRITICAL).** `MissingKey … for participant`, unexpected encrypted to-device `io.element.call.encryption_keys`. Almost certainly downstream of KE-1 (broken Olm sessions). This is the "friend's audio cuts out" symptom.
- **KE-3 — Timeline decrypt error: missing `algorithm` field (HIGH).** rust-crypto can't parse a malformed/legacy encrypted event — capture the offending event id + raw content.
- **KE-4 — MatrixRTC delayed-event / membership timeouts (MEDIUM-HIGH).** `Restart delayed event timed out`, repeated `msc4157.update_delayed_event` — may be partly HS responsiveness; correlate with synapse latency. Same planning session (shares the call-reliability surface).
### Security & Privacy
- **N97 — Access token + device id in plaintext `localStorage`** (`state/sessions.ts`), XSS-exposed. Architectural — needs a token-protection / session-storage redesign.
- **Persisted PII without encryption:** user status message + expiry (`Profile.tsx`), unsent composer drafts (`RoomInput.tsx`). Leak risk on shared devices.
### PWA / Offline / Web Push
- **N107 — Web Push is non-functional:** `src/sw.ts` has no `push` handler. Needs a `push` listener + Matrix push-gateway integration. **The one substantive remaining feature** (session/crypto groundwork it waited on has landed).
- **No app-asset caching strategy** in `src/sw.ts` — no offline capability.
### Dependencies / Build / Hygiene
- Build-time: `lotusDenoise` does heavy sequential `fs` in `closeBundle`; `viteStaticCopy` has redundant renames — could be streamlined.
- `patch-folds.mjs` edits `node_modules` directly (robust today; `patch-package` considered but more brittle to folds restructuring — WON'T-DO unless it breaks).
- `types/matrix/` mirrors SDK types instead of importing them — drift risk; spot-fix highest-risk only.
- `contrib/nginx`/`contrib/caddy` examples: headers + `try_files` already synced with prod; the prod nginx `add_header` isn't inherited by cache `location` blocks (pre-existing; SPA entry `/` still gets all headers).
- `as any` casts across `src/` — gradual typing cleanup. Keep commits scoped (bisect-friendly). Keep README fork-sync version/logo current.
--- ---
## Server Capabilities (as of June 2026) ## 📋 Open Feature Backlog
- **Homeserver:** `matrix.lotusguild.org` ### [ ] P4-4 · Math / LaTeX Rendering (LOW PRIORITY)
- **Synapse version:** `1.155.0` (2026-06-18) — fully up to date; last version for Debian 12 (LXC 151 already on Debian 13 Trixie)
- **Matrix spec:** up to `v1.12` formally; newer MSC features via `unstable_features`
### Confirmed facts Render `$…$` / `$$…$$` via KaTeX; graceful fallback to raw text. **Sanitizer must be patched**`src/app/utils/sanitize.ts` (sanitize-html, `disallowedTagsMode:'discard'`) strips all MathML: add `<math><mi><mo><mn><mrow><mfrac><msqrt><mroot><msub><msup><msubsup><munder><mover><mtable><mtr><mtd>…` + `annotation` to `permittedHtmlTags`, and `xmlns`/`display`/`mathvariant` to `permittedTagToAttributes`. Parser: split text nodes on `/(\$\$.*?\$\$|\$.*?\$)/g` in `react-custom-html-parser.tsx``<KaTeX>`. Lazy-import `katex/dist/katex.min.css` only when a math block renders. Verify KaTeX bundle-size impact.
| Finding | Impact | ### [~] P5-20 · Quick Reply from Browser Notification (partial)
| -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| **MSC flags ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` · `msc3401_matrix_rtc` | All safe to use now | Done: notifications show the real body, click navigates to the specific event + focuses the tab. **Remaining:** inline reply via Notification Actions API needs the SW `push`+`notificationclick` pipeline (switch `new Notification()` `serviceWorkerRegistration.showNotification()` so the SW receives `notificationclick`; on `event.action==='reply'` POST `m.room.message` with the stored `{roomId, threadId}`). Ties into N107.
| **MSC flags OFF:** `msc4306` (thread subscriptions) · `msc3882` · `msc3912` · `msc4155` | These features are BLOCKED |
| **MSC3266** room summary: flag `msc3266_enabled: true` set but `GET /v1/rooms/{id}/summary` still returns 404 (M_UNRECOGNIZED) | Room Preview BLOCKED — endpoint not implemented in Synapse 1.155 | ### [~] P5-30 · Advanced ML Noise Suppression — open verification
| **MSC3892** relation redaction: not in flags | Reaction Redaction feature BLOCKED |
| **MSC4260** report user: `POST /_matrix/client/v3/users/{userId}/report` returns **200** ✅ | **Report User UNBLOCKED** — endpoint live since Synapse 1.133; ready to build | Shipped in the EC fork (DeepFilterNet3 default-capable / DTLN / RNNoise / Speex; AEC on, AGC off for ML tier; never-silent watchdog). **Open:** real-call by-ear **A/B** — model choice, `lotusDenoiseFloor`, AGC on/off (LOTUS_TESTING §D2-1 / J2). **GTCRN (deferred):** tiny MIT 16 kHz model beating RNNoise, but no drop-in browser package — needs `onnxruntime-web` in a Web Worker behind a custom AudioWorklet ring-buffer (ORT can't run in an AudioWorklet, issue #13072); ~1-week build. Revisit only if low-power quality proves insufficient. HW-gated (FRCRN/Maxine) = desktop-Rust-only future.
| **MSC4151** report room: HTTP 405 on GET = endpoint exists (POST only) | Report Room live ✅ |
| `folds AvatarImage` does NOT accept children | Add frame/overlay inside `UserAvatar.tsx` itself — optional `frameName` prop | ### [~] P6-2 · Element Call fork — retire remaining DOM hacks (Phase 2 needs publish)
| No in-app toast system exists (was) | Built `ToastProvider` + Jotai queue; at `App.tsx:65` |
| `useUnverifiedDeviceCount()` hook exists | `src/app/hooks/useDeviceVerificationStatus.ts:65-106` | Phase 1 shipped: `io.lotus.set_deafen` (LiveKit-source deafen/screenshare-audio-mute) replaces the brittle `<audio>.muted` iframe hack; cinny sends it join-gated alongside the transitional DOM fallback. **Phase 2 (blocked on user npm publish):** publish fork `0.20.1-lotus.2` → bump cinny pin `lotus.1``lotus.2` → delete the `CallControl.ts` `.muted` fallback + the EC1EC6 fixes ship. **Deferred pieces (P6-2b):** the `useCallSpeakers` DOM-scrape is a dormant fallback behind `io.lotus.call_state`; `.click()`-by-`data-testid` UI toggles are low-value fork surface. Divergence to confirm: deafen doesn't silence soundboard/`Unknown`-source audio (setVolume type limit).
| Voice player: `AudioContent.tsx:44-223` | Playback rate on hidden `<audio>` at line 217 |
| `CallControl.setMicrophone(bool)` at `CallControl.ts:206-212` | For AFK auto-mute | ### [ ] Mobile audit
| `CallControl.toggleSound()` at `CallControl.ts:230-251` | Push-to-deafen — just wire a hotkey to this |
| matrix-js-sdk has NO arbitrary profile field methods | Use `mx.http.authedRequest()` for MSC4133 | Comprehensive audit of all LOTUS_FEATURES.md features for mobile PWA usability + responsiveness. Method: 44px touch targets, no horizontal overflow, full-screen modals/drawers on mobile, composer not obscured by keyboard.
| Sanitizer (`sanitize.ts`) allows table, div, span, a, code, hr | LFG HTML card is safe locally; test on Element/FluffyChat |
| Sanitizer STRIPS `<math>`/MathML tags | Math/LaTeX task must also modify sanitizer | ### Deferred / dropped (decided — kept for context)
| Service worker EXISTS at `src/sw.ts` | Quick-reply task: add `notificationclick` handler |
| `knockSupported()` utility exists at `matrix.ts:376-391` | Knock UX: only need "Request to Join" in `RoomIntro.tsx` | - **[DEFERRED] P5-51** Federated "Identity Contexts" (session isolation) — multi-sprint, touches auth/crypto/storage core; smaller intermediate step = plain multi-account switch. **[DROPPED] P5-52** per-room sync governor — js-sdk can't truly per-room filter `/sync`; only a cosmetic hide. **[DEFERRED] P5-53** local scripting plugin — prefer a declarative automation-rules feature (no arbitrary code). **[DEFERRED] Audit-3** profile banner — MSC4427 open/unmerged; revisit on merge. **[WON'T FIX] P5-50** Windows HW media pipeline (WebRTC decode lives in WebView2; not injectable). **[MOVED] P5-9** LFG → LotusBot `!lfg`.
| `KeywordMessages.tsx` already has custom keyword push rules | Full push rule editor: only non-keyword rule types need new UI |
| `getMatrixToRoom()` in `matrix-to.ts` generates invite URLs | Invite link: just add QR code to room settings |
| ~~Cindy CANNOT inject audio into EC call stream~~ **UNBLOCKED by EC fork**`io.lotus.inject_audio` widget action publishes a clip as a real call track | In-call soundboard CAN now mix into the call (no longer local-only); needs cinny UI to drive the action |
| Folds uses vanilla-extract in non-TDS, NOT CSS custom properties | Custom accent color: must create new vanilla-extract theme variant dynamically |
| Theme presets need ~50 CSS custom properties each | Significant design work before coding |
| `useCallSpeakers.ts` CSS MutationObserver polling | Visual speaking indicator: TDS ring animation on top of existing data |
| MSC3489/3672 live location: BOTH false on server | Live Location BLOCKED |
--- ---
## Key File Reference ## 🚫 Blocked Features (server / upstream gated)
| What you need | File | Lines | Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade.
| -------------------------------- | ------------------------------------------------------------- | ------------------- |
| Global keydown hook | `src/app/hooks/useKeyDown.ts` | whole file | - **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
| Room navigation | `src/app/hooks/useRoomNavigate.ts` | 19-72 | - **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
| All room IDs atom | `src/app/state/room-list/roomList.ts` | `allRoomsAtom` | - **[BLOCKED] Room Preview before joining** (MSC3266) — `GET /v1/rooms/{id}/summary` returns 404 `M_UNRECOGNIZED` on Synapse 1.155 despite `msc3266_enabled:true`.
| Room unread counts | `src/app/state/room/roomToUnread.ts` | `roomToUnreadAtom` | - **[BLOCKED] Thread Subscriptions** (MSC4306 `false`) — "Follow thread" button (depends on the shipped Thread Panel).
| Overlay portal provider | `src/app/pages/App.tsx` | 65 |
| Portal container div | `index.html` | 101 |
| Room settings tabs | `src/app/features/room-settings/RoomSettings.tsx` | 27-56 |
| State event read/write pattern | `src/app/features/common-settings/general/RoomEncryption.tsx` | 42-52 |
| Power level checker | `src/app/hooks/usePowerLevels.ts` | whole file |
| Slash command registration | `src/app/hooks/useCommands.ts` | 140-537 |
| Chat background picker | `src/app/features/settings/general/General.tsx` | 945-981 |
| Chat backgrounds definition | `src/app/features/lotus/chatBackground.ts` | whole file |
| Matrix.to URL builder | `src/app/plugins/matrix-to.ts` | `getMatrixToRoom()` |
| Media event content types | `src/app/types/matrix/common.ts` | 46-91 |
| Media URL conversion | `src/app/utils/matrix.ts` | `mxcUrlToHttp()` |
| Message pagination (search) | `src/app/features/message-search/useMessageSearch.ts` | 74-121 |
| Infinite pagination pattern | `src/app/features/message-search/MessageSearch.tsx` | 234-365 |
| Poll event format | `src/app/components/message/content/PollContent.tsx` | 1-320 |
| Theme class application | `src/app/hooks/useTheme.ts` | 25-60 |
| Animations file | `src/app/styles/Animations.css.ts` | whole file |
| Message status (EventStatus) | `src/app/features/room/message/Message.tsx` | 84-142 |
| Call member change events | `src/app/hooks/useCall.ts` | 37-52 |
| Mic control in calls | `src/app/plugins/call/CallControl.ts` | 206-212 |
| Device verification hook | `src/app/hooks/useDeviceVerificationStatus.ts` | 65-106 |
| Knock room support check | `src/app/utils/matrix.ts` | 376-391 |
| Room join button location | `src/app/components/room-intro/RoomIntro.tsx` | 25-119 |
| Notification mute via push rules | `src/app/hooks/useRoomsNotificationPreferences.ts` | 110-150 |
| Message text body CSS | `src/app/components/message/layout/layout.css.ts` | 182-205 |
--- ---
## Priority 3 — Higher complexity / lower daily frequency ## 📖 Reference
### [ ] P3-4 · Accessibility Improvements (WCAG 2.1 AA) ### Server Capabilities (as of 2026-06)
**What:** Comprehensive audit and fix pass targeting the critical user paths: - **Homeserver** `matrix.lotusguild.org` · **Synapse** `1.155.0` · **Matrix spec** up to `v1.12` (+ MSC `unstable_features`).
- **MSC ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` (flag on but v1 summary 404s) · `msc3401_matrix_rtc`. **OFF/blocked:** `msc4306` · `msc3882` · `msc3912` · `msc4155` · `msc3489`/`msc3672` · `msc3892`.
- Room list navigation (keyboard-only) - **Live endpoints:** Report User (MSC4260) **200** ✅ · Report Room (MSC4151) ✅.
- Reading messages in the timeline (screen reader announces new messages) - **Homeserver access (audits):** Synapse = LXC 151 (`pct exec 151 -- bash`), config `/etc/matrix-synapse/homeserver.yaml`. Web deploy = LXC 106. Voice guard = `voice-limit-guard.py` on LXC 151.
- Composing and sending a reply - **SDK notes:** no arbitrary profile-field methods (use `mx.http.authedRequest()` for MSC4133); js-sdk can't per-room filter `/sync`; sanitizer strips `<math>`/MathML; SW exists at `src/sw.ts`; `getMatrixToRoom()` builds invite URLs; EC audio-inject unblocked via the fork's `io.lotus.inject_audio`.
- Opening and closing modals (focus trap, return focus)
- ARIA labels on all icon-only buttons ### Key File Reference
**Scope:** Do NOT attempt to make every corner of the app AA-compliant in one pass — focus on the golden path (open app → find room → read → reply → send). | What | File | Lines |
**[AUDIT REQUIRED]** — Run an automated audit first: `npx axe-core` or browser DevTools accessibility tree. Document every violation before writing a single line of code. Prioritize by severity (critical > serious > moderate). | ------------------------------ | ------------------------------------------------------------------- | ------------------- |
| Global keydown / room nav | `hooks/useKeyDown.ts` · `hooks/useRoomNavigate.ts` | whole / 19-72 |
**Investigation Findings:** | Room unread counts atom | `state/room/roomToUnread.ts` | `roomToUnreadAtom` |
| Overlay portal provider | `pages/App.tsx` · `index.html` | 65 / 101 |
- **Root Cause:** Inconsistent focus management, missing `aria-live` regions for dynamic timeline updates, and sparse global keyboard shortcuts. | Room settings tabs | `features/room-settings/RoomSettings.tsx` | 27-56 |
- **Approach:** Standardize `focus-trap-react` usage (reference `RoomNavItem.tsx`). Add `aria-live` regions to the timeline. Expand `useKeyDown.ts` for section navigation shortcuts. | State event read/write pattern | `features/common-settings/general/RoomEncryption.tsx` | 42-52 |
- **Complexity:** Medium-High (audit is the main work). | Power levels | `hooks/usePowerLevels.ts` | whole |
| Slash commands | `hooks/useCommands.ts` | 140-537 |
--- | Chat background picker/defs | `features/settings/general/General.tsx` · `lotus/chatBackground.ts` | 945-981 / whole |
| Matrix.to URL builder | `plugins/matrix-to.ts` | `getMatrixToRoom()` |
### [ ] P3-8 · Thread Panel (full side drawer) | Media URL conversion | `utils/matrix.ts` | `mxcUrlToHttp()` |
| Search pagination / virtual | `features/message-search/{useMessageSearch,MessageSearch}.tsx` | 74-121 / 234-365 |
**⚠️ LARGEST FEATURE — requires its own planning session before implementation.** | Call mic control | `plugins/call/CallControl.ts` | 206-212 |
**What:** A right-side drawer for threaded conversations. Currently "Reply in Thread" exists but there is no panel to read or write thread replies. | Knock support check | `utils/matrix.ts` | 376-391 |
| Notification mute push rules | `hooks/useRoomsNotificationPreferences.ts` | 110-150 |
Features:
### Element Call fork — operational reference
- Click "Reply in Thread" → opens thread drawer on the right
- Thread root event shown at the top of the panel Fork = `LotusGuild/element-call` (branch `lotus`, from upstream tag `v0.20.1`); cinny consumes the npm package `@lotusguild/element-call-embedded` (built bundle copied into `public/element-call/`).
- Full message rendering for all in-thread replies (reuse timeline components)
- Reply input at the bottom (full composer with formatting, emoji, etc.) **Publish a new version (manual; needs the Gitea npm token):** bump `embedded/web/package.json` (current unpublished `0.20.1-lotus.2`) → `pnpm run build:embedded` (Node 24, pnpm 10.33) → `cd embedded/web && npm version <tag> --no-git-tag-version && npm publish` (Gitea registry) → in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.20.1-lotus.1`) → `npm install` → build.
- Unread count badge on the thread button in the main timeline
- Keyboard shortcut to close thread panel **`io.lotus.*` widget actions** (add new toWidget actions to the enum + `LOTUS_TO_WIDGET_ACTIONS` in `src/lotus/lotusActions.ts`; only send AFTER call-join or a 10s timeout fires):
**Architecture:** | Action | Dir | Purpose | Module |
| :--------------------------- | :------ | :----------------------------------------------------- | :-------------------- |
- New Jotai atom: `activeThreadEventId: string | null` | `io.lotus.call_state` | EC→host | speaker/mute/camera stream (`lotusCallState=1`) | `lotusCallState.ts` |
- New component: `src/app/features/room/thread/ThreadPanel.tsx` | `io.lotus.focus_participant` | host→EC | spotlight (works during screenshare) | `lotusFocus.ts` |
- Rendered alongside `RoomView` as a conditional right panel (mirror the members drawer pattern) | `io.lotus.inject_audio` | host→EC | soundboard clip mixed into call (`lotusAudioInject=1`) | `lotusAudioInject.ts` |
- Filter events in timeline to `m.thread` relation for the active root event ID | `io.lotus.set_quality` | host→EC | audio/screenshare bitrate/fps caps | `lotusQuality.ts` |
- Shares the same `mx` client and room reference as the main timeline | `io.lotus.decorations` | host→EC | in-call avatar decorations | `lotusDecorations.ts` |
| `io.lotus.set_deafen` | host→EC | LiveKit-source deafen (P6-2) | `lotusDeafen.ts` |
**[AUDIT REQUIRED]** — Deeply audit how `m.thread` relation events are currently stored and retrieved in the matrix-js-sdk. Understand the thread aggregation API: `GET /rooms/{roomId}/relations/{eventId}/m.thread`. Check if `RoomTimeline.tsx` currently filters out thread replies from the main timeline (it should — confirm).
Also flag-gated: `lotusTransparent`/`lotusTheme`, `lotusDenoiseSource=1` (in-source ML denoise).
**Investigation Findings:**
### CI/CD + per-feature checklist
- **Root Cause:** Current `m.thread` events are treated as standard `m.room.message` events and rendered in the main timeline.
- **Approach:** Introduce new Jotai atom `activeThreadEventId`. Create `ThreadPanel.tsx`. Update `RoomTimeline.tsx` to filter out thread relations (`m.relates_to`). Implement aggregation fetch using `GET /rooms/{roomId}/relations/{eventId}/m.thread`. Use `thread.timelineSet` directly for the most accurate thread view.
- **Complexity:** High.
---
## Priority 4 — Specialized, high complexity, or low priority
### [ ] P4-7 · Virtualized Infinite Scroll for Search Results
**What:** Replace the manual "load more" button with an automated, virtualized infinite scroll for search results.
**Approach:** Utilize `@tanstack/react-virtual` in `MessageSearch.tsx` to handle the `nextToken` automatically as the user scrolls.
### [ ] P4-8 · Encrypted Message Search Indexing & Caching
**What:** Implement a persistent local cache for search results, optimized for encrypted rooms.
**Approach:** Use `IndexedDB` to store search metadata (event IDs, timestamps) to prevent redundant server-side decryption/fetching.
### [ ] P4-1 · Thread Notification Mode Per-Thread (MSC3771)
**Spec:** MSC3771 (stable). Depends on Thread Panel (#P3-8).
**What:** Per-thread notification toggle: "All messages" vs "Mentions only". Accessible from the thread panel header. Tracks unread counts separately per thread.
**[AUDIT REQUIRED]** — Implement after Thread Panel. Requires understanding how the SDK tracks per-thread unread counts.
**Complexity:** Medium (after thread panel exists).
---
### [ ] P4-2 · Thread Subscriptions (MSC4306) [BLOCKED]
**Spec:** MSC4306 (Synapse experimental). Depends on Thread Panel (#P3-8).
**What:** "Follow thread" button to receive notifications for a thread you haven't posted in. Uses MSC4306 subscription endpoint.
**[SERVER CHECK]** — `org.matrix.msc4306 = false` on `matrix.lotusguild.org` — BLOCKED until server enables it.
**Complexity:** Medium (after thread panel exists).
---
### [ ] P4-4 · Math / LaTeX Rendering in Messages (LOW PRIORITY)
**Spec:** CS-API §11.5 (stable) — `formatted_body` can contain LaTeX.
**What:** Render `$...$` or `$$...$$` LaTeX expressions in message bodies. Use KaTeX (lightweight, ~100KB, renders server-side-compatible CSS). Must gracefully fall back to raw LaTeX text if KaTeX fails.
**Note:** This is LOW PRIORITY — only useful for academic/technical communities. Implement last.
**[AUDIT REQUIRED]** — Confirm KaTeX bundle size impact on the Vite bundle. Check if matrix-js-sdk's HTML sanitizer strips LaTeX before it reaches the renderer. The formatted_body sanitization pipeline is the main risk here. (Confirmed: sanitizer STRIPS `<math>` tags — must be patched alongside the renderer.)
**Complexity:** Low-Medium.
---
### [ ] P4-5 · Live Location Sharing (MSC3489 + MSC3672) (LOW PRIORITY, HIGH COMPLEXITY) [BLOCKED]
**Spec:** MSC3489 + MSC3672. Implemented in Element Web.
**Note:** Static location sharing is already implemented. This adds live/real-time GPS beacons. Very low priority per user preference.
**What:** Start sharing live location → creates `m.beacon_info` state event → client posts `m.beacon` events on a timer → other users see your position update live on a map.
**[SERVER CHECK]** — `org.matrix.msc3489 = false` AND `org.matrix.msc3672 = false` on `matrix.lotusguild.org` — BLOCKED.
**Complexity:** High. Requires background geolocation API + live map rendering.
---
### [~] P4-6 · OIDC / SSO Next-Gen Auth (MSC3861) — CLIENT-SIDE BUILT, awaiting live verification
**Spec:** MSC3861 / MSC2965, Matrix spec v1.15. OAuth2-native auth via a Matrix Authentication Service (MAS).
**Scope decision (2026-06):** CLIENT-ONLY. We implemented OIDC login _in the Lotus client_ so it can sign into next-gen homeservers (mozilla.org, eventually matrix.org). We deliberately did **not** convert lotusguild's own Synapse to MAS (no account migration; lotusguild keeps password + legacy Authelia SSO).
**Built (matrix-js-sdk already ships the OIDC API; this was wiring):**
- Discovery: `cs-api.ts` `getOidcIssuer()` (stable `m.authentication` + msc2965). Flow hint: `useParsedLoginFlows` `getOidcCompatibilityFlag()` (MSC3824).
- Login: `pages/auth/oidc/{oidcConfig,oidcLoginUtil,oidcState}.ts` (dynamic registration + cache, PKCE authorize), `login/OidcLogin.tsx`, issuer-gated `Login.tsx`.
- Callback: `oidc/OidcCallback.tsx` + `App.tsx` short-circuit (non-hash redirect path).
- Session/refresh: `state/sessions.ts` OIDC fields, `client/{oidcTokenRefresher,oidcLogout}.ts`, `initMatrix.ts` wiring.
- Account mgmt: `settings/account/OidcManageAccount.tsx`.
- 13 unit tests (discovery/flow/session/cache/callback parsing). All gates green.
**Awaiting verification (needs a real MSC3861 server — lotusguild is NOT one):** deploy + log into **mozilla.org** (requires adding mozilla to the deployed `config.json` homeserverList + its domains to the CSP `connect-src`/`img-src` — see below), OR run a local `matrix-authentication-service` + Synapse `msc3861` dev loop.
**To enable the mozilla.org test:** add to `matrix/cinny/config.json` homeserverList `"mozilla.org"`, and to the nginx CSP `connect-src`/`img-src`: `https://mozilla.org https://mozilla.modular.im https://chat.mozilla.org https://vector.im`.
---
## Priority 5 — Gamer / Aesthetic / Customization
### [MOVED] P5-9 · LFG (Looking for Group) Command → LotusBot
**Decision:** Implemented as `!lfg` in LotusBot rather than a client slash command. Bot-side rendering works consistently across all Matrix clients; client-side enhanced cards would only be visible to Lotus Chat users and require sanitizer auditing. The bot can also support richer flows (list active LFGs, DM interested players, auto-expire posts).
---
### [~] P5-15 · In-Call Soundboard — IMPLEMENTED (⚠️ awaiting live verification, D2-7)
**What:** Soundboard button in the call controls bar → popout grid of the user's clips; clicking one plays it **into the call** as a real published track (peers hear it) and locally (presser hears it). Clips are **user-uploadable, just like custom emojis/stickers**.
**🔱 [EC-FORK] Fork side + cinny side DONE.** The fork ships `io.lotus.inject_audio` (`LotusWidgetActions.InjectAudio`, allow-listed in `widget.ts`), armed via the `lotusAudioInject=1` flag; it publishes a clip as a separate LiveKit track — a **real** in-call soundboard mixed into the call, not local-only. cinny now drives it.
**Shipped (cinny):**
- Clips stored in `io.lotus.soundboard` account data → **synced across devices like emoji/sticker packs** (`useSoundboard` hook; `AccountDataEvent.LotusSoundboard`).
- Upload audio (≤1 MB, ≤40 clips) → `mx.uploadContent` → mxc; play resolves mxc → authed download → `blob:` object URL (the widget can't fetch authenticated media itself) → `control.injectAudio(url, volume)` + local playback.
- `CallSoundboard.tsx` popout in the call bar (upload / play / delete), gated on the `soundboardEnabled` setting (Settings → General → Calls, + volume slider).
**Remaining:** a dedicated Settings management page (optional — upload/delete already live in the popout); a small default clip set; live verification (D2-7). Files: `utils/soundboardClips.ts`, `hooks/useSoundboard.ts`, `features/call/CallSoundboard.tsx`, `plugins/call/CallControl.ts#injectAudio`.
**Complexity:** Medium — done.
---
### [~] P5-20 · Quick Reply from Browser Notification
**What:** Inline reply field in browser notification toasts via Notification Actions API. Reply sends as threaded reply to the triggering message.
**[AUDIT REQUIRED]** (1) Verify browser Notification Actions API support in target browsers. (2) Confirmed: service worker EXISTS at `src/sw.ts` — add `notificationclick` handler there.
**Complexity:** Medium-High.
**Partial Fix Applied ⚠️ UNTESTED:** Notifications now (a) show the real message body (`username: message` instead of "New inbox notification from..."), (b) click navigates directly to the room at the specific event (not the inbox), (c) `window.focus()` called on click so the tab comes to front, (d) reminder toasts also link to the specific event. Full inline-reply via Notification Actions API still needs the SW `push`+`notificationclick` pipeline (requires switching from `new Notification()` to `showNotification()` through the SW).
---
### [x] P5-30 · Advanced ML Noise Suppression (Krisp-style)
**What:** High-end background noise cancellation using a pre-trained ML model (RNNoise) running in the browser. Removes dogs, fans, and keyboard clicks from the mic stream.
**Shipped:** 3-tier setting (Off / Browser-native / ML) in Settings → General → Calls.
**🔱 [EC-FORK] DONE — moved in-source (2026-06).** ML denoise is now a first-class audio stage **inside** the forked Element Call: a LiveKit `TrackProcessor<Audio>` activated by `lotusDenoiseSource=1` (cinny sets it when ML is selected). The old build-time `getUserMedia`/`index.html` monkeypatch is **removed**. Because EC re-runs the processor on every (re)publish, denoise now **survives reconnects and mic-device switches** — this is the A7 fix (see `LOTUS_BUGS.md` A7, `LOTUS_TESTING.md` §D2-1). The processor degrades to the raw mic rather than going silent.
**Key decision:** LiveKit's Krisp filter is LiveKit-Cloud-only (we self-host the SFU); EC's own RNNoise PR #3892 is unmerged. Owning the fork let us implement the in-source stage directly.
**Models — all in-source in the fork:**
- [x] **DeepFilterNet 3** (48 kHz, **ML default**) · **DTLN** (16 kHz) · **RNNoise** (48 kHz) · **Speex** (48 kHz) — all four wired and selectable; dropdown ordered best-quality first. Tier default is **Browser-native**.
- [x] **Quality tuning (2026-07):** dry/wet **attenuation floor** (~-16 dB, RNNoise/Speex only — the "robotic" fix; DTLN/DFN would comb-filter), **gate-after-ML**, **DFN level 80→60**. Floor tunable via `lotusDenoiseFloor`.
- [x] **AEC/AGC (2026-07):** echo-cancellation ON; **AGC OFF for the ML tier** (`autoGainControl=false`, threaded through EC `UrlParams``ConnectionFactory`) so browser AGC doesn't fight the model; playback confirmed no AEC-defeat.
- [x] **Reliability (2026-07):** never-silent watchdog, resume-timeout, WASM-cache reject-eviction, activate-off-local-participant, init/build leak fixes.
- [ ] **Open verification:** real-call by-ear **A/B** — model choice, floor value, AGC on/off (RNNoise known-weak historically). `LOTUS_TESTING.md` §D2-1 / J2.
- [ ] **GTCRN (RESEARCHED — DEFERRED):** tiny MIT 16 kHz model that beats RNNoise, but **no drop-in browser package** — needs a ~1-week from-scratch build: `onnxruntime-web` (WASM, 1 thread) in a **Web Worker** (ORT can't run in an AudioWorklet — issue #13072) behind a custom AudioWorklet ring-buffer node presenting as an `AudioNode`; model `gtcrn_simple.onnx` (~300 KB, stateful — thread `conv/tra/inter` caches per frame); we write STFT/iSTFT (n_fft 512/hop 256). Assets ~34 MB via the `lotusDenoise()` vite plugin. Registration checklist known (both repos, incl. the 2nd `denoisePipeline.ts` used by the DenoiseTester). **Revisit only if low-power quality is insufficient after validating the current tuning.**
- [ ] **Desktop-only / HW-gated (future):** FRCRN or NVIDIA Maxine (RTX/Tensor only) — impossible in-browser; would run in the Tauri Rust backend + bridge a virtual mic into the webview. Detect capability; web falls back to RNNoise.
- **Excluded:** Krisp (LiveKit Cloud only); FRCRN/Maxine on web (GPU/server-bound).
---
### [~] P5-31 · Granular Voice & Screenshare Quality Controls — IMPLEMENTED (⚠️ awaiting live verification, D2-8)
**What:** Let users (and room admins) adjust audio bitrate and screenshare bitrate/framerate.
**🔱 [EC-FORK] Fork side + client side DONE.** The fork ships `io.lotus.set_quality` (`LotusWidgetActions.SetQuality`) that applies audio/screenshare encoding params (`RTCRtpSender.setParameters`, all simulcast encodings, re-applied on `TrackUnmuted`/republish) inside EC. cinny now drives it.
**Shipped (cinny):**
1. **User settings** (Settings → General → Calls): Microphone Bitrate, Screenshare Bitrate, Screenshare Framerate (`callAudioBitrate` / `screenshareBitrate` / `screenshareFramerate`).
2. **Room-admin caps**: `io.lotus.room_quality` state event (`StateEvent.LotusRoomQuality`) + `RoomQuality.tsx` in Room Settings → General → Voice (mirrors `RoomVoiceLimit`).
3. **Apply logic**: `useCallQuality` (wired in `CallEmbedProvider`'s `CallUtils`) builds `min(user setting, room cap)` and sends `io.lotus.set_quality` on join / when settings change (`utils/callQuality.ts`, unit-tested).
**Server-side enforcement (DONE — matrix repo):** extended `voice-limit-guard.py` (LXC 151) to also read `io.lotus.room_quality` and hard-enforce a **publish-source policy** for ALL clients.
- **Reality (researched, primary-source, LiveKit 1.9.11):** numeric bitrate/fps caps **cannot** be hard-enforced server-side — LiveKit is a pure SFU (forwards, never transcodes); there is NO bitrate/fps field in the JWT grant, `RoomConfiguration`, server `limit:` config, or any admin RPC, and stock Element Call ignores room metadata / custom claims for publish quality. So numeric caps stay **cooperative** (our fork honors them via `min()``set_quality`, already shipped).
- **What IS hard-enforced cross-client:** `VideoGrant.canPublishSources`. The guard holds the LiveKit secret, so when `io.lotus.room_quality` sets `allow_screenshare:false` / `allow_camera:false` it re-signs the issued JWT with a narrowed source list → the SFU refuses those tracks for **every** client (Element, FluffyChat, our fork). Mic always kept. Fail-open; unit-tested (`livekit/test_voice_limit_guard.py`). Admin UI: Room Settings → Voice → **Call Permissions** switches. cinny also hides the blocked buttons.
- **Live (mid-call) enforcement — DONE:** the JWT re-sign covers new joins; for participants **already in the call**, a background reconcile loop in the guard calls LiveKit `UpdateParticipant` every ~3 s to narrow `canPublishSources`, which unpublishes an in-progress screenshare/camera **server-side for all clients** and blocks re-publish (verified LiveKit 1.9.11 auto-unpublishes on permission narrowing). Only removes forbidden sources (never grants), preserves other permission flags, no-ops once compliant. So flipping a room audio-only kills live cameras/screenshares within ~one interval.
- **Not enforceable / deferred:** numeric server enforcement (impossible — see above); screenshare **resolution** control (`set_quality` covers bitrate + framerate; resolution needs a `getDisplayMedia` hook inside the fork).
**Complexity:** DONE — client (cooperative numeric caps) + server (hard publish-source policy). Only the physically-impossible numeric server enforcement is out of scope.
---
### [~] P5-35 · Desktop — Notification Click Opens Room — IMPLEMENTED (Tier B, via the P5-41 WinRT toast: click → open room, reply → send); native CI-compile-pending, runtime-verify on Windows
**What:** Clicking a system tray notification navigates to the relevant room. Quick-reply from the notification toast would send the reply without opening the window.
**Status:** Deferred (Tier B). Note: the "can't compile-test without a Windows build environment" premise is **outdated** — CI now compiles Windows (Gitea self-hosted `windows` runner + GitHub `windows-latest`), and `windows`-crate/COM code already ships (e.g. `set_badge_count`, and the Tier A jump list). This still depends on P5-41 (the WinRT toast + custom activator), so it rides with that; it was not part of the Tier A wave.
**Note:** Tray icon and `matrix:` deep links already bring the window forward on most interactions. Revisit when tauri-plugin-notification gains click handler support upstream.
**Complexity:** High (platform-specific native code required).
---
### [~] P5-36 · Desktop — Windows Jump List — IMPLEMENTED (Tier A); native CI-compile-pending, runtime-verify on Windows
**What:** Right-clicking the taskbar icon shows a jump list with recent/favorite rooms for quick navigation.
**Status:** Deferred — implementing the Windows COM jump list API in Tauri requires iterating on C++/COM code that can only be compile-checked on Windows, making blind CI iteration impractical.
**Action when unblocked:** Revisit when a Tauri plugin abstracts the Windows Shell `ICustomDestinationList` interface, or when a Windows build environment is available for local iteration.
**Complexity:** High (Windows-only native COM).
---
### [~] P5-41 · Desktop — Native WinRT Toast Notifications — IMPLEMENTED (Tier B; ToastNotification + reply input + in-process Activated; falls back to tauri-plugin-notification); native CI-compile-pending. Runtime needs a Start-menu shortcut + matching AppUserModelID to surface
**What:** Replace emulated notifications with native WinRT Toast notifications.
**Approach:** Implement native WinRT Toast integration using `windows-rs` to enable full Action Center integration, including native Quick Reply functionality.
### [~] P5-42 · Desktop — Persistent Background Sync — IMPLEMENTED (Batch 3, pragmatic keep-alive); native CI-compile-pending, runtime-verify on Windows
**What:** Keep receiving messages/notifications instantly while the app is closed to the tray.
**Shipped approach (80/20):** rather than a multi-sprint headless Rust sync client, disable Chromium background throttling via WebView2 `additional_browser_args` (`--disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows`, added to the existing Tauri default args) so the existing JS Matrix `/sync` loop keeps running full-speed in the tray. Windows/WebView2 only; does not block system sleep. See `cinny-desktop/src-tauri/src/lib.rs` (WebviewWindowBuilder).
**Deferred (not needed):** the full headless Rust sidecar — only revisit if WebView2 ever hard-suspends despite these flags (would require a second Matrix client with its own /sync + push-rule eval + E2EE-aware notification content).
### [~] P5-43 · Desktop — System Media Transport Controls (SMTC) — IMPLEMENTED (Tier A); CI-compile-pending; SMTC may need an active media/audio session to surface — verify on Windows
**What:** Integrate with Windows SMTC for volume flyout call/media control.
**Approach:** Use Windows SMTC API to expose call status, mic mute/unmute, and media controls to the Windows volume flyout/media overlay.
### [~] P5-44 · Desktop — Taskbar Thumbnail Toolbar — IMPLEMENTED (Tier A); native CI-compile-pending, runtime-verify on Windows
**What:** Add persistent call controls to the taskbar preview.
**Approach:** Implement a COM thumbnail toolbar in the application preview window, featuring Mute/Deafen/End Call buttons.
### [~] P5-46 · Desktop — System Power Management (Call Continuity) — IMPLEMENTED (Tier A reference); web verified; native CI-compile-pending (Windows only; macOS/Linux no-op TODO)
**What:** Prevent system sleep/hibernate during active calls.
**Approach:** Use Tauri/Rust `power-manager` or platform-specific APIs to block system power saving states while a voice/video session is active.
### [~] P5-47 · Desktop — TDS-Styled Native Window Chrome — IMPLEMENTED (Tier A, OPT-IN/default-off, runtime-reversible); web verified; native CI-compile-pending
**What:** Replace system titlebar with custom Lotus TDS chrome.
**Approach:** Configure Tauri window (`decorations: false`) and implement custom, TDS-token compliant titlebar controls (Close/Max/Min) for a cohesive UI.
### [~] P5-48 · Desktop — Native File System Drag-and-Drop Improvements — IMPLEMENTED (recursive folder upload, web-verified: tsc/build/tests). SCOPED-OUT: `.lnk` shortcut resolution (webview never exposes a dropped file's OS path → native can't resolve the target) and "Send To" (installer/registry shell integration) — deferred
**What:** Enhance drag-and-drop support for Windows.
**Approach:** Improve handling for Windows file shortcuts, recursive folder uploads, and shell-integrated "Send To" context menu actions.
### [~] P5-49 · Desktop — Network Awareness (NCSI Integration) — IMPLEMENTED (Tier A; INetworkListManager poll → mx.retryImmediately); native CI-compile-pending, runtime-verify on Windows
**What:** Proactively detect Windows network connectivity changes.
**Approach:** Integrate with the Windows Network Connectivity Status Indicator (NCSI) API to improve offline mode transition latency and network recovery.
### [WON'T FIX] P5-50 · Desktop — Windows Hardware-Accelerated Media Pipeline
**What:** Replace standard browser decoding with native Windows Media Foundation.
**Why won't-fix (researched):** WebRTC media (the call pipeline) lives entirely inside WebView2/Chromium — you cannot inject Media Foundation/DirectShow into WebRTC's decode path from the Tauri host. Chromium already uses the platform's hardware decoders (D3D11VA/MF) where the GPU supports the codec, so there is no separate CPU pipeline to offload. Not actionable as described.
### [DEFERRED] P5-51 · Desktop — Federated "Identity Contexts" (Isolation Manager)
**What:** Compartmentalize sessions, local databases, and caches into isolated "Contexts" (e.g. Work vs. Personal) with a zero-leak boundary.
**Decision:** Deferred (reviewed 2026-07). Multi-sprint, and it touches the auth/crypto/storage core — not worth the risk/effort for a niche need right now. Kept here with a concrete spec so it's actionable later.
**Future-work spec (why it's big):** the app is currently **single-session**.
- Session lives in `src/app/state/sessions.ts` under fixed localStorage keys — `cinny_access_token`, `cinny_device_id`, `cinny_user_id`, `cinny_hs_base_url`, plus the OIDC keys (`cinny_refresh_token`, `cinny_expires_at`, `cinny_oidc_*`).
- Persistence lives in `src/client/initMatrix.ts`: two fixed IndexedDB stores — `web-sync-store` (`IndexedDBStore`) and `crypto-store` (`IndexedDBCryptoStore`) — feeding one `createClient(...)`.
True per-context isolation would require: (1) namespace every localStorage key per context (`ctx:<id>:cinny_*`); (2) per-context IndexedDB dbNames for **both** the sync store and the crypto store; (3) a context registry + switcher UI (create/rename/delete/switch); (4) full client teardown + re-init on switch (`initMatrix` currently assumes one global client); (5) per-context settings + notification/quiet-hours state; (6) careful crypto-store isolation so device keys never bleed across contexts. **Smaller intermediate step** if demand appears: plain multi-account (fast account switch) *without* the hard isolation boundary — much less risky, reuses most of the login flow.
**Priority:** Extreme Low (Multi-sprint/Architectural).
### [DROPPED] P5-52 · Desktop — Room-Level Sync Governor (Performance Control)
**What:** Granular per-room sync tuning (frequency, event-type filtering).
**Why dropped (reviewed 2026-07):** matrix-js-sdk can't do **true** per-room sync filtering — all room events still come down the single `/sync` stream, so "disable typing/receipts in heavy rooms" can only be a **cosmetic client-side hide**, not an actual performance/bandwidth win. That, plus the UX-complexity risk flagged originally, makes it not worth building. If per-room quieting is ever wanted, add a simple "mute typing & receipts in this room" toggle to normal room settings — not a "governor."
### [DEFERRED] P5-53 · Desktop — Local-Only "Scripting" Plugin System (Tampermonkey-like)
**What:** A sandboxed environment for local execution of user scripts on Matrix events.
**Decision:** Deferred (reviewed 2026-07). A full WASM execution engine + script registry + management UI + security model is a large surface for a very small (power-user) audience.
**Recommended lighter alternative (the ~80/20) if we ever want event automation:** a built-in **automation-rules** feature — declarative "when an incoming event matches X (room / sender / keyword / type) → notify / play sound / highlight / auto-react" rules, configured in Settings. Covers the realistic use cases (custom alerts, keyword pings) with **no arbitrary code execution**, so no sandbox/security burden. Build that instead of a scripting engine if the need arises.
### [~] P5-55 · Desktop — Composer Toolbar Drag-and-Drop Reordering — IMPLEMENTED (Tier A); web-verified (tsc/build/tests). Awaiting live UX check
**What:** Allow users to reorder toolbar icons via drag-and-drop.
**Approach:** Extend the current settings-based toolbar toggle system to include a drag-and-drop UI mode in the composer settings, allowing users to personalize their icon order.
### [~] P5-56 · Desktop — Windows "Focus Assist" (DND) Sync — IMPLEMENTED (Tier B; SHQueryUserNotificationState poll → suppresses notifications+sounds via the quiet-hours gate); native CI-compile-pending, runtime-verify on Windows
**What:** Automatically toggle notification state based on Windows Focus Assist.
**Approach:** Integrate with the Windows `NotificationCenter` / `Focus` state via Tauri/Rust to automatically enable/disable Lotus Chat's internal notification suppression mode when Windows Focus Assist is toggled.
### [~] P5-57 · Desktop — Visual Draft Persistence Indicator — IMPLEMENTED (Tier A); web-verified. Awaiting live UX check
---
## 🚀 Features to Add
- [ ] **Mobile Audit:** Comprehensive audit of all features in LOTUS_FEATURES.md for mobile PWA usability and layout responsiveness.
---
## Blocked Features
These features are confirmed desirable but cannot be built until the listed dependency is resolved.
Check back after each Synapse upgrade — re-run `/matrix/client/versions` and `unstable_features` to see if they've become available.
### [BLOCKED] · Live Location Sharing (MSC3489 + MSC3672)
**Blocked by:** `org.matrix.msc3489 = false` AND `org.matrix.msc3672 = false` on `matrix.lotusguild.org` (confirmed from unstable_features).
**What it would do:** Real-time GPS beacon streaming upgrading the existing static location share.
**Action when unblocked:** Both MSCs must be enabled on the homeserver before any client work.
### [BLOCKED] · Reaction / Relation Redaction (MSC3892)
**Blocked by:** `org.matrix.msc3892` = false on `matrix.lotusguild.org`
**What it would do:** Cleanly remove a reaction without redacting the parent message.
**Current behavior:** Full event redaction — acceptable fallback, no user-facing issue.
**Action when unblocked:** Find `onReactionToggle` redaction call site; swap in MSC3892 endpoint with fallback.
### [BLOCKED] · Room Preview Before Joining (MSC3266)
**Blocked by:** `GET /_matrix/client/v1/rooms/{roomId}/summary` returns `M_UNRECOGNIZED` 404 — endpoint not implemented in Synapse 1.155. Config flag `msc3266_enabled: true` is set but has no effect; Synapse appears not to have shipped a stable implementation at the v1 path. Verified 2026-06-18.
**What it would do:** Show room name, topic, avatar, member count before joining.
**Action when unblocked:** Re-test after each future Synapse upgrade.
### [BLOCKED] · Thread Subscriptions (MSC4306)
**Blocked by:** `org.matrix.msc4306` = false on `matrix.lotusguild.org`
**What it would do:** Follow a thread without posting; get notifications for replies.
**Action when unblocked:** Add "Follow thread" button in the thread panel header (depends on #P3-8 Thread Panel).
### [DONE] · Report User (MSC4260) ✅
**Previously blocked by:** Server spec v1.12, but `POST /_matrix/client/v3/users/{userId}/report` was confirmed **200** on 2026-06-18 (live since Synapse 1.133.0).
**What it does:** Reports a specific user to homeserver admins (separate from reporting a message).
**Note:** Report Message already exists in upstream Cinny. This adds Report User to the profile panel.
**Implemented 2026-06-18:** `ReportUserModal.tsx` added at `src/app/features/room/ReportUserModal.tsx`. Button wired into `UserRoomProfile.tsx` between UserModeration and UserDeviceSessions (hidden for own profile). Category dropdown + reason text, inline success/error feedback, auto-close 1500ms after success.
---
## Pending Audits
### [ ] Audit-3 · Profile banner image — Matrix protocol support
Research whether Matrix spec or MSC4133 (v1.16) defines a standard profile banner field. `uk.tcpip.msc4133.stable = true` on our server — check if a `banner_url` or similar field is defined. If no cross-client standard exists, do not implement.
---
## 📚 Implementation Reference
Exhaustive, low-level implementation details for backlog items. Follow these patterns to ensure code is "Lotus-perfect" (idiomatic, performant, and TDS-compliant).
### P3-8 · Thread Panel (Full Side Drawer)
**Architecture:** Mirror the `MembersDrawer` pattern but with a specialized timeline.
- **State (`src/app/state/room/thread.ts`):**
```typescript
export const activeThreadIdAtom = atom<string | null>(null);
```
- **Layout (`src/app/features/room/Room.tsx`):** Insert `ThreadPanel` conditionally alongside `RoomTimeline`:
```tsx
{
activeThreadId && (
<>
<Line variant="Background" direction="Vertical" size="300" />
<ThreadPanel roomId={roomId} threadId={activeThreadId} />
</>
);
}
```
- **Component (`src/app/features/room/thread/ThreadPanel.tsx`):** Use `room.getThread(threadId)` from the SDK. Render a `Header` with a "Close" button that sets `activeThreadIdAtom` to `null`. Reuse `RoomTimeline` but pass a filtered `EventTimelineSet`. Use `thread.timelineSet` directly for the most accurate thread view.
---
### P4-4 · Math / LaTeX Rendering
**Mechanism:** KaTeX injection into the HTML parser.
- **Sanitizer (`src/app/utils/sanitize.ts`):** Allow KaTeX-specific tags and classes (e.g., `span`, `annotation`, `math`). Use a specialized allowed list for math blocks.
> [Gemini_Found] `sanitize.ts` uses **`sanitize-html`** (not DOMPurify) with an explicit allowlist (`allowedTags`) and `disallowedTagsMode: 'discard'`. All MathML tags are currently absent from the allowlist and are silently stripped. Update `permittedHtmlTags` to include: `<math>`, `<mi>`, `<mo>`, `<mn>`, `<ms>`, `<mtext>`, `<mspace>`, `<mrow>`, `<mfrac>`, `<msqrt>`, `<mroot>`, `<mstyle>`, `<merror>`, `<mpadded>`, `<mphantom>`, `<mfenced>`, `<menclose>`, `<msub>`, `<msup>`, `<msubsup>`, `<munder>`, `<mover>`, `<munderover>`, `<mmultiscripts>`, `<mtable>`, `<mtr>`, `<mtd>`, `<maligngroup>`, `<malignmark>`, and `annotation`. Also add the required MathML attributes (e.g. `xmlns`, `display`, `mathvariant`) to `permittedTagToAttributes`.
- **Parser (`src/app/plugins/react-custom-html-parser.tsx`):** Detect `$ ... $` and `$$ ... $$` patterns in text nodes:
```tsx
if (node.type === 'text') {
const parts = node.data.split(/(\$\$.*?\$\$|\$.*?\$)/g);
return parts.map((p) => {
if (p.startsWith('$')) return <KaTeX math={p.replace(/\$/g, '')} />;
return p;
});
}
```
- **CSS (`src/app/styles/CustomHtml.css.ts`):** Import `katex/dist/katex.min.css` only when a math block is rendered to save initial bundle size.
---
### P4-6 · OIDC / SSO Next-Gen Auth (MSC3861)
**Mechanism:** Matrix Authentication Service (MAS) Integration.
- **Architecture:** Shift from password-based `/login` to OAuth2 `authorization_code` flow.
- **Key Files:** `src/app/pages/auth/Login.tsx` and `src/app/hooks/useAuth.ts`.
- **Implementation:** Use `oidc-client-ts` or a similar lightweight OIDC library. Check for `m.authentication` in `/.well-known/matrix/client`. Redirect to the MAS authorization endpoint. Handle the callback in a new `OidcCallback` route and store the OIDC `refresh_token`.
---
### P5-1 · Custom Accent Color Picker (Non-TDS only)
**Mechanism:** Dynamic CSS variable injection.
- **Setting (`src/app/state/settings.ts`):** Add `customAccentColor: string` (hex).
- **Manager (`src/app/pages/ThemeManager.tsx`):** Inside the `useEffect` that monitors theme changes:
```typescript
if (!lotusTerminal && customAccentColor) {
document.documentElement.style.setProperty('--lt-accent-orange', customAccentColor);
document.documentElement.style.setProperty('--lt-accent-orange-glow', `${customAccentColor}80`);
}
```
- **UI (`src/app/features/settings/general/General.tsx`):** Use `<Input type="color">`. Hide this section if `lotusTerminal` is `true`.
---
### P5-15 · In-Call Soundboard
**Mechanism:** Local-to-Global Audio Bridge via Web Audio API.
- Create an `AudioContext` and a `MediaStreamDestinationNode`.
- Create an `AudioBufferSourceNode` for each clip.
- Route the mic `MediaStream` and the clip source to the destination node.
- Pass the destination's `.stream` to the call bridge.
> ⚠️ **[Gemini_Found — CORRECTED]** Gemini originally suggested using LiveKit's `LocalAudioTrack.replaceTrack()` to mix audio into the call stream. This is **not possible** from Lotus Chat's realm: Element Call runs in a **cross-origin iframe** controlled via `matrix-widget-api` (postMessage). LiveKit's JS SDK and its `LocalAudioTrack` live inside EC's sandboxed context — inaccessible from our code. This directly contradicts the confirmed constraint already listed in the Server Capabilities table: _"Cindy CANNOT inject audio into EC call stream — In-call soundboard must be redesigned as local-only."_ The soundboard must be a local-playback-only feature (output through the user's speakers, not mixed into the call audio stream).
>
> 🔱 **[EC-FORK — RESOLVED]** Both the original claim and the earlier "practical blocker still holds" correction are now **outdated**. EC is same-origin **and** we own the source, so we no longer reach into EC's module scope from cinny — instead the fork **exposes the inject point itself**: the `io.lotus.inject_audio` widget action (`LotusWidgetActions.InjectAudio`) publishes a clip as a separate LiveKit track from inside EC. A **real** in-call soundboard (mixed into the call, not local-only) is therefore unblocked, and the cinny-side soundboard UI is now **built** (P5-15 above): uploadable clips played into the call via this action, stored in `io.lotus.soundboard` account data.
---
### P5-20 · Quick Reply from Browser Notification
**Mechanism:** Service Worker `notificationclick` Action.
> [Gemini_Found] Implementation detail: `serviceWorkerRegistration.showNotification()` should be used instead of `new Notification()` so that the service worker can listen to the `notificationclick` event. `new Notification()` creates notifications that are bound to the client page, not the SW.
```typescript
// src/sw.ts
self.addEventListener('notificationclick', (event) => {
if (event.action === 'reply' && event.reply) {
const { roomId, threadId } = event.notification.data;
const session = sessions.get(event.clientId);
fetch(`${session.baseUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message`, {
method: 'POST',
headers: { Authorization: `Bearer ${session.accessToken}` },
body: JSON.stringify({
msgtype: 'm.text',
body: event.reply,
'm.relates_to': threadId ? { rel_type: 'm.thread', event_id: threadId } : undefined,
}),
});
}
});
```
---
### P5-30 · Advanced ML Noise Suppression — Model Roadmap
See shipped implementation in LOTUS_FEATURES.md → "Noise Suppression (Advanced Multi-Tier)".
**Models status:**
- **RNNoise** (sapphi, 48 kHz) — ✅ working, default fallback. Keep — runs on any hardware.
- **Speex** (sapphi, 48 kHz) — ✅ working, low value; candidate to drop.
- **DTLN** (@workadventure, 16 kHz) — 🟡 wired; sample-rate fix applied (was robotic at 48 kHz). **TODO: verify in a real call.** Narrowband (16 kHz) = slightly telephone-y even when correct.
**Constraints:** client-side AudioWorklet, fully self-hosted, no GPU, self-hosted SFU (no LiveKit Cloud).
**Roadmap:**
- [ ] Verify DTLN 16 kHz fix in a real call.
- [ ] **DeepFilterNet 3** — best self-hostable upgrade: Rust→WASM, CPU real-time, 48 kHz fullband. Self-host `df_bg.wasm` + DFN3 ONNX model; wire a 48 kHz worklet. Audio quality unverifiable without a real-call test.
- [ ] **Desktop-only / HW-gated:** FRCRN (Alibaba) or NVIDIA Maxine (RTX/Tensor only). Runs in Tauri Rust backend + bridges a virtual mic into the webview. Must detect capability; web + weak HW falls back to RNNoise/DTLN.
---
### P5-31 · Granular Voice & Screenshare Quality Controls
**Mechanism:** WebRTC Encoding Parameters + Backend Quality Guard.
- **State Event:** `io.lotus.room_quality` (state key `""`) containing:
```json
{ "audio_bitrate": 128000, "screen_max_res": "1080p", "screen_max_fps": 60 }
```
- **Screenshare:** In `src/app/plugins/call/CallControl.ts`, map the "Quality" setting to `getDisplayMedia` constraints.
- **Audio Bitrate:** After the call joins, find the `RTCRtpSender` for the audio track:
```typescript
const sender = peerConnection.getSenders().find((s) => s.track?.kind === 'audio');
const params = sender.getParameters();
params.encodings[0].maxBitrate = roomBitrate || 128000;
await sender.setParameters(params);
```
- **Backend Sidecar:** Extend `voice-limit-guard.py` (LXC 151) to fetch `io.lotus.room_quality` and inject limits into the LiveKit JWT or return them as an authorized config packet.
---
### [x] P5-40 · Desktop — Proactive Update Notifications (Tauri) — DONE (already shipped: `TauriUpdateFeature` in ClientNonUIFeatures.tsx polls every 12h + fires the sticky update toast)
**Key Files:** `src/app/hooks/useTauriUpdater.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx`, `src/app/features/toast/LotusToastContainer.tsx`.
1. Create a `TauriUpdateFeature` component. Use `useTauriUpdater()` to get the `check` function and `status`.
2. In a `useEffect`, call `check()` on mount and then on a `setInterval` (every 12 hours).
3. When status transitions to `{ state: 'available', version: '...' }`, fire a Lotus Toast: "Lotus Chat v[version] is available!" with an "Update" button that calls `install()`.
4. Store `lastCheck` timestamp in `localStorage` to prevent redundant checks on refresh.
---
### Mobile Bookmarks Visibility Fix
**Issue:** `ClientLayout.tsx` explicitly restricts `BookmarksPanel` to `ScreenSize.Desktop` (lines 51-56).
```tsx
// ClientLayout.tsx
{
bookmarksOpen && (
<BookmarksPanel
onClose={() => setBookmarksOpen(false)}
isMobile={screenSize !== ScreenSize.Desktop}
/>
);
}
```
`BookmarksPanel.tsx` already supports the `isMobile` prop (line 127) to enable full-screen absolute positioning. No other changes required.
---
### Remind Me Later (Slack-style)
**Mechanism:** Account Data + Timer/Service Worker.
- **Storage (`src/app/hooks/useReminders.ts`):** Store in account data `io.lotus.reminders` as `Array<{ id: string, roomId: string, eventId: string, timestamp: number }>`.
- **Context Menu (`src/app/features/room/message/MessageContextMenu.tsx`):** Add "Remind me" option → opens date/time picker modal (reuse `JumpToTime.tsx` logic).
- **Trigger (foreground):** `setTimeout` in a hook inside `ReminderMonitor` in `ClientNonUIFeatures.tsx` → pushes to `toastQueueAtom` in `state/toast.ts` when due.
- **Trigger (background):** Use Service Worker — `setTimeout` in the main thread will not fire when the PWA is suspended.
---
### Mobile Usability Audit — Methodology
1. **Viewport & Touch:** All interactive elements must have at least `44px × 44px` touch targets. Audit for horizontal overflow (horizontal scrolling must be disabled).
2. **Modal Responsiveness:** All modals (Settings, Profile, etc.) MUST cover the full screen on mobile, not float as overlays.
3. **Sidebar / Panels:** On mobile, sidebar panels (Members, Bookmarks, Media) must become full-screen overlays (using a `Drawer` or `Modal` pattern) rather than side-by-side flexbox panels.
4. **Input & Composer:** Ensure the composer doesn't get obscured by the mobile keyboard. Test focus trap and blur behaviors.
---
## Implementation Notes
### ⚠️ TDS DESIGN LAW (repeated here for emphasis)
> Every TDS color, animation, glow, border, shadow, and font value MUST come from `/root/code/web_template/base.css`.
> Never hardcode hex values. Never invent CSS variable names.
> Key variables: `--lt-accent-orange` · `--lt-accent-cyan` · `--lt-accent-green` · `--lt-glow-*` · `--lt-box-glow-*` · `--lt-border-color` · `--lt-font-mono`
> Reference implementation: `/root/code/tinker_tickets/` (markdown.js, base.js, ticket.css)
> This applies without exception to every task marked `[IMPROVE]`, `[Build]`, or any UI change.
### Design Rules
- All new components must respect both TDS dark (`LotusTerminalTheme`) and TDS light (`LotusTerminalLightTheme`) modes
- Non-TDS theme work (custom accent color, theme presets) uses vanilla-extract theme files — match the pattern in `src/lotus-terminal.css.ts`
- Code syntax highlighting token classes: `.tok-kw .tok-str .tok-num .tok-cmt .tok-fn` (defined in `web_template/base.css`)
- `folds AvatarImage` does NOT accept children — wrap Avatar components externally for overlays/frames/borders
### CI/CD Pipeline
``` ```
edit → commit → git push origin lotus edit → commit → git push origin lotus
→ Gitea Actions: tsc --noEmit, eslint, prettier (~3 min) → Gitea Actions: tsc --noEmit, eslint, prettier (~3 min)
Webhook: lotus_deploy.sh on LXC 106 polls CI, then npm ci && npm run build → rsync → lotus_deploy.sh on LXC 106 polls CI npm ci && npm run build → rsync → live (~11 min)
→ Live at chat.lotusguild.org (~11 min total)
``` ```
### Per-Feature Checklist (before marking complete) Before marking a feature complete: `npx tsc --noEmit` (0 errors) · `npx eslint src/` (0 new) · `npx prettier --check src/` · `npm test` (Node runner via tsx, hard CI gate — colocated `*.test.ts`) · update `README.md`/`landing/index.html` for Lotus-custom features · visually verify on `chat.lotusguild.org`.
- [ ] `npx tsc --noEmit` — zero TypeScript errors
- [ ] `npx eslint src/` — zero new errors (warnings OK if pre-existing)
- [ ] `npx prettier --check src/` — formatting passes
- [ ] `README.md` updated (Lotus-custom features only — not upstream Cinny features)
- [ ] `landing/index.html` updated if the feature appears in the comparison table
- [ ] Visually tested at `chat.lotusguild.org` after CI deploys
### Homeserver Access (for server audits)
- **Synapse (Matrix):** LXC 151 on `compute-storage-01` — `pct exec 151 -- bash`
- **Config:** `/etc/matrix-synapse/homeserver.yaml`
- **Version check:** `curl -s https://matrix.lotusguild.org/_matrix/client/versions`
+8 -4
View File
@@ -18,6 +18,8 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
### Messaging ### Messaging
- Threads: reply in a thread and read/write the whole conversation in a side panel — root messages show a "N replies" chip with an unread badge (threaded replies live in the panel now, not inline in the room)
- Slack-style thread notifications: by default you're only pinged for threads you're in or where you're @mentioned; set any thread to All / Mentions-only / Mute from the panel's bell menu (muted threads stop bumping badges; syncs across devices)
- See who has read each message, and track delivery status (sending / sent / failed) - See who has read each message, and track delivery status (sending / sent / failed)
- Bookmark any message and revisit saved messages from the sidebar - Bookmark any message and revisit saved messages from the sidebar
- Schedule messages to send at a specific time - Schedule messages to send at a specific time
@@ -33,6 +35,8 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
- Search for and send GIFs from a built-in GIF picker - Search for and send GIFs from a built-in GIF picker
- Control voice message playback speed: 0.75× / 1× / 1.5× / 2× - Control voice message playback speed: 0.75× / 1× / 1.5× / 2×
- Search messages with a date range filter - Search messages with a date range filter
- Optional persistent search index for encrypted rooms (off by default — stores decrypted text on your device; clearable, wiped on logout)
- Write math with LaTeX: `$inline$` and `$$block$$` render via KaTeX (spec `data-mx-maths` supported)
- Room topics support rich formatting (bold, links, italics) - Room topics support rich formatting (bold, links, italics)
- Deleted messages show a placeholder instead of disappearing - Deleted messages show a placeholder instead of disappearing
- Code blocks highlight syntax for JS/TS, Python, and Rust - Code blocks highlight syntax for JS/TS, Python, and Rust
@@ -176,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
+11
View File
@@ -3,4 +3,15 @@ cinny.domain.tld {
root * /path/to/cinny/dist root * /path/to/cinny/dist
try_files {path} /index.html try_files {path} /index.html
file_server file_server
# Security headers (generic; add a Content-Security-Policy suited to your
# homeserver + any embedded services). Caddy serves HTTPS automatically, so
# HSTS is delivered over TLS.
header {
X-Frame-Options SAMEORIGIN
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
Strict-Transport-Security "max-age=63072000; includeSubDomains"
Permissions-Policy "accelerometer=(), autoplay=(self), camera=(self), display-capture=(self), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self), midi=(), payment=(), usb=()"
}
} }
+9
View File
@@ -17,6 +17,15 @@ server {
listen [::]:443 ssl; listen [::]:443 ssl;
server_name cinny.domain.tld; server_name cinny.domain.tld;
# Security headers (generic; add a Content-Security-Policy suited to your
# homeserver + any embedded services). NOTE: nginx does not inherit
# server-level add_header into a location that sets its own add_header.
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header Permissions-Policy "accelerometer=(), autoplay=(self), camera=(self), display-capture=(self), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self), midi=(), payment=(), usb=()" always;
location / { location / {
root /opt/cinny/dist/; root /opt/cinny/dist/;
+4 -4
View File
@@ -5,11 +5,11 @@ experimental_features:
msc3861: msc3861:
enabled: true enabled: true
issuer: http://localhost:8090/ issuer: http://localhost:8090/
client_id: "0000000000000000000SYNAPSE" client_id: '0000000000000000000SYNAPSE'
client_auth_method: client_secret_basic client_auth_method: client_secret_basic
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET" client_secret: 'REPLACE_WITH_A_SHARED_CLIENT_SECRET'
admin_token: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN" admin_token: 'REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN'
account_management_url: "http://localhost:8090/account" account_management_url: 'http://localhost:8090/account'
# With msc3861 enabled, Synapse disables its own password/SSO login and advertises # With msc3861 enabled, Synapse disables its own password/SSO login and advertises
# `m.authentication` in /.well-known/matrix/client — which is exactly what the # `m.authentication` in /.well-known/matrix/client — which is exactly what the
+34 -1
View File
@@ -25,7 +25,7 @@ export default [
tsPlugin.configs['flat/eslint-recommended'], tsPlugin.configs['flat/eslint-recommended'],
...tsPlugin.configs['flat/recommended'], ...tsPlugin.configs['flat/recommended'],
reactPlugin.configs.flat.recommended, reactPlugin.configs.flat.recommended,
reactHooksPlugin.configs.flat['recommended'], reactHooksPlugin.configs.flat.recommended,
// Register jsx-a11y plugin (rules selectively enabled below) // Register jsx-a11y plugin (rules selectively enabled below)
{ plugins: { 'jsx-a11y': jsxA11yPlugin } }, { plugins: { 'jsx-a11y': jsxA11yPlugin } },
// airbnb-base via FlatCompat (JS/import rules; no React plugin, no getFilename issue) // airbnb-base via FlatCompat (JS/import rules; no React plugin, no getFilename issue)
@@ -115,6 +115,26 @@ export default [
'jsx-a11y/media-has-caption': 'off', 'jsx-a11y/media-has-caption': 'off',
'jsx-a11y/no-noninteractive-element-interactions': 'off', 'jsx-a11y/no-noninteractive-element-interactions': 'off',
'jsx-a11y/alt-text': 'off', 'jsx-a11y/alt-text': 'off',
// A11y regression gate (P3-4). A CURATED set — correctness rules that catch
// real WCAG gaps (missing accessible names, malformed ARIA) without
// flooding on the pre-existing clickable-div patterns. The heavier
// interaction rules (no-static-element-interactions,
// click-events-have-key-events) are a separate cleanup and stay OFF.
'jsx-a11y/aria-props': 'error',
'jsx-a11y/aria-proptypes': 'error',
'jsx-a11y/aria-role': ['error', { ignoreNonDOM: true }],
'jsx-a11y/aria-unsupported-elements': 'error',
'jsx-a11y/role-has-required-aria-props': 'error',
'jsx-a11y/role-supports-aria-props': 'error',
'jsx-a11y/no-redundant-roles': 'error',
'jsx-a11y/anchor-has-content': 'error',
'jsx-a11y/heading-has-content': 'error',
'jsx-a11y/label-has-associated-control': ['error', { assert: 'either', depth: 5 }],
// NOT enabled: control-has-associated-label. This repo labels most inputs
// with folds `<Text as="label" htmlFor>` — a component the rule's static
// analysis can't see as a <label>, producing false positives on correctly
// labeled controls. The genuinely-unlabeled controls it surfaced (sliders,
// file input, media players, notes) were fixed directly with aria-label.
}, },
}, },
{ {
@@ -123,4 +143,17 @@ export default [
'no-undef': 'off', 'no-undef': 'off',
}, },
}, },
{
// 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'],
rules: {
'max-classes-per-file': 'off',
'lines-between-class-members': 'off',
'prefer-arrow-callback': 'off',
},
},
]; ];
+64 -40
View File
@@ -24,7 +24,6 @@
"@tanstack/react-query": "5.100.13", "@tanstack/react-query": "5.100.13",
"@tanstack/react-query-devtools": "5.100.13", "@tanstack/react-query-devtools": "5.100.13",
"@tanstack/react-virtual": "3.13.25", "@tanstack/react-virtual": "3.13.25",
"@types/dompurify": "3.2.0",
"@workadventure/noise-suppression": "0.0.4", "@workadventure/noise-suppression": "0.0.4",
"await-to-js": "3.0.0", "await-to-js": "3.0.0",
"badwords-list": "2.0.1-4", "badwords-list": "2.0.1-4",
@@ -36,7 +35,6 @@
"dayjs": "1.11.20", "dayjs": "1.11.20",
"deepfilternet3-noise-filter": "1.2.1", "deepfilternet3-noise-filter": "1.2.1",
"domhandler": "6.0.1", "domhandler": "6.0.1",
"dompurify": "3.4.5",
"emojibase": "17.0.0", "emojibase": "17.0.0",
"emojibase-data": "17.0.0", "emojibase-data": "17.0.0",
"file-saver": "2.0.5", "file-saver": "2.0.5",
@@ -51,13 +49,15 @@
"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",
"katex": "0.16.11",
"linkify-react": "4.3.3", "linkify-react": "4.3.3",
"linkifyjs": "4.3.3", "linkifyjs": "4.3.3",
"matrix-js-sdk": "41.6.0-rc.0", "matrix-js-sdk": "41.7.0",
"matrix-widget-api": "1.17.0", "matrix-widget-api": "1.17.0",
"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.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",
@@ -74,7 +74,8 @@
"slate-history": "0.113.1", "slate-history": "0.113.1",
"slate-react": "0.124.2", "slate-react": "0.124.2",
"styled-components": "6.4.2", "styled-components": "6.4.2",
"ua-parser-js": "2.0.10" "ua-parser-js": "2.0.10",
"workbox-precaching": "7.4.1"
}, },
"devDependencies": { "devDependencies": {
"@lotusguild/element-call-embedded": "0.20.1-lotus.1", "@lotusguild/element-call-embedded": "0.20.1-lotus.1",
@@ -83,6 +84,7 @@
"@types/chroma-js": "3.1.2", "@types/chroma-js": "3.1.2",
"@types/file-saver": "2.0.7", "@types/file-saver": "2.0.7",
"@types/is-hotkey": "0.1.10", "@types/is-hotkey": "0.1.10",
"@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/react": "19.2.15", "@types/react": "19.2.15",
@@ -2695,9 +2697,9 @@
"dev": true "dev": true
}, },
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": { "node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
"version": "18.3.0", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.0.tgz", "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.1.tgz",
"integrity": "sha512-9a4feyt8QLysARu7PHKaRWT+wcCd+IYH074LXp9QK5WqfN4zUXueRhiSSMNT18Bm+8q3sBR/4zxDxOSDR0M8Kg==", "integrity": "sha512-VRjWhE1UgHnPpJ3b9B5+8z71ZC/HICFngPPFIN6ktzmUBKI5RusPujzbAQUoB3CgZ0yU58L99AfSQS4YTztSWw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">= 18" "node": ">= 18"
@@ -3918,16 +3920,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/dompurify": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz",
"integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==",
"deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.",
"license": "MIT",
"dependencies": {
"dompurify": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3974,6 +3966,13 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true "dev": true
}, },
"node_modules/@types/katex": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
"integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "25.9.1", "version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
@@ -4042,7 +4041,7 @@
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"devOptional": true "dev": true
}, },
"node_modules/@types/ua-parser-js": { "node_modules/@types/ua-parser-js": {
"version": "0.7.39", "version": "0.7.39",
@@ -5541,12 +5540,16 @@
"dev": true "dev": true
}, },
"node_modules/content-type": { "node_modules/content-type": {
"version": "1.0.5", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.6" "node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/conventional-commit-types": { "node_modules/conventional-commit-types": {
@@ -6187,15 +6190,6 @@
"node": ">=20.19.0" "node": ">=20.19.0"
} }
}, },
"node_modules/dompurify": {
"version": "3.4.5",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": { "node_modules/domutils": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
@@ -9087,6 +9081,31 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/katex": {
"version": "0.16.11",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.11.tgz",
"integrity": "sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"license": "MIT",
"dependencies": {
"commander": "^8.3.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/katex/node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -9937,16 +9956,16 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/matrix-js-sdk": { "node_modules/matrix-js-sdk": {
"version": "41.6.0-rc.0", "version": "41.7.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.6.0-rc.0.tgz", "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.7.0.tgz",
"integrity": "sha512-FcTQyR+Nfh0ASEogYcX393hxGr1936Esg53Z+0f9O4SBsAxl1ZSkLXY3JfLZRLX9dNe38VVwQDQE6QuwnwV7Zw==", "integrity": "sha512-MP0xNv/VVRbshq00TE6EVo77IIXsQk0KjiVtgKV0t9j/V77a6Klt00QrrO0XykkTUsNC0+mQeBMxnx75rZO86Q==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^18.2.0", "@matrix-org/matrix-sdk-crypto-wasm": "^18.3.1",
"another-json": "^0.2.0", "another-json": "^0.2.0",
"bs58": "^6.0.0", "bs58": "^6.0.0",
"content-type": "^1.0.4", "content-type": "^2.0.0",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"loglevel": "^1.9.2", "loglevel": "^1.9.2",
"matrix-events-sdk": "0.0.1", "matrix-events-sdk": "0.0.1",
@@ -10740,6 +10759,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"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/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",
@@ -13194,7 +13222,6 @@
"version": "7.4.1", "version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz", "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz",
"integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==", "integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/workbox-expiration": { "node_modules/workbox-expiration": {
@@ -13235,7 +13262,6 @@
"version": "7.4.1", "version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz", "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz",
"integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==", "integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"workbox-core": "7.4.1", "workbox-core": "7.4.1",
@@ -13272,7 +13298,6 @@
"version": "7.4.1", "version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz", "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz",
"integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==", "integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"workbox-core": "7.4.1" "workbox-core": "7.4.1"
@@ -13282,7 +13307,6 @@
"version": "7.4.1", "version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz", "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz",
"integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==", "integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"workbox-core": "7.4.1" "workbox-core": "7.4.1"
+6 -4
View File
@@ -49,7 +49,6 @@
"@tanstack/react-query": "5.100.13", "@tanstack/react-query": "5.100.13",
"@tanstack/react-query-devtools": "5.100.13", "@tanstack/react-query-devtools": "5.100.13",
"@tanstack/react-virtual": "3.13.25", "@tanstack/react-virtual": "3.13.25",
"@types/dompurify": "3.2.0",
"@workadventure/noise-suppression": "0.0.4", "@workadventure/noise-suppression": "0.0.4",
"await-to-js": "3.0.0", "await-to-js": "3.0.0",
"badwords-list": "2.0.1-4", "badwords-list": "2.0.1-4",
@@ -61,7 +60,6 @@
"dayjs": "1.11.20", "dayjs": "1.11.20",
"deepfilternet3-noise-filter": "1.2.1", "deepfilternet3-noise-filter": "1.2.1",
"domhandler": "6.0.1", "domhandler": "6.0.1",
"dompurify": "3.4.5",
"emojibase": "17.0.0", "emojibase": "17.0.0",
"emojibase-data": "17.0.0", "emojibase-data": "17.0.0",
"file-saver": "2.0.5", "file-saver": "2.0.5",
@@ -76,13 +74,15 @@
"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",
"katex": "0.16.11",
"linkify-react": "4.3.3", "linkify-react": "4.3.3",
"linkifyjs": "4.3.3", "linkifyjs": "4.3.3",
"matrix-js-sdk": "41.6.0-rc.0", "matrix-js-sdk": "41.7.0",
"matrix-widget-api": "1.17.0", "matrix-widget-api": "1.17.0",
"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.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",
@@ -99,7 +99,8 @@
"slate-history": "0.113.1", "slate-history": "0.113.1",
"slate-react": "0.124.2", "slate-react": "0.124.2",
"styled-components": "6.4.2", "styled-components": "6.4.2",
"ua-parser-js": "2.0.10" "ua-parser-js": "2.0.10",
"workbox-precaching": "7.4.1"
}, },
"devDependencies": { "devDependencies": {
"@lotusguild/element-call-embedded": "0.20.1-lotus.1", "@lotusguild/element-call-embedded": "0.20.1-lotus.1",
@@ -108,6 +109,7 @@
"@types/chroma-js": "3.1.2", "@types/chroma-js": "3.1.2",
"@types/file-saver": "2.0.7", "@types/file-saver": "2.0.7",
"@types/is-hotkey": "0.1.10", "@types/is-hotkey": "0.1.10",
"@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/react": "19.2.15", "@types/react": "19.2.15",
+1
View File
@@ -213,6 +213,7 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
<Text size="L400">Account Data</Text> <Text size="L400">Account Data</Text>
<Input <Input
variant="SurfaceVariant" variant="SurfaceVariant"
aria-label="Account data type"
size="400" size="400"
radii="300" radii="300"
readOnly readOnly
+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);
@@ -5,6 +5,7 @@ import { useTauriSmtc } from '../hooks/useTauriSmtc';
import { useTauriNetwork } from '../hooks/useTauriNetwork'; import { useTauriNetwork } from '../hooks/useTauriNetwork';
import { useTauriToastActions } from '../hooks/useTauriToastActions'; import { useTauriToastActions } from '../hooks/useTauriToastActions';
import { useTauriFocusAssist } from '../hooks/useTauriFocusAssist'; import { useTauriFocusAssist } from '../hooks/useTauriFocusAssist';
import { useTauriDnd } from '../hooks/useTauriDnd';
/** /**
* Mounts the client-scoped native desktop feature hooks (call/room aware). Each * Mounts the client-scoped native desktop feature hooks (call/room aware). Each
@@ -21,5 +22,6 @@ export function TauriDesktopFeatures(): null {
useTauriNetwork(); // P5-49 network-change awareness → sync retry useTauriNetwork(); // P5-49 network-change awareness → sync retry
useTauriToastActions(); // P5-41/35 rich toast click → open room, quick reply → send useTauriToastActions(); // P5-41/35 rich toast click → open room, quick reply → send
useTauriFocusAssist(); // P5-56 Windows Focus Assist → DND suppression atom useTauriFocusAssist(); // P5-56 Windows Focus Assist → DND suppression atom
useTauriDnd(); // P6-1 tray "Do Not Disturb" → notification suppression atom
return null; return null;
} }
@@ -0,0 +1,127 @@
import { Box, config, Icon, Icons, IconSrc, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import React, { MouseEventHandler, ReactNode, useMemo, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../utils/keyboard';
import { ThreadNotificationMode } from '../utils/threadNotifications';
import { useSetThreadNotificationMode } from '../hooks/useThreadNotifications';
import { AsyncStatus } from '../hooks/useAsyncCallback';
export const getThreadNotificationModeIcon = (mode?: ThreadNotificationMode): IconSrc => {
if (mode === ThreadNotificationMode.Mute) return Icons.BellMute;
if (mode === ThreadNotificationMode.MentionsOnly) return Icons.BellPing;
if (mode === ThreadNotificationMode.All) return Icons.BellRing;
return Icons.Bell;
};
const useThreadNotificationModes = (): ThreadNotificationMode[] =>
useMemo(
() => [
ThreadNotificationMode.Default,
ThreadNotificationMode.All,
ThreadNotificationMode.MentionsOnly,
ThreadNotificationMode.Mute,
],
[],
);
const useThreadNotificationModeStr = (): Record<ThreadNotificationMode, string> =>
useMemo(
() => ({
[ThreadNotificationMode.Default]: 'Default (participating)',
[ThreadNotificationMode.All]: 'All replies',
[ThreadNotificationMode.MentionsOnly]: 'Mentions only',
[ThreadNotificationMode.Mute]: 'Mute',
}),
[],
);
type ThreadNotificationModeSwitcherProps = {
roomId: string;
threadId: string;
value?: ThreadNotificationMode;
children: (
handleOpen: MouseEventHandler<HTMLButtonElement>,
opened: boolean,
changing: boolean,
) => ReactNode;
};
export function ThreadNotificationModeSwitcher({
roomId,
threadId,
value = ThreadNotificationMode.Default,
children,
}: ThreadNotificationModeSwitcherProps) {
const modes = useThreadNotificationModes();
const modeToStr = useThreadNotificationModeStr();
const { modeState, setMode } = useSetThreadNotificationMode(roomId, threadId);
const changing = modeState.status === AsyncStatus.Loading;
const [menuCords, setMenuCords] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleClose = () => {
setMenuCords(undefined);
};
const handleSelect = (mode: ThreadNotificationMode) => {
if (changing) return;
setMode(mode);
handleClose();
};
return (
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleClose,
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{modes.map((mode) => (
<MenuItem
key={mode}
size="300"
variant="Surface"
aria-pressed={mode === value}
radii="300"
disabled={changing}
onClick={() => handleSelect(mode)}
before={
<Icon
size="100"
src={getThreadNotificationModeIcon(mode)}
filled={mode === value}
/>
}
>
<Text size="T300">
{mode === value ? <b>{modeToStr[mode]}</b> : modeToStr[mode]}
</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
>
{children(handleOpenMenu, !!menuCords, changing)}
</PopOut>
);
}
+6 -1
View File
@@ -282,7 +282,12 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
> >
{previewUrl && ( {previewUrl && (
<> <>
<audio ref={previewAudioRef} src={previewUrl} onEnded={() => setPreviewPlaying(false)} /> <audio
ref={previewAudioRef}
src={previewUrl}
onEnded={() => setPreviewPlaying(false)}
aria-hidden="true"
/>
<IconButton <IconButton
onClick={() => { onClick={() => {
const audio = previewAudioRef.current; const audio = previewAudioRef.current;
@@ -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';
}} }}
@@ -78,11 +78,14 @@ export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
return ( return (
<Box shrink="No" direction="Column" gap="100"> <Box shrink="No" direction="Column" gap="100">
<Text size="L400">Address (Optional)</Text> <Text as="label" htmlFor="create-room-alias" size="L400">
Address (Optional)
</Text>
<Text size="T200" priority="300"> <Text size="T200" priority="300">
Pick an unique address to make it discoverable. Pick an unique address to make it discoverable.
</Text> </Text>
<Input <Input
id="create-room-alias"
ref={aliasInputRef} ref={aliasInputRef}
onChange={handleAliasChange} onChange={handleAliasChange}
before={ before={
+4 -1
View File
@@ -66,6 +66,8 @@ type CustomEditorProps = {
maxHeight?: string; maxHeight?: string;
editor: Editor; editor: Editor;
placeholder?: string; placeholder?: string;
/** Explicit accessible name for the textbox; falls back to the placeholder. */
ariaLabel?: string;
onKeyDown?: KeyboardEventHandler; onKeyDown?: KeyboardEventHandler;
onKeyUp?: KeyboardEventHandler; onKeyUp?: KeyboardEventHandler;
onChange?: EditorChangeHandler; onChange?: EditorChangeHandler;
@@ -82,6 +84,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
maxHeight = '50vh', maxHeight = '50vh',
editor, editor,
placeholder, placeholder,
ariaLabel,
onKeyDown, onKeyDown,
onKeyUp, onKeyUp,
onChange, onChange,
@@ -139,7 +142,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
data-editable-name={editableName} data-editable-name={editableName}
className={css.EditorTextarea} className={css.EditorTextarea}
placeholder={placeholder} placeholder={placeholder}
aria-label={placeholder ?? 'Message input'} aria-label={ariaLabel ?? placeholder ?? 'Message input'}
aria-multiline="true" aria-multiline="true"
renderPlaceholder={renderPlaceholder} renderPlaceholder={renderPlaceholder}
renderElement={renderElement} renderElement={renderElement}
@@ -1,4 +1,4 @@
import React, { KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo } from 'react'; import React, { KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo, useState } from 'react';
import { Editor } from 'slate'; import { Editor } from 'slate';
import { Box, MenuItem, Text, toRem } from 'folds'; import { Box, MenuItem, Text, toRem } from 'folds';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
@@ -11,7 +11,7 @@ import { onTabPress } from '../../../utils/keyboard';
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils'; import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import { useRelevantImagePacks } from '../../../hooks/useImagePacks'; import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
import { IEmoji, emojis } from '../../../plugins/emoji'; import { IEmoji, emojis, loadEmojiData } from '../../../plugins/emoji';
import { useKeyDown } from '../../../hooks/useKeyDown'; import { useKeyDown } from '../../../hooks/useKeyDown';
import { mxcUrlToHttp } from '../../../utils/matrix'; import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
@@ -47,13 +47,32 @@ export function EmoticonAutocomplete({
const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms); const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
const recentEmoji = useRecentEmoji(mx, 20); const recentEmoji = useRecentEmoji(mx, 20);
// Lazily load emojibase data (see plugins/emoji `loadEmojiData`). Until it
// resolves, `emojis` is empty and autocomplete matches only custom-emoji
// packs; the unicode emoji list fills in once loaded.
const [loadedEmojis, setLoadedEmojis] = useState<IEmoji[]>(() => emojis);
useEffect(() => {
let alive = true;
loadEmojiData()
// Fresh array reference: loadEmojiData populates the module-level array
// IN PLACE, so state set to the same ref would bail out of re-rendering
// and the search list would never gain the unicode emojis.
.then((loaded) => {
if (alive) setLoadedEmojis(loaded.emojis.slice());
})
.catch(() => undefined);
return () => {
alive = false;
};
}, []);
const searchList = useMemo(() => { const searchList = useMemo(() => {
const list: Array<EmoticonSearchItem> = []; const list: Array<EmoticonSearchItem> = [];
return list.concat( return list.concat(
imagePacks.flatMap((pack) => pack.getImages(ImageUsage.Emoticon)), imagePacks.flatMap((pack) => pack.getImages(ImageUsage.Emoticon)),
emojis, loadedEmojis,
); );
}, [imagePacks]); }, [imagePacks, loadedEmojis]);
const [result, search, resetSearch] = useAsyncSearch( const [result, search, resetSearch] = useAsyncSearch(
searchList, searchList,
+37 -6
View File
@@ -8,6 +8,7 @@ import React, {
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState,
} from 'react'; } from 'react';
import { Box, config, Icons, Scroll } from 'folds'; import { Box, config, Icons, Scroll } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
@@ -15,7 +16,7 @@ import { isKeyHotkey } from 'is-hotkey';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai'; import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { IEmoji, emojiGroups, emojis } from '../../plugins/emoji'; import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
import { useEmojiGroupLabels } from './useEmojiGroupLabels'; import { useEmojiGroupLabels } from './useEmojiGroupLabels';
import { useEmojiGroupIcons } from './useEmojiGroupIcons'; import { useEmojiGroupIcons } from './useEmojiGroupIcons';
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard'; import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
@@ -56,6 +57,33 @@ import { VirtualTile } from '../virtualizer';
const RECENT_GROUP_ID = 'recent_group'; const RECENT_GROUP_ID = 'recent_group';
const SEARCH_GROUP_ID = 'search_group'; const SEARCH_GROUP_ID = 'search_group';
/**
* Lazily pull in the emojibase data (see plugins/emoji `loadEmojiData`). The
* `emojis`/`emojiGroups` arrays are populated in place once the promise
* resolves; we wrap them in a fresh object on load so React re-renders and the
* board fills in. Before that, both are empty and the board shows only custom
* image packs / recents (which is fleeting the load starts on mount).
*/
const useEmojiData = (): EmojiData => {
const [data, setData] = useState<EmojiData>(() => ({ emojis, emojiGroups }));
useEffect(() => {
let alive = true;
loadEmojiData()
// Fresh array references (not just a fresh wrapper): downstream memos
// depend on the arrays themselves, which are populated IN PLACE — same
// refs would skip recompute and leave emoji search empty until remount.
.then((loaded) => {
if (alive)
setData({ emojis: loaded.emojis.slice(), emojiGroups: loaded.emojiGroups.slice() });
})
.catch(() => undefined);
return () => {
alive = false;
};
}, []);
return data;
};
type EmojiGroupItem = { type EmojiGroupItem = {
id: string; id: string;
name: string; name: string;
@@ -75,6 +103,7 @@ const useGroups = (
const recentEmojis = useRecentEmoji(mx, 21); const recentEmojis = useRecentEmoji(mx, 21);
const labels = useEmojiGroupLabels(); const labels = useEmojiGroupLabels();
const { emojiGroups: loadedEmojiGroups } = useEmojiData();
const emojiGroupItems = useMemo(() => { const emojiGroupItems = useMemo(() => {
const g: EmojiGroupItem[] = []; const g: EmojiGroupItem[] = [];
@@ -99,7 +128,7 @@ const useGroups = (
}); });
}); });
emojiGroups.forEach((group) => { loadedEmojiGroups.forEach((group) => {
g.push({ g.push({
id: group.id, id: group.id,
name: labels[group.id], name: labels[group.id],
@@ -108,7 +137,7 @@ const useGroups = (
}); });
return g; return g;
}, [mx, recentEmojis, labels, imagePacks, tab]); }, [mx, recentEmojis, labels, imagePacks, tab, loadedEmojiGroups]);
const stickerGroupItems = useMemo(() => { const stickerGroupItems = useMemo(() => {
const g: StickerGroupItem[] = []; const g: StickerGroupItem[] = [];
@@ -177,6 +206,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
const usage = ImageUsage.Emoticon; const usage = ImageUsage.Emoticon;
const labels = useEmojiGroupLabels(); const labels = useEmojiGroupLabels();
const icons = useEmojiGroupIcons(); const icons = useEmojiGroupIcons();
const { emojiGroups: loadedEmojiGroups } = useEmojiData();
const packLabels = useMemo(() => { const packLabels = useMemo(() => {
const map = new Map<string, string | undefined>(); const map = new Map<string, string | undefined>();
@@ -234,7 +264,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
}} }}
> >
<SidebarDivider /> <SidebarDivider />
{emojiGroups.map((group) => ( {loadedEmojiGroups.map((group) => (
<GroupIcon <GroupIcon
key={group.id} key={group.id}
active={activeGroupId === group.id} active={activeGroupId === group.id}
@@ -409,13 +439,14 @@ export function EmojiBoard({
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks); const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks);
const groups = emojiTab ? emojiGroupItems : stickerGroupItems; const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
const renderItem = useItemRenderer(tab); const renderItem = useItemRenderer(tab);
const { emojis: loadedEmojis } = useEmojiData();
const searchList = useMemo(() => { const searchList = useMemo(() => {
let list: Array<PackImageReader | IEmoji> = []; let list: Array<PackImageReader | IEmoji> = [];
list = list.concat(imagePacks.flatMap((pack) => pack.getImages(usage))); list = list.concat(imagePacks.flatMap((pack) => pack.getImages(usage)));
if (emojiTab) list = list.concat(emojis); if (emojiTab) list = list.concat(loadedEmojis);
return list; return list;
}, [emojiTab, usage, imagePacks]); }, [emojiTab, usage, imagePacks, loadedEmojis]);
const [result, search, resetSearch] = useAsyncSearch( const [result, search, resetSearch] = useAsyncSearch(
searchList, searchList,
@@ -200,12 +200,24 @@ export function ImagePackProfileEdit({ meta, onCancel, onSave }: ImagePackProfil
</Box> </Box>
</Box> </Box>
<Box direction="Inherit" gap="100"> <Box direction="Inherit" gap="100">
<Text size="L400">Name</Text> <Text as="label" htmlFor="image-pack-name" size="L400">
<Input name="nameInput" defaultValue={meta.name} variant="Secondary" radii="300" required /> Name
</Text>
<Input
id="image-pack-name"
name="nameInput"
defaultValue={meta.name}
variant="Secondary"
radii="300"
required
/>
</Box> </Box>
<Box direction="Inherit" gap="100"> <Box direction="Inherit" gap="100">
<Text size="L400">Attribution</Text> <Text as="label" htmlFor="image-pack-attribution" size="L400">
Attribution
</Text>
<TextArea <TextArea
id="image-pack-attribution"
name="attributionTextArea" name="attributionTextArea"
defaultValue={meta.attribution} defaultValue={meta.attribution}
variant="Secondary" variant="Secondary"
@@ -261,9 +261,12 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
gap="400" gap="400"
> >
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">User ID</Text> <Text as="label" htmlFor="invite-user-id" size="L400">
User ID
</Text>
<div> <div>
<Input <Input
id="invite-user-id"
size="500" size="500"
ref={inputRef} ref={inputRef}
onChange={handleSearchChange} onChange={handleSearchChange}
@@ -334,8 +337,11 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
</div> </div>
</Box> </Box>
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Reason (Optional)</Text> <Text as="label" htmlFor="invite-reason" size="L400">
Reason (Optional)
</Text>
<TextArea <TextArea
id="invite-reason"
size="500" size="500"
name="reasonInput" name="reasonInput"
variant="Background" variant="Background"
@@ -108,8 +108,11 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
</Text> </Text>
</Box> </Box>
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Address</Text> <Text as="label" htmlFor="join-address" size="L400">
Address
</Text>
<Input <Input
id="join-address"
size="500" size="500"
autoFocus autoFocus
name="addressInput" name="addressInput"
+41
View File
@@ -0,0 +1,41 @@
import React from 'react';
import katex from 'katex';
import 'katex/dist/katex.min.css';
type KaTeXProps = {
/** Raw LaTeX source (without `$`/`$$` delimiters). */
latex: string;
/** Render as block (display) math when true, inline otherwise. */
displayMode?: boolean;
};
/**
* Lazily-loaded KaTeX renderer.
*
* This module statically imports `katex` and its stylesheet, so both only enter
* the bundle via the dynamic `import()` of this file (see the `lazy()` wrapper
* in `react-custom-html-parser.tsx`). They are therefore NOT part of the eager
* import graph.
*
* We render with `throwOnError: false`, so KaTeX itself renders a parse error
* inline (in its error colour) rather than throwing. The HTML returned by
* `renderToString` is produced by our own trusted call from a fixed options
* object it is safe to inject via `dangerouslySetInnerHTML`.
*/
export default function KaTeX({ latex, displayMode = false }: KaTeXProps) {
const html = katex.renderToString(latex, {
displayMode,
throwOnError: false,
output: 'htmlAndMathml',
});
const Wrapper = displayMode ? 'div' : 'span';
return (
<Wrapper
// KaTeX output is generated by our own render call (trusted-safe).
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
+3 -1
View File
@@ -61,6 +61,7 @@ type ReplyProps = {
replyEventId: string; replyEventId: string;
threadRootId?: string | undefined; threadRootId?: string | undefined;
onClick?: MouseEventHandler | undefined; onClick?: MouseEventHandler | undefined;
onThreadClick?: ((threadRootId: string) => void) | undefined;
getMemberPowerTag?: GetMemberPowerTag; getMemberPowerTag?: GetMemberPowerTag;
accessibleTagColors?: Map<string, string>; accessibleTagColors?: Map<string, string>;
legacyUsernameColor?: boolean; legacyUsernameColor?: boolean;
@@ -74,6 +75,7 @@ export const Reply = as<'div', ReplyProps>(
replyEventId, replyEventId,
threadRootId, threadRootId,
onClick, onClick,
onThreadClick,
getMemberPowerTag, getMemberPowerTag,
accessibleTagColors, accessibleTagColors,
legacyUsernameColor, legacyUsernameColor,
@@ -110,7 +112,7 @@ export const Reply = as<'div', ReplyProps>(
<ThreadIndicator <ThreadIndicator
as="button" as="button"
data-event-id={threadRootId} data-event-id={threadRootId}
onClick={onClick} onClick={onThreadClick ? () => onThreadClick(threadRootId) : onClick}
aria-label="View thread" aria-label="View thread"
/> />
)} )}
@@ -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];
-1
View File
@@ -117,7 +117,6 @@ export const PageHeroSection = style([
}, },
]); ]);
export const PageContentCenter = style([ export const PageContentCenter = style([
DefaultReset, DefaultReset,
{ {
@@ -95,7 +95,6 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
returnFocusOnDeactivate: false,
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
onDeactivate: () => setViewTopic(false), onDeactivate: () => setViewTopic(false),
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
@@ -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';
@@ -0,0 +1,49 @@
import React, { useCallback, useMemo } from 'react';
import { Room } from 'matrix-js-sdk';
import { usePowerLevels } from '../../hooks/usePowerLevels';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { SoundboardPackEditor } from './SoundboardPackEditor';
import { SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
import { StateEvent } from '../../../types/matrix/room';
import { useRoomSoundboardPack } from '../../hooks/useSoundboardPacks';
import { PackAddress } from '../../plugins/custom-emoji/PackAddress';
import { randomStr } from '../../utils/common';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators';
type RoomSoundboardPackProps = {
room: Room;
stateKey: string;
};
export function RoomSoundboardPack({ room, stateKey }: RoomSoundboardPackProps) {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canEdit = permissions.stateEvent(
StateEvent.LotusSoundboardRoom as unknown as keyof import('matrix-js-sdk').StateEvents,
userId,
);
const fallbackPack = useMemo(
() => new SoundboardPack(randomStr(4), {}, new PackAddress(room.roomId, stateKey)),
[room.roomId, stateKey],
);
const pack = useRoomSoundboardPack(room, stateKey) ?? fallbackPack;
const handleUpdate = useCallback(
async (content: SoundboardContent) => {
await mx.sendStateEvent(
room.roomId,
StateEvent.LotusSoundboardRoom as unknown as keyof import('matrix-js-sdk').StateEvents,
content as never,
stateKey,
);
},
[mx, room.roomId, stateKey],
);
return <SoundboardPackEditor pack={pack} canEdit={canEdit} onUpdate={handleUpdate} />;
}
@@ -0,0 +1,408 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import {
Box,
Button,
Chip,
Icon,
IconButton,
Icons,
Input,
PopOut,
Spinner,
Text,
color,
config,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { EmojiBoard } from '../emoji-board';
import { SoundboardClip, SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
import { uniqueShortcode } from '../../plugins/soundboard/utils';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import {
playClipLocally,
resolveClipObjectUrl,
SOUNDBOARD_ACCEPT,
SOUNDBOARD_MAX_CLIP_BYTES,
SOUNDBOARD_MAX_CLIPS,
} from '../../utils/soundboardClips';
import { stopPropagation } from '../../utils/keyboard';
type ClipDraft = {
url: string;
body: string;
emoji: string;
volume: number;
info?: SoundboardClip['info'];
};
type SoundboardPackEditorProps = {
pack: SoundboardPack;
canEdit?: boolean;
onUpdate: (content: SoundboardContent) => Promise<void>;
};
/**
* Reusable single-pack soundboard manager (used by the settings page and the
* in-call management mode). Mirrors image-pack-view/ImagePackContent's staged-
* edit + batched-save pattern, but per-clip fields are name + emoji + volume.
*/
export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPackEditorProps) {
const mx = useMatrixClient();
// Staged, unsaved state:
const [drafts, setDrafts] = useState<Map<string, ClipDraft>>(new Map()); // shortcode -> edits
const [deleted, setDeleted] = useState<Set<string>>(new Set());
const [uploads, setUploads] = useState<Array<{ shortcode: string } & ClipDraft>>([]);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string>();
const [emojiFor, setEmojiFor] = useState<string>(); // shortcode currently picking an emoji
const [busyPreview, setBusyPreview] = useState<string>();
const fileInputRef = useRef<HTMLInputElement>(null);
const emojiAnchorRef = useRef<HTMLElement | null>(null);
const existing = useMemo(() => pack.getClips(), [pack]);
const clipCount = existing.filter((c) => !deleted.has(c.shortcode)).length + uploads.length;
const dirty = drafts.size > 0 || deleted.size > 0 || uploads.length > 0;
const draftFor = (shortcode: string, base: { body: string; emoji: string; volume: number }) =>
drafts.get(shortcode) ?? { url: '', ...base };
const setDraft = (shortcode: string, patch: Partial<ClipDraft>, base: ClipDraft) => {
setDrafts((prev) => {
const next = new Map(prev);
next.set(shortcode, { ...base, ...(next.get(shortcode) ?? {}), ...patch });
return next;
});
};
const preview = useCallback(
async (id: string, mxc: string, volume: number) => {
setBusyPreview(id);
try {
const url = await resolveClipObjectUrl(mx, mxc);
playClipLocally(url, volume / 100);
} catch {
/* ignore preview errors */
} finally {
setBusyPreview(undefined);
}
},
[mx],
);
const handleFiles = useCallback(
async (files: FileList | null) => {
if (!files || files.length === 0) return;
setUploading(true);
setError(undefined);
try {
const taken = new Set<string>([
...existing.map((c) => c.shortcode),
...uploads.map((u) => u.shortcode),
]);
for (let i = 0; i < files.length; i += 1) {
const file = files[i];
if (clipCount + uploads.length >= SOUNDBOARD_MAX_CLIPS) {
throw new Error(`Soundboard is full (max ${SOUNDBOARD_MAX_CLIPS} clips).`);
}
if (file.size > SOUNDBOARD_MAX_CLIP_BYTES) {
throw new Error(`"${file.name}" is too large (max 1 MB).`);
}
// eslint-disable-next-line no-await-in-loop
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
const mxc = res.content_uri;
if (!mxc) throw new Error('Upload failed.');
const name = file.name.replace(/\.[^/.]+$/, '');
const shortcode = uniqueShortcode(name, taken);
taken.add(shortcode);
setUploads((prev) => [
...prev,
{
shortcode,
url: mxc,
body: name,
emoji: '',
volume: 100,
info: { mimetype: file.type || undefined, size: file.size },
},
]);
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Upload failed.');
} finally {
setUploading(false);
}
},
[mx, existing, uploads, clipCount],
);
const [saveState, save] = useAsyncCallback(
useCallback(async () => {
const clips: Record<string, SoundboardClip> = {};
existing.forEach((c) => {
if (deleted.has(c.shortcode)) return;
const d = drafts.get(c.shortcode);
clips[c.shortcode] = {
url: c.url,
body: d ? d.body : c.body,
emoji: d ? d.emoji || undefined : c.emoji,
volume: d ? d.volume : c.volume,
info: c.info,
};
});
uploads.forEach((u) => {
clips[u.shortcode] = {
url: u.url,
body: u.body,
emoji: u.emoji || undefined,
volume: u.volume,
info: u.info,
};
});
await onUpdate({ pack: pack.meta.content, clips });
setDrafts(new Map());
setDeleted(new Set());
setUploads([]);
}, [existing, deleted, drafts, uploads, onUpdate, pack]),
);
const saving = saveState.status === AsyncStatus.Loading;
const renderRow = (key: string, base: ClipDraft, isUpload: boolean, markedDeleted: boolean) => {
const d = isUpload ? base : draftFor(key, base);
const rowVolume = isUpload ? base.volume : d.volume;
const rowBody = isUpload ? base.body : d.body;
const rowEmoji = isUpload ? base.emoji : d.emoji;
const commit = (patch: Partial<ClipDraft>) => {
if (isUpload) {
setUploads((prev) => prev.map((u) => (u.shortcode === key ? { ...u, ...patch } : u)));
} else {
setDraft(key, patch, base);
}
};
return (
<Box
key={key}
alignItems="Center"
gap="200"
style={{
padding: config.space.S200,
borderRadius: config.radii.R400,
background: color.SurfaceVariant.Container,
opacity: markedDeleted ? 0.5 : 1,
}}
>
<IconButton
size="300"
radii="300"
variant="Secondary"
disabled={busyPreview === key}
onClick={() => preview(key, base.url, rowVolume)}
aria-label={`Preview ${rowBody}`}
>
{busyPreview === key ? <Spinner size="100" /> : <Icon size="100" src={Icons.Play} />}
</IconButton>
<IconButton
size="300"
radii="300"
variant="Secondary"
disabled={!canEdit || markedDeleted}
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
emojiAnchorRef.current = evt.currentTarget;
setEmojiFor(key);
}}
aria-label="Pick emoji"
>
<Text size="T400">{rowEmoji || '🔊'}</Text>
</IconButton>
<Box grow="Yes">
<Input
variant="Surface"
size="300"
defaultValue={rowBody}
readOnly={!canEdit || markedDeleted}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => commit({ body: e.target.value })}
aria-label="Clip name"
/>
</Box>
<Box alignItems="Center" gap="100" shrink="No" style={{ width: toRem(120) }}>
<Icon size="50" src={Icons.VolumeHigh} />
<input
type="range"
min={0}
max={100}
step={5}
defaultValue={rowVolume}
disabled={!canEdit || markedDeleted}
onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })}
style={{ flexGrow: 1 }}
aria-label="Clip volume"
/>
</Box>
{canEdit && !isUpload && (
<IconButton
size="300"
radii="300"
variant={markedDeleted ? 'Success' : 'Critical'}
onClick={() =>
setDeleted((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
})
}
aria-label={markedDeleted ? 'Undo delete' : 'Delete clip'}
>
<Icon size="100" src={markedDeleted ? Icons.Plus : Icons.Delete} />
</IconButton>
)}
{canEdit && isUpload && (
<IconButton
size="300"
radii="300"
variant="Critical"
onClick={() => setUploads((prev) => prev.filter((u) => u.shortcode !== key))}
aria-label="Remove upload"
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
)}
</Box>
);
};
return (
<Box direction="Column" gap="300">
<input
ref={fileInputRef}
aria-label="Upload soundboard clip"
type="file"
accept={SOUNDBOARD_ACCEPT}
multiple
hidden
onChange={(e) => {
handleFiles(e.target.files);
e.target.value = '';
}}
/>
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="H4">{pack.meta.name ?? 'Soundboard'}</Text>
{canEdit && (
<Chip
variant="Secondary"
radii="Pill"
disabled={uploading || clipCount >= SOUNDBOARD_MAX_CLIPS}
onClick={() => fileInputRef.current?.click()}
before={uploading ? <Spinner size="100" /> : <Icon size="100" src={Icons.Plus} />}
>
<Text size="B300">Upload</Text>
</Chip>
)}
</Box>
<Box direction="Column" gap="100">
{existing.map((c) =>
renderRow(
c.shortcode,
{ url: c.url, body: c.body ?? c.shortcode, emoji: c.emoji ?? '', volume: c.volume },
false,
deleted.has(c.shortcode),
),
)}
{uploads.map((u) => renderRow(u.shortcode, u, true, false))}
{existing.length === 0 && uploads.length === 0 && (
<Text size="T200" priority="300">
No clips yet. Upload a short audio clip (max 1 MB){canEdit ? '' : ' — ask an admin'}.
</Text>
)}
</Box>
{error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{error}
</Text>
)}
{canEdit && dirty && (
<Box gap="200">
<Button
size="300"
variant="Success"
radii="300"
disabled={saving}
onClick={() => save()}
before={saving ? <Spinner size="100" fill="Solid" /> : undefined}
>
<Text size="B300">Save changes</Text>
</Button>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
disabled={saving}
onClick={() => {
setDrafts(new Map());
setDeleted(new Set());
setUploads([]);
setError(undefined);
}}
>
<Text size="B300">Reset</Text>
</Button>
</Box>
)}
<PopOut
anchor={emojiFor ? emojiAnchorRef.current?.getBoundingClientRect() : undefined}
position="Bottom"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setEmojiFor(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<EmojiBoard
imagePackRooms={[]}
returnFocusOnDeactivate={false}
onEmojiSelect={(unicode: string) => {
const key = emojiFor;
setEmojiFor(undefined);
if (!key) return;
const up = uploads.find((u) => u.shortcode === key);
if (up) {
setUploads((prev) =>
prev.map((u) => (u.shortcode === key ? { ...u, emoji: unicode } : u)),
);
} else {
const c = existing.find((x) => x.shortcode === key);
if (c)
setDraft(
key,
{ emoji: unicode },
{
url: c.url,
body: c.body ?? c.shortcode,
emoji: c.emoji ?? '',
volume: c.volume,
},
);
}
}}
requestClose={() => setEmojiFor(undefined)}
/>
</FocusTrap>
}
>
<span />
</PopOut>
</Box>
);
}
@@ -0,0 +1,32 @@
import React, { useCallback, useMemo } from 'react';
import { SoundboardPackEditor } from './SoundboardPackEditor';
import { SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { useUserSoundboardPack } from '../../hooks/useSoundboardPacks';
export function UserSoundboardPack() {
const mx = useMatrixClient();
const defaultPack = useMemo(
() =>
new SoundboardPack(
mx.getUserId() ?? '',
{ pack: { display_name: 'My Soundboard' } },
undefined,
),
[mx],
);
const pack = useUserSoundboardPack() ?? defaultPack;
const handleUpdate = useCallback(
async (content: SoundboardContent) => {
await mx.setAccountData(
AccountDataEvent.LotusSoundboard as unknown as keyof import('matrix-js-sdk').AccountDataEvents,
content as never,
);
},
[mx],
);
return <SoundboardPackEditor pack={pack} canEdit onUpdate={handleUpdate} />;
}
@@ -0,0 +1,3 @@
export * from './SoundboardPackEditor';
export * from './RoomSoundboardPack';
export * from './UserSoundboardPack';
@@ -56,6 +56,7 @@ function PreviewVideo({ fileItem }: PreviewVideoProps) {
return ( return (
<video <video
aria-label="Video attachment preview"
style={{ style={{
objectFit: 'contain', objectFit: 'contain',
width: '100%', width: '100%',
@@ -260,6 +260,7 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer
<Input <Input
ref={reasonInputRef} ref={reasonInputRef}
placeholder="Reason" placeholder="Reason"
aria-label="Moderation reason"
size="300" size="300"
variant="Background" variant="Background"
radii="300" radii="300"
@@ -253,6 +253,7 @@ function UserPrivateNotes({ userId }: { userId: string }) {
)} )}
</Box> </Box>
<textarea <textarea
aria-label="Private note about this user"
value={draft} value={draft}
onChange={handleChange} onChange={handleChange}
maxLength={USER_NOTE_MAX_LENGTH} maxLength={USER_NOTE_MAX_LENGTH}
+55 -5
View File
@@ -1,4 +1,5 @@
import React, { ChangeEvent, useCallback, useEffect, useState } from 'react'; import React, { ChangeEvent, ReactNode, useCallback, useEffect, useState } from 'react';
import { Room } from 'matrix-js-sdk';
import { import {
Avatar, Avatar,
Box, Box,
@@ -16,6 +17,8 @@ import {
import classNames from 'classnames'; import classNames from 'classnames';
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks'; import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomEvent } from '../../hooks/useRoomEvent';
import { MessageDeletedContent } from '../../components/message/content/FallbackContent';
import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { RoomAvatar } from '../../components/room-avatar'; import { RoomAvatar } from '../../components/room-avatar';
@@ -42,9 +45,11 @@ type BookmarkItemProps = {
bookmark: Bookmark; bookmark: Bookmark;
onJump: (roomId: string, eventId: string) => void; onJump: (roomId: string, eventId: string) => void;
onRemove: (eventId: string) => void; onRemove: (eventId: string) => void;
// Optional live-rendered preview node; falls back to the stored snapshot when absent.
preview?: ReactNode;
}; };
function BookmarkItem({ bookmark, onJump, onRemove }: BookmarkItemProps) { function BookmarkItem({ bookmark, onJump, onRemove, preview }: BookmarkItemProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const room = mx.getRoom(bookmark.roomId) ?? undefined; const room = mx.getRoom(bookmark.roomId) ?? undefined;
@@ -104,18 +109,50 @@ function BookmarkItem({ bookmark, onJump, onRemove }: BookmarkItemProps) {
style={{ justifyContent: 'flex-start', height: 'unset', padding: config.space.S200 }} style={{ justifyContent: 'flex-start', height: 'unset', padding: config.space.S200 }}
> >
<Text className={css.BookmarkPreview} size="T200" priority="400"> <Text className={css.BookmarkPreview} size="T200" priority="400">
{bookmark.previewText || '(no preview)'} {preview ?? (bookmark.previewText || '(no preview)')}
</Text> </Text>
</Button> </Button>
</Box> </Box>
); );
} }
type LiveBookmarkItemProps = BookmarkItemProps & { room: Room };
// Renders the same layout as BookmarkItem, but resolves the message body live so
// edits (m.replace, applied by useRoomEvent) and redactions are reflected. The
// stored snapshot (previewText) remains the fallback for loading/failed/empty states.
function LiveBookmarkItem({ room, bookmark, onJump, onRemove }: LiveBookmarkItemProps) {
const liveEvent = useRoomEvent(room, bookmark.eventId, () =>
room.findEventById(bookmark.eventId),
);
const snapshot = bookmark.previewText || '(no preview)';
let preview: ReactNode = snapshot;
// undefined (loading) and null (fetch failed / not found) both keep the snapshot.
if (liveEvent) {
if (liveEvent.isRedacted()) {
preview = (
<MessageDeletedContent
reason={liveEvent.getUnsigned().redacted_because?.content?.reason as string | undefined}
/>
);
} else {
// body is already the edited text since useRoomEvent applied m.replace.
const { body } = liveEvent.getContent();
preview = typeof body === 'string' && body ? body : snapshot;
}
}
return <BookmarkItem bookmark={bookmark} onJump={onJump} onRemove={onRemove} preview={preview} />;
}
type BookmarksPanelProps = { type BookmarksPanelProps = {
onClose: () => void; onClose: () => void;
}; };
export function BookmarksPanel({ onClose }: BookmarksPanelProps) { export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
const mx = useMatrixClient();
const { bookmarks, removeBookmark } = useBookmarks(); const { bookmarks, removeBookmark } = useBookmarks();
const { navigateRoom } = useRoomNavigate(); const { navigateRoom } = useRoomNavigate();
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
@@ -228,14 +265,27 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
</Box> </Box>
) : ( ) : (
<Box className={css.BookmarksContent} direction="Column" gap="200"> <Box className={css.BookmarksContent} direction="Column" gap="200">
{filtered.map((bk) => ( {filtered.map((bk) => {
// Live render when the room is joined (useRoomEvent needs a non-null Room);
// otherwise fall back to the stored snapshot for rooms we've left.
const room = mx.getRoom(bk.roomId);
return room ? (
<LiveBookmarkItem
key={bk.eventId}
room={room}
bookmark={bk}
onJump={handleJump}
onRemove={removeBookmark}
/>
) : (
<BookmarkItem <BookmarkItem
key={bk.eventId} key={bk.eventId}
bookmark={bk} bookmark={bk}
onJump={handleJump} onJump={handleJump}
onRemove={removeBookmark} onRemove={removeBookmark}
/> />
))} );
})}
</Box> </Box>
)} )}
</Scroll> </Scroll>
+39 -5
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 (
@@ -351,10 +381,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200"> <Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} /> <MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} />
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} /> <SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
<ScreenshareAudioButton
muted={screenshareAudioMuted}
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
/>
</Box> </Box>
{!compact && showVideoGroup && <ControlDivider />} {!compact && showVideoGroup && <ControlDivider />}
{showVideoGroup && ( {showVideoGroup && (
@@ -363,12 +389,20 @@ export function CallControls({ callEmbed }: CallControlsProps) {
user can stop it; once stopped it hides and can't be restarted. */} user can stop it; once stopped it hides and can't be restarted. */}
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />} {showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
{showScreenshare && ( {showScreenshare && (
<>
<ScreenShareButton <ScreenShareButton
enabled={screenshare} enabled={screenshare}
onToggle={() => onToggle={() =>
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true) screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
} }
/> />
{/* Mute-screenshare-audio sits directly next to the screenshare
control since they're the same concern. */}
<ScreenshareAudioButton
muted={screenshareAudioMuted}
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
/>
</>
)} )}
{!!document.fullscreenEnabled && ( {!!document.fullscreenEnabled && (
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} /> <FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
+151 -104
View File
@@ -1,108 +1,127 @@
import React, { MouseEventHandler, useCallback, useRef, useState } from 'react'; import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Box, Box,
Chip,
Icon, Icon,
IconButton, IconButton,
Icons, Icons,
Menu, Menu,
PopOut, PopOut,
RectCords, RectCords,
Scroll,
Spinner, Spinner,
Switch,
Text, Text,
Tooltip, Tooltip,
TooltipProvider, TooltipProvider,
color, color,
config, config,
toRem,
} from 'folds'; } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { useAtomValue } from 'jotai';
import { CallEmbed } from '../../plugins/call'; import { CallEmbed } from '../../plugins/call';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSoundboard } from '../../hooks/useSoundboard';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { useRelevantSoundboardPacks } from '../../hooks/useSoundboardPacks';
import { SoundboardClipReader } from '../../plugins/soundboard';
import { UserSoundboardPack, RoomSoundboardPack } from '../../components/soundboard-pack-view';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { import { playClipLocally, resolveClipObjectUrl } from '../../utils/soundboardClips';
SOUNDBOARD_ACCEPT,
SOUNDBOARD_MAX_CLIPS,
playClipLocally,
resolveClipObjectUrl,
} from '../../utils/soundboardClips';
type CallSoundboardProps = { type CallSoundboardProps = {
callEmbed: CallEmbed; callEmbed: CallEmbed;
}; };
type FlatClip = {
key: string; // packId|shortcode
packId: string;
packName: string;
clip: SoundboardClipReader;
};
/** /**
* [P5-15] In-call soundboard: trigger user-uploaded clips into the call. Each * [P5-15 v2] In-call soundboard. Clips come from the aggregated soundboard packs
* clip is published to peers as a separate track by the EC fork * relevant to the call room (the room + parent spaces the user's personal
* (`io.lotus.inject_audio`) and also played locally for the presser's feedback. * pack), just like custom emoji. Playing a clip publishes it into the call via
* Clips are uploadable/managed here and synced across devices via the * the EC fork (`io.lotus.inject_audio`, max one at a time) and plays it locally.
* `io.lotus.soundboard` account data (like custom emoji/sticker packs). * A management toggle reveals the pack editors (personal + this room, if
* permitted). Space-wide packs are managed from Space settings.
*/ */
export function CallSoundboard({ callEmbed }: CallSoundboardProps) { export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const { clips, addClip, removeClip } = useSoundboard(); const { room } = callEmbed;
const roomToParents = useAtomValue(roomToParentsAtom);
const packRooms = useImagePackRooms(room.roomId, roomToParents);
const packs = useRelevantSoundboardPacks(packRooms);
const [soundboardVolume] = useSetting(settingsAtom, 'soundboardVolume'); const [soundboardVolume] = useSetting(settingsAtom, 'soundboardVolume');
const master = Math.max(0, Math.min(1, soundboardVolume / 100));
const [cords, setCords] = useState<RectCords>(); const [cords, setCords] = useState<RectCords>();
const [busyId, setBusyId] = useState<string>(); const [manage, setManage] = useState(false);
const [uploading, setUploading] = useState(false); const [playingKey, setPlayingKey] = useState<string>(); // host-side spam guard
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const fileInputRef = useRef<HTMLInputElement>(null);
const volume = Math.max(0, Math.min(1, soundboardVolume / 100)); // 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(
() =>
packs
.map((pack) => ({
id: pack.id,
name: pack.meta.name ?? 'Soundboard',
clips: pack.getClips(),
}))
.filter((g) => g.clips.length > 0),
[packs],
);
const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => { const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
setError(undefined); setError(undefined);
setCords(evt.currentTarget.getBoundingClientRect()); setCords(evt.currentTarget.getBoundingClientRect());
}; };
const handlePlay = useCallback( const play = useCallback(
async (id: string, mxc: string) => { async (flat: FlatClip) => {
setBusyId(id); if (playingKey) return; // one at a time (fork also enforces this)
setPlayingKey(flat.key);
setError(undefined); setError(undefined);
const done = () => {
if (!mountedRef.current) return;
setPlayingKey((k) => (k === flat.key ? undefined : k));
};
try { try {
const objectUrl = await resolveClipObjectUrl(mx, mxc); const url = await resolveClipObjectUrl(mx, flat.clip.url);
callEmbed.control.injectAudio(objectUrl, volume); const vol = (flat.clip.volume / 100) * master;
playClipLocally(objectUrl, volume); callEmbed.control.injectAudio(url, vol);
const audio = playClipLocally(url, vol);
if (audio) {
audio.addEventListener('ended', done, { once: true });
audio.addEventListener('error', done, { once: true });
} else {
done();
}
// Safety: clear the guard even if the audio never signals end.
window.setTimeout(done, 30_000);
} catch { } catch {
setError('Could not play that clip.'); setError('Could not play that clip.');
} finally { done();
setBusyId(undefined);
} }
}, },
[mx, callEmbed, volume], [mx, callEmbed, master, playingKey],
);
const handleFile = useCallback(
async (file: File | undefined) => {
if (!file) return;
setUploading(true);
setError(undefined);
try {
await addClip(file);
} catch (e) {
setError(e instanceof Error ? e.message : 'Upload failed.');
} finally {
setUploading(false);
}
},
[addClip],
); );
return ( return (
<>
<input
ref={fileInputRef}
type="file"
accept={SOUNDBOARD_ACCEPT}
hidden
onChange={(e) => {
handleFile(e.target.files?.[0]);
e.target.value = '';
}}
/>
<PopOut <PopOut
anchor={cords} anchor={cords}
position="Top" position="Top"
@@ -116,76 +135,105 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
}} }}
> >
<Menu style={{ maxWidth: '320px' }}> <Menu style={{ maxWidth: manage ? toRem(420) : toRem(340), maxHeight: '70vh' }}>
<Box direction="Column" gap="200" style={{ padding: config.space.S200 }}> <Box direction="Column" style={{ maxHeight: '70vh' }}>
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200"> <Box
<Text size="L400">Soundboard</Text> shrink="No"
<Chip alignItems="Center"
variant="Secondary" justifyContent="SpaceBetween"
radii="Pill" gap="200"
disabled={uploading || clips.length >= SOUNDBOARD_MAX_CLIPS} style={{
onClick={() => fileInputRef.current?.click()} padding: config.space.S200,
before={ borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
uploading ? <Spinner size="100" /> : <Icon size="100" src={Icons.Plus} /> }}
}
> >
<Text size="B300">Upload</Text> <Text size="L400">Soundboard</Text>
</Chip> <Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
<Text size="T200" priority="300">
Manage
</Text>
<Switch variant="Primary" value={manage} onChange={setManage} />
</Box>
</Box> </Box>
{clips.length === 0 ? ( <Scroll size="300" hideTrack visibility="Hover">
<Box direction="Column" gap="300" style={{ padding: config.space.S200 }}>
{manage ? (
<>
<RoomSoundboardPack room={room} stateKey="" />
<UserSoundboardPack />
</>
) : (
<>
{groups.length === 0 && (
<Text size="T200" priority="300"> <Text size="T200" priority="300">
No clips yet. Upload a short audio clip (max 1 MB) to play it into the call. No soundboard clips here yet. Turn on <b>Manage</b> to upload some, or add
Clips sync across your devices. a pack in Space settings.
</Text> </Text>
) : ( )}
{groups.map((g) => (
<Box key={g.id} direction="Column" gap="100">
<Text size="L400">{g.name}</Text>
<Box wrap="Wrap" gap="200"> <Box wrap="Wrap" gap="200">
{clips.map((clip) => ( {g.clips.map((clip) => {
const key = `${g.id}|${clip.shortcode}`;
const flat: FlatClip = {
key,
packId: g.id,
packName: g.name,
clip,
};
return (
<Box <Box
key={clip.id} key={key}
as="button"
direction="Column" direction="Column"
alignItems="Center"
justifyContent="Center"
gap="100" gap="100"
style={{ position: 'relative' }} disabled={!!playingKey}
> onClick={() => play(flat)}
<Chip aria-label={`Play ${clip.name}`}
variant="SurfaceVariant" style={{
radii="300" width: toRem(76),
disabled={busyId === clip.id} height: toRem(76),
onClick={() => handlePlay(clip.id, clip.url)} padding: config.space.S100,
before={ borderRadius: config.radii.R400,
busyId === clip.id ? ( border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
<Spinner size="100" /> background:
) : ( playingKey === key
<Icon size="100" src={Icons.Play} /> ? color.Primary.Container
) : color.SurfaceVariant.Container,
} cursor: playingKey ? 'default' : 'pointer',
after={ opacity: playingKey && playingKey !== key ? 0.5 : 1,
<Icon
size="50"
src={Icons.Cross}
style={{ cursor: 'pointer' }}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
removeClip(clip.id);
}} }}
/>
}
> >
<Text size="B300" truncate style={{ maxWidth: '120px' }}> <Text size="H4">
{playingKey === key ? (
<Spinner size="200" />
) : (
clip.emoji || '🔊'
)}
</Text>
<Text size="T200" truncate style={{ maxWidth: '100%' }}>
{clip.name} {clip.name}
</Text> </Text>
</Chip> </Box>
);
})}
</Box>
</Box> </Box>
))} ))}
</Box> </>
)} )}
{error && ( {error && (
<Text size="T200" style={{ color: color.Critical.Main }}> <Text size="T200" style={{ color: color.Critical.Main }}>
{error} {error}
</Text> </Text>
)} )}
</Box> </Box>
</Scroll>
</Box>
</Menu> </Menu>
</FocusTrap> </FocusTrap>
} }
@@ -215,6 +263,5 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
)} )}
</TooltipProvider> </TooltipProvider>
</PopOut> </PopOut>
</>
); );
} }
@@ -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
offline + under strict CSP. White padded quiet-zone so the
default black-on-white code scans on any theme. */}
<Box <Box
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="100"
style={{ style={{
width: 160, padding: config.space.S200,
height: 160, background: '#ffffff',
borderRadius: config.radii.R300, borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container, lineHeight: 0,
}} }}
> >
<Icon size="400" src={Icons.Warning} /> <QRCodeSVG value={inviteUrl} size={160} level="M" title="Room invite QR code" />
<Text size="T200" priority="300" align="Center">
QR code unavailable
</Text>
</Box> </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,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>
@@ -147,6 +147,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
<Text size="L400">Name</Text> <Text size="L400">Name</Text>
<Input <Input
name="nameInput" name="nameInput"
aria-label="Power level name"
defaultValue={tag?.name} defaultValue={tag?.name}
placeholder="Bot" placeholder="Bot"
size="300" size="300"
@@ -160,6 +161,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
<Input <Input
defaultValue={power} defaultValue={power}
name="powerInput" name="powerInput"
aria-label="Power level value"
size="300" size="300"
variant={typeof power === 'number' ? 'SurfaceVariant' : 'Secondary'} variant={typeof power === 'number' ? 'SurfaceVariant' : 'Secondary'}
radii="300" radii="300"
@@ -0,0 +1,61 @@
import React from 'react';
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { useRoom } from '../../../hooks/useRoom';
import { RoomSoundboardPack, UserSoundboardPack } from '../../../components/soundboard-pack-view';
type SoundboardProps = {
requestClose: () => void;
};
/**
* Soundboard management page (Room/Space settings). Mirrors the Emojis &
* Stickers page: a shared room/space pack (admin-editable, inherited by child
* rooms like emoji packs) plus the user's personal pack. A single default room
* pack (state key "") is used per room/space.
*/
export function Soundboard({ requestClose }: SoundboardProps) {
const room = useRoom();
return (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text as="h2" size="H3" truncate>
Soundboard
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<Box direction="Column" gap="200">
<Text size="L400">This room / space (shared)</Text>
<Text size="T200" priority="300">
Clips here are shared with everyone, and inherited by every room under this space
just like emoji/sticker packs. Only members with permission can edit.
</Text>
{room && <RoomSoundboardPack room={room} stateKey="" />}
</Box>
<Box direction="Column" gap="200">
<Text size="L400">Personal</Text>
<Text size="T200" priority="300">
Your own clips, available in every call and synced across your devices.
</Text>
<UserSoundboardPack />
</Box>
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}
@@ -0,0 +1 @@
export * from './Soundboard';
+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;
} }
@@ -11,6 +11,8 @@ import {
Line, Line,
toRem, toRem,
Button, Button,
Switch,
Chip,
} from 'folds'; } from 'folds';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
@@ -41,7 +43,9 @@ import {
ResultGroup, ResultGroup,
useMessageSearch, useMessageSearch,
} from './useMessageSearch'; } from './useMessageSearch';
import { useLocalMessageSearch } from './useLocalMessageSearch'; import { LocalSearchResult, useLocalMessageSearch } from './useLocalMessageSearch';
import { searchCacheEnabledAtom } from '../../state/searchCacheEnabled';
import { clearAll as clearSearchCache } from '../../utils/searchCache';
import { addRecentSearch, recentSearchesAtom } from '../../state/recentSearches'; import { addRecentSearch, recentSearchesAtom } from '../../state/recentSearches';
import { SearchResultGroup } from './SearchResultGroup'; import { SearchResultGroup } from './SearchResultGroup';
import { SearchInput } from './SearchInput'; import { SearchInput } from './SearchInput';
@@ -240,6 +244,10 @@ export function MessageSearch({
// Bump this whenever more messages are loaded so localResult re-computes // Bump this whenever more messages are loaded so localResult re-computes
const [cacheVersion, setCacheVersion] = useState(0); const [cacheVersion, setCacheVersion] = useState(0);
const handleCacheLoaded = useCallback(() => setCacheVersion((v) => v + 1), []); const handleCacheLoaded = useCallback(() => setCacheVersion((v) => v + 1), []);
// Explicit wipe of the persistent on-disk index, then re-run the merge.
const handleClearSearchCache = useCallback(() => {
clearSearchCache().then(() => setCacheVersion((v) => v + 1));
}, []);
// The rooms actually in scope for this search (mirrors server-side logic) // The rooms actually in scope for this search (mirrors server-side logic)
const localSearchRooms = useMemo( const localSearchRooms = useMemo(
@@ -253,24 +261,43 @@ export function MessageSearch({
const hasActiveSearch = msgSearchParams.term !== undefined || !!msgSearchParams.senders?.length; const hasActiveSearch = msgSearchParams.term !== undefined || !!msgSearchParams.senders?.length;
const senderOnlyMode = !msgSearchParams.term && !!msgSearchParams.senders?.length; const senderOnlyMode = !msgSearchParams.term && !!msgSearchParams.senders?.length;
// Run synchronous client-side search immediately. // Run the client-side search whenever inputs change.
// In text-search mode: covers encrypted rooms only (server handles plaintext). // In text-search mode: covers encrypted rooms only (server handles plaintext).
// In sender-only mode: covers all rooms (server has no sender-only search). // In sender-only mode: covers all rooms (server has no sender-only search).
// cacheVersion in deps so it re-runs after "Load more" paginates new events. // The scan is async because — when the persistent cache is enabled — it also
const localResult = useMemo(() => { // reads cached rows from IndexedDB and merges them with the in-memory hits.
if (!hasActiveSearch) return null; // cacheVersion in deps so it re-runs after "Load more" paginates new events;
return searchLocalMessages({ // searchCacheEnabled so toggling the cache re-runs the merge.
const [searchCacheEnabled, setSearchCacheEnabled] = useAtom(searchCacheEnabledAtom);
const [localResult, setLocalResult] = useState<LocalSearchResult | null>(null);
useEffect(() => {
if (!hasActiveSearch) {
setLocalResult(null);
return undefined;
}
let cancelled = false;
searchLocalMessages({
term: msgSearchParams.term ?? '', term: msgSearchParams.term ?? '',
roomIds: localSearchRooms, roomIds: localSearchRooms,
senders: msgSearchParams.senders, senders: msgSearchParams.senders,
fromTs: msgSearchParams.fromTs,
toTs: msgSearchParams.toTs,
}).then((result) => {
if (!cancelled) setLocalResult(result);
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps return () => {
cancelled = true;
};
}, [ }, [
searchLocalMessages, searchLocalMessages,
localSearchRooms, localSearchRooms,
msgSearchParams.term, msgSearchParams.term,
msgSearchParams.senders, msgSearchParams.senders,
msgSearchParams.fromTs,
msgSearchParams.toTs,
hasActiveSearch,
cacheVersion, cacheVersion,
searchCacheEnabled,
]); ]);
const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
@@ -668,6 +695,37 @@ export function MessageSearch({
? `Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Load more history below to extend coverage.` ? `Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Load more history below to extend coverage.`
: `No matches in your local cache. Load messages below to search further back.`} : `No matches in your local cache. Load messages below to search further back.`}
</Text> </Text>
<Box
alignItems="Center"
gap="200"
style={{
padding: config.space.S200,
background: color.SurfaceVariant.Container,
borderRadius: config.radii.R300,
}}
>
<Switch
variant="Primary"
value={searchCacheEnabled}
onChange={setSearchCacheEnabled}
/>
<Box grow="Yes" direction="Column" style={{ minWidth: 0 }}>
<Text size="T300">Persist search index on this device</Text>
<Text size="T200" priority="300">
Stores decrypted text on this device
</Text>
</Box>
{searchCacheEnabled && (
<Chip
variant="Secondary"
radii="Pill"
onClick={handleClearSearchCache}
before={<Icon size="100" src={Icons.Delete} />}
>
<Text size="T200">Clear cached index</Text>
</Chip>
)}
</Box>
<Line size="300" variant="Surface" /> <Line size="300" variant="Surface" />
</Box> </Box>
{localGroups.length > 0 && ( {localGroups.length > 0 && (
@@ -1,12 +1,23 @@
import { EventType } from 'matrix-js-sdk'; import { EventType, MatrixEvent } from 'matrix-js-sdk';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { ResultGroup, ResultItem } from './useMessageSearch'; import { ResultGroup, ResultItem } from './useMessageSearch';
import { searchCacheEnabledAtom } from '../../state/searchCacheEnabled';
import {
mergeSearchResults,
queryRoom,
saveRoomIndex,
SearchCacheRow,
} from '../../utils/searchCache';
export type LocalSearchParams = { export type LocalSearchParams = {
term: string; term: string;
roomIds: string[]; roomIds: string[];
senders?: string[]; senders?: string[];
/** Optional date-range filter (ms). Applied to both memory and cached rows. */
fromTs?: number;
toTs?: number;
}; };
export type LocalSearchResult = { export type LocalSearchResult = {
@@ -17,19 +28,110 @@ export type LocalSearchResult = {
searchedRoomsCount: number; searchedRoomsCount: number;
}; };
/** Extracted, searchable plaintext for a single message event. */
type ExtractedText = {
body: string;
formattedBody: string;
pollText: string;
};
const POLL_START_TYPES = ['m.poll.start', 'org.matrix.msc3381.poll.start'];
/**
* Pull the text we index/search from a decrypted event's content. Returns
* `null` for events that carry no searchable text (e.g. stickers).
*/
const extractText = (event: MatrixEvent): ExtractedText | null => {
const evType = event.getType();
const content = event.getContent();
if (POLL_START_TYPES.includes(evType)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const poll = (content['m.poll'] ?? content['org.matrix.msc3381.poll.start']) as any;
if (!poll) return null;
const qBody =
(poll.question?.['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
(poll.question?.body as string | undefined) ??
'';
const answerBodies = ((poll.answers ?? []) as Array<Record<string, unknown>>)
.map(
(a) =>
((a['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(a['org.matrix.msc3381.poll.answer'] as any)?.body ??
'') as string,
)
.join(' ');
const pollText = `${qBody} ${answerBodies}`.trim();
return pollText ? { body: '', formattedBody: '', pollText } : null;
}
if (evType !== EventType.RoomMessage) return null;
const body = (content.body as string | undefined) ?? '';
const formattedBody = (content.formatted_body as string | undefined) ?? '';
if (!body && !formattedBody) return null;
return { body, formattedBody, pollText: '' };
};
/** Does the extracted text contain the (already-lowercased) term? */
const matchesTerm = (text: ExtractedText, termLower: string): boolean =>
text.body.toLowerCase().includes(termLower) ||
text.formattedBody.toLowerCase().includes(termLower) ||
text.pollText.toLowerCase().includes(termLower);
const rowMatchesTerm = (row: SearchCacheRow, termLower: string): boolean =>
row.body.toLowerCase().includes(termLower) ||
(row.formattedBody ?? '').toLowerCase().includes(termLower) ||
(row.pollText ?? '').toLowerCase().includes(termLower);
/** Build the synthetic result item a cached row renders as (text message). */
const rowToResultItem = (row: SearchCacheRow): ResultItem => {
const bodyText = row.body || row.pollText || '';
const content: Record<string, unknown> = { msgtype: 'm.text', body: bodyText };
if (row.formattedBody) {
content.format = 'org.matrix.custom.html';
content.formatted_body = row.formattedBody;
}
const syntheticEvent = {
room_id: row.roomId,
event_id: row.eventId,
type: EventType.RoomMessage,
sender: row.sender,
origin_server_ts: row.ts,
content,
unsigned: {},
};
return {
rank: 0,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event: syntheticEvent as any,
context: { events_before: [], events_after: [], profile_info: {} },
};
};
/** /**
* Client-side full-text search over locally cached events in encrypted rooms. * Client-side full-text search over locally cached events in encrypted rooms.
* The homeserver cannot search E2EE message content, so we scan whatever the * The homeserver cannot search E2EE message content, so we scan whatever the
* client has already received and decrypted in memory. * client has already received and decrypted in memory.
* *
* Limitation: only messages present in the live timeline window are covered. * When the persistent search cache is enabled (opt-in), the in-memory scan is
* Rooms that haven't been opened yet will return no results. * also persisted to IndexedDB (fire-and-forget) and merged with prior cached
* coverage so results survive reloads. When disabled, zero cache reads/writes
* occur.
*/ */
export const useLocalMessageSearch = () => { export const useLocalMessageSearch = () => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const cacheEnabled = useAtomValue(searchCacheEnabledAtom);
const search = useCallback( const search = useCallback(
({ term, roomIds, senders }: LocalSearchParams): LocalSearchResult => { async ({
term,
roomIds,
senders,
fromTs,
toTs,
}: LocalSearchParams): Promise<LocalSearchResult> => {
const trimmedTerm = term.trim(); const trimmedTerm = term.trim();
const senderSet = senders && senders.length > 0 ? new Set(senders) : null; const senderSet = senders && senders.length > 0 ? new Set(senders) : null;
@@ -41,6 +143,9 @@ export const useLocalMessageSearch = () => {
} }
const termLower = trimmedTerm.toLowerCase(); const termLower = trimmedTerm.toLowerCase();
const inRange = (ts: number): boolean =>
(fromTs === undefined || ts >= fromTs) && (toTs === undefined || ts <= toTs);
const groups: ResultGroup[] = []; const groups: ResultGroup[] = [];
let encryptedRoomsCount = 0; let encryptedRoomsCount = 0;
let searchedRoomsCount = 0; let searchedRoomsCount = 0;
@@ -61,106 +166,99 @@ export const useLocalMessageSearch = () => {
.getUnfilteredTimelineSet() .getUnfilteredTimelineSet()
.getTimelines() .getTimelines()
.flatMap((tl) => tl.getEvents()); .flatMap((tl) => tl.getEvents());
if (events.length === 0) continue;
// eslint-disable-next-line no-await-in-loop
const cachedRows = cacheEnabled ? await queryRoom(roomId) : [];
if (events.length === 0 && cachedRows.length === 0) continue;
searchedRoomsCount += 1; searchedRoomsCount += 1;
const items: ResultItem[] = []; const memoryItems: ResultItem[] = [];
const rowsToPersist: SearchCacheRow[] = [];
for (let i = 0; i < events.length; i += 1) { for (let i = 0; i < events.length; i += 1) {
const event = events[i]; const event = events[i];
// In sender-only mode: include all message types; skip non-message events
if (event.getType() !== EventType.RoomMessage) {
if (senderOnlyMode) continue;
const evType = event.getType();
const isSticker = evType === 'm.sticker';
const isPoll = evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
if (!isSticker && !isPoll) continue;
}
if (event.isDecryptionFailure()) continue; if (event.isDecryptionFailure()) continue;
if (event.isRedacted()) continue; if (event.isRedacted()) continue;
if (senderSet && !senderSet.has(event.getSender() ?? '')) continue;
// getContent() returns decrypted plaintext regardless of encryption
const content = event.getContent();
// Sender-only mode: no text filter needed
if (!senderOnlyMode) {
const evType = event.getType(); const evType = event.getType();
const isPoll = evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start'; const isSticker = evType === 'm.sticker';
const isMessageLike =
evType === EventType.RoomMessage || POLL_START_TYPES.includes(evType);
let body = ''; // Sender-only mode indexes/returns all message types; text mode needs text.
let formattedBody = ''; if (!senderOnlyMode && !isMessageLike && !isSticker) continue;
if (!isPoll) {
body = (content.body as string | undefined) ?? '';
formattedBody = (content.formatted_body as string | undefined) ?? '';
} else {
// Poll — index question text and all answer options
const poll = (content['m.poll'] ??
// eslint-disable-next-line @typescript-eslint/no-explicit-any
content['org.matrix.msc3381.poll.start']) as any;
if (poll) {
const qBody =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(poll.question?.['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
(poll.question?.body as string | undefined) ??
'';
const answerBodies = ((poll.answers ?? []) as Array<Record<string, unknown>>)
.map(
(a) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
((a['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(a['org.matrix.msc3381.poll.answer'] as any)?.body ??
'') as string,
)
.join(' ');
body = `${qBody} ${answerBodies}`.trim();
}
}
if ( const sender = event.getSender() ?? '';
!body.toLowerCase().includes(termLower) && const ts = event.getTs();
!formattedBody.toLowerCase().includes(termLower) const text = extractText(event);
)
continue;
}
// Build a synthetic IEventWithRoomId using decrypted content so the // Persist every indexable (text-bearing) event we scanned, regardless
// existing SearchResultGroup renderer works without modification. // of whether it matches the current term — future searches benefit.
const syntheticEvent = { if (cacheEnabled && text && event.getId()) {
room_id: roomId, rowsToPersist.push({
event_id: event.getId() ?? '', roomId,
type: event.getType(), eventId: event.getId() as string,
sender: event.getSender() ?? '', ts,
origin_server_ts: event.getTs(), sender,
content, body: text.body,
unsigned: event.getUnsigned(), ...(text.formattedBody ? { formattedBody: text.formattedBody } : {}),
}; ...(text.pollText ? { pollText: text.pollText } : {}),
items.push({
rank: 0,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event: syntheticEvent as any,
context: {
events_before: [],
events_after: [],
profile_info: {},
},
}); });
} }
if (senderSet && !senderSet.has(sender)) continue;
if (!inRange(ts)) continue;
if (!senderOnlyMode) {
if (!text || !matchesTerm(text, termLower)) continue;
}
const content = event.getContent();
const syntheticEvent = {
room_id: roomId,
event_id: event.getId() ?? '',
type: evType,
sender,
origin_server_ts: ts,
content,
unsigned: event.getUnsigned(),
};
memoryItems.push({
rank: 0,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event: syntheticEvent as any,
context: { events_before: [], events_after: [], profile_info: {} },
});
}
// Match cached rows (skip ids already present in memory happens in merge).
const cachedItems: ResultItem[] = [];
cachedRows.forEach((row) => {
if (senderSet && !senderSet.has(row.sender)) return;
if (!inRange(row.ts)) return;
if (!senderOnlyMode && !rowMatchesTerm(row, termLower)) return;
cachedItems.push(rowToResultItem(row));
});
const items = mergeSearchResults(memoryItems, cachedItems);
if (items.length > 0) { if (items.length > 0) {
items.sort((a, b) => (b.event.origin_server_ts ?? 0) - (a.event.origin_server_ts ?? 0));
groups.push({ roomId, items }); groups.push({ roomId, items });
} }
// Fire-and-forget persist of freshly scanned rows + coverage.
// saveRoomIndex swallows all errors internally, so a floating promise
// here can never reject.
if (cacheEnabled && rowsToPersist.length > 0) {
saveRoomIndex(roomId, rowsToPersist);
}
} }
return { groups, encryptedRoomsCount, searchedRoomsCount }; return { groups, encryptedRoomsCount, searchedRoomsCount };
}, },
[mx], [mx, cacheEnabled],
); );
return search; return search;
+27 -14
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,
@@ -263,27 +263,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 = {
@@ -338,13 +357,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();
@@ -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>
{exporting ? (
<Button
size="400"
variant="Critical"
fill="Soft"
radii="300"
onClick={handleCancel}
before={<Icon src={Icons.Cross} size="100" />}
>
<Text size="B400">Cancel</Text>
</Button>
) : (
<Button <Button
size="400" size="400"
variant="Primary" variant="Primary"
fill="Solid" fill="Solid"
radii="300" radii="300"
disabled={exporting}
onClick={handleExport} onClick={handleExport}
before={ before={<Icon src={Icons.Download} size="100" />}
exporting ? (
<Spinner size="200" />
) : (
<Icon src={Icons.Download} size="100" />
)
}
> >
<Text size="B400">{exporting ? 'Exporting…' : 'Export'}</Text> <Text size="B400">Export</Text>
</Button> </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>
)} )}
{entry.recommendation && (
<Badge variant="Critical" fill="Soft" radii="Pill"> <Badge variant="Critical" fill="Soft" radii="Pill">
<Text size="T200">{recommendationLabel(entry.recommendation)}</Text> <Text size="T200">{recommendationLabel(entry.recommendation)}</Text>
</Badge> </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>
); );
} }
@@ -13,6 +13,7 @@ import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { General } from './general'; import { General } from './general';
import { Members } from '../common-settings/members'; import { Members } from '../common-settings/members';
import { EmojisStickers } from '../common-settings/emojis-stickers'; import { EmojisStickers } from '../common-settings/emojis-stickers';
import { Soundboard } from '../common-settings/soundboard';
import { Permissions } from './permissions'; import { Permissions } from './permissions';
import { RoomSettingsPage } from '../../state/roomSettings'; import { RoomSettingsPage } from '../../state/roomSettings';
import { useRoom } from '../../hooks/useRoom'; import { useRoom } from '../../hooks/useRoom';
@@ -53,6 +54,11 @@ const BASE_MENU_ITEMS: RoomSettingsMenuItem[] = [
name: 'Emojis & Stickers', name: 'Emojis & Stickers',
icon: Icons.Smile, icon: Icons.Smile,
}, },
{
page: RoomSettingsPage.SoundboardPage,
name: 'Soundboard',
icon: Icons.Bell,
},
{ {
page: RoomSettingsPage.DeveloperToolsPage, page: RoomSettingsPage.DeveloperToolsPage,
name: 'Developer Tools', name: 'Developer Tools',
@@ -226,6 +232,9 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
{activePage === RoomSettingsPage.EmojisStickersPage && ( {activePage === RoomSettingsPage.EmojisStickersPage && (
<EmojisStickers requestClose={handlePageRequestClose} /> <EmojisStickers requestClose={handlePageRequestClose} />
)} )}
{activePage === RoomSettingsPage.SoundboardPage && (
<Soundboard requestClose={handlePageRequestClose} />
)}
{activePage === RoomSettingsPage.DeveloperToolsPage && ( {activePage === RoomSettingsPage.DeveloperToolsPage && (
<DeveloperTools requestClose={handlePageRequestClose} /> <DeveloperTools requestClose={handlePageRequestClose} />
)} )}
+21 -7
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 = {
@@ -186,8 +198,8 @@ function LightboxMedia({
)} )}
{media.status === 'ok' && {media.status === 'ok' &&
(item.msgtype === MsgType.Video ? ( (item.msgtype === MsgType.Video ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video <video
aria-label="Video attachment"
src={media.url} src={media.url}
controls controls
autoPlay autoPlay
@@ -261,7 +273,6 @@ function Lightbox({
escapeDeactivates: false, escapeDeactivates: false,
}} }}
> >
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div <div
role="dialog" role="dialog"
aria-modal aria-modal
@@ -586,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();
@@ -640,13 +654,15 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
className={classNames(css.MediaGalleryDrawer, ContainerColor({ variant: 'Background' }))} className={classNames(css.MediaGalleryDrawer, ContainerColor({ variant: 'Background' }))}
shrink="No" shrink="No"
direction="Column" direction="Column"
role="region"
aria-labelledby="media-gallery-title"
> >
{/* Header */} {/* Header */}
<Header variant="Background" size="600" className={css.MediaGalleryHeader}> <Header variant="Background" size="600" className={css.MediaGalleryHeader}>
<Box grow="Yes" alignItems="Center" gap="200"> <Box grow="Yes" alignItems="Center" gap="200">
<Icon size="200" src={Icons.Photo} /> <Icon size="200" src={Icons.Photo} />
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4" truncate> <Text id="media-gallery-title" size="H4" truncate>
Media Gallery Media Gallery
</Text> </Text>
</Box> </Box>
@@ -711,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;
-2
View File
@@ -142,7 +142,6 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
placeholder="Ask a question…" placeholder="Ask a question…"
value={question} value={question}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuestion(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuestion(e.target.value)}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus autoFocus
/> />
</Box> </Box>
@@ -151,7 +150,6 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
<Box direction="Column" gap="200"> <Box direction="Column" gap="200">
<Text size="L400">Options</Text> <Text size="L400">Options</Text>
{options.map((opt, index) => ( {options.map((opt, index) => (
// eslint-disable-next-line react/no-array-index-key
<Box key={index} alignItems="Center" gap="200"> <Box key={index} alignItems="Center" gap="200">
<Input <Input
style={{ flex: 1 }} style={{ flex: 1 }}
+52 -4
View File
@@ -1,4 +1,4 @@
import React, { useCallback } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import { Box, Line } from 'folds'; import { Box, Line } from 'folds';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
@@ -22,6 +22,8 @@ import { callChatAtom } from '../../state/callEmbed';
import { CallChatView } from './CallChatView'; import { CallChatView } from './CallChatView';
import { useCallEmbed } from '../../hooks/useCallEmbed'; import { useCallEmbed } from '../../hooks/useCallEmbed';
import { useCallMembers, useCallSession } from '../../hooks/useCall'; import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
import { ThreadPanel } from './thread';
export function Room() { export function Room() {
const { eventId } = useParams(); const { eventId } = useParams();
@@ -33,6 +35,8 @@ export function Room() {
const callEmbed = useCallEmbed(); const callEmbed = useCallEmbed();
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const activeThreadId = useAtomValue(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 [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
@@ -45,15 +49,46 @@ export function Room() {
useCallback( useCallback(
(evt) => { (evt) => {
if (isKeyHotkey('escape', evt)) { if (isKeyHotkey('escape', evt)) {
// Skip when a composer already consumed Escape (it preventDefaults).
if (evt.defaultPrevented) return;
// Skip while a thread panel is open: listener registration order
// means this can run BEFORE the panel's own Escape handler, and the
// user's intent there is "close the panel", not "mark room read".
if (activeThreadId) return;
markAsRead(mx, room.roomId, hideActivity); markAsRead(mx, room.roomId, hideActivity);
} }
}, },
[mx, room.roomId, hideActivity], [mx, room.roomId, hideActivity, activeThreadId],
), ),
); );
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:
// opening one closes the other. Detect the just-opened transition so whichever
// was opened most recently wins.
const prevThreadRef = useRef(activeThreadId);
const prevGalleryRef = useRef(galleryOpen);
useEffect(() => {
const threadJustOpened = Boolean(activeThreadId) && !prevThreadRef.current;
const galleryJustOpened = galleryOpen && !prevGalleryRef.current;
if (threadJustOpened && galleryOpen) {
setGalleryOpen(false);
} else if (galleryJustOpened && activeThreadId) {
setActiveThreadId(null);
}
prevThreadRef.current = activeThreadId;
prevGalleryRef.current = galleryOpen;
}, [activeThreadId, galleryOpen, setGalleryOpen, setActiveThreadId]);
// On non-desktop screens at most one right-side panel may show, priority
// thread > gallery > members. On desktop thread + members may coexist while
// thread + gallery stay mutually exclusive (via the effect above).
const isDesktop = screenSize === ScreenSize.Desktop;
const showThreadPanel = !callView && Boolean(activeThreadId);
const showGallery = !callView && galleryOpen && (isDesktop || !activeThreadId);
const showMembers = !callView && isDrawer && (isDesktop || (!activeThreadId && !galleryOpen));
return ( return (
<PowerLevelsContextProvider value={powerLevels}> <PowerLevelsContextProvider value={powerLevels}>
<Box grow="Yes"> <Box grow="Yes">
@@ -82,7 +117,7 @@ export function Room() {
<CallChatView /> <CallChatView />
</> </>
)} )}
{!callView && galleryOpen && ( {showGallery && (
<> <>
{screenSize === ScreenSize.Desktop && ( {screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" /> <Line variant="Background" direction="Vertical" size="300" />
@@ -90,7 +125,20 @@ export function Room() {
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} /> <MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
</> </>
)} )}
{!callView && isDrawer && ( {showThreadPanel && activeThreadId && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<ThreadPanel
key={`${room.roomId}${activeThreadId}`}
room={room}
threadId={activeThreadId}
requestClose={() => setActiveThreadId(null)}
/>
</>
)}
{showMembers && (
<> <>
{screenSize === ScreenSize.Desktop && ( {screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" /> <Line variant="Background" direction="Vertical" size="300" />
+67 -34
View File
@@ -136,6 +136,7 @@ import { ScheduleMessageModal } from './ScheduleMessageModal';
import { ScheduledMessagesTray } from './ScheduledMessagesTray'; import { ScheduledMessagesTray } from './ScheduledMessagesTray';
import { DraftIndicator } from './DraftIndicator'; import { DraftIndicator } from './DraftIndicator';
import { scheduledMessagesAtom } from '../../state/scheduledMessages'; import { scheduledMessagesAtom } from '../../state/scheduledMessages';
import { getThreadDraftKey } from '../../state/room/thread';
const GifPicker = React.lazy(() => const GifPicker = React.lazy(() =>
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })), import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
@@ -149,9 +150,10 @@ interface RoomInputProps {
fileDropContainerRef: RefObject<HTMLElement>; fileDropContainerRef: RefObject<HTMLElement>;
roomId: string; roomId: string;
room: Room; room: Room;
threadRootId?: string;
} }
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>( export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
({ editor, fileDropContainerRef, roomId, room }, ref) => { ({ editor, fileDropContainerRef, roomId, room, threadRootId }, ref) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
@@ -184,8 +186,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const setScheduledMessages = useSetAtom(scheduledMessagesAtom); const setScheduledMessages = useSetAtom(scheduledMessagesAtom);
const alive = useAlive(); const alive = useAlive();
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId)); // Scope drafts/replies/uploads by thread so a thread composer stays fully
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId)); // isolated from the main room composer (and from other threads).
const draftKey = threadRootId ? getThreadDraftKey(roomId, threadRootId) : roomId;
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(draftKey));
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(draftKey));
const replyUserID = replyDraft?.userId; const replyUserID = replyDraft?.userId;
const powerLevelTags = usePowerLevelTags(room, powerLevels); const powerLevelTags = usePowerLevelTags(room, powerLevels);
@@ -206,7 +211,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor; legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor;
const [uploadBoard, setUploadBoard] = useState(true); const [uploadBoard, setUploadBoard] = useState(true);
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId)); const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(draftKey));
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom( const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
roomUploadAtomFamily, roomUploadAtomFamily,
selectedFiles.map((f) => f.file), selectedFiles.map((f) => f.file),
@@ -225,7 +230,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const showLocation = composerToolbarButtons?.showLocation ?? true; const showLocation = composerToolbarButtons?.showLocation ?? true;
const showPoll = composerToolbarButtons?.showPoll ?? true; const showPoll = composerToolbarButtons?.showPoll ?? true;
const showVoice = composerToolbarButtons?.showVoice ?? true; const showVoice = composerToolbarButtons?.showVoice ?? true;
const showSchedule = composerToolbarButtons?.showSchedule ?? true; // Schedule-send is hidden in thread mode (v1 reduction).
const showSchedule = (composerToolbarButtons?.showSchedule ?? true) && !threadRootId;
const composerButtonOrder = useMemo( const composerButtonOrder = useMemo(
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order), () => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
[composerToolbarButtons?.order], [composerToolbarButtons?.order],
@@ -244,7 +250,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
setLocating(false); setLocating(false);
const { latitude, longitude } = pos.coords; const { latitude, longitude } = pos.coords;
const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`; const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
mx.sendMessage(roomId, { mx.sendMessage(roomId, threadRootId ?? null, {
msgtype: 'm.location', msgtype: 'm.location',
body: `Location: ${geoUri}`, body: `Location: ${geoUri}`,
geo_uri: geoUri, geo_uri: geoUri,
@@ -263,7 +269,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}, },
{ timeout: 10000 }, { timeout: 10000 },
); );
}, [mx, roomId]); }, [mx, roomId, threadRootId]);
const handleVoiceSend = useCallback( const handleVoiceSend = useCallback(
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => { async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
@@ -279,7 +285,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (room.hasEncryptionStateEvent()) { if (room.hasEncryptionStateEvent()) {
const { encInfo, file: encBlob } = await encryptFile(blob); const { encInfo, file: encBlob } = await encryptFile(blob);
const uploadResult = await mx.uploadContent(encBlob); const uploadResult = await mx.uploadContent(encBlob);
mx.sendMessage(roomId, { mx.sendMessage(roomId, threadRootId ?? null, {
...baseContent, ...baseContent,
file: { ...encInfo, url: uploadResult.content_uri }, file: { ...encInfo, url: uploadResult.content_uri },
} as any); } as any);
@@ -288,13 +294,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
name: 'voice-message.ogg', name: 'voice-message.ogg',
type: mimeType, type: mimeType,
}); });
mx.sendMessage(roomId, { mx.sendMessage(roomId, threadRootId ?? null, {
...baseContent, ...baseContent,
url: uploadResult.content_uri, url: uploadResult.content_uri,
} as any); } as any);
} }
}, },
[mx, room, roomId], [mx, room, roomId, threadRootId],
); );
const [autocompleteQuery, setAutocompleteQuery] = const [autocompleteQuery, setAutocompleteQuery] =
@@ -364,7 +370,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
} else { } else {
// Jotai draft is empty (page reload) — try localStorage fallback // Jotai draft is empty (page reload) — try localStorage fallback
try { try {
const stored = localStorage.getItem(`draft-msg-${roomId}`); const stored = localStorage.getItem(`draft-msg-${draftKey}`);
if (stored) { if (stored) {
const nodes = JSON.parse(stored); const nodes = JSON.parse(stored);
if (Array.isArray(nodes) && nodes.length > 0) { if (Array.isArray(nodes) && nodes.length > 0) {
@@ -379,22 +385,22 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
// Ignore malformed stored draft // Ignore malformed stored draft
} }
} }
}, [editor, msgDraft, roomId, setMsgDraft]); }, [editor, msgDraft, draftKey, setMsgDraft]);
useEffect( useEffect(
() => () => { () => () => {
if (!isEmptyEditor(editor)) { if (!isEmptyEditor(editor)) {
const parsedDraft = JSON.parse(JSON.stringify(editor.children)); const parsedDraft = JSON.parse(JSON.stringify(editor.children));
setMsgDraft(parsedDraft); setMsgDraft(parsedDraft);
localStorage.setItem(`draft-msg-${roomId}`, JSON.stringify(parsedDraft)); localStorage.setItem(`draft-msg-${draftKey}`, JSON.stringify(parsedDraft));
} else { } else {
setMsgDraft([]); setMsgDraft([]);
localStorage.removeItem(`draft-msg-${roomId}`); localStorage.removeItem(`draft-msg-${draftKey}`);
} }
resetEditor(editor); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
}, },
[roomId, editor, setMsgDraft], [draftKey, editor, setMsgDraft],
); );
const handleFileMetadata = useCallback( const handleFileMetadata = useCallback(
@@ -450,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) {
@@ -487,15 +497,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}); });
handleCancelUpload(uploads); handleCancelUpload(uploads);
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
contents.forEach((content) => mx.sendMessage(roomId, content as any)); contents.forEach((content) => mx.sendMessage(roomId, threadRootId ?? null, content as any));
}, },
[mx, roomId, selectedFiles, handleCancelUpload], [mx, roomId, threadRootId, selectedFiles, handleCancelUpload],
); );
const submit = useCallback(() => { const submit = useCallback(() => {
uploadBoardHandlers.current?.handleSend(); uploadBoardHandlers.current?.handleSend();
const commandName = getBeginCommand(editor); // Slash-command interpretation is disabled in thread mode (v1): "/foo"
// sends literally rather than being parsed as a command.
const commandName = threadRootId ? undefined : getBeginCommand(editor);
let plainText = toPlainText(editor.children, isMarkdown).trim(); let plainText = toPlainText(editor.children, isMarkdown).trim();
let customHtml = trimCustomHtml( let customHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, { toMatrixCustomHTML(editor.children, {
@@ -530,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;
} }
@@ -568,13 +581,25 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
content['m.relates_to'].is_falling_back = false; content['m.relates_to'].is_falling_back = false;
} }
} }
mx.sendMessage(roomId, content as any); mx.sendMessage(roomId, threadRootId ?? null, content as any);
resetEditor(editor); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
localStorage.removeItem(`draft-msg-${roomId}`); setCharCount(0);
localStorage.removeItem(`draft-msg-${draftKey}`);
setReplyDraft(undefined); setReplyDraft(undefined);
sendTypingStatus(false); sendTypingStatus(false);
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]); }, [
mx,
roomId,
threadRootId,
draftKey,
editor,
replyDraft,
sendTypingStatus,
setReplyDraft,
isMarkdown,
commands,
]);
/** /**
* Build a text message content object from the current editor state. * Build a text message content object from the current editor state.
@@ -643,11 +668,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}); });
resetEditor(editor); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
localStorage.removeItem(`draft-msg-${roomId}`); localStorage.removeItem(`draft-msg-${draftKey}`);
setReplyDraft(undefined); setReplyDraft(undefined);
sendTypingStatus(false); sendTypingStatus(false);
}, },
[setScheduledMessages, roomId, editor, setReplyDraft, sendTypingStatus], [setScheduledMessages, roomId, draftKey, editor, setReplyDraft, sendTypingStatus],
); );
const handleKeyDown: KeyboardEventHandler = useCallback( const handleKeyDown: KeyboardEventHandler = useCallback(
@@ -660,15 +685,23 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
submit(); submit();
} }
if (isKeyHotkey('escape', evt)) { if (isKeyHotkey('escape', evt)) {
evt.preventDefault(); // Only consume Escape (and stop it bubbling to the thread panel / room
// window handlers) when the composer actually has something to dismiss.
// If we did nothing, let Escape propagate so those handlers can run.
if (autocompleteQuery) { if (autocompleteQuery) {
evt.preventDefault();
evt.stopPropagation();
setAutocompleteQuery(undefined); setAutocompleteQuery(undefined);
return; return;
} }
if (replyDraft) {
evt.preventDefault();
evt.stopPropagation();
setReplyDraft(undefined); setReplyDraft(undefined);
} }
}
}, },
[submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing], [submit, replyDraft, setReplyDraft, enterForNewline, autocompleteQuery, isComposing],
); );
const handleKeyUp: KeyboardEventHandler = useCallback( const handleKeyUp: KeyboardEventHandler = useCallback(
@@ -742,7 +775,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
); );
const mxcUrl = (uploadRes as { content_uri: string }).content_uri; const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
if (!mxcUrl) return; if (!mxcUrl) return;
mx.sendMessage(roomId, { mx.sendMessage(roomId, threadRootId ?? null, {
msgtype: MsgType.Image, msgtype: MsgType.Image,
body: 'image.gif', body: 'image.gif',
url: mxcUrl, url: mxcUrl,
@@ -757,7 +790,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (alive()) setGifUploading(false); if (alive()) setGifUploading(false);
} }
}, },
[mx, roomId, alive], [mx, roomId, threadRootId, alive],
); );
const handleStickerSelect = useCallback( const handleStickerSelect = useCallback(
@@ -770,13 +803,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
await getImageUrlBlob(stickerUrl), await getImageUrlBlob(stickerUrl),
); );
mx.sendEvent(roomId, EventType.Sticker, { mx.sendEvent(roomId, threadRootId ?? null, EventType.Sticker, {
body: label, body: label,
url: mxc, url: mxc,
info, info,
}); });
}, },
[mx, roomId, useAuthentication], [mx, roomId, threadRootId, useAuthentication],
); );
if (room.getType() === 'm.server_notice') { if (room.getType() === 'm.server_notice') {
@@ -1258,7 +1291,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
{locationError} {locationError}
</Text> </Text>
)} )}
<DraftIndicator roomId={roomId} /> <DraftIndicator roomId={draftKey} />
{charCount > 0 && ( {charCount > 0 && (
<Text <Text
size="T200" size="T200"
+67 -9
View File
@@ -18,9 +18,11 @@ import {
IContent, IContent,
MatrixClient, MatrixClient,
MatrixEvent, MatrixEvent,
RelationType,
Room, Room,
RoomEvent, RoomEvent,
RoomEventHandlerMap, RoomEventHandlerMap,
ThreadEvent,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser'; import { HTMLReactParserOptions } from 'html-react-parser';
import classNames from 'classnames'; import classNames from 'classnames';
@@ -103,6 +105,8 @@ import * as css from './RoomTimeline.css';
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time'; import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor'; import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts'; import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
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 { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
@@ -245,13 +249,26 @@ const useEventTimelineLoader = (
room: Room, room: Room,
onLoad: (eventId: string, linkedTimelines: EventTimeline[], evtAbsIndex: number) => void, onLoad: (eventId: string, linkedTimelines: EventTimeline[], evtAbsIndex: number) => void,
onError: (err: Error | null) => void, onError: (err: Error | null) => void,
onThreadRedirect: (threadRootId: string) => void,
) => { ) => {
const loadEventTimeline = useCallback( const loadEventTimeline = useCallback(
async (eventId: string) => { async (eventId: string) => {
const [err, replyEvtTimeline] = await to( const [err, replyEvtTimeline] = await to(
mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId), mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId),
); );
// Thread events aren't locatable in the main timeline set (getEventTimeline
// returns undefined / no abs index). Best-effort: redirect to the thread panel
// when the fetched event belongs to a thread instead of surfacing an error.
const redirectToThread = () => {
const threadRootId = room.findEventById(eventId)?.threadRootId;
if (threadRootId) {
onThreadRedirect(threadRootId);
return true;
}
return false;
};
if (!replyEvtTimeline) { if (!replyEvtTimeline) {
if (redirectToThread()) return;
onError(err ?? null); onError(err ?? null);
return; return;
} }
@@ -259,13 +276,14 @@ const useEventTimelineLoader = (
const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId); const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId);
if (absIndex === undefined) { if (absIndex === undefined) {
if (redirectToThread()) return;
onError(err ?? null); onError(err ?? null);
return; return;
} }
onLoad(eventId, linkedTimelines, absIndex); onLoad(eventId, linkedTimelines, absIndex);
}, },
[mx, room, onLoad, onError], [mx, room, onLoad, onError, onThreadRedirect],
); );
return loadEventTimeline; return loadEventTimeline;
@@ -460,6 +478,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]); const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId)); const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId));
// Thread summary chips only mount for events that already carry thread data
// (perf: a chip subscribes room-level listeners, so mounting one per rendered
// message would exceed the SDK's emitter cap). This single room-level
// ThreadEvent.New subscription re-renders the timeline once when a brand-new
// thread appears, so the root's chip shows up without unrelated activity.
const [, setThreadNewTick] = useState(0);
useEffect(() => {
const handleThreadNew = () => setThreadNewTick((c) => c + 1);
room.on(ThreadEvent.New, handleThreadNew);
return () => {
room.removeListener(ThreadEvent.New, handleThreadNew);
};
}, [room]);
const powerLevels = usePowerLevelsContext(); const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room); const creators = useRoomCreators(room);
@@ -622,6 +654,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
scrollToBottomRef.current.count += 1; scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false; scrollToBottomRef.current.smooth = false;
}, [alive, room]), }, [alive, room]),
useCallback(
(threadRootId: string) => {
if (!alive()) return;
setActiveThreadId(threadRootId);
},
[alive, setActiveThreadId],
),
); );
useLiveEventArrive( useLiveEventArrive(
@@ -982,14 +1021,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
console.warn('Button should have "data-event-id" attribute!'); console.warn('Button should have "data-event-id" attribute!');
return; return;
} }
if (startThread) {
// Open the thread panel instead of arming an m.thread reply in the main composer.
setActiveThreadId(replyId);
return;
}
const replyEvt = room.findEventById(replyId); const replyEvt = room.findEventById(replyId);
if (!replyEvt) return; if (!replyEvt) return;
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet()); const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent(); const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
const { body, formatted_body: formattedBody } = content; const { body, formatted_body: formattedBody } = content;
const { 'm.relates_to': relation } = startThread const { 'm.relates_to': relation } = replyEvt.getWireContent();
? { 'm.relates_to': { rel_type: 'm.thread', event_id: replyId } }
: replyEvt.getWireContent();
const senderId = replyEvt.getSender(); const senderId = replyEvt.getSender();
if (senderId && typeof body === 'string') { if (senderId && typeof body === 'string') {
setReplyDraft({ setReplyDraft({
@@ -1002,7 +1044,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
setTimeout(() => ReactEditor.focus(editor), 100); setTimeout(() => ReactEditor.focus(editor), 100);
} }
}, },
[room, setReplyDraft, editor], [room, setReplyDraft, setActiveThreadId, editor],
); );
const handleReactionToggle = useCallback( const handleReactionToggle = useCallback(
@@ -1090,6 +1132,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
replyEventId={replyEventId} replyEventId={replyEventId}
threadRootId={threadRootId} threadRootId={threadRootId}
onClick={handleOpenReply} onClick={handleOpenReply}
onThreadClick={setActiveThreadId}
getMemberPowerTag={getMemberPowerTag} getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessiblePowerTagColors} accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct} legacyUsernameColor={legacyUsernameColor || direct}
@@ -1097,7 +1140,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
) )
} }
reactions={ reactions={
reactionRelations && ( <>
{reactionRelations && (
<Reactions <Reactions
style={{ marginTop: config.space.S200 }} style={{ marginTop: config.space.S200 }}
room={room} room={room}
@@ -1106,7 +1150,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
canSendReaction={canSendReaction} canSendReaction={canSendReaction}
onReactionToggle={handleReactionToggle} onReactionToggle={handleReactionToggle}
/> />
) )}
{(!threadRootId || threadRootId === mEventId) &&
(mEvent.getThread() !== undefined ||
mEvent.getServerAggregatedRelation(RelationType.Thread) !== undefined) && (
<ThreadSummary rootEvent={mEvent} room={room} onOpen={setActiveThreadId} />
)}
</>
} }
hideReadReceipts={hideActivity} hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools} showDeveloperTools={showDeveloperTools}
@@ -1175,6 +1225,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
replyEventId={replyEventId} replyEventId={replyEventId}
threadRootId={threadRootId} threadRootId={threadRootId}
onClick={handleOpenReply} onClick={handleOpenReply}
onThreadClick={setActiveThreadId}
getMemberPowerTag={getMemberPowerTag} getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessiblePowerTagColors} accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct} legacyUsernameColor={legacyUsernameColor || direct}
@@ -1182,7 +1233,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
) )
} }
reactions={ reactions={
reactionRelations && ( <>
{reactionRelations && (
<Reactions <Reactions
style={{ marginTop: config.space.S200 }} style={{ marginTop: config.space.S200 }}
room={room} room={room}
@@ -1191,7 +1243,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
canSendReaction={canSendReaction} canSendReaction={canSendReaction}
onReactionToggle={handleReactionToggle} onReactionToggle={handleReactionToggle}
/> />
) )}
{(!threadRootId || threadRootId === mEventId) &&
(mEvent.getThread() !== undefined ||
mEvent.getServerAggregatedRelation(RelationType.Thread) !== undefined) && (
<ThreadSummary rootEvent={mEvent} room={room} onOpen={setActiveThreadId} />
)}
</>
} }
hideReadReceipts={hideActivity} hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools} showDeveloperTools={showDeveloperTools}
+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}>
-1
View File
@@ -583,7 +583,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
returnFocusOnDeactivate: false,
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
onDeactivate: () => setViewTopic(false), onDeactivate: () => setViewTopic(false),
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
@@ -25,3 +25,16 @@ export const RoomViewTyping = style([
export const TypingText = style({ export const TypingText = style({
flexGrow: 1, flexGrow: 1,
}); });
// Visually hidden but available to assistive technology.
export const SrOnly = style({
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
});
+23 -4
View File
@@ -33,8 +33,21 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
[typingMembers, myUserId, room], [typingMembers, myUserId, room],
); );
if (typingNames.length === 0) { // A single, non-truncated string for assistive technology to announce.
return null; // Computed even when empty so the live region can stay mounted (below) —
// a `role="status"` region added to the DOM together with its first text
// is not reliably announced by some screen readers.
let typingAnnouncement = '';
if (typingNames.length === 1) {
typingAnnouncement = `${typingNames[0]} is typing`;
} else if (typingNames.length === 2) {
typingAnnouncement = `${typingNames[0]} and ${typingNames[1]} are typing`;
} else if (typingNames.length === 3) {
typingAnnouncement = `${typingNames[0]}, ${typingNames[1]} and ${typingNames[2]} are typing`;
} else {
typingAnnouncement = `${typingNames[0]}, ${typingNames[1]}, ${typingNames[2]} and ${
typingNames.length - 3
} others are typing`;
} }
const handleDropAll = () => { const handleDropAll = () => {
@@ -50,7 +63,12 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
}; };
return ( return (
<div style={{ position: 'relative' }} aria-live="polite" aria-atomic="false"> <div style={{ position: 'relative' }}>
{/* Persistently mounted so the FIRST "X is typing" is announced. */}
<span className={css.SrOnly} role="status" aria-live="polite" aria-atomic="true">
{typingAnnouncement}
</span>
{typingNames.length > 0 && (
<Box <Box
className={classNames(css.RoomViewTyping, className)} className={classNames(css.RoomViewTyping, className)}
alignItems="Center" alignItems="Center"
@@ -59,7 +77,7 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
ref={ref} ref={ref}
> >
<TypingIndicator /> <TypingIndicator />
<Text className={css.TypingText} size="T300" truncate> <Text className={css.TypingText} size="T300" truncate aria-hidden>
{typingNames.length === 1 && ( {typingNames.length === 1 && (
<> <>
<b>{typingNames[0]}</b> <b>{typingNames[0]}</b>
@@ -127,6 +145,7 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
<Icon size="50" src={Icons.Cross} /> <Icon size="50" src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>
)}
</div> </div>
); );
}, },
@@ -33,6 +33,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
const [scheduledMessages, setScheduledMessages] = useAtom(scheduledMessagesAtom); const [scheduledMessages, setScheduledMessages] = useAtom(scheduledMessagesAtom);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [cancelling, setCancelling] = useState<Set<string>>(new Set()); const [cancelling, setCancelling] = useState<Set<string>>(new Set());
const [cancelErrors, setCancelErrors] = useState<Set<string>>(new Set());
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]); const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
@@ -68,12 +69,17 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
async (msg: ScheduledMessage) => { async (msg: ScheduledMessage) => {
if (cancelling.has(msg.delayId)) return; if (cancelling.has(msg.delayId)) return;
setCancelling((prev) => new Set(prev).add(msg.delayId)); setCancelling((prev) => new Set(prev).add(msg.delayId));
setCancelErrors((prev) => {
if (!prev.has(msg.delayId)) return prev;
const next = new Set(prev);
next.delete(msg.delayId);
return next;
});
try { try {
await cancelScheduledMessage(mx, msg.delayId); await cancelScheduledMessage(mx, msg.delayId);
} catch { // Only prune local state once the server confirms cancellation. If we
// If cancellation fails on the server, still remove locally // removed it optimistically the still-live delayed event would fire and
// since the user intends to remove it // the "cancelled" message would send anyway.
} finally {
setScheduledMessages((prev) => { setScheduledMessages((prev) => {
const next = new Map(prev); const next = new Map(prev);
const current = next.get(roomId) ?? []; const current = next.get(roomId) ?? [];
@@ -85,6 +91,11 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
} }
return next; return next;
}); });
} catch {
// Keep the item (still cancellable) and surface an inline error; the
// delayed event is still scheduled on the server.
setCancelErrors((prev) => new Set(prev).add(msg.delayId));
} finally {
setCancelling((prev) => { setCancelling((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.delete(msg.delayId); next.delete(msg.delayId);
@@ -131,13 +142,13 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
{messages.map((msg) => ( {messages.map((msg) => (
<Box <Box
key={msg.delayId} key={msg.delayId}
alignItems="Center" direction="Column"
gap="200"
style={{ style={{
padding: `${config.space.S100} ${config.space.S300}`, padding: `${config.space.S100} ${config.space.S300}`,
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
}} }}
> >
<Box alignItems="Center" gap="200">
<Text <Text
size="T200" size="T200"
priority="400" priority="400"
@@ -148,7 +159,9 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
> >
{typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)'} {typeof msg.content.body === 'string'
? (msg.content.body as string)
: '(message)'}
</Text> </Text>
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}> <Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
{formatSendAt(msg.sendAt)} {formatSendAt(msg.sendAt)}
@@ -167,6 +180,15 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
<Icon src={Icons.Cross} size="50" /> <Icon src={Icons.Cross} size="50" />
</IconButton> </IconButton>
</Box> </Box>
{cancelErrors.has(msg.delayId) && (
<Text
size="T200"
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
>
Could not cancel this message. Try again.
</Text>
)}
</Box>
))} ))}
</Box> </Box>
)} )}
@@ -1,8 +1,11 @@
import React, { ChangeEvent, useCallback, useState } from 'react'; import React, { ChangeEvent, useCallback, useMemo, useRef, useState } from 'react';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { import {
Avatar, Avatar,
Box, Box,
Button,
Checkbox,
color,
config, config,
Header, Header,
Icon, Icon,
@@ -28,15 +31,17 @@ import { mDirectAtom } from '../../../state/mDirectList';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { mxcUrlToHttp } from '../../../utils/matrix'; import { mxcUrlToHttp } from '../../../utils/matrix';
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar'; import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
import { buildForwardContent } from './forwardContent';
type RoomRowProps = { type RoomRowProps = {
room: Room; room: Room;
dm: boolean; dm: boolean;
useAuthentication: boolean; useAuthentication: boolean;
onClick: () => void; selected: boolean;
onToggle: () => void;
sending: boolean; sending: boolean;
}; };
function RoomRow({ room, dm, useAuthentication, onClick, sending }: RoomRowProps) { function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: RoomRowProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const avatarMxc = room.getMxcAvatarUrl(); const avatarMxc = room.getMxcAvatarUrl();
const avatarUrl = avatarMxc const avatarUrl = avatarMxc
@@ -47,8 +52,20 @@ function RoomRow({ room, dm, useAuthentication, onClick, sending }: RoomRowProps
<MenuItem <MenuItem
size="300" size="300"
radii="300" radii="300"
onClick={onClick} onClick={onToggle}
disabled={sending} disabled={sending}
after={
<Checkbox
checked={selected}
readOnly
variant="Primary"
disabled={sending}
onClick={(evt) => {
evt.stopPropagation();
onToggle();
}}
/>
}
before={ before={
<Avatar size="200" radii="300"> <Avatar size="200" radii="300">
<RoomAvatar <RoomAvatar
@@ -86,43 +103,95 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
const modalStyle = useModalStyle(400); const modalStyle = useModalStyle(400);
const directs = useAtomValue(mDirectAtom); const directs = useAtomValue(mDirectAtom);
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const searchInputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [sentTo, setSentTo] = useState<string | null>(null); const [sentTo, setSentTo] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Selection persists across query changes: a room selected then filtered out
// of the rendered slice stays selected.
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
const allRooms = mx const toggleRoom = useCallback((roomId: string) => {
setSelectedRoomIds((prev) => {
const next = new Set(prev);
if (next.has(roomId)) {
next.delete(roomId);
} else {
next.add(roomId);
}
return next;
});
}, []);
const allRooms = useMemo(
() =>
mx
.getRooms() .getRooms()
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom()) .filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0)); .sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0)),
[mx],
const filtered = query
? allRooms.filter((r) => r.name.toLowerCase().includes(query.toLowerCase()))
: allRooms;
const forward = useCallback(
async (room: Room) => {
if (sending) return;
setSending(true);
const fwdContent: Record<string, unknown> = { ...mEvent.getContent() };
delete fwdContent['m.relates_to'];
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (mx as any).sendEvent(room.roomId, mEvent.getType(), fwdContent);
setSentTo(room.name);
setTimeout(onClose, 1400);
} catch {
setSending(false);
}
},
[mx, mEvent, onClose, sending],
); );
const filtered = useMemo(() => {
if (!query) return allRooms;
const q = query.toLowerCase();
return allRooms.filter((r) => r.name.toLowerCase().includes(q));
}, [allRooms, query]);
const sendToSelected = useCallback(async () => {
if (sending || selectedRoomIds.size === 0) return;
const fwdContent = buildForwardContent(mx, mEvent);
if (!fwdContent) {
setError('This message could not be decrypted, so it cannot be forwarded.');
return;
}
setSending(true);
setError(null);
const ids = [...selectedRoomIds];
const results = await Promise.allSettled(
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ids.map((id) => mx.sendEvent(id, null, mEvent.getType() as any, fwdContent)),
);
const failedIds: string[] = [];
const failedNames: string[] = [];
results.forEach((result, i) => {
if (result.status === 'rejected') {
failedIds.push(ids[i]);
failedNames.push(mx.getRoom(ids[i])?.name ?? ids[i]);
}
});
const total = ids.length;
const failed = failedNames.length;
const succeeded = total - failed;
if (failed === 0) {
setSentTo(`Forwarded to ${total} ${total === 1 ? 'room' : 'rooms'}`);
setTimeout(onClose, 1400);
return;
}
setSending(false);
// Prune to only the failures so a retry doesn't re-send to rooms that
// already succeeded (duplicate messages).
setSelectedRoomIds(new Set(failedIds));
if (succeeded === 0) {
setError('Failed to forward. Try again.');
return;
}
setError(`Forwarded to ${succeeded}/${total}. Failed: ${failedNames.join(', ')}.`);
}, [mx, mEvent, onClose, sending, selectedRoomIds]);
return ( return (
<Overlay open backdrop={<OverlayBackdrop />}> <Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter> <OverlayCenter>
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: () => searchInputRef.current ?? false,
onDeactivate: onClose, onDeactivate: onClose,
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
@@ -153,8 +222,13 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
</IconButton> </IconButton>
</Header> </Header>
{!sentTo && ( {!sentTo && (
<Box shrink="No" style={{ padding: `${config.space.S200} ${config.space.S400}` }}> <Box
shrink="No"
direction="Column"
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
>
<Input <Input
ref={searchInputRef}
variant="Background" variant="Background"
size="400" size="400"
radii="400" radii="400"
@@ -163,6 +237,14 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
value={query} value={query}
onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)} onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
/> />
{error && (
<Text
size="T200"
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
>
{error}
</Text>
)}
</Box> </Box>
)} )}
<Line size="300" /> <Line size="300" />
@@ -174,9 +256,10 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
gap="300" gap="300"
style={{ padding: config.space.S400 }} style={{ padding: config.space.S400 }}
> >
<Text size="T300"> Forwarded to {sentTo}</Text> <Text size="T300"> {sentTo}</Text>
</Box> </Box>
) : ( ) : (
<>
<Box grow="Yes" style={{ minHeight: 0, position: 'relative' }}> <Box grow="Yes" style={{ minHeight: 0, position: 'relative' }}>
<Scroll size="300" hideTrack visibility="Hover"> <Scroll size="300" hideTrack visibility="Hover">
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}> <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
@@ -186,7 +269,8 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
room={room} room={room}
dm={directs.has(room.roomId)} dm={directs.has(room.roomId)}
useAuthentication={useAuthentication} useAuthentication={useAuthentication}
onClick={() => forward(room)} selected={selectedRoomIds.has(room.roomId)}
onToggle={() => toggleRoom(room.roomId)}
sending={sending} sending={sending}
/> />
))} ))}
@@ -218,6 +302,26 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
</Box> </Box>
)} )}
</Box> </Box>
<Line size="300" />
<Box
shrink="No"
direction="Column"
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
>
<Button
variant="Primary"
size="400"
radii="400"
disabled={selectedRoomIds.size === 0 || sending}
before={sending && <Spinner variant="Primary" fill="Solid" size="200" />}
onClick={sendToSelected}
>
<Text size="B400">
Send to {selectedRoomIds.size} {selectedRoomIds.size === 1 ? 'room' : 'rooms'}
</Text>
</Button>
</Box>
</>
)} )}
</Modal> </Modal>
</FocusTrap> </FocusTrap>
@@ -56,6 +56,7 @@ import {
getMemberDisplayName, getMemberDisplayName,
} from '../../../utils/room'; } from '../../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { messageAriaLabel } from '../../../utils/a11y';
import { MessageLayout, MessageSpacing } from '../../../state/settings'; import { MessageLayout, MessageSpacing } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
@@ -972,6 +973,10 @@ export const Message = React.memo(
[MsgAppearClass]: playAppear, [MsgAppearClass]: playAppear,
[MentionHighlightPulse]: playMentionPulse, [MentionHighlightPulse]: playMentionPulse,
})} })}
role="article"
aria-label={
collapse ? messageAriaLabel(senderDisplayName, mEvent.getTs(), hour24Clock) : undefined
}
tabIndex={0} tabIndex={0}
space={messageSpacing} space={messageSpacing}
collapse={collapse} collapse={collapse}
@@ -51,7 +51,13 @@ import { UseStateProvider } from '../../../components/UseStateProvider';
import { EmojiBoard } from '../../../components/emoji-board'; import { EmojiBoard } from '../../../components/emoji-board';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room'; import {
getEditedEvent,
getMemberDisplayName,
getMentionContent,
trimReplyFromFormattedBody,
} from '../../../utils/room';
import { getMxIdLocalPart } from '../../../utils/matrix';
import { mobileOrTablet } from '../../../utils/user-agent'; import { mobileOrTablet } from '../../../utils/user-agent';
import { useComposingCheck } from '../../../hooks/useComposingCheck'; import { useComposingCheck } from '../../../hooks/useComposingCheck';
@@ -66,6 +72,12 @@ export const MessageEditor = as<'div', MessageEditorProps>(
({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => { ({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const editor = useEditor(); const editor = useEditor();
// Accessible name for the edit textbox so screen readers announce which
// message is being edited (a11y, P3-4).
const editSenderId = mEvent.getSender();
const editSenderName = editSenderId
? (getMemberDisplayName(room, editSenderId) ?? getMxIdLocalPart(editSenderId) ?? editSenderId)
: '';
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
@@ -259,6 +271,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
<CustomEditor <CustomEditor
editor={editor} editor={editor}
placeholder="Edit message..." placeholder="Edit message..."
ariaLabel={editSenderId ? `Editing message from ${editSenderName}` : 'Edit message'}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp} onKeyUp={handleKeyUp}
bottom={ bottom={
@@ -106,7 +106,6 @@ export const Reactions = as<'div', ReactionsProps>(
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setViewer(false), onDeactivate: () => setViewer(false),
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
@@ -1,8 +1,9 @@
import React, { useMemo } from 'react'; import React, { useMemo, useState } from 'react';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { import {
Box, Box,
Button, Button,
color,
config, config,
Dialog, Dialog,
Header, Header,
@@ -43,8 +44,14 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
const modalStyle = useModalStyle(320); const modalStyle = useModalStyle(320);
const { addReminder } = useReminders(); const { addReminder } = useReminders();
const presets = useMemo(() => getPresets(), []); const presets = useMemo(() => getPresets(), []);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const handlePick = async (ms: number) => { const handlePick = async (ms: number) => {
if (busy) return;
setBusy(true);
setError(null);
try {
await addReminder({ await addReminder({
roomId, roomId,
eventId, eventId,
@@ -52,6 +59,10 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
message: previewText || 'Reminder', message: previewText || 'Reminder',
}); });
onClose(); onClose();
} catch {
setBusy(false);
setError('Could not set reminder. Try again.');
}
}; };
return ( return (
@@ -108,6 +119,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
variant="Secondary" variant="Secondary"
fill="Soft" fill="Soft"
radii="300" radii="300"
disabled={busy}
onClick={() => handlePick(p.ms)} onClick={() => handlePick(p.ms)}
> >
<Text size="B300" truncate> <Text size="B300" truncate>
@@ -115,6 +127,14 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
</Text> </Text>
</Button> </Button>
))} ))}
{error && (
<Text
size="T200"
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
>
{error}
</Text>
)}
</Box> </Box>
</Dialog> </Dialog>
</FocusTrap> </FocusTrap>
@@ -0,0 +1,138 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { buildForwardContent } from './forwardContent';
// Pure content builder buildForwardContent: refuses undecryptable events, forwards
// the latest edit (`m.new_content`), and strips reply fallbacks + `m.relates_to`.
// MatrixClient / MatrixEvent are mocked minimally. getEditedEvent reads edits off
// `timelineSet.relations.getChildEventsForEvent(...).getRelations()`, so the base
// client returns no child edits and the edit test injects one.
const SENDER = '@me:example.org';
type EventOptions = {
content?: Record<string, unknown>;
type?: string;
id?: string;
roomId?: string;
decryptionFailure?: boolean;
ts?: number;
};
const makeEvent = (options: EventOptions = {}): MatrixEvent => {
const {
content = {},
type = 'm.room.message',
id = '$evt:example.org',
roomId = '!room:example.org',
decryptionFailure = false,
ts = 0,
} = options;
return {
getContent: () => content,
getType: () => type,
getId: () => id,
getRoomId: () => roomId,
getSender: () => SENDER,
getTs: () => ts,
isDecryptionFailure: () => decryptionFailure,
} as unknown as MatrixEvent;
};
// Base client: the timeline reports no `m.replace` edits, so the original content
// is forwarded unchanged.
const makeClient = (): MatrixClient =>
({
getRoom: () => ({
getUnfilteredTimelineSet: () => ({
relations: {
getChildEventsForEvent: () => null,
},
}),
}),
}) as unknown as MatrixClient;
test('plain text forwards the body and strips m.relates_to', () => {
const mx = makeClient();
const mEvent = makeEvent({
content: {
msgtype: 'm.text',
body: 'hello world',
'm.relates_to': { rel_type: 'm.thread', event_id: '$root:example.org' },
},
});
const content = buildForwardContent(mx, mEvent);
assert.ok(content);
assert.equal(content.body, 'hello world');
assert.equal(content.msgtype, 'm.text');
assert.equal(content['m.relates_to'], undefined);
});
test('reply quote is stripped from body and formatted_body', () => {
const mx = makeClient();
const mEvent = makeEvent({
content: {
msgtype: 'm.text',
body: '> <@alice:example.org> original\n\nmy reply',
format: 'org.matrix.custom.html',
formatted_body: '<mx-reply><blockquote>original</blockquote></mx-reply>my reply',
'm.relates_to': { 'm.in_reply_to': { event_id: '$root:example.org' } },
},
});
const content = buildForwardContent(mx, mEvent);
assert.ok(content);
assert.equal(content.body, 'my reply');
assert.equal(content.formatted_body, 'my reply');
assert.equal(content['m.relates_to'], undefined);
});
test('decryption failure returns undefined', () => {
const mx = makeClient();
const mEvent = makeEvent({
content: { msgtype: 'm.bad.encrypted' },
decryptionFailure: true,
});
assert.equal(buildForwardContent(mx, mEvent), undefined);
});
test('edited message forwards m.new_content', () => {
const mEvent = makeEvent({
content: {
msgtype: 'm.text',
body: 'original body',
'm.relates_to': { rel_type: 'm.thread', event_id: '$root:example.org' },
},
});
// The latest `m.replace` edit carries the new content under `m.new_content`.
const editEvent = makeEvent({
content: { 'm.new_content': { msgtype: 'm.text', body: 'edited body' } },
ts: 100,
});
const mx = {
getRoom: () => ({
getUnfilteredTimelineSet: () => ({
relations: {
getChildEventsForEvent: () => ({
getRelations: () => [editEvent],
}),
},
}),
}),
} as unknown as MatrixClient;
const content = buildForwardContent(mx, mEvent);
assert.ok(content);
assert.equal(content.body, 'edited body');
assert.equal(content.msgtype, 'm.text');
assert.equal(content['m.new_content'], undefined);
assert.equal(content['m.relates_to'], undefined);
});
@@ -0,0 +1,42 @@
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { getEditedEvent, trimReplyFromBody, trimReplyFromFormattedBody } from '../../../utils/room';
/**
* Build the content to forward:
* - undecryptable events are refused (would forward `m.bad.encrypted` junk)
* - edited messages forward the LATEST edit (`m.new_content`), not the
* original pre-edit body
* - reply fallbacks (`> <@user> …` quote + `<mx-reply>` block) are stripped
* along with the `m.relates_to` reply/thread relation, so the forwarded
* message stands alone in the target room
*/
export function buildForwardContent(
mx: MatrixClient,
mEvent: MatrixEvent,
): Record<string, unknown> | undefined {
if (mEvent.isDecryptionFailure()) return undefined;
let content = { ...mEvent.getContent() };
const eventId = mEvent.getId();
const room = mx.getRoom(mEvent.getRoomId());
if (eventId && room) {
const editedEvent = getEditedEvent(eventId, mEvent, room.getUnfilteredTimelineSet());
const newContent = editedEvent?.getContent()['m.new_content'];
if (newContent && typeof newContent === 'object') {
content = { ...(newContent as Record<string, unknown>) };
}
}
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') {
content.body = trimReplyFromBody(content.body);
}
if (typeof content.formatted_body === 'string') {
content.formatted_body = trimReplyFromFormattedBody(content.formatted_body);
}
return content;
}
@@ -0,0 +1,30 @@
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
export const ThreadPanel = style({
width: toRem(360),
'@media': {
'(max-width: 750px)': {
position: 'fixed',
inset: 0,
width: '100%',
zIndex: 500,
},
},
});
export const ThreadPanelHeader = style({
flexShrink: 0,
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
borderBottomWidth: config.borderWidth.B300,
});
export const ThreadPanelContent = style({
position: 'relative',
overflow: 'hidden',
});
export const ThreadPanelInput = style({
padding: config.space.S200,
borderTopWidth: config.borderWidth.B300,
});
@@ -0,0 +1,206 @@
import React, { useCallback, useEffect, useRef } from 'react';
import {
Box,
Header,
Icon,
IconButton,
Icons,
Spinner,
Text,
Tooltip,
TooltipProvider,
} from 'folds';
import { Room, RoomEvent, ThreadEvent } from 'matrix-js-sdk';
import { isKeyHotkey } from 'is-hotkey';
import classNames from 'classnames';
import * as css from './ThreadPanel.css';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import { ThreadTimeline } from './ThreadTimeline';
import { markThreadAsRead, useThreadInstance } from './useThread';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useEditor } from '../../../components/editor';
import { useKeyDown } from '../../../hooks/useKeyDown';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { RoomInput } from '../RoomInput';
import {
getThreadNotificationModeIcon,
ThreadNotificationModeSwitcher,
} from '../../../components/ThreadNotificationModeSwitcher';
import { useThreadNotificationMode } from '../../../hooks/useThreadNotifications';
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
type ThreadPanelHeaderProps = {
room: Room;
threadId: string;
requestClose: () => void;
};
function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) {
const mode = useThreadNotificationMode(room.roomId, threadId);
return (
<Header className={css.ThreadPanelHeader} variant="Background" size="600">
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes" direction="Column">
<Text size="H5" truncate>
Thread
</Text>
<Text size="T200" truncate style={{ opacity: 0.65 }}>
{room.name}
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="100">
<ThreadNotificationModeSwitcher roomId={room.roomId} threadId={threadId} value={mode}>
{(handleOpen, opened) => (
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Notifications</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Background"
aria-label="Thread notifications"
aria-pressed={opened}
onClick={handleOpen}
>
<Icon
src={getThreadNotificationModeIcon(mode)}
filled={mode !== ThreadNotificationMode.Default}
/>
</IconButton>
)}
</TooltipProvider>
)}
</ThreadNotificationModeSwitcher>
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Background"
aria-label="Close thread"
onClick={requestClose}
>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
</Box>
</Header>
);
}
export type ThreadPanelProps = {
room: Room;
threadId: string;
requestClose: () => void;
};
export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps) {
const mx = useMatrixClient();
const editor = useEditor();
const thread = useThreadInstance(room, threadId);
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts');
const fileDropContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
useKeyDown(
window,
useCallback(
(evt) => {
if (isKeyHotkey('escape', evt)) {
// The composer preventDefaults Escape when it consumes it (dismissing
// autocomplete / clearing a reply draft). Don't close the panel in
// that case — only when Escape wasn't already handled.
if (evt.defaultPrevented) return;
evt.preventDefault();
evt.stopPropagation();
requestClose();
}
},
[requestClose],
),
);
// Mark the thread read when the panel is open and on each new thread event.
// Deduped on the latest event id: RoomEvent.Timeline re-emits per event during
// backfill and for every edit/reaction, and sendReadReceipt POSTs
// unconditionally — without the guard, opening a thread with N replies would
// fire up to N receipt requests at the same event.
const lastReadEventIdRef = useRef<string | undefined>(undefined);
useEffect(() => {
lastReadEventIdRef.current = undefined;
if (!thread) return undefined;
const markRead = () => {
const events = thread.liveTimeline.getEvents();
let latestId: string | undefined;
for (let i = events.length - 1; i >= 0; i -= 1) {
const evt = events[i];
if (evt && !evt.isSending()) {
latestId = evt.getId() ?? undefined;
break;
}
}
if (!latestId || latestId === lastReadEventIdRef.current) return;
lastReadEventIdRef.current = latestId;
markThreadAsRead(mx, thread, privateReadReceipts).catch(() => {
// Allow a retry on the next event if the receipt POST failed.
if (lastReadEventIdRef.current === latestId) {
lastReadEventIdRef.current = undefined;
}
});
};
markRead();
thread.on(ThreadEvent.NewReply, markRead);
thread.on(RoomEvent.Timeline, markRead);
return () => {
thread.off(ThreadEvent.NewReply, markRead);
thread.off(RoomEvent.Timeline, markRead);
};
}, [mx, thread, privateReadReceipts]);
return (
<Box
className={classNames(css.ThreadPanel, ContainerColor({ variant: 'Background' }))}
shrink="No"
direction="Column"
>
<ThreadPanelHeader room={room} threadId={threadId} requestClose={requestClose} />
{!thread ? (
<Box grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
<Spinner size="400" variant="Secondary" />
<Text size="T300">Loading thread</Text>
</Box>
) : (
<>
<Box grow="Yes" className={css.ThreadPanelContent} direction="Column">
<ThreadTimeline room={room} thread={thread} editor={editor} />
</Box>
<Box className={css.ThreadPanelInput} shrink="No" direction="Column">
<RoomInput
room={room}
roomId={room.roomId}
threadRootId={threadId}
editor={editor}
fileDropContainerRef={fileDropContainerRef}
/>
</Box>
</>
)}
</Box>
);
}
@@ -0,0 +1,51 @@
import React from 'react';
import { Badge, Box, Chip, Icon, Icons, Text, config } from 'folds';
import { MatrixEvent, Room } from 'matrix-js-sdk';
import { useThreadSummary } from '../../../hooks/useThreadSummary';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { timeDayMonthYear, timeHourMinute, today } from '../../../utils/time';
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
type ThreadSummaryProps = {
rootEvent: MatrixEvent;
room: Room;
onOpen: (threadId: string) => void;
};
export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) {
const { summary, unread, mode } = useThreadSummary(rootEvent, room);
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
if (!summary || summary.count === 0) return null;
const { count, latestTs } = summary;
const latestStr =
latestTs !== undefined
? today(latestTs)
? timeHourMinute(latestTs, hour24Clock)
: timeDayMonthYear(latestTs)
: undefined;
return (
<Box style={{ marginTop: config.space.S200 }}>
<Chip
variant="SurfaceVariant"
radii="300"
before={<Icon size="50" src={Icons.Thread} />}
after={
unread > 0 ? <Badge variant="Success" fill="Solid" radii="Pill" size="200" /> : undefined
}
onClick={() => {
const threadId = rootEvent.getId();
if (threadId) onOpen(threadId);
}}
>
<Text size="T200">
{count === 1 ? '1 reply' : `${count} replies`}
{latestStr ? ` · ${latestStr}` : ''}
</Text>
{mode === ThreadNotificationMode.Mute && <Icon size="50" src={Icons.BellMute} />}
</Chip>
</Box>
);
}
@@ -0,0 +1,48 @@
import { style } from '@vanilla-extract/css';
import { color, config } from 'folds';
export const ThreadTimeline = style({
height: '100%',
position: 'relative',
});
export const ThreadTimelineContent = style({
minHeight: '100%',
padding: `${config.space.S400} 0`,
});
export const ThreadTimelineFloat = style({
position: 'absolute',
bottom: config.space.S400,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1,
minWidth: 'max-content',
});
export const ThreadCentered = style({
height: '100%',
padding: config.space.S700,
});
export const RootMessage = style({
backgroundColor: color.SurfaceVariant.Container,
borderRadius: config.radii.R400,
marginBottom: config.space.S100,
});
export const RepliesDivider = style({
padding: `${config.space.S200} ${config.space.S400}`,
});
export const NoReplies = style({
padding: config.space.S400,
});
export const PendingMessage = style({
opacity: 0.6,
});
export const PendingFailed = style({
opacity: 1,
});
@@ -0,0 +1,987 @@
import React, {
Dispatch,
MouseEventHandler,
ReactNode,
SetStateAction,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
Direction,
EventStatus,
EventTimeline,
EventTimelineSet,
EventTimelineSetHandlerMap,
MatrixClient,
MatrixEvent,
RelationType,
Room,
RoomEvent,
Thread,
ThreadEvent,
} from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Editor } from 'slate';
import { ReactEditor } from 'slate-react';
import to from 'await-to-js';
import { useAtomValue, useSetAtom } from 'jotai';
import { Badge, Box, Chip, Icon, Icons, Line, Scroll, Spinner, Text, color, config } from 'folds';
import classNames from 'classnames';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator';
import { useAlive } from '../../../hooks/useAlive';
import { scrollToBottom } from '../../../utils/dom';
import {
DefaultPlaceholder,
MessageBase,
Reply,
RedactedContent,
MSticker,
MessageUnsupportedContent,
MessageNotDecryptedContent,
ImageContent,
} from '../../../components/message';
import {
factoryRenderLinkifyWithMention,
getReactCustomHtmlParser,
LINKIFY_OPTS,
makeMentionCustomProps,
renderMatrixMention,
} from '../../../plugins/react-custom-html-parser';
import {
decryptAllTimelineEvent,
getEditedEvent,
getEventReactions,
getMemberDisplayName,
getReactionContent,
reactionOrEditEvent,
} from '../../../utils/room';
import { useSetting } from '../../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../../state/settings';
import { Message, Reactions, EncryptedContent } from '../message';
import { RenderMessageContent } from '../../../components/RenderMessageContent';
import { Image } from '../../../components/media';
import { ImageViewer } from '../../../components/image-viewer';
import * as css from './ThreadTimeline.css';
import {
inSameDay,
minuteDifference,
timeDayMonthYear,
today,
yesterday,
} from '../../../utils/time';
import { createMentionElement, moveCursor } from '../../../components/editor';
import { roomIdToReplyDraftAtomFamily } from '../../../state/room/roomInputDrafts';
import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
import {
getIntersectionObserverEntry,
useIntersectionObserver,
} from '../../../hooks/useIntersectionObserver';
import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
import { useImagePackRooms } from '../../../hooks/useImagePackRooms';
import { useIsDirectRoom } from '../../../hooks/useRoom';
import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../../hooks/useSpace';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import {
useAccessiblePowerTagColors,
useGetMemberPowerTag,
} from '../../../hooks/useMemberPowerTag';
import { useTheme } from '../../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { EditHistoryModal } from '../message/EditHistoryModal';
import {
getLinkedTimelines,
getTimelineAndBaseIndex,
getTimelineEvent,
getTimelineRelativeIndex,
getTimelinesEventsCount,
timelineToEventsCount,
} from '../RoomTimeline';
import { getThreadDraftKey } from '../../../state/room/thread';
import { useThreadLinkedTimelines, useThreadPendingEvents } from './useThread';
// Virtual window size (how many items render around the viewport).
const PAGINATION_LIMIT = 50;
// Network page size for backward /relations pagination of the thread timeline.
const THREAD_PAGE_LIMIT = 30;
type Timeline = {
linkedTimelines: EventTimeline[];
range: ItemRange;
};
const getEmptyTimeline = (): Timeline => ({
linkedTimelines: [],
range: { start: 0, end: 0 },
});
const getInitialThreadTimeline = (thread: Thread, timelines?: EventTimeline[]): Timeline => {
const linkedTimelines =
timelines && timelines.length > 0 ? timelines : getLinkedTimelines(thread.liveTimeline);
const evLength = getTimelinesEventsCount(linkedTimelines);
return {
linkedTimelines,
range: {
start: Math.max(evLength - PAGINATION_LIMIT, 0),
end: evLength,
},
};
};
/**
* Copy of RoomTimeline's `useTimelinePagination` pattern (not exported from RoomTimeline
* as its ~35 hooks are hardwired to the room live timeline). Works transparently against
* the thread timeline's /relations pagination.
*/
const useThreadTimelinePagination = (
mx: MatrixClient,
timeline: Timeline,
setTimeline: Dispatch<SetStateAction<Timeline>>,
limit: number,
) => {
const timelineRef = useRef(timeline);
timelineRef.current = timeline;
const alive = useAlive();
const handleTimelinePagination = useMemo(() => {
let fetching = false;
const recalibratePagination = (
linkedTimelines: EventTimeline[],
timelinesEventsCount: number[],
backwards: boolean,
) => {
const topTimeline = linkedTimelines[0];
const timelineMatch = (mt: EventTimeline) => (t: EventTimeline) => t === mt;
const newLTimelines = getLinkedTimelines(topTimeline);
const topTmIndex = newLTimelines.findIndex(timelineMatch(topTimeline));
const topAddedTm = topTmIndex === -1 ? [] : newLTimelines.slice(0, topTmIndex);
const topTmAddedEvt =
timelineToEventsCount(newLTimelines[topTmIndex]) - timelinesEventsCount[0];
const offsetRange = getTimelinesEventsCount(topAddedTm) + (backwards ? topTmAddedEvt : 0);
setTimeline((currentTimeline) => ({
linkedTimelines: newLTimelines,
range:
offsetRange > 0
? {
start: currentTimeline.range.start + offsetRange,
end: currentTimeline.range.end + offsetRange,
}
: { ...currentTimeline.range },
}));
};
return async (backwards: boolean) => {
if (fetching) return;
const { linkedTimelines: lTimelines } = timelineRef.current;
const timelinesEventsCount = lTimelines.map(timelineToEventsCount);
const timelineToPaginate = backwards ? lTimelines[0] : lTimelines[lTimelines.length - 1];
if (!timelineToPaginate) return;
const paginationToken = timelineToPaginate.getPaginationToken(
backwards ? Direction.Backward : Direction.Forward,
);
if (
!paginationToken &&
getTimelinesEventsCount(lTimelines) !==
getTimelinesEventsCount(getLinkedTimelines(timelineToPaginate))
) {
recalibratePagination(lTimelines, timelinesEventsCount, backwards);
return;
}
fetching = true;
const [err] = await to(
mx.paginateEventTimeline(timelineToPaginate, {
backwards,
limit,
}),
);
if (err) {
fetching = false;
return;
}
const fetchedTimeline =
timelineToPaginate.getNeighbouringTimeline(
backwards ? Direction.Backward : Direction.Forward,
) ?? timelineToPaginate;
// Decrypt all event ahead of render cycle
const roomId = fetchedTimeline.getRoomId();
const room = roomId ? mx.getRoom(roomId) : null;
if (room?.hasEncryptionStateEvent()) {
await to(decryptAllTimelineEvent(mx, fetchedTimeline));
}
fetching = false;
if (alive()) {
recalibratePagination(lTimelines, timelinesEventsCount, backwards);
}
};
}, [mx, alive, setTimeline, limit]);
return handleTimelinePagination;
};
export type ThreadTimelineProps = {
room: Room;
thread: Thread;
editor: Editor;
};
export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
const mx = useMatrixClient();
const alive = useAlive();
const useAuthentication = useMediaAuthentication();
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
const [perMessageProfiles] = useSetting(settingsAtom, 'perMessageProfiles');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const direct = useIsDirectRoom();
const ignoredUsersList = useIgnoredUsers();
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
const setReplyDraft = useSetAtom(
roomIdToReplyDraftAtomFamily(getThreadDraftKey(room.roomId, thread.id)),
);
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const creatorsTag = useRoomCreatorsTag();
const powerLevelTags = usePowerLevelTags(room, powerLevels);
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
const theme = useTheme();
const accessiblePowerTagColors = useAccessiblePowerTagColors(
theme.kind,
creatorsTag,
powerLevelTags,
);
const permissions = useRoomPermissions(creators, powerLevels);
const canRedact = permissions.action('redact', mx.getSafeUserId());
const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
const mentionClickHandler = useMentionClickHandler(room.roomId);
const spoilerClickHandler = useSpoilerClickHandler();
const openUserRoomProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally();
const roomToParents = useAtomValue(roomToParentsAtom);
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
const [editId, setEditId] = useState<string>();
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
const linkifyOpts = useMemo<LinkifyOpts>(
() => ({
...LINKIFY_OPTS,
render: factoryRenderLinkifyWithMention((href) =>
renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)),
),
}),
[mx, room, mentionClickHandler],
);
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
() =>
getReactCustomHtmlParser(mx, room.roomId, {
linkifyOpts,
useAuthentication,
handleSpoilerClick: spoilerClickHandler,
handleMentionClick: mentionClickHandler,
}),
[mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication],
);
const { timelines, ready } = useThreadLinkedTimelines(mx, thread);
const pendingEvents = useThreadPendingEvents(room, thread.id, thread);
const [timeline, setTimeline] = useState<Timeline>(() =>
ready ? getInitialThreadTimeline(thread, timelines) : getEmptyTimeline(),
);
const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
const canPaginateBack =
typeof timeline.linkedTimelines[0]?.getPaginationToken(Direction.Backward) === 'string';
const rangeAtStart = timeline.range.start === 0;
const scrollRef = useRef<HTMLDivElement>(null);
const atBottomAnchorRef = useRef<HTMLElement>(null);
const [atBottom, setAtBottom] = useState(true);
const atBottomRef = useRef(atBottom);
atBottomRef.current = atBottom;
const scrollToBottomRef = useRef({ count: 0, smooth: true });
const handleTimelinePagination = useThreadTimelinePagination(
mx,
timeline,
setTimeline,
THREAD_PAGE_LIMIT,
);
const getScrollElement = useCallback(() => scrollRef.current, []);
const { getItems, scrollToItem, observeBackAnchor } = useVirtualPaginator({
count: eventsLength,
limit: PAGINATION_LIMIT,
range: timeline.range,
onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
getScrollElement,
getItemElement: useCallback(
(index: number) =>
(scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
undefined,
[],
),
onEnd: handleTimelinePagination,
});
// Seed local timeline once the thread has fetched its initial events.
const seededRef = useRef(false);
useEffect(() => {
if (!ready || seededRef.current) return;
seededRef.current = true;
setTimeline(getInitialThreadTimeline(thread, timelines));
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false;
if (room.hasEncryptionStateEvent()) {
to(decryptAllTimelineEvent(mx, thread.liveTimeline)).then(() => {
if (alive()) setTimeline((ct) => ({ ...ct }));
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- seed once when ready flips
}, [ready, thread]);
// Re-render / stick-to-bottom on live thread activity.
useEffect(() => {
const handleTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
mEvent,
eventRoom,
toStartOfTimeline,
removed,
data,
) => {
if (!data?.liveEvent) return;
if (atBottomRef.current) {
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = true;
setTimeline((ct) => ({
...ct,
range: {
start: ct.range.start + 1,
end: ct.range.end + 1,
},
}));
return;
}
setTimeline((ct) => ({ ...ct }));
};
const handleUpdate = () => setTimeline((ct) => ({ ...ct }));
// A gappy sync / updateThreadMetadata resets the thread's live timeline —
// the stored linkedTimelines would then point at a detached timeline, so
// reseed the window from the fresh liveTimeline.
const handleReset = () => {
setTimeline(getInitialThreadTimeline(thread, getLinkedTimelines(thread.liveTimeline)));
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false;
};
thread.on(RoomEvent.Timeline, handleTimeline);
thread.on(ThreadEvent.Update, handleUpdate);
thread.on(RoomEvent.TimelineReset, handleReset);
return () => {
thread.removeListener(RoomEvent.Timeline, handleTimeline);
thread.removeListener(ThreadEvent.Update, handleUpdate);
thread.removeListener(RoomEvent.TimelineReset, handleReset);
};
}, [thread]);
// atBottom detection
useIntersectionObserver(
useCallback((entries) => {
const target = atBottomAnchorRef.current;
if (!target) return;
const entry = getIntersectionObserverEntry(target, entries);
if (entry) setAtBottom(entry.isIntersecting);
}, []),
useCallback(
() => ({
root: getScrollElement(),
rootMargin: '100px',
}),
[getScrollElement],
),
useCallback(() => atBottomAnchorRef.current, []),
);
// Initial scroll to bottom on mount.
useLayoutEffect(() => {
const scrollEl = scrollRef.current;
if (scrollEl) scrollToBottom(scrollEl);
}, []);
// Scroll to bottom when requested.
const scrollToBottomCount = scrollToBottomRef.current.count;
useLayoutEffect(() => {
if (scrollToBottomCount > 0) {
const scrollEl = scrollRef.current;
if (scrollEl)
scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
}
}, [scrollToBottomCount]);
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.smooth = true;
// Flip atBottom so the layout effect re-runs (count re-read) and live
// events resume sticking to the bottom.
setAtBottom(true);
}, [thread]);
// Scroll in-place editor into view.
useEffect(() => {
if (editId) {
const editMsgElement =
(scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ??
undefined;
editMsgElement?.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
}, [editId]);
const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => {
evt.preventDefault();
evt.stopPropagation();
const userId = evt.currentTarget.getAttribute('data-user-id');
if (!userId) return;
openUserRoomProfile(
room.roomId,
space?.roomId,
userId,
evt.currentTarget.getBoundingClientRect(),
);
},
[room, space, openUserRoomProfile],
);
const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => {
evt.preventDefault();
const userId = evt.currentTarget.getAttribute('data-user-id');
if (!userId) return;
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
editor.insertNode(
createMentionElement(
userId,
name.startsWith('@') ? name : `@${name}`,
userId === mx.getUserId(),
),
);
ReactEditor.focus(editor);
moveCursor(editor);
},
[mx, room, editor],
);
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => {
const replyId = evt.currentTarget.getAttribute('data-event-id');
if (!replyId) return;
const replyEvt = thread.findEventById(replyId) ?? room.findEventById(replyId);
if (!replyEvt) return;
const editedReply = getEditedEvent(replyId, replyEvt, thread.getUnfilteredTimelineSet());
const content = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
const { body, formatted_body: formattedBody } = content;
const senderId = replyEvt.getSender();
if (senderId && typeof body === 'string') {
setReplyDraft({
userId: senderId,
eventId: replyId,
body,
formattedBody,
relation: { rel_type: RelationType.Thread, event_id: thread.id },
});
setTimeout(() => ReactEditor.focus(editor), 100);
}
},
[room, thread, setReplyDraft, editor],
);
const handleReactionToggle = useCallback(
(targetEventId: string, key: string, shortcode?: string) => {
const timelineSet = thread.getUnfilteredTimelineSet();
const relations = getEventReactions(timelineSet, targetEventId);
const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
const reactions = reactionsSet ? Array.from(reactionsSet) : [];
const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!));
if (myReaction && !!myReaction.isRelation()) {
mx.redactEvent(room.roomId, myReaction.getId()!);
return;
}
const rShortcode =
shortcode ||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
mx.sendEvent(
room.roomId,
thread.id,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
MessageEvent.Reaction as any,
getReactionContent(targetEventId, key, rShortcode),
);
},
[mx, room, thread],
);
const handleEdit = useCallback(
(editEvtId?: string) => {
if (editEvtId) {
setEditId(editEvtId);
return;
}
setEditId(undefined);
ReactEditor.focus(editor);
},
[editor],
);
const handleOpenReply: MouseEventHandler = useCallback(
(evt) => {
const targetId = evt.currentTarget.getAttribute('data-event-id');
if (!targetId) return;
// best-effort: scroll to referenced event if it is inside the loaded thread window
let absIndex = -1;
let acc = 0;
timeline.linkedTimelines.some((tl) => {
const idx = tl.getEvents().findIndex((e) => e.getId() === targetId);
if (idx !== -1) {
absIndex = acc + idx;
return true;
}
acc += tl.getEvents().length;
return false;
});
if (absIndex >= 0) {
scrollToItem(absIndex, {
behavior: 'smooth',
align: 'center',
stopInView: true,
});
}
},
[timeline.linkedTimelines, scrollToItem],
);
const renderMessageContent = useCallback(
(mEvent: MatrixEvent, mEventId: string, timelineSet: EventTimelineSet): ReactNode => {
// Evaluated lazily so EncryptedContent can re-run it (re-reading getType())
// after MatrixEventEvent.Decrypted fires — decryption re-emits NEITHER
// RoomEvent.Timeline nor ThreadEvent.Update, so without this wrapper a
// live-arriving encrypted reply would show "Unable to decrypt" forever.
const renderByType = (): ReactNode => {
if (mEvent.isRedacted()) {
return <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />;
}
const type = mEvent.getType();
if (type === MessageEvent.Sticker) {
return (
<MSticker
content={mEvent.getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
/>
);
}
if (type === MessageEvent.RoomMessageEncrypted) {
return (
<Text>
<MessageNotDecryptedContent />
</Text>
);
}
if (type !== MessageEvent.RoomMessage) {
return (
<Text>
<MessageUnsupportedContent />
</Text>
);
}
const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
const getContent = (() =>
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;
const senderId = mEvent.getSender() ?? '';
const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
return (
<RenderMessageContent
displayName={senderDisplayName}
msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()}
edited={!!editedEvent}
onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined}
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
eventId={mEventId}
/>
);
};
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted) {
return <EncryptedContent mEvent={mEvent}>{renderByType}</EncryptedContent>;
}
return renderByType();
},
[room, mediaAutoLoad, showUrlPreview, htmlReactParserOptions, linkifyOpts, messageLayout],
);
const renderMessage = useCallback(
(
mEvent: MatrixEvent,
opts: { item?: number; collapse: boolean; highlight: boolean; editable: boolean },
): ReactNode => {
const mEventId = mEvent.getId();
if (!mEventId) return null;
const timelineSet = thread.getUnfilteredTimelineSet();
const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations?.getSortedAnnotationsByKey();
const hasReactions = !!reactions && reactions.length > 0;
const { replyEventId, threadRootId } = mEvent;
return (
<Message
key={mEventId}
data-message-item={opts.item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
messageSpacing={messageSpacing}
messageLayout={messageLayout}
collapse={opts.collapse}
highlight={opts.highlight}
edit={opts.editable && editId === mEventId}
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
onEditId={opts.editable ? handleEdit : undefined}
reply={
replyEventId && (
<Reply
room={room}
timelineSet={timelineSet}
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
/>
)
}
reactions={
reactionRelations && (
<Reactions
style={{ marginTop: config.space.S200 }}
room={room}
relations={reactionRelations}
mEventId={mEventId}
canSendReaction={canSendReaction}
onReactionToggle={handleReactionToggle}
/>
)
}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
lotusTerminal={!!lotusTerminal}
>
{renderMessageContent(mEvent, mEventId, timelineSet)}
</Message>
);
},
[
thread,
room,
messageSpacing,
messageLayout,
editId,
canRedact,
canDeleteOwn,
canSendReaction,
canPinEvent,
imagePackRooms,
handleUserClick,
handleUsernameClick,
handleReplyClick,
handleReactionToggle,
handleEdit,
handleOpenReply,
getMemberPowerTag,
accessiblePowerTagColors,
legacyUsernameColor,
direct,
hideActivity,
showDeveloperTools,
hour24Clock,
dateFormatString,
lotusTerminal,
mx,
renderMessageContent,
],
);
let prevEvent: MatrixEvent | undefined;
let isPrevRendered = false;
let dayDivider = false;
const eventRenderer = (item: number) => {
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
if (!eventTimeline) return null;
const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
const mEventId = mEvent?.getId();
if (!mEvent || !mEventId) return null;
// Skip annotations, edits, and any state/membership events (they can't be threaded).
if (reactionOrEditEvent(mEvent) || typeof mEvent.getStateKey() === 'string') {
prevEvent = mEvent;
return null;
}
const eventSender = mEvent.getSender();
if (eventSender && ignoredUsersSet.has(eventSender)) {
prevEvent = mEvent;
return null;
}
const isRoot = mEventId === thread.id;
if (!dayDivider) {
dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
}
const collapsed =
!isRoot &&
!perMessageProfiles &&
isPrevRendered &&
!dayDivider &&
prevEvent !== undefined &&
prevEvent.getId() !== thread.id &&
prevEvent.getSender() === eventSender &&
prevEvent.getType() === mEvent.getType() &&
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 5;
const eventJSX = renderMessage(mEvent, {
item,
collapse: collapsed,
highlight: false,
editable: true,
});
const dayDividerJSX =
dayDivider && eventJSX && !isRoot ? (
<MessageBase space={messageSpacing}>
<Box gap="100" justifyContent="Center" alignItems="Center">
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
<Text size="L400">
{(() => {
if (today(mEvent.getTs())) return 'Today';
if (yesterday(mEvent.getTs())) return 'Yesterday';
return timeDayMonthYear(mEvent.getTs());
})()}
</Text>
</Badge>
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
</Box>
</MessageBase>
) : null;
prevEvent = mEvent;
isPrevRendered = !!eventJSX;
if (dayDividerJSX) dayDivider = false;
// Root gets an emphasized container + a "N replies" divider under it.
if (isRoot && eventJSX) {
const replyCount = thread.length;
return (
<React.Fragment key={mEventId}>
<div className={css.RootMessage}>{eventJSX}</div>
{replyCount > 0 && (
<Box
className={css.RepliesDivider}
gap="100"
justifyContent="Center"
alignItems="Center"
>
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
<Text size="L400" priority="300">
{replyCount === 1 ? '1 reply' : `${replyCount} replies`}
</Text>
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
</Box>
)}
</React.Fragment>
);
}
if (eventJSX && dayDividerJSX) {
return (
<React.Fragment key={mEventId}>
{dayDividerJSX}
{eventJSX}
</React.Fragment>
);
}
return eventJSX;
};
const items = getItems();
const showEmptyReplies = ready && thread.length === 0;
const renderPendingEvent = (mEvent: MatrixEvent) => {
const failed =
mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED;
return (
<div
key={mEvent.getId() ?? mEvent.getTxnId()}
className={classNames(failed ? css.PendingFailed : css.PendingMessage)}
>
{renderMessage(mEvent, { collapse: false, highlight: false, editable: false })}
{failed && (
<Box style={{ padding: `0 ${config.space.S400}` }}>
<Text size="T200" style={{ color: color.Critical.Main }}>
Failed to send
</Text>
</Box>
)}
</div>
);
};
if (!ready) {
return (
<Box
className={css.ThreadCentered}
grow="Yes"
direction="Column"
justifyContent="Center"
alignItems="Center"
>
<Spinner variant="Secondary" size="600" />
</Box>
);
}
return (
<Box className={css.ThreadTimeline} grow="Yes">
<Scroll ref={scrollRef} visibility="Hover">
<Box
className={css.ThreadTimelineContent}
direction="Column"
justifyContent="End"
role="log"
aria-label="Thread timeline"
aria-live="polite"
>
{(canPaginateBack || !rangeAtStart) && (
<>
<MessageBase>
<DefaultPlaceholder />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<DefaultPlaceholder />
</MessageBase>
</>
)}
{items.map(eventRenderer)}
{showEmptyReplies && (
<Box className={css.NoReplies} justifyContent="Center">
<Text size="T300" priority="300">
No replies yet say something
</Text>
</Box>
)}
{pendingEvents.map(renderPendingEvent)}
<span ref={atBottomAnchorRef} />
</Box>
</Scroll>
{!atBottom && (
<Box className={css.ThreadTimelineFloat} justifyContent="Center" alignItems="Center">
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.ArrowBottom} />}
onClick={handleJumpToBottom}
>
<Text size="L400">Jump to Latest</Text>
</Chip>
</Box>
)}
{editHistoryEvent && (
<EditHistoryModal
room={room}
mEvent={editHistoryEvent}
onClose={() => setEditHistoryEvent(undefined)}
/>
)}
</Box>
);
}
+2
View File
@@ -0,0 +1,2 @@
export * from './ThreadPanel';
export * from './ThreadSummary';
@@ -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);
};
@@ -0,0 +1,133 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { EventStatus, MatrixEvent, RelationType } from 'matrix-js-sdk';
import { getThreadSummary, isPendingThreadReply } from './threadSummaryData';
// getThreadSummary reads either the live Thread (preferred) or the
// server-aggregated `m.thread` bundle. We stub only the members it touches and
// cast through `unknown` to MatrixEvent, mirroring the light mocking used in
// the state tests.
type ThreadStub = { length: number; lastReplyTs?: number };
type BundleStub = { count: number; latestTs?: number };
const makeRootEvent = (opts: { thread?: ThreadStub; bundle?: BundleStub }): MatrixEvent => {
const thread = opts.thread
? {
length: opts.thread.length,
lastReply: () =>
opts.thread?.lastReplyTs === undefined
? null
: ({ getTs: () => opts.thread?.lastReplyTs } as unknown as MatrixEvent),
}
: undefined;
return {
getThread: () => thread,
getServerAggregatedRelation: (relType: string) => {
if (relType !== RelationType.Thread || !opts.bundle) return undefined;
return {
count: opts.bundle.count,
latest_event:
opts.bundle.latestTs === undefined
? undefined
: { origin_server_ts: opts.bundle.latestTs },
};
},
} as unknown as MatrixEvent;
};
// ---------------------------------------------------------------------------
// getThreadSummary
// ---------------------------------------------------------------------------
test('prefers the live thread: count from length, latestTs from lastReply', () => {
const rootEvent = makeRootEvent({
thread: { length: 3, lastReplyTs: 1700 },
bundle: { count: 99, latestTs: 1 },
});
assert.deepEqual(getThreadSummary(rootEvent), { count: 3, latestTs: 1700 });
});
test('live thread with no replies yields undefined latestTs', () => {
const rootEvent = makeRootEvent({ thread: { length: 0 } });
assert.deepEqual(getThreadSummary(rootEvent), { count: 0, latestTs: undefined });
});
test('falls back to the server bundle when no live thread', () => {
const rootEvent = makeRootEvent({ bundle: { count: 5, latestTs: 1234 } });
assert.deepEqual(getThreadSummary(rootEvent), { count: 5, latestTs: 1234 });
});
test('bundle without latest_event yields undefined latestTs', () => {
const rootEvent = makeRootEvent({ bundle: { count: 2 } });
assert.deepEqual(getThreadSummary(rootEvent), { count: 2, latestTs: undefined });
});
test('returns undefined when there is neither a thread nor a bundle', () => {
const rootEvent = makeRootEvent({});
assert.equal(getThreadSummary(rootEvent), undefined);
});
// ---------------------------------------------------------------------------
// isPendingThreadReply
// ---------------------------------------------------------------------------
const ROOT = '$root:server';
const makeReply = (opts: {
status: EventStatus | null;
threadRootId?: string;
relation?: { rel_type?: string; event_id?: string } | null;
}): MatrixEvent =>
({
status: opts.status,
threadRootId: opts.threadRootId,
getRelation: () => opts.relation ?? null,
}) as unknown as MatrixEvent;
test('SENDING with matching threadRootId is pending', () => {
const event = makeReply({ status: EventStatus.SENDING, threadRootId: ROOT });
assert.equal(isPendingThreadReply(event, ROOT), true);
});
test('NOT_SENT with matching threadRootId is pending', () => {
const event = makeReply({ status: EventStatus.NOT_SENT, threadRootId: ROOT });
assert.equal(isPendingThreadReply(event, ROOT), true);
});
test('SENDING resolved via the m.thread relation content is pending', () => {
const event = makeReply({
status: EventStatus.SENDING,
relation: { rel_type: RelationType.Thread, event_id: ROOT },
});
assert.equal(isPendingThreadReply(event, ROOT), true);
});
test('SENT (confirmed) event is not pending', () => {
const event = makeReply({ status: EventStatus.SENT, threadRootId: ROOT });
assert.equal(isPendingThreadReply(event, ROOT), false);
});
test('null status is not pending', () => {
const event = makeReply({ status: null, threadRootId: ROOT });
assert.equal(isPendingThreadReply(event, ROOT), false);
});
test('SENDING but for a different thread is not pending', () => {
const event = makeReply({ status: EventStatus.SENDING, threadRootId: '$other:server' });
assert.equal(isPendingThreadReply(event, ROOT), false);
});
test('SENDING with a non-thread relation is not pending', () => {
const event = makeReply({
status: EventStatus.SENDING,
relation: { rel_type: RelationType.Reference, event_id: ROOT },
});
assert.equal(isPendingThreadReply(event, ROOT), false);
});
test('SENDING with no relation and no threadRootId is not pending', () => {
const event = makeReply({ status: EventStatus.SENDING });
assert.equal(isPendingThreadReply(event, ROOT), false);
});
@@ -0,0 +1,55 @@
import { EventStatus, IThreadBundledRelationship, MatrixEvent, RelationType } from 'matrix-js-sdk';
export type ThreadSummaryData = {
count: number;
latestTs: number | undefined;
};
/**
* Summary data for a thread root's "N replies" chip.
*
* Prefers the live {@link Thread} object when it exists (it reflects local
* echo + pagination), otherwise falls back to the server-aggregated bundle
* (`unsigned['m.relations']['m.thread']`) so the chip renders before any
* Thread object has been created. Returns `undefined` when the root has no
* thread at all.
*/
export const getThreadSummary = (rootEvent: MatrixEvent): ThreadSummaryData | undefined => {
const thread = rootEvent.getThread();
if (thread) {
const lastReply = thread.lastReply();
return {
count: thread.length,
latestTs: lastReply?.getTs(),
};
}
const bundle = rootEvent.getServerAggregatedRelation<IThreadBundledRelationship>(
RelationType.Thread,
);
if (bundle) {
return {
count: bundle.count,
latestTs: bundle.latest_event?.origin_server_ts,
};
}
return undefined;
};
/**
* True when `event` is a still-in-flight (local echo) reply belonging to the
* given thread root. Used to render the pending strip, since pending thread
* sends never enter the thread's timelineSet.
*/
export const isPendingThreadReply = (event: MatrixEvent, threadRootId: string): boolean => {
const { status } = event;
if (status !== EventStatus.SENDING && status !== EventStatus.NOT_SENT) return false;
// Prefer the SDK's resolved thread root id; fall back to the raw relation
// content for events the SDK hasn't associated with a thread yet.
if (event.threadRootId === threadRootId) return true;
const relation = event.getRelation();
return relation?.rel_type === RelationType.Thread && relation.event_id === threadRootId;
};
+150
View File
@@ -0,0 +1,150 @@
import { useCallback, useEffect, useState } from 'react';
import {
EventStatus,
EventTimeline,
MatrixClient,
MatrixEvent,
Room,
RoomEvent,
RoomEventHandlerMap,
Thread,
ThreadEvent,
} from 'matrix-js-sdk';
import { getLinkedTimelines } from '../RoomTimeline';
import { isPendingThreadReply } from './threadSummaryData';
/**
* Resolve (or bootstrap) the live {@link Thread} for a root event.
*
* Uses the existing thread when present, otherwise creates one via
* `room.createThread` the SDK then auto-fetches the thread's events via
* `/relations` and inserts the root at the top. If the root event isn't loaded
* locally the Thread handles the root fetch itself, so passing `undefined` is
* safe. Re-resolves when a matching thread later appears/updates on the room.
*/
export const useThreadInstance = (room: Room, threadRootId: string): Thread | undefined => {
const getInstance = useCallback((): Thread | undefined => {
const existing = room.getThread(threadRootId);
if (existing) return existing;
const rootEvent = room.findEventById(threadRootId);
return room.createThread(threadRootId, rootEvent, [], false) ?? undefined;
}, [room, threadRootId]);
const [thread, setThread] = useState<Thread | undefined>(getInstance);
useEffect(() => {
setThread(getInstance());
const handleThread: RoomEventHandlerMap[ThreadEvent.New] = (newThread) => {
if (newThread.id === threadRootId) setThread(newThread);
};
const handleThreadUpdate: RoomEventHandlerMap[ThreadEvent.Update] = (updatedThread) => {
if (updatedThread.id === threadRootId) setThread(updatedThread);
};
room.on(ThreadEvent.New, handleThread);
room.on(ThreadEvent.Update, handleThreadUpdate);
return () => {
room.removeListener(ThreadEvent.New, handleThread);
room.removeListener(ThreadEvent.Update, handleThreadUpdate);
};
}, [room, threadRootId, getInstance]);
return thread;
};
/**
* Build the ordered list of linked {@link EventTimeline}s for a thread's live
* timeline and track readiness (`thread.initialEventsFetched`). Subscribes to
* the Thread's re-emitted timeline events so callers repaginate/re-render as
* the thread fills in.
*/
export const useThreadLinkedTimelines = (
mx: MatrixClient,
thread: Thread,
): { timelines: EventTimeline[]; ready: boolean; refresh: () => void } => {
const [timelines, setTimelines] = useState<EventTimeline[]>(() =>
getLinkedTimelines(thread.liveTimeline),
);
const [ready, setReady] = useState<boolean>(() => thread.initialEventsFetched);
const refresh = useCallback(() => {
setTimelines(getLinkedTimelines(thread.liveTimeline));
setReady(thread.initialEventsFetched);
}, [thread]);
useEffect(() => {
refresh();
const handleTimeline = () => refresh();
// Thread re-emits RoomEvent.Timeline / RoomEvent.TimelineReset from its
// timelineSet, and fires ThreadEvent.Update as it (re)populates.
thread.on(RoomEvent.Timeline, handleTimeline);
thread.on(RoomEvent.TimelineReset, handleTimeline);
thread.on(ThreadEvent.Update, handleTimeline);
return () => {
thread.removeListener(RoomEvent.Timeline, handleTimeline);
thread.removeListener(RoomEvent.TimelineReset, handleTimeline);
thread.removeListener(ThreadEvent.Update, handleTimeline);
};
}, [thread, refresh]);
return { timelines, ready, refresh };
};
/**
* Track in-flight (local echo) replies for a thread.
*
* Pending thread sends never enter the thread's timelineSet (chronological
* pending ordering rejects them; `room.getPendingEvents()` THROWS in this
* mode). We instead watch `RoomEvent.LocalEchoUpdated` on the room and keep our
* own list of events that are pending replies to this thread and not yet in the
* thread timeline. When an event's remote echo arrives (status flips to SENT,
* or it lands in the thread) it drops out of the list.
*/
export const useThreadPendingEvents = (
room: Room,
threadRootId: string,
thread: Thread | undefined,
): MatrixEvent[] => {
const [pending, setPending] = useState<MatrixEvent[]>([]);
useEffect(() => {
setPending([]);
const handleLocalEcho: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (event) => {
const eventId = event.getId();
setPending((prev) => {
// Drop any previous entry for this event (same instance across the
// temp-id -> real-id transition, or matched by id).
const without = prev.filter((e) => e !== event && e.getId() !== eventId);
const alreadyInThread =
eventId !== undefined && thread?.findEventById(eventId) !== undefined;
// Keep a tracked event through the SENT window too: the /send response
// flips status to SENT before /sync delivers the event into the thread
// timeline — dropping it there would make the message flash out of view.
// It falls out on the next LocalEchoUpdated once findEventById sees it.
const trackedAndAwaitingSync =
event.status === EventStatus.SENT &&
prev.some((e) => e === event || (eventId !== undefined && e.getId() === eventId));
const stillPending =
!alreadyInThread && (isPendingThreadReply(event, threadRootId) || trackedAndAwaitingSync);
if (stillPending) return [...without, event];
return without.length === prev.length ? prev : without;
});
};
room.on(RoomEvent.LocalEchoUpdated, handleLocalEcho);
return () => {
room.removeListener(RoomEvent.LocalEchoUpdated, handleLocalEcho);
};
}, [room, threadRootId, thread]);
return pending;
};
// markThreadAsRead moved to ./threadReceipt (pure + unit-tested); re-exported
// here for existing import sites.
export { markThreadAsRead } from './threadReceipt';
+8 -2
View File
@@ -247,7 +247,6 @@ export function Search({ requestClose }: SearchProps) {
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: () => inputRef.current, initialFocus: () => inputRef.current,
returnFocusOnDeactivate: false,
allowOutsideClick: true, allowOutsideClick: true,
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
onDeactivate: requestClose, onDeactivate: requestClose,
@@ -257,7 +256,13 @@ export function Search({ requestClose }: SearchProps) {
}, },
}} }}
> >
<Modal size="400" style={{ maxHeight: toRem(400), borderRadius: config.radii.R500 }}> <Modal
size="400"
role="dialog"
aria-modal="true"
aria-label="Search"
style={{ maxHeight: toRem(400), borderRadius: config.radii.R500 }}
>
<Box <Box
shrink="No" shrink="No"
style={{ padding: config.space.S400, paddingBottom: 0 }} style={{ padding: config.space.S400, paddingBottom: 0 }}
@@ -270,6 +275,7 @@ export function Search({ requestClose }: SearchProps) {
radii="400" radii="400"
outlined outlined
placeholder="Search" placeholder="Search"
aria-label="Search rooms"
before={<Icon size="200" src={Icons.Search} />} before={<Icon size="200" src={Icons.Search} />}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleInputKeyDown} onKeyDown={handleInputKeyDown}
+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',
@@ -13,6 +13,7 @@ import {
} from '../../../components/AccountDataEditor'; } from '../../../components/AccountDataEditor';
import { copyToClipboard } from '../../../utils/dom'; import { copyToClipboard } from '../../../utils/dom';
import { AccountData } from './AccountData'; import { AccountData } from './AccountData';
import { CryptoDiagnostics } from '../developer/CryptoDiagnostics';
type DeveloperToolsProps = { type DeveloperToolsProps = {
requestClose: () => void; requestClose: () => void;
@@ -109,6 +110,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
/> />
</SequenceCard> </SequenceCard>
)} )}
{developerTools && <CryptoDiagnostics />}
</Box> </Box>
{developerTools && ( {developerTools && (
<AccountData <AccountData
@@ -0,0 +1,71 @@
import React, { useCallback } from 'react';
import { Badge, Box, Button, Text } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useForceUpdate } from '../../../hooks/useForceUpdate';
import { useInterval } from '../../../hooks/useInterval';
import { buildCryptoDiagReport, getCryptoDiagEntries } from '../../../utils/cryptoDiagLog';
// Lotus E2EE investigation kit — Crypto Diagnostics settings card.
// Mirrors the surrounding Developer Tools cards (see DevelopTools.tsx).
const REFRESH_MS = 1000;
export function CryptoDiagnostics() {
const mx = useMatrixClient();
// Re-render on a light interval so the live matched-entry count stays fresh
// while the settings pane is open.
const [, forceUpdate] = useForceUpdate();
useInterval(forceUpdate, REFRESH_MS);
const count = getCryptoDiagEntries().length;
const handleDownload = useCallback(() => {
const report = buildCryptoDiagReport(mx);
const blob = new Blob([report], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `lotus-crypto-diag-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
a.click();
URL.revokeObjectURL(url);
}, [mx]);
return (
<Box direction="Column" gap="100">
<Text size="L400">Crypto Diagnostics</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Crypto Diagnostics — captures E2EE error signatures this session"
description="Ring-buffers up to 200 matched console warnings/errors for the KE-1..KE-4 bug cluster. Local only — no network calls. The downloaded report includes the matched log lines as evidence."
after={
<Box alignItems="Center" gap="200" shrink="No">
<Badge variant={count > 0 ? 'Critical' : 'Secondary'} fill="Solid" radii="Pill">
<Text as="span" size="L400">
{count}
</Text>
</Badge>
<Button
onClick={handleDownload}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
>
<Text size="B300">Download report</Text>
</Button>
</Box>
}
/>
</SequenceCard>
</Box>
);
}
+50 -4
View File
@@ -102,9 +102,10 @@ import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing'; import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
import { useTauriUpdater } from '../../../hooks/useTauriUpdater'; import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
import { isTauri as isTauriEnv } 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,53 @@ 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>
);
}
/**
* P6-1 "Launch on login" toggle (desktop only). Renders nothing in the
* browser. Reads the current state from the `autostart` plugin on mount and
* enables/disables it via the plugin commands when flipped. Not backed by an
* atom the OS registration is the source of truth, mirrored into local state.
*/
function AutostartSetting() {
const [enabled, setEnabled] = useState(false);
useEffect(() => {
tauriInvoke()?.('plugin:autostart|is_enabled')
.then((value) => setEnabled(value === true))
.catch(() => undefined);
}, []);
const handleChange = (value: boolean) => {
invokeTauri(value ? 'plugin:autostart|enable' : 'plugin:autostart|disable');
setEnabled(value);
};
if (!isTauriEnv()) return null;
return (
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Launch on login"
description="Start Lotus Chat automatically when you sign in to your computer."
after={<Switch variant="Primary" value={enabled} onChange={handleChange} />}
/> />
</SequenceCard> </SequenceCard>
); );
@@ -443,6 +485,7 @@ function Appearance() {
</SequenceCard> </SequenceCard>
<DesktopChromeSetting /> <DesktopChromeSetting />
<AutostartSetting />
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile <SettingTile
@@ -531,6 +574,7 @@ function Appearance() {
Intensity: {nightLightOpacity}% Intensity: {nightLightOpacity}%
</Text> </Text>
<input <input
aria-label="Night light intensity"
type="range" type="range"
min={5} min={5}
max={80} max={80}
@@ -1663,6 +1707,7 @@ function Calls() {
<Text size="T200">{callDenoiseGateThreshold} dB</Text> <Text size="T200">{callDenoiseGateThreshold} dB</Text>
</Box> </Box>
<input <input
aria-label="Noise gate threshold"
type="range" type="range"
min="-100" min="-100"
max="0" max="0"
@@ -2010,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
@@ -2035,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
@@ -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>
@@ -0,0 +1,49 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const ShortcutList = style([
DefaultReset,
{
margin: 0,
},
]);
export const ShortcutRow = style({
padding: `${config.space.S100} 0`,
});
export const ShortcutTerm = style([
DefaultReset,
{
flexGrow: 1,
},
]);
export const ShortcutKeys = style([
DefaultReset,
{
display: 'flex',
alignItems: 'center',
gap: config.space.S100,
flexShrink: 0,
},
]);
export const Kbd = style([
DefaultReset,
{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: toRem(20),
padding: `0 ${config.space.S200}`,
height: toRem(24),
fontFamily: 'inherit',
fontSize: toRem(12),
lineHeight: toRem(24),
color: color.SurfaceVariant.OnContainer,
backgroundColor: color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
},
]);
@@ -0,0 +1,208 @@
import React, { useCallback } from 'react';
import FocusTrap from 'focus-trap-react';
import { atom, useAtom, useSetAtom } from 'jotai';
import {
Box,
Dialog,
Header,
Icon,
IconButton,
Icons,
Line,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Text,
config,
} from 'folds';
import { stopPropagation } from '../../utils/keyboard';
import { editableActiveElement } from '../../utils/dom';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useModalStyle } from '../../hooks/useModalStyle';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { isMacOS } from '../../utils/user-agent';
import { KeySymbol } from '../../utils/key-symbol';
import * as css from './KeyboardShortcutsDialog.css';
/** Global open-state for the keyboard shortcuts help dialog. */
export const keyboardShortcutsDialogAtom = atom<boolean>(false);
/** Read/control the keyboard shortcuts dialog open-state. */
export function useKeyboardShortcutsDialog() {
const [open, setOpen] = useAtom(keyboardShortcutsDialogAtom);
const openDialog = useCallback(() => setOpen(true), [setOpen]);
const closeDialog = useCallback(() => setOpen(false), [setOpen]);
return { open, openDialog, closeDialog };
}
/**
* Registers the global `Shift + /` (`?`) shortcut that opens the keyboard
* shortcuts help dialog. Ignored while the user is typing into an input,
* textarea or contenteditable so it never steals a literal `?` character.
*
* Mount once in the client shell (e.g. `ClientNonUIFeatures`).
*/
export function useKeyboardShortcutsTrigger() {
const setOpen = useSetAtom(keyboardShortcutsDialogAtom);
useKeyDown(
window,
useCallback(
(evt: KeyboardEvent) => {
// Never intercept `?` while the user is typing into a field/editor.
if (editableActiveElement()) return;
// `?` is produced by Shift + `/` on the common layouts.
if (evt.key === '?') {
evt.preventDefault();
// Stop RoomView's window-level "type any char → focus composer"
// handler from also firing — otherwise focus lands in the composer
// behind the dialog and Escape gets swallowed by the contenteditable.
evt.stopImmediatePropagation();
setOpen(true);
}
},
[setOpen],
),
);
}
type ShortcutRow = {
description: string;
keys: string[];
};
type ShortcutSection = {
title: string;
rows: ShortcutRow[];
};
function ShortcutKeys({ keys }: { keys: string[] }) {
return (
<Box as="dd" className={css.ShortcutKeys}>
{keys.map((key, index) => (
<kbd key={`${key}-${index}`} className={css.Kbd}>
{key}
</kbd>
))}
</Box>
);
}
/**
* Accessible keyboard shortcuts help dialog. Renders (as a modal overlay) only
* while `keyboardShortcutsDialogAtom` is `true`. Open it with the `?` shortcut
* (see `useKeyboardShortcutsTrigger`) or via `useKeyboardShortcutsDialog`.
*/
export function KeyboardShortcutsDialog() {
const { open, closeDialog } = useKeyboardShortcutsDialog();
const modalStyle = useModalStyle(480);
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
if (!open) return null;
const sections: ShortcutSection[] = [
{
title: 'General',
rows: [
{ description: 'Show keyboard shortcuts', keys: ['?'] },
{ description: 'Close open panel, otherwise mark room as read', keys: [KeySymbol.Escape] },
],
},
{
title: 'Composer',
rows: [
{ description: 'Focus the message composer', keys: ['Any character'] },
{
description: 'Send message',
keys: enterForNewline ? [modKey, 'Enter'] : ['Enter'],
},
{
description: 'Insert a new line',
keys: enterForNewline ? ['Enter'] : [KeySymbol.Shift, 'Enter'],
},
{ description: 'Send message (always)', keys: [modKey, 'Enter'] },
],
},
{
title: 'Messages',
rows: [
{ description: 'Reveal message actions (react, reply, more)', keys: ['Hover / focus'] },
],
},
];
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: closeDialog,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog
variant="Surface"
aria-labelledby="keyboard-shortcuts-dialog-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="keyboard-shortcuts-dialog-title">
Keyboard Shortcuts
</Text>
</Box>
<IconButton size="300" onClick={closeDialog} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Scroll size="300" hideTrack visibility="Hover">
<Box style={{ padding: config.space.S400 }} direction="Column" gap="500">
{sections.map((section, sectionIndex) => (
<Box key={section.title} direction="Column" gap="300">
{sectionIndex > 0 && <Line variant="Surface" size="300" />}
<Text size="L400" priority="400">
{section.title}
</Text>
<Box as="dl" className={css.ShortcutList} direction="Column">
{section.rows.map((row) => (
<Box
key={row.description}
className={css.ShortcutRow}
direction="Row"
alignItems="Center"
gap="200"
>
<Text as="dt" className={css.ShortcutTerm} size="T300">
{row.description}
</Text>
<ShortcutKeys keys={row.keys} />
</Box>
))}
</Box>
</Box>
))}
<Text size="T200" priority="300">
{enterForNewline
? 'Enter inserts a new line while “Enter for newline” is enabled in Settings.'
: 'Enter sends the message. Enable “Enter for newline” in Settings to swap this.'}
</Text>
</Box>
</Scroll>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
+1
View File
@@ -0,0 +1 @@
export * from './KeyboardShortcutsDialog';
@@ -13,6 +13,7 @@ import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { SpaceSettingsPage } from '../../state/spaceSettings'; import { SpaceSettingsPage } from '../../state/spaceSettings';
import { useRoom } from '../../hooks/useRoom'; import { useRoom } from '../../hooks/useRoom';
import { EmojisStickers } from '../common-settings/emojis-stickers'; import { EmojisStickers } from '../common-settings/emojis-stickers';
import { Soundboard } from '../common-settings/soundboard';
import { Members } from '../common-settings/members'; import { Members } from '../common-settings/members';
import { DeveloperTools } from '../common-settings/developer-tools'; import { DeveloperTools } from '../common-settings/developer-tools';
import { General } from './general'; import { General } from './general';
@@ -48,6 +49,11 @@ const BASE_SPACE_MENU_ITEMS: SpaceSettingsMenuItem[] = [
name: 'Emojis & Stickers', name: 'Emojis & Stickers',
icon: Icons.Smile, icon: Icons.Smile,
}, },
{
page: SpaceSettingsPage.SoundboardPage,
name: 'Soundboard',
icon: Icons.Bell,
},
{ {
page: SpaceSettingsPage.DeveloperToolsPage, page: SpaceSettingsPage.DeveloperToolsPage,
name: 'Developer Tools', name: 'Developer Tools',
@@ -190,6 +196,9 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
{activePage === SpaceSettingsPage.EmojisStickersPage && ( {activePage === SpaceSettingsPage.EmojisStickersPage && (
<EmojisStickers requestClose={handlePageRequestClose} /> <EmojisStickers requestClose={handlePageRequestClose} />
)} )}
{activePage === SpaceSettingsPage.SoundboardPage && (
<Soundboard requestClose={handlePageRequestClose} />
)}
{activePage === SpaceSettingsPage.DeveloperToolsPage && ( {activePage === SpaceSettingsPage.DeveloperToolsPage && (
<DeveloperTools requestClose={handlePageRequestClose} /> <DeveloperTools requestClose={handlePageRequestClose} />
)} )}
+41 -39
View File
@@ -4,60 +4,66 @@ 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;
} else if (speaking) {
// Voice detected on the published track — reset the silence timer.
silenceStart = null; silenceStart = null;
} else if (silenceStart === null) { } else if (silenceStart === null) {
// Mic is unmuted (effect only runs while unmuted) but silent — start the timer. // Mic is unmuted (effect only runs while unmuted) but silent — start the timer.
@@ -74,14 +80,10 @@ export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
silenceStart = null; silenceStart = null;
} }
}, CHECK_INTERVAL_MS); }, CHECK_INTERVAL_MS);
})
.catch(() => undefined);
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;
}) })
+86 -26
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');
+16 -1
View File
@@ -2,11 +2,26 @@ import { useEffect, useState } from 'react';
import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk'; import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { getRecentEmojis } from '../plugins/recent-emoji'; import { getRecentEmojis } from '../plugins/recent-emoji';
import { AccountDataEvent } from '../../types/matrix/accountData'; import { AccountDataEvent } from '../../types/matrix/accountData';
import { IEmoji } from '../plugins/emoji'; import { IEmoji, loadEmojiData } from '../plugins/emoji';
export const useRecentEmoji = (mx: MatrixClient, limit?: number): IEmoji[] => { export const useRecentEmoji = (mx: MatrixClient, limit?: number): IEmoji[] => {
const [recentEmoji, setRecentEmoji] = useState(() => getRecentEmojis(mx, limit)); const [recentEmoji, setRecentEmoji] = useState(() => getRecentEmojis(mx, limit));
// Recent emojis are resolved against the (now lazily loaded) emojibase data
// via getRecentEmojis. Recompute once loadEmojiData has populated it so the
// recent list fills in on first open.
useEffect(() => {
let alive = true;
loadEmojiData()
.then(() => {
if (alive) setRecentEmoji(getRecentEmojis(mx, limit));
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [mx, limit]);
useEffect(() => { useEffect(() => {
const handleAccountData = (event: MatrixEvent) => { const handleAccountData = (event: MatrixEvent) => {
if (event.getType() !== AccountDataEvent.ElementRecentEmoji) return; if (event.getType() !== AccountDataEvent.ElementRecentEmoji) return;

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