Compare commits

..

79 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:19:02 -04:00
jared c1efa7b94e feat(accent): custom accent themes links, text selection, and focus rings
CI / Build & Quality Checks (push) Successful in 10m53s
CI / Trigger Desktop Build (push) Successful in 8s
The accent previously only overrode the folds Primary.* family; links kept the
hardcoded --tc-link blue, ::selection was browser-default, and focus rings were
neutral grey (Other.FocusRing). Now all three derive from the chosen base color:
- --tc-link → accent hex (messages, topics, URL previews)
- ::selection via an injected <style id="lotus-accent-style"> (accent bg +
  WCAG-contrasting text)
- Other.FocusRing → rgba(accent, 0.5)

Deliberately NOT recolored: Secondary.* (doubles as the neutral text/button/
badge palette), Success.* + mention pills (semantic mention/notification green),
scrollbar thumbs (folds styles them per-component; a global rule would only
half-apply). removeCustomAccent() clears everything — no residue when switching
off or to the TDS theme. +2 unit tests (561 total).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:44:26 -04:00
jared e31b84c08e fix(chrome): TitleBar drag via explicit window_start_drag (official recipe)
data-tauri-drag-region only fires when the exact element is the event target
and was never runtime-verified; replace it with the official Tauri custom-
titlebar recipe — primary-button mousedown starts an OS drag, detail===2
toggles maximize. Works across the whole region (brand text included, which
already passes pointer events through).

Pairs with cinny-desktop set_custom_chrome Mica fix (clear backdrop before
undecorating; window-state no longer restores the decorated flag).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:42:56 -04:00
jared 258e3ec620 fix(desktop): address code-review findings on the desktop wave
CI / Build & Quality Checks (push) Successful in 10m40s
CI / Trigger Desktop Build (push) Successful in 8s
- fileEntries: a single unreadable file/dir in a dropped folder no longer aborts
  the whole traversal (try/catch per entry, skip failures) — was discarding ALL
  dropped files (incl. the flat-file path) + an unhandled rejection; also add
  .catch in both useFileDrop consumers.
- RoomInput: mirror a localStorage-restored draft into the draft atom so the
  P5-57 indicator reflects a persisted draft after a page reload, not only on
  same-session room re-entry.
- useTauriThumbbar: swallow toggleMicrophone()/hangup() rejections (parity with
  SMTC) — avoids an unhandled rejection when clicked mid-teardown.
- App/DesktopChrome: keep wrapper element types stable across the chrome toggle
  (display:contents when off) so flipping it no longer remounts RouterProvider.
- settings: normalizeComposerToolbarOrder also appends missing keys from the
  canonical key set (safety net if a new button is absent from the default order).

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:40:31 -04:00
jared 3336abb66f docs: P5-42 done + final Tier C dispositions (P5-51/52/53)
- P5-42 → [~] IMPLEMENTED (pragmatic WebView2 keep-alive) + LOTUS_FEATURES entry.
- P5-51 → [DEFERRED] with a concrete future-work spec (single-session storage map:
  sessions.ts localStorage keys + initMatrix IndexedDB stores; the 6 things true
  per-context isolation needs; multi-account as the smaller intermediate step).
- P5-52 → [DROPPED] (matrix-js-sdk can't do true per-room sync filtering; only
  cosmetic client-side hiding).
- P5-53 → [DEFERRED] with the lighter automation-rules alternative recorded.

Every desktop P5 item is now dispositioned: implemented, won't-fix, or
deferred-with-spec/dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:27:23 -04:00
jared a184ee0221 docs: document desktop features (Tier A + B) across catalog/README/TODO
- LOTUS_FEATURES.md: new "Desktop App Features" section (+ TOC) covering all
  desktop capabilities — no-sleep, jump list, thumbbar, SMTC, network awareness,
  rich notifications, Focus Assist, window chrome, update toast, toolbar reorder,
  draft indicator, recursive folder DnD.
- README.md: "Desktop-Specific Features" bullets under the Desktop App section.
- LOTUS_TODO.md: P5-35/41/56 → [~] IMPLEMENTED (Tier B); P5-48 → [~] (recursive
  folder upload; .lnk/Send-To scoped-out with rationale).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:04:03 -04:00
jared 4509a2b6d3 feat(desktop): Tier B web side — toast actions, Focus Assist gate, folder DnD
- P5-41/35 useTauriToastActions: native rich-toast click → navigate(path) (opens
  the room), quick reply → mx.sendMessage(roomId, m.text). The desktop bridge
  routes message notifications (tag=roomId) to show_rich_toast.
- P5-56 useTauriFocusAssist + focusAssistActiveAtom: a native focus-assist-changed
  event drives the atom, OR'd into the existing quiet-hours gate in
  ClientNonUIFeatures so notifications+sounds suppress during Windows Focus Assist.
- P5-48 recursive folder drag-drop: fileEntries.ts (sync webkitGetAsEntry capture
  → async batched readEntries traversal, path-prefixed names, 500-file cap) wired
  into useFileDrop, reusing the existing upload pipeline. +3 unit tests.

Hooks no-op in the browser. Gates: tsc/eslint/prettier clean, build OK, 559 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:01:10 -04:00
jared 7e38baa7b6 docs(todo): mark desktop Tier A wave; P5-40 done, P5-50 won't-fix
- P5-36/43/44/46/47/49/55/57 → [~] IMPLEMENTED (web verified; native
  CI-compile-pending, runtime-verify on Windows).
- P5-40 → [x] DONE (TauriUpdateFeature already ships the update toast).
- P5-50 → [WON'T FIX] (can't inject Media Foundation into WebView2's WebRTC
  pipeline; Chromium already HW-decodes).
- P5-35 → note the "can't compile-test without Windows" premise is outdated
  (CI compiles Windows now); remains Tier B (rides with P5-41).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:10:04 -04:00
jared aab7e5ae20 feat(desktop): Tier A desktop features — web side (P5-46/36/44/43/49/47/55/57)
Web half of the desktop feature wave. A shared bridge (`hooks/useTauri.ts`:
invokeTauri/isTauri/useTauriEvent) backs per-feature hooks that no-op in the
browser and drive the native Tauri commands (compiled in cinny-desktop):

- P5-46 useTauriCallPower — hold system awake while a call is active.
- P5-36 useTauriJumpList — Windows jump list of recent rooms → matrix: deep links.
- P5-44 useTauriThumbbar — taskbar Mute/Deafen/End; events toggle mic/sound/hangup.
- P5-43 useTauriSmtc — SMTC call state + button events.
- P5-49 useTauriNetwork — react to native network-change → mx.retryImmediately().
- P5-47 window chrome — opt-in `customWindowChromeAtom` + TDS `TitleBar`; DesktopChrome
  wrapper in App.tsx (zero layout impact when off) + a desktop-only settings toggle.
- P5-55 composer toolbar drag-reorder (settings order[] + pragmatic-drag-and-drop).
- P5-57 DraftIndicator — subtle "draft saved" cue in the composer.

Client-scoped hooks mount via TauriDesktopFeatures in ClientNonUIFeatures; window
chrome mounts at App level. Gates: tsc/eslint/prettier clean, build OK, 556 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:07:03 -04:00
jared a0fcdf74da feat(denoise): autoGainControl=false for the ML tier + docs
CI / Build & Quality Checks (push) Successful in 11m16s
CI / Trigger Desktop Build (push) Successful in 10s
- CallEmbed sets `autoGainControl=false` for the ML noise-suppression tier so
  the browser's auto gain control doesn't fight the in-source ML model; the
  browser/off tiers keep AGC on.
- Docs: refresh the LOTUS_FEATURES noise-suppression section (browser-native
  default, quality-ordered dropdown, DFN3 ML default, attenuation floor,
  gate-after-ML, DFN level 60, AGC-off, the reliability fixes) and LOTUS_TODO
  P5-30 (mark tuning/reliability/AGC done; record GTCRN as researched-and-deferred).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:46:39 -04:00
jared ebc782b16c feat(denoise): browser-native default, quality-ordered model picker, wire native-NS
CI / Build & Quality Checks (push) Successful in 11m15s
CI / Trigger Desktop Build (push) Successful in 18s
- Model dropdown is now ordered by quality/CPU, best first (DeepFilterNet 3 →
  DTLN → RNNoise → Speex); fix RNNoise's inaccurate "High" voice-quality label.
- When a user opts into the ML tier, default to the highest-quality model
  (DeepFilterNet 3). The tier default stays browser-native (known-good, best
  perceived in testing so far).
- Wire the "Series Suppression" (native-NS-before-ML) toggle into the real call
  path — it was applied only in the settings tester, so the tester could sound
  better than the actual call. Default it OFF (a single NS stage is best
  practice; it's an opt-in test aid).
- isMLDenoiseSupported now also requires WebAssembly, so ML isn't offered on
  strict-CSP shells where it would silently fall back to the raw mic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 23:02:41 -04:00
jared 7939dc92d4 docs(call): cover soundboard/quality/permissions in user-facing docs
- README "Calls & Voice": add the in-call soundboard, per-user call quality
  settings, and admin room call-permissions bullets.
- LOTUS_TODO: mark the soundboard UI as built (was "cinny UI remains / dormant").
- HANDOFF_ELEMENT_CALL_FORK: add a COMPLETE status banner to the §12.1 host
  checklist; fix stale denoise specifics (all four models are in-source;
  flag is lotusDenoiseSource=1, not lotusDenoise=ml).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:43:49 -04:00
jared 7c06b27c73 feat(call): in-call soundboard, quality controls, room call-permissions
CI / Build & Quality Checks (push) Successful in 10m49s
CI / Trigger Desktop Build (push) Successful in 8s
Element Call is now consumed as our self-built fork
(@lotusguild/element-call-embedded); wire up its previously-dormant
capabilities and document the fork as live.

Soundboard (P5-15): a call-bar button plays user-uploaded audio clips into the
call as a real published track (io.lotus.inject_audio) plus local playback.
Clips are uploadable like emoji/sticker packs, stored in io.lotus.soundboard
account data (synced across devices). Gated by a Settings toggle + volume.

Quality controls (P5-31): per-user mic/screenshare bitrate + screenshare
framerate (Settings -> Calls), applied via io.lotus.set_quality clamped to any
room cap. Room admins set caps and hard call-permissions (allow_screenshare /
allow_camera) in Room Settings -> Voice; the call bar hides blocked buttons.

- New: CallSoundboard, useSoundboard, soundboardClips; RoomQuality,
  useCallQuality, callQuality (+ unit tests).
- Optimistic-write RoomQuality admin UI (no stale-state clobber).
- Docs: mark EC fork live across README/FEATURES/TODO/BUGS/TESTING; add D2
  manual-test steps.

Numeric quality caps are client-cooperative; screenshare/camera permissions are
hard-enforced server-side (see LotusGuild/matrix voice-limit-guard).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:34:17 -04:00
jared 02b2ce8109 feat(chat-bg): redesign 19 chat backgrounds as modular per-pattern files
Same treatment as the seasonal themes: split the 502-line chatBackground.ts
Record into one premium module per background under lotus/backgrounds/ (each
exposes a tuned dark + light ChatBgVariants), one Opus agent per background
against a shared brief. chatBackground.ts now assembles DARK/LIGHT from the
modules; getChatBg is unchanged. Carbon + Aurora are kept inline as-is (user
favorites); none stays the empty layer.

Every redesign: layered oklch palettes, seamless tiling with worked-out tile
math (integer-multiple periods; edge-wrapping inline-SVG data-URIs for
circuit/hexgrid/waves/herringbone/chevron/tactical), independently-tuned
dark+light (not a recolor), and low "felt-not-read" opacity so chat text stays
WCAG-AA legible. The 5 animated backgrounds (rain, star drift, grid pulse,
aurora flow, fireflies) each colocate a vanilla-extract keyframe .css.ts,
animate only background-position for a jump-free loop, and — since getChatBg
strips animation for reduced-motion — render a finished static frame too.

Redesigned: blueprint, stars, topographic, herringbone, crosshatch, chevron,
polka, triangles, plaid, tactical, circuit, hexgrid, waves, neon, anim-rain,
anim-stars, anim-pulse, anim-aurora, anim-fireflies.

Gates: tsc clean, ESLint clean, Prettier clean, build OK, 551 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:23:54 -04:00
249 changed files with 16595 additions and 3643 deletions
-655
View File
@@ -1,655 +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)
The EC side is additive and dormant until cinny opts in. Host work needed (in
`src/app/plugins/call/CallEmbed.ts` unless noted):
> ⚠️ **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** — the fork implements only `rnnoise`/`speex`; cinny's `dtln`/
> `deepfilternet` selections silently fall back to rnnoise (now logged). Restrict
> the embedded-call model picker to rnnoise/speex, or implement the others in
> `lotusDenoiseProcessor.ts`. **F4** — cinny sends `lotusNativeNS`, which the
> fork ignores; drop it or wire it in. **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 already sets `lotusDenoise=ml` etc.)
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 |
**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-29: the fork is live.** We now own and
> self-build Element Call (`LotusGuild/element-call` →
> `@lotusguild/element-call-embedded`, Phase 1 done & cinny wired). A5/A6/A7
> below are **no longer "won't fix"** — they are ordinary source changes. See
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md) §10 + the Phase
> 2 work list. (The iframe is **same-origin** / self-hosted; the old blocker was
> that we didn't own EC's compiled source — which we now do.)
The in-call participant grid is rendered **inside EC's app**. Previously a
pre-built npm bundle we could only style/place around; now editable source.
Items from testing, with their fork-level fix path:
- **A5 — "Focus camera":** EC supports native tile-pinning. Our bottom-bar "Focus
camera" is a programmatic wrapper that **`.click()`s the tile** today
(`CallControl.ts` `focusCameraParticipant`), and during a screenshare EC
spotlights the shared screen so a camera pin may not override it. **Fork fix:**
add an `io.lotus.focus_participant` widget action that pins a participant in
EC's layout (coexisting with / overriding the screenshare spotlight); cinny
sends it via the widget API and the DOM-click hack is deleted. _Status: Open —
Actionable (Phase 2)._
- **A6 — avatar decorations in-call:** decorations render on **our** pre-join
lobby roster (`CallMemberCard`) but not on EC's in-call video tiles. **Fork
fix:** render the decoration APNG inside EC's participant-tile component, fed
decoration slugs via widget member data. _Status: Open — Actionable (Phase 2)._
- **A7 — mic dead after EC's "Reconnect":** the mid-call "Connection lost /
Reconnect" screen is **EC's own** (our load watchdog only covers an initial
hung load). After EC reconnects, the mic isn't re-published through our denoise
`getUserMedia` shim until a clean End+rejoin. **Fork fix:** move denoise into
EC's mic-capture/publish pipeline as a first-class audio stage — EC re-runs it
on every (re)publish, so reconnects keep denoise alive natively, and the
build-time `index.html` injection is removed. _Status: Open — Actionable
(Phase 2); root cause is the `getUserMedia` monkeypatch, not EC itself._
---
## 🔴 Open — Actionable
### Calls / Audio
- ~~**N127 — ML denoise shim is never injected in `vite dev`.**~~ **RESOLVED (dissolved by the A7 denoise cutover).** `vite.config.js` no longer injects a getUserMedia shim at all — the forked Element Call runs ML denoise in-source as a LiveKit `TrackProcessor` (activated by `lotusDenoiseSource=1`), so there is no build-time injection that could be missing in dev. Nothing to fix.
### 🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED · 👤 SENIOR ENGINEER
> **Observed live in prod 2026-06-30** on `chat.lotusguild.org` during a 2-person
> **Element Call** (E2EE enabled). These span **client rust-crypto (via
> `matrix-js-sdk@41.6.0-rc.0`) ↔ Synapse ↔ Element Call's MatrixRTC E2EE** and are
> very likely **interrelated** (see KE-1 → KE-2). Do **not** spot-fix — they need
> a dedicated cross-system planning session with the homeserver owner. Capture
> full client console + a synapse-side trace for the same call before starting.
> **None of these are caused by the EC fork work** (the issues reproduce on the
> old build; the local mic/denoise path is unrelated to key distribution).
- **KE-1 — One-time-key (OTK) upload conflict storm (CRITICAL, root-cause candidate).**
`POST /_matrix/client/v3/keys/upload` returns `400 M_UNKNOWN: One time key
signed_curve25519:AAAAAAAAAGQ already exists. Old key: {…} new key: {…}`
firing **continuously** (many/sec). The client repeatedly tries to publish an
OTK at a key id the server already holds **with a different value**, i.e. the
rust-crypto key store and Synapse have **diverged OTK state**. Impact: floods
the crypto outgoing-request loop and is the prime suspect for the downstream
missing-key failures (no fresh OTKs ⇒ no new Olm sessions ⇒ undecryptable
to-device key events). _Investigate:_ device/key-store reset-or-restore
mismatch, OTK id-counter desync, RC-SDK (`41.6.0-rc.0`) regression, or a
Synapse OTK bug. Repro signature: grep console for `already exists`.
**Extreme — planning session.**
- **KE-2 — Element Call media keys not arriving/decrypting → audio & video cut out (CRITICAL).**
`MissingKey: missing key at index N for participant @user`, `skipping decryption
due to missing key`, `MissingKey: key set not found for @user at index 0`, and
rust-crypto `WARN … Received an unexpected encrypted to-device event …
event_type="io.element.call.encryption_keys"`. EC distributes per-participant
media keys as **encrypted to-device `io.element.call.encryption_keys`** events;
these aren't being received/decrypted in order, so remote LiveKit audio/video
can't be decrypted — **this is the "friend's audio cuts out occasionally"
symptom.** Almost certainly downstream of **KE-1** (broken Olm sessions). Spans
EC's MatrixRTC E2EE + rust-crypto to-device + Synapse. **Extreme — planning
session.**
- **KE-3 — Timeline decryption error: missing `algorithm` field (HIGH).**
`Error decrypting event (… type=m.room.encrypted …): DecryptionError[msg:
missing field 'algorithm' at line 1 column 138 …]`. A malformed/legacy
encrypted event (or a serialization mismatch in the RC SDK) that rust-crypto
can't parse. Lower frequency than KE-1/2 but a distinct decode-path failure —
capture the offending event id (`$SASBBzoqj…` seen) and inspect its raw content.
- **KE-4 — MatrixRTC delayed-event / membership timeouts (MEDIUM-HIGH, reliability).**
`[MembershipManager] Network local timeout error while sending event, immediate
retry … AbortError: Restart delayed event timed out before the HS responded`,
with repeated `org.matrix.msc4157.update_delayed_event`. MSC4140/4157
delayed-event reliability against `matrix.lotusguild.org` — can cause stale/ghost
call membership and missed leave events. May be partly **homeserver
responsiveness**; correlate with synapse latency/load. Include in the same
planning session since it shares the call-reliability + HS-interaction surface.
### Security & Privacy
- **N97 — Access token stored in plaintext `localStorage`** (`state/sessions.ts`), vulnerable to XSS; device ID likewise. Architectural — needs a token-protection / session-storage redesign.
- **Session writes are non-atomic and not cross-tab synced** (`state/sessions.ts`) — risks inconsistent state / races across tabs.
- **Persisted PII without encryption:** user status message + expiry (`settings/account/Profile.tsx`), unsent composer drafts (`room/RoomInput.tsx`). Leak risk on shared devices.
### PWA / Offline / Notifications
- **N107 — SW has no `push` handler** — Web Push delivery is entirely non-functional. Needs a `push` listener + a Matrix push-gateway integration.
- **No app-asset caching strategy** (`src/sw.ts`) — no offline capability.
- ~~**`manifest: false`** may block PWA install~~ — **verified OK (2026-06):** `index.html` links `/manifest.json`, which exists in `public/` and is copied to `dist/`; VitePWA intentionally doesn't generate one. Not a bug.
### Dependencies & Build
- **`matrix-js-sdk` pinned to a Release Candidate** (`41.6.0-rc.0`); `@atlaskit` and build tools (`vite`, `typescript`, `eslint`) on unstable/experimental pins — review for stable versions; RC SDK is a tree-shaking/bundle-size risk.
- **Build-time overhead:** `lotusDenoise` does heavy sequential `fs` work in `closeBundle`; `viteStaticCopy` config is complex with redundant renames — could be streamlined.
### Code Hygiene / DevEx
- **Automated test suite — 545 tests across 62 modules, a hard CI gate.** `npm test` runs Node's built-in runner via `tsx` (not vitest — Vite 8 is ahead of vitest's range) and **blocks the build job on failure**. Broad pure-logic coverage: utils (common, regex, sanitize/XSS, time, matrix, matrix-uia, mimeTypes, sort, accentColor, findAndReplace, AsyncSearch, ASCIILexicalTable, keyboard, room, matrix-crypto, featureCheck, syntaxHighlight, imageCompression, user-agent, callSounds), state (settings, sessions, recentSearches, upload, typingMembers, lists, room-list, toast, scheduledMessages, backupRestore, callEmbed/callPreferences, spaceRooms, …), plugins (matrix-to, call/utils, via-servers, bad-words, recent-emoji, custom-emoji, markdown block/inline/utils), OIDC (cs-api, useParsedLoginFlows, oidcState), lotus/avatarDecorations, message-search, search filters. Prevention work has caught + fixed **4 real bugs** (`findAndReplace` infinite-loop; `getSettings` crash-on-load when storage is blocked; `isMacOS` never matching modern Macs; `isMLDenoiseSupported` throwing `ReferenceError` instead of returning false on browsers lacking the `AudioWorkletNode` binding). **Next:** component/integration tests (the untestable-under-tsx DOM/React surface).
- **Extensive `as any` casts** across `src/` — gradual typing cleanup.
- **`types/matrix/` mirrors SDK types** instead of importing them — drift risk.
- **Hardcoded CDN URL** should move to an env var (the decoration CDN is now single-sourced in `avatarDecorations.ts`, but the literal is still in-repo).
- **`patch-folds.mjs` edits `node_modules` directly** — consider `patch-package`.
- **Infra docs:** `contrib/nginx` lacks security headers (HSTS/CSP) + uses rewrites over `try_files`; `contrib/caddy` has a placeholder path. CI/CD (`prod-deploy.yml`): sequential deploy, aggressive 1-min Netlify timeout, `package-manager-cache: false`.
- **README:** keep the fork-sync version + logo path current. (`CONTRIBUTING.md` is intentionally left as upstream Cinny's — not a Lotus concern.)
- **Architecture notes (low priority):** deep `features/` + `hooks/` nesting, many small coupled hooks, possible dead CSS/components, `SpacingVariant` / `DropTarget` recipe simplification.
- **Git workflow (forward-looking):** keep commits scoped — past monolithic "fix all bugs" commits and inconsistent prefixes hurt `git bisect`.
### Big Projects
- **#5 — Seasonal themes & chat-background redesign.** Current backgrounds are basic CSS; goal is high-fidelity, research-backed, GPU-accelerated designs (layered `oklch`, `backdrop-filter`, `contain:paint`) with WCAG-AA overlay contrast. Treat each as its own design sprint.
+283 -25
View File
@@ -18,14 +18,16 @@ Last updated: June 2026.
9. [Per-Message Read Receipts](#per-message-read-receipts)
10. [Delivery Status Indicators](#delivery-status-indicators)
11. [Messaging Enhancements](#messaging-enhancements)
12. [Presence](#presence)
13. [UX & Composer](#ux--composer)
14. [Room Customization](#room-customization)
15. [Moderation](#moderation)
16. [Notifications](#notifications)
17. [Server Integration](#server-integration)
18. [Infrastructure](#infrastructure)
19. [Key Custom Files](#key-custom-files)
12. [Threads (P3-8)](#threads-p3-8)
13. [Presence](#presence)
14. [UX & Composer](#ux--composer)
15. [Room Customization](#room-customization)
16. [Moderation](#moderation)
17. [Notifications](#notifications)
18. [Server Integration](#server-integration)
19. [Infrastructure](#infrastructure)
20. [Desktop App Features](#desktop-app-features)
21. [Key Custom Files](#key-custom-files)
---
@@ -322,14 +324,104 @@ Users can set a custom background color for `@mention` chips that highlight thei
## Voice / Video Call Improvements
> 🔱 **[EC-FORK]** Element Call is embedded as a **pre-built npm bundle** today.
> The plan to fork & self-build it from source for true ownership — and which of
> the items below would move into our EC source — is in
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md).
> 🔱 **[EC-FORK] LIVE (2026-06).** Element Call is now our **self-built fork**
> (`@lotusguild/element-call-embedded@0.20.1-lotus.1`, source at
> `LotusGuild/element-call`), served same-origin — no longer the upstream
> 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
> work list are in
> the Element Call fork reference in [`LOTUS_TODO.md`](./LOTUS_TODO.md).
### Element Call Upgrade
### Element Call — Self-Built Fork (`0.20.1-lotus.1`)
Upgraded embedded Element Call widget from **0.16.3** to **0.19.4**.
The embedded widget was upgraded **0.16.3 → 0.19.4 → 0.20.1**, then **forked**.
We self-build `LotusGuild/element-call` and publish it to our private Gitea npm
registry as `@lotusguild/element-call-embedded`; cinny consumes that instead of
`@element-hq/element-call-embedded`. The iframe prints
`Element Call embedded-v0.20.1-lotus.1` in its console (vs. `embedded-v0.20.1`
upstream) — the quickest way to confirm a deploy landed the fork.
All custom behavior lives in the fork's `src/lotus/` modules and is **additive
and dormant by default**, gated by URL flags / widget actions the host opts into,
so a stock EC config is byte-for-byte upstream behavior.
**Active (cinny drives them today):**
| # | Feature | Mechanism | Replaces (old hack) |
| --- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| A7 | **Denoise in-source** | ML noise suppression runs inside EC as a LiveKit `TrackProcessor<Audio>` (flag `lotusDenoiseSource=1`); re-applied on every (re)publish | the build-time `getUserMedia` monkeypatch injected into `index.html`**removed**. Fixes mic-dead-after-reconnect. |
| #2 | **Speaking / mute events** | EC emits `io.lotus.call_state` (throttled); cinny reads speaker + mute state from it (flag `lotusCallState=1`) | scraping EC's DOM for `[data-lk-speaking]` (kept only as fallback) |
| A5 | **Focus participant** | host sends `io.lotus.focus_participant` to pin a tile, coexisting with / overriding the screenshare spotlight | the `.click()`-the-tile DOM hack in `CallControl.ts`**removed** |
| #6 | **In-call avatar decorations** | host pushes `io.lotus.decorations` (per-user APNG URLs); the fork renders them on EC's video-tile avatars | previously impossible — decorations only showed on our pre-join lobby roster |
| #5 | **Native transparent background** | flag `lotusTransparent=1` makes EC's surface transparent so the host wallpaper shows through | the injected `background:none !important` CSS |
**Now wired (cinny drives them — ⚠️ awaiting live verification):**
| # | Capability | Widget action | cinny surface |
| ----- | -------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |
| P5-15 | **Audio inject** | `io.lotus.inject_audio` — plays a clip into the call as a separately published track | In-Call Soundboard (uploadable clips) — see below |
| P5-31 | **Quality controls** | `io.lotus.set_quality` — sets audio/screenshare encoding bitrate/framerate | Call Quality Controls (user settings + room-admin caps) — see below |
> Both were dormant capabilities; cinny now drives them (armed via
> `lotusAudioInject=1`). The **only** EC item still open is the P5-31
> **server-side** quality guard (a `voice-limit-guard`-style sidecar reading
> `io.lotus.room_quality`) for hard enforcement across all Matrix clients — the
> client cap is best-effort.
### In-Call Soundboard (P5-15)
A soundboard button (🔔) in the call controls bar opens a popout of the user's
clips. Clicking one **injects it into the call as a real published LiveKit
track** (every participant hears it, via the fork's `io.lotus.inject_audio`) and
plays it locally for the presser (LiveKit doesn't loop your own track back).
- **User-uploadable, like custom emoji/sticker packs.** Clips are stored in the
`io.lotus.soundboard` account data event, so they **sync across all your
devices**. Upload short audio (≤ 1 MB, ≤ 40 clips) from the popout; delete
inline.
- Authenticated media can't be fetched from the widget's realm, so the host
resolves each mxc clip → an authenticated download → a same-session `blob:`
object URL and hands that to the widget.
- Gated by the **Soundboard** toggle (Settings → General → Calls) with a volume
slider. The button is hidden when disabled.
- Files: `utils/soundboardClips.ts`, `hooks/useSoundboard.ts`,
`features/call/CallSoundboard.tsx`, `plugins/call/CallControl.ts#injectAudio`.
### Call Quality Controls (P5-31)
Discord-style encoding controls applied to the local tracks via the fork's
`io.lotus.set_quality` (`RTCRtpSender.setParameters` across all simulcast
encodings, re-applied on every re-publish/reconnect).
- **User settings** (Settings → General → Calls): Microphone Bitrate,
Screenshare Bitrate, Screenshare Framerate (each defaults to **Auto**).
- **Room-admin caps**: admins set a ceiling in Room Settings → General → Voice
(`io.lotus.room_quality` state event); every Lotus client clamps its per-user
quality to `min(user setting, room cap)`.
- Applied by the `useCallQuality` hook on join and whenever settings/caps
change; `utils/callQuality.ts` builds the payload (unit-tested).
**Server-enforced call permissions (hard, ALL clients).** The same
`io.lotus.room_quality` event carries a **publish-source policy**
(`allow_screenshare`, `allow_camera`) enforced server-side by
`voice-limit-guard` (matrix repo, LXC 151): it re-signs the LiveKit JWT's
`canPublishSources`, so the SFU refuses screenshare/camera tracks for **every**
Matrix client (Element, FluffyChat, our fork) — not just Lotus. Admins toggle
these in Room Settings → Voice → **Call Permissions**; cinny also hides the
blocked buttons in the call bar. Enforcement is **live**: the JWT re-sign covers
new joins, and a background reconcile loop revokes an **in-progress**
screenshare/camera (via LiveKit `UpdateParticipant`) within ~3 s of an admin
flipping the policy — so it kills active shares mid-call, not just future ones.
- **Why numeric caps aren't server-enforced:** LiveKit is a pure SFU (forwards,
never transcodes) and has no publisher bitrate/fps field anywhere in the JWT
grant, room config, server `limit:`, or admin API; stock Element Call ignores
room metadata for publish quality. Numeric caps are therefore inherently
**cooperative** — our fork honors them, which is the design above. The
publish-source policy is the one genuine hard, cross-client lever, and it's
implemented.
- **Not yet**: screenshare resolution control (needs a `getDisplayMedia` hook in
the fork).
### Camera Default Off
@@ -422,7 +514,7 @@ A comprehensive mic noise-suppression system in **Settings → General → Calls
**Advanced Features & Test Options:**
- **Multiple ML Models:** Toggle between **RNNoise** (standard hybrid) and **Speex** (legacy DSP-based) to compare artifact levels and suppression strength.
- **Multiple ML Models:** Four in-source models, selectable from a dropdown **ordered by quality/CPU** (best first): **DeepFilterNet 3** (48 kHz, best), **DTLN** (16 kHz), **RNNoise** (48 kHz), **Speex** (48 kHz, lightest). The **tier default is Browser-native**; when a user opts into ML the default model is **DeepFilterNet 3**.
- **Series Suppression (Combination):** Optional toggle to run the browser's native stationary noise filter _before_ the ML model. This allows testing the individual performance of the ML model vs the combined effectiveness at removing fan hum.
- **Noise Gate:** Configurable hardware-style gate with a dB threshold. Hard-cuts all audio when input is below the threshold, ensuring absolute silence between sentences.
- **Live Microphone Meter:** A real-time volume visualizer in the settings panel to help users accurately tune their Noise Gate threshold.
@@ -431,20 +523,44 @@ A comprehensive mic noise-suppression system in **Settings → General → Calls
- **Support Detection:** UI now detects `AudioWorklet` / `AudioContext` support and disables ML options in unsupported environments.
- **Status Reporting:** The ML shim notifies the host app via `postMessage`. If initialization fails, a system toast alerts the user of the fallback to the raw microphone.
**Open-Source Model Roadmap:**
| Model | Transients (Clicks) | Voice Quality | CPU Usage (WASM) |
| :--- | :--- | :--- | :--- |
| **RNNoise** | Poor | Moderate | < 5% |
| **DTLN** | Good | High | 10-20% |
| **DeepFilterNet 3** | **Excellent** | **Very High** | 25-50%+ |
**Open-Source Models (all now in-source in the EC fork):**
| Model | Transients (Clicks) | Voice Quality | CPU Usage (WASM) | Sample rate |
| :--- | :--- | :--- | :--- | :--- |
| **DeepFilterNet 3** (ML default) | **Excellent** | **Very High** | 25-50%+ | 48 kHz |
| **DTLN** | Good | High | 10-20% | 16 kHz |
| **RNNoise** | Poor | Moderate | < 5% | 48 kHz |
| **Speex** | Poor | Low | < 5% | 48 kHz |
> **Note:** DeepFilterNet 3 is planned for future inclusion in the desktop build where larger binaries and higher CPU overhead are more acceptable.
> **Update (2026-06):** with the EC fork live, denoise runs **inside** Element
> Call as a LiveKit `TrackProcessor` and **all four models ship in-source**
> (DTLN at 16 kHz, the rest at 48 kHz; the processor degrades to the raw mic
> rather than ever going silent). The model picker selects between them.
> **Update (2026-07) — quality, reliability & AEC/AGC:**
>
> - **Quality tuning** (addresses the "robotic/underwater" RNNoise reports):
> a **dry/wet attenuation floor** (default ~-16 dB) blends a little raw mic
> under the denoised signal so suppression can't fully collapse the noise
> floor — applied only to the low-latency flat models (RNNoise/Speex); DTLN/DFN
> would comb-filter, so they rely on their own level. The **noise gate now runs
> after the ML stage**, and **DeepFilterNet 3 level 80 → 60**. Tunable via the
> `lotusDenoiseFloor` param.
> - **AEC/AGC:** browser **echo cancellation stays ON**, but the ML tier now sets
> **auto gain control OFF** (`autoGainControl=false`) so the browser's dynamic
> gain doesn't fight the ML model. Browser/off tiers keep AGC on. (Remote
> playback stays on standard elements — no AEC-defeat vector.)
> - **Reliability:** never-silent watchdog (auto-resume a suspended context),
> `resume()` timeout (no track-lock deadlock), rejected-WASM-fetch eviction
> (transient failures recover), activation off the local participant (works
> solo), and init/build-failure leak fixes.
> - Real-call **audio-quality** A/B (model choice, floor value, AGC on/off) is the
> open by-ear validation item — see `LOTUS_TESTING.md` §D2-1.
### Files
- `build/lotus-denoise.js` — multi-model getUserMedia shim
- `vite.config.js``lotusDenoise()` plugin (copies assets for RNNoise, Speex, and NoiseGate)
- `src/app/plugins/call/CallEmbed.ts` — advanced tier → widget URL params
- **EC fork** `src/lotus/lotusDenoise.ts` + `lotusDenoiseProcessor.ts` — in-source LiveKit `TrackProcessor` (RNNoise/Speex 48 kHz, DTLN 16 kHz, DeepFilterNet 48 kHz); activated by `lotusDenoiseSource=1`. (The old build-time `getUserMedia` shim `build/lotus-denoise.js` is **removed**.)
- `vite.config.js``lotusDenoise()` plugin (now only **copies model assets** for the fork to load; no longer injects a shim)
- `src/app/plugins/call/CallEmbed.ts` — advanced tier → `lotusDenoiseSource` widget URL param
- `src/app/utils/lotusDenoiseUtils.ts` — support detection and model comparison metadata
- `src/app/features/settings/general/General.tsx` — advanced settings UI + mic meter
@@ -575,6 +691,24 @@ Context menu → **Forward** allows forwarding a message to any room the user is
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds) passed to the search API
- A chip shows the active date range with an **×** button to clear it
### Encrypted Search Cache (P4-8, opt-in)
Persistent local index for encrypted-room search, so coverage survives page reloads instead of requiring re-pagination + re-decryption every session.
- Raw IndexedDB (`lotus-search-cache`): message rows keyed `[roomId, eventId]` + per-room coverage markers; merged into local search results with in-memory-wins dedupe
- **Opt-in, default OFF** (it stores decrypted text at rest): toggle + "Clear cached index" live in the search panel's Encrypted Rooms section, with the privacy note "Stores decrypted text on this device"
- Always wiped on logout; any IndexedDB error degrades to a cache-miss (never breaks search)
- Files: `src/app/utils/searchCache.ts`, `src/app/state/searchCacheEnabled.ts`, `features/message-search/useLocalMessageSearch.ts`
### Math / LaTeX Rendering (P4-4)
KaTeX-rendered math in messages, two paths:
- **Spec path (CS-API §11.5):** `<span/div data-mx-maths="…">` in `formatted_body` renders the attribute's LaTeX (block for div, inline for span); on render failure the element's child fallback content shows instead
- **Plain-text path:** `$…$` (inline) and `$$…$$` (block) with conservative rules — escape-aware (`\$`), currency-guarded (`$5 and $10` stays text), never inside `code`/`pre`
- KaTeX + its CSS load lazily on first math encountered — zero cost to the main bundle
- Files: `src/app/utils/mathParse.ts` (+14 tests), `components/math/KaTeX.tsx`, `plugins/react-custom-html-parser.tsx`
### Image / Video Captions
Images and videos can be sent with a caption. The caption and media are sent as a single event.
@@ -650,6 +784,36 @@ Generic (non-domain-specific) cards display a Google S2 favicon. Empty or unpars
---
## Threads (P3-8)
Full threaded-conversation support (`m.thread`, matrix-js-sdk `threadSupport`), Element-consistent.
### Thread Panel
A right-side drawer (mirrors the members drawer; fullscreen on mobile) with the thread's root message emphasized at top, an "N replies" divider, the full reply timeline (virtualized, back-paginates via `/relations`, decrypts E2EE threads), reactions/edits/redactions, and its own composer. Open it from **Reply in Thread** in the message menu, a reply's thread indicator, or a summary chip; close with **×** or Escape. Reading the panel sends threaded read receipts so per-thread unread counts clear.
### Summary Chips
Root messages in the main timeline show a **"N replies · time"** chip (server-aggregated `m.thread` bundle, or the live Thread once loaded) with an unread badge — threaded replies no longer render inline in the main timeline, so the chip is how conversations stay discoverable.
### Thread Composer
The panel embeds the full composer (uploads, emoji, stickers, GIFs, voice, location, polls) with drafts, reply state, and upload queues **isolated per thread** (`roomId::threadRootId` keys). Replies-to-replies produce spec-correct `m.thread` + `m.in_reply_to` (`is_falling_back: false`). Scheduling and slash commands are disabled inside threads (v1).
### Notifications (Slack-style, P4-1)
By default you're notified for a thread reply only when you **participate** in that thread (you've posted in it) or the reply **@mentions** you — other threads accumulate quietly behind their chip badges. Every thread can be overridden from the bell menu in the panel header: **Default (participating) / All replies / Mentions only / Mute**. Modes sync across your devices (`io.lotus.thread_notifications` account data, auto-pruned). Muting a thread silences notifications and sounds, removes the chip's unread badge (a small bell-mute glyph shows instead), and subtracts that thread from the room's sidebar unread badge (client-side — other Matrix clients on the account still count it).
### Under the Hood
- `threadSupport: true` (startClient) partitions thread events into SDK `Thread` timelines; markAsRead sends **unthreaded** receipts so room badges keep clearing
- Thread replies are notified via exactly one path (room-level `ThreadEvent.NewReply` w/ per-thread dedupe + panel-aware focus suppression); the main timeline notifier is thread-guarded, and room badges refresh live on `RoomEvent.UnreadNotifications`
- Pending sends render via a `LocalEchoUpdated` strip (chronological local echo never enters thread timelineSets)
- Deep links to thread events redirect into the panel
- Files: `features/room/thread/*`, `state/room/thread.ts`, `hooks/useThreadSummary.ts` (+35 tests across the stack)
---
## Presence
### Discord-Style Presence Selector
@@ -741,6 +905,14 @@ Hook: `src/app/hooks/useUserNotes.ts`
## 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
A character count indicator is shown in the composer when `charCount > 0`. The counter resets to zero when switching rooms.
@@ -1015,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
### Authenticated Media
@@ -1045,6 +1229,80 @@ The `useAuthentication` parameter was previously mispositioned, causing unauthen
The `encUrlPreview` setting defaults to `true` rather than `false`. A security advisory chip in **Settings → Privacy** explains the tradeoff (the homeserver can see which URLs are being previewed) so users can make an informed choice.
### Hardened Session Storage (N97 partial, 2026-07)
The session persists as ONE atomic `cinny_session_v1` JSON write (previously ~10 separate localStorage keys written non-atomically). Reads prefer the blob with transparent migration from the legacy keys (dual-written one release for rollback). Cross-tab sync: logging out or in from one tab reloads the others so no tab runs with stale credentials. `state/sessions.ts` (22 tests), `hooks/useSessionSync.ts`.
### Crypto Diagnostics (E2EE investigation kit)
**Settings → Developer Tools → Crypto Diagnostics**: a capture-only ring buffer (max 200) hooks `console.warn/error` for E2EE failure signatures (OTK upload conflicts, missing call media keys, decryption errors, delayed-event timeouts) and downloads a JSON report — the evidence input for the KE-1→4 investigation. Companion diagnosis: the Encryption / E2EE section of [`LOTUS_TODO.md`](./LOTUS_TODO.md). `utils/cryptoDiagLog.ts`, `features/settings/developer/CryptoDiagnostics.tsx`.
---
## Desktop App Features
Native capabilities of the Lotus Chat **Tauri v2** desktop app (Windows, macOS, Linux) on top of the shared web client. Web hooks live in `src/app/hooks/useTauri*.ts` (each no-ops in the browser) and call Rust commands in `cinny-desktop/src-tauri/src/native/*`. Windows-only pieces are `#[cfg(target_os = "windows")]`, compile-verified in CI (Windows runners).
### Call Continuity — No-Sleep (P5-46)
Holds the system awake (`SetThreadExecutionState`) while a voice/video call is active; releases on end. `useTauriCallPower``native/power.rs`.
### Windows Jump List (P5-36)
Right-click the taskbar icon → a **Recent Rooms** list of your most-active rooms; each entry opens that room via the `matrix:` deep-link. `useTauriJumpList``native/jumplist.rs` (`ICustomDestinationList`).
### Taskbar Thumbnail Toolbar (P5-44)
Hover the taskbar preview during a call → **Mute / Deafen / End Call** buttons. `useTauriThumbbar``native/thumbbar.rs` (`ITaskbarList3` + a window subclass for `THBN_CLICKED`).
### System Media Transport Controls — SMTC (P5-43)
Exposes call status + a mute control to the Windows volume-flyout / media overlay (WinRT `SystemMediaTransportControls`). `useTauriSmtc``native/smtc.rs`. _Experimental — may require an active audio session to surface._
### Network Awareness (P5-49)
Detects Windows connectivity changes (`INetworkListManager`) and nudges the Matrix client to reconnect (`retryImmediately`). `useTauriNetwork``native/network.rs`.
### Instant Background Sync (P5-42)
Keeps the `/sync` loop + notifications running full-speed while the app is closed to the tray, by disabling Chromium background throttling via WebView2 `additional_browser_args` (`lib.rs`) — no separate background process. Windows/WebView2 only; doesn't block system sleep.
### Native Rich Notifications (P5-41 / P5-35)
Windows toasts with **click-to-open-room** and **inline quick reply** (WinRT `ToastNotification`, in-process `Activated` event). Falls back to the standard toast otherwise. `useTauriToastActions``native/toast.rs`; the desktop notification bridge routes room notifications to it.
### Focus Assist Sync (P5-56)
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)
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`.
### Proactive Update Toast (P5-40)
Checks for a new desktop release every 12h and offers a one-click update. `TauriUpdateFeature` (ClientNonUIFeatures) + `useTauriUpdater`.
### Cross-platform composer niceties
- **Composer toolbar drag-reorder (P5-55)** — drag to reorder the composer buttons (Settings → General), via `@atlaskit/pragmatic-drag-and-drop`.
- **Draft-saved indicator (P5-57)** — a subtle cue in the composer when the current room has a persisted draft.
- **Recursive folder drag-drop (P5-48)** — drop a folder to upload every file inside it (all nesting levels), `utils/fileEntries.ts`.
### Files
- Web: `src/app/hooks/useTauri*.ts`, `src/app/components/TauriDesktopFeatures.tsx`, `src/app/features/desktop/TitleBar.tsx`, `src/app/features/room/DraftIndicator.tsx`, `src/app/utils/fileEntries.ts`, `src/app/state/{customWindowChrome,focusAssist}.ts`.
- Native (`cinny-desktop`): `src-tauri/src/native/{power,jumplist,thumbbar,smtc,network,chrome,toast,focus_assist}.rs` + `native/mod.rs` (registered in `lib.rs`).
---
## Key Custom Files
+212 -11
View File
@@ -1,6 +1,6 @@
# 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.
> **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.
@@ -267,12 +267,59 @@ Flag: `lotusTransparent=1` (native, replacing the injected `background:none !imp
- [ ] Call background looks right — host wallpaper/surface shows through; **no** black box, bad
see-through, or layout breakage (also covered loosely by §D2 "looks right").
### D2-6. Dormant features — confirm they do NOTHING (no regression)
### D2-7. In-Call Soundboard (#3 / P5-15) — 👥 2 people — **NEW**
EC ships the capability but cinny has **no UI** to trigger them yet:
Flag: `lotusAudioInject=1`. A 🔔 **Soundboard** button now sits in the call controls bar (left group,
next to the chat button). Clips are user-uploadable and sync across your devices like emoji packs.
_Prereq:_ Settings → General → Calls → **Soundboard** must be ON (default on).
- [ ] **Soundboard audio-inject (#3)** and **quality controls (#7)** — there should be no new UI and no
effect. (Nothing to test; noted so a tester doesn't go hunting.)
- [ ] **Upload:** open the soundboard popout → **Upload** → pick a short audio file (mp3/ogg/wav, ≤ 1 MB).
It appears as a clip tile. (Too-big / too-many shows an error, doesn't crash.)
- [ ] **Plays into the call:** with a second person in the call, click a clip. **They hear it**, and
**you hear it locally** too. ✅ good if both hear it; ❌ tell us if only one side does.
- [ ] **Sync:** the uploaded clip shows up on your **other device**/session (account-data sync).
- [ ] **Delete:** the ✕ on a tile removes it (everywhere, after sync).
- [ ] **Off switch:** turn Settings → Calls → **Soundboard** off → the call-bar button disappears.
- [ ] Injecting a clip does **not** mute/interrupt your mic or anyone else's audio.
### D2-8. Call Quality Controls (#7 / P5-31) — 👥 2 people — **NEW**
Action: `io.lotus.set_quality`. User settings in **Settings → General → Calls** (Microphone Bitrate,
Screenshare Bitrate, Screenshare Framerate; all default **Auto**). Admin caps in **Room Settings →
General → Voice → Call Quality Caps**.
- [ ] **No regression at Auto:** with everything on **Auto**, calls/screenshare work exactly as before.
- [ ] **User cap takes effect:** set Microphone Bitrate to **32 kbps**, rejoin/continue a call — audio
still flows (thinner is fine). Set Screenshare Framerate to **15 fps** and share your screen — it
still shares. ❌ tell us if any setting kills audio/screenshare.
- [ ] **Applies mid-call:** changing a setting **during** a call takes effect without End+rejoin.
- [ ] **Room-admin cap (admin needed):** as a room admin, set **Max Microphone Bitrate = 64 kbps** in
Room Settings → Voice. A member whose user setting is higher (e.g. 256) should be **clamped to 64**
(best-effort/UX — this is client-side; hard server enforcement is a separate follow-up).
- [ ] Resetting a setting back to **Auto** removes the cap for the rest of the call.
> Soundboard + quality are no longer "dormant" — if either does nothing, grab the **EC iframe console**
> and check for `io.lotus.inject_audio` / `io.lotus.set_quality` rejections.
### D2-9. Call Permissions — HARD server-side, cross-client (👥 2 people, admin) — **NEW**
This is enforced by the `voice-limit-guard` on the server (re-signs the LiveKit JWT), so it applies to
**every** client, not just Lotus Chat. Set in **Room Settings → General → Voice → Call Permissions**.
_(Requires the guard deployed on LXC 151 — auto-deploys on a `matrix` repo push.)_
- [ ] **Disable screenshare:** as admin, turn **Allow Screen Sharing** off. In a call, the
**screenshare button disappears** in Lotus Chat. ✅ good if no one can screenshare.
- [ ] **Cross-client (the important one):** have someone join the **same room from stock Element / Element
X** and try to screenshare → the server **refuses** the track (it won't publish). This proves it's
not just our client hiding a button.
- [ ] **Audio-only room:** turn **Allow Camera** off too → the camera button disappears and cameras are
server-blocked for all clients; **microphones still work**.
- [ ] **⭐ Live kill (mid-call):** while someone is **actively screensharing**, an admin turns **Allow
Screen Sharing** off. Within a few seconds their screenshare should **stop for everyone** on its own
(no rejoin needed) — this is the server reconcile loop revoking it live. Works even if the sharer is
on stock Element. ✅ good if the share drops within ~35 s; ❌ tell us if it keeps going.
- [ ] **Turning it back on** restores the ability to screenshare/camera (start a new share).
- [ ] **No policy = no change:** a room with Call Permissions left on defaults behaves exactly as before.
> If any D2 item fails, grab the **EC iframe console** (right-click the call → inspect the iframe) — a
> widget-action/payload mismatch shows up there as a `io.lotus.*` rejection or a `MissingKey`/transport log.
@@ -281,7 +328,7 @@ EC ships the capability but cinny has **no UI** to trigger them yet:
# 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)
@@ -526,10 +573,164 @@ 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
1. **A4** (in-call banner) + **A3** (ringtone) — newest, most logic, hardest to reproduce.
2. **B1B3** (polls on a default theme) — the confirmed visual bug.
3. **D** (EC 0.20.1 control sweep) — guards against the upstream merge breaking calls.
4. **A7** false-positive check (normal joins don't show the error overlay).
5. Everything else.
1. **O1 + O2** (threads + per-thread notifications) — the largest new surface; the main-timeline change is user-visible.
2. **O7** (desktop CSP smoke) — CI can't catch CSP breakage; a wrong directive silently breaks media/fonts/maps.
3. **O5** (session cross-tab) + **O6** (scheduled-cancel ghost-send) — auth-critical + a real data-loss-class fix.
4. **A4** (in-call banner) + **A3** (ringtone) — newest call logic, hardest to reproduce.
5. **D** (EC control sweep) — guards against the fork breaking calls.
6. Everything else.
---
## Outstanding verification backlog
**Room Widgets (MSC1236, 2026-07 — needs the CSP `frame-src` widening + `nginx -s reload` first):** In a room, the header **Widgets** button (grid icon, desktop) opens a right-side panel. As an admin (PL to modify widgets): **Add Widget** with a name + an https URL (e.g. an Etherpad `https://…` or any embeddable page) → it appears in the list; click it → it renders in a sandboxed iframe in the panel; **Remove** clears it. A non-admin sees the list + can open widgets but has no Add/Remove. Check: a non-https or same-origin URL is rejected on Add with a clear message; the panel is a full-screen overlay on mobile and is mutually exclusive with the Thread/Gallery/Members panels; if a widget stays blank, the prod CSP `frame-src` still needs widening. Widgets get only benign display capabilities (they can't send/read room events in v1).
**QR Device Verification (2026-07):** With two logged-in Lotus sessions (or Lotus + Element), start a device verification. On the **Ready** step you now see your own QR code plus a **"Scan their QR code"** button and a **"Verify with emoji instead"** fallback. Have one device **scan** the other's code (grant camera permission) → the showing device asks you to **Confirm**, and both reach **verified**. Check: emoji-SAS still works unchanged; denying camera shows a graceful "verify with emojis instead" message; a deliberately-wrong scan cancels cleanly. Desktop (WebView2) auto-grants the camera; web needs the Permissions-Policy camera allowance (already set).
**Disappearing Messages (MSC1763 `m.room.retention`, 2026-07):** In Room Settings → General → **Message Retention**, an admin picks Off / 1 Day / 1 Week / 1 Month (non-admins see the buttons disabled). After setting e.g. 1 Day, messages older than a day **vanish from the timeline** for everyone in Lotus (toggle Settings → General → **Show Hidden Events** to reveal them again). Setting back to **Off** restores them. Separately, each user can enable Settings → General → **Enforce Message Retention** (default OFF) → their OWN expired messages then get **permanently redacted** within ~30 s (verify: OTHER people's messages are NEVER redacted by this; only your own). Note true server-side purge also needs Synapse `retention:` configured.
**Mark as Unread + Low Priority (MSC2867 / m.lowpriority, 2026-07):** Right-click a room in the sidebar → **Mark as Unread** puts a dot on the row (bold name) even with no new messages; opening/reading the room clears it, and it syncs to another device. **Mark as Read** on a marked room clears it too. Right-click → **Add to Low Priority** moves the room into a collapsed "Low Priority" category at the bottom of the room list (and removes it from Favorites if it was there, and vice-versa); **Remove from Low Priority** returns it to Rooms.
**Windows rich toast (D6, 2026-07 — desktop/Windows build only):** get a message notification while the desktop app is backgrounded → the toast is attributed to **Lotus Chat** (not "PowerShell"/generic) and shows an inline **reply box + Send**; typing a reply + Send **posts it to that room**; clicking the toast body **opens the room**. Previously these silently fell back to a plain toast (no reply/click). If it still falls back, check that a `Lotus Chat.lnk` exists in the Start-Menu Programs folder.
**Invite QR is now generated LOCALLY (2026-07):** Room settings → Share Room → the QR code renders (a black-on-white SVG in a white box) with **no network request** to `api.qrserver.com` (check DevTools Network — there should be no external QR fetch, and it should work offline / behind strict CSP). **Scan it** with a phone camera / Matrix app → it opens the correct `matrix.to` room-invite link. (`api.qrserver.com` was removed from the prod CSP img-src, so a regression would make the QR blank rather than silently phone home.)
**Unread dot on federated rooms + avatar-decoration console storm (2026-07):**
- **Read receipts (regression guard — highest priority):** open several rooms and open the Home/Direct tabs (which mark all orphan rooms read on mount) → rooms **stay read**, unread dots clear and don't come back. (A prior attempt sent a receipt for the thread _root_ when a thread's replies weren't loaded, which the SDK treats as a main receipt at an old event and re-unread every room on every mark-read. Fixed + locked by `notifications.test.ts`.)
- **Thread dot:** a room with an unread reply in a thread whose replies are loaded → its dot clears on read; for a thread not yet loaded, the dot clears once you open/load the thread. (mark-as-read now sends a threaded receipt only for a genuine loaded reply, never the root.)
- With DevTools console open on federated rooms, the `io.lotus.avatar_decoration` `403`/`502` (and federated media) errors should **not** repeat on every scroll/mount — each failing user is now requested at most ~twice per session, so the storm (and its homeserver load) is gone.
**Custom Window Chrome (Beta) fix (2026-07):** on the desktop build, Settings → General → toggle **Custom Window Chrome** — it should reload and come up with the Lotus title bar and a normal, stable feed (no screen-expand / auto-scroll-into-the-past). Toggle back off → reloads to the native frame.
_Ported from the retired `LOTUS_BUGS.md` (2026-07). Compact index of shipped-but-not-live-tested items; the detailed steps are in the lettered sections above._
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
| ID | Item | File / area | Test |
| :--- | :-------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
| #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 |
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
| #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 |
| #12 | PiP "All muted" badge re-fixed (was firing on any single mute) | `hooks/useCallSpeakers.ts` | G1 |
| N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 |
| N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 |
| N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 |
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
| N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room |
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
| P3-8 | Thread Panel (side drawer, chips, threaded receipts, thread composer) | `features/room/thread/*`, `RoomTimeline/RoomInput` | 6-step checklist in LOTUS_TODO §P3-8 |
| P4-4 | KaTeX math (`$…$`, `$$…$$`, data-mx-maths; lazy chunk) | `utils/mathParse.ts`, `components/math/` | send `$x^2$`, `$$\int f$$`, `$5 and $10` (stays text), math inside code block (stays text) |
| P4-8 | Encrypted-search cache (opt-in toggle, clear button, logout wipe) | `utils/searchCache.ts`, message-search | enable in search panel → search → reload → coverage persists; logout wipes |
| N97a | Session blob migration + cross-tab logout sync | `state/sessions.ts`, `useSessionSync` | login on old build → new build migrates; logout in tab A → tab B drops to auth |
| P4-1 | Slack-style thread notifications (participating default, All/Mentions/Mute, badge math) | `utils/threadNotifications.ts`, `ClientNonUIFeatures`, `roomToUnread` | 6-step checklist in LOTUS_TODO §P4-1 |
| AW-1 | Scheduled-message cancel no longer ghost-sends (error row on failure) | `ScheduledMessagesTray.tsx` | schedule → cancel with network cut → item stays + error; retry works |
| AW-2 | Emoji lazy-load (search/autocomplete/recents fill in; board opens fast) | `plugins/emoji.ts` + consumers | first emoji-board open of a session: grid+search populate; reactions still label |
| AW-3 | SW precache (repeat-visit near-instant; deploys still picked up immediately) | `sw.ts`, `vite.config.js` | load app twice (2nd = cached assets); deploy → reload picks new version |
| AW-4 | Desktop CSP tighten + Escape/panel fixes + thread Jump to Latest | `tauri.conf.json`, Room/ThreadPanel | desktop: boots, avatars/media load, VT323 font renders, location maps embed, calls connect, deep links work |
| P3-4 | Accessibility compliance pass (collapsed-msg SR sender, form/overlay labels, typing announce, focus-return, `?` help, jsx-a11y CI gate) | `message/*`, `RoomViewTyping`, `features/shortcuts/*`, `eslint.config.mjs` | LOTUS_TESTING §P — axe-core + VoiceOver/NVDA on the golden path |
| P6-1 | Desktop Linux parity (no-sleep in calls, launcher badge), autostart toggle, tray Do-Not-Disturb | `native/power.rs`, `lib.rs`, `useTauriDnd`, `General.tsx` | Linux desktop: no display sleep during a call; tray DND silences notifications; launch-on-login persists; Unity badge (Ubuntu); DND toggle polarity |
| P6-2 | EC deafen/screenshare-audio-mute via `io.lotus.set_deafen` (retires the `<audio>.muted` iframe hack) | fork `lotusDeafen.ts`, cinny `CallControl.ts` | AFTER publish+pin-bump: deafen silences remote audio + survives a reconnect / new screenshare / late joiner (the cases the DOM hack failed); screenshare-audio-mute toggles independently |
| P6-3 | Forward-to-multiple-rooms (multi-select + partial-failure summary) + live bookmark previews (edits/redactions, snapshot fallback) | `ForwardMessageDialog.tsx`+`forwardContent.ts`, `BookmarksPanel.tsx` | forward one msg to 3 rooms (incl. 1 you cannot post to = partial summary); bookmark then edit shows edited; redact shows deleted; leave room shows snapshot |
| P6-4 | HSTS + Permissions-Policy on prod nginx (+ contrib examples) | `matrix/cinny/nginx.conf`, `contrib/nginx`, `contrib/caddy` | after `nginx -s reload`: `curl -sI https://chat.lotusguild.org` shows HSTS + Permissions-Policy; a call (cam/mic/screenshare) + location share still work |
**Verified working in live testing (2026-06):** A2, B1B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.
---
+198 -654
View File
@@ -1,704 +1,248 @@
# Lotus Chat — Work Backlog
**Repo:** `lotus` branch at `https://code.lotusguild.org/LotusGuild/cinny`
**Repo:** `lotus` branch at `https://code.lotusguild.org/LotusGuild/cinny`
**Deploy:** push to `lotus` → CI → auto-deploy to `chat.lotusguild.org` (~11 min)
> Completed features are documented in [LOTUS_FEATURES.md](./LOTUS_FEATURES.md). Manual test steps live in [LOTUS_TESTING.md](./LOTUS_TESTING.md). This file is **open work only** — resolved audit findings and shipped-feature write-ups were removed 2026-07 (full history in git).
Status legend: `[ ]` pending · `[~]` in progress / shipped-awaiting-QA · `[x]` done · `[BLOCKED]` server/upstream-gated · `[DEFERRED]`/`[DROPPED]`/`[WON'T FIX]` decided.
---
## ⚠️ TDS DESIGN LAW — READ BEFORE TOUCHING ANY UI
> **ALL Lotus Terminal Design System (TDS) styling — colors, animations, glows, borders, fonts, spacing — MUST come exclusively from `/root/code/web_template/base.css` CSS variables.**
> Do NOT hardcode hex values. Do NOT invent new variable names. Do NOT deviate from the design tokens defined in that file.
> The canonical variable reference: `--lt-accent-orange`, `--lt-accent-cyan`, `--lt-accent-green`, `--lt-glow-orange`, `--lt-box-glow-*`, `--lt-border-color`, etc.
> Reference implementation for code patterns: `/root/code/tinker_tickets/` (markdown.js, base.js, ticket.css)
> This rule applies to EVERY task in this file without exception.
---
> Do NOT hardcode hex values. Do NOT invent new variable names. Canonical tokens: `--lt-accent-orange`, `--lt-accent-cyan`, `--lt-accent-green`, `--lt-glow-*`, `--lt-box-glow-*`, `--lt-border-color`, `--lt-font-mono`. Syntax-highlight token classes: `.tok-kw .tok-str .tok-num .tok-cmt .tok-fn`.
> Reference patterns: `/root/code/tinker_tickets/` (markdown.js, base.js, ticket.css). Applies to every task without exception.
> New components must respect both TDS dark (`LotusTerminalTheme`) and TDS light (`LotusTerminalLightTheme`); non-TDS theme work uses vanilla-extract (match `src/lotus-terminal.css.ts`).
## 🧩 NATIVE-CINNY LAW — EVERY FEATURE MUST FEEL LIKE STOCK CINNY
> **Every feature we implement must feel native to the upstream Cinny app — indistinguishable from something the Cinny team would have shipped.** Reference: <https://github.com/cinnyapp/cinny>.
> **Every feature must feel native to upstream Cinny — indistinguishable from what the Cinny team would ship.** Reference: <https://github.com/cinnyapp/cinny>.
>
> Concretely this means:
>
> - **Use the `folds` design system, not bespoke UI.** Build with folds primitives (`Button`, `Chip`, `IconButton`, `Menu`, `MenuItem`, `Dialog`, `Modal`, `Input`, `Switch`, `Badge`, `SettingTile`, `SequenceCard`, etc.) and folds tokens (`color.*`, `config.space.*`, `config.radii.*`, `config.borderWidth.*`). No hardcoded hex/`rgba()` for UI chrome, no invented/undefined CSS variables.
> - **Match Cinny's existing patterns.** Before adding UI, find the closest existing Cinny component/flow and mirror it (e.g. a new dropdown uses `Button`+`PopOut`+`Menu`+`MenuItem` like the rest; a new modal has a `Header` with a close `IconButton`; a new setting is a `SettingTile` inside a `SequenceCard`). Consistency with stock Cinny beats personal style.
> - **Lotus-custom additions should be unobtrusive** and fit Cinny's visual language, spacing, and interaction conventions — a stranger using Cinny should not be able to tell which features are ours.
>
> **The ONE exception:** explicit **Lotus Terminal Design System (TDS)** features, which intentionally have their own distinct look and follow the **TDS Design Law** above. TDS styling is opt-in (only active in Lotus Terminal mode); everything else must look and feel like native Cinny.
> - **Use the `folds` design system, not bespoke UI** (`Button`, `Chip`, `IconButton`, `Menu`, `MenuItem`, `Dialog`, `Modal`, `Input`, `Switch`, `Badge`, `SettingTile`, `SequenceCard`, …) and folds tokens (`color.*`, `config.space.*`, `config.radii.*`). **Use folds `Icon`/`Icons`, never literal emoji, in UI chrome.** No hardcoded hex/`rgba()`, no invented CSS variables.
> - **Match Cinny's existing patterns** — find the closest existing component/flow and mirror it before adding UI.
> - **The ONE exception:** explicit **TDS** features, which follow the TDS Design Law above (opt-in, only in Lotus Terminal mode).
---
Completed features are documented in [LOTUS_FEATURES.md](./LOTUS_FEATURES.md).
## ✅ Audit (2026-07) — closed out
A three-wave feature bug-hunt (~15 parallel agents, each batch independently reviewed) plus a low-tail cleanup. All confirmed 🔴/🟠 and the clean 🟡 tail are **fixed, reviewed, and gate-green**; details in git history + LOTUS_FEATURES. Only the minor items below remain open.
**Still open (low tail — all 🟡 minor):**
- **Calls host:** C-M1 deafen DOM-fallback leaks late-added `<audio>` tracks; C-M2 `.click()`-by-testid toggles no-op if EC renames — **both retire via EC-fork P6-2**. C-L1 AFK mic not released if EC elides the echo; C-L2 ringtone-preview global cross-cancel; C-L3 first ring after cold load can be silent (ctx not unlocked); C-L5 speaker-observer churn on membership change; C-L7 all-muted DOM miscount if EC label format differs; C-L8 PiP sw/nw resize anchor jitter at min size.
- **Threads:** T5 `participating` detection is server-bundle-only (`thread.hasCurrentUserParticipated`) → can under-notify a thread you just replied to; T6 room "Mentions & Keywords" not honored for participated/Default thread replies (over-notify); T7 account-data thread-mute write is a lost-update race.
- **Crypto/session:** F5 OIDC refresh drops `expiresAt` on persist (`persistTokens` can't reach the expiry without SDK-internal plumbing; refresh is reactive on 401).
- **Native/desktop:** D7 Unity badge `application://cinny.desktop` id may not match the installed `.desktop` basename — **runtime-verify** on the `.deb`/AppImage. H10 room-name setter fire-and-forget/silent length reject (trivial). N6 per-message read-receipt avatars may not refresh on membership change (emitter uncertain, low impact).
- **EC fork (EC1EC6 fixed on `element-call:lotus`, needs a republish):** re-apply `setTimeout` cleanup, remote-gated subscription → `allConnections$`, per-call decoration state leak, re-subscribe-every-render, focus-clear on missing `userId`. Rides with **P6-2 phase 2**.
---
## ✅ Done — Awaiting Verification
## ✅ Shipped — Awaiting Live Verification
Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then they graduate to LOTUS_FEATURES.md. (Bug-side fixes awaiting verification live in LOTUS_BUGS.md.)
Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then graduate to LOTUS_FEATURES.md. Includes the **desktop/native Tier A/B stack** (P5-35/36/41/42/43/44/46/47/48/49/55/56/57, P6-1 Linux parity) — all **CI-compile-verified, runtime-verify on Windows/Linux** — plus:
| Feature | Test guide |
| :-------------------------------------------------------------------------------- | :---------------- |
| Full-Screen Camera Broadcasts (per-participant focus) | A5 / G2 |
| Advanced search filters (sender/date/has-link/has:image·file·video/pinned/recent) | K2 / M1 / M2 / M4 |
| Custom Accent Color Picker (non-TDS themes) | M3 |
| 5 Color Theme Presets (Cyberpunk/Ocean/Blood Red/Matrix/Midnight) | M5 |
| Intersection-based lazy media loading | H1 |
| Context-aware thumbnail previews | H2 |
| Desktop — proactive update notifications (Tauri) | J1 |
| Remind Me Later | K1 |
| Mobile Bookmarks access | E5 |
| Area | Test guide |
| :-------------------------------------------------------------------------- | :-------------------- |
| Full-Screen Camera Broadcasts (per-participant focus) | A5 / G2 |
| Advanced search filters + virtualized infinite scroll | K2 / M1 / M2 / M4 |
| Custom Accent Color Picker (non-TDS) · 5 Color Theme Presets | M3 / M5 |
| Intersection lazy media loading · context-aware thumbnails | H1 / H2 |
| Thread Panel (side drawer) + per-thread notification modes (P4-1) | (thread QA) |
| Encrypted message search indexing/caching (opt-in, default OFF) | search backlog |
| Remind Me Later · Mobile Bookmarks access | K1 / E5 |
| In-Call Soundboard (P5-15) · Quality Controls (P5-31) · Permissions (P5-31) | D2-7 / D2-8 / D2-9 |
| Desktop proactive update notifications (P5-40) | J1 |
| OIDC/SSO login (P4-6, needs an MSC3861 server — pick mozilla.org on login) | OIDC |
| Windows native WinRT toast quick-reply / click-to-open (D6, AUMID) | rich-toast (§backlog) |
---
Legend:
## 🔴 Open — Actionable
- `[AUDIT REQUIRED]` — at least one assumption needs code/server verification before implementing
- `[SERVER CHECK]` — depends on a Synapse feature or MSC; verify on `matrix.lotusguild.org`
- `[LOW PRIORITY]` — implement after all higher-priority items
- `[EXTREME COMPLEXITY]` — multi-sprint, plan separately before touching
- `[BLOCKED]` — cannot build until a server upgrade, upstream MSC, or dependency resolves
- `[IMPROVE]` — feature exists in upstream Cinny; this task enhances it for Lotus Chat
### ✅ Unread/read-receipt flakiness (reported 2026-07) — FIXED (pending prod QA)
Status: `[ ]` pending · `[~]` in progress · `[x]` completed
Room unread dots were inconsistent: reading a message sometimes cleared the dot, sometimes left it stuck, sometimes it resurrected. Root cause (confirmed by tracing + diffing upstream cinny `dev`): **our own "N4" change.** `handleReceipt` recomputed via `getUnreadInfo`, which reads `room.getUnreadNotificationCount()` — server-computed and **stale on the synchronous synthetic receipt echo** (SDK only zeroes it immediately when the last event is your own message) → it PUT the stale non-zero count back → stuck/resurrecting. Compounded by `hasUnread = !!unread` lighting the dot on any present map entry, incl. phantom `{0,0}` PUTs from our `UnreadNotifications` listener. Plus a Mark-as-Unread (MSC2867) flag that never cleared on opening an already-read room (no receipt → no auto-clear).
**Fix:** `roomToUnread.ts``handleReceipt` reverts to upstream's optimistic `DELETE` on own receipt; reducer collapses `{0,0}` PUT → DELETE. `notifications.ts markAsRead` clears the marked-unread flag directly. `markedUnread.ts onReceipt` gated to main/unthreaded receipts (`myMainReceiptPresent`). Unit tests added; 700/700 pass, typecheck + build clean. Deploy + manual QA (read → dot clears & stays; thread read; mark-unread → open → clears; reconnect no resurrect).
### 🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED
Observed live in prod 2026-06-30 during a 2-person **Element Call** (E2EE). These span client rust-crypto (`matrix-js-sdk@41.7.0`) ↔ Synapse ↔ EC MatrixRTC E2EE and are **interrelated** — do NOT spot-fix. **Capture first:** run **Settings → Developer Tools → Crypto Diagnostics** during the next affected call + a synapse-side trace before any fix. (Full runbook was in `LOTUS_E2EE_INVESTIGATION.md`, now in git history.) None are caused by the EC fork work.
- **KE-1 — OTK upload conflict storm (CRITICAL, root-cause candidate).** `POST /keys/upload` returns `400 M_UNKNOWN: One time key … already exists` continuously — the rust-crypto store and Synapse have **diverged OTK state** (upstream `matrix-rust-sdk#5200`, OPEN: on the 400 the SDK never marks the request sent → re-uploads forever; **not** fixed in 41.7.0). Leading web trigger: cinny never calls **`navigator.storage.persist()`**, so the IndexedDB crypto store is evictable while the `localStorage` session survives → device resurrects with a blank store. **Buildable preventive fix (no call needed):** request persistent storage on login (+ optional multi-tab guard + a 400-loop→recovery prompt). Healing an already-diverged device still needs a clean logout+login.
- **KE-2 — EC media keys not arriving/decrypting → audio/video cut out (CRITICAL).** `MissingKey … for participant`, unexpected encrypted to-device `io.element.call.encryption_keys`. Almost certainly downstream of KE-1 (broken Olm sessions). This is the "friend's audio cuts out" symptom.
- **KE-3 — Timeline decrypt error: missing `algorithm` field (HIGH).** rust-crypto can't parse a malformed/legacy encrypted event — capture the offending event id + raw content.
- **KE-4 — MatrixRTC delayed-event / membership timeouts (MEDIUM-HIGH).** `Restart delayed event timed out`, repeated `msc4157.update_delayed_event` — may be partly HS responsiveness; correlate with synapse latency. Same planning session (shares the call-reliability surface).
### Security & Privacy
- **N97 — Access token + device id in plaintext `localStorage`** (`state/sessions.ts`), XSS-exposed. Architectural — needs a token-protection / session-storage redesign.
- **Persisted PII without encryption:** user status message + expiry (`Profile.tsx`), unsent composer drafts (`RoomInput.tsx`). Leak risk on shared devices.
### PWA / Offline / Web Push
- **N107 — Web Push is non-functional:** `src/sw.ts` has no `push` handler. Needs a `push` listener + Matrix push-gateway integration. **The one substantive remaining feature** (session/crypto groundwork it waited on has landed).
- **No app-asset caching strategy** in `src/sw.ts` — no offline capability.
### Dependencies / Build / Hygiene
- Build-time: `lotusDenoise` does heavy sequential `fs` in `closeBundle`; `viteStaticCopy` has redundant renames — could be streamlined.
- `patch-folds.mjs` edits `node_modules` directly (robust today; `patch-package` considered but more brittle to folds restructuring — WON'T-DO unless it breaks).
- `types/matrix/` mirrors SDK types instead of importing them — drift risk; spot-fix highest-risk only.
- `contrib/nginx`/`contrib/caddy` examples: headers + `try_files` already synced with prod; the prod nginx `add_header` isn't inherited by cache `location` blocks (pre-existing; SPA entry `/` still gets all headers).
- `as any` casts across `src/` — gradual typing cleanup. Keep commits scoped (bisect-friendly). Keep README fork-sync version/logo current.
---
## Server Capabilities (as of June 2026)
## 🌐 Matrix Protocol Gaps
- **Homeserver:** `matrix.lotusguild.org`
- **Synapse version:** `1.155.0` (2026-06-18) — fully up to date; last version for Debian 12 (LXC 151 already on Debian 13 Trixie)
- **Matrix spec:** up to `v1.12` formally; newer MSC features via `unstable_features`
Genuine Matrix client-spec / MSC features Lotus does **not** yet implement (audited 2026-07 against the codebase — almost everything else is built: pinning, stickers+picker, room directory, mutual rooms MSC2666, blurhash, key backup/recovery/SSSS, SAS verification, ignore list, invite spam-filter, voice messages, polls, threads, spaces, OIDC, extended profiles, delayed events, authed media). Build each **fully** — spec-correct events, native-Cinny folds UI, tests. Order = clean wins first.
### Confirmed facts
**Phase A ✅ (2026-07, gate-green 683 tests):**
| Finding | Impact |
| --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| **MSC flags ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` · `msc3401_matrix_rtc` | All safe to use now |
| **MSC flags OFF:** `msc4306` (thread subscriptions) · `msc3882` · `msc3912` · `msc4155` | These features are BLOCKED |
| **MSC3266** room summary: flag `msc3266_enabled: true` set but `GET /v1/rooms/{id}/summary` still returns 404 (M_UNRECOGNIZED) | Room Preview BLOCKED — endpoint not implemented in Synapse 1.155 |
| **MSC3892** relation redaction: not in flags | Reaction Redaction feature BLOCKED |
| **MSC4260** report user: `POST /_matrix/client/v3/users/{userId}/report` returns **200** ✅ | **Report User UNBLOCKED** — endpoint live since Synapse 1.133; ready to build |
| **MSC4151** report room: HTTP 405 on GET = endpoint exists (POST only) | Report Room live ✅ |
| `folds AvatarImage` does NOT accept children | Add frame/overlay inside `UserAvatar.tsx` itself — optional `frameName` prop |
| No in-app toast system exists (was) | Built `ToastProvider` + Jotai queue; at `App.tsx:65` |
| `useUnverifiedDeviceCount()` hook exists | `src/app/hooks/useDeviceVerificationStatus.ts:65-106` |
| Voice player: `AudioContent.tsx:44-223` | Playback rate on hidden `<audio>` at line 217 |
| `CallControl.setMicrophone(bool)` at `CallControl.ts:206-212` | For AFK auto-mute |
| `CallControl.toggleSound()` at `CallControl.ts:230-251` | Push-to-deafen — just wire a hotkey to this |
| matrix-js-sdk has NO arbitrary profile field methods | Use `mx.http.authedRequest()` for MSC4133 |
| Sanitizer (`sanitize.ts`) allows table, div, span, a, code, hr | LFG HTML card is safe locally; test on Element/FluffyChat |
| Sanitizer STRIPS `<math>`/MathML tags | Math/LaTeX task must also modify sanitizer |
| Service worker EXISTS at `src/sw.ts` | Quick-reply task: add `notificationclick` handler |
| `knockSupported()` utility exists at `matrix.ts:376-391` | Knock UX: only need "Request to Join" in `RoomIntro.tsx` |
| `KeywordMessages.tsx` already has custom keyword push rules | Full push rule editor: only non-keyword rule types need new UI |
| `getMatrixToRoom()` in `matrix-to.ts` generates invite URLs | Invite link: just add QR code to room settings |
| Cindy CANNOT inject audio into EC call stream | In-call soundboard must be redesigned as local-only |
| Folds uses vanilla-extract in non-TDS, NOT CSS custom properties | Custom accent color: must create new vanilla-extract theme variant dynamically |
| Theme presets need ~50 CSS custom properties each | Significant design work before coding |
| `useCallSpeakers.ts` CSS MutationObserver polling | Visual speaking indicator: TDS ring animation on top of existing data |
| MSC3489/3672 live location: BOTH false on server | Live Location BLOCKED |
- [x] **Mark as Unread — MSC2867 `m.marked_unread`.** Room account data `{ unread: true }` (+ unstable `com.famedly.marked_unread`) via `mx.setRoomAccountData`; clear on read. Context-menu item in `RoomNavItem` + light the existing unread dot; integrate `state/room/roomToUnread.ts`.
- [x] **Low Priority rooms — `m.lowpriority` tag.** Mirror the favourite impl (`RoomNavItem.tsx:331-337` `setRoomTag/deleteRoomTag` + the favourites category in `home/Home.tsx`): context-menu toggle + a collapsed "Low Priority" category sorted to the bottom, excluded from normal unread nudging.
**Phase B ✅ (2026-07, gate-green 688 tests):**
- [x] **Disappearing Messages — MSC1763 `m.room.retention`.** PL-gated room-settings `SettingTile` to set `{ max_lifetime }`; retention badge; a client-side sweep hides/self-redacts own expired events (pattern like the mute-timer restore in `ClientNonUIFeatures.tsx`). True server deletion also wants Synapse `retention:` (LXC 151).
- [x] **QR Device Verification — reciprocate QR.** Add the QR path beside emoji-SAS in `components/DeviceVerification.tsx`: render with `qrcode.react` (already a dep), scan via `BarcodeDetector` (fallback `jsQR`); uses the SDK `VerificationRequest` QR/reciprocate support.
**Phase C (Room Widgets ✅ 2026-07; Sliding Sync ❌ evaluated — parked):**
- [x] **Room Widgets — MSC1236 + widget API.** No general widget UI exists (only the PL entry `im.vector.modular.widgets`; the EC call widget is hardcoded). Read `im.vector.modular.widgets`/`m.widget` state, add an Add/Manage panel + sandboxed iframe renderer via `matrix-widget-api`**extend the existing EC widget plumbing** (`plugins/call/CallEmbed.ts`). Enables Etherpad/notes/dashboards/integrations.
- **[PARKED] Sliding Sync — MSC3575 / simplified MSC4186** (evaluated 2026-07, 3 research passes). Server side is GA (`simplified_msc3575`), but the **client** side is not viable for a safe rollout: matrix-js-sdk's `SlidingSync`/`SlidingSyncSdk` are `_internal_`/`@experimental` (Element shipped labs-only, never GA in ~2 yrs, moved to the Rust SDK); **presence isn't delivered over sliding sync** (regresses Lotus presence badges/rings/status); **no upstream Cinny impl** to follow; and Cinny's whole nav (sidebar/spaces/DM/unread) is derived from the **full local room set** (`allRoomsAtom``mx.getRooms()`), so ~14 subsystems (4 core) need re-architecting to a server-windowed list. ~10% confidence a full rollout wouldn't break/regress (missing rooms/messages/unread = worst failure class). **Revisit only if we adopt the Rust SDK or accounts grow large enough that startup latency is a real complaint; an off-by-default experimental spike is possible but not recommended.** Full assessment: git plan history.
**Room Widgets v1 follow-ups:** capability-approval consent prompt (let widgets request send/read room events); Jitsi/stickerpicker special types; account-data (user/sticker) widgets; per-widget popout / always-on-screen. Requires the prod CSP `frame-src` widening (done in `matrix/cinny/nginx.conf`**`nginx -s reload`**) or external widgets are blocked.
**Server-gated / advanced (capture, don't build yet):** QR sign-in for a new device (**MSC4108** rendezvous — needs an HS-side endpoint); dehydrated devices (**MSC3814** — offline key delivery, also helps the E2EE KE cluster); E2EE history key sharing on invite (**MSC3061** `shared_history`, niche); voice broadcast (Element MSC3888, low value — skip).
### Remaining spec/MSC gaps (2026-07 full-surface survey)
After Phases AC the client spec is ~complete. What's left, flagged by **what unblocks it**:
**✅ Buildable NOW (client-only, no server/infra change):**
- [ ] **Custom room tags / sections** — user-defined room categories in the sidebar via standard `u.*` room tags (beyond the built-in Favourite / Low-Priority). Mirrors the favourite/low-priority category pattern (`RoomNavItem` context-menu + `Home.tsx` categories). _Medium._ The only substantive client-only feature left.
**🔧 Needs INFRASTRUCTURE (NOT a Synapse-flag flip — you'd have to stand it up):**
- **Invite by email / 3PID invite** — we invite by Matrix user-ID only (`mx.invite` is user-ID-only). Email invites need an **identity server** (lotusguild runs none). Build only if an identity server is deployed.
- QR sign-in for a new device (**MSC4108**) — needs a **rendezvous** endpoint. Dehydrated devices (**MSC3814**) — needs server support. (Also listed above.)
**🚫 BLOCKED until a Synapse upgrade enables the flag** — re-run `/_matrix/client/versions` `unstable_features` after each upgrade; client work is ready the moment the flag flips. See the **Blocked Features** section below:
- Live Location Sharing (**MSC3489** + **MSC3672** — both `false`)
- Reaction / relation redaction (**MSC3892** — `false`)
- Room preview before joining (**MSC3266** — summary endpoint 404s on 1.155)
- Thread subscriptions (**MSC4306** — `false`)
**Niche / low-value (noted, not planned):** E2EE history-key-on-invite (MSC3061), voice broadcast (MSC3888), a native account-deactivation flow (currently delegated to the OIDC provider for OIDC accounts).
**Already implemented (verified, not gaps):** space reordering (drag — confirmed working in the desktop client), pinning, stickers + picker, room directory, mutual rooms (MSC2666), blurhash, key backup / recovery / SSSS / cross-signing / key export-import, SAS **and** QR verification, ignore list, invite spam-filter, voice messages, polls, threads + per-thread notifs, spaces, OIDC, extended profiles, delayed/scheduled events, authed media, report user/room/message, 3PID contact-info display, disappearing messages, mark-unread, low-priority, room widgets.
---
## Key File Reference
## 📋 Open Feature Backlog
| What you need | File | Lines |
| -------------------------------- | ------------------------------------------------------------- | ------------------- |
| Global keydown hook | `src/app/hooks/useKeyDown.ts` | whole file |
| Room navigation | `src/app/hooks/useRoomNavigate.ts` | 19-72 |
| All room IDs atom | `src/app/state/room-list/roomList.ts` | `allRoomsAtom` |
| Room unread counts | `src/app/state/room/roomToUnread.ts` | `roomToUnreadAtom` |
| Overlay portal provider | `src/app/pages/App.tsx` | 65 |
| Portal container div | `index.html` | 101 |
| Room settings tabs | `src/app/features/room-settings/RoomSettings.tsx` | 27-56 |
| State event read/write pattern | `src/app/features/common-settings/general/RoomEncryption.tsx` | 42-52 |
| Power level checker | `src/app/hooks/usePowerLevels.ts` | whole file |
| Slash command registration | `src/app/hooks/useCommands.ts` | 140-537 |
| Chat background picker | `src/app/features/settings/general/General.tsx` | 945-981 |
| Chat backgrounds definition | `src/app/features/lotus/chatBackground.ts` | whole file |
| Matrix.to URL builder | `src/app/plugins/matrix-to.ts` | `getMatrixToRoom()` |
| Media event content types | `src/app/types/matrix/common.ts` | 46-91 |
| Media URL conversion | `src/app/utils/matrix.ts` | `mxcUrlToHttp()` |
| Message pagination (search) | `src/app/features/message-search/useMessageSearch.ts` | 74-121 |
| Infinite pagination pattern | `src/app/features/message-search/MessageSearch.tsx` | 234-365 |
| Poll event format | `src/app/components/message/content/PollContent.tsx` | 1-320 |
| Theme class application | `src/app/hooks/useTheme.ts` | 25-60 |
| Animations file | `src/app/styles/Animations.css.ts` | whole file |
| Message status (EventStatus) | `src/app/features/room/message/Message.tsx` | 84-142 |
| Call member change events | `src/app/hooks/useCall.ts` | 37-52 |
| Mic control in calls | `src/app/plugins/call/CallControl.ts` | 206-212 |
| Device verification hook | `src/app/hooks/useDeviceVerificationStatus.ts` | 65-106 |
| Knock room support check | `src/app/utils/matrix.ts` | 376-391 |
| Room join button location | `src/app/components/room-intro/RoomIntro.tsx` | 25-119 |
| Notification mute via push rules | `src/app/hooks/useRoomsNotificationPreferences.ts` | 110-150 |
| Message text body CSS | `src/app/components/message/layout/layout.css.ts` | 182-205 |
### [ ] Basic in-app audio editor / video→audio extractor (LARGE PROJECT)
A minimal audio editor for soundboard clips and voice content. Scope: (1) **trim/clip** an audio file to a chosen start/end (waveform scrubber, in/out handles); (2) **upload a video file → strip and discard the video track, keep only the audio** (extract audio, then the source video is dropped — never uploaded/stored); (3) minimal edits only (trim, maybe gain/normalize, fade in/out) — not a full DAW. Likely Web Audio API (`AudioContext.decodeAudioData` → trim `AudioBuffer` → re-encode) + `MediaRecorder`/an encoder for output; video demux via a `<video>`+`MediaElementSource` capture or ffmpeg.wasm (weigh bundle cost). Feeds the soundboard uploader (`utils/soundboardClips.ts`, `SoundboardPackEditor`) and attachments. Design under TDS + native-cinny law. Big build — plan a dedicated session; evaluate ffmpeg.wasm size/CSP (wasm) before committing.
### [ ] P4-4 · Math / LaTeX Rendering (LOW PRIORITY)
Render `$…$` / `$$…$$` via KaTeX; graceful fallback to raw text. **Sanitizer must be patched**`src/app/utils/sanitize.ts` (sanitize-html, `disallowedTagsMode:'discard'`) strips all MathML: add `<math><mi><mo><mn><mrow><mfrac><msqrt><mroot><msub><msup><msubsup><munder><mover><mtable><mtr><mtd>…` + `annotation` to `permittedHtmlTags`, and `xmlns`/`display`/`mathvariant` to `permittedTagToAttributes`. Parser: split text nodes on `/(\$\$.*?\$\$|\$.*?\$)/g` in `react-custom-html-parser.tsx``<KaTeX>`. Lazy-import `katex/dist/katex.min.css` only when a math block renders. Verify KaTeX bundle-size impact.
### [~] P5-20 · Quick Reply from Browser Notification (partial)
Done: notifications show the real body, click navigates to the specific event + focuses the tab. **Remaining:** inline reply via Notification Actions API needs the SW `push`+`notificationclick` pipeline (switch `new Notification()``serviceWorkerRegistration.showNotification()` so the SW receives `notificationclick`; on `event.action==='reply'` POST `m.room.message` with the stored `{roomId, threadId}`). Ties into N107.
### [~] P5-30 · Advanced ML Noise Suppression — open verification
Shipped in the EC fork (DeepFilterNet3 default-capable / DTLN / RNNoise / Speex; AEC on, AGC off for ML tier; never-silent watchdog). **Open:** real-call by-ear **A/B** — model choice, `lotusDenoiseFloor`, AGC on/off (LOTUS_TESTING §D2-1 / J2). **GTCRN (deferred):** tiny MIT 16 kHz model beating RNNoise, but no drop-in browser package — needs `onnxruntime-web` in a Web Worker behind a custom AudioWorklet ring-buffer (ORT can't run in an AudioWorklet, issue #13072); ~1-week build. Revisit only if low-power quality proves insufficient. HW-gated (FRCRN/Maxine) = desktop-Rust-only future.
### [~] P6-2 · Element Call fork — retire remaining DOM hacks (Phase 2 needs publish)
Phase 1 shipped: `io.lotus.set_deafen` (LiveKit-source deafen/screenshare-audio-mute) replaces the brittle `<audio>.muted` iframe hack; cinny sends it join-gated alongside the transitional DOM fallback. **Phase 2 (blocked on user npm publish):** publish fork `0.20.1-lotus.2` → bump cinny pin `lotus.1``lotus.2` → delete the `CallControl.ts` `.muted` fallback + the EC1EC6 fixes ship. **Deferred pieces (P6-2b):** the `useCallSpeakers` DOM-scrape is a dormant fallback behind `io.lotus.call_state`; `.click()`-by-`data-testid` UI toggles are low-value fork surface. Divergence to confirm: deafen doesn't silence soundboard/`Unknown`-source audio (setVolume type limit).
### [ ] Mobile audit
Comprehensive audit of all LOTUS_FEATURES.md features for mobile PWA usability + responsiveness. Method: 44px touch targets, no horizontal overflow, full-screen modals/drawers on mobile, composer not obscured by keyboard.
### Deferred / dropped (decided — kept for context)
- **[DEFERRED] P5-51** Federated "Identity Contexts" (session isolation) — multi-sprint, touches auth/crypto/storage core; smaller intermediate step = plain multi-account switch. **[DROPPED] P5-52** per-room sync governor — js-sdk can't truly per-room filter `/sync`; only a cosmetic hide. **[DEFERRED] P5-53** local scripting plugin — prefer a declarative automation-rules feature (no arbitrary code). **[DEFERRED] Audit-3** profile banner — MSC4427 open/unmerged; revisit on merge. **[WON'T FIX] P5-50** Windows HW media pipeline (WebRTC decode lives in WebView2; not injectable). **[MOVED] P5-9** LFG → LotusBot `!lfg`.
---
## Priority 3 — Higher complexity / lower daily frequency
## 🚫 Blocked Features (server / upstream gated)
### [ ] P3-4 · Accessibility Improvements (WCAG 2.1 AA)
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade.
**What:** Comprehensive audit and fix pass targeting the critical user paths:
- Room list navigation (keyboard-only)
- Reading messages in the timeline (screen reader announces new messages)
- Composing and sending a reply
- Opening and closing modals (focus trap, return focus)
- ARIA labels on all icon-only buttons
**Scope:** Do NOT attempt to make every corner of the app AA-compliant in one pass — focus on the golden path (open app → find room → read → reply → send).
**[AUDIT REQUIRED]** — Run an automated audit first: `npx axe-core` or browser DevTools accessibility tree. Document every violation before writing a single line of code. Prioritize by severity (critical > serious > moderate).
**Investigation Findings:**
- **Root Cause:** Inconsistent focus management, missing `aria-live` regions for dynamic timeline updates, and sparse global keyboard shortcuts.
- **Approach:** Standardize `focus-trap-react` usage (reference `RoomNavItem.tsx`). Add `aria-live` regions to the timeline. Expand `useKeyDown.ts` for section navigation shortcuts.
- **Complexity:** Medium-High (audit is the main work).
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
- **[BLOCKED] Room Preview before joining** (MSC3266) — `GET /v1/rooms/{id}/summary` returns 404 `M_UNRECOGNIZED` on Synapse 1.155 despite `msc3266_enabled:true`.
- **[BLOCKED] Thread Subscriptions** (MSC4306 `false`) — "Follow thread" button (depends on the shipped Thread Panel).
---
### [ ] P3-8 · Thread Panel (full side drawer)
**⚠️ LARGEST FEATURE — requires its own planning session before implementation.**
**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:
- Click "Reply in Thread" → opens thread drawer on the right
- Thread root event shown at the top of the panel
- Full message rendering for all in-thread replies (reuse timeline components)
- Reply input at the bottom (full composer with formatting, emoji, etc.)
- Unread count badge on the thread button in the main timeline
- Keyboard shortcut to close thread panel
**Architecture:**
- New Jotai atom: `activeThreadEventId: string | null`
- New component: `src/app/features/room/thread/ThreadPanel.tsx`
- Rendered alongside `RoomView` as a conditional right panel (mirror the members drawer pattern)
- Filter events in timeline to `m.thread` relation for the active root event ID
- Shares the same `mx` client and room reference as the main timeline
**[AUDIT REQUIRED]** — Deeply audit how `m.thread` relation events are currently stored and retrieved in the matrix-js-sdk. Understand the thread aggregation API: `GET /rooms/{roomId}/relations/{eventId}/m.thread`. Check if `RoomTimeline.tsx` currently filters out thread replies from the main timeline (it should — confirm).
**Investigation Findings:**
- **Root Cause:** Current `m.thread` events are treated as standard `m.room.message` events and rendered in the main timeline.
- **Approach:** Introduce new Jotai atom `activeThreadEventId`. Create `ThreadPanel.tsx`. Update `RoomTimeline.tsx` to filter out thread relations (`m.relates_to`). Implement aggregation fetch using `GET /rooms/{roomId}/relations/{eventId}/m.thread`. Use `thread.timelineSet` directly for the most accurate thread view.
- **Complexity:** High.
---
## Priority 4 — Specialized, high complexity, or low priority
### [ ] P4-7 · Virtualized Infinite Scroll for Search Results
**What:** Replace the manual "load more" button with an automated, virtualized infinite scroll for search results.
**Approach:** Utilize `@tanstack/react-virtual` in `MessageSearch.tsx` to handle the `nextToken` automatically as the user scrolls.
### [ ] P4-8 · Encrypted Message Search Indexing & Caching
**What:** Implement a persistent local cache for search results, optimized for encrypted rooms.
**Approach:** Use `IndexedDB` to store search metadata (event IDs, timestamps) to prevent redundant server-side decryption/fetching.
### [ ] P4-1 · Thread Notification Mode Per-Thread (MSC3771)
**Spec:** MSC3771 (stable). Depends on Thread Panel (#P3-8).
**What:** Per-thread notification toggle: "All messages" vs "Mentions only". Accessible from the thread panel header. Tracks unread counts separately per thread.
**[AUDIT REQUIRED]** — Implement after Thread Panel. Requires understanding how the SDK tracks per-thread unread counts.
**Complexity:** Medium (after thread panel exists).
---
### [ ] P4-2 · Thread Subscriptions (MSC4306) [BLOCKED]
**Spec:** MSC4306 (Synapse experimental). Depends on Thread Panel (#P3-8).
**What:** "Follow thread" button to receive notifications for a thread you haven't posted in. Uses MSC4306 subscription endpoint.
**[SERVER CHECK]** — `org.matrix.msc4306 = false` on `matrix.lotusguild.org` — BLOCKED until server enables it.
**Complexity:** Medium (after thread panel exists).
---
### [ ] P4-4 · Math / LaTeX Rendering in Messages (LOW PRIORITY)
**Spec:** CS-API §11.5 (stable) — `formatted_body` can contain LaTeX.
**What:** Render `$...$` or `$$...$$` LaTeX expressions in message bodies. Use KaTeX (lightweight, ~100KB, renders server-side-compatible CSS). Must gracefully fall back to raw LaTeX text if KaTeX fails.
**Note:** This is LOW PRIORITY — only useful for academic/technical communities. Implement last.
**[AUDIT REQUIRED]** — Confirm KaTeX bundle size impact on the Vite bundle. Check if matrix-js-sdk's HTML sanitizer strips LaTeX before it reaches the renderer. The formatted_body sanitization pipeline is the main risk here. (Confirmed: sanitizer STRIPS `<math>` tags — must be patched alongside the renderer.)
**Complexity:** Low-Medium.
---
### [ ] P4-5 · Live Location Sharing (MSC3489 + MSC3672) (LOW PRIORITY, HIGH COMPLEXITY) [BLOCKED]
**Spec:** MSC3489 + MSC3672. Implemented in Element Web.
**Note:** Static location sharing is already implemented. This adds live/real-time GPS beacons. Very low priority per user preference.
**What:** Start sharing live location → creates `m.beacon_info` state event → client posts `m.beacon` events on a timer → other users see your position update live on a map.
**[SERVER CHECK]** — `org.matrix.msc3489 = false` AND `org.matrix.msc3672 = false` on `matrix.lotusguild.org` — BLOCKED.
**Complexity:** High. Requires background geolocation API + live map rendering.
---
### [~] P4-6 · OIDC / SSO Next-Gen Auth (MSC3861) — CLIENT-SIDE BUILT, awaiting live verification
**Spec:** MSC3861 / MSC2965, Matrix spec v1.15. OAuth2-native auth via a Matrix Authentication Service (MAS).
**Scope decision (2026-06):** CLIENT-ONLY. We implemented OIDC login _in the Lotus client_ so it can sign into next-gen homeservers (mozilla.org, eventually matrix.org). We deliberately did **not** convert lotusguild's own Synapse to MAS (no account migration; lotusguild keeps password + legacy Authelia SSO).
**Built (matrix-js-sdk already ships the OIDC API; this was wiring):**
- Discovery: `cs-api.ts` `getOidcIssuer()` (stable `m.authentication` + msc2965). Flow hint: `useParsedLoginFlows` `getOidcCompatibilityFlag()` (MSC3824).
- Login: `pages/auth/oidc/{oidcConfig,oidcLoginUtil,oidcState}.ts` (dynamic registration + cache, PKCE authorize), `login/OidcLogin.tsx`, issuer-gated `Login.tsx`.
- Callback: `oidc/OidcCallback.tsx` + `App.tsx` short-circuit (non-hash redirect path).
- Session/refresh: `state/sessions.ts` OIDC fields, `client/{oidcTokenRefresher,oidcLogout}.ts`, `initMatrix.ts` wiring.
- Account mgmt: `settings/account/OidcManageAccount.tsx`.
- 13 unit tests (discovery/flow/session/cache/callback parsing). All gates green.
**Awaiting verification (needs a real MSC3861 server — lotusguild is NOT one):** deploy + log into **mozilla.org** (requires adding mozilla to the deployed `config.json` homeserverList + its domains to the CSP `connect-src`/`img-src` — see below), OR run a local `matrix-authentication-service` + Synapse `msc3861` dev loop.
**To enable the mozilla.org test:** add to `matrix/cinny/config.json` homeserverList `"mozilla.org"`, and to the nginx CSP `connect-src`/`img-src`: `https://mozilla.org https://mozilla.modular.im https://chat.mozilla.org https://vector.im`.
---
## Priority 5 — Gamer / Aesthetic / Customization
### [MOVED] P5-9 · LFG (Looking for Group) Command → LotusBot
**Decision:** Implemented as `!lfg` in LotusBot rather than a client slash command. Bot-side rendering works consistently across all Matrix clients; client-side enhanced cards would only be visible to Lotus Chat users and require sanitizer auditing. The bot can also support richer flows (list active LFGs, DM interested players, auto-expire posts).
---
### [ ] P5-15 · In-Call Soundboard
**What:** Grid of short audio clips playable into the call audio stream via Web Audio API (AudioBufferSourceNode → MediaStreamDestinationNode → mixed with mic). Built-in clips + user-uploadable custom clips (stored as mxc://). Accessible from call controls bar.
**[AUDIT REQUIRED]** Verify the Element Call integration exposes the mic MediaStream for mixing. This is the highest-risk part of this feature.
**🔱 [EC-FORK]** Owning the EC source (see [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md)) would unblock real audio-injection — a proper soundboard mixed into the call — which is impossible against the prebuilt bundle today.
**Complexity:** High.
---
### [~] P5-20 · Quick Reply from Browser Notification
**What:** Inline reply field in browser notification toasts via Notification Actions API. Reply sends as threaded reply to the triggering message.
**[AUDIT REQUIRED]** (1) Verify browser Notification Actions API support in target browsers. (2) Confirmed: service worker EXISTS at `src/sw.ts` — add `notificationclick` handler there.
**Complexity:** Medium-High.
**Partial Fix Applied ⚠️ UNTESTED:** Notifications now (a) show the real message body (`username: message` instead of "New inbox notification from..."), (b) click navigates directly to the room at the specific event (not the inbox), (c) `window.focus()` called on click so the tab comes to front, (d) reminder toasts also link to the specific event. Full inline-reply via Notification Actions API still needs the SW `push`+`notificationclick` pipeline (requires switching from `new Notification()` to `showNotification()` through the SW).
---
### [x] P5-30 · Advanced ML Noise Suppression (Krisp-style)
**What:** High-end background noise cancellation using a pre-trained ML model (RNNoise) running in the browser. Removes dogs, fans, and keyboard clicks from the mic stream.
**Shipped:** 3-tier setting (Off / Browser-native / ML) in Settings → General → Calls. ML tier injects a same-origin pre-init shim into the vendored Element Call `index.html` that monkeypatches `getUserMedia` and routes the captured mic through an RNNoise `AudioWorklet` before LiveKit publishes — no EC fork required. See LOTUS_FEATURES.md → "Noise Suppression (Advanced Multi-Tier)".
**🔱 [EC-FORK]** Once we own the EC source (see [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md)), denoise should become a first-class audio stage **inside** EC instead of an `index.html` getUserMedia monkeypatch — more robust, survives reconnects (fixes the A7 mic-after-reconnect bug), and removes the build-time injection hack.
**Key decision:** LiveKit's Krisp filter is LiveKit-Cloud-only (we self-host the SFU); EC's own RNNoise PR #3892 is unmerged. The shim is the same post-capture pipeline #3892 uses, executed from the realm we control, so it survives EC version bumps.
**AEC note (resolved-as-accepted):** WebAudio capture routing can weaken browser AEC — same tradeoff as EC's upstream feature; mitigated by keeping `echoCancellation`/`autoGainControl` on the raw capture and labeling the tier "beta".
**Model Roadmap (priority order):**
- [ ] **Verify DTLN** (16 kHz narrowband fix) in a real call before investing further — wired but unverified.
- [ ] **DeepFilterNet 3** — best self-hostable upgrade: Rust→WASM, CPU real-time, 48 kHz fullband. Effort: self-host `df_bg.wasm` + DFN3 ONNX model, wire a 48 kHz worklet.
- [ ] **Desktop-only / HW-gated:** FRCRN or NVIDIA Maxine (RTX/Tensor only) — impossible in-browser; would run in Tauri Rust backend + bridge a virtual mic into the webview. Must detect capability and only offer on supported hardware; web falls back to RNNoise.
- **Excluded:** Krisp (LiveKit Cloud only); FRCRN/Maxine on web (GPU/server-bound).
---
### [ ] P5-31 · Granular Voice & Screenshare Quality Controls (Discord-style)
**What:** Let users (or room admins via room settings) adjust audio bitrates (e.g., 64kbps to 512kbps) and screenshare quality (resolution: 720p/1080p/Source, framerate: 15/30/60fps).
**Note:** Requires tight integration with the LiveKit SFU and custom state events for per-room quality caps.
**[AUDIT REQUIRED]** Must verify if current `lk-jwt-service` can be extended with custom bitrate/resolution claims or if a new sidecar (similar to `voice-limit-guard`) is needed for server-side enforcement.
**Complexity:** Extreme.
---
### [ ] P5-35 · Desktop — Notification Click Opens Room (DEFERRED)
**What:** Clicking a system tray notification navigates to the relevant room. Quick-reply from the notification toast would send the reply without opening the window.
**Status:** Deferred — `tauri-plugin-notification` has no Rust click/action callback API. Quick-reply would need a custom WinRT toast activator + COM registration, which can't be compile-tested without a Windows build environment.
**Note:** Tray icon and `matrix:` deep links already bring the window forward on most interactions. Revisit when tauri-plugin-notification gains click handler support upstream.
**Complexity:** High (platform-specific native code required).
---
### [ ] P5-36 · Desktop — Windows Jump List (DEFERRED)
**What:** Right-clicking the taskbar icon shows a jump list with recent/favorite rooms for quick navigation.
**Status:** Deferred — implementing the Windows COM jump list API in Tauri requires iterating on C++/COM code that can only be compile-checked on Windows, making blind CI iteration impractical.
**Action when unblocked:** Revisit when a Tauri plugin abstracts the Windows Shell `ICustomDestinationList` interface, or when a Windows build environment is available for local iteration.
**Complexity:** High (Windows-only native COM).
---
### [ ] P5-41 · Desktop — Native WinRT Toast Notifications
**What:** Replace emulated notifications with native WinRT Toast notifications.
**Approach:** Implement native WinRT Toast integration using `windows-rs` to enable full Action Center integration, including native Quick Reply functionality.
### [ ] P5-42 · Desktop — Persistent Background Sync
**What:** Maintain light connection to homeserver when WebView2 is suspended.
**Approach:** Implement a headless Rust sidecar to fetch unread counts/notifications while the webview is suspended to ensure instant notification delivery.
### [ ] P5-43 · Desktop — System Media Transport Controls (SMTC)
**What:** Integrate with Windows SMTC for volume flyout call/media control.
**Approach:** Use Windows SMTC API to expose call status, mic mute/unmute, and media controls to the Windows volume flyout/media overlay.
### [ ] P5-44 · Desktop — Taskbar Thumbnail Toolbar
**What:** Add persistent call controls to the taskbar preview.
**Approach:** Implement a COM thumbnail toolbar in the application preview window, featuring Mute/Deafen/End Call buttons.
### [ ] P5-46 · Desktop — System Power Management (Call Continuity)
**What:** Prevent system sleep/hibernate during active calls.
**Approach:** Use Tauri/Rust `power-manager` or platform-specific APIs to block system power saving states while a voice/video session is active.
### [ ] P5-47 · Desktop — TDS-Styled Native Window Chrome
**What:** Replace system titlebar with custom Lotus TDS chrome.
**Approach:** Configure Tauri window (`decorations: false`) and implement custom, TDS-token compliant titlebar controls (Close/Max/Min) for a cohesive UI.
### [ ] P5-48 · Desktop — Native File System Drag-and-Drop Improvements
**What:** Enhance drag-and-drop support for Windows.
**Approach:** Improve handling for Windows file shortcuts, recursive folder uploads, and shell-integrated "Send To" context menu actions.
### [ ] P5-49 · Desktop — Network Awareness (NCSI Integration)
**What:** Proactively detect Windows network connectivity changes.
**Approach:** Integrate with the Windows Network Connectivity Status Indicator (NCSI) API to improve offline mode transition latency and network recovery.
### [ ] P5-50 · Desktop — Windows Hardware-Accelerated Media Pipeline
**What:** Replace standard browser decoding with native Windows Media Foundation.
**Approach:** Leverage DirectShow/Media Foundation to offload video/audio decoding from the CPU to the GPU, significantly reducing power consumption and latency during calls.
### [ ] P5-51 · Desktop — Federated "Identity Contexts" (Isolation Manager)
**What:** Compartmentalize sessions, local databases, and caches into isolated "Contexts."
**Approach:** Implement a zero-leak boundary for personas (e.g., Work vs. Personal) by isolating `IndexedDB`, filesystem caches, and session persistence per context.
**Priority:** Extreme Low (Multi-sprint/Architectural).
### [~] P5-52 · Desktop — Room-Level Sync Governor (Performance Control) [STILL_CONSIDERING]
**What:** Granular sync tuning for individual rooms.
**Approach:** Allow per-room overrides for sync frequency and event type filtering (e.g., disable read receipts/typing in heavy rooms) to optimize performance. Implementation requires careful UX to prevent complexity fatigue.
### [ ] P5-53 · Desktop — Local-Only "Scripting" Plugin System (Tampermonkey-like)
**What:** A sandboxed environment for local execution of user scripts on Matrix events.
**Approach:** Implement a WASM-based execution engine that allows users to write local-only, client-side scripts to interact with incoming Matrix events, trigger sounds/notifications, or inject custom UI elements based on event payload rules. Designed for privacy — all logic runs exclusively on the local machine.
### [ ] P5-55 · Desktop — Composer Toolbar Drag-and-Drop Reordering
**What:** Allow users to reorder toolbar icons via drag-and-drop.
**Approach:** Extend the current settings-based toolbar toggle system to include a drag-and-drop UI mode in the composer settings, allowing users to personalize their icon order.
### [ ] P5-56 · Desktop — Windows "Focus Assist" (DND) Sync
**What:** Automatically toggle notification state based on Windows Focus Assist.
**Approach:** Integrate with the Windows `NotificationCenter` / `Focus` state via Tauri/Rust to automatically enable/disable Lotus Chat's internal notification suppression mode when Windows Focus Assist is toggled.
### [ ] P5-57 · Desktop — Visual Draft Persistence Indicator
---
## 🚀 Features to Add
- [ ] **Mobile Audit:** Comprehensive audit of all features in LOTUS_FEATURES.md for mobile PWA usability and layout responsiveness.
---
## Blocked Features
These features are confirmed desirable but cannot be built until the listed dependency is resolved.
Check back after each Synapse upgrade — re-run `/matrix/client/versions` and `unstable_features` to see if they've become available.
### [BLOCKED] · Live Location Sharing (MSC3489 + MSC3672)
**Blocked by:** `org.matrix.msc3489 = false` AND `org.matrix.msc3672 = false` on `matrix.lotusguild.org` (confirmed from unstable_features).
**What it would do:** Real-time GPS beacon streaming upgrading the existing static location share.
**Action when unblocked:** Both MSCs must be enabled on the homeserver before any client work.
### [BLOCKED] · Reaction / Relation Redaction (MSC3892)
**Blocked by:** `org.matrix.msc3892` = false on `matrix.lotusguild.org`
**What it would do:** Cleanly remove a reaction without redacting the parent message.
**Current behavior:** Full event redaction — acceptable fallback, no user-facing issue.
**Action when unblocked:** Find `onReactionToggle` redaction call site; swap in MSC3892 endpoint with fallback.
### [BLOCKED] · Room Preview Before Joining (MSC3266)
**Blocked by:** `GET /_matrix/client/v1/rooms/{roomId}/summary` returns `M_UNRECOGNIZED` 404 — endpoint not implemented in Synapse 1.155. Config flag `msc3266_enabled: true` is set but has no effect; Synapse appears not to have shipped a stable implementation at the v1 path. Verified 2026-06-18.
**What it would do:** Show room name, topic, avatar, member count before joining.
**Action when unblocked:** Re-test after each future Synapse upgrade.
### [BLOCKED] · Thread Subscriptions (MSC4306)
**Blocked by:** `org.matrix.msc4306` = false on `matrix.lotusguild.org`
**What it would do:** Follow a thread without posting; get notifications for replies.
**Action when unblocked:** Add "Follow thread" button in the thread panel header (depends on #P3-8 Thread Panel).
### [DONE] · Report User (MSC4260) ✅
**Previously blocked by:** Server spec v1.12, but `POST /_matrix/client/v3/users/{userId}/report` was confirmed **200** on 2026-06-18 (live since Synapse 1.133.0).
**What it does:** Reports a specific user to homeserver admins (separate from reporting a message).
**Note:** Report Message already exists in upstream Cinny. This adds Report User to the profile panel.
**Implemented 2026-06-18:** `ReportUserModal.tsx` added at `src/app/features/room/ReportUserModal.tsx`. Button wired into `UserRoomProfile.tsx` between UserModeration and UserDeviceSessions (hidden for own profile). Category dropdown + reason text, inline success/error feedback, auto-close 1500ms after success.
---
## Pending Audits
### [ ] Audit-3 · Profile banner image — Matrix protocol support
Research whether Matrix spec or MSC4133 (v1.16) defines a standard profile banner field. `uk.tcpip.msc4133.stable = true` on our server — check if a `banner_url` or similar field is defined. If no cross-client standard exists, do not implement.
---
## 📚 Implementation Reference
Exhaustive, low-level implementation details for backlog items. Follow these patterns to ensure code is "Lotus-perfect" (idiomatic, performant, and TDS-compliant).
### P3-8 · Thread Panel (Full Side Drawer)
**Architecture:** Mirror the `MembersDrawer` pattern but with a specialized timeline.
- **State (`src/app/state/room/thread.ts`):**
```typescript
export const activeThreadIdAtom = atom<string | null>(null);
```
- **Layout (`src/app/features/room/Room.tsx`):** Insert `ThreadPanel` conditionally alongside `RoomTimeline`:
```tsx
{
activeThreadId && (
<>
<Line variant="Background" direction="Vertical" size="300" />
<ThreadPanel roomId={roomId} threadId={activeThreadId} />
</>
);
}
```
- **Component (`src/app/features/room/thread/ThreadPanel.tsx`):** Use `room.getThread(threadId)` from the SDK. Render a `Header` with a "Close" button that sets `activeThreadIdAtom` to `null`. Reuse `RoomTimeline` but pass a filtered `EventTimelineSet`. Use `thread.timelineSet` directly for the most accurate thread view.
---
### P4-4 · Math / LaTeX Rendering
**Mechanism:** KaTeX injection into the HTML parser.
- **Sanitizer (`src/app/utils/sanitize.ts`):** Allow KaTeX-specific tags and classes (e.g., `span`, `annotation`, `math`). Use a specialized allowed list for math blocks.
> [Gemini_Found] `sanitize.ts` uses **`sanitize-html`** (not DOMPurify) with an explicit allowlist (`allowedTags`) and `disallowedTagsMode: 'discard'`. All MathML tags are currently absent from the allowlist and are silently stripped. Update `permittedHtmlTags` to include: `<math>`, `<mi>`, `<mo>`, `<mn>`, `<ms>`, `<mtext>`, `<mspace>`, `<mrow>`, `<mfrac>`, `<msqrt>`, `<mroot>`, `<mstyle>`, `<merror>`, `<mpadded>`, `<mphantom>`, `<mfenced>`, `<menclose>`, `<msub>`, `<msup>`, `<msubsup>`, `<munder>`, `<mover>`, `<munderover>`, `<mmultiscripts>`, `<mtable>`, `<mtr>`, `<mtd>`, `<maligngroup>`, `<malignmark>`, and `annotation`. Also add the required MathML attributes (e.g. `xmlns`, `display`, `mathvariant`) to `permittedTagToAttributes`.
- **Parser (`src/app/plugins/react-custom-html-parser.tsx`):** Detect `$ ... $` and `$$ ... $$` patterns in text nodes:
```tsx
if (node.type === 'text') {
const parts = node.data.split(/(\$\$.*?\$\$|\$.*?\$)/g);
return parts.map((p) => {
if (p.startsWith('$')) return <KaTeX math={p.replace(/\$/g, '')} />;
return p;
});
}
```
- **CSS (`src/app/styles/CustomHtml.css.ts`):** Import `katex/dist/katex.min.css` only when a math block is rendered to save initial bundle size.
---
### P4-6 · OIDC / SSO Next-Gen Auth (MSC3861)
**Mechanism:** Matrix Authentication Service (MAS) Integration.
- **Architecture:** Shift from password-based `/login` to OAuth2 `authorization_code` flow.
- **Key Files:** `src/app/pages/auth/Login.tsx` and `src/app/hooks/useAuth.ts`.
- **Implementation:** Use `oidc-client-ts` or a similar lightweight OIDC library. Check for `m.authentication` in `/.well-known/matrix/client`. Redirect to the MAS authorization endpoint. Handle the callback in a new `OidcCallback` route and store the OIDC `refresh_token`.
---
### P5-1 · Custom Accent Color Picker (Non-TDS only)
**Mechanism:** Dynamic CSS variable injection.
- **Setting (`src/app/state/settings.ts`):** Add `customAccentColor: string` (hex).
- **Manager (`src/app/pages/ThemeManager.tsx`):** Inside the `useEffect` that monitors theme changes:
```typescript
if (!lotusTerminal && customAccentColor) {
document.documentElement.style.setProperty('--lt-accent-orange', customAccentColor);
document.documentElement.style.setProperty('--lt-accent-orange-glow', `${customAccentColor}80`);
}
```
- **UI (`src/app/features/settings/general/General.tsx`):** Use `<Input type="color">`. Hide this section if `lotusTerminal` is `true`.
---
### P5-15 · In-Call Soundboard
**Mechanism:** Local-to-Global Audio Bridge via Web Audio API.
- Create an `AudioContext` and a `MediaStreamDestinationNode`.
- Create an `AudioBufferSourceNode` for each clip.
- Route the mic `MediaStream` and the clip source to the destination node.
- Pass the destination's `.stream` to the call bridge.
> ⚠️ **[Gemini_Found — CORRECTED]** Gemini originally suggested using LiveKit's `LocalAudioTrack.replaceTrack()` to mix audio into the call stream. This is **not possible** from Lotus Chat's realm: Element Call runs in a **cross-origin iframe** controlled via `matrix-widget-api` (postMessage). LiveKit's JS SDK and its `LocalAudioTrack` live inside EC's sandboxed context — inaccessible from our code. This directly contradicts the confirmed constraint already listed in the Server Capabilities table: _"Cindy CANNOT inject audio into EC call stream — In-call soundboard must be redesigned as local-only."_ The soundboard must be a local-playback-only feature (output through the user's speakers, not mixed into the call audio stream).
>
> 🔱 **[EC-FORK — partial correction]** The "cross-origin" claim above is **outdated**: EC is now **same-origin** / self-hosted (`iframe.sandbox` has `allow-same-origin`; we read `contentDocument`). The _practical_ blocker still holds — LiveKit's `LocalAudioTrack` lives in EC's **module scope** (not on `window`), so it's unreachable from cinny even same-origin. **Owning the EC source** (see [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md)) is the path to a real call-audio-inject API, which would unblock a true in-call soundboard.
---
### P5-20 · Quick Reply from Browser Notification
**Mechanism:** Service Worker `notificationclick` Action.
> [Gemini_Found] Implementation detail: `serviceWorkerRegistration.showNotification()` should be used instead of `new Notification()` so that the service worker can listen to the `notificationclick` event. `new Notification()` creates notifications that are bound to the client page, not the SW.
```typescript
// src/sw.ts
self.addEventListener('notificationclick', (event) => {
if (event.action === 'reply' && event.reply) {
const { roomId, threadId } = event.notification.data;
const session = sessions.get(event.clientId);
fetch(`${session.baseUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message`, {
method: 'POST',
headers: { Authorization: `Bearer ${session.accessToken}` },
body: JSON.stringify({
msgtype: 'm.text',
body: event.reply,
'm.relates_to': threadId ? { rel_type: 'm.thread', event_id: threadId } : undefined,
}),
});
}
});
```
---
### P5-30 · Advanced ML Noise Suppression — Model Roadmap
See shipped implementation in LOTUS_FEATURES.md → "Noise Suppression (Advanced Multi-Tier)".
**Models status:**
- **RNNoise** (sapphi, 48 kHz) — ✅ working, default fallback. Keep — runs on any hardware.
- **Speex** (sapphi, 48 kHz) — ✅ working, low value; candidate to drop.
- **DTLN** (@workadventure, 16 kHz) — 🟡 wired; sample-rate fix applied (was robotic at 48 kHz). **TODO: verify in a real call.** Narrowband (16 kHz) = slightly telephone-y even when correct.
**Constraints:** client-side AudioWorklet, fully self-hosted, no GPU, self-hosted SFU (no LiveKit Cloud).
**Roadmap:**
- [ ] Verify DTLN 16 kHz fix in a real call.
- [ ] **DeepFilterNet 3** — best self-hostable upgrade: Rust→WASM, CPU real-time, 48 kHz fullband. Self-host `df_bg.wasm` + DFN3 ONNX model; wire a 48 kHz worklet. Audio quality unverifiable without a real-call test.
- [ ] **Desktop-only / HW-gated:** FRCRN (Alibaba) or NVIDIA Maxine (RTX/Tensor only). Runs in Tauri Rust backend + bridges a virtual mic into the webview. Must detect capability; web + weak HW falls back to RNNoise/DTLN.
---
### P5-31 · Granular Voice & Screenshare Quality Controls
**Mechanism:** WebRTC Encoding Parameters + Backend Quality Guard.
- **State Event:** `io.lotus.room_quality` (state key `""`) containing:
```json
{ "audio_bitrate": 128000, "screen_max_res": "1080p", "screen_max_fps": 60 }
```
- **Screenshare:** In `src/app/plugins/call/CallControl.ts`, map the "Quality" setting to `getDisplayMedia` constraints.
- **Audio Bitrate:** After the call joins, find the `RTCRtpSender` for the audio track:
```typescript
const sender = peerConnection.getSenders().find((s) => s.track?.kind === 'audio');
const params = sender.getParameters();
params.encodings[0].maxBitrate = roomBitrate || 128000;
await sender.setParameters(params);
```
- **Backend Sidecar:** Extend `voice-limit-guard.py` (LXC 151) to fetch `io.lotus.room_quality` and inject limits into the LiveKit JWT or return them as an authorized config packet.
---
### P5-40 · Desktop — Proactive Update Notifications (Tauri)
**Key Files:** `src/app/hooks/useTauriUpdater.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx`, `src/app/features/toast/LotusToastContainer.tsx`.
1. Create a `TauriUpdateFeature` component. Use `useTauriUpdater()` to get the `check` function and `status`.
2. In a `useEffect`, call `check()` on mount and then on a `setInterval` (every 12 hours).
3. When status transitions to `{ state: 'available', version: '...' }`, fire a Lotus Toast: "Lotus Chat v[version] is available!" with an "Update" button that calls `install()`.
4. Store `lastCheck` timestamp in `localStorage` to prevent redundant checks on refresh.
---
### Mobile Bookmarks Visibility Fix
**Issue:** `ClientLayout.tsx` explicitly restricts `BookmarksPanel` to `ScreenSize.Desktop` (lines 51-56).
```tsx
// ClientLayout.tsx
{
bookmarksOpen && (
<BookmarksPanel
onClose={() => setBookmarksOpen(false)}
isMobile={screenSize !== ScreenSize.Desktop}
/>
);
}
```
`BookmarksPanel.tsx` already supports the `isMobile` prop (line 127) to enable full-screen absolute positioning. No other changes required.
---
### Remind Me Later (Slack-style)
**Mechanism:** Account Data + Timer/Service Worker.
- **Storage (`src/app/hooks/useReminders.ts`):** Store in account data `io.lotus.reminders` as `Array<{ id: string, roomId: string, eventId: string, timestamp: number }>`.
- **Context Menu (`src/app/features/room/message/MessageContextMenu.tsx`):** Add "Remind me" option → opens date/time picker modal (reuse `JumpToTime.tsx` logic).
- **Trigger (foreground):** `setTimeout` in a hook inside `ReminderMonitor` in `ClientNonUIFeatures.tsx` → pushes to `toastQueueAtom` in `state/toast.ts` when due.
- **Trigger (background):** Use Service Worker — `setTimeout` in the main thread will not fire when the PWA is suspended.
---
### Mobile Usability Audit — Methodology
1. **Viewport & Touch:** All interactive elements must have at least `44px × 44px` touch targets. Audit for horizontal overflow (horizontal scrolling must be disabled).
2. **Modal Responsiveness:** All modals (Settings, Profile, etc.) MUST cover the full screen on mobile, not float as overlays.
3. **Sidebar / Panels:** On mobile, sidebar panels (Members, Bookmarks, Media) must become full-screen overlays (using a `Drawer` or `Modal` pattern) rather than side-by-side flexbox panels.
4. **Input & Composer:** Ensure the composer doesn't get obscured by the mobile keyboard. Test focus trap and blur behaviors.
---
## Implementation Notes
### ⚠️ TDS DESIGN LAW (repeated here for emphasis)
> Every TDS color, animation, glow, border, shadow, and font value MUST come from `/root/code/web_template/base.css`.
> Never hardcode hex values. Never invent CSS variable names.
> Key variables: `--lt-accent-orange` · `--lt-accent-cyan` · `--lt-accent-green` · `--lt-glow-*` · `--lt-box-glow-*` · `--lt-border-color` · `--lt-font-mono`
> Reference implementation: `/root/code/tinker_tickets/` (markdown.js, base.js, ticket.css)
> This applies without exception to every task marked `[IMPROVE]`, `[Build]`, or any UI change.
### Design Rules
- All new components must respect both TDS dark (`LotusTerminalTheme`) and TDS light (`LotusTerminalLightTheme`) modes
- Non-TDS theme work (custom accent color, theme presets) uses vanilla-extract theme files — match the pattern in `src/lotus-terminal.css.ts`
- Code syntax highlighting token classes: `.tok-kw .tok-str .tok-num .tok-cmt .tok-fn` (defined in `web_template/base.css`)
- `folds AvatarImage` does NOT accept children — wrap Avatar components externally for overlays/frames/borders
### CI/CD Pipeline
## 📖 Reference
### Server Capabilities (as of 2026-06)
- **Homeserver** `matrix.lotusguild.org` · **Synapse** `1.155.0` · **Matrix spec** up to `v1.12` (+ MSC `unstable_features`).
- **MSC ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` (flag on but v1 summary 404s) · `msc3401_matrix_rtc`. **OFF/blocked:** `msc4306` · `msc3882` · `msc3912` · `msc4155` · `msc3489`/`msc3672` · `msc3892`.
- **Live endpoints:** Report User (MSC4260) **200** ✅ · Report Room (MSC4151) ✅.
- **Homeserver access (audits):** Synapse = LXC 151 (`pct exec 151 -- bash`), config `/etc/matrix-synapse/homeserver.yaml`. Web deploy = LXC 106. Voice guard = `voice-limit-guard.py` on LXC 151.
- **SDK notes:** no arbitrary profile-field methods (use `mx.http.authedRequest()` for MSC4133); js-sdk can't per-room filter `/sync`; sanitizer strips `<math>`/MathML; SW exists at `src/sw.ts`; `getMatrixToRoom()` builds invite URLs; EC audio-inject unblocked via the fork's `io.lotus.inject_audio`.
### Key File Reference
| What | File | Lines |
| ------------------------------ | ------------------------------------------------------------------- | ------------------- |
| Global keydown / room nav | `hooks/useKeyDown.ts` · `hooks/useRoomNavigate.ts` | whole / 19-72 |
| Room unread counts atom | `state/room/roomToUnread.ts` | `roomToUnreadAtom` |
| Overlay portal provider | `pages/App.tsx` · `index.html` | 65 / 101 |
| Room settings tabs | `features/room-settings/RoomSettings.tsx` | 27-56 |
| State event read/write pattern | `features/common-settings/general/RoomEncryption.tsx` | 42-52 |
| Power levels | `hooks/usePowerLevels.ts` | whole |
| Slash commands | `hooks/useCommands.ts` | 140-537 |
| Chat background picker/defs | `features/settings/general/General.tsx` · `lotus/chatBackground.ts` | 945-981 / whole |
| Matrix.to URL builder | `plugins/matrix-to.ts` | `getMatrixToRoom()` |
| Media URL conversion | `utils/matrix.ts` | `mxcUrlToHttp()` |
| Search pagination / virtual | `features/message-search/{useMessageSearch,MessageSearch}.tsx` | 74-121 / 234-365 |
| Call mic control | `plugins/call/CallControl.ts` | 206-212 |
| Knock support check | `utils/matrix.ts` | 376-391 |
| Notification mute push rules | `hooks/useRoomsNotificationPreferences.ts` | 110-150 |
### Element Call fork — operational reference
Fork = `LotusGuild/element-call` (branch `lotus`, from upstream tag `v0.20.1`); cinny consumes the npm package `@lotusguild/element-call-embedded` (built bundle copied into `public/element-call/`).
**Publish a new version (manual; needs the Gitea npm token):** bump `embedded/web/package.json` (current unpublished `0.20.1-lotus.2`) → `pnpm run build:embedded` (Node 24, pnpm 10.33) → `cd embedded/web && npm version <tag> --no-git-tag-version && npm publish` (Gitea registry) → in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.20.1-lotus.1`) → `npm install` → build.
**`io.lotus.*` widget actions** (add new toWidget actions to the enum + `LOTUS_TO_WIDGET_ACTIONS` in `src/lotus/lotusActions.ts`; only send AFTER call-join or a 10s timeout fires):
| Action | Dir | Purpose | Module |
| :--------------------------- | :------ | :----------------------------------------------------- | :-------------------- |
| `io.lotus.call_state` | EC→host | speaker/mute/camera stream (`lotusCallState=1`) | `lotusCallState.ts` |
| `io.lotus.focus_participant` | host→EC | spotlight (works during screenshare) | `lotusFocus.ts` |
| `io.lotus.inject_audio` | host→EC | soundboard clip mixed into call (`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 | LiveKit-source deafen (P6-2) | `lotusDeafen.ts` |
Also flag-gated: `lotusTransparent`/`lotusTheme`, `lotusDenoiseSource=1` (in-source ML denoise).
### CI/CD + per-feature checklist
```
edit → commit → git push origin lotus
→ Gitea Actions: tsc --noEmit, eslint, prettier (~3 min)
Webhook: lotus_deploy.sh on LXC 106 polls CI, then npm ci && npm run build → rsync
→ Live at chat.lotusguild.org (~11 min total)
→ lotus_deploy.sh on LXC 106 polls CI npm ci && npm run build → rsync → live (~11 min)
```
### Per-Feature Checklist (before marking complete)
- [ ] `npx tsc --noEmit` — zero TypeScript errors
- [ ] `npx eslint src/` — zero new errors (warnings OK if pre-existing)
- [ ] `npx prettier --check src/` — formatting passes
- [ ] `README.md` updated (Lotus-custom features only — not upstream Cinny features)
- [ ] `landing/index.html` updated if the feature appears in the comparison table
- [ ] Visually tested at `chat.lotusguild.org` after CI deploys
### Homeserver Access (for server audits)
- **Synapse (Matrix):** LXC 151 on `compute-storage-01` — `pct exec 151 -- bash`
- **Config:** `/etc/matrix-synapse/homeserver.yaml`
- **Version check:** `curl -s https://matrix.lotusguild.org/_matrix/client/versions`
Before marking a feature complete: `npx tsc --noEmit` (0 errors) · `npx eslint src/` (0 new) · `npx prettier --check src/` · `npm test` (Node runner via tsx, hard CI gate — colocated `*.test.ts`) · update `README.md`/`landing/index.html` for Lotus-custom features · visually verify on `chat.lotusguild.org`.
+38 -14
View File
@@ -18,6 +18,8 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
### Messaging
- Threads: reply in a thread and read/write the whole conversation in a side panel — root messages show a "N replies" chip with an unread badge (threaded replies live in the panel now, not inline in the room)
- Slack-style thread notifications: by default you're only pinged for threads you're in or where you're @mentioned; set any thread to All / Mentions-only / Mute from the panel's bell menu (muted threads stop bumping badges; syncs across devices)
- See who has read each message, and track delivery status (sending / sent / failed)
- Bookmark any message and revisit saved messages from the sidebar
- Schedule messages to send at a specific time
@@ -33,6 +35,8 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
- Search for and send GIFs from a built-in GIF picker
- Control voice message playback speed: 0.75× / 1× / 1.5× / 2×
- Search messages with a date range filter
- Optional persistent search index for encrypted rooms (off by default — stores decrypted text on your device; clearable, wiped on logout)
- Write math with LaTeX: `$inline$` and `$$block$$` render via KaTeX (spec `data-mx-maths` supported)
- Room topics support rich formatting (bold, links, italics)
- Deleted messages show a placeholder instead of disappearing
- Code blocks highlight syntax for JS/TS, Python, and Rust
@@ -52,6 +56,9 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
- AFK auto-mute: mic is automatically silenced after a configurable idle timeout (130 min); a toast confirms the action
- Voice channel user limit: admins can cap how many people can be in a room's call — enforced server-side for every Matrix client (not just Lotus Chat); others see "Channel Full" until a spot opens
- Custom join/leave sound effects when someone enters or leaves your call — choose Chime, Soft, Retro, or off
- Soundboard: upload your own short audio clips (like custom emojis — they sync across your devices) and play them into a call so everyone hears them
- Call quality settings: cap your microphone bitrate, screenshare bitrate, and screenshare framerate — handy on a slow connection (Settings → Calls)
- Room call permissions: admins can turn off screen sharing or make a room audio-only (no cameras) — enforced server-side for every Matrix client, and it stops an in-progress share within seconds of being switched off
### Customization & Appearance
@@ -136,6 +143,20 @@ When you first run the installer on Windows, you may see a popup that says **"Wi
After the first install, automatic in-app updates handle all future versions — you will not see this prompt again for updates.
### Desktop-Specific Features
Beyond the web client, the desktop app adds native OS integration (Windows-focused; graceful no-ops elsewhere). See [`LOTUS_FEATURES.md`](./LOTUS_FEATURES.md#desktop-app-features) for detail.
- **Native rich notifications** — Windows toasts you can click to open the room or reply to inline, right from the toast.
- **Focus Assist sync** — Lotus silences its own notifications while Windows Focus Assist / Quiet Hours is on.
- **Windows Jump List** — right-click the taskbar icon for quick access to your most-active rooms.
- **Taskbar call controls** — Mute / Deafen / End Call buttons on the taskbar thumbnail during a call, plus call status in the volume flyout (SMTC).
- **Stays awake in calls** — the system won't sleep or dim during a voice/video call.
- **Network awareness** — reconnects promptly when Windows connectivity changes.
- **Custom window chrome** (opt-in) — a Lotus-styled title bar in place of the OS one.
- **Recursive folder drag-drop** — drop a whole folder onto the composer to upload everything inside it.
- **Automatic background updates** with a one-click update toast.
---
## For Developers
@@ -144,22 +165,25 @@ The source code lives in `/root/code/cinny`. All changes should be made on the `
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
### 🔱 Planned: Element Call fork ("Lotus Call")
### 🔱 Element Call fork ("Lotus Call") — LIVE
Voice/video channels embed **Element Call**. Today it's a **pre-built npm bundle**
(`@element-hq/element-call-embedded` 0.20.1) copied to `public/element-call/` and
served same-origin; we steer it via the `matrix-widget-api` plus fragile DOM
hacks. Because we don't own its compiled source, several in-call issues (avatar
decorations on tiles, camera focus/fullscreen during screenshare, mic recovery
after reconnect, native theming, real call-audio injection) are unfixable from
outside.
Voice/video channels embed **Element Call**, which is now our **self-built fork**
(`@lotusguild/element-call-embedded` `0.20.1-lotus.1`, source at
`LotusGuild/element-call`), published to our private Gitea npm registry and served
same-origin. We no longer depend on the upstream prebuilt bundle, so in-call
behavior is editable source instead of fragile DOM/widget hacks.
**The plan is to fork `element-hq/element-call` into a new `LotusGuild/element-call`
repo, build it from source, and host our own build** for true ownership. The full
self-contained plan and integration map — written for a fresh session with no
prior context — is in **[`HANDOFF_ELEMENT_CALL_FORK.md`](HANDOFF_ELEMENT_CALL_FORK.md)**.
Infra/hosting notes also live in the `LotusGuild/matrix` repo README. Search the
docs for the **`[EC-FORK]`** tag to find every related note.
**Shipped via the fork:** denoise as an in-source LiveKit audio stage (survives
reconnects), in-call speaking/mute events, focus-a-participant during screenshare,
avatar decorations on EC video tiles, and a native transparent background.
**Built but dormant (need cinny UI):** real call-audio injection
(`io.lotus.inject_audio` → in-call soundboard) and quality controls
(`io.lotus.set_quality`).
The fork's `io.lotus.*` action catalog + the publish procedure are in
**[`LOTUS_TODO.md`](LOTUS_TODO.md)** ("Element Call fork — operational reference");
infra/hosting + build-pipeline notes live in the `LotusGuild/matrix` repo README.
Search the docs for the **`[EC-FORK]`** tag to find every related note.
### Build
+12 -1
View File
@@ -1,6 +1,17 @@
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
cinny.domain.tld {
root * /path/to/cinny/dist
try_files {path} / index.html
try_files {path} /index.html
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;
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 / {
root /opt/cinny/dist/;
+4 -4
View File
@@ -5,11 +5,11 @@ experimental_features:
msc3861:
enabled: true
issuer: http://localhost:8090/
client_id: "0000000000000000000SYNAPSE"
client_id: '0000000000000000000SYNAPSE'
client_auth_method: client_secret_basic
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET"
admin_token: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN"
account_management_url: "http://localhost:8090/account"
client_secret: 'REPLACE_WITH_A_SHARED_CLIENT_SECRET'
admin_token: 'REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN'
account_management_url: 'http://localhost:8090/account'
# With msc3861 enabled, Synapse disables its own password/SSO login and advertises
# `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/recommended'],
reactPlugin.configs.flat.recommended,
reactHooksPlugin.configs.flat['recommended'],
reactHooksPlugin.configs.flat.recommended,
// Register jsx-a11y plugin (rules selectively enabled below)
{ plugins: { 'jsx-a11y': jsxA11yPlugin } },
// 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/no-noninteractive-element-interactions': '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',
},
},
{
// 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',
},
},
];
+278 -41
View File
@@ -24,7 +24,6 @@
"@tanstack/react-query": "5.100.13",
"@tanstack/react-query-devtools": "5.100.13",
"@tanstack/react-virtual": "3.13.25",
"@types/dompurify": "3.2.0",
"@workadventure/noise-suppression": "0.0.4",
"await-to-js": "3.0.0",
"badwords-list": "2.0.1-4",
@@ -36,7 +35,6 @@
"dayjs": "1.11.20",
"deepfilternet3-noise-filter": "1.2.1",
"domhandler": "6.0.1",
"dompurify": "3.4.5",
"emojibase": "17.0.0",
"emojibase-data": "17.0.0",
"file-saver": "2.0.5",
@@ -51,13 +49,17 @@
"immer": "11.1.8",
"is-hotkey": "0.2.0",
"jotai": "2.20.0",
"jsqr": "1.4.0",
"katex": "0.16.11",
"linkify-react": "4.3.3",
"linkifyjs": "4.3.3",
"matrix-js-sdk": "41.6.0-rc.0",
"matrix-js-sdk": "41.7.0",
"matrix-widget-api": "1.17.0",
"millify": "6.1.0",
"pdfjs-dist": "5.7.284",
"prismjs": "1.30.0",
"qrcode": "1.5.4",
"qrcode.react": "4.2.0",
"react": "19.2.6",
"react-aria": "3.48.0",
"react-blurhash": "0.3.0",
@@ -74,7 +76,8 @@
"slate-history": "0.113.1",
"slate-react": "0.124.2",
"styled-components": "6.4.2",
"ua-parser-js": "2.0.10"
"ua-parser-js": "2.0.10",
"workbox-precaching": "7.4.1"
},
"devDependencies": {
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
@@ -83,8 +86,10 @@
"@types/chroma-js": "3.1.2",
"@types/file-saver": "2.0.7",
"@types/is-hotkey": "0.1.10",
"@types/katex": "0.16.8",
"@types/node": "25.9.1",
"@types/prismjs": "1.26.6",
"@types/qrcode": "1.5.6",
"@types/react": "19.2.15",
"@types/react-dom": "19.2.3",
"@types/react-google-recaptcha": "2.1.9",
@@ -2695,9 +2700,9 @@
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
"version": "18.3.0",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.0.tgz",
"integrity": "sha512-9a4feyt8QLysARu7PHKaRWT+wcCd+IYH074LXp9QK5WqfN4zUXueRhiSSMNT18Bm+8q3sBR/4zxDxOSDR0M8Kg==",
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.1.tgz",
"integrity": "sha512-VRjWhE1UgHnPpJ3b9B5+8z71ZC/HICFngPPFIN6ktzmUBKI5RusPujzbAQUoB3CgZ0yU58L99AfSQS4YTztSWw==",
"license": "Apache-2.0",
"engines": {
"node": ">= 18"
@@ -3918,16 +3923,6 @@
"dev": true,
"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": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3974,6 +3969,13 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true
},
"node_modules/@types/katex": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
"integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
@@ -3991,6 +3993,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.15",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
@@ -4042,7 +4054,7 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"devOptional": true
"dev": true
},
"node_modules/@types/ua-parser-js": {
"version": "0.7.39",
@@ -5172,6 +5184,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/camelize": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
@@ -5541,12 +5562,16 @@
"dev": true
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/conventional-commit-types": {
@@ -5962,6 +5987,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dedent": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -6105,6 +6139,12 @@
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/direction": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz",
@@ -6187,15 +6227,6 @@
"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": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
@@ -9063,6 +9094,12 @@
"node": ">=0.10.0"
}
},
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
"license": "Apache-2.0"
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -9087,6 +9124,31 @@
"node": ">=18"
}
},
"node_modules/katex": {
"version": "0.16.11",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.11.tgz",
"integrity": "sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"license": "MIT",
"dependencies": {
"commander": "^8.3.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/katex/node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -9937,16 +9999,16 @@
"license": "Apache-2.0"
},
"node_modules/matrix-js-sdk": {
"version": "41.6.0-rc.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.6.0-rc.0.tgz",
"integrity": "sha512-FcTQyR+Nfh0ASEogYcX393hxGr1936Esg53Z+0f9O4SBsAxl1ZSkLXY3JfLZRLX9dNe38VVwQDQE6QuwnwV7Zw==",
"version": "41.7.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.7.0.tgz",
"integrity": "sha512-MP0xNv/VVRbshq00TE6EVo77IIXsQk0KjiVtgKV0t9j/V77a6Klt00QrrO0XykkTUsNC0+mQeBMxnx75rZO86Q==",
"license": "Apache-2.0",
"dependencies": {
"@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",
"bs58": "^6.0.0",
"content-type": "^1.0.4",
"content-type": "^2.0.0",
"jwt-decode": "^4.0.0",
"loglevel": "^1.9.2",
"matrix-events-sdk": "0.0.1",
@@ -10481,6 +10543,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -10518,7 +10589,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -10633,6 +10703,15 @@
"pathe": "^2.0.1"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@@ -10740,6 +10819,150 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/raf-schd": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
@@ -11160,6 +11383,12 @@
"node": "*"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@@ -11490,6 +11719,12 @@
"node": ">=20.0.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@@ -12955,6 +13190,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": {
"version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
@@ -13194,7 +13435,6 @@
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz",
"integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==",
"dev": true,
"license": "MIT"
},
"node_modules/workbox-expiration": {
@@ -13235,7 +13475,6 @@
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz",
"integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.1",
@@ -13272,7 +13511,6 @@
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz",
"integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.1"
@@ -13282,7 +13520,6 @@
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz",
"integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.1"
+9 -4
View File
@@ -49,7 +49,6 @@
"@tanstack/react-query": "5.100.13",
"@tanstack/react-query-devtools": "5.100.13",
"@tanstack/react-virtual": "3.13.25",
"@types/dompurify": "3.2.0",
"@workadventure/noise-suppression": "0.0.4",
"await-to-js": "3.0.0",
"badwords-list": "2.0.1-4",
@@ -61,7 +60,6 @@
"dayjs": "1.11.20",
"deepfilternet3-noise-filter": "1.2.1",
"domhandler": "6.0.1",
"dompurify": "3.4.5",
"emojibase": "17.0.0",
"emojibase-data": "17.0.0",
"file-saver": "2.0.5",
@@ -76,13 +74,17 @@
"immer": "11.1.8",
"is-hotkey": "0.2.0",
"jotai": "2.20.0",
"jsqr": "1.4.0",
"katex": "0.16.11",
"linkify-react": "4.3.3",
"linkifyjs": "4.3.3",
"matrix-js-sdk": "41.6.0-rc.0",
"matrix-js-sdk": "41.7.0",
"matrix-widget-api": "1.17.0",
"millify": "6.1.0",
"pdfjs-dist": "5.7.284",
"prismjs": "1.30.0",
"qrcode": "1.5.4",
"qrcode.react": "4.2.0",
"react": "19.2.6",
"react-aria": "3.48.0",
"react-blurhash": "0.3.0",
@@ -99,7 +101,8 @@
"slate-history": "0.113.1",
"slate-react": "0.124.2",
"styled-components": "6.4.2",
"ua-parser-js": "2.0.10"
"ua-parser-js": "2.0.10",
"workbox-precaching": "7.4.1"
},
"devDependencies": {
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
@@ -108,8 +111,10 @@
"@types/chroma-js": "3.1.2",
"@types/file-saver": "2.0.7",
"@types/is-hotkey": "0.1.10",
"@types/katex": "0.16.8",
"@types/node": "25.9.1",
"@types/prismjs": "1.26.6",
"@types/qrcode": "1.5.6",
"@types/react": "19.2.15",
"@types/react-dom": "19.2.3",
"@types/react-google-recaptcha": "2.1.9",
+1
View File
@@ -213,6 +213,7 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
<Text size="L400">Account Data</Text>
<Input
variant="SurfaceVariant"
aria-label="Account data type"
size="400"
radii="300"
readOnly
+19 -2
View File
@@ -45,6 +45,7 @@ import { useMatrixClient } from '../hooks/useMatrixClient';
import { previewRingtone, startRingtone } from '../utils/ringtones';
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
import { useCallQuality } from '../hooks/useCallQuality';
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
import { mDirectAtom } from '../state/mDirectList';
@@ -55,6 +56,7 @@ import { useRoomNavigate } from '../hooks/useRoomNavigate';
import { getChatBg } from '../features/lotus/chatBackground';
import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls';
import { useTheme, ThemeKind } from '../hooks/useTheme';
import { useReducedMotion } from '../hooks/useReducedMotion';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { getStateEvent, getStateEvents, getMemberDisplayName } from '../utils/room';
@@ -412,6 +414,16 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
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(
async (event, room, toStartOfTimeline, removed, data) => {
// only process rtc notification reference events.
@@ -426,6 +438,9 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
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.
// Without this the caller's UI keeps "ringing" until the notification
// lifetime expires, with no indication the callee said no.
@@ -584,6 +599,7 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
useCallMemberSoundSync(embed);
useCallJoinLeaveSounds(embed);
useCallThemeSync(embed);
useCallQuality(embed);
useCallHangupEvent(
embed,
useCallback(() => {
@@ -704,9 +720,10 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const theme = useTheme();
const isDark = theme.kind === ThemeKind.Dark;
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
const reduced = useReducedMotion();
const wallpaperStyle = React.useMemo(
() => getChatBg(chatBackground, isDark),
[chatBackground, isDark],
() => getChatBg(chatBackground, isDark, reduced),
[chatBackground, isDark, reduced],
);
const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
+142 -34
View File
@@ -1,12 +1,14 @@
import {
ShowQrCodeCallbacks,
ShowSasCallbacks,
VerificationPhase,
VerificationRequest,
Verifier,
} from 'matrix-js-sdk/lib/crypto-api';
import React, { CSSProperties, useCallback, useEffect, useState } from 'react';
import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { VerificationMethod } from 'matrix-js-sdk/lib/types';
import QRCode from 'qrcode';
import {
Box,
Button,
@@ -27,11 +29,13 @@ import {
useVerificationRequestPhase,
useVerificationRequestReceived,
useVerifierCancel,
useVerifierShowReciprocateQr,
useVerifierShowSas,
} from '../hooks/useVerificationRequest';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { ContainerColor } from '../styles/ContainerColor.css';
import { useModalStyle } from '../hooks/useModalStyle';
import { QrScanner } from './QrScanner';
const DialogHeaderStyles: CSSProperties = {
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -97,32 +101,6 @@ function VerificationAccept({ onAccept }: VerificationAcceptProps) {
);
}
function VerificationWaitStart() {
const { t } = useTranslation();
return (
<Box direction="Column" gap="400">
<Text>{t('Organisms.DeviceVerification.request_accepted')}</Text>
<WaitingMessage message={t('Organisms.DeviceVerification.waiting_response')} />
</Box>
);
}
type VerificationStartProps = {
onStart: () => Promise<void>;
};
function AutoVerificationStart({ onStart }: VerificationStartProps) {
const { t } = useTranslation();
useEffect(() => {
onStart();
}, [onStart]);
return (
<Box direction="Column" gap="400">
<WaitingMessage message={t('Organisms.DeviceVerification.starting_emoji')} />
</Box>
);
}
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
const { t } = useTranslation();
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
@@ -237,6 +215,120 @@ function VerificationCanceled({ onClose }: VerificationCanceledProps) {
);
}
function QrCodeImage({ data }: { data: Uint8ClampedArray }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// Byte-mode so the raw verification bytes round-trip (a string value would
// mangle high bytes via UTF-8).
QRCode.toCanvas(canvas, [{ data: new Uint8Array(data), mode: 'byte' }], {
width: 220,
margin: 2,
color: { dark: '#000000', light: '#ffffff' },
}).catch(() => undefined);
}, [data]);
return (
<Box justifyContent="Center">
<canvas ref={canvasRef} style={{ borderRadius: config.radii.R300 }} />
</Box>
);
}
type VerificationReadyProps = {
request: VerificationRequest;
onStartSas: () => void;
onScanned: (bytes: Uint8ClampedArray) => void;
};
function VerificationReady({ request, onStartSas, onScanned }: VerificationReadyProps) {
const [myQr, setMyQr] = useState<Uint8ClampedArray>();
const [scanning, setScanning] = useState(false);
const canShowMine = request.otherPartySupportsMethod(VerificationMethod.ScanQrCode);
const canScanTheirs = request.otherPartySupportsMethod(VerificationMethod.ShowQrCode);
useEffect(() => {
if (!canShowMine) return;
request
.generateQRCode()
.then((bytes) => {
if (bytes) setMyQr(bytes);
})
.catch(() => undefined);
}, [request, canShowMine]);
if (scanning) {
return <QrScanner onScan={onScanned} onCancel={() => setScanning(false)} />;
}
return (
<Box direction="Column" gap="400">
{myQr && (
<Box direction="Column" gap="200">
<Text size="T300">Scan this code with your other device to verify.</Text>
<QrCodeImage data={myQr} />
</Box>
)}
<Box direction="Column" gap="200">
{canScanTheirs && (
<Button variant="Primary" fill="Solid" onClick={() => setScanning(true)}>
<Text size="B400">Scan their QR code</Text>
</Button>
)}
<Button variant="Secondary" fill="Soft" onClick={onStartSas}>
<Text size="B400">Verify with emoji instead</Text>
</Button>
</Box>
</Box>
);
}
type ReciprocateVerificationProps = {
verifier: Verifier;
onCancel: () => void;
};
function ReciprocateVerification({ verifier, onCancel }: ReciprocateVerificationProps) {
const [qrCallbacks, setQrCallbacks] = useState<ShowQrCodeCallbacks>();
const [confirmState, confirm] = useAsyncCallback(
useCallback(async () => qrCallbacks?.confirm(), [qrCallbacks]),
);
useVerifierShowReciprocateQr(verifier, setQrCallbacks);
useVerifierCancel(verifier, onCancel);
const confirming =
confirmState.status === AsyncStatus.Loading || confirmState.status === AsyncStatus.Success;
// The showing side gets ShowReciprocateQr callbacks after the other device
// scans; the scanning side never does (it already called verify()) and just
// waits for completion.
if (!qrCallbacks) {
return (
<Box direction="Column" gap="400">
<WaitingMessage message="Verifying…" />
</Box>
);
}
return (
<Box direction="Column" gap="400">
<Text>The other device scanned this code. Confirm it now shows as verified.</Text>
<Box direction="Column" gap="200">
<Button variant="Primary" fill="Soft" onClick={confirm} disabled={confirming}>
<Text size="B400">Confirm</Text>
</Button>
<Button
variant="Primary"
fill="Soft"
onClick={() => qrCallbacks.cancel()}
disabled={confirming}
>
<Text size="B400">Cancel</Text>
</Button>
</Box>
</Box>
);
}
type DeviceVerificationProps = {
request: VerificationRequest;
onExit: () => void;
@@ -256,6 +348,17 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
const handleStart = useCallback(async () => {
await request.startVerification(VerificationMethod.Sas);
}, [request]);
const handleScanned = useCallback(
async (bytes: Uint8ClampedArray) => {
try {
const verifier = await request.scanQRCode(bytes);
await verifier.verify();
} catch {
// A bad/mismatched scan cancels the request; the Cancelled phase renders.
}
},
[request],
);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
@@ -290,15 +393,20 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
) : (
<VerificationAccept onAccept={handleAccept} />
))}
{phase === VerificationPhase.Ready &&
(request.initiatedByMe ? (
<AutoVerificationStart onStart={handleStart} />
) : (
<VerificationWaitStart />
))}
{phase === VerificationPhase.Ready && (
<VerificationReady
request={request}
onStartSas={handleStart}
onScanned={handleScanned}
/>
)}
{phase === VerificationPhase.Started &&
(request.verifier ? (
<SasVerification verifier={request.verifier} onCancel={handleCancel} />
request.chosenMethod === VerificationMethod.Reciprocate ? (
<ReciprocateVerification verifier={request.verifier} onCancel={handleCancel} />
) : (
<SasVerification verifier={request.verifier} onCancel={handleCancel} />
)
) : (
<VerificationUnexpected
message="Unexpected Error! Verification is started but verifier is missing."
@@ -13,9 +13,9 @@ import {
color,
Spinner,
} from 'folds';
import FileSaver from 'file-saver';
import to from 'await-to-js';
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
import { useSaveFile } from '../hooks/useSaveFile';
import { useModalStyle } from '../hooks/useModalStyle';
import { PasswordInput } from './password-input';
import { ContainerColor } from '../styles/ContainerColor.css';
@@ -230,6 +230,7 @@ type RecoveryKeyDisplayProps = {
};
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
const [show, setShow] = useState(false);
const saveFile = useSaveFile();
const handleCopy = () => {
copyToClipboard(recoveryKey);
@@ -239,7 +240,7 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
const blob = new Blob([recoveryKey], {
type: 'text/plain;charset=us-ascii',
});
FileSaver.saveAs(blob, 'recovery-key.txt');
saveFile(blob, 'recovery-key.txt');
};
const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*');
+3 -2
View File
@@ -19,7 +19,7 @@ import {
config,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import FileSaver from 'file-saver';
import { useSaveFile } from '../../hooks/useSaveFile';
import * as css from './PdfViewer.css';
import { AsyncStatus } from '../../hooks/useAsyncCallback';
import { useZoom } from '../../hooks/useZoom';
@@ -36,6 +36,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
({ className, name, src, requestClose, ...props }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const saveFile = useSaveFile();
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const [pdfJSState, loadPdfJS] = usePdfJSLoader();
@@ -76,7 +77,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
}, [docState, pageNo, zoom]);
const handleDownload = () => {
FileSaver.saveAs(src, name);
saveFile(src, name);
};
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+101
View File
@@ -0,0 +1,101 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box, Button, color, config, Text } from 'folds';
import jsQR from 'jsqr';
type QrScannerProps = {
onScan: (bytes: Uint8ClampedArray) => void;
onCancel: () => void;
};
// Camera QR scanner. Decodes frames with jsQR and hands back the raw byte
// segment (`result.binaryData`) — Matrix QR verification needs the raw bytes,
// not a decoded string, so the string-only `BarcodeDetector` can't be used.
export function QrScanner({ onScan, onCancel }: QrScannerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [error, setError] = useState<string>();
const doneRef = useRef(false);
useEffect(() => {
let stream: MediaStream | undefined;
let raf = 0;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const tick = () => {
const video = videoRef.current;
if (!doneRef.current && video && ctx && video.readyState === video.HAVE_ENOUGH_DATA) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
const result = jsQR(image.data, image.width, image.height);
if (result && result.binaryData.length > 0) {
doneRef.current = true;
onScan(new Uint8ClampedArray(result.binaryData));
return;
}
}
raf = requestAnimationFrame(tick);
};
(async () => {
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
raf = requestAnimationFrame(tick);
} catch {
setError(
'Could not access the camera. Grant camera permission, or verify with emojis instead.',
);
}
})();
return () => {
doneRef.current = true;
cancelAnimationFrame(raf);
stream?.getTracks().forEach((track) => track.stop());
};
}, [onScan]);
if (error) {
return (
<Box direction="Column" gap="400">
<Text style={{ color: color.Critical.Main }} size="T300">
{error}
</Text>
<Button variant="Secondary" fill="Soft" onClick={onCancel}>
<Text size="B400">Back</Text>
</Button>
</Box>
);
}
return (
<Box direction="Column" gap="400" alignItems="Center">
<Text size="T300" align="Center">
Point your camera at the QR code shown on your other device.
</Text>
<video
ref={videoRef}
muted
playsInline
style={{
width: '100%',
maxWidth: 280,
borderRadius: config.radii.R400,
background: '#000',
}}
>
<track kind="captions" />
</video>
<Button variant="Secondary" fill="Soft" onClick={onCancel}>
<Text size="B400">Cancel</Text>
</Button>
</Box>
);
}
+46 -1
View File
@@ -31,7 +31,29 @@ import { ImageViewer } from './image-viewer';
import { PdfViewer } from './Pdf-viewer';
import { TextViewer } from './text-viewer';
import { testMatrixTo } from '../plugins/matrix-to';
import { IImageContent } from '../../types/matrix/common';
import { IAudioContent, IFileContent, IImageContent } from '../../types/matrix/common';
// Audio is frequently sent as m.file (bridges/other clients, or when the browser
// reported a non-audio/* mime on upload). Detect that so we can play it inline
// like m.audio instead of showing only a download button.
const AUDIO_EXT_MIME: Record<string, string> = {
mp3: 'audio/mpeg',
m4a: 'audio/mp4',
aac: 'audio/aac',
oga: 'audio/ogg',
ogg: 'audio/ogg',
opus: 'audio/ogg',
wav: 'audio/wav',
flac: 'audio/flac',
weba: 'audio/webm',
};
const resolveInlineAudioMime = (content: IFileContent): string | undefined => {
const mime = content.info?.mimetype;
if (typeof mime === 'string' && mime.startsWith('audio')) return mime;
const name = content.filename ?? content.body ?? '';
const ext = name.split('.').pop()?.toLowerCase();
return ext ? AUDIO_EXT_MIME[ext] : undefined;
};
type RenderMessageContentProps = {
displayName: string;
@@ -276,6 +298,29 @@ export function RenderMessageContent({
}
if (msgType === MsgType.File) {
// If an m.file is actually audio, play it inline (like m.audio) instead of
// only offering a download. MAudio falls back to renderFile if playback fails.
const audioMime = resolveInlineAudioMime(getContent<IFileContent>());
if (audioMime) {
const fileContent = getContent<IFileContent>();
const audioContent = {
...fileContent,
info: { ...(fileContent.info ?? {}), mimetype: audioMime },
} as unknown as IAudioContent;
return (
<>
<MAudio
content={audioContent}
renderAsFile={renderFile}
renderAudioContent={(props) => (
<AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} />
)}
outlined={outlineAttachment}
/>
{renderCaption()}
</>
);
}
return renderFile();
}
@@ -0,0 +1,27 @@
import { useTauriCallPower } from '../hooks/useTauriCallPower';
import { useTauriJumpList } from '../hooks/useTauriJumpList';
import { useTauriThumbbar } from '../hooks/useTauriThumbbar';
import { useTauriSmtc } from '../hooks/useTauriSmtc';
import { useTauriNetwork } from '../hooks/useTauriNetwork';
import { useTauriToastActions } from '../hooks/useTauriToastActions';
import { useTauriFocusAssist } from '../hooks/useTauriFocusAssist';
import { useTauriDnd } from '../hooks/useTauriDnd';
/**
* Mounts the client-scoped native desktop feature hooks (call/room aware). Each
* `useTauri*` hook no-ops in the browser (guards on `isTauri`), so this is safe
* to render unconditionally. Rendered once by `ClientNonUIFeatures`. App-level
* desktop features (window chrome) live in `App.tsx` instead, so they work
* before login.
*/
export function TauriDesktopFeatures(): null {
useTauriCallPower(); // P5-46 no-sleep during calls
useTauriJumpList(); // P5-36 Windows jump list of recent rooms
useTauriThumbbar(); // P5-44 taskbar thumbnail toolbar (mute/deafen/end)
useTauriSmtc(); // P5-43 system media transport controls
useTauriNetwork(); // P5-49 network-change awareness → sync retry
useTauriToastActions(); // P5-41/35 rich toast click → open room, quick reply → send
useTauriFocusAssist(); // P5-56 Windows Focus Assist → DND suppression atom
useTauriDnd(); // P6-1 tray "Do Not Disturb" → notification suppression atom
return null;
}
@@ -0,0 +1,127 @@
import { Box, config, Icon, Icons, IconSrc, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import React, { MouseEventHandler, ReactNode, useMemo, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../utils/keyboard';
import { ThreadNotificationMode } from '../utils/threadNotifications';
import { useSetThreadNotificationMode } from '../hooks/useThreadNotifications';
import { AsyncStatus } from '../hooks/useAsyncCallback';
export const getThreadNotificationModeIcon = (mode?: ThreadNotificationMode): IconSrc => {
if (mode === ThreadNotificationMode.Mute) return Icons.BellMute;
if (mode === ThreadNotificationMode.MentionsOnly) return Icons.BellPing;
if (mode === ThreadNotificationMode.All) return Icons.BellRing;
return Icons.Bell;
};
const useThreadNotificationModes = (): ThreadNotificationMode[] =>
useMemo(
() => [
ThreadNotificationMode.Default,
ThreadNotificationMode.All,
ThreadNotificationMode.MentionsOnly,
ThreadNotificationMode.Mute,
],
[],
);
const useThreadNotificationModeStr = (): Record<ThreadNotificationMode, string> =>
useMemo(
() => ({
[ThreadNotificationMode.Default]: 'Default (participating)',
[ThreadNotificationMode.All]: 'All replies',
[ThreadNotificationMode.MentionsOnly]: 'Mentions only',
[ThreadNotificationMode.Mute]: 'Mute',
}),
[],
);
type ThreadNotificationModeSwitcherProps = {
roomId: string;
threadId: string;
value?: ThreadNotificationMode;
children: (
handleOpen: MouseEventHandler<HTMLButtonElement>,
opened: boolean,
changing: boolean,
) => ReactNode;
};
export function ThreadNotificationModeSwitcher({
roomId,
threadId,
value = ThreadNotificationMode.Default,
children,
}: ThreadNotificationModeSwitcherProps) {
const modes = useThreadNotificationModes();
const modeToStr = useThreadNotificationModeStr();
const { modeState, setMode } = useSetThreadNotificationMode(roomId, threadId);
const changing = modeState.status === AsyncStatus.Loading;
const [menuCords, setMenuCords] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleClose = () => {
setMenuCords(undefined);
};
const handleSelect = (mode: ThreadNotificationMode) => {
if (changing) return;
setMode(mode);
handleClose();
};
return (
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleClose,
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{modes.map((mode) => (
<MenuItem
key={mode}
size="300"
variant="Surface"
aria-pressed={mode === value}
radii="300"
disabled={changing}
onClick={() => handleSelect(mode)}
before={
<Icon
size="100"
src={getThreadNotificationModeIcon(mode)}
filled={mode === value}
/>
}
>
<Text size="T300">
{mode === value ? <b>{modeToStr[mode]}</b> : modeToStr[mode]}
</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
>
{children(handleOpenMenu, !!menuCords, changing)}
</PopOut>
);
}
+6 -1
View File
@@ -282,7 +282,12 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
>
{previewUrl && (
<>
<audio ref={previewAudioRef} src={previewUrl} onEnded={() => setPreviewPlaying(false)} />
<audio
ref={previewAudioRef}
src={previewUrl}
onEnded={() => setPreviewPlaying(false)}
aria-hidden="true"
/>
<IconButton
onClick={() => {
const audio = previewAudioRef.current;
@@ -31,6 +31,10 @@ export function AvatarDecoration({
>
{children}
<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)}
style={{
position: 'absolute',
@@ -48,6 +52,9 @@ export function AvatarDecoration({
aria-hidden="true"
loading="lazy"
decoding="async"
onLoad={(e) => {
(e.currentTarget as HTMLImageElement).style.removeProperty('display');
}}
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
@@ -78,11 +78,14 @@ export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
return (
<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">
Pick an unique address to make it discoverable.
</Text>
<Input
id="create-room-alias"
ref={aliasInputRef}
onChange={handleAliasChange}
before={
+4 -1
View File
@@ -66,6 +66,8 @@ type CustomEditorProps = {
maxHeight?: string;
editor: Editor;
placeholder?: string;
/** Explicit accessible name for the textbox; falls back to the placeholder. */
ariaLabel?: string;
onKeyDown?: KeyboardEventHandler;
onKeyUp?: KeyboardEventHandler;
onChange?: EditorChangeHandler;
@@ -82,6 +84,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
maxHeight = '50vh',
editor,
placeholder,
ariaLabel,
onKeyDown,
onKeyUp,
onChange,
@@ -139,7 +142,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
data-editable-name={editableName}
className={css.EditorTextarea}
placeholder={placeholder}
aria-label={placeholder ?? 'Message input'}
aria-label={ariaLabel ?? placeholder ?? 'Message input'}
aria-multiline="true"
renderPlaceholder={renderPlaceholder}
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 { Box, MenuItem, Text, toRem } from 'folds';
import { Room } from 'matrix-js-sdk';
@@ -11,7 +11,7 @@ import { onTabPress } from '../../../utils/keyboard';
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
import { IEmoji, emojis } from '../../../plugins/emoji';
import { IEmoji, emojis, loadEmojiData } from '../../../plugins/emoji';
import { useKeyDown } from '../../../hooks/useKeyDown';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
@@ -47,13 +47,32 @@ export function EmoticonAutocomplete({
const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
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 list: Array<EmoticonSearchItem> = [];
return list.concat(
imagePacks.flatMap((pack) => pack.getImages(ImageUsage.Emoticon)),
emojis,
loadedEmojis,
);
}, [imagePacks]);
}, [imagePacks, loadedEmojis]);
const [result, search, resetSearch] = useAsyncSearch(
searchList,
+37 -6
View File
@@ -8,6 +8,7 @@ import React, {
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Box, config, Icons, Scroll } from 'folds';
import FocusTrap from 'focus-trap-react';
@@ -15,7 +16,7 @@ import { isKeyHotkey } from 'is-hotkey';
import { Room } from 'matrix-js-sdk';
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
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 { useEmojiGroupIcons } from './useEmojiGroupIcons';
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
@@ -56,6 +57,33 @@ import { VirtualTile } from '../virtualizer';
const RECENT_GROUP_ID = 'recent_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 = {
id: string;
name: string;
@@ -75,6 +103,7 @@ const useGroups = (
const recentEmojis = useRecentEmoji(mx, 21);
const labels = useEmojiGroupLabels();
const { emojiGroups: loadedEmojiGroups } = useEmojiData();
const emojiGroupItems = useMemo(() => {
const g: EmojiGroupItem[] = [];
@@ -99,7 +128,7 @@ const useGroups = (
});
});
emojiGroups.forEach((group) => {
loadedEmojiGroups.forEach((group) => {
g.push({
id: group.id,
name: labels[group.id],
@@ -108,7 +137,7 @@ const useGroups = (
});
return g;
}, [mx, recentEmojis, labels, imagePacks, tab]);
}, [mx, recentEmojis, labels, imagePacks, tab, loadedEmojiGroups]);
const stickerGroupItems = useMemo(() => {
const g: StickerGroupItem[] = [];
@@ -177,6 +206,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
const usage = ImageUsage.Emoticon;
const labels = useEmojiGroupLabels();
const icons = useEmojiGroupIcons();
const { emojiGroups: loadedEmojiGroups } = useEmojiData();
const packLabels = useMemo(() => {
const map = new Map<string, string | undefined>();
@@ -234,7 +264,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
}}
>
<SidebarDivider />
{emojiGroups.map((group) => (
{loadedEmojiGroups.map((group) => (
<GroupIcon
key={group.id}
active={activeGroupId === group.id}
@@ -409,13 +439,14 @@ export function EmojiBoard({
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks);
const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
const renderItem = useItemRenderer(tab);
const { emojis: loadedEmojis } = useEmojiData();
const searchList = useMemo(() => {
let list: Array<PackImageReader | IEmoji> = [];
list = list.concat(imagePacks.flatMap((pack) => pack.getImages(usage)));
if (emojiTab) list = list.concat(emojis);
if (emojiTab) list = list.concat(loadedEmojis);
return list;
}, [emojiTab, usage, imagePacks]);
}, [emojiTab, usage, imagePacks, loadedEmojis]);
const [result, search, resetSearch] = useAsyncSearch(
searchList,
@@ -200,12 +200,24 @@ export function ImagePackProfileEdit({ meta, onCancel, onSave }: ImagePackProfil
</Box>
</Box>
<Box direction="Inherit" gap="100">
<Text size="L400">Name</Text>
<Input name="nameInput" defaultValue={meta.name} variant="Secondary" radii="300" required />
<Text as="label" htmlFor="image-pack-name" size="L400">
Name
</Text>
<Input
id="image-pack-name"
name="nameInput"
defaultValue={meta.name}
variant="Secondary"
radii="300"
required
/>
</Box>
<Box direction="Inherit" gap="100">
<Text size="L400">Attribution</Text>
<Text as="label" htmlFor="image-pack-attribution" size="L400">
Attribution
</Text>
<TextArea
id="image-pack-attribution"
name="attributionTextArea"
defaultValue={meta.attribution}
variant="Secondary"
@@ -1,8 +1,8 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import FileSaver from 'file-saver';
import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
import { useSaveFile } from '../../hooks/useSaveFile';
import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan';
@@ -17,12 +17,13 @@ export type ImageViewerProps = {
export const ImageViewer = as<'div', ImageViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => {
const { t } = useTranslation();
const saveFile = useSaveFile();
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
const handleDownload = async () => {
const fileContent = await downloadMedia(src);
FileSaver.saveAs(fileContent, alt);
saveFile(fileContent, alt);
};
return (
@@ -261,9 +261,12 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
gap="400"
>
<Box direction="Column" gap="100">
<Text size="L400">User ID</Text>
<Text as="label" htmlFor="invite-user-id" size="L400">
User ID
</Text>
<div>
<Input
id="invite-user-id"
size="500"
ref={inputRef}
onChange={handleSearchChange}
@@ -334,8 +337,11 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
</div>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Reason (Optional)</Text>
<Text as="label" htmlFor="invite-reason" size="L400">
Reason (Optional)
</Text>
<TextArea
id="invite-reason"
size="500"
name="reasonInput"
variant="Background"
@@ -108,8 +108,11 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
</Text>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Address</Text>
<Text as="label" htmlFor="join-address" size="L400">
Address
</Text>
<Input
id="join-address"
size="500"
autoFocus
name="addressInput"
+41
View File
@@ -0,0 +1,41 @@
import React from 'react';
import katex from 'katex';
import 'katex/dist/katex.min.css';
type KaTeXProps = {
/** Raw LaTeX source (without `$`/`$$` delimiters). */
latex: string;
/** Render as block (display) math when true, inline otherwise. */
displayMode?: boolean;
};
/**
* Lazily-loaded KaTeX renderer.
*
* This module statically imports `katex` and its stylesheet, so both only enter
* the bundle via the dynamic `import()` of this file (see the `lazy()` wrapper
* in `react-custom-html-parser.tsx`). They are therefore NOT part of the eager
* import graph.
*
* We render with `throwOnError: false`, so KaTeX itself renders a parse error
* inline (in its error colour) rather than throwing. The HTML returned by
* `renderToString` is produced by our own trusted call from a fixed options
* object it is safe to inject via `dangerouslySetInnerHTML`.
*/
export default function KaTeX({ latex, displayMode = false }: KaTeXProps) {
const html = katex.renderToString(latex, {
displayMode,
throwOnError: false,
output: 'htmlAndMathml',
});
const Wrapper = displayMode ? 'div' : 'span';
return (
<Wrapper
// KaTeX output is generated by our own render call (trusted-safe).
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
+10 -6
View File
@@ -1,8 +1,8 @@
import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds';
import React, { ReactNode, useCallback } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FileSaver from 'file-saver';
import { mimeTypeToExt } from '../../utils/mimeTypes';
import { useSaveFile } from '../../hooks/useSaveFile';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
@@ -24,6 +24,7 @@ type FileDownloadButtonProps = {
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const saveFile = useSaveFile();
const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
@@ -34,18 +35,19 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
: await downloadMedia(mediaUrl);
const fileURL = URL.createObjectURL(fileContent);
FileSaver.saveAs(fileURL, filename);
saveFile(fileURL, filename);
return fileURL;
}, [mx, url, useAuthentication, mimeType, encInfo, filename]),
}, [mx, url, useAuthentication, mimeType, encInfo, filename, saveFile]),
);
const downloading = downloadState.status === AsyncStatus.Loading;
const hasError = downloadState.status === AsyncStatus.Error;
const succeeded = downloadState.status === AsyncStatus.Success;
return (
<IconButton
disabled={downloading}
onClick={download}
variant={hasError ? 'Critical' : 'SurfaceVariant'}
variant={hasError ? 'Critical' : succeeded ? 'Success' : 'SurfaceVariant'}
size="300"
radii="300"
aria-label={
@@ -53,13 +55,15 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
? 'Downloading...'
: hasError
? 'Download failed, click to retry'
: 'Download file'
: succeeded
? 'Downloaded — click to download again'
: 'Download file'
}
>
{downloading ? (
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
) : (
<Icon size="100" src={Icons.Download} />
<Icon size="100" src={succeeded ? Icons.Check : Icons.Download} />
)}
</IconButton>
);
+3 -1
View File
@@ -61,6 +61,7 @@ type ReplyProps = {
replyEventId: string;
threadRootId?: string | undefined;
onClick?: MouseEventHandler | undefined;
onThreadClick?: ((threadRootId: string) => void) | undefined;
getMemberPowerTag?: GetMemberPowerTag;
accessibleTagColors?: Map<string, string>;
legacyUsernameColor?: boolean;
@@ -74,6 +75,7 @@ export const Reply = as<'div', ReplyProps>(
replyEventId,
threadRootId,
onClick,
onThreadClick,
getMemberPowerTag,
accessibleTagColors,
legacyUsernameColor,
@@ -110,7 +112,7 @@ export const Reply = as<'div', ReplyProps>(
<ThreadIndicator
as="button"
data-event-id={threadRootId}
onClick={onClick}
onClick={onThreadClick ? () => onThreadClick(threadRootId) : onClick}
aria-label="View thread"
/>
)}
@@ -99,9 +99,21 @@ export function AudioContent({
const [playbackSpeed, setPlaybackSpeed] = useState<0.75 | 1 | 1.5 | 2>(1);
useEffect(() => {
if (audioRef.current) {
audioRef.current.playbackRate = playbackSpeed;
}
const audio = audioRef.current;
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]);
const SPEED_STEPS: Array<0.75 | 1 | 1.5 | 2> = [0.75, 1, 1.5, 2];
@@ -14,10 +14,10 @@ import {
TooltipProvider,
as,
} from 'folds';
import FileSaver from 'file-saver';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FocusTrap from 'focus-trap-react';
import { IFileInfo } from '../../../../types/matrix/common';
import { useSaveFile } from '../../../hooks/useSaveFile';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { bytesToSize } from '../../../utils/common';
@@ -252,6 +252,7 @@ export type DownloadFileProps = {
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const saveFile = useSaveFile();
const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
@@ -262,9 +263,9 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
: await downloadMedia(mediaUrl);
const fileURL = URL.createObjectURL(fileContent);
FileSaver.saveAs(fileURL, body);
saveFile(fileURL, body);
return fileURL;
}, [mx, url, useAuthentication, mimeType, encInfo, body]),
}, [mx, url, useAuthentication, mimeType, encInfo, body, saveFile]),
);
return downloadState.status === AsyncStatus.Error ? (
@@ -277,7 +278,7 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
size="400"
onClick={() =>
downloadState.status === AsyncStatus.Success
? FileSaver.saveAs(downloadState.data, body)
? saveFile(downloadState.data, body)
: download()
}
disabled={downloadState.status === AsyncStatus.Loading}
-1
View File
@@ -117,7 +117,6 @@ export const PageHeroSection = style([
},
]);
export const PageContentCenter = style([
DefaultReset,
{
@@ -95,7 +95,6 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
clickOutsideDeactivates: true,
onDeactivate: () => setViewTopic(false),
escapeDeactivates: stopPropagation,
@@ -1,6 +1,7 @@
import React, { useMemo } from 'react';
import { useAtomValue } from 'jotai';
import { settingsAtom } from '../../state/settings';
import { useReducedMotion } from '../../hooks/useReducedMotion';
import { zIndices } from '../../styles/zIndex';
import { SeasonTheme } from './types';
import { getActiveSeason } from './seasonSchedule';
@@ -94,8 +95,7 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
export function SeasonalEffect() {
const settings = useAtomValue(settingsAtom);
const reduced =
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const reduced = useReducedMotion();
const theme = useMemo<SeasonTheme | null>(() => {
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,526 @@
import React, { useCallback, useEffect, 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 {
getAudioDurationMs,
playClipLocally,
resolveClipObjectUrl,
SOUNDBOARD_ACCEPT,
SOUNDBOARD_MAX_CLIP_BYTES,
SOUNDBOARD_MAX_CLIPS,
} from '../../utils/soundboardClips';
import { stopPropagation } from '../../utils/keyboard';
// Injected once: the little "now playing" equalizer bars animation.
const EQ_STYLE_ID = 'lotus-soundboard-eq-keyframes';
function ensureEqKeyframes() {
if (typeof document === 'undefined' || document.getElementById(EQ_STYLE_ID)) return;
const style = document.createElement('style');
style.id = EQ_STYLE_ID;
style.textContent = `
@keyframes lotusSbEq { 0%,100% { transform: scaleY(0.3); } 50% { transform: scaleY(1); } }
@media (prefers-reduced-motion: reduce) { @keyframes lotusSbEq { 0%,100% { transform: scaleY(0.6); } } }
`;
document.head.appendChild(style);
}
function PlayingBars() {
return (
<Box alignItems="Center" gap="100" style={{ height: toRem(14) }} aria-hidden>
{[0, 1, 2].map((i) => (
<span
key={i}
style={{
display: 'inline-block',
width: toRem(3),
height: toRem(14),
borderRadius: toRem(2),
background: color.Primary.Main,
transformOrigin: 'center bottom',
animation: `lotusSbEq 0.7s ease-in-out ${i * 0.15}s infinite`,
}}
/>
))}
</Box>
);
}
// Short clip length shown while adjusting a sound: "3.2s", or "1:04" if ≥ 60s.
const formatClipSeconds = (seconds: number): string => {
if (!Number.isFinite(seconds) || seconds < 0) return '';
if (seconds < 60) return `${seconds.toFixed(1)}s`;
const m = Math.floor(seconds / 60);
const s = Math.round(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
};
type ClipDraft = {
url: string;
body: string;
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 [playingKey, setPlayingKey] = useState<string>();
const [durations, setDurations] = useState<Map<string, number>>(new Map()); // shortcode -> seconds
const audioElRef = useRef<HTMLAudioElement | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const emojiAnchorRef = useRef<HTMLElement | null>(null);
useEffect(() => {
ensureEqKeyframes();
return () => {
audioElRef.current?.pause();
audioElRef.current = null;
};
}, []);
const existing = useMemo(() => pack.getClips(), [pack]);
const clipCount = existing.filter((c) => !deleted.has(c.shortcode)).length + uploads.length;
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 stopPlayback = useCallback(() => {
audioElRef.current?.pause();
audioElRef.current = null;
setPlayingKey(undefined);
}, []);
const preview = useCallback(
async (id: string, mxc: string, volume: number) => {
// Clicking the clip that's already playing stops it (toggle).
if (audioElRef.current && playingKey === id) {
stopPlayback();
return;
}
stopPlayback(); // stop any other clip first
setBusyPreview(id);
try {
const url = await resolveClipObjectUrl(mx, mxc);
const audio = playClipLocally(url, volume / 100);
if (audio) {
audioElRef.current = audio;
setPlayingKey(id);
audio.addEventListener('loadedmetadata', () => {
if (Number.isFinite(audio.duration)) {
setDurations((prev) => new Map(prev).set(id, audio.duration));
}
});
const clear = () => {
if (audioElRef.current === audio) audioElRef.current = null;
setPlayingKey((k) => (k === id ? undefined : k));
};
audio.addEventListener('ended', clear);
audio.addEventListener('pause', clear);
audio.addEventListener('error', clear);
}
} catch {
/* ignore preview errors */
} finally {
setBusyPreview(undefined);
}
},
[mx, playingKey, stopPlayback],
);
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 durationMs = await getAudioDurationMs(file);
// eslint-disable-next-line no-await-in-loop
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
const mxc = res.content_uri;
if (!mxc) throw new Error('Upload failed.');
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, duration: durationMs },
},
]);
}
} 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);
}
};
const isPlaying = playingKey === key;
const clipSeconds =
durations.get(key) ?? (base.info?.duration != null ? base.info.duration / 1000 : undefined);
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={isPlaying ? 'Primary' : 'Secondary'}
disabled={busyPreview === key}
onClick={() => preview(key, base.url, rowVolume)}
aria-label={isPlaying ? `Stop ${rowBody}` : `Preview ${rowBody}`}
>
{busyPreview === key ? (
<Spinner size="100" />
) : (
<Icon size="100" src={isPlaying ? Icons.Pause : Icons.Play} filled={isPlaying} />
)}
</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"
justifyContent="End"
gap="100"
shrink="No"
style={{ width: toRem(52) }}
>
{isPlaying ? (
<PlayingBars />
) : (
clipSeconds !== undefined && (
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap' }}>
{formatClipSeconds(clipSeconds)}
</Text>
)
)}
</Box>
<Box alignItems="Center" gap="100" shrink="No" style={{ width: toRem(148) }}>
<Icon size="50" src={Icons.VolumeHigh} />
<input
type="range"
min={0}
max={100}
step={5}
defaultValue={rowVolume}
disabled={!canEdit || markedDeleted}
onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })}
style={{ flexGrow: 1, minWidth: 0 }}
aria-label="Clip volume"
/>
<Text size="T200" priority="300" style={{ width: toRem(30), textAlign: 'right' }}>
{rowVolume}%
</Text>
</Box>
{canEdit && !isUpload && (
<IconButton
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,
info: c.info,
},
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 (
<video
aria-label="Video attachment preview"
style={{
objectFit: 'contain',
width: '100%',
@@ -260,6 +260,7 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer
<Input
ref={reasonInputRef}
placeholder="Reason"
aria-label="Moderation reason"
size="300"
variant="Background"
radii="300"
@@ -253,6 +253,7 @@ function UserPrivateNotes({ userId }: { userId: string }) {
)}
</Box>
<textarea
aria-label="Private note about this user"
value={draft}
onChange={handleChange}
maxLength={USER_NOTE_MAX_LENGTH}
+61 -11
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 {
Avatar,
Box,
@@ -16,6 +17,8 @@ import {
import classNames from 'classnames';
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomEvent } from '../../hooks/useRoomEvent';
import { MessageDeletedContent } from '../../components/message/content/FallbackContent';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { RoomAvatar } from '../../components/room-avatar';
@@ -42,9 +45,11 @@ type BookmarkItemProps = {
bookmark: Bookmark;
onJump: (roomId: string, 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 useAuthentication = useMediaAuthentication();
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 }}
>
<Text className={css.BookmarkPreview} size="T200" priority="400">
{bookmark.previewText || '(no preview)'}
{preview ?? (bookmark.previewText || '(no preview)')}
</Text>
</Button>
</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 = {
onClose: () => void;
};
export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
const mx = useMatrixClient();
const { bookmarks, removeBookmark } = useBookmarks();
const { navigateRoom } = useRoomNavigate();
const [filter, setFilter] = useState('');
@@ -228,14 +265,27 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
</Box>
) : (
<Box className={css.BookmarksContent} direction="Column" gap="200">
{filtered.map((bk) => (
<BookmarkItem
key={bk.eventId}
bookmark={bk}
onJump={handleJump}
onRemove={removeBookmark}
/>
))}
{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
key={bk.eventId}
bookmark={bk}
onJump={handleJump}
onRemove={removeBookmark}
/>
);
})}
</Box>
)}
</Scroll>
+76 -18
View File
@@ -1,4 +1,5 @@
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
import { useSetAtom } from 'jotai';
import {
Box,
Button,
@@ -32,11 +33,16 @@ import {
import { CallEmbed, useCallControlState } from '../../plugins/call';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { callEmbedAtom } from '../../state/callEmbed';
import { useResizeObserver } from '../../hooks/useResizeObserver';
import { stopPropagation } from '../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
import { useAfkAutoMute } from '../../hooks/useAfkAutoMute';
import { CallSoundboard } from './CallSoundboard';
import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
import { RoomQualityContent } from '../../utils/callQuality';
type CallControlsProps = {
callEmbed: CallEmbed;
@@ -44,6 +50,7 @@ type CallControlsProps = {
export function CallControls({ callEmbed }: CallControlsProps) {
const controlRef = useRef<HTMLDivElement>(null);
const callEmbedRef = useCallEmbedRef();
const setCallEmbed = useSetAtom(callEmbedAtom);
const [compact, setCompact] = useState(document.body.clientWidth < 500);
const [isFullscreen, setIsFullscreen] = useState(false);
@@ -88,6 +95,19 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const [pttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey] = useSetting(settingsAtom, 'pttKey');
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
const [soundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
// [P5-31] Hard room publish policy — hide controls the server will refuse so
// users don't click dead buttons. Absent/true = allowed.
const roomQualityEvent = useStateEvent(callEmbed.room, StateEvent.LotusRoomQuality);
const roomQuality = roomQualityEvent?.getContent<RoomQualityContent>();
const cameraAllowed = roomQuality?.allow_camera !== false;
const screenshareAllowed = roomQuality?.allow_screenshare !== false;
// Keep a forbidden control visible while its track is still live (so the user
// can stop it); otherwise hide it entirely.
const showCamera = cameraAllowed || video;
const showScreenshare = screenshareAllowed || screenshare;
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
const [pttActive, setPttActive] = useState(false);
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
@@ -158,22 +178,28 @@ export function CallControls({ callEmbed }: CallControlsProps) {
};
if (isEditable(target)) return;
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);
pttActiveRef.current = true;
setPttActive(true);
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code !== pttKey) return;
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false);
pttActiveRef.current = false;
setPttActive(false);
};
const onBlur = () => {
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false);
pttActiveRef.current = false;
setPttActive(false);
};
const onFocus = () => {
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false);
pttActiveRef.current = false;
setPttActive(false);
@@ -198,6 +224,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
iframeWindow?.removeEventListener('focus', onFocus);
// BUG-8: if callEmbed changes while PTT is active, release mic on cleanup
if (pttActiveRef.current) {
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false);
pttActiveRef.current = false;
setPttActive(false);
@@ -225,8 +252,15 @@ export function CallControls({ callEmbed }: CallControlsProps) {
e.preventDefault();
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);
return () => window.removeEventListener('keydown', onKeyDown);
iframeWindow?.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('keydown', onKeyDown);
iframeWindow?.removeEventListener('keydown', onKeyDown);
};
}, [callEmbed, deafenKey]);
const [hangupState, hangup] = useAsyncCallback(
@@ -235,6 +269,19 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const exiting =
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', '');
return (
@@ -334,29 +381,40 @@ export function CallControls({ callEmbed }: CallControlsProps) {
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} />
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
<ScreenshareAudioButton
muted={screenshareAudioMuted}
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
/>
</Box>
{!compact && <ControlDivider />}
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<VideoButton enabled={video} onToggle={handleVideoToggle} />
<ScreenShareButton
enabled={screenshare}
onToggle={() =>
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
/>
{!!document.fullscreenEnabled && (
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
)}
</Box>
{!compact && showVideoGroup && <ControlDivider />}
{showVideoGroup && (
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
{/* Show a forbidden control while its track is still live so the
user can stop it; once stopped it hides and can't be restarted. */}
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
{showScreenshare && (
<>
<ScreenShareButton
enabled={screenshare}
onToggle={() =>
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 && (
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
)}
</Box>
)}
</Box>
{!compact && <ControlDivider />}
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<ChatButton />
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
<PopOut
anchor={cords}
position="Top"
+280
View File
@@ -0,0 +1,280 @@
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Icon,
IconButton,
Icons,
Menu,
PopOut,
RectCords,
Scroll,
Spinner,
Switch,
Text,
Tooltip,
TooltipProvider,
color,
config,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useAtomValue } from 'jotai';
import { CallEmbed } from '../../plugins/call';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSetting } from '../../state/hooks/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 { playClipLocally, resolveClipObjectUrl } from '../../utils/soundboardClips';
type CallSoundboardProps = {
callEmbed: CallEmbed;
};
type FlatClip = {
key: string; // packId|shortcode
packId: string;
packName: string;
clip: SoundboardClipReader;
};
/**
* [P5-15 v2] In-call soundboard. Clips come from the aggregated soundboard packs
* relevant to the call room (the room + parent spaces the user's personal
* pack), just like custom emoji. Playing a clip publishes it into the call via
* the EC fork (`io.lotus.inject_audio`, max one at a time) and plays it locally.
* 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) {
const mx = useMatrixClient();
const { room } = callEmbed;
const roomToParents = useAtomValue(roomToParentsAtom);
const packRooms = useImagePackRooms(room.roomId, roomToParents);
const packs = useRelevantSoundboardPacks(packRooms);
const [soundboardVolume] = useSetting(settingsAtom, 'soundboardVolume');
const master = Math.max(0, Math.min(1, soundboardVolume / 100));
const [cords, setCords] = useState<RectCords>();
const [manage, setManage] = useState(false);
const [playingKey, setPlayingKey] = useState<string>(); // host-side spam guard
const [error, setError] = useState<string>();
// C-L6: the play() flow schedules a 30s safety timeout that clears playingKey;
// guard those setState calls against the component unmounting first.
const mountedRef = useRef(true);
useEffect(
() => () => {
mountedRef.current = false;
},
[],
);
const groups = useMemo(
() =>
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) => {
setError(undefined);
setCords(evt.currentTarget.getBoundingClientRect());
};
const play = useCallback(
async (flat: FlatClip) => {
if (playingKey) return; // one at a time (fork also enforces this)
setPlayingKey(flat.key);
setError(undefined);
const done = () => {
if (!mountedRef.current) return;
setPlayingKey((k) => (k === flat.key ? undefined : k));
};
try {
const url = await resolveClipObjectUrl(mx, flat.clip.url);
const vol = (flat.clip.volume / 100) * master;
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 {
setError('Could not play that clip.');
done();
}
},
[mx, callEmbed, master, playingKey],
);
return (
<PopOut
anchor={cords}
position="Top"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ maxWidth: manage ? toRem(420) : toRem(340), maxHeight: '70vh' }}>
<Box direction="Column" style={{ maxHeight: '70vh' }}>
<Box
shrink="No"
alignItems="Center"
justifyContent="SpaceBetween"
gap="200"
style={{
padding: config.space.S200,
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}}
>
<Text size="L400">Soundboard</Text>
<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>
<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">
No soundboard clips here yet. Turn on <b>Manage</b> to upload some, or add
a pack in Space settings.
</Text>
)}
{groups.map((g) => (
<Box key={g.id} direction="Column" gap="100">
<Text size="L400">{g.name}</Text>
<Box wrap="Wrap" gap="200">
{g.clips.map((clip) => {
const key = `${g.id}|${clip.shortcode}`;
const flat: FlatClip = {
key,
packId: g.id,
packName: g.name,
clip,
};
return (
<Box
key={key}
as="button"
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="100"
disabled={!!playingKey}
onClick={() => play(flat)}
aria-label={`Play ${clip.name}`}
style={{
width: toRem(76),
minHeight: toRem(76),
height: 'auto',
padding: config.space.S100,
borderRadius: config.radii.R400,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
background:
playingKey === key
? color.Primary.Container
: color.SurfaceVariant.Container,
cursor: playingKey ? 'default' : 'pointer',
opacity: playingKey && playingKey !== key ? 0.5 : 1,
}}
>
<Text size="H4">
{playingKey === key ? (
<Spinner size="200" />
) : (
clip.emoji || '🔊'
)}
</Text>
<Text
size="T200"
style={{
maxWidth: '100%',
textAlign: 'center',
wordBreak: 'break-word',
lineHeight: 1.15,
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{clip.name}
</Text>
</Box>
);
})}
</Box>
</Box>
))}
</>
)}
{error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{error}
</Text>
)}
</Box>
</Scroll>
</Box>
</Menu>
</FocusTrap>
}
>
<TooltipProvider
tooltip={
<Tooltip>
<Text>Soundboard</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Surface"
fill="Soft"
radii="400"
size="400"
onClick={handleOpen}
outlined
aria-label="Soundboard"
aria-expanded={!!cords}
aria-haspopup="menu"
>
<Icon size="400" src={Icons.BellRing} />
</IconButton>
)}
</TooltipProvider>
</PopOut>
);
}
@@ -0,0 +1,198 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Switch, Text } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import {
AUDIO_BITRATE_OPTIONS,
RoomQualityContent,
SCREENSHARE_BITRATE_OPTIONS,
SCREENSHARE_FRAMERATE_OPTIONS,
} from '../../../utils/callQuality';
// Only the numeric cap keys are edited via `update`; the boolean policy keys
// are handled by `setAllow`.
type CapKey = 'audio_max_kbps' | 'screenshare_max_kbps' | 'screenshare_max_fps';
// String <-> numeric bridge for SettingsSelect (which needs string values).
const toValue = (n?: number): string => (typeof n === 'number' ? String(n) : 'auto');
const CAP_KEYS: (keyof RoomQualityContent)[] = [
'audio_max_kbps',
'screenshare_max_kbps',
'screenshare_max_fps',
'allow_screenshare',
'allow_camera',
];
const capsEqual = (a: RoomQualityContent, b: RoomQualityContent): boolean =>
CAP_KEYS.every((k) => a[k] === b[k]);
type RoomQualityProps = {
permissions: RoomPermissionsAPI;
};
/**
* [P5-31] Room-admin quality ceiling. Writes `io.lotus.room_quality`; every
* Lotus client clamps its per-user quality to these caps. Hard enforcement for
* ALL Matrix clients is a server-side follow-up (see LOTUS_TODO.md P5-31).
*/
export function RoomQuality({ permissions }: RoomQualityProps) {
const mx = useMatrixClient();
const room = useRoom();
const canEdit = permissions.stateEvent(StateEvent.LotusRoomQuality, mx.getSafeUserId());
const event = useStateEvent(room, StateEvent.LotusRoomQuality);
const caps = useMemo<RoomQualityContent>(() => event?.getContent() ?? {}, [event]);
const [submitState, submit] = useAsyncCallback(
useCallback(
async (next: RoomQualityContent) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await mx.sendStateEvent(room.roomId, StateEvent.LotusRoomQuality as any, next);
},
[mx, room.roomId],
),
);
const submitting = submitState.status === AsyncStatus.Loading;
// Optimistic mirror: `useStateEvent` only refreshes when the write echoes
// back via /sync (not when sendStateEvent resolves), so consecutive edits
// must build on the pending write — otherwise a second edit spreads a stale
// `caps` and silently drops the first. `effective` is what the UI shows and
// what each edit merges into; it's reconciled below once the echo lands.
const [pending, setPending] = useState<RoomQualityContent | null>(null);
const effective = pending ?? caps;
useEffect(() => {
if (!pending) return;
// Revert the optimistic view if the write failed…
if (submitState.status === AsyncStatus.Error) {
setPending(null);
return;
}
// …or drop it once the synced state actually reflects it.
if (capsEqual(caps, pending)) setPending(null);
}, [caps, pending, submitState.status]);
const commit = (next: RoomQualityContent) => {
setPending(next);
submit(next);
};
const update = (key: CapKey, value: string) => {
const next: RoomQualityContent = { ...effective };
if (value === 'auto') delete next[key];
else next[key] = parseInt(value, 10);
commit(next);
};
const setAllow = (key: 'allow_screenshare' | 'allow_camera', allowed: boolean) => {
const next: RoomQualityContent = { ...effective };
// Absent = allowed, so only persist the key when forbidding.
if (allowed) delete next[key];
else next[key] = false;
commit(next);
};
// Absent/true = allowed.
const screenshareAllowed = effective.allow_screenshare !== false;
const cameraAllowed = effective.allow_camera !== false;
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Call Permissions"
description={
<Text size="T200" priority="300">
Control what participants may share in this room. These are enforced on the server for
every Matrix client (Element, FluffyChat, Lotus Chat, ).
</Text>
}
/>
<Box direction="Column" gap="300">
<SettingTile
title="Allow Screen Sharing"
description="When off, no one can share their screen in this room."
after={
<Switch
variant="Primary"
value={screenshareAllowed}
onChange={(v) => setAllow('allow_screenshare', v)}
disabled={!canEdit || submitting}
/>
}
/>
<SettingTile
title="Allow Camera"
description="When off, this is an audio-only room — no one can turn on their camera. Microphones are always allowed."
after={
<Switch
variant="Primary"
value={cameraAllowed}
onChange={(v) => setAllow('allow_camera', v)}
disabled={!canEdit || submitting}
/>
}
/>
</Box>
<SettingTile
title="Call Quality Caps"
description={
<Text size="T200" priority="300">
Set a maximum microphone bitrate, screenshare bitrate, and screenshare framerate for
this room. Lotus Chat clamps each participant to these ceilings (best-effort applies
to Lotus Chat clients). Auto = no cap.
</Text>
}
/>
<Box direction="Column" gap="300">
<SettingTile
title="Max Microphone Bitrate"
after={
<SettingsSelect
value={toValue(effective.audio_max_kbps)}
onChange={(v) => update('audio_max_kbps', v)}
options={AUDIO_BITRATE_OPTIONS}
disabled={!canEdit || submitting}
/>
}
/>
<SettingTile
title="Max Screenshare Bitrate"
after={
<SettingsSelect
value={toValue(effective.screenshare_max_kbps)}
onChange={(v) => update('screenshare_max_kbps', v)}
options={SCREENSHARE_BITRATE_OPTIONS}
disabled={!canEdit || submitting}
/>
}
/>
<SettingTile
title="Max Screenshare Framerate"
after={
<SettingsSelect
value={toValue(effective.screenshare_max_fps)}
onChange={(v) => update('screenshare_max_fps', v)}
options={SCREENSHARE_FRAMERATE_OPTIONS}
disabled={!canEdit || submitting}
/>
}
/>
</Box>
</SequenceCard>
);
}
@@ -0,0 +1,80 @@
import React, { useCallback } from 'react';
import { Box, Button, color, Spinner, Text } from 'folds';
import { MatrixError } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
import { RetentionContent, RETENTION_PRESETS } from '../../../utils/retention';
type RoomRetentionProps = {
permissions: RoomPermissionsAPI;
};
export function RoomRetention({ permissions }: RoomRetentionProps) {
const mx = useMatrixClient();
const room = useRoom();
const canEdit = permissions.stateEvent(StateEvent.RoomRetention, mx.getSafeUserId());
const event = useStateEvent(room, StateEvent.RoomRetention);
const currentMs = event?.getContent<RetentionContent>().max_lifetime ?? 0;
const [submitState, submit] = useAsyncCallback(
useCallback(
async (ms: number) => {
const content: RetentionContent = ms > 0 ? { max_lifetime: ms } : {};
// Lotus custom-state convention: cast the type key (RoomRetention isn't a
// typed key in the SDK's StateEvents map).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await mx.sendStateEvent(room.roomId, StateEvent.RoomRetention as any, content);
},
[mx, room.roomId],
),
);
const submitting = submitState.status === AsyncStatus.Loading;
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Message Retention"
description="Messages older than this window disappear from the timeline. Each member can opt in to permanently delete their own expired messages in Settings → General; full server-side deletion also requires homeserver retention to be configured."
>
<Box gap="200" alignItems="Center" style={{ flexWrap: 'wrap' }}>
{RETENTION_PRESETS.map((preset) => {
const active = currentMs === preset.ms;
return (
<Button
key={preset.label}
type="button"
size="300"
variant={active ? 'Primary' : 'Secondary'}
fill={active ? 'Solid' : 'Soft'}
radii="300"
disabled={!canEdit || submitting}
onClick={() => submit(preset.ms)}
>
<Text size="B300">{preset.label}</Text>
</Button>
);
})}
{submitting && <Spinner size="100" variant="Secondary" />}
</Box>
{submitState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T200">
{(submitState.error as MatrixError).message}
</Text>
)}
</SettingTile>
</SequenceCard>
);
}
@@ -1,5 +1,6 @@
import React, { useCallback, useState } from 'react';
import { 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 { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
@@ -12,11 +13,9 @@ export function RoomShareInvite() {
const mx = useMatrixClient();
const room = useRoom();
const [copied, setCopied] = useState(false);
const [qrError, setQrError] = useState(false);
const domain = mx.getDomain() ?? 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(() => {
navigator.clipboard.writeText(inviteUrl).then(() => {
@@ -64,35 +63,19 @@ export function RoomShareInvite() {
</Box>
</Box>
<Box justifyContent="Center">
{qrError ? (
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="100"
style={{
width: 160,
height: 160,
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
}}
>
<Icon size="400" src={Icons.Warning} />
<Text size="T200" priority="300" align="Center">
QR code unavailable
</Text>
</Box>
) : (
<img
src={qrSrc}
alt="QR code for room invite link"
width={160}
height={160}
loading="lazy"
onError={() => setQrError(true)}
style={{ display: 'block', borderRadius: config.radii.R300 }}
/>
)}
{/* 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
style={{
padding: config.space.S200,
background: '#ffffff',
borderRadius: config.radii.R300,
lineHeight: 0,
}}
>
<QRCodeSVG value={inviteUrl} size={160} level="M" title="Room invite QR code" />
</Box>
</Box>
</Box>
</CutoutCard>
@@ -4,6 +4,8 @@ export * from './RoomHistoryVisibility';
export * from './RoomJoinRules';
export * from './RoomProfile';
export * from './RoomPublish';
export * from './RoomQuality';
export * from './RoomRetention';
export * from './RoomShareInvite';
export * from './RoomUpgrade';
export * from './RoomVoiceLimit';
@@ -5,6 +5,8 @@ import {
Button,
Chip,
Text,
Icon,
Icons,
RectCords,
PopOut,
Menu,
@@ -75,15 +77,16 @@ function PeekPermissions({ powerLevels, power, permissionGroups, children }: Pee
const hasPower = requiredPower <= power;
return (
<Text
<Box
key={itemIndex}
size="T200"
style={{
color: hasPower ? undefined : color.Critical.Main,
}}
as="span"
alignItems="Center"
gap="100"
style={{ color: hasPower ? undefined : color.Critical.Main }}
>
{hasPower ? '✅' : '❌'} {item.name}
</Text>
<Icon size="50" src={hasPower ? Icons.Check : Icons.Cross} />
<Text size="T200">{item.name}</Text>
</Box>
);
})}
</div>
@@ -147,6 +147,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
<Text size="L400">Name</Text>
<Input
name="nameInput"
aria-label="Power level name"
defaultValue={tag?.name}
placeholder="Bot"
size="300"
@@ -160,6 +161,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
<Input
defaultValue={power}
name="powerInput"
aria-label="Power level value"
size="300"
variant={typeof power === 'number' ? 'SurfaceVariant' : 'Secondary'}
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';
+77
View File
@@ -0,0 +1,77 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
const BAR_HEIGHT = toRem(32);
const CONTROL_WIDTH = toRem(46);
export const TitleBar = style([
DefaultReset,
{
display: 'flex',
alignItems: 'stretch',
flexShrink: 0,
height: BAR_HEIGHT,
width: '100%',
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
borderBottom: `${toRem(1)} solid ${color.SurfaceVariant.ContainerLine}`,
// Sit above app content but never intercept scroll etc. below the bar.
userSelect: 'none',
},
]);
// The draggable region carries `data-tauri-drag-region`; it must expand to fill
// the free space so most of the bar is grabbable.
export const DragRegion = style({
display: 'flex',
alignItems: 'center',
flexGrow: 1,
minWidth: 0,
gap: config.space.S200,
paddingInline: config.space.S300,
});
export const Brand = style({
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
// Children shouldn't swallow the drag; the region itself owns the attribute.
pointerEvents: 'none',
});
export const Controls = style({
display: 'flex',
alignItems: 'stretch',
flexShrink: 0,
});
export const ControlButton = style([
DefaultReset,
{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: CONTROL_WIDTH,
height: '100%',
padding: 0,
border: 'none',
cursor: 'pointer',
backgroundColor: 'transparent',
color: 'inherit',
transition: 'background-color 100ms ease',
selectors: {
'&:hover': {
backgroundColor: color.SurfaceVariant.ContainerLine,
},
},
},
]);
export const ControlButtonClose = style({
selectors: {
'&:hover': {
backgroundColor: color.Critical.Main,
color: color.Critical.OnMain,
},
},
});
+144
View File
@@ -0,0 +1,144 @@
import React, { MouseEvent, ReactNode } from 'react';
import { useAtomValue } from 'jotai';
import { Text } from 'folds';
import { customWindowChromeAtom } from '../../state/customWindowChrome';
import { invokeTauri, isTauri } from '../../hooks/useTauri';
import * as css from './TitleBar.css';
/**
* Detect macOS from the web side (no `tauri-plugin-os` dependency). We only need
* a coarse "is this a Mac" signal to decide which side the window controls sit
* on, so the UA/platform sniff is sufficient and stays cross-platform.
*/
const isMacOS = (): boolean => {
const platform =
(
navigator as unknown as {
userAgentData?: { platform?: string };
}
).userAgentData?.platform ??
navigator.platform ??
navigator.userAgent;
return /mac/i.test(platform);
};
const MIN_GLYPH = (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<rect x="1" y="4.5" width="8" height="1" fill="currentColor" />
</svg>
);
const MAX_GLYPH = (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<rect x="1" y="1" width="8" height="8" fill="none" stroke="currentColor" strokeWidth="1" />
</svg>
);
const CLOSE_GLYPH = (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<path d="M1 1 L9 9 M9 1 L1 9" stroke="currentColor" strokeWidth="1" fill="none" />
</svg>
);
type ControlButtonProps = {
label: string;
glyph: ReactNode;
onClick: () => void;
close?: boolean;
};
function ControlButton({ label, glyph, onClick, close }: ControlButtonProps) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={`${css.ControlButton}${close ? ` ${css.ControlButtonClose}` : ''}`}
>
{glyph}
</button>
);
}
/**
* P5-47 TDS Custom Window Chrome titlebar.
*
* Renders `null` unless we're inside Tauri **and** the user opted into custom
* window chrome. Otherwise it draws a thin (~32px) folds/TDS-styled titlebar: a
* draggable region (explicit `window_start_drag` on mousedown, double-press to
* maximize) with the app brand, plus minimize / maximize / close controls that
* call the native window commands.
*
* OS-aware: Windows/Linux put the controls on the right; macOS mirrors them to
* the left (the native traffic-light position) since decorations and thus the
* real traffic lights are stripped while custom chrome is on.
*/
export function TitleBar() {
const enabled = useAtomValue(customWindowChromeAtom);
if (!isTauri() || !enabled) return null;
const mac = isMacOS();
// Official Tauri custom-titlebar recipe: primary-button mousedown starts an
// OS window drag; a double press (detail === 2) toggles maximize instead. An
// explicit `window_start_drag` invoke is used rather than
// `data-tauri-drag-region` because the attribute only fires when the exact
// element is the event target (children like the brand text wouldn't drag).
const handleDragMouseDown = (evt: MouseEvent<HTMLDivElement>): void => {
if (evt.button !== 0) return;
if (evt.detail === 2) {
invokeTauri('window_toggle_maximize');
} else {
invokeTauri('window_start_drag');
}
};
const controls = (
<div className={css.Controls}>
<ControlButton
label="Minimize"
glyph={MIN_GLYPH}
onClick={() => invokeTauri('window_minimize')}
/>
<ControlButton
label="Maximize"
glyph={MAX_GLYPH}
onClick={() => invokeTauri('window_toggle_maximize')}
/>
<ControlButton
label="Close"
glyph={CLOSE_GLYPH}
onClick={() => invokeTauri('window_close')}
close
/>
</div>
);
const dragRegion = (
<div className={css.DragRegion} onMouseDown={handleDragMouseDown}>
<span className={css.Brand}>
<Text as="span" size="T200" truncate>
Lotus Chat
</Text>
</span>
</div>
);
return (
<header className={css.TitleBar}>
{mac ? (
<>
{controls}
{dragRegion}
</>
) : (
<>
{dragRegion}
{controls}
</>
)}
</header>
);
}
@@ -0,0 +1,49 @@
import { keyframes } from '@vanilla-extract/css';
// Aurora Flow — a SLOW, gentle pan of layered soft aurora ribbons.
//
// The living-aurora illusion is a pure `background-position` drift: each
// comma-separated gradient layer is authored larger than the viewport
// (backgroundSize 200%300%, see animAurora.ts) so there is slack to slide it
// around. Panning several broad blurred bands by DIFFERENT
// amounts and along DIFFERENT paths makes the ribbons appear to curl and cross
// like real northern lights — no single layer ever moves in lockstep.
//
// LAYER ORDER (must match animAurora.ts exactly — one position value per layer):
// 1. green ribbon (drifts a wide, lazy horizontal arc)
// 2. teal ribbon (drifts on a slower, offset diagonal)
// 3. violet ribbon (drifts vertically, the "curtain" fold)
// 4. sky/aqua highlight (small counter-drift for shimmer)
// 5. calm reading core (STATIC — kept at 50% 50% so the center never moves)
// 6. vignette (STATIC — kept at 50% 50% so edges never move)
//
// SEAMLESS LOOP: every animated layer starts and ends on the SAME position
// ('0%'/'100%' being identical sample points of the repeating gradient tile),
// so one period returns each band to its origin with no visible jump. The two
// static layers list their fixed position at every stop so they never pan.
//
// SLOW & GENTLE: paired with a long duration + ease-in-out in animAurora.ts, the
// motion reads as a barely-perceptible breathing drift, keeping the reading
// center calm and text crisp.
//
// getChatBg adds `willChange: 'background-position'` here and STRIPS the whole
// `animation` for prefers-reduced-motion / pause-animations, at which point the
// static `backgroundPosition` authored in animAurora.ts is what shows — already
// a finished, gorgeous aurora.
export const auroraFlow = keyframes({
'0%': {
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
},
'25%': {
backgroundPosition: '35% 45%, 70% 55%, 55% 35%, 45% 60%, 50% 50%, 50% 50%',
},
'50%': {
backgroundPosition: '65% 60%, 40% 40%, 45% 70%, 70% 35%, 50% 50%, 50% 50%',
},
'75%': {
backgroundPosition: '35% 45%, 70% 55%, 55% 35%, 45% 60%, 50% 50%, 50% 50%',
},
'100%': {
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
},
});
@@ -0,0 +1,81 @@
import { CSSProperties } from 'react';
import { ChatBgVariants } from './types';
import { auroraFlow } from './animAurora.css';
// Aurora Flow — a premium ANIMATED aurora: soft ribbons of northern-lights color
// slowly drifting and curling over a deep, calm base.
//
// CONCEPT
// Broad, heavily-feathered gradient bands stacked over a deep midnight base, with
// a gentle vignette that darkens the edges and keeps the reading center calm.
// The distinct STATIC 'aurora' is a favorite still; this one earns its own slot
// by MOVING — see animAurora.css.ts, which slowly pans each ribbon along its own
// path via `background-position` so the curtains appear to fold and cross.
//
// LAYER ORDER (must stay in lockstep with auroraFlow's per-layer position list):
// 1. green ribbon 2. teal ribbon 3. violet ribbon 4. sky highlight
// 5. calm reading core (static) 6. vignette (static)
//
// READABILITY
// Every ribbon is a wide ellipse fading fully to transparent well before its
// edge, at low alpha (~0.050.13), so no band ever concentrates enough contrast
// under the message column to threaten WCAG-AA. Layer 5 lifts a soft, even wash
// through the vertical center — the reading zone — so text always sits on a calm,
// low-variance field. oklch() keeps every hue perceptually smooth and low-chroma.
//
// MOTION / SEAMLESS LOOP
// backgroundSize is >100% per animated layer, giving room to drift; the keyframe
// returns every band to its start over one long, ease-in-out period, so the loop
// is seamless and the motion barely-perceptible. willChange/animation are added
// (and stripped for reduced-motion) by getChatBg; the static positions below are
// the finished still that shows when motion is off.
const dark: CSSProperties = {
// Deep midnight blue — the polar night sky the aurora glows over.
backgroundColor: 'oklch(0.17 0.045 255)',
backgroundImage: [
// 1. Green ribbon — the signature aurora band.
'radial-gradient(ellipse 70% 45% at 50% 50%, oklch(0.7 0.14 160 / 0.13) 0%, oklch(0.7 0.14 160 / 0.05) 45%, transparent 72%)',
// 2. Teal ribbon — cool counterpart, offset.
'radial-gradient(ellipse 80% 40% at 50% 50%, oklch(0.65 0.12 200 / 0.12) 0%, oklch(0.65 0.12 200 / 0.04) 48%, transparent 74%)',
// 3. Violet ribbon — the high curtain fold.
'radial-gradient(ellipse 65% 55% at 50% 50%, oklch(0.55 0.13 300 / 0.11) 0%, oklch(0.55 0.13 300 / 0.04) 46%, transparent 70%)',
// 4. Sky/aqua highlight — subtle shimmer that counter-drifts.
'radial-gradient(ellipse 55% 35% at 50% 50%, oklch(0.72 0.1 220 / 0.09) 0%, transparent 65%)',
// 5. Calm reading core (static) — a soft even wash down the center column so
// message text always rests on a low-variance field.
'radial-gradient(ellipse 120% 60% at 50% 50%, oklch(0.2 0.04 255 / 0.5) 0%, transparent 70%)',
// 6. Vignette (static) — gently darkens the edges for luminous depth.
'radial-gradient(ellipse 130% 120% at 50% 50%, transparent 55%, oklch(0.12 0.04 260 / 0.55) 100%)',
].join(','),
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
backgroundRepeat: 'no-repeat',
animation: `${auroraFlow} 60s ease-in-out infinite`,
};
const light: CSSProperties = {
// Pale cool base — a soft pre-dawn sky the pastel aurora dreams over.
backgroundColor: 'oklch(0.97 0.012 240)',
backgroundImage: [
// 1. Mint ribbon.
'radial-gradient(ellipse 70% 45% at 50% 50%, oklch(0.85 0.08 160 / 0.5) 0%, oklch(0.85 0.08 160 / 0.16) 45%, transparent 72%)',
// 2. Sky ribbon.
'radial-gradient(ellipse 80% 40% at 50% 50%, oklch(0.83 0.07 220 / 0.48) 0%, oklch(0.83 0.07 220 / 0.14) 48%, transparent 74%)',
// 3. Lilac ribbon — the high curtain fold.
'radial-gradient(ellipse 65% 55% at 50% 50%, oklch(0.82 0.07 300 / 0.42) 0%, oklch(0.82 0.07 300 / 0.12) 46%, transparent 70%)',
// 4. Aqua highlight — subtle shimmer that counter-drifts.
'radial-gradient(ellipse 55% 35% at 50% 50%, oklch(0.88 0.06 200 / 0.34) 0%, transparent 65%)',
// 5. Calm reading core (static) — a bright even wash down the center column
// so dark message text always rests on a light, low-variance field.
'radial-gradient(ellipse 120% 60% at 50% 50%, oklch(0.99 0.005 240 / 0.6) 0%, transparent 70%)',
// 6. Vignette (static) — a whisper of cool shade at the edges for depth.
'radial-gradient(ellipse 130% 120% at 50% 50%, transparent 55%, oklch(0.9 0.02 250 / 0.45) 100%)',
].join(','),
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
backgroundRepeat: 'no-repeat',
animation: `${auroraFlow} 60s ease-in-out infinite`,
};
export const animAurora: ChatBgVariants = { dark, light };
@@ -0,0 +1,45 @@
import { keyframes } from '@vanilla-extract/css';
// Fireflies — a slow, gentle PAN of sparse glowing motes across a warm summer
// dusk. The scene in animFireflies.ts stacks these background layers:
// 1. large bright motes — tile 227x227, brightest core+halo, drifts FASTEST
// 2. medium motes — tile 293x293, dimmer, medium drift
// 3. tiny far sparks — tile 179x179, faintest, drifts SLOWEST (small step)
// 4. center vignette (100% 100%) — STATIC
// 5. warm dusk wash A (100% 100%) — STATIC
// 6. warm dusk wash B (100% 100%) — STATIC
//
// Seamless drift: the single `animation` shorthand shares ONE duration across all
// layers, so the differing apparent speeds come purely from how FAR each layer
// travels. For a jump-free loop every mote layer must translate by an EXACT
// integer multiple of its own tile period in BOTH axes, so the mote re-entering
// at the wrap is identical to the one that left. Each layer moves exactly one
// full tile:
// large : -227 / -227 (1 x 227)
// medium: -293 / -293 (1 x 293) — bigger tile, same 1-tile move => SLOWER look
// far : -179 / -179 (1 x 179) — smallest tile, damped by low opacity so it
// reads as the calm distant layer
// Because tile sizes differ, one shared 1-tile translation yields three distinct
// apparent speeds — the wandering-firefly parallax — while every layer lands back
// on an identical phase at 100% for a perfectly seamless repeat.
//
// The diagonal component (both x and y shift) makes motes feel like they wander
// through the meadow rather than slide flatly. The three static layers (vignette
// and the two dusk washes) are pinned at '0 0' every frame so the warm ambient
// glow and the calm reading center never move under the text.
//
// The '0%' frame MUST match the static backgroundPosition authored in
// animFireflies.ts, so when getChatBg STRIPS this animation for
// prefers-reduced-motion the finished scene of glowing motes shows without a jump.
export const firefliesDrift = keyframes({
'0%': {
// large, medium, far, vignette, wash A, wash B
backgroundPosition: '0 0, 83px 47px, 131px 101px, 0 0, 0 0, 0 0',
},
'100%': {
// large: 0-227 / 0-227
// medium: 83-293 / 47-293
// far: 131-179 / 101-179
backgroundPosition: '-227px -227px, -210px -246px, -48px -78px, 0 0, 0 0, 0 0',
},
});
@@ -0,0 +1,98 @@
import { ChatBgVariants } from './types';
import { firefliesDrift } from './animFireflies.css';
// Fireflies — a warm summer-dusk meadow. A few soft golden-green motes drift over
// a deep base, each mote a bright core melting into a warm halo. Sparse by design
// so the reading column stays clear; the motion is a slow, gentle background-
// position PAN (see animFireflies.css.ts) that reads as fireflies wandering.
//
// Layer stacking order (topmost first — CSS paints image #1 on top):
// 1. large bright motes — crisp warm core -> warm halo, sparse, largest step
// 2. medium motes — dimmer, smaller, more of them
// 3. tiny far sparks — faintest, smallest tile, calm distant layer
// 4. center vignette — keeps the reading center the calmest area
// 5. warm dusk wash A — ambient glow, upper
// 6. warm dusk wash B — ambient glow, lower
// Mote tiles use coprime-ish sizes (227/293/179) so their repeats never line up
// and the field reads as scattered, not gridded.
//
// getChatBg STRIPS the `animation` for prefers-reduced-motion / pause, so the
// authored backgroundPosition already composes a finished, gorgeous still scene
// of glowing motes on its own — the animation only sets them gently adrift.
export const animFireflies: ChatBgVariants = {
// Dark: warm gold-green glows on a deep forest-navy base with a soft vignette.
// Cores sit near oklch(0.85 0.13 110); halos fall to a warm amber-green. All
// opacities are kept low so message text stays crisp (WCAG-AA) over the field.
dark: {
backgroundColor: 'oklch(0.17 0.035 175)',
backgroundImage: [
// 1. large bright motes — golden-green core fading through a warm halo
'radial-gradient(circle at center, oklch(0.85 0.13 110 / 0.55) 1.4px, oklch(0.72 0.14 95 / 0.16) 3px, transparent 6px)',
// 2. medium motes — a touch cooler-green, dimmer, more numerous
'radial-gradient(circle at center, oklch(0.82 0.13 128 / 0.40) 1.1px, oklch(0.70 0.12 110 / 0.12) 2.4px, transparent 5px)',
// 3. tiny far sparks — faint warm pinpoints, the calm distant layer
'radial-gradient(circle at center, oklch(0.88 0.11 100 / 0.28) 0.8px, transparent 2.4px)',
// 4. center vignette — darkens the edges, keeps reading center calmest
'radial-gradient(ellipse 125% 95% at 50% 44%, transparent 40%, oklch(0.10 0.03 175 / 0.55) 100%)',
// 5. warm dusk wash A — a low amber-green glow drifting in from upper-right
'radial-gradient(ellipse 140% 120% at 80% 10%, oklch(0.30 0.07 120 / 0.45) 0%, transparent 58%)',
// 6. warm dusk wash B — deep teal-navy pooling into the lower-left
'radial-gradient(ellipse 135% 115% at 16% 94%, oklch(0.22 0.05 190 / 0.50) 0%, transparent 60%)',
].join(','),
backgroundSize: [
'227px 227px', // large motes
'293px 293px', // medium motes
'179px 179px', // far sparks
'100% 100%', // vignette
'100% 100%', // wash A
'100% 100%', // wash B
].join(','),
backgroundPosition: [
'0 0', // large (matches firefliesDrift 0%)
'83px 47px', // medium (offset breaks alignment)
'131px 101px', // far (offset again)
'0 0', // vignette (static)
'0 0', // wash A (static)
'0 0', // wash B (static)
].join(','),
animation: `${firefliesDrift} 44s linear infinite`,
},
// Light: a cozy warm dim-dusk. No harsh dots on white — soft amber motes with
// gentle halos float on a warm blush->honey gradient. Contrast stays low so the
// reading area is comfortable and text remains crisp (WCAG-AA).
light: {
backgroundColor: 'oklch(0.955 0.02 85)',
backgroundImage: [
// 1. large amber motes — warm honey core into a soft amber halo
'radial-gradient(circle at center, oklch(0.80 0.11 80 / 0.30) 1.4px, oklch(0.85 0.09 70 / 0.12) 3px, transparent 6px)',
// 2. medium motes — slightly greener-gold, softer
'radial-gradient(circle at center, oklch(0.78 0.10 95 / 0.22) 1.1px, oklch(0.86 0.08 85 / 0.10) 2.4px, transparent 5px)',
// 3. tiny far sparks — faint warm pinpoints for texture, never noise
'radial-gradient(circle at center, oklch(0.75 0.10 75 / 0.16) 0.8px, transparent 2.4px)',
// 4. center vignette — brightens the calm reading center a touch
'radial-gradient(ellipse 125% 95% at 50% 44%, oklch(1 0 0 / 0.40) 30%, transparent 100%)',
// 5. warm dusk wash A — honey glow from the upper-right
'radial-gradient(ellipse 140% 120% at 80% 8%, oklch(0.92 0.06 85 / 0.55) 0%, transparent 60%)',
// 6. warm dusk wash B — soft rose blush pooling lower-left
'radial-gradient(ellipse 135% 115% at 15% 95%, oklch(0.93 0.05 40 / 0.45) 0%, transparent 62%)',
].join(','),
backgroundSize: [
'227px 227px', // large motes
'293px 293px', // medium motes
'179px 179px', // far sparks
'100% 100%', // vignette
'100% 100%', // wash A
'100% 100%', // wash B
].join(','),
backgroundPosition: [
'0 0', // large (matches firefliesDrift 0%)
'83px 47px', // medium
'131px 101px', // far
'0 0', // vignette (static)
'0 0', // wash A (static)
'0 0', // wash B (static)
].join(','),
animation: `${firefliesDrift} 44s linear infinite`,
},
};
@@ -0,0 +1,39 @@
import { keyframes } from '@vanilla-extract/css';
// Grid Pulse — a slow "energy" glow that sweeps across a static tech grid.
//
// The motif is a crisp thin grid that pulses. Rather than scaling the grid
// (which shifts every line and reads as a jitter behind text), we keep the grid
// perfectly still and PAN a single soft radial "bloom" layer diagonally across
// it. As the bloom drifts, the grid lines it passes over appear to brighten and
// then settle — a calm travelling pulse, never a flash.
//
// Layer mapping (see animPulse.ts — one background-position value per layer):
// 0. grid core lines (vertical) — STATIC ('0 0')
// 1. grid core lines (horizontal) — STATIC ('0 0')
// 2. grid fine sub-lines (V) — STATIC ('0 0')
// 3. grid fine sub-lines (H) — STATIC ('0 0')
// 4. TRAVELLING BLOOM — panned here (the only moving layer)
// 5. base wash / centre glow — STATIC ('0 0')
// 6. vignette — STATIC ('0 0')
//
// Seamless loop: the bloom layer is authored to tile (its backgroundSize in
// animPulse.ts is 480px — an exact 4x multiple of the 120px grid module, and
// 8x of the 60px sub-grid). Panning it by EXACTLY one bloom-tile (480px on both
// axes) returns every pixel to an identical neighbouring tile, so the wrap at
// 100% is invisible. Diagonal travel (both axes move together) makes the sweep
// feel organic while still landing on a whole-tile offset.
//
// getChatBg adds `willChange: 'background-position'` for the animated case, so a
// background-position pulse is exactly what the compositor is hinted for. It
// STRIPS this whole `animation` for prefers-reduced-motion / pause-animations,
// at which point the static bloom position authored in animPulse.ts is what
// shows — a finished, gently glowing grid.
export const gridPulse = keyframes({
'0%': {
backgroundPosition: '0 0, 0 0, 0 0, 0 0, 0px 0px, 0 0, 0 0',
},
'100%': {
backgroundPosition: '0 0, 0 0, 0 0, 0 0, 480px 480px, 0 0, 0 0',
},
});
@@ -0,0 +1,121 @@
import { ChatBgVariants } from './types';
import { gridPulse } from './animPulse.css';
// Grid Pulse (anim-pulse) — a refined sci-fi grid with a slow energy pulse.
//
// Concept: a crisp thin tech grid over which a single soft radial glow drifts
// diagonally, so the lines it crosses seem to charge and settle — a hypnotic
// travelling pulse rather than a strobing brightness flash. Three ingredients,
// exactly per the quality bar:
// 1. a crisp thin grid — two hairline linear layers (V + H) at a 120px module
// plus a fainter 60px sub-grid, so the mesh reads as fine machined lattice;
// 2. a soft bloom layer — one wide, very-low-opacity radial that TRAVELS across
// the grid (the pulse), authored to tile so the loop is seamless;
// 3. a radial vignette — keeps the reading centre calm (dark theme darkens it,
// light theme brightens it) so text always sits on the quietest region.
//
// Animation approach & why it's subtle: only ONE layer moves — the bloom — and
// it moves by pure background-position (the property getChatBg hints via
// willChange). No line ever shifts, no global brightness flicker, so text never
// wobbles. The glow itself is barely-there (opacity well under the neon bloom),
// so the "pulse" is felt as a slow wash of light passing behind the words. 22s
// per cycle makes it meditative, not busy.
//
// Seamless loop: the bloom's backgroundSize is 480px — an exact 4x multiple of
// the 120px grid module (and 8x of the 60px sub-grid). The keyframe pans it by
// exactly one 480px tile on both axes, so it wraps onto an identical tile with
// no visible seam (see animPulse.css.ts).
//
// Reduced-motion fallback: getChatBg strips `animation`, leaving the bloom at
// its authored static position — parked slightly above-centre so the finished
// frame reads as a deliberately-lit, gently glowing grid rather than a frozen
// mid-sweep. The grid, wash and vignette are all static regardless, so the
// still image is already a complete, premium background.
//
// Dark vs light: dark is a cool cyan lattice glowing on deep blue-black with a
// dim bloom and a centre-darkening vignette. Light is a soft slate-blue lattice
// on pale cool-white with a whisper-faint bloom and a centre-BRIGHTENING
// vignette, so the reading column lifts toward white. Both keep line + glow
// opacity low for WCAG-AA legibility in either app theme.
export const animPulse: ChatBgVariants = {
// Dark: cyan grid on deep blue-black, a dim energy bloom sweeping through.
dark: {
backgroundColor: 'oklch(0.16 0.03 240)',
backgroundImage: [
// 0. grid core — vertical hairlines (cool cyan)
'linear-gradient(90deg, oklch(0.75 0.11 200 / 0.14) 0 1px, transparent 1px)',
// 1. grid core — horizontal hairlines
'linear-gradient(0deg, oklch(0.75 0.11 200 / 0.14) 0 1px, transparent 1px)',
// 2. fine sub-grid — vertical (fainter, half module)
'linear-gradient(90deg, oklch(0.75 0.11 200 / 0.05) 0 1px, transparent 1px)',
// 3. fine sub-grid — horizontal
'linear-gradient(0deg, oklch(0.75 0.11 200 / 0.05) 0 1px, transparent 1px)',
// 4. TRAVELLING BLOOM — the pulse: a wide soft cyan glow that drifts
'radial-gradient(circle at 50% 50%, oklch(0.8 0.12 200 / 0.16) 0%, oklch(0.75 0.11 205 / 0.06) 26%, transparent 55%)',
// 5. base wash — a faint steady centre glow so the grid never looks flat
'radial-gradient(ellipse 120% 100% at 50% 42%, oklch(0.42 0.07 235 / 0.28) 0%, transparent 62%)',
// 6. vignette — darken the edges, keep the reading centre calm & dark
'radial-gradient(ellipse 130% 100% at 50% 46%, transparent 34%, oklch(0.11 0.02 245 / 0.72) 100%)',
].join(','),
backgroundSize: [
'120px 120px', // grid core V
'120px 120px', // grid core H
'60px 60px', // sub-grid V (exact 1/2 divisor — re-registers)
'60px 60px', // sub-grid H
'480px 480px', // bloom (4x module — pans one whole tile, seamless)
'100% 100%', // base wash
'100% 100%', // vignette
].join(','),
backgroundPosition: [
'0 0', // grid core V
'0 0', // grid core H
'0 0', // sub-grid V
'0 0', // sub-grid H
'120px 40px', // bloom static (reduced-motion) — parked above-centre
'0 0', // base wash
'0 0', // vignette
].join(','),
animation: `${gridPulse} 22s ease-in-out infinite`,
},
// Light: soft slate-blue grid on pale cool-white, a gentle luminance breathe.
light: {
backgroundColor: 'oklch(0.975 0.006 235)',
backgroundImage: [
// 0. grid core — vertical hairlines (soft slate-blue)
'linear-gradient(90deg, oklch(0.55 0.08 245 / 0.15) 0 1px, transparent 1px)',
// 1. grid core — horizontal hairlines
'linear-gradient(0deg, oklch(0.55 0.08 245 / 0.15) 0 1px, transparent 1px)',
// 2. fine sub-grid — vertical (fainter, half module)
'linear-gradient(90deg, oklch(0.55 0.08 245 / 0.055) 0 1px, transparent 1px)',
// 3. fine sub-grid — horizontal
'linear-gradient(0deg, oklch(0.55 0.08 245 / 0.055) 0 1px, transparent 1px)',
// 4. TRAVELLING BLOOM — a whisper of slate-blue light drifting through
'radial-gradient(circle at 50% 50%, oklch(0.6 0.09 240 / 0.09) 0%, oklch(0.62 0.08 245 / 0.035) 26%, transparent 55%)',
// 5. base wash — the faintest cool tint so the grid sits on soft light
'radial-gradient(ellipse 120% 100% at 50% 42%, oklch(0.86 0.03 235 / 0.30) 0%, transparent 62%)',
// 6. vignette — brighten the calm reading centre toward white for legibility
'radial-gradient(ellipse 130% 100% at 50% 46%, oklch(1 0 0 / 0.5) 30%, transparent 100%)',
].join(','),
backgroundSize: [
'120px 120px', // grid core V
'120px 120px', // grid core H
'60px 60px', // sub-grid V
'60px 60px', // sub-grid H
'480px 480px', // bloom (4x module — seamless one-tile pan)
'100% 100%', // base wash
'100% 100%', // vignette
].join(','),
backgroundPosition: [
'0 0', // grid core V
'0 0', // grid core H
'0 0', // sub-grid V
'0 0', // sub-grid H
'120px 40px', // bloom static (reduced-motion) — parked above-centre
'0 0', // base wash
'0 0', // vignette
].join(','),
animation: `${gridPulse} 22s ease-in-out infinite`,
},
};
@@ -0,0 +1,22 @@
import { keyframes } from '@vanilla-extract/css';
// Digital Rain — a slow vertical PAN of the streak columns.
//
// The streak SVG tile is authored 200px tall (see animRain.ts, backgroundSize
// height = 200px). The falling illusion is a pure background-position translate
// downward by EXACTLY one tile height (200px) over the cycle, so the loop is
// perfectly seamless — the pixel at y re-enters where the pixel at y-200 was,
// which is identical because the tile repeats.
//
// Only the first background layer (the streak SVG) is panned; every subsequent
// comma-separated layer is kept at its authored position ('0 0') so the base
// gradients / vignette stay put while the rain falls over them. Listing a value
// per layer is required — a single value would pan ALL layers.
//
// getChatBg adds `willChange: 'background-position'` for the animated case, and
// STRIPS this whole `animation` for reduced-motion, at which point the static
// backgroundPosition authored in animRain.ts is what shows.
export const rainFall = keyframes({
'0%': { backgroundPosition: '0 0, 0 0, 0 0, 0 0' },
'100%': { backgroundPosition: '0 200px, 0 0, 0 0, 0 0' },
});
@@ -0,0 +1,123 @@
import { ChatBgVariants } from './types';
import { rainFall } from './animRain.css';
// anim-rain — "Digital Rain" — a premium take on the Matrix code-rain motif.
//
// Concept: sparse vertical columns of falling glyph-streaks. Each streak is a
// soft vertical gradient that fades from a brighter LEADING glyph (the drop's
// head) up into a dim trailing tail, punctuated by a scatter of faint monospace
// glyph marks so it reads as CODE rather than plain stripes. It floats over a
// near-black base carrying a subtle green phosphor cast and a gentle vignette.
// Columns are deliberately sparse (only a handful across the 260px-wide tile)
// so the reading area breathes and text always wins the contrast fight.
//
// SEAMLESS TILING + PAN — the streak SVG tile is 260×200. Its content is
// authored to wrap top↔bottom: each streak's gradient and glyphs are placed so
// the tile is vertically continuous, and the animation (see animRain.css.ts)
// pans this first layer down by EXACTLY one tile height (200px) per cycle, so
// the "fall" loops with no seam. The base / vignette layers are 100% 100% and
// stay fixed (the keyframe holds them at '0 0').
//
// ANIMATION-STRIP SAFETY — getChatBg removes `animation` for reduced-motion /
// pause-animations users, so the non-animation properties below already read as
// a finished, gorgeous STATIC rain: a frozen frame of streaks over the base.
//
// CSP / Tauri-safe: inline SVG via encodeURIComponent (NOT base64). oklch used
// throughout; alphas kept low so both themes stay WCAG-AA-friendly for text.
// One vertical streak-column, colour-parameterised. Placed at x within a
// 260-wide tile. `head` is the bright leading-glyph colour, `tail` the dim
// trailing colour, `glyph` the colour of the riding monospace glyph ticks.
const streak = (
x: number,
headY: number, // y of the leading glyph (drop head)
len: number, // trailing tail length upward
head: string,
tail: string,
glyph: string,
): string => {
const topY = headY - len;
const id = `g${x}_${headY}`; // unique even when two columns share an x
// Vertical fade: transparent at the tail top → tail colour → bright head.
const grad = `
<linearGradient id='${id}' x1='0' y1='${topY}' x2='0' y2='${headY}' gradientUnits='userSpaceOnUse'>
<stop offset='0' stop-color='${tail}' stop-opacity='0'/>
<stop offset='0.55' stop-color='${tail}'/>
<stop offset='1' stop-color='${head}'/>
</linearGradient>`;
// The streak body is a soft, slightly-blurred vertical bar.
const bar = `<rect x='${x - 3}' y='${topY}' width='6' height='${len}' rx='3' fill='url(#${id})'/>`;
// A few monospace glyph ticks riding the column (short horizontal dashes).
const ticks = [0.22, 0.45, 0.68, 0.86]
.map((f, i) => {
const gy = Math.round(topY + len * f);
const gw = i % 2 === 0 ? 5 : 3;
const op = i === 3 ? '0.9' : '0.5';
return `<rect x='${x - gw / 2}' y='${gy}' width='${gw}' height='1.4' rx='0.7' fill='${glyph}' fill-opacity='${op}'/>`;
})
.join('');
// The leading glyph: a brighter small square cap at the head.
const cap = `<rect x='${x - 2.5}' y='${headY - 3}' width='5' height='5' rx='1' fill='${head}'/>`;
return grad + bar + ticks + cap;
};
// Full 260×200 tile. Columns are wrapped vertically: a column whose head sits
// low in the tile has its tail running off the top, and a companion column
// re-enters that space, so panning by one tile height reads as continuous fall.
const tile = (head: string, tail: string, glyph: string): string => {
const cols = [
streak(24, 150, 140, head, tail, glyph),
streak(78, 60, 120, head, tail, glyph),
streak(122, 196, 160, head, tail, glyph), // head near bottom → tail wraps up
streak(122, 40, 160, head, tail, glyph), // partner near top completes the wrap
streak(178, 110, 100, head, tail, glyph),
streak(232, 176, 130, head, tail, glyph),
].join('');
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='260' height='200' viewBox='0 0 260 200'><defs></defs>${cols}</svg>`;
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
};
export const animRain: ChatBgVariants = {
// Dark: phosphor-green streaks on deep near-black with a faint green cast.
dark: {
backgroundColor: 'oklch(0.16 0.02 150)',
backgroundImage: [
// 1) the falling streak columns (this is the panned layer)
tile(
'oklch(0.75 0.14 150 / 0.5)', // head — bright phosphor glyph
'oklch(0.68 0.12 150 / 0.28)', // tail — dim phosphor
'oklch(0.82 0.1 150 / 0.5)', // glyph ticks — brightest
),
// 2) soft top-down phosphor haze so the rain has atmosphere
'linear-gradient(180deg, oklch(0.24 0.04 150 / 0.55) 0%, transparent 40%)',
// 3) subtle green cast pooling toward the bottom
'radial-gradient(120% 90% at 50% 100%, oklch(0.28 0.05 150 / 0.45) 0%, transparent 60%)',
// 4) vignette — quiet the corners so the reading column stays clean
'radial-gradient(140% 140% at 50% 45%, transparent 60%, oklch(0.1 0.02 150 / 0.6) 100%)',
].join(','),
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
animation: `${rainFall} 12s linear infinite`,
},
// Light: soft teal-grey streaks on a pale cool base — elegant, never neon.
light: {
backgroundColor: 'oklch(0.97 0.008 165)',
backgroundImage: [
tile(
'oklch(0.55 0.07 165 / 0.4)', // head — soft teal-grey drop
'oklch(0.62 0.05 165 / 0.22)', // tail — faint teal-grey
'oklch(0.5 0.06 165 / 0.42)', // glyph ticks
),
// gentle cool wash from the top
'linear-gradient(180deg, oklch(0.94 0.015 175 / 0.6) 0%, transparent 42%)',
// faint teal pooling at the bottom edge
'radial-gradient(120% 90% at 50% 100%, oklch(0.9 0.02 170 / 0.5) 0%, transparent 60%)',
// soft vignette in cool grey
'radial-gradient(140% 140% at 50% 45%, transparent 62%, oklch(0.88 0.02 165 / 0.5) 100%)',
].join(','),
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
animation: `${rainFall} 12s linear infinite`,
},
};
@@ -0,0 +1,39 @@
import { keyframes } from '@vanilla-extract/css';
// Star Drift — a slow, serene PAN of a deep-space starfield with real parallax.
//
// The starfield in animStars.ts stacks six background layers:
// 1. near stars — tile 137x137, brighter, drifts FASTEST
// 2. mid stars — tile 191x191, medium
// 3. far dust — tile 233x233, dimmest, drifts SLOWEST
// 4. center vignette (100% 100%) — STATIC
// 5. nebula wash A (100% 100%) — STATIC
// 6. nebula wash B (100% 100%) — STATIC
//
// Seamless parallax: the single `animation` shorthand shares ONE duration across
// all layers, so speed differences are produced purely by how FAR each layer
// travels in the keyframe. For a perfectly seamless loop each star layer must
// translate by an EXACT integer multiple of its own tile period, so the pixel
// re-entering at the wrap is identical to the one that left. We move:
// near : -274px = 2 x 137 (two tiles -> fastest apparent drift)
// mid : -191px = 1 x 191 (one tile -> medium)
// far : -233px = 1 x 233 (one tile, but larger tile => slowest apparent)
// so near/mid/far read as three depths sliding past each other, yet every layer
// lands back on an identical phase at 100% for a jump-free repeat.
//
// A diagonal component (both x and y shift) makes the drift feel like gentle
// motion through space rather than a flat slide. The static layers are pinned at
// '0 0' every frame so the vignette and nebula never move under the text.
//
// The start frame ('0%') MUST match the static backgroundPosition authored in
// animStars.ts, so that when getChatBg STRIPS this animation for
// prefers-reduced-motion the finished starfield shows without a jump.
export const starDrift = keyframes({
'0%': {
backgroundPosition: '0 0, 61px 43px, 113px 97px, 0 0, 0 0, 0 0',
},
'100%': {
// near: -274/-274 (2 tiles), mid: 61-191/43-191, far: 113-233/97-233
backgroundPosition: '-274px -274px, -130px -148px, -120px -136px, 0 0, 0 0, 0 0',
},
});
@@ -0,0 +1,117 @@
import { ChatBgVariants } from './types';
import { starDrift } from './animStars.css';
// animStars ("Star Drift") — a serene deep-space field slowly drifting, with
// genuine parallax between a near (brighter, faster) and a far (dim, slower)
// star layer, floated on a faint nebula wash and calmed by a center vignette.
//
// Concept: three tiling star layers at coprime-ish tile sizes (137/191/233 dark,
// 149/199/251 light) so their combined repeat is astronomically large and no
// seam is ever perceivable. The near layer is crisp and sparse; the far "dust"
// layer is dim and dense — the layer that gives depth. Beneath the stars sit a
// deep-blue -> violet nebula (two soft ellipses) and a center vignette that keeps
// the reading column the calmest, lowest-contrast area of the whole canvas.
//
// Layer stacking order (CSS paints image #1 on TOP):
// 1. near stars — brighter, largest visible drift (tile 137 / 149)
// 2. mid stars — softer, medium (tile 191 / 199)
// 3. far dust — dimmest, slowest, most-repeated (tile 233 / 251)
// 4. center vignette (100% 100%, static)
// 5. nebula wash A (100% 100%, static)
// 6. nebula wash B (100% 100%, static)
//
// Animation: `starDrift` (see animStars.css.ts) is a SLOW background-position PAN
// that translates each star layer by an exact integer number of its own tiles,
// so the loop is seamless AND the three layers drift at different apparent
// speeds (parallax). getChatBg adds willChange/contain for the animated case and
// STRIPS the `animation` for prefers-reduced-motion — at which point the static
// backgroundPosition below (identical to the keyframe's 0% frame) shows as a
// fully finished starfield on its own.
//
// Density is kept modest toward the center by the vignette + conservative dot
// sizes, and every star opacity stays low so text over the field always clears
// WCAG-AA in both themes.
export const animStars: ChatBgVariants = {
// Dark: cool white + faint blue stars on a near-black cosmos, lifted onto a
// deep-blue -> violet nebula with a soft vignette darkening the calm center.
dark: {
backgroundColor: 'oklch(0.15 0.03 275)',
backgroundImage: [
// 1. near stars — crisp cool-white, sparse, the "fast" parallax layer
'radial-gradient(circle at center, oklch(0.98 0.012 255 / 0.85) 0.6px, transparent 1.5px)',
// 2. mid stars — softer, a touch blue, more of them
'radial-gradient(circle at center, oklch(0.90 0.03 260 / 0.52) 0.6px, transparent 1.3px)',
// 3. far dust — faint blue haze, the slow depth layer (most repeats)
'radial-gradient(circle at center, oklch(0.78 0.06 255 / 0.28) 0.5px, transparent 1.1px)',
// 4. center vignette — keeps the reading column calmest / lowest-contrast
'radial-gradient(ellipse 120% 90% at 50% 42%, transparent 40%, oklch(0.09 0.03 270 / 0.58) 100%)',
// 5. nebula wash A — deep violet high-right
'radial-gradient(ellipse 140% 120% at 78% 10%, oklch(0.26 0.09 285 / 0.55) 0%, transparent 55%)',
// 6. nebula wash B — deep blue low-left
'radial-gradient(ellipse 130% 110% at 16% 94%, oklch(0.21 0.07 250 / 0.50) 0%, transparent 58%)',
].join(','),
backgroundSize: [
'137px 137px', // near stars
'191px 191px', // mid stars
'233px 233px', // far dust
'100% 100%', // vignette
'100% 100%', // nebula A
'100% 100%', // nebula B
].join(','),
// Must equal starDrift's 0% frame so reduced-motion shows this exact field.
backgroundPosition: [
'0 0', // near
'61px 43px', // mid (offset breaks tile alignment)
'113px 97px', // far (offset again)
'0 0', // vignette
'0 0', // nebula A
'0 0', // nebula B
].join(','),
animation: `${starDrift} 90s linear infinite`,
},
// Light: an airy pre-dawn sky. No literal white-on-white stars — instead very
// soft pale sparkles plus the merest cool speckles, floated on a gentle cool
// gradient. Reads as elegant atmosphere, never as noise over text.
light: {
backgroundColor: 'oklch(0.965 0.008 255)',
backgroundImage: [
// 1. near sparkles — a hair brighter/warmer than the sky
'radial-gradient(circle at center, oklch(0.995 0.015 90 / 0.50) 0.6px, transparent 1.5px)',
// 2. mid cool speckles — faintest hint of darkness for texture/contrast
'radial-gradient(circle at center, oklch(0.60 0.05 260 / 0.15) 0.5px, transparent 1.2px)',
// 3. far dust — very soft cool haze, the slow depth layer
'radial-gradient(circle at center, oklch(0.70 0.04 255 / 0.11) 0.5px, transparent 1.1px)',
// 4. center vignette — subtly brightens the calm reading center
'radial-gradient(ellipse 120% 90% at 50% 44%, oklch(1 0 0 / 0.45) 30%, transparent 100%)',
// 5. pre-dawn wash A — cool blue high-right
'radial-gradient(ellipse 150% 120% at 80% 6%, oklch(0.90 0.05 255 / 0.60) 0%, transparent 60%)',
// 6. pre-dawn wash B — warm blush low-left
'radial-gradient(ellipse 140% 120% at 14% 96%, oklch(0.93 0.04 40 / 0.42) 0%, transparent 62%)',
].join(','),
// Same tile sizes as dark (137/191/233). The shared starDrift keyframe pans
// each layer by an exact integer multiple of ITS tile (near 2x137, mid 1x191,
// far 1x233); reusing these tiles here guarantees the loop wraps seamlessly in
// light mode too, since one keyframe drives both themes. Coprime-ish sizes keep
// the combined repeat astronomically large so no seam is ever perceivable.
backgroundSize: [
'137px 137px', // near sparkles
'191px 191px', // mid speckles
'233px 233px', // far dust
'100% 100%', // vignette
'100% 100%', // wash A
'100% 100%', // wash B
].join(','),
// Positions mirror the keyframe 0% frame (== reduced-motion static field).
backgroundPosition: [
'0 0', // near
'61px 43px', // mid
'113px 97px', // far
'0 0', // vignette
'0 0', // wash A
'0 0', // wash B
].join(','),
animation: `${starDrift} 100s linear infinite`,
},
};
@@ -0,0 +1,85 @@
import { ChatBgVariants } from './types';
// blueprint — an engineering / architectural drafting sheet.
//
// Layers (painted top-to-bottom):
// 1. SVG draftsman tick-marks + a centred crosshair accent (96px tile — lands
// exactly on the major grid; corner quarter-arms tile into a full "+" on
// every major intersection).
// 2. Major grid lines (heavier) — 96px.
// 3. Minor grid lines (fine, fainter) — 16px (96 = 6 × 16, so it nests
// seamlessly inside the major grid with no beat/moiré).
// 4. A soft radial vignette + a gentle sheet-glow so the surface reads like a
// real drafting sheet with subtle dimension rather than a flat tile.
//
// Everything is kept at low alpha (~0.030.16) so the motif is felt, not read:
// crisp message text sits comfortably above it in both themes (WCAG-AA safe).
const DARK_TICKS =
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2296%22%20height%3D%2296%22%3E%3Cg%20stroke%3D%22oklch%280.72%200.11%20230%20%2F%200.32%29%22%20stroke-width%3D%221%22%3E%3Cpath%20d%3D%22M0%200%20H7%20M0%200%20V7%22%2F%3E%3Cpath%20d%3D%22M96%200%20H89%20M96%200%20V7%22%2F%3E%3Cpath%20d%3D%22M0%2096%20H7%20M0%2096%20V89%22%2F%3E%3Cpath%20d%3D%22M96%2096%20H89%20M96%2096%20V89%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.72%200.11%20230%20%2F%200.18%29%22%20stroke-width%3D%221%22%3E%3Cpath%20d%3D%22M48%2044%20V52%20M44%2048%20H52%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
const LIGHT_TICKS =
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2296%22%20height%3D%2296%22%3E%3Cg%20stroke%3D%22oklch%280.48%200.13%20250%20%2F%200.38%29%22%20stroke-width%3D%221%22%3E%3Cpath%20d%3D%22M0%200%20H7%20M0%200%20V7%22%2F%3E%3Cpath%20d%3D%22M96%200%20H89%20M96%200%20V7%22%2F%3E%3Cpath%20d%3D%22M0%2096%20H7%20M0%2096%20V89%22%2F%3E%3Cpath%20d%3D%22M96%2096%20H89%20M96%2096%20V89%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.48%200.13%20250%20%2F%200.22%29%22%20stroke-width%3D%221%22%3E%3Cpath%20d%3D%22M48%2044%20V52%20M44%2048%20H52%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
export const blueprint: ChatBgVariants = {
// Cyan-blue lines on a deep navy sheet.
dark: {
backgroundColor: 'oklch(0.22 0.05 250)',
backgroundImage: [
// 1. draftsman ticks + centre crosshair
DARK_TICKS,
// 4a. sheet-glow: a faint cooler highlight drifting off the top-left,
// giving the flat navy some dimension.
'radial-gradient(120% 120% at 18% 8%, oklch(0.30 0.06 245 / 0.55) 0%, transparent 55%)',
// 4b. vignette: gently darkens the corners like a drafting sheet edge.
'radial-gradient(140% 140% at 50% 42%, transparent 58%, oklch(0.14 0.04 255 / 0.5) 100%)',
// 2. major grid (heavier)
'linear-gradient(oklch(0.72 0.12 230 / 0.13) 1px, transparent 1px)',
'linear-gradient(90deg, oklch(0.72 0.12 230 / 0.13) 1px, transparent 1px)',
// 3. minor grid (fine, fainter)
'linear-gradient(oklch(0.72 0.12 230 / 0.05) 1px, transparent 1px)',
'linear-gradient(90deg, oklch(0.72 0.12 230 / 0.05) 1px, transparent 1px)',
].join(','),
backgroundSize: [
'96px 96px', // ticks
'100% 100%', // sheet-glow
'100% 100%', // vignette
'96px 96px', // major V
'96px 96px', // major H
'16px 16px', // minor V
'16px 16px', // minor H
].join(','),
// All layers share the default top-left (0 0) origin so the tick tile, the
// 96px major grid and the 16px minor grid stay phase-locked (96 = 6 × 16) —
// no drift, no visible seams. (A per-layer `center` would let the differently
// sized tiles center independently and fall out of alignment.)
},
// Blue lines on a cool paper-white sheet.
light: {
backgroundColor: 'oklch(0.97 0.01 240)',
backgroundImage: [
LIGHT_TICKS,
// sheet-glow: a hint of brighter paper toward the top-left.
'radial-gradient(120% 120% at 18% 8%, oklch(0.99 0.008 240 / 0.7) 0%, transparent 55%)',
// vignette: soft cool shading into the corners.
'radial-gradient(140% 140% at 50% 42%, transparent 60%, oklch(0.90 0.02 245 / 0.55) 100%)',
// major grid (heavier)
'linear-gradient(oklch(0.48 0.13 250 / 0.15) 1px, transparent 1px)',
'linear-gradient(90deg, oklch(0.48 0.13 250 / 0.15) 1px, transparent 1px)',
// minor grid (fine, fainter)
'linear-gradient(oklch(0.48 0.13 250 / 0.06) 1px, transparent 1px)',
'linear-gradient(90deg, oklch(0.48 0.13 250 / 0.06) 1px, transparent 1px)',
].join(','),
backgroundSize: [
'96px 96px',
'100% 100%',
'100% 100%',
'96px 96px',
'96px 96px',
'16px 16px',
'16px 16px',
].join(','),
// Shared top-left origin keeps the tick tile and both grids phase-locked.
},
};
@@ -0,0 +1,76 @@
import { CSSProperties } from 'react';
import { ChatBgVariants } from './types';
// chevron — refined woven-upholstery zigzag.
//
// The motif is a continuous, crisp chevron built to read as *textured fabric*
// rather than flat stripes. The zigzag threads themselves are drawn with a
// tiny inline-SVG tile (guaranteed geometrically seamless — the "V" path exits
// each tile edge exactly where the next tile's path enters, both horizontally
// and vertically). Over that, layered CSS gradients add the premium feel:
// • a soft light→shade sweep across the weave gives each band an embossed,
// woven cross-section (catches light on one diagonal face, shade on the
// other);
// • a faint two-tone wash alternates the tint of successive chevron rows for
// an interlocked-yarn look;
// • a gentle centre lift + corner vignette settle the field so text always
// sits over the calmer middle.
//
// SEAMLESS TILING
// The SVG is a WxH tile whose path is one full zigzag wave: it starts at the
// left edge, dips to the vertex, rises to the right edge at the SAME y it
// started — so horizontally each tile's end meets the next tile's start with no
// step. Two stacked strokes (offset by H) fill the vertical repeat, and the
// tile height equals the row pitch, so vertical stacking is seamless too. The
// gradient overlays are non-repeating (100% 100%) or share the SVG's tile
// width, so none of them introduce a seam.
//
// Everything sits at low alpha (~0.030.11) so the pattern is felt, not read:
// crisp message text stays comfortably WCAG-AA in both themes.
// One zigzag wave, 40px wide × 20px tall. Path enters at (0,4), dips to the
// vertex at (20,16), climbs back to (40,4) — identical entry/exit y => seamless
// horizontal repeat. A second copy shifted +10 in y keeps a soft double thread.
const svg = (stroke: string, faint: string) =>
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20' +
'width%3D%2240%22%20height%3D%2220%22%3E' +
`%3Cpath%20d%3D%22M0%204%20L20%2016%20L40%204%22%20fill%3D%22none%22%20stroke%3D%22${stroke}%22%20stroke-width%3D%223%22%2F%3E` +
`%3Cpath%20d%3D%22M0%2014%20L20%2026%20L40%2014%20M0%20-6%20L20%206%20L40%20-6%22%20fill%3D%22none%22%20stroke%3D%22${faint}%22%20stroke-width%3D%222%22%2F%3E` +
'%3C%2Fsvg%3E")';
const dark: CSSProperties = {
backgroundColor: 'oklch(0.20 0.022 260)',
backgroundImage: [
// 1. The zigzag threads — muted indigo/slate, main + fainter under-thread.
svg('oklch(0.55 0.05 265 %2F 0.16)', 'oklch(0.50 0.045 262 %2F 0.07)'),
// 2. Woven emboss — a soft diagonal light→shade sweep across the weave so
// the bands catch light on one face and fall to shade on the other.
'linear-gradient(135deg, oklch(0.62 0.05 265 / 0.05) 0%, transparent 45%, transparent 55%, oklch(0.14 0.02 260 / 0.06) 100%)',
// 3. Two-tone weft — a whisper shade on alternate chevron rows.
'repeating-linear-gradient(0deg, oklch(0.50 0.04 258 / 0.035) 0px, oklch(0.50 0.04 258 / 0.035) 20px, transparent 20px, transparent 40px)',
// 4. Tonal wash — cool centre lift for gentle depth.
'radial-gradient(ellipse 90% 75% at 50% 42%, oklch(0.26 0.03 262 / 0.40) 0%, transparent 60%)',
// 5. Vignette — feather corners into deeper charcoal-blue.
'radial-gradient(ellipse 120% 130% at 50% 45%, transparent 60%, oklch(0.15 0.02 260 / 0.55) 100%)',
].join(','),
backgroundSize: '40px 20px, 100% 100%, 40px 40px, 100% 100%, 100% 100%',
};
const light: CSSProperties = {
backgroundColor: 'oklch(0.965 0.006 85)',
backgroundImage: [
// 1. The zigzag threads — soft dusty-blue, main + fainter under-thread.
svg('oklch(0.55 0.05 255 %2F 0.14)', 'oklch(0.52 0.045 255 %2F 0.06)'),
// 2. Woven emboss — diagonal light→shade sweep for a knit-fabric surface.
'linear-gradient(135deg, oklch(0.99 0.008 85 / 0.06) 0%, transparent 45%, transparent 55%, oklch(0.55 0.05 255 / 0.05) 100%)',
// 3. Two-tone weft — faint alternating-row shade.
'repeating-linear-gradient(0deg, oklch(0.52 0.04 255 / 0.03) 0px, oklch(0.52 0.04 255 / 0.03) 20px, transparent 20px, transparent 40px)',
// 4. Tonal wash — warm paper highlight through the reading centre.
'radial-gradient(ellipse 90% 75% at 50% 42%, oklch(0.99 0.008 85 / 0.55) 0%, transparent 60%)',
// 5. Vignette — settle corners into a slightly deeper dusty tone.
'radial-gradient(ellipse 120% 130% at 50% 45%, transparent 60%, oklch(0.91 0.012 250 / 0.40) 100%)',
].join(','),
backgroundSize: '40px 20px, 100% 100%, 40px 40px, 100% 100%, 100% 100%',
};
export const chevron: ChatBgVariants = { dark, light };
@@ -0,0 +1,116 @@
import { ChatBgVariants } from './types';
// circuit — an elegant printed-circuit board.
//
// Concept: thin right-angle copper traces route between small pads / vias and
// the occasional solder-junction dot, over a deep board base. It reads as an
// authentic PCB rather than a plain grid: the routing turns corners, dead-ends
// at through-hole pads, and picks up faint via-glows — but stays sparse, with
// generous negative space so message text always wins the contrast fight.
//
// The trace network is a single inline SVG data-URI (encodeURIComponent, NOT
// base64 — CSP / Tauri-safe) so the geometry can be real right-angle routing
// instead of gradient fakery. It is layered over a subtle board-base gradient.
//
// SEAMLESS TILING — the 120×120 tile is authored so every trace that leaves an
// edge re-enters at the identical coordinate on the OPPOSITE edge, so the copper
// runs continuously across tile boundaries with no visible seam:
// • horizontal runs cross the left/right edges at y = 30 and y = 90
// • vertical runs cross the top/bottom edges at x = 40 and x = 88
// backgroundSize is set to the tile size (120px) so those crossings line up
// exactly on repeat.
//
// Two hand-tuned SVGs (dark / light) differ only in stroke/fill colour + alpha.
// Alphas stay low (≈0.050.5 on the accents, traces ~0.10.16) so the pattern is
// felt, not read — crisp text sits comfortably above it in both themes.
// Shared geometry, colour-parameterised so the two themes stay pixel-identical
// in layout and only diverge in palette.
const tile = (
trace: string, // trace stroke colour
traceW: string, // trace stroke-width
pad: string, // pad ring colour
padFill: string, // pad centre / board-coloured hole
via: string, // via glow colour
junction: string, // filled junction-dot colour
): string => {
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'>
<g fill='none' stroke='${trace}' stroke-width='${traceW}' stroke-linecap='round' stroke-linejoin='round'>
<path d='M0 30 H26 V58 H60'/>
<path d='M60 58 V90 H120'/>
<path d='M0 90 H40 V120'/>
<path d='M40 0 V22 H88'/>
<path d='M88 0 V44 H104'/>
<path d='M104 44 V90 H120'/>
<path d='M88 120 V90'/>
<path d='M60 30 H120'/>
<path d='M60 30 V58'/>
<path d='M26 58 V90'/>
</g>
<g fill='none' stroke='${pad}' stroke-width='${traceW}'>
<circle cx='26' cy='58' r='3.4'/>
<circle cx='40' cy='90' r='3.4'/>
<circle cx='88' cy='22' r='3.4'/>
<circle cx='104' cy='44' r='3.4'/>
</g>
<g fill='${padFill}'>
<circle cx='26' cy='58' r='1.3'/>
<circle cx='40' cy='90' r='1.3'/>
<circle cx='88' cy='22' r='1.3'/>
<circle cx='104' cy='44' r='1.3'/>
</g>
<g fill='${junction}'>
<circle cx='60' cy='58' r='2'/>
<circle cx='60' cy='30' r='2'/>
<circle cx='104' cy='90' r='2'/>
</g>
<g fill='${via}'>
<circle cx='26' cy='58' r='7'/>
<circle cx='88' cy='22' r='7'/>
<circle cx='104' cy='44' r='7'/>
</g>
</svg>`;
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
};
export const circuit: ChatBgVariants = {
// Faint teal/green copper with dim cyan via-glows on a near-black board.
dark: {
backgroundColor: 'oklch(0.17 0.02 165)',
backgroundImage: [
tile(
'oklch(0.7 0.1 165 / 0.16)', // traces — faint teal-green copper
'1',
'oklch(0.72 0.11 175 / 0.32)', // pad rings — slightly brighter
'oklch(0.17 0.02 165)', // pad holes — board colour (drilled look)
'oklch(0.78 0.13 200 / 0.14)', // via glow — dim cyan halo
'oklch(0.74 0.12 170 / 0.4)', // junction dots — solid copper
),
// board-base: a gentle diagonal sheen so the flat near-black gains depth.
'radial-gradient(130% 130% at 20% 12%, oklch(0.22 0.03 170 / 0.6) 0%, transparent 58%)',
// vignette: barely darkens the corners like a laminated board edge.
'radial-gradient(140% 140% at 50% 45%, transparent 62%, oklch(0.12 0.02 165 / 0.55) 100%)',
].join(','),
backgroundSize: ['120px 120px', '100% 100%', '100% 100%'].join(','),
},
// Soft green-grey traces on a pale board.
light: {
backgroundColor: 'oklch(0.96 0.012 160)',
backgroundImage: [
tile(
'oklch(0.55 0.07 165 / 0.24)', // traces — soft green-grey copper
'1',
'oklch(0.5 0.08 170 / 0.4)', // pad rings
'oklch(0.96 0.012 160)', // pad holes — board colour
'oklch(0.6 0.09 200 / 0.1)', // via glow — faint cool halo
'oklch(0.5 0.08 165 / 0.42)', // junction dots
),
// board-base: a hint of brighter laminate toward the top-left.
'radial-gradient(130% 130% at 20% 12%, oklch(0.99 0.008 160 / 0.7) 0%, transparent 58%)',
// vignette: soft green-grey shading into the corners.
'radial-gradient(140% 140% at 50% 45%, transparent 62%, oklch(0.9 0.02 160 / 0.5) 100%)',
].join(','),
backgroundSize: ['120px 120px', '100% 100%', '100% 100%'].join(','),
},
};
@@ -0,0 +1,56 @@
import { CSSProperties } from 'react';
import { ChatBgVariants } from './types';
// crosshatch — fine pen-and-ink engraving, like a banknote guilloché.
// Three hatch directions (right-leaning, left-leaning, near-horizontal cross)
// are layered at low opacity so the eye reads a woven ink texture rather than
// discrete stripes. Each direction uses a slightly different pitch so the
// combined pattern never lines up into a coarse moire, and a barely-there
// diagonal tonal gradient lends etched depth.
//
// Seamless tiling: each hatch is a `repeating-linear-gradient`, which repeats
// infinitely by definition, so the layers are left at `backgroundSize: auto`
// and tile with no visible seam at any element size (constraining a diagonal
// repeat to a small square would clip it mid-period and create a seam). The
// tonal wash is a single non-repeating gradient stretched to `cover`.
//
// Opacities are kept in the 0.020.05 range so the texture is felt, not read —
// crisp message text sits comfortably above it in both themes (WCAG-AA safe).
const dark: CSSProperties = {
// near-black base with a whisper of cool blue so silver ink reads as engraving
backgroundColor: 'oklch(0.16 0.01 255)',
backgroundImage: [
// faint tonal gradient — top-left slightly lifted for etched depth
'linear-gradient(135deg, oklch(0.20 0.012 255 / 0.5) 0%, oklch(0.15 0.01 255 / 0) 55%, oklch(0.14 0.008 260 / 0.45) 100%)',
// primary hatch, right-leaning fine lines (cool silver ink), ~9px pitch
'repeating-linear-gradient(45deg, oklch(0.75 0.02 250 / 0.05) 0, oklch(0.75 0.02 250 / 0.05) 0.75px, transparent 0.75px, transparent 9px)',
// secondary hatch, left-leaning — the cross of the crosshatch
'repeating-linear-gradient(135deg, oklch(0.75 0.02 250 / 0.045) 0, oklch(0.75 0.02 250 / 0.045) 0.75px, transparent 0.75px, transparent 9px)',
// tertiary hatch, right-leaning at a denser pitch for engraved richness
'repeating-linear-gradient(45deg, oklch(0.78 0.018 250 / 0.02) 0, oklch(0.78 0.018 250 / 0.02) 0.5px, transparent 0.5px, transparent 4.5px)',
// quaternary near-horizontal fill line, very faint, weaves the mesh together
'repeating-linear-gradient(20deg, oklch(0.72 0.015 255 / 0.018) 0, oklch(0.72 0.015 255 / 0.018) 0.5px, transparent 0.5px, transparent 13px)',
].join(','),
backgroundSize: 'cover, auto, auto, auto, auto',
};
const light: CSSProperties = {
// warm paper base — graphite ink on cream stock
backgroundColor: 'oklch(0.975 0.006 85)',
backgroundImage: [
// faint tonal wash — soft warm depth for aged-paper feel
'linear-gradient(135deg, oklch(0.94 0.008 85 / 0.55) 0%, oklch(0.98 0.005 85 / 0) 55%, oklch(0.93 0.01 80 / 0.5) 100%)',
// primary hatch, right-leaning graphite lines, ~9px pitch
'repeating-linear-gradient(45deg, oklch(0.42 0.01 265 / 0.055) 0, oklch(0.42 0.01 265 / 0.055) 0.75px, transparent 0.75px, transparent 9px)',
// secondary hatch, left-leaning — the cross
'repeating-linear-gradient(135deg, oklch(0.42 0.01 265 / 0.05) 0, oklch(0.42 0.01 265 / 0.05) 0.75px, transparent 0.75px, transparent 9px)',
// tertiary denser right-leaning hatch for engraved fineness
'repeating-linear-gradient(45deg, oklch(0.40 0.012 265 / 0.025) 0, oklch(0.40 0.012 265 / 0.025) 0.5px, transparent 0.5px, transparent 4.5px)',
// quaternary near-horizontal weave line, barely-there
'repeating-linear-gradient(20deg, oklch(0.45 0.01 260 / 0.022) 0, oklch(0.45 0.01 260 / 0.022) 0.5px, transparent 0.5px, transparent 13px)',
].join(','),
backgroundSize: 'cover, auto, auto, auto, auto',
};
export const crosshatch: ChatBgVariants = { dark, light };
@@ -0,0 +1,52 @@
import { ChatBgVariants } from './types';
// Herringbone — a refined, tactile broken-zigzag weave (the classic parquet / tweed
// motif) rather than a flat hairline grid. Each plank is drawn twice in a compact SVG
// data-URI tile: a lit "thread" and a 0.6px-offset shadow companion, so every plank
// reads as a beveled, three-dimensional strand of fabric instead of a line.
//
// SEAMLESS TILING: planks live on a 12px lattice and their orientation follows the true
// herringbone rule orient(cx, cy) = '/' when (cx - cy) mod 4 in {0, 1}, else '\\'.
// That rule is exactly periodic every 4 cells in BOTH axes, so the 48x48px tile repeats
// with no seam at any scroll offset; segment endpoints all land on lattice corners, so
// the broken V's interlock perfectly across tile edges.
//
// DEPTH: beneath the weave sit two very low-contrast oklch layers — a diagonal two-tone
// wash that gives the fabric a faint lit/shadowed side, plus a soft vignette that lets
// the centre (where text lives) stay calmest. Everything is kept in the "felt, not read"
// opacity band so WCAG-AA body text sits comfortably on top in both themes.
const WEAVE_DARK =
'data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2248%22%20height%3D%2248%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cpath%20d%3D%22M-11.4%200.6L0.6%20-11.4M0.6%200.6L12.6%20-11.4M12.6%20-11.4L24.6%200.6M24.6%20-11.4L36.6%200.6M36.6%200.6L48.6%20-11.4M48.6%200.6L60.6%20-11.4M-11.4%200.6L0.6%2012.6M0.6%2012.6L12.6%200.6M12.6%2012.6L24.6%200.6M24.6%200.6L36.6%2012.6M36.6%200.6L48.6%2012.6M48.6%2012.6L60.6%200.6M-11.4%2012.6L0.6%2024.6M0.6%2012.6L12.6%2024.6M12.6%2024.6L24.6%2012.6M24.6%2024.6L36.6%2012.6M36.6%2012.6L48.6%2024.6M48.6%2012.6L60.6%2024.6M-11.4%2036.6L0.6%2024.6M0.6%2024.6L12.6%2036.6M12.6%2024.6L24.6%2036.6M24.6%2036.6L36.6%2024.6M36.6%2036.6L48.6%2024.6M48.6%2024.6L60.6%2036.6M-11.4%2048.6L0.6%2036.6M0.6%2048.6L12.6%2036.6M12.6%2036.6L24.6%2048.6M24.6%2036.6L36.6%2048.6M36.6%2048.6L48.6%2036.6M48.6%2048.6L60.6%2036.6M-11.4%2048.6L0.6%2060.6M0.6%2060.6L12.6%2048.6M12.6%2060.6L24.6%2048.6M24.6%2048.6L36.6%2060.6M36.6%2048.6L48.6%2060.6M48.6%2060.6L60.6%2048.6%22%20fill%3D%22none%22%20stroke%3D%22rgb%2810%2C8%2C6%29%22%20stroke-width%3D%221.1%22%20stroke-opacity%3D%220.085%22%20stroke-linecap%3D%22round%22%2F%3E%3Cpath%20d%3D%22M-12%200L0%20-12M0%200L12%20-12M12%20-12L24%200M24%20-12L36%200M36%200L48%20-12M48%200L60%20-12M-12%200L0%2012M0%2012L12%200M12%2012L24%200M24%200L36%2012M36%200L48%2012M48%2012L60%200M-12%2012L0%2024M0%2012L12%2024M12%2024L24%2012M24%2024L36%2012M36%2012L48%2024M48%2012L60%2024M-12%2036L0%2024M0%2024L12%2036M12%2024L24%2036M24%2036L36%2024M36%2036L48%2024M48%2024L60%2036M-12%2048L0%2036M0%2048L12%2036M12%2036L24%2048M24%2036L36%2048M36%2048L48%2036M48%2048L60%2036M-12%2048L0%2060M0%2060L12%2048M12%2060L24%2048M24%2048L36%2060M36%2048L48%2060M48%2060L60%2048%22%20fill%3D%22none%22%20stroke%3D%22rgb%28210%2C199%2C180%29%22%20stroke-width%3D%221.1%22%20stroke-opacity%3D%220.111%22%20stroke-linecap%3D%22round%22%2F%3E%3C%2Fsvg%3E';
const WEAVE_LIGHT =
'data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2248%22%20height%3D%2248%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cpath%20d%3D%22M-11.4%200.6L0.6%20-11.4M0.6%200.6L12.6%20-11.4M12.6%20-11.4L24.6%200.6M24.6%20-11.4L36.6%200.6M36.6%200.6L48.6%20-11.4M48.6%200.6L60.6%20-11.4M-11.4%200.6L0.6%2012.6M0.6%2012.6L12.6%200.6M12.6%2012.6L24.6%200.6M24.6%200.6L36.6%2012.6M36.6%200.6L48.6%2012.6M48.6%2012.6L60.6%200.6M-11.4%2012.6L0.6%2024.6M0.6%2012.6L12.6%2024.6M12.6%2024.6L24.6%2012.6M24.6%2024.6L36.6%2012.6M36.6%2012.6L48.6%2024.6M48.6%2012.6L60.6%2024.6M-11.4%2036.6L0.6%2024.6M0.6%2024.6L12.6%2036.6M12.6%2024.6L24.6%2036.6M24.6%2036.6L36.6%2024.6M36.6%2036.6L48.6%2024.6M48.6%2024.6L60.6%2036.6M-11.4%2048.6L0.6%2036.6M0.6%2048.6L12.6%2036.6M12.6%2036.6L24.6%2048.6M24.6%2036.6L36.6%2048.6M36.6%2048.6L48.6%2036.6M48.6%2048.6L60.6%2036.6M-11.4%2048.6L0.6%2060.6M0.6%2060.6L12.6%2048.6M12.6%2060.6L24.6%2048.6M24.6%2048.6L36.6%2060.6M36.6%2048.6L48.6%2060.6M48.6%2060.6L60.6%2048.6%22%20fill%3D%22none%22%20stroke%3D%22rgb%28126%2C116%2C98%29%22%20stroke-width%3D%221.1%22%20stroke-opacity%3D%220.075%22%20stroke-linecap%3D%22round%22%2F%3E%3Cpath%20d%3D%22M-12%200L0%20-12M0%200L12%20-12M12%20-12L24%200M24%20-12L36%200M36%200L48%20-12M48%200L60%20-12M-12%200L0%2012M0%2012L12%200M12%2012L24%200M24%200L36%2012M36%200L48%2012M48%2012L60%200M-12%2012L0%2024M0%2012L12%2024M12%2024L24%2012M24%2024L36%2012M36%2012L48%2024M48%2012L60%2024M-12%2036L0%2024M0%2024L12%2036M12%2024L24%2036M24%2036L36%2024M36%2036L48%2024M48%2024L60%2036M-12%2048L0%2036M0%2048L12%2036M12%2036L24%2048M24%2036L36%2048M36%2048L48%2036M48%2048L60%2036M-12%2048L0%2060M0%2060L12%2048M12%2060L24%2048M24%2048L36%2060M36%2048L48%2060M48%2060L60%2048%22%20fill%3D%22none%22%20stroke%3D%22rgb%28255%2C253%2C247%29%22%20stroke-width%3D%221.1%22%20stroke-opacity%3D%220.098%22%20stroke-linecap%3D%22round%22%2F%3E%3C%2Fsvg%3E';
export const herringbone: ChatBgVariants = {
// Warm taupe threads (~oklch(0.79 0.02 75)) over a charcoal base. The two-tone wash
// runs cool-charcoal -> slightly warmer charcoal across the diagonal so the weave has
// a gentle light side; the vignette darkens the far corners a touch for depth.
dark: {
backgroundColor: '#14120f',
backgroundImage: [
`url("${WEAVE_DARK}")`,
'linear-gradient(135deg, oklch(0.26 0.012 70 / 0.5) 0%, oklch(0.2 0.008 60 / 0.5) 100%)',
'radial-gradient(120% 120% at 50% 40%, oklch(0.24 0.01 65 / 0) 55%, oklch(0.12 0.006 55 / 0.45) 100%)',
].join(','),
backgroundSize: '48px 48px, 100% 100%, 100% 100%',
backgroundRepeat: 'repeat, no-repeat, no-repeat',
},
// Greige threads (shadow ~oklch(0.6 0.015 75)) with a warm-white highlight over a warm
// off-white base. The wash tilts warm-white -> faint greige across the diagonal for the
// lit/shadow side; a whisper-soft vignette keeps corners from going flat.
light: {
backgroundColor: '#f6f3ec',
backgroundImage: [
`url("${WEAVE_LIGHT}")`,
'linear-gradient(135deg, oklch(0.99 0.006 85 / 0.6) 0%, oklch(0.93 0.01 80 / 0.6) 100%)',
'radial-gradient(120% 120% at 50% 40%, oklch(0.98 0.006 85 / 0) 58%, oklch(0.87 0.012 78 / 0.4) 100%)',
].join(','),
backgroundSize: '48px 48px, 100% 100%, 100% 100%',
backgroundRepeat: 'repeat, no-repeat, no-repeat',
},
};
@@ -0,0 +1,66 @@
import { CSSProperties } from 'react';
import { ChatBgVariants } from './types';
// hexgrid — a refined sci-fi HUD honeycomb lattice.
//
// The motif is a crisp pointy-top hexagon honeycomb, drawn as thin interlocking
// outlines like the readout of a sci-fi interface. It is layered over a soft
// depth sheen: a faint central glow lifts the middle of the field and a gentle
// vignette settles the corners, so the lattice reads as a lit HUD surface with
// dimension rather than a flat repeating tile. Everything is kept at low alpha
// (hex lines ~0.140.16, washes well under legibility thresholds) so the motif
// is *felt, not read* — crisp message text stays comfortably WCAG-AA in both
// themes.
//
// SEAMLESS TILING
// The hex outlines live in a single inline-SVG data-URI tile of exactly
// √3·s × 3·s = 34.641 × 60 (side length s = 20). That is the natural repeat cell
// of a pointy-top honeycomb: one full central hexagon plus the six neighbours
// whose bodies straddle the tile edges. Because each straddling hexagon is drawn
// in full, the half that spills past one edge is completed pixel-for-pixel by the
// matching half re-entering from the opposite edge on the next repeat — the six
// vertical side edges land exactly on x = 0 and x = 34.641, the slanted edges
// meet across y = 0 / y = 60, so the lattice interlocks with no seam and no
// moiré. `backgroundSize: 34.641px 60px` locks the tile to that period; the glow
// and vignette are single non-repeating layers sized to 100%.
//
// DARK vs LIGHT
// Dark: cool cyan hex lines (oklch 0.72 0.1 200) on a deep blue-black base, with
// a soft cyan-tinted central glow — the classic "cold HUD" look.
// Light: soft slate-blue hexes (oklch 0.55 0.07 250) on a pale cool-white sheet,
// with a bright paper highlight at centre. Each alpha/lightness is tuned
// independently so both feel equally quiet against their own base.
// One seamless honeycomb tile (√3·20 × 3·20). Colour is injected per-theme.
const hexTile = (stroke: string): string =>
`url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2234.641%22%20height%3D%2260%22%3E%3Cpath%20d%3D%22M17.32%2010L0%2020L0%2040L17.32%2050L34.64%2040L34.64%2020Z%20M0%20-20L-17.32%20-10L-17.32%2010L0%2020L17.32%2010L17.32%20-10Z%20M34.64%20-20L17.32%20-10L17.32%2010L34.64%2020L51.96%2010L51.96%20-10Z%20M0%2040L-17.32%2050L-17.32%2070L0%2080L17.32%2070L17.32%2050Z%20M34.64%2040L17.32%2050L17.32%2070L34.64%2080L51.96%2070L51.96%2050Z%20M17.32%20-50L0%20-40L0%20-20L17.32%20-10L34.64%20-20L34.64%20-40Z%20M17.32%2070L0%2080L0%20100L17.32%20110L34.64%20100L34.64%2080Z%22%20fill%3D%22none%22%20stroke%3D%22${encodeURIComponent(
stroke,
)}%22%20stroke-width%3D%220.9%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E")`;
const dark: CSSProperties = {
backgroundColor: 'oklch(0.19 0.03 245)',
backgroundImage: [
// 1. the honeycomb lattice — cool cyan hex outlines.
hexTile('oklch(0.72 0.1 200 / 0.14)'),
// 2. central glow — a soft cyan lift so the field looks lit from within.
'radial-gradient(120% 90% at 50% 42%, oklch(0.30 0.05 210 / 0.55) 0%, transparent 60%)',
// 3. vignette — settles the corners into the deep base for depth.
'radial-gradient(130% 130% at 50% 45%, transparent 55%, oklch(0.13 0.02 240 / 0.55) 100%)',
].join(','),
backgroundSize: '34.641px 60px, 100% 100%, 100% 100%',
};
const light: CSSProperties = {
backgroundColor: 'oklch(0.965 0.008 240)',
backgroundImage: [
// 1. the honeycomb lattice — soft slate-blue hex outlines.
hexTile('oklch(0.55 0.07 250 / 0.16)'),
// 2. central highlight — a hint of brighter paper toward the middle.
'radial-gradient(120% 90% at 50% 40%, oklch(0.99 0.005 240 / 0.7) 0%, transparent 60%)',
// 3. vignette — feather the edges into a slightly cooler paper.
'radial-gradient(130% 130% at 50% 45%, transparent 58%, oklch(0.90 0.015 245 / 0.5) 100%)',
].join(','),
backgroundSize: '34.641px 60px, 100% 100%, 100% 100%',
};
export const hexgrid: ChatBgVariants = { dark, light };
+121
View File
@@ -0,0 +1,121 @@
import { ChatBgVariants } from './types';
// neon — a synthwave neon grid with real bloom, kept restrained for readability.
//
// Concept: a retro-futuristic magenta/cyan grid that *glows* rather than shouts.
// The glow is built the way a real neon tube reads: a crisp hairline of light
// sitting inside a much wider, softer halo of the same hue. We achieve this per
// axis by stacking TWO linear-gradient layers that share the identical tile size
// (so their lines land on exactly the same pixel column/row across every repeat):
// - a wide "bloom" line: a fat, very-low-opacity band with a soft gradient
// falloff on both sides (transparent -> colour -> transparent), reading as
// out-of-focus glow;
// - a crisp "core" line: a 1px bright hairline centred in that bloom.
// A dark radial vignette then pulls the whole grid back toward the edges and
// keeps the reading column — the calm centre — darkest and highest-contrast, so
// text stays crisp. Pure CSS: only linear + radial gradients, no assets.
//
// Seamless tiling: every grid layer uses the SAME backgroundSize per axis
// (magenta and cyan share one 88px module in dark; the fine cyan sub-grid is an
// exact 1/2 divisor at 44px so it re-registers). Because the bloom and core for
// an axis share a size and a 0/0 position, their lines are always co-registered
// and no seam is possible. Vignette/wash layers are 100% 100% and never tile.
export const neon: ChatBgVariants = {
// Dark: magenta + cyan tubes glowing over near-black, bloom kept low so the
// lines are felt, not read. Vignette darkens the centre for legibility.
dark: {
backgroundColor: 'oklch(0.135 0.02 285)',
backgroundImage: [
// 1. magenta core hairlines — crisp, bright, thin (vertical + horizontal)
'linear-gradient(90deg, oklch(0.68 0.21 350 / 0.34) 0 1px, transparent 1px)',
'linear-gradient(0deg, oklch(0.68 0.21 350 / 0.34) 0 1px, transparent 1px)',
// 2. magenta bloom — a wide soft halo hugging the same lines
'linear-gradient(90deg, transparent 0, oklch(0.66 0.2 350 / 0.11) 3px, transparent 7px)',
'linear-gradient(0deg, transparent 0, oklch(0.66 0.2 350 / 0.11) 3px, transparent 7px)',
// 3. cyan core hairlines on the offset half-grid — the cross accent
'linear-gradient(90deg, oklch(0.82 0.13 200 / 0.20) 0 1px, transparent 1px)',
'linear-gradient(0deg, oklch(0.82 0.13 200 / 0.20) 0 1px, transparent 1px)',
// 4. cyan bloom — soft cool halo on the same half-grid lines
'linear-gradient(90deg, transparent 0, oklch(0.80 0.12 200 / 0.07) 2px, transparent 5px)',
'linear-gradient(0deg, transparent 0, oklch(0.80 0.12 200 / 0.07) 2px, transparent 5px)',
// 5. vignette — recede the grid, keep the reading centre calm & dark
'radial-gradient(ellipse 125% 95% at 50% 44%, transparent 34%, oklch(0.10 0.02 285 / 0.72) 100%)',
// 6. horizon wash — a faint magenta->cyan synthwave glow low on the canvas
'radial-gradient(ellipse 150% 90% at 50% 108%, oklch(0.4 0.14 340 / 0.30) 0%, transparent 60%)',
].join(','),
backgroundSize: [
'88px 88px', // magenta core V
'88px 88px', // magenta core H
'88px 88px', // magenta bloom V
'88px 88px', // magenta bloom H
'44px 44px', // cyan core V (exact 1/2 divisor — re-registers)
'44px 44px', // cyan core H
'44px 44px', // cyan bloom V
'44px 44px', // cyan bloom H
'100% 100%', // vignette
'100% 100%', // horizon wash
].join(','),
backgroundPosition: [
'0 0', // magenta core V
'0 0', // magenta core H
'-3px 0', // magenta bloom V — centre the 7px halo on the 1px core
'0 -3px', // magenta bloom H
'22px 22px', // cyan core V — sit the fine grid between magenta lines
'22px 22px', // cyan core H
'20px 22px', // cyan bloom V — centre the 5px halo on the cyan core
'22px 20px', // cyan bloom H
'0 0', // vignette
'0 0', // horizon wash
].join(','),
},
// Light: "neon" reinterpreted as a soft luminous violet/teal grid on a pale
// cool-white base — no glow-on-black, just gentle coloured light. Bloom is even
// lighter here; a subtle centre-brightening vignette lifts the reading column.
light: {
backgroundColor: 'oklch(0.972 0.006 275)',
backgroundImage: [
// 1. violet core hairlines — soft but defined
'linear-gradient(90deg, oklch(0.55 0.17 330 / 0.16) 0 1px, transparent 1px)',
'linear-gradient(0deg, oklch(0.55 0.17 330 / 0.16) 0 1px, transparent 1px)',
// 2. violet bloom — the merest wide halo for luminosity
'linear-gradient(90deg, transparent 0, oklch(0.6 0.16 330 / 0.06) 3px, transparent 7px)',
'linear-gradient(0deg, transparent 0, oklch(0.6 0.16 330 / 0.06) 3px, transparent 7px)',
// 3. teal core hairlines on the offset half-grid — cool accent
'linear-gradient(90deg, oklch(0.58 0.11 200 / 0.11) 0 1px, transparent 1px)',
'linear-gradient(0deg, oklch(0.58 0.11 200 / 0.11) 0 1px, transparent 1px)',
// 4. teal bloom — faint cool halo on the same half-grid lines
'linear-gradient(90deg, transparent 0, oklch(0.62 0.1 200 / 0.045) 2px, transparent 5px)',
'linear-gradient(0deg, transparent 0, oklch(0.62 0.1 200 / 0.045) 2px, transparent 5px)',
// 5. vignette — brighten the calm reading centre for max legibility
'radial-gradient(ellipse 125% 95% at 50% 44%, oklch(1 0 0 / 0.50) 30%, transparent 100%)',
// 6. horizon wash — a whisper of violet->teal light low on the canvas
'radial-gradient(ellipse 150% 90% at 50% 108%, oklch(0.8 0.09 320 / 0.28) 0%, transparent 60%)',
].join(','),
backgroundSize: [
'88px 88px', // violet core V
'88px 88px', // violet core H
'88px 88px', // violet bloom V
'88px 88px', // violet bloom H
'44px 44px', // teal core V
'44px 44px', // teal core H
'44px 44px', // teal bloom V
'44px 44px', // teal bloom H
'100% 100%', // vignette
'100% 100%', // horizon wash
].join(','),
backgroundPosition: [
'0 0', // violet core V
'0 0', // violet core H
'-3px 0', // violet bloom V
'0 -3px', // violet bloom H
'22px 22px', // teal core V
'22px 22px', // teal core H
'20px 22px', // teal bloom V
'22px 20px', // teal bloom H
'0 0', // vignette
'0 0', // horizon wash
].join(','),
},
};
+135
View File
@@ -0,0 +1,135 @@
import { CSSProperties } from 'react';
import { ChatBgVariants } from './types';
// plaid — an authentic woven tartan, muted to a heather-wool hush.
//
// Real tartan is not a grid of lines: it is a *sett* — a repeating sequence of
// coloured bands of different widths — thrown in BOTH warp (vertical) and weft
// (horizontal) directions with the SAME sequence. Where a warp band crosses a
// weft band of the same colour the yarn density doubles and the colour visibly
// deepens; that reinforced overlap at every crossing is exactly what makes cloth
// read as woven rather than printed. We reproduce that physically with
// semi-transparent bands: a vertical band at alpha a and a horizontal band at
// alpha a stack to ~2a where they cross (over transparent to 1x elsewhere), so
// the crossings darken on their own with no extra layer.
//
// THE SETT (band widths across one tile)
// We use a few closely-related widths for a wool-flannel rhythm rather than a
// clean check: a wide ground band, a medium companion, and a thin accent
// over-stripe of a warmer hue (the classic single guard line). The identical
// sequence in warp and weft yields the tartan lattice. A faint diagonal twill
// hatch sits on top at very low alpha to suggest the 2/2 twill thread angle of
// woven wool. A soft central wash lifts the reading zone and a gentle vignette
// settles the corners.
//
// SEAMLESS TILING
// Every band layer is a `repeating-linear-gradient` whose stop sequence is
// expressed in px and whose period divides the tile exactly (dark tile 96px:
// wide=48, medium=24, accent=96; light tile 88px similarly). Warp layers repeat
// at 0deg-across (90deg gradient) and weft at 0deg, sharing one square
// `backgroundSize`, so the sett closes on itself with no seam in either axis.
// The twill hatch is a repeating-linear-gradient on a small square tile that
// divides the main tile. Wash and vignette are single non-repeating gradients
// at 100% 100%, so they never seam.
const dark: CSSProperties = {
// Deep muted forest-charcoal ground.
backgroundColor: 'oklch(0.19 0.018 155)',
backgroundImage: [
// Twill hatch — whisper-faint diagonal thread angle of the weave itself.
'repeating-linear-gradient(45deg,' +
' oklch(0.55 0.03 155 / 0.03) 0px, oklch(0.55 0.03 155 / 0.03) 1px,' +
' transparent 1px, transparent 4px)',
// WEFT (horizontal bands) --------------------------------------------
// Wide muted-forest ground band.
'repeating-linear-gradient(0deg,' +
' oklch(0.45 0.05 150 / 0.14) 0px, oklch(0.45 0.05 150 / 0.14) 22px,' +
' transparent 22px, transparent 48px)',
// Medium companion band (cooler, offset into the ground gap).
'repeating-linear-gradient(0deg,' +
' transparent 0px, transparent 60px,' +
' oklch(0.42 0.035 175 / 0.11) 60px, oklch(0.42 0.035 175 / 0.11) 72px,' +
' transparent 72px, transparent 96px)',
// Thin warm amber guard line — the single accent over-stripe.
'repeating-linear-gradient(0deg,' +
' transparent 0px, transparent 36px,' +
' oklch(0.60 0.08 40 / 0.13) 36px, oklch(0.60 0.08 40 / 0.13) 38px,' +
' transparent 38px, transparent 96px)',
// WARP (vertical bands, identical sett) -------------------------------
'repeating-linear-gradient(90deg,' +
' oklch(0.45 0.05 150 / 0.14) 0px, oklch(0.45 0.05 150 / 0.14) 22px,' +
' transparent 22px, transparent 48px)',
'repeating-linear-gradient(90deg,' +
' transparent 0px, transparent 60px,' +
' oklch(0.42 0.035 175 / 0.11) 60px, oklch(0.42 0.035 175 / 0.11) 72px,' +
' transparent 72px, transparent 96px)',
'repeating-linear-gradient(90deg,' +
' transparent 0px, transparent 36px,' +
' oklch(0.60 0.08 40 / 0.13) 36px, oklch(0.60 0.08 40 / 0.13) 38px,' +
' transparent 38px, transparent 96px)',
// Tonal wash — soft warm-green lift through the reading centre.
'radial-gradient(ellipse 92% 78% at 50% 42%, oklch(0.27 0.03 150 / 0.38) 0%, transparent 62%)',
// Vignette — feather the corners into deeper forest-charcoal.
'radial-gradient(ellipse 122% 132% at 50% 45%, transparent 58%, oklch(0.14 0.016 155 / 0.55) 100%)',
].join(','),
backgroundSize:
'8px 8px,' + // twill (multiple of 4px hatch period → seamless)
'96px 96px, 96px 96px, 96px 96px,' + // weft: wide, medium, accent
'96px 96px, 96px 96px, 96px 96px,' + // warp: wide, medium, accent
'100% 100%, 100% 100%', // wash, vignette
};
const light: CSSProperties = {
// Warm off-white paper ground.
backgroundColor: 'oklch(0.965 0.007 85)',
backgroundImage: [
// Twill hatch — faint diagonal weave angle on paper.
'repeating-linear-gradient(45deg,' +
' oklch(0.45 0.03 250 / 0.025) 0px, oklch(0.45 0.03 250 / 0.025) 1px,' +
' transparent 1px, transparent 4px)',
// WEFT (horizontal bands) --------------------------------------------
// Wide dusty-blue ground band.
'repeating-linear-gradient(0deg,' +
' oklch(0.60 0.045 245 / 0.12) 0px, oklch(0.60 0.045 245 / 0.12) 20px,' +
' transparent 20px, transparent 44px)',
// Medium greige companion band.
'repeating-linear-gradient(0deg,' +
' transparent 0px, transparent 55px,' +
' oklch(0.62 0.018 90 / 0.11) 55px, oklch(0.62 0.018 90 / 0.11) 66px,' +
' transparent 66px, transparent 88px)',
// Thin warm sand guard line — the single accent over-stripe.
'repeating-linear-gradient(0deg,' +
' transparent 0px, transparent 33px,' +
' oklch(0.68 0.06 55 / 0.12) 33px, oklch(0.68 0.06 55 / 0.12) 35px,' +
' transparent 35px, transparent 88px)',
// WARP (vertical bands, identical sett) -------------------------------
'repeating-linear-gradient(90deg,' +
' oklch(0.60 0.045 245 / 0.12) 0px, oklch(0.60 0.045 245 / 0.12) 20px,' +
' transparent 20px, transparent 44px)',
'repeating-linear-gradient(90deg,' +
' transparent 0px, transparent 55px,' +
' oklch(0.62 0.018 90 / 0.11) 55px, oklch(0.62 0.018 90 / 0.11) 66px,' +
' transparent 66px, transparent 88px)',
'repeating-linear-gradient(90deg,' +
' transparent 0px, transparent 33px,' +
' oklch(0.68 0.06 55 / 0.12) 33px, oklch(0.68 0.06 55 / 0.12) 35px,' +
' transparent 35px, transparent 88px)',
// Tonal wash — warm paper highlight through the reading centre.
'radial-gradient(ellipse 92% 78% at 50% 42%, oklch(0.99 0.008 85 / 0.55) 0%, transparent 62%)',
// Vignette — settle the corners into a slightly deeper dusty tone.
'radial-gradient(ellipse 122% 132% at 50% 45%, transparent 58%, oklch(0.90 0.014 245 / 0.40) 100%)',
].join(','),
backgroundSize:
'8px 8px,' + // twill (multiple of 4px hatch period → seamless)
'88px 88px, 88px 88px, 88px 88px,' + // weft: wide, medium, accent
'88px 88px, 88px 88px, 88px 88px,' + // warp: wide, medium, accent
'100% 100%, 100% 100%', // wash, vignette
};
export const plaid: ChatBgVariants = { dark, light };
@@ -0,0 +1,59 @@
import { CSSProperties } from 'react';
import { ChatBgVariants } from './types';
// polka — a grown-up polka dot: embossed leather / fine letterpress stationery,
// not childish spots. Each dot is not a flat circle but a soft radial "bump":
// an off-centre highlight fading into a faint recessed shadow, so it reads as a
// gently raised (or debossed) node catching a single top-left light. Two subtly
// different dot sizes are staggered on a half-tile offset for a refined,
// hand-set rhythm, and a large single vignette gradient adds quiet depth toward
// the edges.
//
// SEAMLESS TILING: both dot layers repeat on the SAME 44px cell (backgroundSize
// 44px 44px). The larger "primary" dots sit at 0 0; the smaller "secondary"
// dots are shifted by exactly half a tile (22px 22px) so they fall in the gaps
// of the primary lattice — a true staggered brick layout that wraps with no
// seam. Each radial gradient's highlight/shadow rings are fully enclosed well
// inside its cell, so nothing is clipped at a tile boundary. The vignette is a
// single non-repeating gradient covering the whole element ('cover').
//
// SUBTLETY: dot opacities live in the 0.030.10 range and every dot fades to
// transparent over a soft edge (no hard rim), so the surface is felt as tactile
// grain rather than read as dots. Crisp message text sits comfortably above it
// in both themes (WCAG-AA safe).
const dark: CSSProperties = {
// deep espresso base — warm, near-black brown
backgroundColor: 'oklch(0.19 0.018 65)',
backgroundImage: [
// vignette — corners settle darker so the field feels like supple leather
'radial-gradient(120% 120% at 50% 40%, oklch(0.22 0.02 65 / 0.5) 0%, oklch(0.19 0.018 65 / 0) 55%, oklch(0.15 0.015 60 / 0.55) 100%)',
// PRIMARY dot — larger raised pearl. Top-left warm highlight, then the body,
// then a whisper of shadow at the lower-right rim for embossed dimension.
'radial-gradient(circle at 42% 40%, oklch(0.82 0.02 80 / 0.10) 0%, oklch(0.80 0.02 80 / 0.075) 22%, oklch(0.55 0.02 70 / 0.045) 44%, oklch(0.12 0.01 60 / 0.05) 62%, transparent 72%)',
// SECONDARY dot — smaller, staggered into the gaps, fainter for depth layering
'radial-gradient(circle at 42% 40%, oklch(0.82 0.02 80 / 0.075) 0%, oklch(0.78 0.02 80 / 0.05) 26%, oklch(0.50 0.02 70 / 0.03) 52%, oklch(0.12 0.01 60 / 0.04) 70%, transparent 82%)',
].join(','),
// primary dots ~9px, secondary ~6px, both on the same 44px lattice
backgroundSize: 'cover, 44px 44px, 44px 44px',
// secondary offset by half a tile => staggered brick lattice
backgroundPosition: 'center, 0 0, 22px 22px',
};
const light: CSSProperties = {
// cream stationery base — warm off-white paper stock
backgroundColor: 'oklch(0.975 0.008 85)',
backgroundImage: [
// vignette — a gentle warm settling toward the edges, like heavy cotton paper
'radial-gradient(120% 120% at 50% 40%, oklch(0.99 0.006 85 / 0.5) 0%, oklch(0.975 0.008 85 / 0) 55%, oklch(0.945 0.012 80 / 0.55) 100%)',
// PRIMARY dot — soft taupe deboss. Faint paper highlight at top-left, taupe
// body, then a soft shadow lower-right so each dot reads pressed into the sheet.
'radial-gradient(circle at 42% 40%, oklch(0.99 0.004 85 / 0.35) 0%, oklch(0.72 0.02 70 / 0.075) 30%, oklch(0.60 0.025 65 / 0.09) 50%, oklch(0.55 0.025 60 / 0.05) 66%, transparent 76%)',
// SECONDARY dot — smaller, staggered, lighter for a two-tier hand-set rhythm
'radial-gradient(circle at 42% 40%, oklch(0.99 0.004 85 / 0.28) 0%, oklch(0.74 0.02 70 / 0.05) 34%, oklch(0.62 0.025 65 / 0.06) 56%, oklch(0.56 0.025 60 / 0.035) 72%, transparent 84%)',
].join(','),
backgroundSize: 'cover, 44px 44px, 44px 44px',
backgroundPosition: 'center, 0 0, 22px 22px',
};
export const polka: ChatBgVariants = { dark, light };
@@ -0,0 +1,91 @@
import { ChatBgVariants } from './types';
// stars — a deep-space starfield with subtle depth.
//
// Concept: three parallax layers of stars at different tile sizes and offsets
// (so the repeat never lines up and reads as a genuine random field), lifted
// onto a faint deep-blue->violet nebula wash for depth, and finished with a
// gentle center vignette that keeps the reading column the calmest area of the
// canvas. Every layer is a stacked radial-gradient — pure CSS, no assets.
//
// Layer stacking order (topmost first, as CSS paints image #1 on top):
// 1. bright near stars (crisp, sparse, largest tile)
// 2. mid stars (dimmer, medium tile)
// 3. faint blue far stars (haze, smallest tile — most repeats, least visible)
// 4. calming center vignette
// 5. nebula wash (deep blue -> violet)
// The three star tiles use coprime-ish sizes (137/191/233 dark) so their least
// common repeat is enormous and no seam is perceivable.
export const stars: ChatBgVariants = {
// Dark: bright/dim white + faint blue stars on a near-black cosmos, with a
// deep-blue->violet nebula and a soft vignette that darkens the calm center.
dark: {
backgroundColor: 'oklch(0.16 0.03 275)',
backgroundImage: [
// 1. bright near stars — crisp cool-white, sparse
'radial-gradient(circle at center, oklch(0.98 0.01 260 / 0.85) 0.6px, transparent 1.4px)',
// 2. mid stars — softer, more of them
'radial-gradient(circle at center, oklch(0.92 0.02 265 / 0.55) 0.6px, transparent 1.3px)',
// 3. faint blue far dust — the parallax haze
'radial-gradient(circle at center, oklch(0.80 0.06 255 / 0.30) 0.5px, transparent 1.1px)',
// 4. center vignette — keeps the reading column calmest
'radial-gradient(ellipse 120% 90% at 50% 42%, transparent 42%, oklch(0.10 0.03 270 / 0.55) 100%)',
// 5. nebula wash — deep blue -> violet drift
'radial-gradient(ellipse 140% 120% at 78% 12%, oklch(0.25 0.08 280 / 0.55) 0%, transparent 55%)',
'radial-gradient(ellipse 130% 110% at 18% 92%, oklch(0.20 0.06 250 / 0.50) 0%, transparent 58%)',
].join(','),
backgroundSize: [
'137px 137px', // near stars
'191px 191px', // mid stars
'233px 233px', // far dust
'100% 100%', // vignette
'100% 100%', // nebula A
'100% 100%', // nebula B
].join(','),
backgroundPosition: [
'0 0', // near
'61px 43px', // mid (offset breaks alignment)
'113px 97px', // far (offset again)
'0 0', // vignette
'0 0', // nebula A
'0 0', // nebula B
].join(','),
},
// Light: an airy pre-dawn sky. No literal white stars on white — instead very
// soft pale sparkles paired with the faintest cool-grey speckles, floated on a
// gentle cool gradient. Reads as elegant atmosphere, never as noise over text.
light: {
backgroundColor: 'oklch(0.965 0.008 255)',
backgroundImage: [
// 1. pale warm pre-dawn sparkles — a hair brighter than the sky
'radial-gradient(circle at center, oklch(0.995 0.015 90 / 0.55) 0.6px, transparent 1.4px)',
// 2. tiny cool speckles — the merest hint of darkness for texture/contrast
'radial-gradient(circle at center, oklch(0.62 0.05 260 / 0.16) 0.5px, transparent 1.2px)',
// 3. faint far dust — very soft, most-repeated layer
'radial-gradient(circle at center, oklch(0.70 0.04 255 / 0.12) 0.5px, transparent 1.1px)',
// 4. center vignette — brightens the calm reading center slightly
'radial-gradient(ellipse 120% 90% at 50% 44%, oklch(1 0 0 / 0.45) 30%, transparent 100%)',
// 5. pre-dawn wash — cool blue high, warm blush low
'radial-gradient(ellipse 150% 120% at 80% 8%, oklch(0.90 0.05 255 / 0.60) 0%, transparent 60%)',
'radial-gradient(ellipse 140% 120% at 15% 95%, oklch(0.93 0.04 40 / 0.45) 0%, transparent 62%)',
].join(','),
backgroundSize: [
'149px 149px', // sparkles
'199px 199px', // speckles
'251px 251px', // far dust
'100% 100%', // vignette
'100% 100%', // wash A
'100% 100%', // wash B
].join(','),
backgroundPosition: [
'0 0', // sparkles
'71px 53px', // speckles
'127px 109px', // far dust
'0 0', // vignette
'0 0', // wash A
'0 0', // wash B
].join(','),
},
};
@@ -0,0 +1,93 @@
import { ChatBgVariants } from './types';
// tactical — a military tactical display / recon coordinate grid (MGRS-style).
//
// The motif is a fine grid nested inside bold sector squares, with a reticle
// crosshair (arms + ring) at every sector intersection, small stencil corner
// brackets inside each sector, and coordinate tick-marks along the sector edges
// — a convincing mil-spec map overlay rather than a plain dot grid.
//
// Layers (painted top-to-bottom):
// 1. SVG reticle/stencil tile (128px). Corner arms + quarter-ring arcs radiate
// from each of the four tile corners, so four neighbouring tiles combine
// into ONE full crosshair "+" with a full ring at every sector intersection.
// The tile also carries L-shaped stencil brackets, edge coordinate ticks and
// a micro centre reticle. Because every mark is anchored to the 128px tile
// lattice, it stays phase-locked to the grids below — no seams, no drift.
// 2. Sector lines (heavier) — 128px.
// 3. Fine recon grid (fainter) — 16px (128 = 8 × 16, so it nests
// exactly inside every sector with no beat/moiré).
// 4. A soft scan vignette that keeps the CENTRE calm and clear for text while
// letting the grid fall away slightly toward the edges — dimension without
// contrast.
//
// All strokes sit at low alpha (~0.030.30 on 1px marks) so the display is felt,
// not read: crisp message text stays comfortably WCAG-AA legible in both themes.
// A single shared top-left (0 0) origin keeps the reticle tile, the 128px sector
// grid and the 16px fine grid all in phase.
const DARK_RETICLE =
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22128%22%20height%3D%22128%22%3E%3Cg%20stroke%3D%22oklch%280.72%200.13%2085%20%2F%200.30%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M0%200%20H14%20M0%200%20V14%22%2F%3E%3Cpath%20d%3D%22M128%200%20H114%20M128%200%20V14%22%2F%3E%3Cpath%20d%3D%22M0%20128%20H14%20M0%20128%20V114%22%2F%3E%3Cpath%20d%3D%22M128%20128%20H114%20M128%20128%20V114%22%2F%3E%3Ccircle%20cx%3D%220%22%20cy%3D%220%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%220%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%220%22%20cy%3D%22128%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%22128%22%20r%3D%226%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.72%200.10%2095%20%2F%200.22%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M10%2020%20V10%20H20%22%2F%3E%3Cpath%20d%3D%22M118%2020%20V10%20H108%22%2F%3E%3Cpath%20d%3D%22M10%20108%20V118%20H20%22%2F%3E%3Cpath%20d%3D%22M118%20108%20V118%20H108%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.72%200.10%2095%20%2F%200.22%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M64%200%20V6%20M64%20128%20V122%20M0%2064%20H6%20M128%2064%20H122%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.72%200.13%2085%20%2F%200.30%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M64%2058%20V70%20M58%2064%20H70%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
const LIGHT_RETICLE =
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22128%22%20height%3D%22128%22%3E%3Cg%20stroke%3D%22oklch%280.45%200.07%20120%20%2F%200.40%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M0%200%20H14%20M0%200%20V14%22%2F%3E%3Cpath%20d%3D%22M128%200%20H114%20M128%200%20V14%22%2F%3E%3Cpath%20d%3D%22M0%20128%20H14%20M0%20128%20V114%22%2F%3E%3Cpath%20d%3D%22M128%20128%20H114%20M128%20128%20V114%22%2F%3E%3Ccircle%20cx%3D%220%22%20cy%3D%220%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%220%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%220%22%20cy%3D%22128%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%22128%22%20r%3D%226%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.42%200.05%20130%20%2F%200.28%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M10%2020%20V10%20H20%22%2F%3E%3Cpath%20d%3D%22M118%2020%20V10%20H108%22%2F%3E%3Cpath%20d%3D%22M10%20108%20V118%20H20%22%2F%3E%3Cpath%20d%3D%22M118%20108%20V118%20H108%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.42%200.05%20130%20%2F%200.28%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M64%200%20V6%20M64%20128%20V122%20M0%2064%20H6%20M128%2064%20H122%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.45%200.07%20120%20%2F%200.40%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M64%2058%20V70%20M58%2064%20H70%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
export const tactical: ChatBgVariants = {
// Phosphor amber/olive lines glowing on a near-black recon display.
dark: {
backgroundColor: 'oklch(0.17 0.012 95)',
backgroundImage: [
// 1. reticles + stencil brackets + coordinate ticks
DARK_RETICLE,
// 4. scan vignette: keeps the centre calm, eases grid contrast at edges.
'radial-gradient(135% 120% at 50% 46%, transparent 52%, oklch(0.11 0.01 100 / 0.55) 100%)',
// a faint phosphor bloom drifting off the top so the black isn't dead flat.
'radial-gradient(120% 90% at 50% 0%, oklch(0.24 0.03 90 / 0.45) 0%, transparent 60%)',
// 2. sector lines (heavier)
'linear-gradient(oklch(0.72 0.13 85 / 0.11) 1px, transparent 1px)',
'linear-gradient(90deg, oklch(0.72 0.13 85 / 0.11) 1px, transparent 1px)',
// 3. fine recon grid (fainter)
'linear-gradient(oklch(0.72 0.11 90 / 0.045) 1px, transparent 1px)',
'linear-gradient(90deg, oklch(0.72 0.11 90 / 0.045) 1px, transparent 1px)',
].join(','),
backgroundSize: [
'128px 128px', // reticle tile
'100% 100%', // vignette
'100% 100%', // phosphor bloom
'128px 128px', // sector V
'128px 128px', // sector H
'16px 16px', // fine V
'16px 16px', // fine H
].join(','),
// Shared top-left origin: reticle tile + 128px sector grid + 16px fine grid
// (128 = 8 × 16) stay phase-locked, so corner arms land on sector crossings.
},
// Olive-graphite recon grid printed on cool tactical paper.
light: {
backgroundColor: 'oklch(0.95 0.008 120)',
backgroundImage: [
LIGHT_RETICLE,
// scan vignette: gentle cool shading into the corners, calm centre.
'radial-gradient(135% 120% at 50% 46%, transparent 56%, oklch(0.86 0.02 125 / 0.5) 100%)',
// paper sheen toward the top so the surface reads like a printed sheet.
'radial-gradient(120% 90% at 50% 0%, oklch(0.98 0.006 120 / 0.7) 0%, transparent 60%)',
// sector lines (heavier)
'linear-gradient(oklch(0.45 0.07 120 / 0.14) 1px, transparent 1px)',
'linear-gradient(90deg, oklch(0.45 0.07 120 / 0.14) 1px, transparent 1px)',
// fine recon grid (fainter)
'linear-gradient(oklch(0.45 0.06 125 / 0.055) 1px, transparent 1px)',
'linear-gradient(90deg, oklch(0.45 0.06 125 / 0.055) 1px, transparent 1px)',
].join(','),
backgroundSize: [
'128px 128px',
'100% 100%',
'100% 100%',
'128px 128px',
'128px 128px',
'16px 16px',
'16px 16px',
].join(','),
// Shared top-left origin keeps the reticle tile and both grids phase-locked.
},
};
@@ -0,0 +1,73 @@
import { CSSProperties } from 'react';
import { ChatBgVariants } from './types';
// topographic — an elegant contour / elevation map.
//
// The motif is a delicate cartographic contour survey: nested rings suggest two
// gentle "peaks" and a shallow "valley", drawn with occasional heavier "index
// contour" lines for authenticity, all floating over a soft tonal wash. It is
// tuned to be *felt, not read* — line opacities sit well under legibility
// thresholds so crisp message text stays comfortably WCAG-AA in both themes.
//
// SEAMLESS TILING
// Each contour system is a `repeating-radial-gradient` whose ring period P is a
// clean divisor of its `backgroundSize` tile. A repeating-radial-gradient tiles
// seamlessly only when the tile edge falls on a whole number of ring periods, so
// every layer below uses tile = N * P. Peak A's fine (32px) and index (128px)
// layers share one 256px tile (256 = 8*32 = 2*128) AND one center, so the heavy
// index lines land exactly on every 4th fine ring — a true index contour, never
// drifting out of register. Peak B tiles 288 = 12*24; the valley tiles 384 =
// 8*48. The tonal washes/vignette are single non-repeating gradients sized to
// the same tiles, so nothing shows a visible seam.
const dark: CSSProperties = {
backgroundColor: 'oklch(0.205 0.018 235)',
backgroundImage: [
// Peak A — fine contour lines (soft teal), 32px period.
'repeating-radial-gradient(circle at 27% 34%, transparent 0, transparent 26px,' +
' oklch(0.62 0.055 190 / 0.09) 27px, oklch(0.62 0.055 190 / 0.09) 28px, transparent 29px, transparent 32px)',
// Peak A — index (heavier) contour every 4th ring, 128px period.
'repeating-radial-gradient(circle at 27% 34%, transparent 0, transparent 122px,' +
' oklch(0.66 0.06 190 / 0.10) 123px, oklch(0.66 0.06 190 / 0.10) 125px, transparent 126px, transparent 128px)',
// Peak B — fine contour lines (cooler sage-teal), 24px period.
'repeating-radial-gradient(circle at 78% 72%, transparent 0, transparent 19px,' +
' oklch(0.60 0.05 200 / 0.07) 20px, oklch(0.60 0.05 200 / 0.07) 21px, transparent 22px, transparent 24px)',
// Valley — broad shallow rings (very faint), 48px period.
'repeating-radial-gradient(circle at 52% 8%, transparent 0, transparent 42px,' +
' oklch(0.58 0.045 195 / 0.05) 43px, oklch(0.58 0.045 195 / 0.05) 44px, transparent 45px, transparent 48px)',
// Tonal wash — lifts the "peaks", sinks the corners for depth.
'radial-gradient(circle at 27% 34%, oklch(0.26 0.03 200 / 0.55) 0%, transparent 46%)',
'radial-gradient(circle at 78% 72%, oklch(0.24 0.028 205 / 0.45) 0%, transparent 44%)',
// Vignette — soft edge darkening keeps the field calm behind text.
'radial-gradient(ellipse 120% 130% at 50% 42%, transparent 58%, oklch(0.15 0.02 235 / 0.55) 100%)',
].join(','),
backgroundSize:
'256px 256px, 256px 256px, 288px 288px, 384px 384px, 100% 100%, 100% 100%, 100% 100%',
};
const light: CSSProperties = {
backgroundColor: 'oklch(0.965 0.008 85)',
backgroundImage: [
// Peak A — fine contour lines (warm graphite/sand), 32px period.
'repeating-radial-gradient(circle at 27% 34%, transparent 0, transparent 26px,' +
' oklch(0.45 0.03 70 / 0.08) 27px, oklch(0.45 0.03 70 / 0.08) 28px, transparent 29px, transparent 32px)',
// Peak A — index (heavier) contour every 4th ring, 128px period.
'repeating-radial-gradient(circle at 27% 34%, transparent 0, transparent 122px,' +
' oklch(0.40 0.035 68 / 0.10) 123px, oklch(0.40 0.035 68 / 0.10) 125px, transparent 126px, transparent 128px)',
// Peak B — fine contour lines (soft sage-graphite), 24px period.
'repeating-radial-gradient(circle at 78% 72%, transparent 0, transparent 19px,' +
' oklch(0.47 0.028 120 / 0.06) 20px, oklch(0.47 0.028 120 / 0.06) 21px, transparent 22px, transparent 24px)',
// Valley — broad shallow rings (very faint), 48px period.
'repeating-radial-gradient(circle at 52% 8%, transparent 0, transparent 42px,' +
' oklch(0.46 0.025 75 / 0.045) 43px, oklch(0.46 0.025 75 / 0.045) 44px, transparent 45px, transparent 48px)',
// Tonal wash — warm paper highlights over the "peaks".
'radial-gradient(circle at 27% 34%, oklch(0.985 0.012 85 / 0.60) 0%, transparent 46%)',
'radial-gradient(circle at 78% 72%, oklch(0.945 0.014 95 / 0.45) 0%, transparent 44%)',
// Vignette — feather the edges to a slightly deeper sand for depth.
'radial-gradient(ellipse 120% 130% at 50% 42%, transparent 58%, oklch(0.90 0.016 80 / 0.45) 100%)',
].join(','),
backgroundSize:
'256px 256px, 256px 256px, 288px 288px, 384px 384px, 100% 100%, 100% 100%, 100% 100%',
};
export const topographic: ChatBgVariants = { dark, light };
@@ -0,0 +1,103 @@
import { CSSProperties } from 'react';
import { ChatBgVariants } from './types';
// triangles — elegant low-poly / faceted-crystal mesh.
//
// The motif stays true to its name — a triangular tessellation — but is rebuilt
// to read as a *faceted crystalline surface* rather than the old flat isometric
// lines. Neighbouring triangular facets carry barely-there tonal shifts (one
// face catches a whisper of light, the adjacent one falls into a whisper of
// shade) so the plane looks gently faceted and dimensional, like brushed slate
// or cut glass seen at a shallow angle. A hairline "mesh glint" traces the facet
// edges so the crystalline structure is felt, never read. A soft tonal wash and
// a feathered vignette give the whole field quiet architectural depth.
//
// FACET SHADING
// An isometric triangle grid is three families of parallel lines at 0deg, 60deg
// and 120deg. Each `linear-gradient` below is a *hard-edged* two-band ramp along
// one of those axes: a faint tonal band followed by transparent, repeating
// across the tile. Overlapping the three axes partitions the plane into small
// triangular cells; because each axis contributes its shade to a different set
// of cells, up-pointing and down-pointing facets end up carrying subtly
// different summed tones — the alternating light/shadow facet look. A separate
// hairline layer per axis draws the thin edge glint at the facet borders.
//
// SEAMLESS TILING
// Equilateral geometry needs the tile height to be the width times sqrt(3). We
// use a 48x83px tile (48 * 1.732 = 83.1, rounded to 83) so the 60deg/120deg
// ramps close exactly on the tile box, and the horizontal edge family repeats on
// half-height (48x41.5 -> the 0deg hairline is sized to the full tile so its
// bands land on tile edges). Every facet-shade and edge layer shares this tile
// (or an exact multiple), and the 60/120 layers meet at the tile's mid columns,
// so triangles interlock across every seam with no drift. Wash and vignette are
// single non-repeating gradients at 100% 100%, so they never seam.
const dark: CSSProperties = {
// Deep navy base — the crystal sits on cool night stone.
backgroundColor: 'oklch(0.19 0.028 258)',
backgroundImage: [
// --- Facet shading: three cool-slate tonal ramps, one per triangle axis.
// Ascending-diagonal facets — a soft light band on one face family.
'linear-gradient(60deg,' +
' oklch(0.46 0.03 250 / 0.07) 0%, oklch(0.46 0.03 250 / 0.07) 50%,' +
' transparent 50%, transparent 100%)',
// Descending-diagonal facets — the shade family, closing the triangles.
'linear-gradient(120deg,' +
' oklch(0.34 0.03 255 / 0.06) 0%, oklch(0.34 0.03 255 / 0.06) 50%,' +
' transparent 50%, transparent 100%)',
// Horizontal facets — a third, fainter slate band so cells read three-sided.
'linear-gradient(0deg,' +
' oklch(0.42 0.028 248 / 0.045) 0%, oklch(0.42 0.028 248 / 0.045) 50%,' +
' transparent 50%, transparent 100%)',
// --- Mesh glint: hairline edges tracing the crystalline facet borders.
'linear-gradient(60deg, transparent 0, transparent calc(50% - 0.5px),' +
' oklch(0.62 0.035 250 / 0.10) calc(50% - 0.5px), oklch(0.62 0.035 250 / 0.10) 50%,' +
' transparent 50%)',
'linear-gradient(120deg, transparent 0, transparent calc(50% - 0.5px),' +
' oklch(0.62 0.035 250 / 0.10) calc(50% - 0.5px), oklch(0.62 0.035 250 / 0.10) 50%,' +
' transparent 50%)',
// --- Tonal wash — a gentle cool lift through the reading centre for depth.
'radial-gradient(ellipse 95% 80% at 50% 40%, oklch(0.28 0.03 255 / 0.45) 0%, transparent 62%)',
// --- Vignette — feather the corners into deeper navy.
'radial-gradient(ellipse 125% 130% at 50% 45%, transparent 58%, oklch(0.13 0.022 258 / 0.55) 100%)',
].join(','),
backgroundSize: '48px 83px, 48px 83px, 48px 83px, 48px 83px, 48px 83px, 100% 100%, 100% 100%',
// Offset the 120deg (shade) and its glint by half a tile so up/down facets
// interlock — this is what alternates the light/shadow triangles.
backgroundPosition: '0 0, 24px 0, 0 0, 0 0, 24px 0, 0 0, 0 0',
};
const light: CSSProperties = {
// Pale ice-white base — cut glass on frosted paper.
backgroundColor: 'oklch(0.975 0.004 250)',
backgroundImage: [
// --- Facet shading: soft cool-grey tonal ramps, one per triangle axis.
// Ascending-diagonal facets — a barely-there shade on one face family.
'linear-gradient(60deg,' +
' oklch(0.66 0.022 252 / 0.09) 0%, oklch(0.66 0.022 252 / 0.09) 50%,' +
' transparent 50%, transparent 100%)',
// Descending-diagonal facets — a hair darker, closing the triangles.
'linear-gradient(120deg,' +
' oklch(0.58 0.024 255 / 0.08) 0%, oklch(0.58 0.024 255 / 0.08) 50%,' +
' transparent 50%, transparent 100%)',
// Horizontal facets — the third, faintest cool-grey band.
'linear-gradient(0deg,' +
' oklch(0.62 0.02 250 / 0.055) 0%, oklch(0.62 0.02 250 / 0.055) 50%,' +
' transparent 50%, transparent 100%)',
// --- Mesh glint: crisp hairline facet edges in cool slate.
'linear-gradient(60deg, transparent 0, transparent calc(50% - 0.5px),' +
' oklch(0.50 0.03 255 / 0.11) calc(50% - 0.5px), oklch(0.50 0.03 255 / 0.11) 50%,' +
' transparent 50%)',
'linear-gradient(120deg, transparent 0, transparent calc(50% - 0.5px),' +
' oklch(0.50 0.03 255 / 0.11) calc(50% - 0.5px), oklch(0.50 0.03 255 / 0.11) 50%,' +
' transparent 50%)',
// --- Tonal wash — a clean white highlight through the reading centre.
'radial-gradient(ellipse 95% 80% at 50% 40%, oklch(0.995 0.003 250 / 0.60) 0%, transparent 62%)',
// --- Vignette — settle the corners into a faint cool grey.
'radial-gradient(ellipse 125% 130% at 50% 45%, transparent 58%, oklch(0.90 0.012 252 / 0.42) 100%)',
].join(','),
backgroundSize: '48px 83px, 48px 83px, 48px 83px, 48px 83px, 48px 83px, 100% 100%, 100% 100%',
backgroundPosition: '0 0, 24px 0, 0 0, 0 0, 24px 0, 0 0, 0 0',
};
export const triangles: ChatBgVariants = { dark, light };
@@ -0,0 +1,13 @@
import { CSSProperties } from 'react';
// A chat background provides an independently-tuned CSSProperties per app theme:
// the `dark` variant is a subtle light-ish pattern on a dark base, the `light`
// variant a subtle dark-ish pattern on a light base. Each sits DIRECTLY behind
// the chat message list, so both must stay subtle enough to preserve WCAG-AA
// text legibility. Animated backgrounds include an `animation`; getChatBg strips
// it for prefers-reduced-motion / pause-animations, so the remaining properties
// must already read as a finished static background on their own.
export type ChatBgVariants = {
dark: CSSProperties;
light: CSSProperties;
};
@@ -0,0 +1,68 @@
import { CSSProperties } from 'react';
import { ChatBgVariants } from './types';
// waves — a serene, rhythmic ocean swell / sound-wave contour.
//
// The motif is three stacked sine contours — layered swell at slightly varied
// amplitude, weight and opacity — floating over a soft vertical depth wash so
// the field reads like gentle water or sculpted sand. It is tuned to be *felt,
// not read*: every stroke sits well under legibility thresholds so crisp
// message text stays comfortably WCAG-AA in both themes.
//
// TRUE SINE CURVES VIA INLINE SVG
// Gradients can't draw a real sine, so each wave is a polyline sampling of
// y = yc - amp*sin(2*pi*N*x/W), rendered as an inline SVG data-URI (fully
// URL-encoded, so it is CSP/Tauri-safe and needs no external asset). oklch()
// stroke colors give perceptually even, low-chroma lines.
//
// SEAMLESS TILING
// The SVG tile is 240x120 with EXACTLY N=2 whole periods across its 240px
// width, so the first and last sample of every wave share the same y — the
// horizontal repeat has no seam. All three contours live within y = 24..106,
// clear of the 0/120 tile edges, so the vertical repeat is seam-free too. To
// avoid a rigid stacked look, the same tile is layered a second time shifted by
// half a tile (120px x, 60px y) at lower opacity, weaving the rows into a
// continuous drifting swell. backgroundSize = 240px 120px keeps the SVG at its
// authored scale; the depth wash is a single 100% gradient sized to match.
const waveTileDark =
'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22240%22%20height%3D%22120%22%20viewBox%3D%220%200%20240%20120%22%3E%3Cg%20fill%3D%22none%22%20stroke-linecap%3D%22round%22%3E%3Cpath%20d%3D%22M0%2034%20L5%2031.41%20L10%2029%20L15%2026.93%20L20%2025.34%20L25%2024.34%20L30%2024%20L35%2024.34%20L40%2025.34%20L45%2026.93%20L50%2029%20L55%2031.41%20L60%2034%20L65%2036.59%20L70%2039%20L75%2041.07%20L80%2042.66%20L85%2043.66%20L90%2044%20L95%2043.66%20L100%2042.66%20L105%2041.07%20L110%2039%20L115%2036.59%20L120%2034%20L125%2031.41%20L130%2029%20L135%2026.93%20L140%2025.34%20L145%2024.34%20L150%2024%20L155%2024.34%20L160%2025.34%20L165%2026.93%20L170%2029%20L175%2031.41%20L180%2034%20L185%2036.59%20L190%2039%20L195%2041.07%20L200%2042.66%20L205%2043.66%20L210%2044%20L215%2043.66%20L220%2042.66%20L225%2041.07%20L230%2039%20L235%2036.59%20L240%2034%22%20stroke%3D%22oklch(0.65%200.08%20200%20%2F%200.16)%22%20stroke-width%3D%221.5%22%2F%3E%3Cpath%20d%3D%22M0%2064%20L5%2062.19%20L10%2060.5%20L15%2059.05%20L20%2057.94%20L25%2057.24%20L30%2057%20L35%2057.24%20L40%2057.94%20L45%2059.05%20L50%2060.5%20L55%2062.19%20L60%2064%20L65%2065.81%20L70%2067.5%20L75%2068.95%20L80%2070.06%20L85%2070.76%20L90%2071%20L95%2070.76%20L100%2070.06%20L105%2068.95%20L110%2067.5%20L115%2065.81%20L120%2064%20L125%2062.19%20L130%2060.5%20L135%2059.05%20L140%2057.94%20L145%2057.24%20L150%2057%20L155%2057.24%20L160%2057.94%20L165%2059.05%20L170%2060.5%20L175%2062.19%20L180%2064%20L185%2065.81%20L190%2067.5%20L195%2068.95%20L200%2070.06%20L205%2070.76%20L210%2071%20L215%2070.76%20L220%2070.06%20L225%2068.95%20L230%2067.5%20L235%2065.81%20L240%2064%22%20stroke%3D%22oklch(0.68%200.07%20195%20%2F%200.11)%22%20stroke-width%3D%221.2%22%2F%3E%3Cpath%20d%3D%22M0%2094%20L5%2090.89%20L10%2088%20L15%2085.51%20L20%2083.61%20L25%2082.41%20L30%2082%20L35%2082.41%20L40%2083.61%20L45%2085.51%20L50%2088%20L55%2090.89%20L60%2094%20L65%2097.11%20L70%20100%20L75%20102.49%20L80%20104.39%20L85%20105.59%20L90%20106%20L95%20105.59%20L100%20104.39%20L105%20102.49%20L110%20100%20L115%2097.11%20L120%2094%20L125%2090.89%20L130%2088%20L135%2085.51%20L140%2083.61%20L145%2082.41%20L150%2082%20L155%2082.41%20L160%2083.61%20L165%2085.51%20L170%2088%20L175%2090.89%20L180%2094%20L185%2097.11%20L190%20100%20L195%20102.49%20L200%20104.39%20L205%20105.59%20L210%20106%20L215%20105.59%20L220%20104.39%20L225%20102.49%20L230%20100%20L235%2097.11%20L240%2094%22%20stroke%3D%22oklch(0.62%200.075%20205%20%2F%200.14)%22%20stroke-width%3D%221.6%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
const waveTileLight =
'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22240%22%20height%3D%22120%22%20viewBox%3D%220%200%20240%20120%22%3E%3Cg%20fill%3D%22none%22%20stroke-linecap%3D%22round%22%3E%3Cpath%20d%3D%22M0%2034%20L5%2031.41%20L10%2029%20L15%2026.93%20L20%2025.34%20L25%2024.34%20L30%2024%20L35%2024.34%20L40%2025.34%20L45%2026.93%20L50%2029%20L55%2031.41%20L60%2034%20L65%2036.59%20L70%2039%20L75%2041.07%20L80%2042.66%20L85%2043.66%20L90%2044%20L95%2043.66%20L100%2042.66%20L105%2041.07%20L110%2039%20L115%2036.59%20L120%2034%20L125%2031.41%20L130%2029%20L135%2026.93%20L140%2025.34%20L145%2024.34%20L150%2024%20L155%2024.34%20L160%2025.34%20L165%2026.93%20L170%2029%20L175%2031.41%20L180%2034%20L185%2036.59%20L190%2039%20L195%2041.07%20L200%2042.66%20L205%2043.66%20L210%2044%20L215%2043.66%20L220%2042.66%20L225%2041.07%20L230%2039%20L235%2036.59%20L240%2034%22%20stroke%3D%22oklch(0.62%200.045%20235%20%2F%200.16)%22%20stroke-width%3D%221.5%22%2F%3E%3Cpath%20d%3D%22M0%2064%20L5%2062.19%20L10%2060.5%20L15%2059.05%20L20%2057.94%20L25%2057.24%20L30%2057%20L35%2057.24%20L40%2057.94%20L45%2059.05%20L50%2060.5%20L55%2062.19%20L60%2064%20L65%2065.81%20L70%2067.5%20L75%2068.95%20L80%2070.06%20L85%2070.76%20L90%2071%20L95%2070.76%20L100%2070.06%20L105%2068.95%20L110%2067.5%20L115%2065.81%20L120%2064%20L125%2062.19%20L130%2060.5%20L135%2059.05%20L140%2057.94%20L145%2057.24%20L150%2057%20L155%2057.24%20L160%2057.94%20L165%2059.05%20L170%2060.5%20L175%2062.19%20L180%2064%20L185%2065.81%20L190%2067.5%20L195%2068.95%20L200%2070.06%20L205%2070.76%20L210%2071%20L215%2070.76%20L220%2070.06%20L225%2068.95%20L230%2067.5%20L235%2065.81%20L240%2064%22%20stroke%3D%22oklch(0.66%200.04%20240%20%2F%200.11)%22%20stroke-width%3D%221.2%22%2F%3E%3Cpath%20d%3D%22M0%2094%20L5%2090.89%20L10%2088%20L15%2085.51%20L20%2083.61%20L25%2082.41%20L30%2082%20L35%2082.41%20L40%2083.61%20L45%2085.51%20L50%2088%20L55%2090.89%20L60%2094%20L65%2097.11%20L70%20100%20L75%20102.49%20L80%20104.39%20L85%20105.59%20L90%20106%20L95%20105.59%20L100%20104.39%20L105%20102.49%20L110%20100%20L115%2097.11%20L120%2094%20L125%2090.89%20L130%2088%20L135%2085.51%20L140%2083.61%20L145%2082.41%20L150%2082%20L155%2082.41%20L160%2083.61%20L165%2085.51%20L170%2088%20L175%2090.89%20L180%2094%20L185%2097.11%20L190%20100%20L195%20102.49%20L200%20104.39%20L205%20105.59%20L210%20106%20L215%20105.59%20L220%20104.39%20L225%20102.49%20L230%20100%20L235%2097.11%20L240%2094%22%20stroke%3D%22oklch(0.60%200.05%20230%20%2F%200.14)%22%20stroke-width%3D%221.6%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
const dark: CSSProperties = {
// Deep ink-blue base — the "water" the swell floats on.
backgroundColor: 'oklch(0.19 0.03 245)',
backgroundImage: [
// Primary swell — teal/aqua sine contours.
waveTileDark,
// Offset echo — same tile shifted half a period, dimmed, to weave rows.
waveTileDark,
// Depth wash — subtle lift toward the top, sink toward the bottom.
'linear-gradient(180deg, oklch(0.24 0.04 240 / 0.5) 0%, oklch(0.16 0.025 250 / 0.5) 100%)',
].join(','),
backgroundSize: '240px 120px, 240px 120px, 100% 100%',
backgroundPosition: '0 0, 120px 60px, 0 0',
// Dim the offset echo layer relative to the primary swell.
backgroundBlendMode: 'normal, soft-light, normal',
};
const light: CSSProperties = {
// Soft warm white base — like sunlit paper or pale sand.
backgroundColor: 'oklch(0.975 0.006 240)',
backgroundImage: [
// Primary swell — pale blue-grey sine contours.
waveTileLight,
// Offset echo — same tile shifted half a period, dimmed, to weave rows.
waveTileLight,
// Depth wash — faint cool tint feathering toward the bottom for calm depth.
'linear-gradient(180deg, oklch(0.99 0.004 240 / 0.5) 0%, oklch(0.95 0.01 245 / 0.5) 100%)',
].join(','),
backgroundSize: '240px 120px, 240px 120px, 100% 100%',
backgroundPosition: '0 0, 120px 60px, 0 0',
// Dim the offset echo layer relative to the primary swell.
backgroundBlendMode: 'normal, multiply, normal',
};
export const waves: ChatBgVariants = { dark, light };
+67 -413
View File
@@ -1,12 +1,24 @@
import { CSSProperties } from 'react';
import { ChatBackground } from '../../state/settings';
import {
animRainKeyframe,
animStarsDriftKeyframe,
animGridPulseKeyframe,
animAuroraKeyframe,
animFirefliesKeyframe,
} from '../../styles/Animations.css';
import { blueprint } from './backgrounds/blueprint';
import { stars } from './backgrounds/stars';
import { topographic } from './backgrounds/topographic';
import { herringbone } from './backgrounds/herringbone';
import { crosshatch } from './backgrounds/crosshatch';
import { chevron } from './backgrounds/chevron';
import { polka } from './backgrounds/polka';
import { triangles } from './backgrounds/triangles';
import { plaid } from './backgrounds/plaid';
import { tactical } from './backgrounds/tactical';
import { circuit } from './backgrounds/circuit';
import { hexgrid } from './backgrounds/hexgrid';
import { waves } from './backgrounds/waves';
import { neon } from './backgrounds/neon';
import { animRain } from './backgrounds/animRain';
import { animStars } from './backgrounds/animStars';
import { animPulse } from './backgrounds/animPulse';
import { animAurora } from './backgrounds/animAurora';
import { animFireflies } from './backgrounds/animFireflies';
export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
{ value: 'none', label: 'None' },
@@ -33,20 +45,14 @@ export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
{ value: 'anim-fireflies', label: 'Fireflies' },
];
// `none`, `carbon` and `aurora` stay inline: carbon + aurora are the kept user
// favorites, none is the empty layer. Every other background is a premium
// per-pattern module under ./backgrounds/ (each exposes a `dark` + `light`
// variant). Keeping the whole record here lets getChatBg stay the single entry
// point and preserves the Record<ChatBackground, ...> exhaustiveness check.
const DARK: Record<ChatBackground, CSSProperties> = {
none: {},
blueprint: {
backgroundColor: '#0a1628',
backgroundImage: [
'linear-gradient(rgba(100,149,237,0.14) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(100,149,237,0.14) 1px, transparent 1px)',
'linear-gradient(rgba(100,149,237,0.05) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(100,149,237,0.05) 1px, transparent 1px)',
].join(','),
backgroundSize: '80px 80px, 80px 80px, 16px 16px, 16px 16px',
},
carbon: {
backgroundColor: '#0e0e0e',
backgroundImage: [
@@ -55,138 +61,6 @@ const DARK: Record<ChatBackground, CSSProperties> = {
].join(','),
backgroundSize: '8px 8px',
},
stars: {
backgroundColor: '#050510',
backgroundImage: [
'radial-gradient(circle, rgba(255,255,255,0.85) 1px, transparent 1px)',
'radial-gradient(circle, rgba(255,255,255,0.55) 1px, transparent 1px)',
'radial-gradient(circle, rgba(200,200,255,0.3) 1px, transparent 1px)',
].join(','),
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
backgroundPosition: '0 0, 65px 32px, 32px 97px',
},
topographic: {
backgroundColor: '#0f0f17',
backgroundImage: [
'repeating-radial-gradient(circle at 20% 20%, transparent 0, transparent 30px, rgba(152,0,0,0.07) 31px, transparent 32px)',
'repeating-radial-gradient(circle at 80% 80%, transparent 0, transparent 25px, rgba(100,100,200,0.06) 26px, transparent 27px)',
'repeating-radial-gradient(circle at 50% 10%, transparent 0, transparent 45px, rgba(152,0,0,0.04) 46px, transparent 47px)',
].join(','),
},
herringbone: {
backgroundColor: '#111118',
backgroundImage: [
'repeating-linear-gradient(60deg, rgba(180,160,210,0.08) 0, rgba(180,160,210,0.08) 1px, transparent 0, transparent 50%)',
'repeating-linear-gradient(120deg, rgba(180,160,210,0.08) 0, rgba(180,160,210,0.08) 1px, transparent 0, transparent 50%)',
].join(','),
backgroundSize: '20px 36px',
},
crosshatch: {
backgroundColor: '#0f0f0f',
backgroundImage: [
'linear-gradient(rgba(255,255,255,0.06) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(255,255,255,0.06) 1px, transparent 1px)',
'linear-gradient(rgba(255,255,255,0.022) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(255,255,255,0.022) 1px, transparent 1px)',
].join(','),
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
},
// Interlocking zigzag stripes
chevron: {
backgroundColor: '#0f0f17',
backgroundImage: [
'linear-gradient(135deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
'linear-gradient(225deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
'linear-gradient(315deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
'linear-gradient(45deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
].join(','),
backgroundSize: '20px 20px',
},
// Even dot grid
polka: {
backgroundColor: '#0e0e14',
backgroundImage: 'radial-gradient(circle, rgba(255,255,255,0.2) 2px, transparent 2px)',
backgroundSize: '28px 28px',
},
// Isometric triangle grid
triangles: {
backgroundColor: '#111118',
backgroundImage: [
'linear-gradient(60deg, rgba(100,149,237,0.09) 25%, transparent 25%, transparent 75%, rgba(100,149,237,0.09) 75%)',
'linear-gradient(120deg, rgba(100,149,237,0.09) 25%, transparent 25%, transparent 75%, rgba(100,149,237,0.09) 75%)',
].join(','),
backgroundSize: '40px 70px',
backgroundPosition: '0 0, 20px 35px',
},
// Tartan-inspired crossing lines with accent colour
plaid: {
backgroundColor: '#0a1020',
backgroundImage: [
'repeating-linear-gradient(0deg, transparent, transparent 39px, rgba(100,149,237,0.13) 39px, rgba(100,149,237,0.13) 40px)',
'repeating-linear-gradient(90deg, transparent, transparent 39px, rgba(100,149,237,0.13) 39px, rgba(100,149,237,0.13) 40px)',
'repeating-linear-gradient(0deg, transparent, transparent 7px, rgba(152,0,0,0.08) 7px, rgba(152,0,0,0.08) 8px)',
'repeating-linear-gradient(90deg, transparent, transparent 7px, rgba(152,0,0,0.08) 7px, rgba(152,0,0,0.08) 8px)',
].join(','),
},
// LotusGuild TDS exact dot-grid
tactical: {
backgroundColor: '#030508',
backgroundImage: 'radial-gradient(circle, rgba(0,212,255,0.055) 1px, transparent 1px)',
backgroundSize: '28px 28px',
},
// Circuit board — green grid with node dots
circuit: {
backgroundColor: '#040a04',
backgroundImage: [
'linear-gradient(rgba(0,255,136,0.045) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,255,136,0.045) 1px, transparent 1px)',
'radial-gradient(circle, rgba(0,255,136,0.20) 1.5px, transparent 1.5px)',
].join(','),
backgroundSize: '40px 40px, 40px 40px, 40px 40px',
backgroundPosition: '0 0, 0 0, 20px 20px',
},
// True pointy-top hexagonal grid via SVG data URI
hexgrid: {
backgroundColor: '#060c14',
backgroundImage:
'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2229%22%20height%3D%2250%22%3E%3Cpath%20d%3D%22M14.5%200L29%208L29%2025L14.5%2033L0%2025L0%208Z%20M14.5%2033L29%2041V50%20M14.5%2033L0%2041V50%22%20fill%3D%22none%22%20stroke%3D%22rgba%280%2C212%2C255%2C0.13%29%22%20stroke-width%3D%220.8%22/%3E%3C/svg%3E")',
backgroundSize: '29px 50px',
},
// Flowing sine-wave lines
waves: {
backgroundColor: '#080c18',
backgroundImage: [
'repeating-radial-gradient(ellipse at 0% 50%, transparent 0, transparent 18px, rgba(80,130,255,0.07) 19px, transparent 20px)',
'repeating-radial-gradient(ellipse at 100% 50%, transparent 0, transparent 28px, rgba(80,130,255,0.05) 29px, transparent 30px)',
'repeating-radial-gradient(ellipse at 50% 0%, transparent 0, transparent 22px, rgba(100,60,200,0.06) 23px, transparent 24px)',
].join(','),
},
// Neon cyberpunk grid — orange/cyan TDS colors
neon: {
backgroundColor: '#020408',
backgroundImage: [
'linear-gradient(rgba(255,107,0,0.10) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(255,107,0,0.10) 1px, transparent 1px)',
'linear-gradient(rgba(0,212,255,0.05) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,212,255,0.05) 1px, transparent 1px)',
].join(','),
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
},
// Aurora borealis — flowing gradient bands
aurora: {
backgroundColor: '#030810',
backgroundImage: [
@@ -197,86 +71,30 @@ const DARK: Record<ChatBackground, CSSProperties> = {
].join(','),
},
// Animated: Matrix digital rain — scrolling stripe columns + phosphor glow flicker
'anim-rain': {
backgroundColor: '#010804',
backgroundImage: [
'repeating-linear-gradient(180deg, rgba(0,255,136,0.16) 0px, rgba(0,255,136,0.16) 1px, transparent 1px, transparent 20px)',
'repeating-linear-gradient(180deg, rgba(0,255,136,0.07) 0px, rgba(0,255,136,0.07) 1px, transparent 1px, transparent 8px)',
].join(','),
backgroundSize: '40px 200px, 12px 200px',
backgroundPosition: '0 0, 0 0',
animation: `${animRainKeyframe} 8s linear infinite`,
},
// Animated: drifting star field — three seamlessly-tiling layers at different speeds
'anim-stars': {
backgroundColor: '#050510',
backgroundImage: [
'radial-gradient(circle, rgba(255,255,255,0.85) 1px, transparent 1px)',
'radial-gradient(circle, rgba(200,220,255,0.55) 1px, transparent 1px)',
'radial-gradient(circle, rgba(180,200,255,0.3) 1px, transparent 1px)',
].join(','),
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
backgroundPosition: '0 0, 65px 32px, 32px 97px',
animation: `${animStarsDriftKeyframe} 30s linear infinite`,
},
// Animated: neon grid pulse — size breathe + independent brightness oscillation
'anim-pulse': {
backgroundColor: '#030508',
backgroundImage: [
'linear-gradient(rgba(255,107,0,0.12) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(255,107,0,0.12) 1px, transparent 1px)',
'linear-gradient(rgba(0,212,255,0.06) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,212,255,0.06) 1px, transparent 1px)',
].join(','),
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
animation: `${animGridPulseKeyframe} 4s ease-in-out infinite`,
},
// Animated: aurora borealis — four bands each travel an independent path
'anim-aurora': {
backgroundColor: '#020a10',
backgroundImage: [
'radial-gradient(ellipse 80% 60% at 20% 30%, rgba(0,255,136,0.12) 0%, transparent 70%)',
'radial-gradient(ellipse 70% 50% at 80% 70%, rgba(0,100,255,0.12) 0%, transparent 70%)',
'radial-gradient(ellipse 90% 70% at 50% 10%, rgba(191,95,255,0.09) 0%, transparent 65%)',
'radial-gradient(ellipse 75% 55% at 60% 90%, rgba(0,212,255,0.09) 0%, transparent 65%)',
].join(','),
backgroundSize: '200% 200%, 250% 250%, 300% 300%, 220% 220%',
backgroundPosition: '0% 0%, 100% 0%, 50% 100%, 0% 50%',
animation: `${animAuroraKeyframe} 28s ease-in-out infinite`,
},
// Animated: fireflies — drift + brightness glow + opacity blink at prime periods
'anim-fireflies': {
backgroundColor: '#030508',
backgroundImage: [
'radial-gradient(circle, rgba(255,220,50,0.7) 1.5px, rgba(255,160,0,0.18) 3px, transparent 4px)',
'radial-gradient(circle, rgba(255,200,30,0.55) 1px, rgba(255,140,0,0.14) 2.5px, transparent 3.5px)',
'radial-gradient(circle, rgba(255,240,100,0.4) 1px, transparent 2px)',
].join(','),
backgroundSize: '200px 200px, 280px 280px, 160px 160px',
backgroundPosition: '0 0, 120px 80px, 60px 140px',
animation: `${animFirefliesKeyframe} 30s linear infinite`,
},
blueprint: blueprint.dark,
stars: stars.dark,
topographic: topographic.dark,
herringbone: herringbone.dark,
crosshatch: crosshatch.dark,
chevron: chevron.dark,
polka: polka.dark,
triangles: triangles.dark,
plaid: plaid.dark,
tactical: tactical.dark,
circuit: circuit.dark,
hexgrid: hexgrid.dark,
waves: waves.dark,
neon: neon.dark,
'anim-rain': animRain.dark,
'anim-stars': animStars.dark,
'anim-pulse': animPulse.dark,
'anim-aurora': animAurora.dark,
'anim-fireflies': animFireflies.dark,
};
const LIGHT: Record<ChatBackground, CSSProperties> = {
none: {},
blueprint: {
backgroundColor: '#eef3ff',
backgroundImage: [
'linear-gradient(rgba(50,100,220,0.16) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(50,100,220,0.16) 1px, transparent 1px)',
'linear-gradient(rgba(50,100,220,0.06) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(50,100,220,0.06) 1px, transparent 1px)',
].join(','),
backgroundSize: '80px 80px, 80px 80px, 16px 16px, 16px 16px',
},
carbon: {
backgroundColor: '#efefef',
backgroundImage: [
@@ -285,129 +103,6 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
].join(','),
backgroundSize: '8px 8px',
},
// Stars is intentionally always dark — it's a night-sky theme
stars: {
backgroundColor: '#050510',
backgroundImage: [
'radial-gradient(circle, rgba(255,255,255,0.85) 1px, transparent 1px)',
'radial-gradient(circle, rgba(255,255,255,0.55) 1px, transparent 1px)',
'radial-gradient(circle, rgba(200,200,255,0.3) 1px, transparent 1px)',
].join(','),
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
backgroundPosition: '0 0, 65px 32px, 32px 97px',
},
topographic: {
backgroundColor: '#faf8f5',
backgroundImage: [
'repeating-radial-gradient(circle at 20% 20%, transparent 0, transparent 30px, rgba(100,60,60,0.09) 31px, transparent 32px)',
'repeating-radial-gradient(circle at 80% 80%, transparent 0, transparent 25px, rgba(60,60,130,0.07) 26px, transparent 27px)',
'repeating-radial-gradient(circle at 50% 10%, transparent 0, transparent 45px, rgba(100,60,60,0.05) 46px, transparent 47px)',
].join(','),
},
herringbone: {
backgroundColor: '#f9f9f9',
backgroundImage: [
'repeating-linear-gradient(60deg, rgba(80,70,110,0.09) 0, rgba(80,70,110,0.09) 1px, transparent 0, transparent 50%)',
'repeating-linear-gradient(120deg, rgba(80,70,110,0.09) 0, rgba(80,70,110,0.09) 1px, transparent 0, transparent 50%)',
].join(','),
backgroundSize: '20px 36px',
},
crosshatch: {
backgroundColor: '#ffffff',
backgroundImage: [
'linear-gradient(rgba(0,0,0,0.07) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,0,0,0.07) 1px, transparent 1px)',
'linear-gradient(rgba(0,0,0,0.025) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,0,0,0.025) 1px, transparent 1px)',
].join(','),
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
},
chevron: {
backgroundColor: '#f9f8ff',
backgroundImage: [
'linear-gradient(135deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
'linear-gradient(225deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
'linear-gradient(315deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
'linear-gradient(45deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
].join(','),
backgroundSize: '20px 20px',
},
polka: {
backgroundColor: '#fafafa',
backgroundImage: 'radial-gradient(circle, rgba(0,0,0,0.18) 2px, transparent 2px)',
backgroundSize: '28px 28px',
},
triangles: {
backgroundColor: '#f4f7ff',
backgroundImage: [
'linear-gradient(60deg, rgba(50,100,220,0.1) 25%, transparent 25%, transparent 75%, rgba(50,100,220,0.1) 75%)',
'linear-gradient(120deg, rgba(50,100,220,0.1) 25%, transparent 25%, transparent 75%, rgba(50,100,220,0.1) 75%)',
].join(','),
backgroundSize: '40px 70px',
backgroundPosition: '0 0, 20px 35px',
},
plaid: {
backgroundColor: '#f5f0ff',
backgroundImage: [
'repeating-linear-gradient(0deg, transparent, transparent 39px, rgba(100,50,180,0.15) 39px, rgba(100,50,180,0.15) 40px)',
'repeating-linear-gradient(90deg, transparent, transparent 39px, rgba(100,50,180,0.15) 39px, rgba(100,50,180,0.15) 40px)',
'repeating-linear-gradient(0deg, transparent, transparent 7px, rgba(200,0,0,0.09) 7px, rgba(200,0,0,0.09) 8px)',
'repeating-linear-gradient(90deg, transparent, transparent 7px, rgba(200,0,0,0.09) 7px, rgba(200,0,0,0.09) 8px)',
].join(','),
},
tactical: {
backgroundColor: '#f0f4fa',
backgroundImage: 'radial-gradient(circle, rgba(0,100,200,0.08) 1px, transparent 1px)',
backgroundSize: '28px 28px',
},
circuit: {
backgroundColor: '#f0f8f0',
backgroundImage: [
'linear-gradient(rgba(0,160,80,0.06) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,160,80,0.06) 1px, transparent 1px)',
'radial-gradient(circle, rgba(0,160,80,0.22) 1.5px, transparent 1.5px)',
].join(','),
backgroundSize: '40px 40px, 40px 40px, 40px 40px',
backgroundPosition: '0 0, 0 0, 20px 20px',
},
hexgrid: {
backgroundColor: '#f4f8ff',
backgroundImage:
'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2229%22%20height%3D%2250%22%3E%3Cpath%20d%3D%22M14.5%200L29%208L29%2025L14.5%2033L0%2025L0%208Z%20M14.5%2033L29%2041V50%20M14.5%2033L0%2041V50%22%20fill%3D%22none%22%20stroke%3D%22rgba%2850%2C100%2C220%2C0.11%29%22%20stroke-width%3D%220.8%22/%3E%3C/svg%3E")',
backgroundSize: '29px 50px',
},
waves: {
backgroundColor: '#eef3ff',
backgroundImage: [
'repeating-radial-gradient(ellipse at 0% 50%, transparent 0, transparent 18px, rgba(50,100,220,0.09) 19px, transparent 20px)',
'repeating-radial-gradient(ellipse at 100% 50%, transparent 0, transparent 28px, rgba(50,100,220,0.07) 29px, transparent 30px)',
'repeating-radial-gradient(ellipse at 50% 0%, transparent 0, transparent 22px, rgba(80,40,180,0.07) 23px, transparent 24px)',
].join(','),
},
neon: {
backgroundColor: '#fafafa',
backgroundImage: [
'linear-gradient(rgba(196,78,0,0.12) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(196,78,0,0.12) 1px, transparent 1px)',
'linear-gradient(rgba(0,98,184,0.06) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,98,184,0.06) 1px, transparent 1px)',
].join(','),
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
},
aurora: {
backgroundColor: '#f4faf8',
backgroundImage: [
@@ -418,78 +113,37 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
].join(','),
},
// Animated light variants
'anim-rain': {
backgroundColor: '#f0fff4',
backgroundImage: [
'repeating-linear-gradient(180deg, rgba(0,160,80,0.16) 0px, rgba(0,160,80,0.16) 1px, transparent 1px, transparent 20px)',
'repeating-linear-gradient(180deg, rgba(0,160,80,0.07) 0px, rgba(0,160,80,0.07) 1px, transparent 1px, transparent 8px)',
].join(','),
backgroundSize: '40px 200px, 12px 200px',
backgroundPosition: '0 0, 0 0',
animation: `${animRainKeyframe} 8s linear infinite`,
},
'anim-stars': {
backgroundColor: '#f5f5ff',
backgroundImage: [
'radial-gradient(circle, rgba(60,60,160,0.50) 1px, transparent 1px)',
'radial-gradient(circle, rgba(80,80,180,0.35) 1px, transparent 1px)',
'radial-gradient(circle, rgba(100,100,200,0.20) 1px, transparent 1px)',
].join(','),
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
backgroundPosition: '0 0, 65px 32px, 32px 97px',
animation: `${animStarsDriftKeyframe} 30s linear infinite`,
},
'anim-pulse': {
backgroundColor: '#ffffff',
backgroundImage: [
'linear-gradient(rgba(0,98,184,0.14) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,98,184,0.14) 1px, transparent 1px)',
'linear-gradient(rgba(0,98,184,0.06) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,98,184,0.06) 1px, transparent 1px)',
].join(','),
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
animation: `${animGridPulseKeyframe} 4s ease-in-out infinite`,
},
'anim-aurora': {
backgroundColor: '#f0f8f4',
backgroundImage: [
'radial-gradient(ellipse 80% 60% at 20% 30%, rgba(0,160,80,0.13) 0%, transparent 70%)',
'radial-gradient(ellipse 70% 50% at 80% 70%, rgba(0,80,200,0.13) 0%, transparent 70%)',
'radial-gradient(ellipse 90% 70% at 50% 10%, rgba(140,60,220,0.10) 0%, transparent 65%)',
'radial-gradient(ellipse 75% 55% at 60% 90%, rgba(0,160,200,0.10) 0%, transparent 65%)',
].join(','),
backgroundSize: '200% 200%, 250% 250%, 300% 300%, 220% 220%',
backgroundPosition: '0% 0%, 100% 0%, 50% 100%, 0% 50%',
animation: `${animAuroraKeyframe} 28s ease-in-out infinite`,
},
'anim-fireflies': {
backgroundColor: '#fffdf0',
backgroundImage: [
'radial-gradient(circle, rgba(180,120,0,0.70) 1.5px, rgba(160,90,0,0.18) 3px, transparent 4px)',
'radial-gradient(circle, rgba(160,100,0,0.55) 1px, rgba(140,80,0,0.14) 2.5px, transparent 3.5px)',
'radial-gradient(circle, rgba(200,140,0,0.40) 1px, transparent 2px)',
].join(','),
backgroundSize: '200px 200px, 280px 280px, 160px 160px',
backgroundPosition: '0 0, 120px 80px, 60px 140px',
animation: `${animFirefliesKeyframe} 30s linear infinite`,
},
blueprint: blueprint.light,
stars: stars.light,
topographic: topographic.light,
herringbone: herringbone.light,
crosshatch: crosshatch.light,
chevron: chevron.light,
polka: polka.light,
triangles: triangles.light,
plaid: plaid.light,
tactical: tactical.light,
circuit: circuit.light,
hexgrid: hexgrid.light,
waves: waves.light,
neon: neon.light,
'anim-rain': animRain.light,
'anim-stars': animStars.light,
'anim-pulse': animPulse.light,
'anim-aurora': animAurora.light,
'anim-fireflies': animFireflies.light,
};
export const getChatBg = (
bg: ChatBackground,
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 => {
const style = isDark ? DARK[bg] : LIGHT[bg];
const reducedMotion =
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if ((pauseAnimations || reducedMotion) && style.animation) {
if (suppressAnimation && style.animation) {
const { animation: _anim, ...rest } = style;
return rest;
}
@@ -11,6 +11,8 @@ import {
Line,
toRem,
Button,
Switch,
Chip,
} from 'folds';
import { useAtom, useAtomValue } from 'jotai';
import { useVirtualizer } from '@tanstack/react-virtual';
@@ -41,7 +43,9 @@ import {
ResultGroup,
useMessageSearch,
} from './useMessageSearch';
import { useLocalMessageSearch } from './useLocalMessageSearch';
import { LocalSearchResult, useLocalMessageSearch } from './useLocalMessageSearch';
import { searchCacheEnabledAtom } from '../../state/searchCacheEnabled';
import { clearAll as clearSearchCache } from '../../utils/searchCache';
import { addRecentSearch, recentSearchesAtom } from '../../state/recentSearches';
import { SearchResultGroup } from './SearchResultGroup';
import { SearchInput } from './SearchInput';
@@ -240,6 +244,10 @@ export function MessageSearch({
// Bump this whenever more messages are loaded so localResult re-computes
const [cacheVersion, setCacheVersion] = useState(0);
const handleCacheLoaded = useCallback(() => setCacheVersion((v) => v + 1), []);
// Explicit wipe of the persistent on-disk index, then re-run the merge.
const handleClearSearchCache = useCallback(() => {
clearSearchCache().then(() => setCacheVersion((v) => v + 1));
}, []);
// The rooms actually in scope for this search (mirrors server-side logic)
const localSearchRooms = useMemo(
@@ -253,24 +261,43 @@ export function MessageSearch({
const hasActiveSearch = msgSearchParams.term !== undefined || !!msgSearchParams.senders?.length;
const senderOnlyMode = !msgSearchParams.term && !!msgSearchParams.senders?.length;
// Run synchronous client-side search immediately.
// Run the client-side search whenever inputs change.
// In text-search mode: covers encrypted rooms only (server handles plaintext).
// In sender-only mode: covers all rooms (server has no sender-only search).
// cacheVersion in deps so it re-runs after "Load more" paginates new events.
const localResult = useMemo(() => {
if (!hasActiveSearch) return null;
return searchLocalMessages({
// The scan is async because — when the persistent cache is enabled — it also
// reads cached rows from IndexedDB and merges them with the in-memory hits.
// cacheVersion in deps so it re-runs after "Load more" paginates new events;
// searchCacheEnabled so toggling the cache re-runs the merge.
const [searchCacheEnabled, setSearchCacheEnabled] = useAtom(searchCacheEnabledAtom);
const [localResult, setLocalResult] = useState<LocalSearchResult | null>(null);
useEffect(() => {
if (!hasActiveSearch) {
setLocalResult(null);
return undefined;
}
let cancelled = false;
searchLocalMessages({
term: msgSearchParams.term ?? '',
roomIds: localSearchRooms,
senders: msgSearchParams.senders,
fromTs: msgSearchParams.fromTs,
toTs: msgSearchParams.toTs,
}).then((result) => {
if (!cancelled) setLocalResult(result);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
return () => {
cancelled = true;
};
}, [
searchLocalMessages,
localSearchRooms,
msgSearchParams.term,
msgSearchParams.senders,
msgSearchParams.fromTs,
msgSearchParams.toTs,
hasActiveSearch,
cacheVersion,
searchCacheEnabled,
]);
const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
@@ -668,6 +695,37 @@ export function MessageSearch({
? `Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Load more history below to extend coverage.`
: `No matches in your local cache. Load messages below to search further back.`}
</Text>
<Box
alignItems="Center"
gap="200"
style={{
padding: config.space.S200,
background: color.SurfaceVariant.Container,
borderRadius: config.radii.R300,
}}
>
<Switch
variant="Primary"
value={searchCacheEnabled}
onChange={setSearchCacheEnabled}
/>
<Box grow="Yes" direction="Column" style={{ minWidth: 0 }}>
<Text size="T300">Persist search index on this device</Text>
<Text size="T200" priority="300">
Stores decrypted text on this device
</Text>
</Box>
{searchCacheEnabled && (
<Chip
variant="Secondary"
radii="Pill"
onClick={handleClearSearchCache}
before={<Icon size="100" src={Icons.Delete} />}
>
<Text size="T200">Clear cached index</Text>
</Chip>
)}
</Box>
<Line size="300" variant="Surface" />
</Box>
{localGroups.length > 0 && (
@@ -1,12 +1,23 @@
import { EventType } from 'matrix-js-sdk';
import { EventType, MatrixEvent } from 'matrix-js-sdk';
import { useCallback } from 'react';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { ResultGroup, ResultItem } from './useMessageSearch';
import { searchCacheEnabledAtom } from '../../state/searchCacheEnabled';
import {
mergeSearchResults,
queryRoom,
saveRoomIndex,
SearchCacheRow,
} from '../../utils/searchCache';
export type LocalSearchParams = {
term: string;
roomIds: string[];
senders?: string[];
/** Optional date-range filter (ms). Applied to both memory and cached rows. */
fromTs?: number;
toTs?: number;
};
export type LocalSearchResult = {
@@ -17,19 +28,110 @@ export type LocalSearchResult = {
searchedRoomsCount: number;
};
/** Extracted, searchable plaintext for a single message event. */
type ExtractedText = {
body: string;
formattedBody: string;
pollText: string;
};
const POLL_START_TYPES = ['m.poll.start', 'org.matrix.msc3381.poll.start'];
/**
* Pull the text we index/search from a decrypted event's content. Returns
* `null` for events that carry no searchable text (e.g. stickers).
*/
const extractText = (event: MatrixEvent): ExtractedText | null => {
const evType = event.getType();
const content = event.getContent();
if (POLL_START_TYPES.includes(evType)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const poll = (content['m.poll'] ?? content['org.matrix.msc3381.poll.start']) as any;
if (!poll) return null;
const qBody =
(poll.question?.['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
(poll.question?.body as string | undefined) ??
'';
const answerBodies = ((poll.answers ?? []) as Array<Record<string, unknown>>)
.map(
(a) =>
((a['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(a['org.matrix.msc3381.poll.answer'] as any)?.body ??
'') as string,
)
.join(' ');
const pollText = `${qBody} ${answerBodies}`.trim();
return pollText ? { body: '', formattedBody: '', pollText } : null;
}
if (evType !== EventType.RoomMessage) return null;
const body = (content.body as string | undefined) ?? '';
const formattedBody = (content.formatted_body as string | undefined) ?? '';
if (!body && !formattedBody) return null;
return { body, formattedBody, pollText: '' };
};
/** Does the extracted text contain the (already-lowercased) term? */
const matchesTerm = (text: ExtractedText, termLower: string): boolean =>
text.body.toLowerCase().includes(termLower) ||
text.formattedBody.toLowerCase().includes(termLower) ||
text.pollText.toLowerCase().includes(termLower);
const rowMatchesTerm = (row: SearchCacheRow, termLower: string): boolean =>
row.body.toLowerCase().includes(termLower) ||
(row.formattedBody ?? '').toLowerCase().includes(termLower) ||
(row.pollText ?? '').toLowerCase().includes(termLower);
/** Build the synthetic result item a cached row renders as (text message). */
const rowToResultItem = (row: SearchCacheRow): ResultItem => {
const bodyText = row.body || row.pollText || '';
const content: Record<string, unknown> = { msgtype: 'm.text', body: bodyText };
if (row.formattedBody) {
content.format = 'org.matrix.custom.html';
content.formatted_body = row.formattedBody;
}
const syntheticEvent = {
room_id: row.roomId,
event_id: row.eventId,
type: EventType.RoomMessage,
sender: row.sender,
origin_server_ts: row.ts,
content,
unsigned: {},
};
return {
rank: 0,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event: syntheticEvent as any,
context: { events_before: [], events_after: [], profile_info: {} },
};
};
/**
* Client-side full-text search over locally cached events in encrypted rooms.
* The homeserver cannot search E2EE message content, so we scan whatever the
* client has already received and decrypted in memory.
*
* Limitation: only messages present in the live timeline window are covered.
* Rooms that haven't been opened yet will return no results.
* When the persistent search cache is enabled (opt-in), the in-memory scan is
* also persisted to IndexedDB (fire-and-forget) and merged with prior cached
* coverage so results survive reloads. When disabled, zero cache reads/writes
* occur.
*/
export const useLocalMessageSearch = () => {
const mx = useMatrixClient();
const cacheEnabled = useAtomValue(searchCacheEnabledAtom);
const search = useCallback(
({ term, roomIds, senders }: LocalSearchParams): LocalSearchResult => {
async ({
term,
roomIds,
senders,
fromTs,
toTs,
}: LocalSearchParams): Promise<LocalSearchResult> => {
const trimmedTerm = term.trim();
const senderSet = senders && senders.length > 0 ? new Set(senders) : null;
@@ -41,6 +143,9 @@ export const useLocalMessageSearch = () => {
}
const termLower = trimmedTerm.toLowerCase();
const inRange = (ts: number): boolean =>
(fromTs === undefined || ts >= fromTs) && (toTs === undefined || ts <= toTs);
const groups: ResultGroup[] = [];
let encryptedRoomsCount = 0;
let searchedRoomsCount = 0;
@@ -61,106 +166,99 @@ export const useLocalMessageSearch = () => {
.getUnfilteredTimelineSet()
.getTimelines()
.flatMap((tl) => tl.getEvents());
if (events.length === 0) continue;
// eslint-disable-next-line no-await-in-loop
const cachedRows = cacheEnabled ? await queryRoom(roomId) : [];
if (events.length === 0 && cachedRows.length === 0) continue;
searchedRoomsCount += 1;
const items: ResultItem[] = [];
const memoryItems: ResultItem[] = [];
const rowsToPersist: SearchCacheRow[] = [];
for (let i = 0; i < events.length; i += 1) {
const event = events[i];
// In sender-only mode: include all message types; skip non-message events
if (event.getType() !== EventType.RoomMessage) {
if (senderOnlyMode) continue;
const evType = event.getType();
const isSticker = evType === 'm.sticker';
const isPoll = evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
if (!isSticker && !isPoll) continue;
}
if (event.isDecryptionFailure()) continue;
if (event.isRedacted()) continue;
if (senderSet && !senderSet.has(event.getSender() ?? '')) continue;
// getContent() returns decrypted plaintext regardless of encryption
const content = event.getContent();
const evType = event.getType();
const isSticker = evType === 'm.sticker';
const isMessageLike =
evType === EventType.RoomMessage || POLL_START_TYPES.includes(evType);
// Sender-only mode: no text filter needed
if (!senderOnlyMode) {
const evType = event.getType();
const isPoll = evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
// Sender-only mode indexes/returns all message types; text mode needs text.
if (!senderOnlyMode && !isMessageLike && !isSticker) continue;
let body = '';
let formattedBody = '';
if (!isPoll) {
body = (content.body as string | undefined) ?? '';
formattedBody = (content.formatted_body as string | undefined) ?? '';
} else {
// Poll — index question text and all answer options
const poll = (content['m.poll'] ??
// eslint-disable-next-line @typescript-eslint/no-explicit-any
content['org.matrix.msc3381.poll.start']) as any;
if (poll) {
const qBody =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(poll.question?.['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
(poll.question?.body as string | undefined) ??
'';
const answerBodies = ((poll.answers ?? []) as Array<Record<string, unknown>>)
.map(
(a) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
((a['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(a['org.matrix.msc3381.poll.answer'] as any)?.body ??
'') as string,
)
.join(' ');
body = `${qBody} ${answerBodies}`.trim();
}
}
const sender = event.getSender() ?? '';
const ts = event.getTs();
const text = extractText(event);
if (
!body.toLowerCase().includes(termLower) &&
!formattedBody.toLowerCase().includes(termLower)
)
continue;
// Persist every indexable (text-bearing) event we scanned, regardless
// of whether it matches the current term — future searches benefit.
if (cacheEnabled && text && event.getId()) {
rowsToPersist.push({
roomId,
eventId: event.getId() as string,
ts,
sender,
body: text.body,
...(text.formattedBody ? { formattedBody: text.formattedBody } : {}),
...(text.pollText ? { pollText: text.pollText } : {}),
});
}
// Build a synthetic IEventWithRoomId using decrypted content so the
// existing SearchResultGroup renderer works without modification.
if (senderSet && !senderSet.has(sender)) continue;
if (!inRange(ts)) continue;
if (!senderOnlyMode) {
if (!text || !matchesTerm(text, termLower)) continue;
}
const content = event.getContent();
const syntheticEvent = {
room_id: roomId,
event_id: event.getId() ?? '',
type: event.getType(),
sender: event.getSender() ?? '',
origin_server_ts: event.getTs(),
type: evType,
sender,
origin_server_ts: ts,
content,
unsigned: event.getUnsigned(),
};
items.push({
memoryItems.push({
rank: 0,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event: syntheticEvent as any,
context: {
events_before: [],
events_after: [],
profile_info: {},
},
context: { events_before: [], events_after: [], profile_info: {} },
});
}
// Match cached rows (skip ids already present in memory happens in merge).
const cachedItems: ResultItem[] = [];
cachedRows.forEach((row) => {
if (senderSet && !senderSet.has(row.sender)) return;
if (!inRange(row.ts)) return;
if (!senderOnlyMode && !rowMatchesTerm(row, termLower)) return;
cachedItems.push(rowToResultItem(row));
});
const items = mergeSearchResults(memoryItems, cachedItems);
if (items.length > 0) {
items.sort((a, b) => (b.event.origin_server_ts ?? 0) - (a.event.origin_server_ts ?? 0));
groups.push({ roomId, items });
}
// Fire-and-forget persist of freshly scanned rows + coverage.
// saveRoomIndex swallows all errors internally, so a floating promise
// here can never reject.
if (cacheEnabled && rowsToPersist.length > 0) {
saveRoomIndex(roomId, rowsToPersist);
}
}
return { groups, encryptedRoomsCount, searchedRoomsCount };
},
[mx],
[mx, cacheEnabled],
);
return search;
+85 -19
View File
@@ -1,5 +1,5 @@
import React, { MouseEventHandler, forwardRef, useCallback, useRef, useState } from 'react';
import { Room } from 'matrix-js-sdk';
import { MatrixClient, Room } from 'matrix-js-sdk';
import {
Avatar,
Box,
@@ -38,6 +38,7 @@ import { nameInitials } from '../../utils/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { markedUnreadAtom, setMarkedUnread } from '../../state/room/markedUnread';
import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels';
import { markAsRead } from '../../utils/notifications';
import { UseStateProvider } from '../../components/UseStateProvider';
@@ -263,27 +264,46 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
}
// 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 {
return JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function saveMuteTimers(timers: MuteTimerEntry[]): void {
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
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 {
const unmuteAt = Date.now() + durationMs;
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
saveMuteTimers([...existing, { roomId, unmuteAt }]);
setTimeout(onUnmute, durationMs);
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
}
type RoomNavItemMenuProps = {
@@ -310,18 +330,39 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const isServerNotice = room.getType() === 'm.server_notice';
const isFavorite = !!room.tags?.['m.favourite'];
const isLowPriority = !!room.tags?.['m.lowpriority'];
const handleToggleFavorite = () => {
if (isFavorite) {
mx.deleteRoomTag(room.roomId, 'm.favourite');
} else {
// Favourite and low-priority are mutually exclusive.
if (isLowPriority) mx.deleteRoomTag(room.roomId, 'm.lowpriority');
mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 });
}
requestClose();
};
const handleToggleLowPriority = () => {
if (isLowPriority) {
mx.deleteRoomTag(room.roomId, 'm.lowpriority');
} else {
if (isFavorite) mx.deleteRoomTag(room.roomId, 'm.favourite');
mx.setRoomTag(room.roomId, 'm.lowpriority', { order: 0.5 });
}
requestClose();
};
const markedUnread = useAtomValue(markedUnreadAtom).has(room.roomId);
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
if (markedUnread) setMarkedUnread(mx, room.roomId, false).catch(() => undefined);
requestClose();
};
const handleMarkAsUnread = () => {
setMarkedUnread(mx, room.roomId, true).catch(() => undefined);
requestClose();
};
@@ -338,13 +379,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
).catch(() => {});
if (durationMs !== null) {
scheduleMuteTimer(room.roomId, durationMs, () => {
setRoomNotificationPreference(
mx,
room.roomId,
RoomNotificationMode.Unset,
RoomNotificationMode.Mute,
).catch(() => {});
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== room.roomId));
unmuteRoom(mx, room.roomId);
});
}
requestClose();
@@ -380,12 +415,23 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
disabled={!unread && !markedUnread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
<MenuItem
onClick={handleMarkAsUnread}
size="300"
after={<Icon size="100" src={Icons.MessageUnread} />}
radii="300"
disabled={!!unread || markedUnread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Unread
</Text>
</MenuItem>
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
{(handleOpen, opened, changing) => (
<MenuItem
@@ -480,6 +526,17 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
{isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}
</Text>
</MenuItem>
<MenuItem
onClick={handleToggleLowPriority}
size="300"
after={<Icon size="100" src={Icons.ChevronBottom} />}
radii="300"
aria-pressed={isLowPriority}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{isLowPriority ? 'Remove from Low Priority' : 'Add to Low Priority'}
</Text>
</MenuItem>
<MenuItem
onClick={handleInvite}
variant="Primary"
@@ -597,6 +654,10 @@ function RoomNavItem_({
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [renameDialog, setRenameDialog] = useState(false);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
// MSC2867: an explicit "mark as unread" lights the row even with no unread
// count. `hasUnread` drives the bold name / icon emphasis below.
const markedUnread = useAtomValue(markedUnreadAtom).has(room.roomId);
const hasUnread = !!unread || markedUnread;
const typingMember = useRoomTypingMember(room.roomId).filter(
(receipt) => receipt.userId !== mx.getUserId(),
);
@@ -679,7 +740,7 @@ function RoomNavItem_({
<NavItem
variant="Background"
radii="400"
highlight={unread !== undefined}
highlight={hasUnread}
aria-selected={selected}
data-hover={!!menuAnchor}
onContextMenu={handleContextMenu}
@@ -708,7 +769,7 @@ function RoomNavItem_({
) : (
<RoomIcon
style={{
opacity: unread ? config.opacity.P500 : config.opacity.P300,
opacity: hasUnread ? config.opacity.P500 : config.opacity.P300,
}}
filled={selected}
size="100"
@@ -719,7 +780,7 @@ function RoomNavItem_({
</Avatar>
<Box as="span" direction="Column" grow="Yes" style={{ minWidth: 0 }}>
<Box as="span" grow="Yes" alignItems="Center" gap="100">
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
<Text priority={hasUnread ? '500' : '300'} as="span" size="Inherit" truncate>
{roomName}
</Text>
{hasLocalName && (
@@ -760,7 +821,7 @@ function RoomNavItem_({
</Box>
)}
</Box>
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
{!optionsVisible && !hasUnread && !selected && typingMember.length > 0 && (
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
<TypingIndicator size="300" disableAnimation />
</Badge>
@@ -770,6 +831,11 @@ function RoomNavItem_({
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
</UnreadBadgeCenter>
)}
{!optionsVisible && !unread && markedUnread && (
<UnreadBadgeCenter>
<UnreadBadge highlight={false} count={0} />
</UnreadBadgeCenter>
)}
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
<Icon
size="50"
@@ -1,5 +1,5 @@
import React, { useCallback, useState } from 'react';
import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Spinner, Text } from 'folds';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Text } from 'folds';
import { EventType } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -16,6 +16,12 @@ const FORMAT_LABELS: Record<ExportFormat, string> = {
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 = {
requestClose: () => void;
};
@@ -30,11 +36,28 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
const [toDate, setToDate] = useState('');
const [exporting, setExporting] = useState(false);
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 () => {
if (exporting) return;
cancelledRef.current = false;
setExporting(true);
setExportCount(0);
setNotice('');
try {
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 timeline = room.getLiveTimeline();
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>) => {
for (const ev of events) {
@@ -70,12 +101,14 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
if (ev.getType() !== EventType.RoomMessage) continue;
if (ev.isDecryptionFailure()) continue;
const ts = ev.getTs();
if (ts < oldestRawTs) oldestRawTs = ts;
if (fromTs !== null && ts < fromTs) continue;
if (toTs !== null && ts > toTs) continue;
const content = ev.getContent();
const body: string = content.body ?? '';
const msgtype: string = content.msgtype ?? '';
if (!body) continue;
if (ts < oldestTs) oldestTs = ts;
collected.push({
ts,
sender: ev.getSender() ?? '',
@@ -89,25 +122,40 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
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) {
// If we have a fromTs, check whether the oldest collected event is already
// before it — if so we don't need to paginate further.
if (fromTs !== null && collected.length > 0) {
const oldestTs = Math.min(...collected.map((r) => r.ts));
if (oldestTs < fromTs) break;
if (cancelledRef.current) {
cancelled = true;
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
canLoadMore = await mx.paginateEventTimeline(timeline, {
backwards: true,
limit: 100,
limit: PAGE_LIMIT,
});
// eslint-disable-next-line no-await-in-loop
await addEvents(timeline.getEvents());
}
if (cancelled) {
setNotice(`Export cancelled after ${collected.length} messages.`);
return;
}
// Sort chronologically (oldest first)
collected.sort((a, b) => a.ts - b.ts);
@@ -191,6 +239,12 @@ ${msgRows}
a.download = `export-${safeRoomName}-${dateStr}.${ext}`;
a.click();
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 {
setExporting(false);
}
@@ -297,24 +351,35 @@ ${msgRows}
? `Exporting… ${exportCount} messages`
: 'Export will download automatically.'}
</Text>
<Button
size="400"
variant="Primary"
fill="Solid"
radii="300"
disabled={exporting}
onClick={handleExport}
before={
exporting ? (
<Spinner size="200" />
) : (
<Icon src={Icons.Download} size="100" />
)
}
>
<Text size="B400">{exporting ? 'Exporting…' : 'Export'}</Text>
</Button>
{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
size="400"
variant="Primary"
fill="Solid"
radii="300"
onClick={handleExport}
before={<Icon src={Icons.Download} size="100" />}
>
<Text size="B400">Export</Text>
</Button>
)}
</Box>
{notice && (
<Text size="T200" priority="400">
{notice}
</Text>
)}
</SequenceCard>
</Box>
</Box>
@@ -46,7 +46,9 @@ function isGlob(entity: string): boolean {
}
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;
}
@@ -103,9 +105,11 @@ function PolicyEntryRow({ entry }: { entry: PolicyEntry }) {
<Text size="T200">glob</Text>
</Badge>
)}
<Badge variant="Critical" fill="Soft" radii="Pill">
<Text size="T200">{recommendationLabel(entry.recommendation)}</Text>
</Badge>
{entry.recommendation && (
<Badge variant="Critical" fill="Soft" radii="Pill">
<Text size="T200">{recommendationLabel(entry.recommendation)}</Text>
</Badge>
)}
</Box>
{entry.reason && (
<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 (
prevMembership === 'invite' ||
prevMembership === 'knock' ||
prevMembership === undefined ||
prevMembership === null
) {
@@ -115,6 +116,19 @@ function describeEvent(mx: ReturnType<typeof useMatrixClient>, ev: MatrixEvent):
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 {
text: (
<>
@@ -115,10 +115,16 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
const totalMessages = [...msgCounts.values()].reduce((a, b) => a + b, 0);
const uniqueParticipants = msgCounts.size;
const msgEvents = events.filter((ev) => ev.getType() === EventType.RoomMessage);
const allTs = msgEvents.map((ev) => ev.getTs());
const oldestTs = allTs.length > 0 ? Math.min(...allTs) : null;
const newestTs = allTs.length > 0 ? Math.max(...allTs) : null;
// Single-pass min/max — `Math.min(...allTs)` spreads one arg per message and
// overflows the call stack (RangeError) on a large paginated timeline.
let oldestTs: number | null = 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 {
top5,
+170 -15
View File
@@ -3,16 +3,22 @@ import {
Box,
Button,
Checkbox,
Dialog,
Header,
Icon,
IconButton,
Icons,
Input,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Spinner,
Text,
color,
config,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoom } from '../../hooks/useRoom';
@@ -24,6 +30,8 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { SequenceCard } from '../../components/sequence-card';
import { SequenceCardStyle } from '../common-settings/styles.css';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -42,20 +50,52 @@ const DEFAULT_ACL: ServerAclContent = {
// ── Validation ────────────────────────────────────────────────────────────────
/**
* Validate a server name or wildcard pattern.
* Allowed forms:
* - plain hostname / IP: letters, digits, hyphens, dots
* - wildcard prefix: *.example.com (asterisk only at the very start)
* The Matrix spec allows `*` on its own (match-all wildcard).
* Validate a server-name glob for an ACL entry.
*
* Matrix ACL `allow`/`deny` entries are globs where `*` (any run of chars) and
* `?` (single char) may appear ANYWHERE e.g. `*`, `*.example.com`,
* `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 {
if (value === '*') return true;
// Strip leading wildcard
const rest = value.startsWith('*.') ? value.slice(2) : value;
// Must not be empty after stripping wildcard
if (!rest) return false;
// Remaining part: only letters, digits, dots, hyphens, colons (for IPv6/ports)
return /^[A-Za-z0-9.:_-]+$/.test(rest);
const v = value.trim();
if (!v) return false;
// Only hostname/IP glob chars — wildcards may appear at any position.
if (!/^[A-Za-z0-9.:*?-]+$/.test(v)) return false;
// Structural rules for the dotted parts.
if (v.startsWith('.') || v.endsWith('.') || v.includes('..')) return false;
// 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 ─────────────────────────────────────────────────
@@ -78,7 +118,7 @@ function ServerList({ label, entries, canEdit, onAdd, onRemove }: ServerListProp
if (!value) return;
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;
}
setError(undefined);
@@ -181,6 +221,7 @@ type RoomServerACLProps = {
export function RoomServerACL({ requestClose }: RoomServerACLProps) {
const mx = useMatrixClient();
const room = useRoom();
const modalStyle = useModalStyle(480);
// Power level checks
const powerLevels = usePowerLevels(room);
@@ -221,6 +262,26 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
const saveError =
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
const requiredPL = readPowerLevel.state(powerLevels, StateEvent.RoomServerAcl);
const myPL = readPowerLevel.user(powerLevels, myUserId);
@@ -242,8 +303,8 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
variant="Primary"
fill="Solid"
radii="300"
disabled={saving || !isDirty}
onClick={() => save()}
disabled={saving || !isDirty || emptyAllow}
onClick={() => setPrompt(true)}
before={saving ? <Spinner size="200" /> : <Icon src={Icons.Check} size="100" />}
>
<Text size="B400">{saving ? 'Saving…' : 'Save Changes'}</Text>
@@ -290,6 +351,24 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
</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 */}
<Box direction="Column" gap="100">
<Text size="L400">IP Address Access</Text>
@@ -352,6 +431,82 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
</PageContent>
</Scroll>
</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>
);
}
@@ -13,6 +13,7 @@ import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { General } from './general';
import { Members } from '../common-settings/members';
import { EmojisStickers } from '../common-settings/emojis-stickers';
import { Soundboard } from '../common-settings/soundboard';
import { Permissions } from './permissions';
import { RoomSettingsPage } from '../../state/roomSettings';
import { useRoom } from '../../hooks/useRoom';
@@ -53,6 +54,11 @@ const BASE_MENU_ITEMS: RoomSettingsMenuItem[] = [
name: 'Emojis & Stickers',
icon: Icons.Smile,
},
{
page: RoomSettingsPage.SoundboardPage,
name: 'Soundboard',
icon: Icons.Bell,
},
{
page: RoomSettingsPage.DeveloperToolsPage,
name: 'Developer Tools',
@@ -226,6 +232,9 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
{activePage === RoomSettingsPage.EmojisStickersPage && (
<EmojisStickers requestClose={handlePageRequestClose} />
)}
{activePage === RoomSettingsPage.SoundboardPage && (
<Soundboard requestClose={handlePageRequestClose} />
)}
{activePage === RoomSettingsPage.DeveloperToolsPage && (
<DeveloperTools requestClose={handlePageRequestClose} />
)}
@@ -11,6 +11,8 @@ import {
RoomLocalAddresses,
RoomPublishedAddresses,
RoomPublish,
RoomQuality,
RoomRetention,
RoomShareInvite,
RoomUpgrade,
RoomVoiceLimit,
@@ -55,9 +57,14 @@ export function General({ requestClose }: GeneralProps) {
<RoomEncryption permissions={permissions} />
<RoomPublish permissions={permissions} />
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Message Retention</Text>
<RoomRetention permissions={permissions} />
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Voice</Text>
<RoomVoiceLimit permissions={permissions} />
<RoomQuality permissions={permissions} />
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Addresses</Text>
@@ -0,0 +1,31 @@
import { keyframes, style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';
// A brief, gentle acknowledgement when a draft first becomes persisted.
// Guarded by `prefers-reduced-motion` so it only plays for users who opt in.
const savedPulse = keyframes({
'0%': { opacity: 0.4, transform: 'scale(0.7)' },
'45%': { opacity: 1, transform: 'scale(1.15)' },
'100%': { opacity: 1, transform: 'scale(1)' },
});
export const DraftIndicatorBase = style({
userSelect: 'none',
whiteSpace: 'nowrap',
});
export const DraftDot = style({
width: toRem(6),
height: toRem(6),
borderRadius: '50%',
backgroundColor: color.Success.Main,
flexShrink: 0,
});
export const DraftDotPulse = style({
'@media': {
'(prefers-reduced-motion: no-preference)': {
animation: `${savedPulse} 600ms ease-out`,
},
},
});
+64
View File
@@ -0,0 +1,64 @@
import React, { useEffect, useRef, useState } from 'react';
import { useAtomValue } from 'jotai';
import { Box, Text, config } from 'folds';
import { roomIdToMsgDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { toPlainText } from '../../components/editor';
import { DraftDot, DraftDotPulse, DraftIndicatorBase } from './DraftIndicator.css';
const PULSE_DURATION = 600;
type DraftIndicatorProps = {
roomId: string;
};
/**
* Subtle, non-distracting status shown near the composer when the current room
* has a persisted (unsent) message draft. It reacts to the shared draft atom
* (`roomIdToMsgDraftAtomFamily`) the same source that backs the
* `draft-msg-${roomId}` localStorage persistence so it never introduces a
* parallel persistence path.
*
* A short "Saved" pulse plays the moment a draft becomes persisted, then the
* indicator settles into a quiet, muted resting state. The pulse is gated behind
* `prefers-reduced-motion` in CSS, so motion-averse users only ever see the
* static label.
*/
export function DraftIndicator({ roomId }: DraftIndicatorProps) {
const draft = useAtomValue(roomIdToMsgDraftAtomFamily(roomId));
// Real content, not just an empty paragraph.
const hasDraft = toPlainText(draft, false).trim().length > 0;
const [pulse, setPulse] = useState(false);
const hadDraft = useRef(false);
useEffect(() => {
if (hasDraft && !hadDraft.current) {
hadDraft.current = true;
setPulse(true);
const timeout = setTimeout(() => setPulse(false), PULSE_DURATION);
return () => clearTimeout(timeout);
}
hadDraft.current = hasDraft;
return undefined;
}, [hasDraft]);
if (!hasDraft) return null;
return (
<Box
className={DraftIndicatorBase}
as="span"
shrink="No"
alignItems="Center"
gap="200"
style={{ padding: `0 ${config.space.S100}` }}
aria-hidden
>
<span className={`${DraftDot}${pulse ? ` ${DraftDotPulse}` : ''}`} />
<Text as="span" size="T200" priority="300">
Draft saved
</Text>
</Box>
);
}
+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;
}
// 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 ──────────────────────────────────────────────────────────────────
type LightboxItem = {
@@ -186,8 +198,8 @@ function LightboxMedia({
)}
{media.status === 'ok' &&
(item.msgtype === MsgType.Video ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video
aria-label="Video attachment"
src={media.url}
controls
autoPlay
@@ -261,7 +273,6 @@ function Lightbox({
escapeDeactivates: false,
}}
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
role="dialog"
aria-modal
@@ -586,7 +597,10 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const lightboxItems: LightboxItem[] = events
.filter((ev) => {
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) => {
const c = ev.getContent();
@@ -640,13 +654,15 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
className={classNames(css.MediaGalleryDrawer, ContainerColor({ variant: 'Background' }))}
shrink="No"
direction="Column"
role="region"
aria-labelledby="media-gallery-title"
>
{/* Header */}
<Header variant="Background" size="600" className={css.MediaGalleryHeader}>
<Box grow="Yes" alignItems="Center" gap="200">
<Icon size="200" src={Icons.Photo} />
<Box grow="Yes">
<Text size="H4" truncate>
<Text id="media-gallery-title" size="H4" truncate>
Media Gallery
</Text>
</Box>
@@ -711,9 +727,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
// Prefer thumbnail_file (encrypted thumb) > file > thumbnail_url > url
const thumbMxc: string | undefined = isEnc
? (info?.thumbnail_file?.url ?? c.file?.url)
: (info?.thumbnail_url ?? c.url);
const thumbMxc: string | undefined = getThumbMxc(mEvent);
const thumbEnc: IEncryptedFile | undefined = isEnc
? (info?.thumbnail_file ?? c.file)
: undefined;
-2
View File
@@ -142,7 +142,6 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
placeholder="Ask a question…"
value={question}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuestion(e.target.value)}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
/>
</Box>
@@ -151,7 +150,6 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
<Box direction="Column" gap="200">
<Text size="L400">Options</Text>
{options.map((opt, index) => (
// eslint-disable-next-line react/no-array-index-key
<Box key={index} alignItems="Center" gap="200">
<Input
style={{ flex: 1 }}
+78 -4
View File
@@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import { Box, Line } from 'folds';
import { useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey';
@@ -7,6 +7,8 @@ import { RoomView } from './RoomView';
import { MembersDrawer } from './MembersDrawer';
import { MediaGallery } from './MediaGallery';
import { mediaGalleryAtom } from '../../state/mediaGallery';
import { WidgetsPanel } from './widgets/WidgetsPanel';
import { widgetsPanelAtom } from '../../state/widgetsPanel';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
@@ -22,6 +24,8 @@ import { callChatAtom } from '../../state/callEmbed';
import { CallChatView } from './CallChatView';
import { useCallEmbed } from '../../hooks/useCallEmbed';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
import { ThreadPanel } from './thread';
export function Room() {
const { eventId } = useParams();
@@ -33,8 +37,12 @@ export function Room() {
const callEmbed = useCallEmbed();
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const activeThreadId = useAtomValue(roomIdToActiveThreadIdAtomFamily(room.roomId));
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId));
const galleryOpen = useAtomValue(mediaGalleryAtom);
const setGalleryOpen = useSetAtom(mediaGalleryAtom);
const widgetsOpen = useAtomValue(widgetsPanelAtom);
const setWidgetsOpen = useSetAtom(widgetsPanelAtom);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext();
const powerLevels = usePowerLevels(room);
@@ -45,15 +53,56 @@ export function Room() {
useCallback(
(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);
}
},
[mx, room.roomId, hideActivity],
[mx, room.roomId, hideActivity, activeThreadId],
),
);
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
// The content panels (thread / media gallery / widgets) are mutually exclusive
// on every screen size: opening one closes the others. Detect the just-opened
// transition so whichever was opened most recently wins.
const prevThreadRef = useRef(activeThreadId);
const prevGalleryRef = useRef(galleryOpen);
const prevWidgetsRef = useRef(widgetsOpen);
useEffect(() => {
const threadJustOpened = Boolean(activeThreadId) && !prevThreadRef.current;
const galleryJustOpened = galleryOpen && !prevGalleryRef.current;
const widgetsJustOpened = widgetsOpen && !prevWidgetsRef.current;
if (threadJustOpened) {
if (galleryOpen) setGalleryOpen(false);
if (widgetsOpen) setWidgetsOpen(false);
} else if (galleryJustOpened) {
if (activeThreadId) setActiveThreadId(null);
if (widgetsOpen) setWidgetsOpen(false);
} else if (widgetsJustOpened) {
if (activeThreadId) setActiveThreadId(null);
if (galleryOpen) setGalleryOpen(false);
}
prevThreadRef.current = activeThreadId;
prevGalleryRef.current = galleryOpen;
prevWidgetsRef.current = widgetsOpen;
}, [activeThreadId, galleryOpen, widgetsOpen, setGalleryOpen, setActiveThreadId, setWidgetsOpen]);
// On non-desktop screens at most one right-side panel may show, priority
// thread > gallery > widgets > members. On desktop thread + members may coexist
// while the content panels stay mutually exclusive (via the effect above).
const isDesktop = screenSize === ScreenSize.Desktop;
const showThreadPanel = !callView && Boolean(activeThreadId);
const showGallery = !callView && galleryOpen && (isDesktop || !activeThreadId);
const showWidgets = !callView && widgetsOpen && (isDesktop || (!activeThreadId && !galleryOpen));
const showMembers =
!callView && isDrawer && (isDesktop || (!activeThreadId && !galleryOpen && !widgetsOpen));
return (
<PowerLevelsContextProvider value={powerLevels}>
<Box grow="Yes">
@@ -82,7 +131,7 @@ export function Room() {
<CallChatView />
</>
)}
{!callView && galleryOpen && (
{showGallery && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
@@ -90,7 +139,32 @@ export function Room() {
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
</>
)}
{!callView && isDrawer && (
{showWidgets && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<WidgetsPanel
key={room.roomId}
room={room}
requestClose={() => setWidgetsOpen(false)}
/>
</>
)}
{showThreadPanel && activeThreadId && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<ThreadPanel
key={`${room.roomId}${activeThreadId}`}
room={room}
threadId={activeThreadId}
requestClose={() => setActiveThreadId(null)}
/>
</>
)}
{showMembers && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />

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