Compare commits

..

39 Commits

Author SHA1 Message Date
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
141 changed files with 5115 additions and 2708 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.
-166
View File
@@ -1,166 +0,0 @@
# Lotus Chat — Open Bugs & Technical Debt
**Only OPEN and awaiting-verification items live here.** Resolved findings
(fixed-and-verified, false-positives, won't-fix) have been removed to keep this
actionable — the full history is in git. Items fixed in code but not yet
verified in a real environment are in **Needs Verification** below and have
step-by-step checks in [`LOTUS_TESTING.md`](./LOTUS_TESTING.md).
> Design rules for any fix here: follow the **Native-Cinny Law** and **TDS
> Design Law** in [`LOTUS_TODO.md`](./LOTUS_TODO.md).
---
## ⚠️ Needs Verification — fixed in code, awaiting live testing
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
| ID | Item | File / area | Test |
| :--- | :------------------------------------------------------------------------------------- | :--------------------------------------------------- | :-------------------------------------------------------------------------------- |
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
| #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 |
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
| #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 |
| #12 | PiP "All muted" badge re-fixed (was firing on any single mute) | `hooks/useCallSpeakers.ts` | G1 |
| N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 |
| N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 |
| N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 |
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
| N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room |
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
| P3-8 | Thread Panel (side drawer, chips, threaded receipts, thread composer) | `features/room/thread/*`, `RoomTimeline/RoomInput` | 6-step checklist in LOTUS_TODO §P3-8 |
| P4-4 | KaTeX math (`$…$`, `$$…$$`, data-mx-maths; lazy chunk) | `utils/mathParse.ts`, `components/math/` | send `$x^2$`, `$$\int f$$`, `$5 and $10` (stays text), math inside code block (stays text) |
| P4-8 | Encrypted-search cache (opt-in toggle, clear button, logout wipe) | `utils/searchCache.ts`, message-search | enable in search panel → search → reload → coverage persists; logout wipes |
| N97a | Session blob migration + cross-tab logout sync | `state/sessions.ts`, `useSessionSync` | login on old build → new build migrates; logout in tab A → tab B drops to auth |
| P4-1 | Slack-style thread notifications (participating default, All/Mentions/Mute, badge math) | `utils/threadNotifications.ts`, `ClientNonUIFeatures`, `roomToUnread` | 6-step checklist in LOTUS_TODO §P4-1 |
**Verified working in live testing (2026-06):** A2, B1B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.
---
## 🧩 Element Call source-level items — now actionable via the fork
> 🔱 **[EC-FORK]** **UPDATE 2026-06-30: Phase 2 IMPLEMENTED.** We own and
> self-build Element Call (`LotusGuild/element-call` →
> `@lotusguild/element-call-embedded@0.20.1-lotus.1`, cinny wired). A5/A6/A7
> below are **fixed in the fork** — they are now ⚠️ awaiting **live
> verification** (`LOTUS_TESTING.md` §D2), not open work. See
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md) §10. Delete each
> row once verified live.
The in-call participant grid is rendered **inside EC's app** — now editable source
(previously a prebuilt npm bundle we could only style around). Status of the items
from testing:
- **A5 — "Focus camera": ⚠️ FIXED in fork, awaiting verify (D2-3).** cinny now
sends an `io.lotus.focus_participant` widget action that pins a participant in
EC's layout (coexisting with / overriding the screenshare spotlight); the old
`.click()`-the-tile DOM hack in `CallControl.ts` is deleted.
- **A6 — avatar decorations in-call: ⚠️ FIXED in fork, awaiting verify (D2-4).**
cinny pushes `io.lotus.decorations` (per-user APNG URLs) and the fork renders
them on EC's participant video-tile avatars — not just our pre-join lobby roster.
- **A7 — mic dead after EC's "Reconnect": ⚠️ FIXED in fork, awaiting verify
(D2-1).** Denoise moved into EC's mic-capture/publish pipeline as a first-class
LiveKit `TrackProcessor` (flag `lotusDenoiseSource=1`); EC re-runs it on every
(re)publish, so reconnects keep denoise alive natively. The build-time
`getUserMedia`/`index.html` injection (the root cause) is removed. **Highest
blast radius — everyone's mic; verify D2-1 carefully.**
---
## 🔴 Open — Actionable
### 🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED · 👤 SENIOR ENGINEER
> 🧰 **Investigation kit ready (2026-07):** [`LOTUS_E2EE_INVESTIGATION.md`](./LOTUS_E2EE_INVESTIGATION.md)
> has the per-KE capture runbook (console signatures, synapse-side queries, the
> KE-1→KE-2 causality decision tree, ranked remediations), and the client now
> ships a **Crypto Diagnostics** capture helper (Settings) — run it during the
> next affected call and download the report before starting any fix.
> **Observed live in prod 2026-06-30** on `chat.lotusguild.org` during a 2-person
> **Element Call** (E2EE enabled). These span **client rust-crypto (via
> `matrix-js-sdk@41.6.0-rc.0`) ↔ Synapse ↔ Element Call's MatrixRTC E2EE** and are
> very likely **interrelated** (see KE-1 → KE-2). Do **not** spot-fix — they need
> a dedicated cross-system planning session with the homeserver owner. Capture
> full client console + a synapse-side trace for the same call before starting.
> **None of these are caused by the EC fork work** (the issues reproduce on the
> old build; the local mic/denoise path is unrelated to key distribution).
- **KE-1 — One-time-key (OTK) upload conflict storm (CRITICAL, root-cause candidate).**
`POST /_matrix/client/v3/keys/upload` returns `400 M_UNKNOWN: One time key
signed_curve25519:AAAAAAAAAGQ already exists. Old key: {…} new key: {…}`
firing **continuously** (many/sec). The client repeatedly tries to publish an
OTK at a key id the server already holds **with a different value**, i.e. the
rust-crypto key store and Synapse have **diverged OTK state**. Impact: floods
the crypto outgoing-request loop and is the prime suspect for the downstream
missing-key failures (no fresh OTKs ⇒ no new Olm sessions ⇒ undecryptable
to-device key events). _Investigate:_ device/key-store reset-or-restore
mismatch, OTK id-counter desync, RC-SDK (`41.6.0-rc.0`) regression, or a
Synapse OTK bug. Repro signature: grep console for `already exists`.
**Extreme — planning session.**
- **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 — 561+ tests across 65+ modules, a hard CI gate.** `npm test` runs Node's built-in runner via `tsx` (not vitest — Vite 8 is ahead of vitest's range) and **blocks the build job on failure**. Broad pure-logic coverage: utils (common, regex, sanitize/XSS, time, matrix, matrix-uia, mimeTypes, sort, accentColor, findAndReplace, AsyncSearch, ASCIILexicalTable, keyboard, room, matrix-crypto, featureCheck, syntaxHighlight, imageCompression, user-agent, callSounds), state (settings, sessions, recentSearches, upload, typingMembers, lists, room-list, toast, scheduledMessages, backupRestore, callEmbed/callPreferences, spaceRooms, …), plugins (matrix-to, call/utils, via-servers, bad-words, recent-emoji, custom-emoji, markdown block/inline/utils), OIDC (cs-api, useParsedLoginFlows, oidcState), lotus/avatarDecorations, message-search, search filters. Prevention work has caught + fixed **4 real bugs** (`findAndReplace` infinite-loop; `getSettings` crash-on-load when storage is blocked; `isMacOS` never matching modern Macs; `isMLDenoiseSupported` throwing `ReferenceError` instead of returning false on browsers lacking the `AudioWorkletNode` binding). **Next:** component/integration tests (the untestable-under-tsx DOM/React surface).
- **Extensive `as any` casts** across `src/` — gradual typing cleanup.
- **`types/matrix/` mirrors SDK types** instead of importing them — drift risk.
- ~~**Hardcoded CDN URL** should move to an env var~~ — **done:** `avatarDecorations.ts` already honors a `VITE_DECORATION_CDN` env override (lines 14-16); the in-repo literal is only the default. Nothing left.
- **`patch-folds.mjs` edits `node_modules` directly** — consider `patch-package`.
- **Infra docs:** `contrib/nginx` lacks security headers (HSTS/CSP) + uses rewrites over `try_files`; `contrib/caddy` has a placeholder path. CI/CD (`prod-deploy.yml`): sequential deploy, aggressive 1-min Netlify timeout, `package-manager-cache: false`.
- **README:** keep the fork-sync version + logo path current. (`CONTRIBUTING.md` is intentionally left as upstream Cinny's — not a Lotus concern.)
- **Architecture notes (low priority):** deep `features/` + `hooks/` nesting, many small coupled hooks, possible dead CSS/components, `SpacingVariant` / `DropTarget` recipe simplification.
- **Git workflow (forward-looking):** keep commits scoped — past monolithic "fix all bugs" commits and inconsistent prefixes hurt `git bisect`.
### Big Projects
- ~~**#5 — Seasonal themes & chat-background redesign.**~~ **DONE (2026-06/07):** 11 seasonal/holiday overlays shipped and later toned down + given a settings preview grid; all 19 chat backgrounds redesigned (Carbon + Aurora kept per user preference), one design sprint each, GPU-friendly CSS with `prefers-reduced-motion` + pause toggle. Remaining polish rides normal bug flow, not a "big project."
-402
View File
@@ -1,402 +0,0 @@
# Lotus Chat — E2EE Investigation Runbook (KE-1 → KE-4)
> **Scope:** evidence-gathering only. Do **not** apply fixes from this document
> without a cross-system planning session (client rust-crypto ↔ Synapse ↔
> Element Call MatrixRTC). Symptom source: `LOTUS_BUGS.md` §"Encryption / E2EE"
> (KE-1..KE-4), observed live 2026-06-30 on `chat.lotusguild.org` during a
> 2-person Element Call.
>
> **Client:** Lotus Cinny fork, `matrix-js-sdk@41.6.0-rc.0`, rust-crypto.
> **Server:** Synapse `1.155.0` on **LXC 151** (`10.10.10.29`), PostgreSQL 17.9
> on **LXC 109** (`10.10.10.44`). Facts below are copy-pasteable against that
> deployment (paths/IPs from `/root/code/matrix/README.md`).
---
## 0. Deployment facts used by this runbook
From the matrix infra README (`/root/code/matrix/README.md`):
| Thing | Value |
|-------|-------|
| Synapse host | LXC **151**, `10.10.10.29` (Synapse 1.155.0) |
| Synapse log | `/var/log/matrix-synapse/homeserver.log` |
| Synapse config | `/etc/matrix-synapse/homeserver.yaml` (+ `conf.d/`) |
| Synapse HTTP | `10.10.10.29:8008` |
| PostgreSQL host | LXC **109**, `10.10.10.44` (PG 17.9), db `synapse` |
| synapse-admin UI | `http://10.10.10.29:8080` |
| LiveKit / lk-jwt / guard | LXC 151: LiveKit `:7880/:7881`, guard `:8070`, lk-jwt `:8071` |
| SSH path to Synapse | `ssh root@10.10.10.4` then `pct enter 151` |
| SSH path to PG | `ssh root@10.10.10.4` then `pct enter 109` |
**Getting a psql shell** (run on LXC 109, or from 151 over the network):
```bash
# On LXC 109:
sudo -u postgres psql synapse
# From LXC 151 (pg_hba allows 10.10.10.29):
psql "host=10.10.10.44 user=synapse dbname=synapse"
```
**Tailing Synapse during a call** (on LXC 151):
```bash
tail -F /var/log/matrix-synapse/homeserver.log | tee /tmp/lotus-call-$(date +%s).log
```
Synapse E2EE/to-device logging is chatty at `INFO`; if a category is silent,
temporarily raise it in `/etc/matrix-synapse/conf.d/log.yaml` (or the
`log_config` file referenced by `homeserver.yaml`):
```yaml
loggers:
synapse.rest.client.keys: { level: DEBUG }
synapse.handlers.e2e_keys: { level: DEBUG }
synapse.storage.databases.main.end_to_end_keys: { level: DEBUG }
synapse.handlers.devicemessage: { level: DEBUG } # to-device
```
Then `systemctl reload matrix-synapse` (reload re-reads log config without a
full restart). **Revert to `INFO` after the capture** — DEBUG is very verbose.
---
## 1. Per-KE evidence matrix
Client greps assume Chrome/Firefox DevTools console (filter box or, better,
"Preserve log" + save-as). The **Crypto Diagnostics** card (Settings →
Developer Tools) auto-captures every signature below into a downloadable JSON —
use it as the primary client artifact and DevTools as the raw backup.
### KE-1 — OTK upload conflict storm (root-cause candidate)
- **Console signature (grep):**
- `already exists`
- full: `POST /_matrix/client/v3/keys/upload … 400 M_UNKNOWN: One time key signed_curve25519:<id> already exists. Old key: {…} new key: {…}`
- **Capture client-side:**
- Timestamp (first occurrence + rate — "N/sec"), **device id**, **user id**.
- DevTools → **Network** → filter `keys/upload`: for a failing call save the
**request body** (the `one_time_keys` map — note the exact `signed_curve25519:<id>`)
and the **response body** (the `Old key` / `new key` JSON). This diff is the
smoking gun: same key-id, different value ⇒ store vs server divergence.
- Whether it self-heals or loops forever (KE-1 loops).
- **Synapse log grep (LXC 151):**
```bash
grep -E "keys/upload|One time key .* already exists|OneTimeKey" \
/var/log/matrix-synapse/homeserver.log | grep "<user_id>"
```
- **Synapse SQL (LXC 109) — what the server thinks it holds:**
```sql
-- Current OTK inventory for the device (compare key_id set against the
-- request body the client keeps retrying).
SELECT algorithm, key_id, ts_added_ms
FROM e2e_one_time_keys_json
WHERE user_id = '@user:matrix.lotusguild.org'
AND device_id = '<DEVICE_ID>'
ORDER BY algorithm, key_id;
-- Server's advertised counts (this is what /sync tells the client it has,
-- and drives whether the client decides to upload more).
SELECT algorithm, count(*) FROM e2e_one_time_keys_json
WHERE user_id = '@user:matrix.lotusguild.org' AND device_id = '<DEVICE_ID>'
GROUP BY algorithm;
-- Fallback key state (used when OTKs are exhausted).
SELECT algorithm, key_id, used, ts_added_ms
FROM e2e_fallback_keys_json
WHERE user_id = '@user:matrix.lotusguild.org' AND device_id = '<DEVICE_ID>';
```
> Table names are Synapse 1.155 (`e2e_one_time_keys_json`,
> `e2e_fallback_keys_json`). If a name is absent, list with `\dt e2e*` in psql.
- **Confirms:** if the offending `key_id` (from the 400) is **present** in
`e2e_one_time_keys_json` with a **different** stored value than the client's
request body → OTK state has diverged (rust-crypto store vs Synapse). That is
the KE-1 root condition.
### KE-2 — EC media keys not arriving/decrypting (audio/video cutouts)
- **Console signature (grep):**
- `MissingKey`
- `missing key at index` (e.g. `MissingKey: missing key at index N for participant @user`)
- `key set not found`
- `io.element.call.encryption_keys` (rust-crypto: `WARN … Received an unexpected encrypted to-device event … event_type="io.element.call.encryption_keys"`)
- **Capture client-side:**
- Timestamp windows where a participant's audio/video cut out, and the
`@participant` + `index N` from the message.
- The `io.element.call.encryption_keys` warnings (these are the media-key
to-device events failing to decrypt) with their timestamps.
- Own device id + user id (to correlate with the sender's Olm session).
- **Synapse log grep (LXC 151) — to-device delivery of the media keys:**
```bash
grep -E "io.element.call.encryption_keys|m.room.encrypted|/sendToDevice|to_device" \
/var/log/matrix-synapse/homeserver.log | grep -E "<user_id>|<participant_id>"
```
- **Synapse SQL (LXC 109) — undelivered / queued to-device events:**
```sql
-- Backlog of to-device messages queued for the affected device. A growing
-- count here = the HS has the media-key events but the device isn't draining
-- them via /sync (or they were sent to a stale device id).
SELECT user_id, device_id, count(*) AS pending
FROM device_inbox
WHERE user_id = '@user:matrix.lotusguild.org'
GROUP BY user_id, device_id;
-- Cross-check the device id the sender is targeting actually exists / is current.
SELECT device_id, display_name, last_seen, ts
FROM devices WHERE user_id = '@user:matrix.lotusguild.org';
```
- **Confirms:** to-device events present but undecryptable (client shows the
`io.element.call.encryption_keys` "unexpected encrypted" warning) ⇒ there is
**no valid Olm session** to decrypt them — the expected downstream of KE-1.
### KE-3 — Timeline decryption error: missing `algorithm` field
- **Console signature (grep):**
- `DecryptionError`
- full: `Error decrypting event (… type=m.room.encrypted …): DecryptionError[msg: missing field 'algorithm' at line 1 column 138 …]`
- **Capture client-side:**
- The **event id** (`$SASBBzoqj…` was one) and the **room id**.
- Pull the raw event JSON via DevTools or the Developer Tools account-data/event
viewer, or directly:
```
GET https://matrix.lotusguild.org/_matrix/client/v3/rooms/<roomId>/event/<eventId>
```
Inspect `content` — confirm whether `algorithm` (should be
`m.megolm.v1.aes-sha2`) is truly absent vs a serialization mismatch.
- **Synapse log grep (LXC 151):**
```bash
grep -E "<eventId>" /var/log/matrix-synapse/homeserver.log
```
- **Synapse SQL (LXC 109) — the stored event content as the HS holds it:**
```sql
SELECT ej.event_id, e.type, e.sender, e.origin_server_ts,
(ej.json::json -> 'content' -> 'algorithm') AS algorithm
FROM event_json ej
JOIN events e USING (event_id)
WHERE ej.event_id = '$SASBBzoqj...';
```
- **Confirms:** if the stored `content.algorithm` is **NULL/absent** on the HS →
a malformed/legacy event was persisted (sender-side or federation). If it is
**present** on the HS but the client throws → an RC-SDK deserialization bug.
This distinction decides whether KE-3 is a data problem or a client problem.
### KE-4 — MatrixRTC delayed-event / membership timeouts
- **Console signature (grep):**
- `update_delayed_event` (`org.matrix.msc4157.update_delayed_event`)
- `delayed event` / `Restart delayed event timed out`
- full: `[MembershipManager] Network local timeout error while sending event, immediate retry … AbortError: Restart delayed event timed out before the HS responded`
- **Capture client-side:**
- Timestamps of each timeout; whether they correlate with call join/leave or
with general sync slowness.
- DevTools → Network: the `…/delayed_events…` / `update_delayed_event`
requests — their **HTTP status and latency** (timed-out vs slow-200).
- **Synapse log grep (LXC 151):**
```bash
grep -E "delayed_event|msc4140|msc4157|update_delayed" \
/var/log/matrix-synapse/homeserver.log | grep "<user_id>"
# HS responsiveness in the same window (KE-4 may be pure latency):
grep -E "Processed request|/sync" /var/log/matrix-synapse/homeserver.log | tail -50
```
- **Server-side corroboration (Grafana, `dashboard.lotusguild.org`):** Synapse
p99 response time (excl. `/sync`), event-processing lag, DB query latency for
the call window. High latency here ⇒ KE-4 is (partly) homeserver
responsiveness, not a client bug.
- **Confirms:** timeouts that line up with HS latency spikes → reliability/load;
timeouts with a healthy HS → client MembershipManager retry logic.
---
## 2. Causality hypothesis
```
KE-1 OTK upload conflict storm
(rust-crypto store ↔ Synapse OTK state DIVERGED; server rejects re-uploads)
│ no fresh OTKs can be published/claimed
No new Olm (1:1) sessions can be established with this device
KE-2 EC media-key to-device events (io.element.call.encryption_keys)
arrive but cannot be decrypted ⇒ MissingKey at index N
⇒ friend's audio/video cuts out
```
KE-3 (missing `algorithm`) and KE-4 (delayed-event timeouts) are **likely
independent** of the KE-1→KE-2 chain: KE-3 is a decode/serialization path,
KE-4 is a MatrixRTC-vs-HS reliability path. Confirm/refute independence with the
decision tree below.
### Decision tree — which capture confirms/refutes each link
```
Q1. Does the KE-1 offending key_id from the 400 response exist in
e2e_one_time_keys_json with a DIFFERENT value than the client request body?
├─ YES → OTK divergence CONFIRMED (KE-1 root). Go to Q2.
└─ NO → Not divergence. Check: are OTK counts at 0 with fallback key `used=true`?
├─ YES → OTK exhaustion, not divergence — different remediation.
└─ NO → Suspect RC-SDK 41.6.0-rc.0 upload-loop regression (see §3).
Q2. During the same call, are io.element.call.encryption_keys to-device events
present in device_inbox / Synapse to-device logs for our device id?
├─ YES + client shows "unexpected encrypted"/MissingKey
│ → KE-1 ⇒ KE-2 LINK CONFIRMED (events delivered, no Olm session to open them).
├─ YES + client decrypts fine, but LiveKit still silent
│ → KE-2 is downstream of LiveKit/SFU, NOT KE-1. Decouple from crypto.
└─ NO (nothing queued/targeted our device)
→ media keys never sent to us: stale device id / membership (see KE-4)
→ KE-2 is a device-targeting problem, weakly linked to KE-1.
Q3. KE-3: is content.algorithm NULL in event_json on the HS?
├─ YES → malformed persisted event (sender/federation). Independent of KE-1.
└─ NO → client-side RC-SDK deserialization bug. Independent of KE-1.
Q4. KE-4: do delayed-event timeouts coincide with Synapse p99 latency spikes
(Grafana) in the same minute?
├─ YES → homeserver responsiveness/load. Independent of KE-1..KE-3.
└─ NO → client MembershipManager retry behavior. Independent.
```
---
## 3. Ranked remediation options (with blast radius)
> Ordered least-destructive → most-destructive. **Do not run any of these as a
> "fix" before the planning session** — they are listed so evidence collection
> can be paired with a recovery plan. Confirm the root condition (Q1/Q2) first.
1. **Per-device logout + re-login of the affected device** *(lowest blast radius)*
- **What:** log the one glitching device out and back in. Forces a fresh
device id, fresh device keys, and a clean OTK batch — sidesteps a diverged
OTK store without touching other sessions.
- **Blast radius:** that device only. Other sessions/devices untouched.
- **Cost:** the new device must be re-verified (cross-signing) and will need
to restore room keys from **key backup** to read old encrypted history.
- **Confirms/uses:** if KE-1 stops after this, OTK-store divergence (Q1) was
the cause.
2. **Client crypto-store reset (`clearLoginData` path)** *(medium)*
- **What:** `clearLoginData()` in `src/client/initMatrix.ts` (coordinator's
file — do not edit) **deletes ALL IndexedDB databases** (incl.
`web-sync-store` and the rust-crypto store `crypto-store`), **unregisters
service workers**, **clears all Cache Storage**, and **`localStorage.clear()`**,
then reloads. `clearCacheAndReload()` is lighter — it only calls
`mx.store.deleteAllData()` (sync cache) and does **not** wipe crypto.
- **Blast radius:** this browser profile only, but total: you are logged out,
lose all cached sync state, drafts, settings, and **the local
megolm/room-key store**.
- **⚠️ Message-history / backup implication:** wiping `crypto-store` destroys
locally-held **room keys (megolm inbound sessions)**. Any history **not
backed up to server-side Key Backup** becomes **permanently undecryptable
on this device**. Before doing this: verify Key Backup is enabled and the
recovery key / passphrase is available (Settings → Security), or the user
loses readable history. Cross-signing must be re-established too.
- **Use when:** the rust-crypto store itself is corrupt/diverged and option 1
didn't clear it.
3. **SDK pin change off the RC** *(medium — codebase change, needs rebuild)*
- **Current pin:** `package.json` → `"matrix-js-sdk": "41.6.0-rc.0"` (a
release candidate).
- **Finding (npm / GitHub changelog, checked 2026-07):** stable **`41.6.0`**
was released **2026-05-26**. Its only changelog line is *"Throw sane error
on completeLoginOnNewDevice IdP rejection"* — **no OTK / keys-upload / Olm /
to-device fix** relative to the RC. Later stable lines exist
(`41.7.0`, `41.8.0`; `41.7.0-rc.3` / `41.9.0-rc.0` seen as pre-releases).
Nearby crypto-relevant entries: `41.5.0` *"Enable encrypted history sharing
by default"*; `41.4.0` key-backup handling. **No changelog entry directly
addresses the KE-1 OTK-conflict symptom** in the immediate range — so
moving RC→`41.6.0` stable is a low-risk hygiene step but is **not expected
to fix KE-1 by itself**. Before pinning, re-read the CHANGELOG for any
`41.7.x`/`41.8.x` OTK/one-time-key/olm entry that post-dates this note.
- **Blast radius:** all users after the next `cinny-build.sh` deploy. Test the
rust-crypto IndexedDB schema — a downgrade triggers the `IDB_VERSION_CONFLICT`
path in `initMatrix.ts`.
4. **Synapse-side OTK row surgery** *(LAST RESORT — highest danger)*
- **What:** deleting/rewriting rows in `e2e_one_time_keys_json` (and/or
`e2e_fallback_keys_json`, `device_inbox`) for the affected device to force
the client to re-upload a clean batch.
- **⚠️ Danger:** direct writes to Synapse crypto tables can **desync every
device of that user**, break Olm sessions **for everyone who has claimed one
of those keys**, and are easy to get wrong (wrong `key_id`, cache not
invalidated). Synapse caches OTK counts — a raw DELETE without a restart can
leave the advertised count wrong, **worsening** the KE-1 loop.
- **Guardrails if ever done (planning session + HS owner only):** full
`pg_dump` of `synapse` first; do it during **zero active calls**; delete only
the exact diverged `key_id` for the exact `device_id`; `systemctl restart
matrix-synapse` to flush caches; then log the device out/in (option 1) so it
republishes. **Never** run this speculatively.
---
## 4. "Capture session" checklist (run during the next call)
Do these **in order**. Aim to have client + server capturing the **same call**.
1. **Prep server tail (LXC 151):** SSH in, start
`tail -F /var/log/matrix-synapse/homeserver.log | tee /tmp/lotus-call-$(date +%s).log`.
(Optionally raise the `synapse.rest.client.keys` / `handlers.e2e_keys` /
`handlers.devicemessage` loggers to DEBUG per §0 and `systemctl reload
matrix-synapse` — remember to revert after.)
2. **Prep client:** open Lotus Chat → Settings → Developer Tools → **enable
Developer Tools** so the **Crypto Diagnostics** card is visible; note its
entry count starts at (or reset by reload to) 0.
3. **Open DevTools** (F12) → Console: enable **Preserve log**; Network tab:
enable **Preserve log** + **Record**. Note your **device id** and **user id**
(Settings → Devices / Developer Tools → Copy access token page shows ids).
4. **Note wall-clock start time** (ISO/UTC) on both machines so logs align.
5. **Join the Element Call** with the second participant; reproduce the fault
(wait for the audio/video cutouts and let KE-1 storm run ~3060s).
6. **When a fault occurs, note the wall-clock timestamp** and which symptom
(audio cut / video freeze / etc.) — this bounds the log window.
7. **Client artifacts:** in the Crypto Diagnostics card click **Download report**
(`lotus-crypto-diag-<ts>.json`); in DevTools Network, save the failing
`keys/upload` request+response (right-click → Save/Copy), and the raw HAR
(Network → Save all as HAR) for the call window.
8. **Grab KE-3 event id / KE-2 participant+index** from the console (or the
diag JSON `entries[]`) for the SQL lookups.
9. **Server artifacts:** stop the tail; run the per-KE greps and SQL from §1
against the noted device id / user id / event id, saving output alongside the
client JSON. Screenshot the Grafana Synapse latency panels for the window
(for KE-4).
10. **Bundle & label:** put client JSON + HAR + server log slice + SQL output in
one folder named with the call's UTC start time. Revert any DEBUG log config
(`systemctl reload matrix-synapse`). Hand off to the planning session — **do
not apply §3 remediations yet.**
---
## 5. Client diagnostics helper (this kit)
- **`src/app/utils/cryptoDiagLog.ts`** — capture-only console instrumentation.
- `installCryptoDiagLog()` — idempotent; wraps `console.warn`/`console.error`
with pass-through wrappers (originals always called) that ring-buffer (max
**200**) any line matching the KE signatures. No network, no timers.
- `getCryptoDiagEntries()` — snapshot copy of the buffer (`{ ts, level, ke,
signature, message }`, most-recent-last).
- `buildCryptoDiagReport(mx)` — JSON string: SDK version, device id, user id,
sync state, `cryptoReady` (`mx.getCrypto()` presence), per-KE counts, and the
entry buffer. No tokens/PII beyond those ids; captured log lines are retained
verbatim as evidence.
- **Signatures → KE mapping:** `already exists`→KE-1; `missing key at index` /
`io.element.call.encryption_keys` / `MissingKey`→KE-2; `DecryptionError`→KE-3;
`update_delayed_event` / `delayed event`→KE-4.
- **`src/app/features/settings/developer/CryptoDiagnostics.tsx`** — a folds
`SequenceCard`/`SettingTile` card (mirrors `developer-tools/DevelopTools.tsx`)
showing the live matched-entry count (Badge) and a **Download report** button
(Blob → `lotus-crypto-diag-<ts>.json`, same download idiom as
`room-settings/ExportRoomHistory.tsx`).
### Recommended mount points (coordinator)
- **Install call:** call `installCryptoDiagLog()` **as early as possible during
boot** so it captures crypto errors from first sync — ideally at the top of
the client entry module or inside `ClientRoot` before/around `initClient`
(e.g. `src/app/pages/client/ClientRoot.tsx`). It is idempotent, side-effect
only, and needs no `mx`, so a module-scope call at app entry is safe. (Do
**not** put it in `initMatrix.ts` — that file is off-limits.)
- **Settings card:** render `<CryptoDiagnostics />` inside the Developer Tools
page — in `src/app/features/settings/developer-tools/DevelopTools.tsx`, add it
to the `Box direction="Column" gap="700"` list (guarded by the existing
`developerTools` flag), right after the "Access Token" card. It pulls `mx`
from `useMatrixClient()` itself, so it just needs to be placed in the tree.
+31 -2
View File
@@ -330,7 +330,7 @@ Users can set a custom background color for `@mention` chips that highlight thei
> pre-built npm bundle. Several in-call behaviors below are now first-class > pre-built npm bundle. Several in-call behaviors below are now first-class
> source changes rather than DOM/widget hacks. Background, plan, and the Phase-2 > source changes rather than DOM/widget hacks. Background, plan, and the Phase-2
> work list are in > work list are in
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md). > the Element Call fork reference in [`LOTUS_TODO.md`](./LOTUS_TODO.md).
### Element Call — Self-Built Fork (`0.20.1-lotus.1`) ### Element Call — Self-Built Fork (`0.20.1-lotus.1`)
@@ -905,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.
@@ -1179,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
@@ -1215,7 +1235,7 @@ The session persists as ONE atomic `cinny_session_v1` JSON write (previously ~10
### Crypto Diagnostics (E2EE investigation kit) ### Crypto Diagnostics (E2EE investigation kit)
**Settings → Developer Tools → Crypto Diagnostics**: a capture-only ring buffer (max 200) hooks `console.warn/error` for E2EE failure signatures (OTK upload conflicts, missing call media keys, decryption errors, delayed-event timeouts) and downloads a JSON report — the evidence input for the KE-1→4 investigation. Companion runbook: [`LOTUS_E2EE_INVESTIGATION.md`](./LOTUS_E2EE_INVESTIGATION.md). `utils/cryptoDiagLog.ts`, `features/settings/developer/CryptoDiagnostics.tsx`. **Settings → Developer Tools → Crypto Diagnostics**: a capture-only ring buffer (max 200) hooks `console.warn/error` for E2EE failure signatures (OTK upload conflicts, missing call media keys, decryption errors, delayed-event timeouts) and downloads a JSON report — the evidence input for the KE-1→4 investigation. Companion diagnosis: the Encryption / E2EE section of [`LOTUS_TODO.md`](./LOTUS_TODO.md). `utils/cryptoDiagLog.ts`, `features/settings/developer/CryptoDiagnostics.tsx`.
--- ---
@@ -1255,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.
---
+300 -10
View File
@@ -33,9 +33,97 @@ Completed features are documented in [LOTUS_FEATURES.md](./LOTUS_FEATURES.md).
--- ---
## 🔎 Audit findings — Wave 1 (2026-07)
Bug-hunt of the Tier-1 high-risk areas (notifications/unread/receipts, threads, calls host-side, Element Call fork) by 4 parallel deep-audit agents. `[T#]`=threads, `[N#]`=notifications, `[C#]`=calls host, `[EC#]`=fork.
**✅ FIXED (2026-07):** all 🔴 (T1, N1, N2); web 🟠 (T2, T4, N3, N4); calls-host 🟠 (C-H1, C-H2, C-H3) + 🟡 (C-M3, C-M4, C-M5, C-M6, C-L4, C-L6) — reviewed (the C-H2 AFK rewrite + C-H1 rejoin guard verified). EC-fork 🟡 (EC1EC6) fixed on `element-call:lotus` (**needs a republish**). Web + calls gate-green (677 tests + `threadReceipt.test.ts` locking the T1 regression). **Still open (low tail):** C-M1/C-M2 (DOM-hack fragility — retire via the fork), C-L1/L2/L3/L5/L7/L8, and N5, N6, T5, T6, T7.
### 🔴 High — data-integrity / broken core UX
| ID | Defect | File:line | Repro | Fix sketch |
| :----- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :--------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
| **T1** | **`markThreadAsRead` corrupts the room's MAIN read marker** — sends `sendReadReceipt(latestEvent, Read)` with `unthreaded` omitted (→false); `latestEvent` is the thread **root** when replies aren't loaded, so the SDK writes a `thread_id:"main"` receipt at the old root → `getEventReadUpTo` jumps back → room re-lights. **Same class as the P6 regression, second path.** CONFIRMED. | `features/room/thread/useThread.ts:172` (fired on mount by `ThreadPanel.tsx:145-167`) | Open a thread whose root is an older message (or any thread before replies load) → previously-read main messages resurface as unread; room dot won't clear. **Likely a live cause of the current "unread won't clear."** | Mirror `notifications.ts`: use `thread.lastReply()` and bail if null, or `if (latestEvent.getId() === thread.id) return;` — only receipt a real reply. |
| **N1** | Favicon + tab-title unread count **double-counts space aggregates**`putUnreadInfo` writes an entry for the leaf room AND every ancestor space; `FaviconUpdater` sums **all** map entries. CONFIRMED. | `pages/client/ClientNonUIFeatures.tsx:97-103` (FaviconUpdater) vs `state/room/roomToUnread.ts:67-74` | A room w/ 3 highlights nested in Space→Subspace → tab title reads `(9)` not `(3)`. Orphan-only users unaffected (why it hid). | Sum only leaf entries (skip aggregates: leaves have `from:null`), or compute from `mx.getRooms()`. |
| **N2** | **Missed notifications/sounds** — the `deliverNotification` dedupe cache (`unreadCacheRef`) is written only on incoming messages, never cleared on read. `unreadInfo.total` resets to 0 on read but the cache keeps the stale value. CONFIRMED. | `pages/client/ClientNonUIFeatures.tsx:364-408` | DM cadence "msg→read→msg→read…": after the first, every later message's notification+sound is suppressed (`unreadEqual(1,1)`). | Clear/refresh `unreadCacheRef` from a `RoomEvent.Receipt` listener, or dedupe on last-notified `eventId` (as the thread path already does). |
### 🟠 Medium — visible wrong behavior
| ID | Defect | File:line | Notes |
| :--------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------- |
| **T4** | Explicit per-thread **"All" override is defeated** by the room-count gate: `deliverNotification` returns early on `unreadInfo.total===0` and dedupes on `unreadEqual`, but a non-mention thread reply in a Mentions-only room doesn't bump the count → "notify all replies" silently drops. CONFIRMED. | `ClientNonUIFeatures.tsx:370-380` vs `:489-502` | Don't gate the thread path on room `total`; dedupe on thread event id (`lastNotifiedThreadRef`) only. |
| **N3/T3+** | **Muted-thread-only room gets a phantom unread dot**`getUnreadInfos` pushes a `{0,0}` entry (roomHaveNotification true from the muted thread's server count), which still lights the nav row + pollutes "unread only" filters. CONFIRMED. | `utils/room.ts:261-278` | Don't push when `getUnreadInfo``{0,0}` and `roomHaveUnread` is false. |
| **N4/T3** | Reading a thread **DELETEs the whole room badge**`handleReceipt` unconditionally `DELETE`s on any of my receipts; if the thread was already read (no `UnreadNotifications` PUT follows) the room's legit main-timeline badge vanishes until the next event. Also a PUT/DELETE ordering race. PLAUSIBLE. | `state/room/roomToUnread.ts:243-263` | For threaded receipts, recompute via `getUnreadInfo` PUT instead of blanket DELETE. |
| **T2** | Thread **"Jump to Latest" doesn't reset the virtual window** — only bumps scroll count; when scrolled up with live replies arriving, newest replies aren't rendered so the chip lands on a mid/old event. CONFIRMED. | `features/room/thread/ThreadTimeline.tsx:462-468` | Re-anchor with `setTimeline(getInitialThreadTimeline(...))` like the main timeline's `handleJumpToLatest`. |
| **C-H1** | `forceState()` **re-runs on every `JoinCall`** (no once-guard) → on an EC reconnect it resets live mic/video/deafen back to the join-time snapshot, clobbering the user's mid-call toggles. PLAUSIBLE (structurally confirmed). | `plugins/call/CallEmbed.ts:362-364,502-509` + `CallControl.ts:141-156` | Guard `onCallJoined` to `forceState` only on first join, or re-apply _current_ state on rejoin. |
| **C-H2** | AFK auto-mute monitors the **browser default mic**, not EC's selected device → measures silence while the user talks on a non-default mic → **auto-mutes an active speaker**; also a 2nd OS mic indicator. PLAUSIBLE. | `hooks/useAfkAutoMute.ts:39-40` | Acquire with EC's selected `deviceId`, or tap EC's existing track. |
| **C-H3** | Screenshare/spotlight **state observer goes stale**`bodyMutationObserver` uses `subtree:false`, so when EC re-renders its controls subtree `observeControls()` never re-runs and the reflected screenshare/spotlight boolean freezes. PLAUSIBLE. | `plugins/call/CallControl.ts:168-200,274-287` | Observe the stable controls container with `subtree:true`, or re-query on each mutation. |
### 🟡 Low / minor / cosmetic / perf
- **Calls host:** C-M1 deafen DOM-fallback leaks late-added `<audio>` tracks; C-M2 `.click()`-by-testid toggles silently no-op if EC renames; C-M3 `setQuality` not join-gated (pends to 10s timeout) — mirror the deafen gate; C-M4 End button spins forever if EC ACKs hangup but never echoes Close (add fallback dispose timeout); C-M5 PTT while deafened silently un-deafens on key-up; C-M6 `screenshareAudioMuted` never reset when screenshare stops; C-L1 AFK mic not released if EC elides the echo; C-L2 ringtone-preview global cross-cancel; C-L3 first incoming ring after cold load can be silent (ringtones ctx not unlocked); C-L4 deafen M-key bound to window only (not iframe); C-L5 speaker-observer churn on every membership change; C-L6 setState-after-unmount (IncomingCallListener, CallSoundboard 30s timer); C-L7 all-muted DOM-fallback miscount if EC label format differs; C-L8 PiP sw/nw resize anchor jitter at min size.
- **Notifications:** N5 `deleteUnreadInfo` `?? roomId` spreads a string into chars (latent/unreachable; should be `?? []`) — `roomToUnread.ts:85`; N6 per-message read-receipt avatars may not refresh on membership change (`RoomMemberEvent.Membership` on `Room` may not bubble) — `useRoomReadPositions.ts:57-61`.
- **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, partly masked by T4); T7 account-data thread-mute write is a lost-update read-modify-write race.
- **EC fork (needs a republish):** EC1 `lotusQuality` 500ms re-apply `setTimeout` never cleared → fires on torn-down rooms; EC2/EC3 `lotusQuality`+`lotusAudioInject` subscribe to the remote-gated `livekitRoomItems$` (no-op when alone) — should use `allConnections$` like denoise; EC4 `lotusDecorations` keeps module-scoped per-call state (cosmetic leak into next call — the last deafen-class sibling); EC5 `lotusDecorations` inline `subscribe` re-subscribes every render; EC6 `lotusFocus` clears the pin on a payload missing `userId` (should keep current).
**Verified sound (spot-check list):** `markAsRead` (no root fallback), `useRoomsListener` variable-arity fix, `unreadEqual`, `getUnreadInfo` muted-thread subtraction, own-reply suppression, single-owner timeline rule, `lotusDeafen` (closure-scoped), denoise processor lifecycle, PTT stuck-mic hardening + editable-target guard, PiP position validation, embed single-dispose. (Full per-area "looks-correct" lists in the audit run.)
---
## 🔎 Audit findings — Wave 2 (2026-07)
Tier-2 bug-hunt (desktop/native, crypto/session/infra, messaging data) by 3 parallel agents. `[D#]`=desktop/native, `[F#]`=crypto/session/infra, `[M#]`=messaging.
**✅ FIXED (2026-07):**
- **🔴/security:** F1 (search-cache DECRYPTED plaintext never wiped on server-forced logout → now `deleteSearchCacheDatabase()` on that path); D1 (Linux no-sleep was totally broken — zbus inhibit bound to a dropped connection; now a long-lived connection in managed state); M1/M2 (bookmarks + user-notes account-data **lost-update** data-loss → serialized via `latestRef`+write-queue like `useReminders`).
- **🟠:** M3 (reminder cross-instance race → hoisted the queue to module scope); M4 (image compression flattened transparent PNGs to black + stripped EXIF orientation → skip PNG, `createImageBitmap` orientation, `.jpg` rename); M6 (export "all" had unbounded pagination/OOM → 200-page cap + Cancel button + incremental `oldestTs`); D2 (desktop taskbar/Unity badge double-counted spaces — same as favicon N1 → leaf-only sum); D3 (tray DND desynced from `manualDndAtom` after reload → `get_tray_dnd` re-hydrate); F4 (search-cache delete falsely reported success while `onblocked` → wait for real delete, 3s cap).
- **🟡:** M5 (MediaGallery lightbox opened the wrong item — index drift; shared `getThumbMxc` guard); M8 (audio playback-rate reset on async decrypt → re-apply on loadedmetadata/play); D5 (updater never relaunched → `app.restart()` + terminal UI state).
**⚠️ FLAGGED — product decision (not auto-changed):**
- **F2 — DECIDED (keep ON, 2026-07):** URL previews stay **default ON in encrypted rooms** (`settings.ts encUrlPreview: true`) per user preference — the deliberate Lotus "URL Preview Default in Encrypted Rooms" feature. (Trade-off acknowledged: the homeserver fetches links from E2EE messages; users can turn it off per-room.)
**Won't-fix / by-design:** M7 (scheduledMessages clamps a past target to 1s — intentional + unit-tested; the modal already guards ≥60s).
**Still open (low tail / follow-ups):**
- **D4** cold-start deep link may navigate twice (idempotent; guard the argv path). **D6 ✅ FIXED (2026-07):** WinRT rich-toast AUMID now registered (`native/aumid.rs`: SetCurrentProcessExplicitAppUserModelID + a Start-Menu shortcut with PKEY_AppUserModel_ID; toast uses CreateToastNotifierWithId) so P5-41 quick-reply / P5-35 click-to-open work on Windows. **CI-compile-pending; runtime-verify on a Windows build.** **D7** Unity badge `application://cinny.desktop` id may not match the installed `.desktop` basename (runtime-verify on the `.deb`/AppImage).
- **F3** session blob unconditionally wins over legacy keys even if legacy is fresher (downgrade-then-upgrade → stale token → forced re-login); **F5** OIDC refresh drops `expiresAt`/id-token claims on persist; **F6** server-forced logout leaves a stale token in the SW + skips issuer revocation (token already revoked server-side — minor).
- **Nit:** ForwardMessageDialog doesn't strip `m.mentions` → forwarding can re-ping.
**Verified sound (spot-checks):** media-auth token only in the `Authorization` header (never a URL); `removeFallbackSession` clears all credential keys; session cross-tab sync; the opt-in search gate; `cryptoCallbacks`; SW precache (no stale SPA shell); Windows `SetThreadExecutionState` main-thread clear; native IPC surface matches end-to-end; GDI/COM/jumplist/thumbbar resource hygiene; `useReminders` serialization template; forward multi-select index alignment; KaTeX (`trust:false`, no XSS); `mathParse`; `searchCache` merge/coverage; ScheduleMessageModal local-tz + ≥60s guard; polls 210 bounds; edit-history pagination; `useLocalTime` DST.
---
## 🔎 Audit findings — Wave 3 (2026-07)
Tier-3 bug-hunt (theming/visual, presence/UX/composer, rooms-customization/moderation) by 3 parallel agents. Higher-severity than expected in the non-theming areas. `[P#]`=presence/UX, `[H#]`=rooms/moderation, `[T#]`=theming.
**✅ FIXED (2026-07), reviewed + gate-green (677 tests):** the ACL cluster [H1H4] (empty-allow block, self-ban warning w/ case-insensitive glob match, glob validation, confirm dialog), [P1] wrong-room menu, [P2] presence override, [H6] insights overflow, [H7/H8] mod-log labels, [P3/P4/P5] mute-restore + status-expiry + timezone-`m.tz`, [P6P9] favorites/charCount/DM-preview, and theming [T-P1/P2/P4/P5]. **[H5] invite-QR ✅ FIXED (2026-07):** now generated locally via `qrcode.react` (no api.qrserver.com); removed from the prod CSP.
**🔴 High (fixing/fixed this pass):**
- **[H1H4] Server ACL editor can brick a room's federation in one click** — no guard against saving an **empty allow-list** (denies every server → room partitioned, unrecoverable), no warning on **denying/omitting your own homeserver**, glob validation **rejects valid patterns** (`1.2.3.*`, `*.evil.*`), and a single Save writes `m.room.server_acl` with no confirmation. → adding empty-allow block, self-ban warning (`mx.getDomain()`), glob validation, and a confirm dialog.
- **[P1] Room context menu acts on the WRONG room after a live reorder** — `RoomNavItem` keyed by list `index`, so an open menu rebinds to a different room on activity-sort reorder → Leave/Mute/Favorite hits the wrong room. → key by `roomId`.
- **[P2] Setting a status message force-flips presence to `online`** — overrides Invisible/DND/Idle (an Invisible user is outed as online). → derive presence from the `presenceStatus` setting.
- **[H5] Invite QR leaks room identity to a third party** — the QR is fetched from `api.qrserver.com` (`RoomShareInvite.tsx`). **DEFERRED — needs a bundled QR lib** (none in deps); generate locally instead of a remote call.
**🟠 Medium (fixing/fixed):**
- **[H6] RoomInsights `Math.min(...allTs)`** spread overflowed the call stack on a large timeline → **FIXED** (single-pass min/max). [H7] policy-list mislabels `org.matrix.mjolnir.ban` + empty recommendation badge; [H8] activity-log mislabels knock→join and invite-retraction.
- **[P3] Timed-mute timers never restored on startup** → a mute set before a reload stays stuck forever; re-arm/expire persisted timers on client init. **[P4]** custom-status auto-clear **never fires** (timer lives in the Settings modal) → move to an always-mounted watcher. **[P5]** timezone written to `im.lotus.timezone` but read from the `m.tz` profile field → invisible to other users despite the "visible to others" copy; also PUT `m.tz`.
- **[T-P1] Decoration picker eager-loads ~100 animated PNGs** (jank/CPU) → `loading="lazy"`. **[T-P2]** a redundant always-on animated `<body>` compositor layer when glassmorphism is off → gate on `glassmorphismSidebar`. **[T-P4]** `prefers-reduced-motion` sampled once, never re-subscribed → a `useReducedMotion()` hook.
**🟡 Low (fixing/open):** [P6/P7] favorites collapse chevron doesn't hide + filter ignores favorites; [P8] `charCount` not reset on send; [P9] encrypted DM preview stale until next event (listen for `Decrypted`); [P10] presence badge not seeded when the User appears late; [T-P5] decoration `<img>` stuck hidden on a recycled node; [H10] room-name setter fire-and-forget/silent length reject; theming [T-P3/P6/P7/P8] preview-grid perf + seasonal-swatch viewport-units + mutual-exclusion UX asymmetry (mostly acceptable); `App.tsx` mention-color assumes 6-digit hex.
**Verified sound (spot-checks):** NO theming leaks (all backgrounds/overlays pure-CSS; `lotus-boot`/`LotusDecorationPusher` timers self-clean; NightLight unmounts + `pointerEvents:none`; reduced-motion honored on load); favorites use per-room `m.tag` (**no** account-data race); bookmarks serialization intact; toast queue self-dismiss + dedup; composer-toolbar config; CollapsibleBody ResizeObserver; syntax highlighter renders React children (**no XSS**); Report Room endpoint (MSC4151); knock badge gated on PL; ACL event wire shape.
---
## ✅ Done — Awaiting Verification ## ✅ Done — Awaiting 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 they graduate to LOTUS_FEATURES.md. (Open bugs + the verification backlog now live in this file and LOTUS_TESTING.md.)
| Feature | Test guide | | Feature | Test guide |
| :-------------------------------------------------------------------------------- | :---------------- | | :-------------------------------------------------------------------------------- | :---------------- |
@@ -141,7 +229,12 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
## Priority 3 — Higher complexity / lower daily frequency ## Priority 3 — Higher complexity / lower daily frequency
### [ ] P3-4 · Accessibility Improvements (WCAG 2.1 AA) ### [~] P3-4 · Accessibility Improvements (WCAG 2.1 AA) — COMPLIANCE PASS DONE (2026-07), ⚠️ AWAITING LIVE AXE/SR AUDIT
**Shipped (compliance + shortcuts-help tier):** messages `role="article"` + collapsed-message sender/time announced to AT (the biggest gap — collapsed rows had no sender for a screen reader); ~10 unlabeled form inputs + Media Gallery / Search overlays named; emoji/emoticon aria-labels; typing indicator now announced via a `role="status"` live region; editing a message announces "Editing message from X"; focus now returns to the trigger on close of 4 genuine dialogs (RoomIntro/Reactions/RoomViewHeader-topic/Search — inline popouts correctly left); a `?` keyboard-shortcuts help dialog; and a **jsx-a11y lint gate** (curated ARIA-correctness + label rules, enforced in CI) to prevent regressions. Already-good before this pass: skip link + landmarks, timeline `role="log"`/`aria-live`, ~99% icon-button labels, labeled editor.
**DEFERRED (documented):** virtualization keeps scrolled-away history out of the a11y tree (architectural; the live-region announces newly-arriving messages) — not re-architected to avoid perf regression; roving-tabindex + command palette + section-jump shortcuts (user-deferred); the live axe-core / VoiceOver+NVDA audit → LOTUS_TESTING §P.
_Original scope (for reference):_
**What:** Comprehensive audit and fix pass targeting the critical user paths: **What:** Comprehensive audit and fix pass targeting the critical user paths:
@@ -167,13 +260,14 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
Built per the design below (4-agent build + 2-agent review). Gates green (tsc/eslint/build/tests). **Release note: threaded replies no longer render inline in the main timeline — roots show a "N replies" chip that opens the panel.** Built per the design below (4-agent build + 2-agent review). Gates green (tsc/eslint/build/tests). **Release note: threaded replies no longer render inline in the main timeline — roots show a "N replies" chip that opens the panel.**
**Manual QA checklist (post-deploy):** **Manual QA checklist (post-deploy):**
1. Reply in Thread (message menu) → panel opens; send text/upload/emoji into it (appears pending → confirmed) 1. Reply in Thread (message menu) → panel opens; send text/upload/emoji into it (appears pending → confirmed)
2. Reply to a reply inside the panel → event carries `m.thread` + `m.in_reply_to` with `is_falling_back:false` 2. Reply to a reply inside the panel → event carries `m.thread` + `m.in_reply_to` with `is_falling_back:false`
3. Main timeline: root + chip only (replies absent); chip count/time updates live; unread badge appears for others' thread replies and clears after viewing the panel 3. Main timeline: root + chip only (replies absent); chip count/time updates live; unread badge appears for others' thread replies and clears after viewing the panel
4. Room badge clears via normal markAsRead even with unread threads (unthreaded receipt) 4. Room badge clears via normal markAsRead even with unread threads (unthreaded receipt)
5. Reload: partitioning persists; encrypted-room threads decrypt (back-pagination too) 5. Reload: partitioning persists; encrypted-room threads decrypt (back-pagination too)
6. Escape / × closes; mobile = fullscreen panel; switching rooms and back restores the open thread 6. Escape / × closes; mobile = fullscreen panel; switching rooms and back restores the open thread
**What:** A right-side drawer for threaded conversations. Currently "Reply in Thread" exists but there is no panel to read or write thread replies. **What:** A right-side drawer for threaded conversations. Currently "Reply in Thread" exists but there is no panel to read or write thread replies.
Features: Features:
@@ -209,23 +303,23 @@ Features:
**What:** Replace the manual "load more" button with an automated, virtualized infinite scroll for search results. **What:** Replace the manual "load more" button with an automated, virtualized infinite scroll for search results.
**Status:** Done in a prior session — `MessageSearch.tsx` already uses `useVirtualizer` (~line 336) over the result groups AND auto-fetches the `nextToken` page when the last virtual item scrolls into view (~line 469) via `useInfiniteQuery`. Nothing left to build. **Status:** Done in a prior session — `MessageSearch.tsx` already uses `useVirtualizer` (~line 336) over the result groups AND auto-fetches the `nextToken` page when the last virtual item scrolls into view (~line 469) via `useInfiniteQuery`. Nothing left to build.
### [ ] P4-8 · Encrypted Message Search Indexing & Caching ### [~] P4-8 · Encrypted Message Search Indexing & Caching — IMPLEMENTED (2026-07), opt-in
**What:** Implement a persistent local cache for search results, optimized for encrypted rooms. **Shipped:** `src/app/utils/searchCache.ts` — raw-IndexedDB per-room index (`lotus-search-cache`) of decrypted search rows + coverage markers, merged into local search (in-memory-wins dedupe). **Opt-in, default OFF** (stores plaintext at rest) with a privacy note, Clear button, and logout wipe. Awaiting live QA (LOTUS_TESTING outstanding-verification backlog).
**Approach:** Use `IndexedDB` to store search metadata (event IDs, timestamps) to prevent redundant server-side decryption/fetching.
### [~] P4-1 · Thread Notification Mode Per-Thread — IMPLEMENTED (2026-07), ⚠️ AWAITING LIVE QA ### [~] P4-1 · Thread Notification Mode Per-Thread — IMPLEMENTED (2026-07), ⚠️ AWAITING LIVE QA
**Shipped (Slack-style):** default = **Participating** (notified only for threads you've posted in or where you're @mentioned); per-thread override **All / Mentions-only / Mute** via the bell menu in the thread panel header; modes sync across devices (`io.lotus.thread_notifications` account data, pruned on write). Mute also suppresses the chip badge and subtracts the thread from the room's sidebar badge (client-side). Also fixed the underlying path: thread replies are notified via exactly one handler (room-level `ThreadEvent.NewReply`), with the main-timeline notifier + unread binder thread-guarded, and live badge refresh on `RoomEvent.UnreadNotifications`. **Shipped (Slack-style):** default = **Participating** (notified only for threads you've posted in or where you're @mentioned); per-thread override **All / Mentions-only / Mute** via the bell menu in the thread panel header; modes sync across devices (`io.lotus.thread_notifications` account data, pruned on write). Mute also suppresses the chip badge and subtracts the thread from the room's sidebar badge (client-side). Also fixed the underlying path: thread replies are notified via exactly one handler (room-level `ThreadEvent.NewReply`), with the main-timeline notifier + unread binder thread-guarded, and live badge refresh on `RoomEvent.UnreadNotifications`.
**Manual QA checklist (post-deploy):** **Manual QA checklist (post-deploy):**
1. Friend replies in a thread YOU posted in → notification + sound; in a thread you never touched → silent (chip badge only) 1. Friend replies in a thread YOU posted in → notification + sound; in a thread you never touched → silent (chip badge only)
2. @mention in any thread → notified regardless of participation 2. @mention in any thread → notified regardless of participation
3. Set a thread to Mute → no notifications, chip badge gone (bell-mute glyph), room sidebar badge drops by that thread's count 3. Set a thread to Mute → no notifications, chip badge gone (bell-mute glyph), room sidebar badge drops by that thread's count
4. Set to All → every reply notifies; Mentions-only → only @mentions 4. Set to All → every reply notifies; Mentions-only → only @mentions
5. Second device shows the same per-thread modes (account-data sync) 5. Second device shows the same per-thread modes (account-data sync)
6. Room-level Mute still silences everything incl. thread overrides 6. Room-level Mute still silences everything incl. thread overrides
**Known caveats:** Mentions-only can under-notify in E2EE rooms (decision runs pre-decryption — same class as the existing notifier); muted-thread badge subtraction is Lotus-only (other clients still count them). **Known caveats:** Mentions-only can under-notify in E2EE rooms (decision runs pre-decryption — same class as the existing notifier); muted-thread badge subtraction is Lotus-only (other clients still count them).
--- ---
@@ -310,7 +404,7 @@ Features:
**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. **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. **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. **🔱 [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_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. **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:** **Models — all in-source in the fork:**
@@ -418,10 +512,11 @@ Features:
**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. **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**. **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_*`). - 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(...)`. - 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. 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). **Priority:** Extreme Low (Multi-sprint/Architectural).
### [DROPPED] P5-52 · Desktop — Room-Level Sync Governor (Performance Control) ### [DROPPED] P5-52 · Desktop — Room-Level Sync Governor (Performance Control)
@@ -502,6 +597,66 @@ Check back after each Synapse upgrade — re-run `/matrix/client/versions` and `
--- ---
## Priority 6 — Post-audit batches (2026-07)
Buildable follow-ups surfaced by the deep-audit wave. Web Push (N107) deliberately deferred. **macOS is out of scope for all of these — Linux is the parity target (Windows already has most native features).**
### [~] P6-1 · Desktop — cross-platform parity (Linux + Windows; NO macOS) — IMPLEMENTED (2026-07); native CI-compile-pending, runtime-verify on Linux
From the desktop audit. Round out the native app now that the full Rust stack compiles:
- **No-sleep during calls on Linux**`power.rs` is Windows-only (`SetThreadExecutionState`); add a Linux inhibitor (`org.freedesktop.login1.Manager.Inhibit` / ScreenSaver inhibit via zbus/D-Bus) so the display/system doesn't sleep mid-call.
- **Taskbar/launcher unread badge on Linux**`set_badge_count` is Windows-only; add Unity/`com.canonical.Unity.LauncherEntry` (D-Bus) count where supported.
- **Launch-on-login** — add `tauri-plugin-autostart` (cross-platform) + a Settings/tray toggle.
- **Tray "Do Not Disturb" toggle** — the tray menu is Open/Quit only; add a DND item (reuses the Focus-Assist suppression atom path) so users can silence notifications from the tray.
CI-compile-verified (Windows + Linux runners); no local Rust.
### [~] P6-2 · Element Call fork — retire the remaining DOM hacks — DEAFEN DONE (2026-07), Phase-2 pending publish
**Shipped (Phase 1):** new `io.lotus.set_deafen` action in the fork (`lotusDeafen.ts`) sets remote `RemoteParticipant.setVolume` per source (mic + screenshare-audio), persisting to late joiners — replaces the brittle `CallControl.setSound`/`applyScreenshareAudioMuted` `<audio>.muted` iframe-DOM hack. cinny now sends it (join-gated) alongside the retained DOM hack (transitional). Folded into unpublished fork `0.20.1-lotus.2`.
**Phase 2 (needs user publish):** publish `0.20.1-lotus.2` to npm → bump cinny pin `lotus.1``lotus.2` → delete the DOM `.muted` code. See HANDOFF §12.4.
**DEFERRED (rationale):** the `useCallSpeakers` DOM-scrape is a dormant _fallback_ behind `io.lotus.call_state` (deleting only removes the safety net); the `.click()`-by-`data-testid` UI toggles (screenshare/grid/spotlight/reactions/settings) are low-value and would balloon fork surface for buttons that just trigger EC's own UI.
**Divergence:** deafen doesn\'t silence soundboard/`Unknown`-source audio (setVolume type limit) — confirm UX.
_Original scope below._
### [ ] P6-2b · Element Call fork — remaining DOM hacks (deferred pieces)
Replace cinny's fragile iframe-`contentDocument` reaches with proper `io.lotus.*` widget actions in the fork (`LotusGuild/element-call`), which break on EC re-renders/version bumps:
- **Deafen / screenshare-audio-mute** → an `io.lotus` action that mutes/attenuates `RemoteAudioTrack`s at the LiveKit source (replaces `CallControl.ts` `setSound`/`applyScreenshareAudioMuted` DOM `.muted` poking).
- **UI-toggle actions** (screenshare/spotlight/reactions/settings) → replace the `.click()`-by-`data-testid` calls.
- Retire the `useCallSpeakers` DOM-scrape fallback once `io.lotus.call_state` is verified.
Fork commits are local (coordinator); publishing needs the user's npm token.
### [~] P6-3 · Web UX wins - DONE (2026-07): forward multi-select + live bookmark previews
**Shipped:** Forward Message multi-select (checkbox rooms + "Send to N", batch `Promise.allSettled` with partial-failure summary; content builder extracted to tested `forwardContent.ts`). Live bookmark previews (`BookmarksPanel` renders the live event via `useRoomEvent` - edits + redactions - snapshot as fallback / left-room). Both `lotus`, gate-green (665 tests).
_Original scope:_
### [ ] P6-3-orig · Web UX wins (from the audit ADD list)
- **Forward to multiple rooms** — multi-select (checkbox + "Send to N") in `ForwardMessageDialog` (currently one room per open, capped at 60).
- **Live bookmark previews**`BookmarksPanel` shows a stale snapshot captured at save time; resolve live from the event when cached (edits/redactions), fall back to the snapshot.
- Other small paper-cuts as scoped.
### [~] P6-4 · Hygiene sweep - TRIMMED (2026-07): security headers only
**Shipped:** HSTS + Permissions-Policy on the real prod nginx (`matrix/cinny/nginx.conf`, already had X-Frame/CSP/Referrer) + synced the `contrib/nginx` + `contrib/caddy` examples (also fixed the caddy `try_files` SPA fallback). Permissions-Policy allows `self` for the features the app uses (camera/mic/display-capture/geolocation/autoplay/fullscreen), denies unused. **User must `nginx -s reload` on the LXC + verify calls/location still work.**
**WON'T-DO (rationale):** patch-package migration - the current `patch-folds.mjs` is already robust (fails hard on drift) and patch-package would be more brittle to folds restructuring; `types/matrix` drift - risky spot-fixes with no concrete bug; build-config streamlining - build is already ~5s. Known follow-up: nginx `add_header` isn't inherited by the cache `location` blocks (pre-existing; the SPA entry `/` still gets all headers, so HSTS is delivered).
_Original scope:_
### [ ] P6-4-orig · Hygiene sweep
- `patch-folds.mjs` (edits `node_modules` directly) → `patch-package`.
- `contrib/nginx` + `contrib/caddy`: security headers (HSTS/CSP), `try_files` over rewrites, fix the caddy placeholder path.
- `types/matrix/` drift (mirrors SDK types) — spot-fix the highest-risk.
- Build-config: streamline `lotusDenoise` sequential `fs` work + redundant `viteStaticCopy` renames.
---
## 📚 Implementation Reference ## 📚 Implementation Reference
Exhaustive, low-level implementation details for backlog items. Follow these patterns to ensure code is "Lotus-perfect" (idiomatic, performant, and TDS-compliant). Exhaustive, low-level implementation details for backlog items. Follow these patterns to ensure code is "Lotus-perfect" (idiomatic, performant, and TDS-compliant).
@@ -511,7 +666,7 @@ Exhaustive, low-level implementation details for backlog items. Follow these pat
**Decisions (each backed by SDK evidence in node_modules/matrix-js-sdk):** **Decisions (each backed by SDK evidence in node_modules/matrix-js-sdk):**
| Question | Decision | | Question | Decision |
|---|---| | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Thread rendering | **New lean `ThreadTimeline`** reusing `Message`, `useVirtualPaginator`, and RoomTimeline's exported timeline helpers (lines 156-227). Do NOT refactor 2214-line RoomTimeline (its ~35 hooks are hardwired to the room live timeline). | | Thread rendering | **New lean `ThreadTimeline`** reusing `Message`, `useVirtualPaginator`, and RoomTimeline's exported timeline helpers (lines 156-227). Do NOT refactor 2214-line RoomTimeline (its ~35 hooks are hardwired to the room live timeline). |
| threadSupport | **Enable `threadSupport: true`** in `initMatrix.ts` (~line 39). ⚠️ Thread replies then LEAVE the main timeline (`room.js eventShouldLiveIn``shouldLiveInRoom:false`), retroactively on reload — MUST ship the "N replies" summary chip in the same release. Roots stay in both timelines. | | threadSupport | **Enable `threadSupport: true`** in `initMatrix.ts` (~line 39). ⚠️ Thread replies then LEAVE the main timeline (`room.js eventShouldLiveIn``shouldLiveInRoom:false`), retroactively on reload — MUST ship the "N replies" summary chip in the same release. Roots stay in both timelines. |
| State | `roomIdToActiveThreadIdAtomFamily` (per-room, mirrors `roomIdToReplyDraftAtomFamily`) in new `state/room/thread.ts` + `getThreadDraftKey(roomId, threadRootId)` = `` `${roomId}::${threadRootId}` `` | | State | `roomIdToActiveThreadIdAtomFamily` (per-room, mirrors `roomIdToReplyDraftAtomFamily`) in new `state/room/thread.ts` + `getThreadDraftKey(roomId, threadRootId)` = `` `${roomId}::${threadRootId}` `` |
@@ -520,10 +675,12 @@ Exhaustive, low-level implementation details for backlog items. Follow these pat
| Mobile | Pure CSS like `MembersDrawer.css.ts`: fixed width toRem(360) desktop, `position:fixed; inset:0` under 750px. | | Mobile | Pure CSS like `MembersDrawer.css.ts`: fixed width toRem(360) desktop, `position:fixed; inset:0` under 750px. |
**Critical side-effect fixes (one-liners, land FIRST):** **Critical side-effect fixes (one-liners, land FIRST):**
1. `initMatrix.ts``threadSupport: true`. 1. `initMatrix.ts``threadSupport: true`.
2. `utils/notifications.ts:24``sendReadReceipt(latestEvent, type, /*unthreaded*/ true)` — otherwise markAsRead becomes `main`-scoped and room badges stick permanently unread (room unread total includes thread counts). 2. `utils/notifications.ts:24``sendReadReceipt(latestEvent, type, /*unthreaded*/ true)` — otherwise markAsRead becomes `main`-scoped and room badges stick permanently unread (room unread total includes thread counts).
**Known SDK traps (verified):** **Known SDK traps (verified):**
- **Local echo gap:** chronological pending ordering means the thread timelineSet never receives pending events (`canContain` rejects; `room.getPendingEvents()` THROWS in this mode) — ThreadTimeline must render its own pending strip via `RoomEvent.LocalEchoUpdated` filtering on `threadRootId`, deduped against `thread.findEventById`. - **Local echo gap:** chronological pending ordering means the thread timelineSet never receives pending events (`canContain` rejects; `room.getPendingEvents()` THROWS in this mode) — ThreadTimeline must render its own pending strip via `RoomEvent.LocalEchoUpdated` filtering on `threadRootId`, deduped against `thread.findEventById`.
- **Bootstrap:** `room.getThread(id) ?? room.createThread(id, room.findEventById(id), [], false)` — the SDK auto-fetches via `/relations` and inserts the root at top; gate rendering on `thread.initialEventsFetched`; decrypt with `decryptAllTimelineEvent` after init + each pagination. - **Bootstrap:** `room.getThread(id) ?? room.createThread(id, room.findEventById(id), [], false)` — the SDK auto-fetches via `/relations` and inserts the root at top; gate rendering on `thread.initialEventsFetched`; decrypt with `decryptAllTimelineEvent` after init + each pagination.
- **Deep links:** `getEventTimeline(mainSet, threadEventId)` returns undefined for thread events — redirect jump-to-event to the panel (best-effort v1). - **Deep links:** `getEventTimeline(mainSet, threadEventId)` returns undefined for thread events — redirect jump-to-event to the panel (best-effort v1).
@@ -757,3 +914,136 @@ edit → commit → git push origin lotus
- **Synapse (Matrix):** LXC 151 on `compute-storage-01``pct exec 151 -- bash` - **Synapse (Matrix):** LXC 151 on `compute-storage-01``pct exec 151 -- bash`
- **Config:** `/etc/matrix-synapse/homeserver.yaml` - **Config:** `/etc/matrix-synapse/homeserver.yaml`
- **Version check:** `curl -s https://matrix.lotusguild.org/_matrix/client/versions` - **Version check:** `curl -s https://matrix.lotusguild.org/_matrix/client/versions`
---
## Element Call fork — operational reference
_Ported from the retired `HANDOFF_ELEMENT_CALL_FORK.md` (2026-07; full history in git). The fork lives at `LotusGuild/element-call` (branch `lotus`, forked from upstream tag `v0.20.1`); cinny consumes it as the npm package `@lotusguild/element-call-embedded`, whose built bundle is copied into `public/element-call/`._
**Publish a new fork version (manual; needs the Gitea npm token):**
1. In the fork, bump `embedded/web/package.json` version (current unpublished: `0.20.1-lotus.2`).
2. Build: `pnpm run build:embedded` (Node 24, pnpm 10.33.0; output → repo `dist/`, staged into `embedded/web/dist`).
3. `cd embedded/web && npm version <tag> --no-git-tag-version && npm publish` to the Gitea registry (`code.lotusguild.org`). Publicly readable; only publishing needs the token.
4. In cinny: bump the `@lotusguild/element-call-embedded` pin (`package.json`, currently `0.20.1-lotus.1`) → the new version, `npm install`, build.
**`io.lotus.*` widget actions (fork ↔ cinny host):**
| Action | Direction | Purpose | Fork module |
| :-- | :-- | :-- | :-- |
| `io.lotus.call_state` | EC→host | speaker/mute/camera state stream (URL `lotusCallState=1`) | `lotusCallState.ts` |
| `io.lotus.focus_participant` | host→EC | spotlight a participant (works during screenshare) | `lotusFocus.ts` |
| `io.lotus.inject_audio` | host→EC | soundboard clip mixed into the call (URL `lotusAudioInject=1`) | `lotusAudioInject.ts` |
| `io.lotus.set_quality` | host→EC | audio/screenshare bitrate/fps caps | `lotusQuality.ts` |
| `io.lotus.decorations` | host→EC | in-call avatar decorations | `lotusDecorations.ts` |
| `io.lotus.set_deafen` | host→EC | deafen / screenshare-audio-mute at the LiveKit source (P6-2) | `lotusDeafen.ts` |
Also flag-gated (URL params): `lotusTransparent`/`lotusTheme` (theme), `lotusDenoiseSource=1` (in-source ML denoise). New toWidget actions must be added to the enum + `LOTUS_TO_WIDGET_ACTIONS` in `src/lotus/lotusActions.ts` and only SENT after call-join (else a 10s timeout). **P6-2 phase 2 pending:** after publishing lotus.2, bump the cinny pin + delete the `CallControl.ts` `<audio>.muted` fallback.
---
## 🔴 Open — Actionable
### 🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED · 👤 SENIOR ENGINEER
> 🧰 **Investigation kit ready (2026-07):** `LOTUS_E2EE_INVESTIGATION.md` (git history)
> has the per-KE capture runbook (console signatures, synapse-side queries, the
> KE-1→KE-2 causality decision tree, ranked remediations), and the client now
> ships a **Crypto Diagnostics** capture helper (Settings) — run it during the
> next affected call and download the report before starting any fix.
> **Observed live in prod 2026-06-30** on `chat.lotusguild.org` during a 2-person
> **Element Call** (E2EE enabled). These span **client rust-crypto (via
> `matrix-js-sdk@41.6.0-rc.0`) ↔ Synapse ↔ Element Call's MatrixRTC E2EE** and are
> very likely **interrelated** (see KE-1 → KE-2). Do **not** spot-fix — they need
> a dedicated cross-system planning session with the homeserver owner. Capture
> full client console + a synapse-side trace for the same call before starting.
> **None of these are caused by the EC fork work** (the issues reproduce on the
> old build; the local mic/denoise path is unrelated to key distribution).
- **KE-1 — One-time-key (OTK) upload conflict storm (CRITICAL, root-cause candidate).**
`POST /_matrix/client/v3/keys/upload` returns `400 M_UNKNOWN: One time key
signed_curve25519:AAAAAAAAAGQ already exists. Old key: {…} new key: {…}` —
firing **continuously** (many/sec). The client repeatedly tries to publish an
OTK at a key id the server already holds **with a different value**, i.e. the
rust-crypto key store and Synapse have **diverged OTK state**. Impact: floods
the crypto outgoing-request loop and is the prime suspect for the downstream
missing-key failures (no fresh OTKs ⇒ no new Olm sessions ⇒ undecryptable
to-device key events). _Investigate:_ device/key-store reset-or-restore
mismatch, OTK id-counter desync, RC-SDK (`41.6.0-rc.0`) regression, or a
Synapse OTK bug. Repro signature: grep console for `already exists`.
**Extreme — planning session.**
**Update 2026-07 (investigation §6):** upstream `matrix-rust-sdk#5200` (still
OPEN) confirms the mechanism — on the 400, `mark_request_as_sent()` never fires
so the SDK re-issues the identical upload forever. **`41.7.0` does NOT fix it**
(crypto-wasm 17→18.3.1 has no OTK/upload change; 18.3.x was to-device security
only) — the SDK-pin lever is closed. Root cause = **store↔server OTK
divergence**; the leading **web-specific** trigger is that cinny never calls
**`navigator.storage.persist()`**, so the IndexedDB crypto store is evictable
while the `localStorage` session/device-id survives → device resurrects with a
blank store → re-uploads OTKs the server still holds. **Actionable preventive
fix (buildable now, no call needed):** request persistent storage on login
(+ optional multi-tab guard + 400-loop→recovery-prompt). Healing an already-
diverged device still needs a clean **logout+login** (not just "clear
storage"). Full runbook (synapse SQL, capture checklist, §6 diagnosis) is in git history at `LOTUS_E2EE_INVESTIGATION.md` (removed 2026-07).
- **KE-2 — Element Call media keys not arriving/decrypting → audio & video cut out (CRITICAL).**
`MissingKey: missing key at index N for participant @user`, `skipping decryption
due to missing key`, `MissingKey: key set not found for @user at index 0`, and
rust-crypto `WARN … Received an unexpected encrypted to-device event …
event_type="io.element.call.encryption_keys"`. EC distributes per-participant
media keys as **encrypted to-device `io.element.call.encryption_keys`** events;
these aren't being received/decrypted in order, so remote LiveKit audio/video
can't be decrypted — **this is the "friend's audio cuts out occasionally"
symptom.** Almost certainly downstream of **KE-1** (broken Olm sessions). Spans
EC's MatrixRTC E2EE + rust-crypto to-device + Synapse. **Extreme — planning
session.**
- **KE-3 — Timeline decryption error: missing `algorithm` field (HIGH).**
`Error decrypting event (… type=m.room.encrypted …): DecryptionError[msg:
missing field 'algorithm' at line 1 column 138 …]`. A malformed/legacy
encrypted event (or a serialization mismatch in the RC SDK) that rust-crypto
can't parse. Lower frequency than KE-1/2 but a distinct decode-path failure —
capture the offending event id (`$SASBBzoqj…` seen) and inspect its raw content.
- **KE-4 — MatrixRTC delayed-event / membership timeouts (MEDIUM-HIGH, reliability).**
`[MembershipManager] Network local timeout error while sending event, immediate
retry … AbortError: Restart delayed event timed out before the HS responded`,
with repeated `org.matrix.msc4157.update_delayed_event`. MSC4140/4157
delayed-event reliability against `matrix.lotusguild.org` — can cause stale/ghost
call membership and missed leave events. May be partly **homeserver
responsiveness**; correlate with synapse latency/load. Include in the same
planning session since it shares the call-reliability + HS-interaction surface.
### Security & Privacy
- **N97 — Access token stored in plaintext `localStorage`** (`state/sessions.ts`), vulnerable to XSS; device ID likewise. Architectural — needs a token-protection / session-storage redesign.
- ~~**Session writes are non-atomic and not cross-tab synced**~~**done (2026-07):** atomic single-key `cinny_session_v1` blob (legacy-key migration + dual-write) + `subscribeSessionChanges`/`useSessionSync` cross-tab reload. (The plaintext-token concern in N97 above is the remaining, separate architectural item.)
- **Persisted PII without encryption:** user status message + expiry (`settings/account/Profile.tsx`), unsent composer drafts (`room/RoomInput.tsx`). Leak risk on shared devices.
### PWA / Offline / Notifications
- **N107 — SW has no `push` handler** — Web Push delivery is entirely non-functional. Needs a `push` listener + a Matrix push-gateway integration.
- **No app-asset caching strategy** (`src/sw.ts`) — no offline capability.
- ~~**`manifest: false`** may block PWA install~~**verified OK (2026-06):** `index.html` links `/manifest.json`, which exists in `public/` and is copied to `dist/`; VitePWA intentionally doesn't generate one. Not a bug.
### Dependencies & Build
- ~~**`matrix-js-sdk` pinned to a Release Candidate**~~**done (2026-07):** moved to `41.7.0` stable (crypto-wasm 18.3.1 security bump). Deep-audit dep triage: all 16 npm advisories are dev-only/unreachable/dead-dep — zero shipped exposure; dead `dompurify` removed. `@atlaskit`/build-tool pins remain review-worthy but low priority.
- **Build-time overhead:** `lotusDenoise` does heavy sequential `fs` work in `closeBundle`; `viteStaticCopy` config is complex with redundant renames — could be streamlined.
### Code Hygiene / DevEx
- **Automated test suite — 561+ tests across 65+ modules, a hard CI gate.** `npm test` runs Node's built-in runner via `tsx` (not vitest — Vite 8 is ahead of vitest's range) and **blocks the build job on failure**. Broad pure-logic coverage: utils (common, regex, sanitize/XSS, time, matrix, matrix-uia, mimeTypes, sort, accentColor, findAndReplace, AsyncSearch, ASCIILexicalTable, keyboard, room, matrix-crypto, featureCheck, syntaxHighlight, imageCompression, user-agent, callSounds), state (settings, sessions, recentSearches, upload, typingMembers, lists, room-list, toast, scheduledMessages, backupRestore, callEmbed/callPreferences, spaceRooms, …), plugins (matrix-to, call/utils, via-servers, bad-words, recent-emoji, custom-emoji, markdown block/inline/utils), OIDC (cs-api, useParsedLoginFlows, oidcState), lotus/avatarDecorations, message-search, search filters. Prevention work has caught + fixed **4 real bugs** (`findAndReplace` infinite-loop; `getSettings` crash-on-load when storage is blocked; `isMacOS` never matching modern Macs; `isMLDenoiseSupported` throwing `ReferenceError` instead of returning false on browsers lacking the `AudioWorkletNode` binding). **Next:** component/integration tests (the untestable-under-tsx DOM/React surface).
- **Extensive `as any` casts** across `src/` — gradual typing cleanup.
- **`types/matrix/` mirrors SDK types** instead of importing them — drift risk.
- ~~**Hardcoded CDN URL** should move to an env var~~**done:** `avatarDecorations.ts` already honors a `VITE_DECORATION_CDN` env override (lines 14-16); the in-repo literal is only the default. Nothing left.
- **`patch-folds.mjs` edits `node_modules` directly** — consider `patch-package`.
- **Infra docs:** `contrib/nginx` lacks security headers (HSTS/CSP) + uses rewrites over `try_files`; `contrib/caddy` has a placeholder path. CI/CD (`prod-deploy.yml`): sequential deploy, aggressive 1-min Netlify timeout, `package-manager-cache: false`.
- **README:** keep the fork-sync version + logo path current. (`CONTRIBUTING.md` is intentionally left as upstream Cinny's — not a Lotus concern.)
- **Architecture notes (low priority):** deep `features/` + `hooks/` nesting, many small coupled hooks, possible dead CSS/components, `SpacingVariant` / `DropTarget` recipe simplification.
- **Git workflow (forward-looking):** keep commits scoped — past monolithic "fix all bugs" commits and inconsistent prefixes hurt `git bisect`.
### Big Projects
- ~~**#5 — Seasonal themes & chat-background redesign.**~~ **DONE (2026-06/07):** 11 seasonal/holiday overlays shipped and later toned down + given a settings preview grid; all 19 chat backgrounds redesigned (Carbon + Aurora kept per user preference), one design sprint each, GPU-friendly CSS with `prefers-reduced-motion` + pause toggle. Remaining polish rides normal bug flow, not a "big project."
+4 -4
View File
@@ -180,10 +180,10 @@ avatar decorations on EC video tiles, and a native transparent background.
(`io.lotus.inject_audio` → in-call soundboard) and quality controls (`io.lotus.inject_audio` → in-call soundboard) and quality controls
(`io.lotus.set_quality`). (`io.lotus.set_quality`).
The full plan and integration map is in The fork's `io.lotus.*` action catalog + the publish procedure are in
**[`HANDOFF_ELEMENT_CALL_FORK.md`](HANDOFF_ELEMENT_CALL_FORK.md)**; infra/hosting + **[`LOTUS_TODO.md`](LOTUS_TODO.md)** ("Element Call fork — operational reference");
build-pipeline notes live in the `LotusGuild/matrix` repo README. Search the docs infra/hosting + build-pipeline notes live in the `LotusGuild/matrix` repo README.
for the **`[EC-FORK]`** tag to find every related note. Search the docs for the **`[EC-FORK]`** tag to find every related note.
### Build ### Build
+12 -1
View File
@@ -1,6 +1,17 @@
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas # more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
cinny.domain.tld { 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',
},
},
]; ];
+30 -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",
@@ -54,11 +52,12 @@
"katex": "0.16.11", "katex": "0.16.11",
"linkify-react": "4.3.3", "linkify-react": "4.3.3",
"linkifyjs": "4.3.3", "linkifyjs": "4.3.3",
"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",
@@ -75,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",
@@ -2697,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"
@@ -3920,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",
@@ -4051,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",
@@ -5550,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": {
@@ -6196,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",
@@ -9971,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",
@@ -10774,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",
@@ -13228,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": {
@@ -13269,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",
@@ -13306,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"
@@ -13316,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"
+4 -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",
@@ -79,11 +77,12 @@
"katex": "0.16.11", "katex": "0.16.11",
"linkify-react": "4.3.3", "linkify-react": "4.3.3",
"linkifyjs": "4.3.3", "linkifyjs": "4.3.3",
"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",
@@ -100,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",
+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;
} }
+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"
@@ -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;
} }
+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 }}
+36 -5
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';
@@ -49,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">
@@ -86,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" />
@@ -94,7 +125,7 @@ export function Room() {
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} /> <MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
</> </>
)} )}
{!callView && activeThreadId && ( {showThreadPanel && activeThreadId && (
<> <>
{screenSize === ScreenSize.Desktop && ( {screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" /> <Line variant="Background" direction="Vertical" size="300" />
@@ -107,7 +138,7 @@ export function Room() {
/> />
</> </>
)} )}
{!callView && isDrawer && ( {showMembers && (
<> <>
{screenSize === ScreenSize.Desktop && ( {screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" /> <Line variant="Background" direction="Vertical" size="300" />
+20 -6
View File
@@ -456,12 +456,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (compressionResult) { if (compressionResult) {
const originalFile = fileItem.originalFile as File; const originalFile = fileItem.originalFile as File;
const compressedFile = new File([compressionResult.blob], originalFile.name, { // compressImage re-encodes as JPEG; swap the extension so the file
type: 'image/jpeg', // name and MIME type agree (avoids e.g. a JPEG named "photo.png").
const compressedType = compressionResult.type;
const compressedName = `${originalFile.name.replace(/\.[^./\\]+$/, '')}.jpg`;
const compressedFile = new File([compressionResult.blob], compressedName, {
type: compressedType,
}); });
const uploadRes = await mx.uploadContent(compressedFile, { const uploadRes = await mx.uploadContent(compressedFile, {
name: originalFile.name, name: compressedName,
type: 'image/jpeg', type: compressedType,
}); });
const compressedMxc = (uploadRes as { content_uri: string }).content_uri; const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
if (compressedMxc) { if (compressedMxc) {
@@ -538,6 +542,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
} }
resetEditor(editor); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
setCharCount(0);
sendTypingStatus(false); sendTypingStatus(false);
return; return;
} }
@@ -579,6 +584,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
mx.sendMessage(roomId, threadRootId ?? null, content as any); mx.sendMessage(roomId, threadRootId ?? null, content as any);
resetEditor(editor); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
setCharCount(0);
localStorage.removeItem(`draft-msg-${draftKey}`); localStorage.removeItem(`draft-msg-${draftKey}`);
setReplyDraft(undefined); setReplyDraft(undefined);
sendTypingStatus(false); sendTypingStatus(false);
@@ -679,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(
+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,39 @@
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'];
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;
}
@@ -123,6 +123,10 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
useCallback( useCallback(
(evt) => { (evt) => {
if (isKeyHotkey('escape', 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.preventDefault();
evt.stopPropagation(); evt.stopPropagation();
requestClose(); requestClose();
@@ -11,6 +11,15 @@ export const ThreadTimelineContent = style({
padding: `${config.space.S400} 0`, 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({ export const ThreadCentered = style({
height: '100%', height: '100%',
padding: config.space.S700, padding: config.space.S700,
@@ -29,7 +29,7 @@ import { Editor } from 'slate';
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
import to from 'await-to-js'; import to from 'await-to-js';
import { useAtomValue, useSetAtom } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import { Badge, Box, Line, Scroll, Spinner, Text, color, config } from 'folds'; import { Badge, Box, Chip, Icon, Icons, Line, Scroll, Spinner, Text, color, config } from 'folds';
import classNames from 'classnames'; import classNames from 'classnames';
import { Opts as LinkifyOpts } from 'linkifyjs'; import { Opts as LinkifyOpts } from 'linkifyjs';
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../../utils/matrix'; import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../../utils/matrix';
@@ -459,6 +459,19 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
} }
}, [scrollToBottomCount]); }, [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. // Scroll in-place editor into view.
useEffect(() => { useEffect(() => {
if (editId) { if (editId) {
@@ -949,6 +962,19 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
<span ref={atBottomAnchorRef} /> <span ref={atBottomAnchorRef} />
</Box> </Box>
</Scroll> </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 && ( {editHistoryEvent && (
<EditHistoryModal <EditHistoryModal
room={room} room={room}
@@ -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);
};
@@ -1,7 +1,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { EventStatus, MatrixEvent, RelationType } from 'matrix-js-sdk'; import { EventStatus, MatrixEvent, RelationType } from 'matrix-js-sdk';
import { getThreadSummary, isPendingThreadReply } from './threadSummary'; import { getThreadSummary, isPendingThreadReply } from './threadSummaryData';
// getThreadSummary reads either the live Thread (preferred) or the // getThreadSummary reads either the live Thread (preferred) or the
// server-aggregated `m.thread` bundle. We stub only the members it touches and // server-aggregated `m.thread` bundle. We stub only the members it touches and
+4 -31
View File
@@ -4,7 +4,6 @@ import {
EventTimeline, EventTimeline,
MatrixClient, MatrixClient,
MatrixEvent, MatrixEvent,
ReceiptType,
Room, Room,
RoomEvent, RoomEvent,
RoomEventHandlerMap, RoomEventHandlerMap,
@@ -12,7 +11,7 @@ import {
ThreadEvent, ThreadEvent,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import { getLinkedTimelines } from '../RoomTimeline'; import { getLinkedTimelines } from '../RoomTimeline';
import { isPendingThreadReply } from './threadSummary'; import { isPendingThreadReply } from './threadSummaryData';
/** /**
* Resolve (or bootstrap) the live {@link Thread} for a root event. * Resolve (or bootstrap) the live {@link Thread} for a root event.
@@ -146,32 +145,6 @@ export const useThreadPendingEvents = (
return pending; return pending;
}; };
/** // markThreadAsRead moved to ./threadReceipt (pure + unit-tested); re-exported
* Send a threaded read receipt up to the latest confirmed event in the thread. // here for existing import sites.
* export { markThreadAsRead } from './threadReceipt';
* The receipt is threaded by default (scoped to this thread), which clears the
* per-thread unread count. Mirrors the latest-valid-event scan in
* `utils/notifications.ts`.
*/
export const markThreadAsRead = async (
mx: MatrixClient,
thread: Thread,
privateReceipt: boolean,
): Promise<void> => {
const events = thread.liveTimeline.getEvents();
let latestEvent: MatrixEvent | undefined;
for (let i = events.length - 1; i >= 0; i -= 1) {
const evt = events[i];
if (evt && !evt.isSending()) {
latestEvent = evt;
break;
}
}
if (!latestEvent) return;
await mx.sendReadReceipt(
latestEvent,
privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read,
);
};
+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',
+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;
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useState } from 'react';
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
const readReducedMotion = (): boolean =>
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia(REDUCED_MOTION_QUERY).matches;
/**
* Reactively tracks the OS `prefers-reduced-motion: reduce` setting.
*
* Unlike a one-off `window.matchMedia(...).matches` read, this subscribes to the
* media query's `change` event, so toggling the OS setting mid-session updates
* the returned value (and any animation gated on it) without a page reload.
* SSR/undefined-safe: returns `false` when `window`/`matchMedia` is unavailable.
*/
export function useReducedMotion(): boolean {
const [reduced, setReduced] = useState<boolean>(readReducedMotion);
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return undefined;
}
const mql = window.matchMedia(REDUCED_MOTION_QUERY);
const onChange = (event: MediaQueryListEvent) => setReduced(event.matches);
// Re-sync in case the setting changed between the initial render and this effect.
setReduced(mql.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, []);
return reduced;
}
+87 -54
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { MatrixClient } from 'matrix-js-sdk'; import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
export type Reminder = { export type Reminder = {
roomId: string; roomId: string;
@@ -23,6 +22,75 @@ function readReminders(mx: MatrixClient): Reminder[] {
); );
} }
// Module-scoped serialization state.
//
// The latest snapshot and the write queue must be shared across every hook
// instance: ReminderMonitor (auto-removes fired reminders) and RemindMeDialog
// (adds reminders) mount separate hooks, and a per-instance queue would let a
// remove and an add race across instances and clobber each other (setAccountData
// replaces the whole content, no server merge). We therefore keep a single
// shared queue + latest ref, keyed off the active MatrixClient.
type ReminderModuleState = {
mx: MatrixClient;
latest: Reminder[];
writeQueue: Promise<unknown>;
listeners: Set<(list: Reminder[]) => void>;
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
};
let moduleState: ReminderModuleState | null = null;
// Lazily initialize the shared state for the given client. On a client change
// (login/logout swaps the MatrixClient) we tear down the old subscription and
// re-initialize against the new client so we never leak or double-subscribe.
function ensureModuleState(mx: MatrixClient): ReminderModuleState {
if (moduleState && moduleState.mx === mx) {
return moduleState;
}
if (moduleState) {
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
}
const state: ReminderModuleState = {
mx,
latest: readReminders(mx),
writeQueue: Promise.resolve(),
listeners: new Set(),
// Reassigned below once `state` is captured.
onAccountData: () => undefined,
};
state.onAccountData = (evt) => {
if (evt.getType() === REMINDERS_KEY) {
const list = evt.getContent<RemindersContent>()?.reminders ?? [];
state.latest = list;
state.listeners.forEach((listener) => listener(list));
}
};
mx.on(ClientEvent.AccountData, state.onAccountData);
moduleState = state;
return state;
}
function enqueueReminderWrite(
mx: MatrixClient,
compute: (current: Reminder[]) => Reminder[],
): Promise<void> {
const state = ensureModuleState(mx);
const run = state.writeQueue.then(async () => {
const next = compute(state.latest);
state.latest = next;
state.listeners.forEach((listener) => listener(next));
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
});
// Keep the chain alive even if one write rejects, but propagate the
// rejection to this caller so it can react (e.g. retry).
state.writeQueue = run.catch(() => undefined);
return run;
}
export function useReminders(): { export function useReminders(): {
reminders: Reminder[]; reminders: Reminder[];
addReminder: (r: Reminder) => Promise<void>; addReminder: (r: Reminder) => Promise<void>;
@@ -30,69 +98,34 @@ export function useReminders(): {
getReminders: () => Reminder[]; getReminders: () => Reminder[];
} { } {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [reminders, setReminders] = useState<Reminder[]>(() => readReminders(mx)); const [reminders, setReminders] = useState<Reminder[]>(() => ensureModuleState(mx).latest);
// Authoritative local snapshot used to compute mutations. Reading // Subscribe to the shared module state. A single AccountData listener is
// mx.getAccountData() per-mutation is racy: two quick add/remove calls both // installed per client (in ensureModuleState); each hook instance only
// read the same stale baseline and the second write clobbers the first // registers a local setter and unregisters it on unmount / client change.
// (N113). We instead mutate from this ref, kept in sync with server echoes.
const latestRef = useRef<Reminder[]>(reminders);
// Serialize writes so overlapping setAccountData calls can't land out of
// order on the server (last-write-wins would otherwise drop data).
const writeQueueRef = useRef<Promise<unknown>>(Promise.resolve());
const applyServerState = useCallback((list: Reminder[]) => {
latestRef.current = list;
setReminders(list);
}, []);
useAccountDataCallback(
mx,
useCallback(
(evt) => {
if (evt.getType() === REMINDERS_KEY) {
applyServerState(evt.getContent<RemindersContent>()?.reminders ?? []);
}
},
[applyServerState],
),
);
// Re-read on mx change
useEffect(() => { useEffect(() => {
applyServerState(readReminders(mx)); const state = ensureModuleState(mx);
}, [mx, applyServerState]); setReminders(state.latest);
state.listeners.add(setReminders);
const enqueueWrite = useCallback( return () => {
(compute: (current: Reminder[]) => Reminder[]): Promise<void> => { state.listeners.delete(setReminders);
const run = writeQueueRef.current.then(async () => { };
const next = compute(latestRef.current); }, [mx]);
latestRef.current = next;
setReminders(next);
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
});
// Keep the chain alive even if one write rejects, but propagate the
// rejection to this caller so it can react (e.g. retry).
writeQueueRef.current = run.catch(() => undefined);
return run;
},
[mx],
);
const addReminder = useCallback( const addReminder = useCallback(
(r: Reminder) => enqueueWrite((current) => [...current, r]), (r: Reminder) => enqueueReminderWrite(mx, (current) => [...current, r]),
[enqueueWrite], [mx],
); );
const removeReminder = useCallback( const removeReminder = useCallback(
(eventId: string, timestamp: number) => (eventId: string, timestamp: number) =>
enqueueWrite((current) => enqueueReminderWrite(mx, (current) =>
current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)), current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)),
), ),
[enqueueWrite], [mx],
); );
const getReminders = useCallback(() => reminders, [reminders]); const getReminders = useCallback(() => ensureModuleState(mx).latest, [mx]);
return { reminders, addReminder, removeReminder, getReminders }; return { reminders, addReminder, removeReminder, getReminders };
} }
+17 -1
View File
@@ -1,4 +1,11 @@
import { MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import {
MatrixEvent,
MatrixEventEvent,
MatrixEventHandlerMap,
Room,
RoomEvent,
RoomEventHandlerMap,
} from 'matrix-js-sdk';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { settingsAtom } from '../state/settings'; import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings'; import { useSetting } from '../state/hooks/settings';
@@ -45,11 +52,20 @@ export const useRoomLatestRenderedEvent = (room: Room) => {
const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = () => { const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = () => {
setLatestEvent(getLatestEvent()); setLatestEvent(getLatestEvent());
}; };
// An E2EE message often arrives as an undecrypted placeholder and is decrypted
// shortly after — decryption does NOT re-fire RoomEvent.Timeline, so without this
// the DM preview stays stale ("Encrypted message") until the next timeline event.
const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (event) => {
if (event.getRoomId() !== room.roomId) return;
setLatestEvent(getLatestEvent());
};
setLatestEvent(getLatestEvent()); setLatestEvent(getLatestEvent());
room.on(RoomEvent.Timeline, handleTimelineEvent); room.on(RoomEvent.Timeline, handleTimelineEvent);
room.client.on(MatrixEventEvent.Decrypted, handleDecrypted);
return () => { return () => {
room.removeListener(RoomEvent.Timeline, handleTimelineEvent); room.removeListener(RoomEvent.Timeline, handleTimelineEvent);
room.client.removeListener(MatrixEventEvent.Decrypted, handleDecrypted);
}; };
}, [room, hideMembershipEvents, hideNickAvatarEvents, showHiddenEvents]); }, [room, hideMembershipEvents, hideNickAvatarEvents, showHiddenEvents]);
-101
View File
@@ -1,101 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
import { AccountDataEvent } from '../../types/matrix/accountData';
import {
SoundboardClip,
SoundboardContent,
SOUNDBOARD_MAX_CLIP_BYTES,
SOUNDBOARD_MAX_CLIPS,
SOUNDBOARD_NAME_MAX,
readSoundboardClips,
} from '../utils/soundboardClips';
const KEY = AccountDataEvent.LotusSoundboard;
/**
* [P5-15] Read/write the user's personal soundboard, stored in the
* `io.lotus.soundboard` account data event (synced across devices like custom
* emoji/sticker packs). Uploading writes the audio to the media repo and
* appends an mxc reference.
*/
export function useSoundboard(): {
clips: SoundboardClip[];
addClip: (file: File, name?: string) => Promise<void>;
removeClip: (id: string) => Promise<void>;
renameClip: (id: string, name: string) => Promise<void>;
} {
const mx = useMatrixClient();
const [clips, setClips] = useState<SoundboardClip[]>(() => readSoundboardClips(mx));
useAccountDataCallback(
mx,
useCallback((evt) => {
if (evt.getType() === KEY) {
const content = evt.getContent<SoundboardContent>();
setClips(Array.isArray(content?.clips) ? content.clips : []);
}
}, []),
);
useEffect(() => {
setClips(readSoundboardClips(mx));
}, [mx]);
const persist = useCallback(
async (next: SoundboardClip[]) => {
const content: SoundboardContent = { clips: next };
await (
mx as unknown as { setAccountData: (t: string, c: unknown) => Promise<void> }
).setAccountData(KEY, content);
},
[mx],
);
const addClip = useCallback(
async (file: File, name?: string) => {
const current = readSoundboardClips(mx);
if (current.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('Clip is too large (max 1 MB).');
}
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
const mxc = res.content_uri;
if (!mxc) throw new Error('Upload failed.');
const label = (name ?? file.name.replace(/\.[^/.]+$/, ''))
.trim()
.slice(0, SOUNDBOARD_NAME_MAX);
const clip: SoundboardClip = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: label || 'Clip',
url: mxc,
mimetype: file.type || undefined,
size: file.size,
};
await persist([...current, clip]);
},
[mx, persist],
);
const removeClip = useCallback(
async (id: string) => {
const next = readSoundboardClips(mx).filter((c) => c.id !== id);
await persist(next);
},
[mx, persist],
);
const renameClip = useCallback(
async (id: string, name: string) => {
const trimmed = name.trim().slice(0, SOUNDBOARD_NAME_MAX);
if (!trimmed) return;
const next = readSoundboardClips(mx).map((c) => (c.id === id ? { ...c, name: trimmed } : c));
await persist(next);
},
[mx, persist],
);
return { clips, addClip, removeClip, renameClip };
}
+160
View File
@@ -0,0 +1,160 @@
import { Room } from 'matrix-js-sdk';
import { useCallback, useMemo, useState } from 'react';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { StateEvent } from '../../types/matrix/room';
import {
getGlobalSoundboardPacks,
getRoomSoundboardPack,
getRoomSoundboardPacks,
getUserSoundboardPack,
SoundboardPack,
} from '../plugins/soundboard';
import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
import { useStateEventCallback } from './useStateEventCallback';
// Parallels hooks/useImagePacks.ts (custom emoji). Same aggregation shape.
export const useUserSoundboardPack = (): SoundboardPack | undefined => {
const mx = useMatrixClient();
const [userPack, setUserPack] = useState(() => getUserSoundboardPack(mx));
useAccountDataCallback(
mx,
useCallback(
(mEvent) => {
if (mEvent.getType() === AccountDataEvent.LotusSoundboard) {
setUserPack(getUserSoundboardPack(mx));
}
},
[mx],
),
);
return userPack;
};
export const useGlobalSoundboardPacks = (): SoundboardPack[] => {
const mx = useMatrixClient();
const [globalPacks, setGlobalPacks] = useState(() => getGlobalSoundboardPacks(mx));
useAccountDataCallback(
mx,
useCallback(
(mEvent) => {
if (mEvent.getType() === AccountDataEvent.LotusSoundboardRooms) {
setGlobalPacks(getGlobalSoundboardPacks(mx));
}
},
[mx],
),
);
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
const roomId = mEvent.getRoomId();
const stateKey = mEvent.getStateKey();
if (
mEvent.getType() === StateEvent.LotusSoundboardRoom &&
roomId &&
typeof stateKey === 'string'
) {
const isGlobal = !!globalPacks.find(
(pack) =>
pack.address && pack.address.roomId === roomId && pack.address.stateKey === stateKey,
);
if (isGlobal) setGlobalPacks(getGlobalSoundboardPacks(mx));
}
},
[mx, globalPacks],
),
);
return globalPacks;
};
export const useRoomSoundboardPack = (room: Room, stateKey: string): SoundboardPack | undefined => {
const mx = useMatrixClient();
const [roomPack, setRoomPack] = useState(() => getRoomSoundboardPack(room, stateKey));
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
if (
mEvent.getRoomId() === room.roomId &&
mEvent.getType() === StateEvent.LotusSoundboardRoom &&
mEvent.getStateKey() === stateKey
) {
setRoomPack(getRoomSoundboardPack(room, stateKey));
}
},
[room, stateKey],
),
);
return roomPack;
};
export const useRoomSoundboardPacks = (room: Room): SoundboardPack[] => {
const mx = useMatrixClient();
const [roomPacks, setRoomPacks] = useState(() => getRoomSoundboardPacks(room));
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
if (
mEvent.getRoomId() === room.roomId &&
mEvent.getType() === StateEvent.LotusSoundboardRoom
) {
setRoomPacks(getRoomSoundboardPacks(room));
}
},
[room],
),
);
return roomPacks;
};
export const useRoomsSoundboardPacks = (rooms: Room[]): SoundboardPack[] => {
const mx = useMatrixClient();
const [roomPacks, setRoomPacks] = useState(() => rooms.flatMap(getRoomSoundboardPacks));
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
if (
rooms.find((room) => room.roomId === mEvent.getRoomId()) &&
mEvent.getType() === StateEvent.LotusSoundboardRoom
) {
setRoomPacks(rooms.flatMap(getRoomSoundboardPacks));
}
},
[rooms],
),
);
return roomPacks;
};
/** User global room packs, deduped by id, keeping only packs with clips. */
export const useRelevantSoundboardPacks = (rooms: Room[]): SoundboardPack[] => {
const userPack = useUserSoundboardPack();
const globalPacks = useGlobalSoundboardPacks();
const roomsPacks = useRoomsSoundboardPacks(rooms);
return useMemo(() => {
const packs = userPack ? [userPack] : [];
const globalPackIds = new Set(globalPacks.map((pack) => pack.id));
const relPacks = packs.concat(
globalPacks,
roomsPacks.filter((pack) => !globalPackIds.has(pack.id)),
);
return relPacks.filter((pack) => pack.getClips().length > 0);
}, [userPack, globalPacks, roomsPacks]);
};
+35
View File
@@ -0,0 +1,35 @@
import { useEffect } from 'react';
import { useSetAtom } from 'jotai';
import { manualDndAtom } from '../state/manualDnd';
import { tauriInvoke, useTauriEvent } from './useTauri';
/** Detail shape of the `lotus-dnd-changed` event emitted by the native side. */
type DndChangedDetail = {
active: boolean;
};
/**
* P6-1 Tray "Do Not Disturb" notification suppression (desktop). Subscribes
* to the native `lotus-dnd-changed` event (emitted when the user toggles the
* tray "Do Not Disturb" item, `{ active }`) and mirrors it into `manualDndAtom`,
* which the notification gate reads to suppress notifications while DND is on.
* Inert in the browser, since `useTauriEvent` only listens under Tauri.
*/
export function useTauriDnd(): void {
const setDnd = useSetAtom(manualDndAtom);
useTauriEvent<DndChangedDetail>('lotus-dnd-changed', ({ active }) => setDnd(active));
// Re-hydrate on mount. The tray CheckMenuItem persists its checkstate, but
// `manualDndAtom` is in-memory and resets to false on every reload (the
// custom-chrome toggle, logout). Without this the tray could show DND ON while
// notifications resume firing. Query the native tray state (`get_tray_dnd`) so
// they stay in sync. No-op in the browser.
useEffect(() => {
tauriInvoke()?.('get_tray_dnd')
.then((active) => {
if (typeof active === 'boolean') setDnd(active);
})
.catch(() => undefined);
}, [setDnd]);
}
@@ -18,6 +18,10 @@ export function useTauriNotificationBadge() {
let totalHighlights = 0; let totalHighlights = 0;
roomToUnread.forEach((unread) => { roomToUnread.forEach((unread) => {
// Sum only leaf rooms (from === null); roomToUnread also holds per-ancestor
// space aggregates (from = Set), so counting all entries double-counts a
// space-nested room. Mirrors the favicon fix in ClientNonUIFeatures.
if (unread.from !== null) return;
totalHighlights += unread.highlight; totalHighlights += unread.highlight;
}); });
+4
View File
@@ -38,6 +38,10 @@ export function useTauriUpdater() {
setStatus({ state: 'installing' }); setStatus({ state: 'installing' });
try { try {
await invoke('install_update'); await invoke('install_update');
// On a successful install the native side calls app.restart(), so this
// resolve is only reached when nothing was installed (no update found) —
// don't leave the UI stuck on "installing".
setStatus({ state: 'up-to-date' });
} catch (e) { } catch (e) {
setStatus({ state: 'error', message: String(e) }); setStatus({ state: 'error', message: String(e) });
} }
+1 -1
View File
@@ -8,7 +8,7 @@ import {
RoomEventHandlerMap, RoomEventHandlerMap,
ThreadEvent, ThreadEvent,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import { getThreadSummary, ThreadSummaryData } from '../features/room/thread/threadSummary'; import { getThreadSummary, ThreadSummaryData } from '../features/room/thread/threadSummaryData';
import { threadNotificationsAtom } from '../state/threadNotifications'; import { threadNotificationsAtom } from '../state/threadNotifications';
import { getThreadNotificationMode, ThreadNotificationMode } from '../utils/threadNotifications'; import { getThreadNotificationMode, ThreadNotificationMode } from '../utils/threadNotifications';
+85 -17
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { MatrixClient } from 'matrix-js-sdk'; import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
const NOTES_KEY = 'io.lotus.user_notes'; const NOTES_KEY = 'io.lotus.user_notes';
export const USER_NOTE_MAX_LENGTH = 500; export const USER_NOTE_MAX_LENGTH = 500;
@@ -12,39 +11,108 @@ function readNotes(mx: MatrixClient): UserNotesContent {
return (mx.getAccountData(NOTES_KEY as any)?.getContent() as UserNotesContent | undefined) ?? {}; return (mx.getAccountData(NOTES_KEY as any)?.getContent() as UserNotesContent | undefined) ?? {};
} }
// Module-scoped serialization state.
//
// useUserNotes() can be mounted by many components at once, so a per-instance
// latest/queue would only serialize writes within one instance. Notes for
// different users saved from different instances (before the server echo lands)
// would each compute from a stale snapshot and clobber each other, since
// setAccountData replaces the whole record with no server merge. We therefore
// keep a single shared latest record + write queue, keyed off the active client.
type UserNotesModuleState = {
mx: MatrixClient;
latest: UserNotesContent;
writeQueue: Promise<unknown>;
listeners: Set<(record: UserNotesContent) => void>;
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
};
let moduleState: UserNotesModuleState | null = null;
// Lazily initialize the shared state for the given client. On a client change
// (login/logout swaps the MatrixClient) we tear down the old subscription and
// re-initialize against the new client so we never leak or double-subscribe.
function ensureModuleState(mx: MatrixClient): UserNotesModuleState {
if (moduleState && moduleState.mx === mx) {
return moduleState;
}
if (moduleState) {
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
}
const state: UserNotesModuleState = {
mx,
latest: readNotes(mx),
writeQueue: Promise.resolve(),
listeners: new Set(),
// Reassigned below once `state` is captured.
onAccountData: () => undefined,
};
state.onAccountData = (evt) => {
if (evt.getType() === NOTES_KEY) {
const record = evt.getContent<UserNotesContent>() ?? {};
state.latest = record;
state.listeners.forEach((listener) => listener(record));
}
};
mx.on(ClientEvent.AccountData, state.onAccountData);
moduleState = state;
return state;
}
function enqueueNotesWrite(
mx: MatrixClient,
compute: (current: UserNotesContent) => UserNotesContent,
): Promise<void> {
const state = ensureModuleState(mx);
const run = state.writeQueue.then(async () => {
const next = compute(state.latest);
state.latest = next;
state.listeners.forEach((listener) => listener(next));
await (mx as any).setAccountData(NOTES_KEY, next);
});
// Keep the chain alive even if one write rejects, but propagate the
// rejection to this caller so it can react (e.g. retry).
state.writeQueue = run.catch(() => undefined);
return run;
}
export function useUserNotes(): { export function useUserNotes(): {
getNote: (userId: string) => string; getNote: (userId: string) => string;
setNote: (userId: string, note: string) => Promise<void>; setNote: (userId: string, note: string) => Promise<void>;
} { } {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [notes, setNotes] = useState<UserNotesContent>(() => readNotes(mx)); const [notes, setNotes] = useState<UserNotesContent>(() => ensureModuleState(mx).latest);
useAccountDataCallback(
mx,
useCallback((evt) => {
if (evt.getType() === NOTES_KEY) {
setNotes(evt.getContent<UserNotesContent>() ?? {});
}
}, []),
);
// Subscribe to the shared module state. A single AccountData listener is
// installed per client (in ensureModuleState); each hook instance only
// registers a local setter and unregisters it on unmount / client change.
useEffect(() => { useEffect(() => {
setNotes(readNotes(mx)); const state = ensureModuleState(mx);
setNotes(state.latest);
state.listeners.add(setNotes);
return () => {
state.listeners.delete(setNotes);
};
}, [mx]); }, [mx]);
const getNote = useCallback((userId: string) => notes[userId] ?? '', [notes]); const getNote = useCallback((userId: string) => notes[userId] ?? '', [notes]);
const setNote = useCallback( const setNote = useCallback(
async (userId: string, note: string) => { (userId: string, note: string) => {
const current = readNotes(mx);
const updated = { ...current };
const trimmed = note.trim().slice(0, USER_NOTE_MAX_LENGTH); const trimmed = note.trim().slice(0, USER_NOTE_MAX_LENGTH);
return enqueueNotesWrite(mx, (current) => {
const updated = { ...current };
if (trimmed) { if (trimmed) {
updated[userId] = trimmed; updated[userId] = trimmed;
} else { } else {
delete updated[userId]; delete updated[userId];
} }
await (mx as any).setAccountData(NOTES_KEY, updated); return updated;
});
}, },
[mx], [mx],
); );

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