Compare commits

...

35 Commits

Author SHA1 Message Date
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
152 changed files with 12250 additions and 1648 deletions
+20 -9
View File
@@ -548,8 +548,19 @@ 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):
> ✅ **STATUS (2026-06): COMPLETE.** All items below are shipped. call_state,
> focus_participant, decorations, and transparent background are active; the
> in-source denoise cutover is done (flag `lotusDenoiseSource=1`, **all four**
> models in-source); and the two formerly-dormant capabilities now have cinny
> UI — **soundboard** (`io.lotus.inject_audio`, P5-15) and **quality controls +
> room permissions** (`io.lotus.set_quality` + `io.lotus.room_quality`, P5-31,
> with server-side enforcement in `LotusGuild/matrix`). See `LOTUS_FEATURES.md`
> → "Element Call — Self-Built Fork". The checklist is kept below as the record
> of what was wired. (One open denoise item tracked separately: the "Series
> Suppression" native-NS toggle is not wired to the real call path.)
The EC side is additive and dormant until cinny opts in. Host work (in
`src/app/plugins/call/CallEmbed.ts` unless noted) — **done**:
> ⚠️ **CRITICAL TIMING (protocol audit F1):** only send `io.lotus.*` **toWidget**
> actions (#3 focus, #6 decorations, #7 quality, audio-inject) **after** the call
@@ -559,16 +570,16 @@ The EC side is additive and dormant until cinny opts in. Host work needed (in
> 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.
> Also: **F3 (RESOLVED)** — all four models (`rnnoise`/`speex`/`dtln`/
> `deepfilternet`) are now implemented in-source in `lotusDenoiseProcessor.ts`;
> the picker offers all four. **F4** — cinny no longer forwards a native-NS flag
> in the `ml` branch (the "Series Suppression" toggle is currently a no-op in
> real calls — open item). **F7** — no widget _capability_ changes needed;
> custom actions bypass capability checks.
1. **Set the URL flags** on the widget iframe params (the `URLSearchParams` in
`CallEmbed`): `lotusCallState=1`, `lotusTransparent=1`/`lotusTheme=1`,
`lotusAudioInject=1` as desired. (Denoise already sets `lotusDenoise=ml` etc.)
`lotusAudioInject=1` as desired. (Denoise sets `lotusDenoiseSource=1` + `lotusModel`/`lotusGate`/`lotusGateThreshold` in the `ml` tier.)
2. **Ack `io.lotus.call_state`**: add `listenAction('io.lotus.call_state', …)` —
without a reply the fork's sends time out every 250ms. Feed the payload into
`useCallSpeakers` and RETIRE its `contentDocument` DOM scrape.
+41 -37
View File
@@ -32,6 +32,15 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
| 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 |
**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.
@@ -39,49 +48,44 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
## 🧩 Element Call source-level items — now actionable via the fork
> 🔱 **[EC-FORK]** **UPDATE 2026-06-29: the fork is live.** We now own and
> 🔱 **[EC-FORK]** **UPDATE 2026-06-30: Phase 2 IMPLEMENTED.** We 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.)
> `@lotusguild/element-call-embedded@0.20.1-lotus.1`, cinny wired). A5/A6/A7
> below are **fixed in the fork** — they are now ⚠️ awaiting **live
> verification** (`LOTUS_TESTING.md` §D2), not open work. See
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md) §10. Delete each
> row once verified live.
The in-call participant grid is rendered **inside EC's app**. Previously a
pre-built npm bundle we could only style/place around; now editable source.
Items from testing, with their fork-level fix path:
The in-call participant grid is rendered **inside EC's app** — now editable source
(previously a prebuilt npm bundle we could only style around). Status of the items
from testing:
- **A5 — "Focus camera":** 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._
- **A5 — "Focus camera": ⚠️ FIXED in fork, awaiting verify (D2-3).** cinny now
sends an `io.lotus.focus_participant` widget action that pins a participant in
EC's layout (coexisting with / overriding the screenshare spotlight); the old
`.click()`-the-tile DOM hack in `CallControl.ts` is deleted.
- **A6 — avatar decorations in-call: ⚠️ FIXED in fork, awaiting verify (D2-4).**
cinny pushes `io.lotus.decorations` (per-user APNG URLs) and the fork renders
them on EC's participant video-tile avatars — not just our pre-join lobby roster.
- **A7 — mic dead after EC's "Reconnect": ⚠️ FIXED in fork, awaiting verify
(D2-1).** Denoise moved into EC's mic-capture/publish pipeline as a first-class
LiveKit `TrackProcessor` (flag `lotusDenoiseSource=1`); EC re-runs it on every
(re)publish, so reconnects keep denoise alive natively. The build-time
`getUserMedia`/`index.html` injection (the root cause) is removed. **Highest
blast radius — everyone's mic; verify D2-1 carefully.**
---
## 🔴 Open — Actionable
### Calls / Audio
- ~~**N127 — ML denoise shim is never injected in `vite dev`.**~~ **RESOLVED (dissolved by the A7 denoise cutover).** `vite.config.js` no longer injects a getUserMedia shim at all — the forked Element Call runs ML denoise in-source as a LiveKit `TrackProcessor` (activated by `lotusDenoiseSource=1`), so there is no build-time injection that could be missing in dev. Nothing to fix.
### 🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED · 👤 SENIOR ENGINEER
> 🧰 **Investigation kit ready (2026-07):** [`LOTUS_E2EE_INVESTIGATION.md`](./LOTUS_E2EE_INVESTIGATION.md)
> has the per-KE capture runbook (console signatures, synapse-side queries, the
> KE-1→KE-2 causality decision tree, ranked remediations), and the client now
> ships a **Crypto Diagnostics** capture helper (Settings) — run it during the
> next affected call and download the report before starting any fix.
> **Observed live in prod 2026-06-30** on `chat.lotusguild.org` during a 2-person
> **Element Call** (E2EE enabled). These span **client rust-crypto (via
> `matrix-js-sdk@41.6.0-rc.0`) ↔ Synapse ↔ Element Call's MatrixRTC E2EE** and are
@@ -146,15 +150,15 @@ retry … AbortError: Restart delayed event timed out before the HS responded`,
### 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.
- ~~**`matrix-js-sdk` pinned to a Release Candidate**~~ — **done (2026-07):** moved to `41.7.0` stable (crypto-wasm 18.3.1 security bump). Deep-audit dep triage: all 16 npm advisories are dev-only/unreachable/dead-dep — zero shipped exposure; dead `dompurify` removed. `@atlaskit`/build-tool pins remain review-worthy but low priority.
- **Build-time overhead:** `lotusDenoise` does heavy sequential `fs` work in `closeBundle`; `viteStaticCopy` config is complex with redundant renames — could be streamlined.
### Code Hygiene / DevEx
- **Automated test suite — 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).
- **Automated test suite — 561+ tests across 65+ modules, a hard CI gate.** `npm test` runs Node's built-in runner via `tsx` (not vitest — Vite 8 is ahead of vitest's range) and **blocks the build job on failure**. Broad pure-logic coverage: utils (common, regex, sanitize/XSS, time, matrix, matrix-uia, mimeTypes, sort, accentColor, findAndReplace, AsyncSearch, ASCIILexicalTable, keyboard, room, matrix-crypto, featureCheck, syntaxHighlight, imageCompression, user-agent, callSounds), state (settings, sessions, recentSearches, upload, typingMembers, lists, room-list, toast, scheduledMessages, backupRestore, callEmbed/callPreferences, spaceRooms, …), plugins (matrix-to, call/utils, via-servers, bad-words, recent-emoji, custom-emoji, markdown block/inline/utils), OIDC (cs-api, useParsedLoginFlows, oidcState), lotus/avatarDecorations, message-search, search filters. Prevention work has caught + fixed **4 real bugs** (`findAndReplace` infinite-loop; `getSettings` crash-on-load when storage is blocked; `isMacOS` never matching modern Macs; `isMLDenoiseSupported` throwing `ReferenceError` instead of returning false on browsers lacking the `AudioWorkletNode` binding). **Next:** component/integration tests (the untestable-under-tsx DOM/React surface).
- **Extensive `as any` casts** across `src/` — gradual typing cleanup.
- **`types/matrix/` mirrors SDK types** instead of importing them — drift risk.
- **Hardcoded CDN URL** should move to an env var (the decoration CDN is now single-sourced in `avatarDecorations.ts`, but the literal is still in-repo).
- ~~**Hardcoded CDN URL** should move to an env var~~ — **done:** `avatarDecorations.ts` already honors a `VITE_DECORATION_CDN` env override (lines 14-16); the in-repo literal is only the default. Nothing left.
- **`patch-folds.mjs` edits `node_modules` directly** — consider `patch-package`.
- **Infra docs:** `contrib/nginx` lacks security headers (HSTS/CSP) + uses rewrites over `try_files`; `contrib/caddy` has a placeholder path. CI/CD (`prod-deploy.yml`): sequential deploy, aggressive 1-min Netlify timeout, `package-manager-cache: false`.
- **README:** keep the fork-sync version + logo path current. (`CONTRIBUTING.md` is intentionally left as upstream Cinny's — not a Lotus concern.)
@@ -163,4 +167,4 @@ retry … AbortError: Restart delayed event timed out before the HS responded`,
### 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.
- ~~**#5 — Seasonal themes & chat-background redesign.**~~ **DONE (2026-06/07):** 11 seasonal/holiday overlays shipped and later toned down + given a settings preview grid; all 19 chat backgrounds redesigned (Carbon + Aurora kept per user preference), one design sprint each, GPU-friendly CSS with `prefers-reduced-motion` + pause toggle. Remaining polish rides normal bug flow, not a "big project."
+402
View File
@@ -0,0 +1,402 @@
# Lotus Chat — E2EE Investigation Runbook (KE-1 → KE-4)
> **Scope:** evidence-gathering only. Do **not** apply fixes from this document
> without a cross-system planning session (client rust-crypto ↔ Synapse ↔
> Element Call MatrixRTC). Symptom source: `LOTUS_BUGS.md` §"Encryption / E2EE"
> (KE-1..KE-4), observed live 2026-06-30 on `chat.lotusguild.org` during a
> 2-person Element Call.
>
> **Client:** Lotus Cinny fork, `matrix-js-sdk@41.6.0-rc.0`, rust-crypto.
> **Server:** Synapse `1.155.0` on **LXC 151** (`10.10.10.29`), PostgreSQL 17.9
> on **LXC 109** (`10.10.10.44`). Facts below are copy-pasteable against that
> deployment (paths/IPs from `/root/code/matrix/README.md`).
---
## 0. Deployment facts used by this runbook
From the matrix infra README (`/root/code/matrix/README.md`):
| Thing | Value |
|-------|-------|
| Synapse host | LXC **151**, `10.10.10.29` (Synapse 1.155.0) |
| Synapse log | `/var/log/matrix-synapse/homeserver.log` |
| Synapse config | `/etc/matrix-synapse/homeserver.yaml` (+ `conf.d/`) |
| Synapse HTTP | `10.10.10.29:8008` |
| PostgreSQL host | LXC **109**, `10.10.10.44` (PG 17.9), db `synapse` |
| synapse-admin UI | `http://10.10.10.29:8080` |
| LiveKit / lk-jwt / guard | LXC 151: LiveKit `:7880/:7881`, guard `:8070`, lk-jwt `:8071` |
| SSH path to Synapse | `ssh root@10.10.10.4` then `pct enter 151` |
| SSH path to PG | `ssh root@10.10.10.4` then `pct enter 109` |
**Getting a psql shell** (run on LXC 109, or from 151 over the network):
```bash
# On LXC 109:
sudo -u postgres psql synapse
# From LXC 151 (pg_hba allows 10.10.10.29):
psql "host=10.10.10.44 user=synapse dbname=synapse"
```
**Tailing Synapse during a call** (on LXC 151):
```bash
tail -F /var/log/matrix-synapse/homeserver.log | tee /tmp/lotus-call-$(date +%s).log
```
Synapse E2EE/to-device logging is chatty at `INFO`; if a category is silent,
temporarily raise it in `/etc/matrix-synapse/conf.d/log.yaml` (or the
`log_config` file referenced by `homeserver.yaml`):
```yaml
loggers:
synapse.rest.client.keys: { level: DEBUG }
synapse.handlers.e2e_keys: { level: DEBUG }
synapse.storage.databases.main.end_to_end_keys: { level: DEBUG }
synapse.handlers.devicemessage: { level: DEBUG } # to-device
```
Then `systemctl reload matrix-synapse` (reload re-reads log config without a
full restart). **Revert to `INFO` after the capture** — DEBUG is very verbose.
---
## 1. Per-KE evidence matrix
Client greps assume Chrome/Firefox DevTools console (filter box or, better,
"Preserve log" + save-as). The **Crypto Diagnostics** card (Settings →
Developer Tools) auto-captures every signature below into a downloadable JSON —
use it as the primary client artifact and DevTools as the raw backup.
### KE-1 — OTK upload conflict storm (root-cause candidate)
- **Console signature (grep):**
- `already exists`
- full: `POST /_matrix/client/v3/keys/upload … 400 M_UNKNOWN: One time key signed_curve25519:<id> already exists. Old key: {…} new key: {…}`
- **Capture client-side:**
- Timestamp (first occurrence + rate — "N/sec"), **device id**, **user id**.
- DevTools → **Network** → filter `keys/upload`: for a failing call save the
**request body** (the `one_time_keys` map — note the exact `signed_curve25519:<id>`)
and the **response body** (the `Old key` / `new key` JSON). This diff is the
smoking gun: same key-id, different value ⇒ store vs server divergence.
- Whether it self-heals or loops forever (KE-1 loops).
- **Synapse log grep (LXC 151):**
```bash
grep -E "keys/upload|One time key .* already exists|OneTimeKey" \
/var/log/matrix-synapse/homeserver.log | grep "<user_id>"
```
- **Synapse SQL (LXC 109) — what the server thinks it holds:**
```sql
-- Current OTK inventory for the device (compare key_id set against the
-- request body the client keeps retrying).
SELECT algorithm, key_id, ts_added_ms
FROM e2e_one_time_keys_json
WHERE user_id = '@user:matrix.lotusguild.org'
AND device_id = '<DEVICE_ID>'
ORDER BY algorithm, key_id;
-- Server's advertised counts (this is what /sync tells the client it has,
-- and drives whether the client decides to upload more).
SELECT algorithm, count(*) FROM e2e_one_time_keys_json
WHERE user_id = '@user:matrix.lotusguild.org' AND device_id = '<DEVICE_ID>'
GROUP BY algorithm;
-- Fallback key state (used when OTKs are exhausted).
SELECT algorithm, key_id, used, ts_added_ms
FROM e2e_fallback_keys_json
WHERE user_id = '@user:matrix.lotusguild.org' AND device_id = '<DEVICE_ID>';
```
> Table names are Synapse 1.155 (`e2e_one_time_keys_json`,
> `e2e_fallback_keys_json`). If a name is absent, list with `\dt e2e*` in psql.
- **Confirms:** if the offending `key_id` (from the 400) is **present** in
`e2e_one_time_keys_json` with a **different** stored value than the client's
request body → OTK state has diverged (rust-crypto store vs Synapse). That is
the KE-1 root condition.
### KE-2 — EC media keys not arriving/decrypting (audio/video cutouts)
- **Console signature (grep):**
- `MissingKey`
- `missing key at index` (e.g. `MissingKey: missing key at index N for participant @user`)
- `key set not found`
- `io.element.call.encryption_keys` (rust-crypto: `WARN … Received an unexpected encrypted to-device event … event_type="io.element.call.encryption_keys"`)
- **Capture client-side:**
- Timestamp windows where a participant's audio/video cut out, and the
`@participant` + `index N` from the message.
- The `io.element.call.encryption_keys` warnings (these are the media-key
to-device events failing to decrypt) with their timestamps.
- Own device id + user id (to correlate with the sender's Olm session).
- **Synapse log grep (LXC 151) — to-device delivery of the media keys:**
```bash
grep -E "io.element.call.encryption_keys|m.room.encrypted|/sendToDevice|to_device" \
/var/log/matrix-synapse/homeserver.log | grep -E "<user_id>|<participant_id>"
```
- **Synapse SQL (LXC 109) — undelivered / queued to-device events:**
```sql
-- Backlog of to-device messages queued for the affected device. A growing
-- count here = the HS has the media-key events but the device isn't draining
-- them via /sync (or they were sent to a stale device id).
SELECT user_id, device_id, count(*) AS pending
FROM device_inbox
WHERE user_id = '@user:matrix.lotusguild.org'
GROUP BY user_id, device_id;
-- Cross-check the device id the sender is targeting actually exists / is current.
SELECT device_id, display_name, last_seen, ts
FROM devices WHERE user_id = '@user:matrix.lotusguild.org';
```
- **Confirms:** to-device events present but undecryptable (client shows the
`io.element.call.encryption_keys` "unexpected encrypted" warning) ⇒ there is
**no valid Olm session** to decrypt them — the expected downstream of KE-1.
### KE-3 — Timeline decryption error: missing `algorithm` field
- **Console signature (grep):**
- `DecryptionError`
- full: `Error decrypting event (… type=m.room.encrypted …): DecryptionError[msg: missing field 'algorithm' at line 1 column 138 …]`
- **Capture client-side:**
- The **event id** (`$SASBBzoqj…` was one) and the **room id**.
- Pull the raw event JSON via DevTools or the Developer Tools account-data/event
viewer, or directly:
```
GET https://matrix.lotusguild.org/_matrix/client/v3/rooms/<roomId>/event/<eventId>
```
Inspect `content` — confirm whether `algorithm` (should be
`m.megolm.v1.aes-sha2`) is truly absent vs a serialization mismatch.
- **Synapse log grep (LXC 151):**
```bash
grep -E "<eventId>" /var/log/matrix-synapse/homeserver.log
```
- **Synapse SQL (LXC 109) — the stored event content as the HS holds it:**
```sql
SELECT ej.event_id, e.type, e.sender, e.origin_server_ts,
(ej.json::json -> 'content' -> 'algorithm') AS algorithm
FROM event_json ej
JOIN events e USING (event_id)
WHERE ej.event_id = '$SASBBzoqj...';
```
- **Confirms:** if the stored `content.algorithm` is **NULL/absent** on the HS →
a malformed/legacy event was persisted (sender-side or federation). If it is
**present** on the HS but the client throws → an RC-SDK deserialization bug.
This distinction decides whether KE-3 is a data problem or a client problem.
### KE-4 — MatrixRTC delayed-event / membership timeouts
- **Console signature (grep):**
- `update_delayed_event` (`org.matrix.msc4157.update_delayed_event`)
- `delayed event` / `Restart delayed event timed out`
- full: `[MembershipManager] Network local timeout error while sending event, immediate retry … AbortError: Restart delayed event timed out before the HS responded`
- **Capture client-side:**
- Timestamps of each timeout; whether they correlate with call join/leave or
with general sync slowness.
- DevTools → Network: the `…/delayed_events…` / `update_delayed_event`
requests — their **HTTP status and latency** (timed-out vs slow-200).
- **Synapse log grep (LXC 151):**
```bash
grep -E "delayed_event|msc4140|msc4157|update_delayed" \
/var/log/matrix-synapse/homeserver.log | grep "<user_id>"
# HS responsiveness in the same window (KE-4 may be pure latency):
grep -E "Processed request|/sync" /var/log/matrix-synapse/homeserver.log | tail -50
```
- **Server-side corroboration (Grafana, `dashboard.lotusguild.org`):** Synapse
p99 response time (excl. `/sync`), event-processing lag, DB query latency for
the call window. High latency here ⇒ KE-4 is (partly) homeserver
responsiveness, not a client bug.
- **Confirms:** timeouts that line up with HS latency spikes → reliability/load;
timeouts with a healthy HS → client MembershipManager retry logic.
---
## 2. Causality hypothesis
```
KE-1 OTK upload conflict storm
(rust-crypto store ↔ Synapse OTK state DIVERGED; server rejects re-uploads)
│ no fresh OTKs can be published/claimed
No new Olm (1:1) sessions can be established with this device
KE-2 EC media-key to-device events (io.element.call.encryption_keys)
arrive but cannot be decrypted ⇒ MissingKey at index N
⇒ friend's audio/video cuts out
```
KE-3 (missing `algorithm`) and KE-4 (delayed-event timeouts) are **likely
independent** of the KE-1→KE-2 chain: KE-3 is a decode/serialization path,
KE-4 is a MatrixRTC-vs-HS reliability path. Confirm/refute independence with the
decision tree below.
### Decision tree — which capture confirms/refutes each link
```
Q1. Does the KE-1 offending key_id from the 400 response exist in
e2e_one_time_keys_json with a DIFFERENT value than the client request body?
├─ YES → OTK divergence CONFIRMED (KE-1 root). Go to Q2.
└─ NO → Not divergence. Check: are OTK counts at 0 with fallback key `used=true`?
├─ YES → OTK exhaustion, not divergence — different remediation.
└─ NO → Suspect RC-SDK 41.6.0-rc.0 upload-loop regression (see §3).
Q2. During the same call, are io.element.call.encryption_keys to-device events
present in device_inbox / Synapse to-device logs for our device id?
├─ YES + client shows "unexpected encrypted"/MissingKey
│ → KE-1 ⇒ KE-2 LINK CONFIRMED (events delivered, no Olm session to open them).
├─ YES + client decrypts fine, but LiveKit still silent
│ → KE-2 is downstream of LiveKit/SFU, NOT KE-1. Decouple from crypto.
└─ NO (nothing queued/targeted our device)
→ media keys never sent to us: stale device id / membership (see KE-4)
→ KE-2 is a device-targeting problem, weakly linked to KE-1.
Q3. KE-3: is content.algorithm NULL in event_json on the HS?
├─ YES → malformed persisted event (sender/federation). Independent of KE-1.
└─ NO → client-side RC-SDK deserialization bug. Independent of KE-1.
Q4. KE-4: do delayed-event timeouts coincide with Synapse p99 latency spikes
(Grafana) in the same minute?
├─ YES → homeserver responsiveness/load. Independent of KE-1..KE-3.
└─ NO → client MembershipManager retry behavior. Independent.
```
---
## 3. Ranked remediation options (with blast radius)
> Ordered least-destructive → most-destructive. **Do not run any of these as a
> "fix" before the planning session** — they are listed so evidence collection
> can be paired with a recovery plan. Confirm the root condition (Q1/Q2) first.
1. **Per-device logout + re-login of the affected device** *(lowest blast radius)*
- **What:** log the one glitching device out and back in. Forces a fresh
device id, fresh device keys, and a clean OTK batch — sidesteps a diverged
OTK store without touching other sessions.
- **Blast radius:** that device only. Other sessions/devices untouched.
- **Cost:** the new device must be re-verified (cross-signing) and will need
to restore room keys from **key backup** to read old encrypted history.
- **Confirms/uses:** if KE-1 stops after this, OTK-store divergence (Q1) was
the cause.
2. **Client crypto-store reset (`clearLoginData` path)** *(medium)*
- **What:** `clearLoginData()` in `src/client/initMatrix.ts` (coordinator's
file — do not edit) **deletes ALL IndexedDB databases** (incl.
`web-sync-store` and the rust-crypto store `crypto-store`), **unregisters
service workers**, **clears all Cache Storage**, and **`localStorage.clear()`**,
then reloads. `clearCacheAndReload()` is lighter — it only calls
`mx.store.deleteAllData()` (sync cache) and does **not** wipe crypto.
- **Blast radius:** this browser profile only, but total: you are logged out,
lose all cached sync state, drafts, settings, and **the local
megolm/room-key store**.
- **⚠️ Message-history / backup implication:** wiping `crypto-store` destroys
locally-held **room keys (megolm inbound sessions)**. Any history **not
backed up to server-side Key Backup** becomes **permanently undecryptable
on this device**. Before doing this: verify Key Backup is enabled and the
recovery key / passphrase is available (Settings → Security), or the user
loses readable history. Cross-signing must be re-established too.
- **Use when:** the rust-crypto store itself is corrupt/diverged and option 1
didn't clear it.
3. **SDK pin change off the RC** *(medium — codebase change, needs rebuild)*
- **Current pin:** `package.json` → `"matrix-js-sdk": "41.6.0-rc.0"` (a
release candidate).
- **Finding (npm / GitHub changelog, checked 2026-07):** stable **`41.6.0`**
was released **2026-05-26**. Its only changelog line is *"Throw sane error
on completeLoginOnNewDevice IdP rejection"* — **no OTK / keys-upload / Olm /
to-device fix** relative to the RC. Later stable lines exist
(`41.7.0`, `41.8.0`; `41.7.0-rc.3` / `41.9.0-rc.0` seen as pre-releases).
Nearby crypto-relevant entries: `41.5.0` *"Enable encrypted history sharing
by default"*; `41.4.0` key-backup handling. **No changelog entry directly
addresses the KE-1 OTK-conflict symptom** in the immediate range — so
moving RC→`41.6.0` stable is a low-risk hygiene step but is **not expected
to fix KE-1 by itself**. Before pinning, re-read the CHANGELOG for any
`41.7.x`/`41.8.x` OTK/one-time-key/olm entry that post-dates this note.
- **Blast radius:** all users after the next `cinny-build.sh` deploy. Test the
rust-crypto IndexedDB schema — a downgrade triggers the `IDB_VERSION_CONFLICT`
path in `initMatrix.ts`.
4. **Synapse-side OTK row surgery** *(LAST RESORT — highest danger)*
- **What:** deleting/rewriting rows in `e2e_one_time_keys_json` (and/or
`e2e_fallback_keys_json`, `device_inbox`) for the affected device to force
the client to re-upload a clean batch.
- **⚠️ Danger:** direct writes to Synapse crypto tables can **desync every
device of that user**, break Olm sessions **for everyone who has claimed one
of those keys**, and are easy to get wrong (wrong `key_id`, cache not
invalidated). Synapse caches OTK counts — a raw DELETE without a restart can
leave the advertised count wrong, **worsening** the KE-1 loop.
- **Guardrails if ever done (planning session + HS owner only):** full
`pg_dump` of `synapse` first; do it during **zero active calls**; delete only
the exact diverged `key_id` for the exact `device_id`; `systemctl restart
matrix-synapse` to flush caches; then log the device out/in (option 1) so it
republishes. **Never** run this speculatively.
---
## 4. "Capture session" checklist (run during the next call)
Do these **in order**. Aim to have client + server capturing the **same call**.
1. **Prep server tail (LXC 151):** SSH in, start
`tail -F /var/log/matrix-synapse/homeserver.log | tee /tmp/lotus-call-$(date +%s).log`.
(Optionally raise the `synapse.rest.client.keys` / `handlers.e2e_keys` /
`handlers.devicemessage` loggers to DEBUG per §0 and `systemctl reload
matrix-synapse` — remember to revert after.)
2. **Prep client:** open Lotus Chat → Settings → Developer Tools → **enable
Developer Tools** so the **Crypto Diagnostics** card is visible; note its
entry count starts at (or reset by reload to) 0.
3. **Open DevTools** (F12) → Console: enable **Preserve log**; Network tab:
enable **Preserve log** + **Record**. Note your **device id** and **user id**
(Settings → Devices / Developer Tools → Copy access token page shows ids).
4. **Note wall-clock start time** (ISO/UTC) on both machines so logs align.
5. **Join the Element Call** with the second participant; reproduce the fault
(wait for the audio/video cutouts and let KE-1 storm run ~3060s).
6. **When a fault occurs, note the wall-clock timestamp** and which symptom
(audio cut / video freeze / etc.) — this bounds the log window.
7. **Client artifacts:** in the Crypto Diagnostics card click **Download report**
(`lotus-crypto-diag-<ts>.json`); in DevTools Network, save the failing
`keys/upload` request+response (right-click → Save/Copy), and the raw HAR
(Network → Save all as HAR) for the call window.
8. **Grab KE-3 event id / KE-2 participant+index** from the console (or the
diag JSON `entries[]`) for the SQL lookups.
9. **Server artifacts:** stop the tail; run the per-KE greps and SQL from §1
against the noted device id / user id / event id, saving output alongside the
client JSON. Screenshot the Grafana Synapse latency panels for the window
(for KE-4).
10. **Bundle & label:** put client JSON + HAR + server log slice + SQL output in
one folder named with the call's UTC start time. Revert any DEBUG log config
(`systemctl reload matrix-synapse`). Hand off to the planning session — **do
not apply §3 remediations yet.**
---
## 5. Client diagnostics helper (this kit)
- **`src/app/utils/cryptoDiagLog.ts`** — capture-only console instrumentation.
- `installCryptoDiagLog()` — idempotent; wraps `console.warn`/`console.error`
with pass-through wrappers (originals always called) that ring-buffer (max
**200**) any line matching the KE signatures. No network, no timers.
- `getCryptoDiagEntries()` — snapshot copy of the buffer (`{ ts, level, ke,
signature, message }`, most-recent-last).
- `buildCryptoDiagReport(mx)` — JSON string: SDK version, device id, user id,
sync state, `cryptoReady` (`mx.getCrypto()` presence), per-KE counts, and the
entry buffer. No tokens/PII beyond those ids; captured log lines are retained
verbatim as evidence.
- **Signatures → KE mapping:** `already exists`→KE-1; `missing key at index` /
`io.element.call.encryption_keys` / `MissingKey`→KE-2; `DecryptionError`→KE-3;
`update_delayed_event` / `delayed event`→KE-4.
- **`src/app/features/settings/developer/CryptoDiagnostics.tsx`** — a folds
`SequenceCard`/`SettingTile` card (mirrors `developer-tools/DevelopTools.tsx`)
showing the live matched-entry count (Badge) and a **Download report** button
(Blob → `lotus-crypto-diag-<ts>.json`, same download idiom as
`room-settings/ExportRoomHistory.tsx`).
### Recommended mount points (coordinator)
- **Install call:** call `installCryptoDiagLog()` **as early as possible during
boot** so it captures crypto errors from first sync — ideally at the top of
the client entry module or inside `ClientRoot` before/around `initClient`
(e.g. `src/app/pages/client/ClientRoot.tsx`). It is idempotent, side-effect
only, and needs no `mx`, so a module-scope call at app entry is safe. (Do
**not** put it in `initMatrix.ts` — that file is off-limits.)
- **Settings card:** render `<CryptoDiagnostics />` inside the Developer Tools
page — in `src/app/features/settings/developer-tools/DevelopTools.tsx`, add it
to the `Box direction="Column" gap="700"` list (guarded by the existing
`developerTools` flag), right after the "Access Token" card. It pulls `mx`
from `useMatrixClient()` itself, so it just needs to be placed in the tree.
+253 -24
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
> 🔱 **[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
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.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
@@ -1045,6 +1209,71 @@ 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 runbook: [`LOTUS_E2EE_INVESTIGATION.md`](./LOTUS_E2EE_INVESTIGATION.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`).
### 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
+51 -4
View File
@@ -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.
+157 -102
View File
@@ -48,6 +48,9 @@ Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then th
| Desktop — proactive update notifications (Tauri) | J1 |
| Remind Me Later | K1 |
| Mobile Bookmarks access | E5 |
| In-Call Soundboard (P5-15, uploadable clips → real call inject) | D2-7 |
| Call Quality Controls (P5-31, user + room-admin caps) | D2-8 |
| Call Permissions (P5-31, hard server-side screenshare/camera policy) | D2-9 |
---
@@ -72,32 +75,32 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
### Confirmed facts
| 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 |
| 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~~ **UNBLOCKED by EC fork**`io.lotus.inject_audio` widget action publishes a clip as a real call track | In-call soundboard CAN now mix into the call (no longer local-only); needs cinny UI to drive the action |
| Folds uses vanilla-extract in non-TDS, NOT CSS custom properties | Custom accent color: must create new vanilla-extract theme variant dynamically |
| Theme presets need ~50 CSS custom properties each | Significant design work before coding |
| `useCallSpeakers.ts` CSS MutationObserver polling | Visual speaking indicator: TDS ring animation on top of existing data |
| MSC3489/3672 live location: BOTH false on server | Live Location BLOCKED |
---
@@ -159,9 +162,17 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
---
### [ ] P3-8 · Thread Panel (full side drawer)
### [~] P3-8 · Thread Panel (full side drawer) — IMPLEMENTED (2026-07), ⚠️ AWAITING LIVE QA
**⚠️ LARGEST FEATURE — requires its own planning session before implementation.**
Built per the design below (4-agent build + 2-agent review). Gates green (tsc/eslint/build/tests). **Release note: threaded replies no longer render inline in the main timeline — roots show a "N replies" chip that opens the panel.**
**Manual QA checklist (post-deploy):**
1. Reply in Thread (message menu) → panel opens; send text/upload/emoji into it (appears pending → confirmed)
2. Reply to a reply inside the panel → event carries `m.thread` + `m.in_reply_to` with `is_falling_back:false`
3. Main timeline: root + chip only (replies absent); chip count/time updates live; unread badge appears for others' thread replies and clears after viewing the panel
4. Room badge clears via normal markAsRead even with unread threads (unthreaded receipt)
5. Reload: partitioning persists; encrypted-room threads decrypt (back-pagination too)
6. Escape / × closes; mobile = fullscreen panel; switching rooms and back restores the open thread
**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:
@@ -193,22 +204,28 @@ Features:
## Priority 4 — Specialized, high complexity, or low priority
### [ ] P4-7 · Virtualized Infinite Scroll for Search Results
### [x] P4-7 · Virtualized Infinite Scroll for Search Results — ALREADY IMPLEMENTED (found 2026-07)
**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.
**Status:** Done in a prior session — `MessageSearch.tsx` already uses `useVirtualizer` (~line 336) over the result groups AND auto-fetches the `nextToken` page when the last virtual item scrolls into view (~line 469) via `useInfiniteQuery`. Nothing left to build.
### [ ] P4-8 · Encrypted Message Search Indexing & Caching
**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)
### [~] P4-1 · Thread Notification Mode Per-Thread — IMPLEMENTED (2026-07), ⚠️ AWAITING LIVE QA
**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).
**Shipped (Slack-style):** default = **Participating** (notified only for threads you've posted in or where you're @mentioned); per-thread override **All / Mentions-only / Mute** via the bell menu in the thread panel header; modes sync across devices (`io.lotus.thread_notifications` account data, pruned on write). Mute also suppresses the chip badge and subtracts the thread from the room's sidebar badge (client-side). Also fixed the underlying path: thread replies are notified via exactly one handler (room-level `ThreadEvent.NewReply`), with the main-timeline notifier + unread binder thread-guarded, and live badge refresh on `RoomEvent.UnreadNotifications`.
**Manual QA checklist (post-deploy):**
1. Friend replies in a thread YOU posted in → notification + sound; in a thread you never touched → silent (chip badge only)
2. @mention in any thread → notified regardless of participation
3. Set a thread to Mute → no notifications, chip badge gone (bell-mute glyph), room sidebar badge drops by that thread's count
4. Set to All → every reply notifies; Mentions-only → only @mentions
5. Second device shows the same per-thread modes (account-data sync)
6. Room-level Mute still silences everything incl. thread overrides
**Known caveats:** Mentions-only can under-notify in E2EE rooms (decision runs pre-decryption — same class as the existing notifier); muted-thread badge subtraction is Lotus-only (other clients still count them).
---
@@ -254,7 +271,7 @@ Features:
- 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`.
**Mozilla.org test enablement: ALREADY DEPLOYED (verified 2026-07)** `matrix/cinny/config.json` homeserverList includes `mozilla.org` and the nginx CSP `connect-src` includes the mozilla/modular/vector domains (`matrix/cinny/nginx.conf:42`). **Nothing blocks the test — just pick mozilla.org on the login screen and complete an OIDC login.**
---
@@ -266,12 +283,17 @@ Features:
---
### [ ] P5-15 · In-Call Soundboard
### [~] P5-15 · In-Call Soundboard — IMPLEMENTED (⚠️ awaiting live verification, D2-7)
**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.
**What:** Soundboard button in the call controls bar → popout grid of the user's clips; clicking one plays it **into the call** as a real published track (peers hear it) and locally (presser hears it). Clips are **user-uploadable, just like custom emojis/stickers**.
**🔱 [EC-FORK] Fork side + cinny side DONE.** The fork ships `io.lotus.inject_audio` (`LotusWidgetActions.InjectAudio`, allow-listed in `widget.ts`), armed via the `lotusAudioInject=1` flag; it publishes a clip as a separate LiveKit track — a **real** in-call soundboard mixed into the call, not local-only. cinny now drives it.
**Shipped (cinny):**
- Clips stored in `io.lotus.soundboard` account data → **synced across devices like emoji/sticker packs** (`useSoundboard` hook; `AccountDataEvent.LotusSoundboard`).
- Upload audio (≤1 MB, ≤40 clips) → `mx.uploadContent` → mxc; play resolves mxc → authed download → `blob:` object URL (the widget can't fetch authenticated media itself) → `control.injectAudio(url, volume)` + local playback.
- `CallSoundboard.tsx` popout in the call bar (upload / play / delete), gated on the `soundboardEnabled` setting (Settings → General → Calls, + volume slider).
**Remaining:** a dedicated Settings management page (optional — upload/delete already live in the popout); a small default clip set; live verification (D2-7). Files: `utils/soundboardClips.ts`, `hooks/useSoundboard.ts`, `features/call/CallSoundboard.tsx`, `plugins/call/CallControl.ts#injectAudio`.
**Complexity:** Medium — done.
---
@@ -287,39 +309,55 @@ Features:
### [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".
**Shipped:** 3-tier setting (Off / Browser-native / ML) in Settings → General → Calls.
**🔱 [EC-FORK] DONE — moved in-source (2026-06).** ML denoise is now a first-class audio stage **inside** the forked Element Call: a LiveKit `TrackProcessor<Audio>` activated by `lotusDenoiseSource=1` (cinny sets it when ML is selected). The old build-time `getUserMedia`/`index.html` monkeypatch is **removed**. Because EC re-runs the processor on every (re)publish, denoise now **survives reconnects and mic-device switches** — this is the A7 fix (see `LOTUS_BUGS.md` A7, `LOTUS_TESTING.md` §D2-1). The processor degrades to the raw mic rather than going silent.
**Key decision:** LiveKit's Krisp filter is LiveKit-Cloud-only (we self-host the SFU); EC's own RNNoise PR #3892 is unmerged. Owning the fork let us implement the in-source stage directly.
**Model Roadmap (priority order):**
**Models — all in-source in the fork:**
- [ ] **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.
- [x] **DeepFilterNet 3** (48 kHz, **ML default**) · **DTLN** (16 kHz) · **RNNoise** (48 kHz) · **Speex** (48 kHz) — all four wired and selectable; dropdown ordered best-quality first. Tier default is **Browser-native**.
- [x] **Quality tuning (2026-07):** dry/wet **attenuation floor** (~-16 dB, RNNoise/Speex only — the "robotic" fix; DTLN/DFN would comb-filter), **gate-after-ML**, **DFN level 80→60**. Floor tunable via `lotusDenoiseFloor`.
- [x] **AEC/AGC (2026-07):** echo-cancellation ON; **AGC OFF for the ML tier** (`autoGainControl=false`, threaded through EC `UrlParams``ConnectionFactory`) so browser AGC doesn't fight the model; playback confirmed no AEC-defeat.
- [x] **Reliability (2026-07):** never-silent watchdog, resume-timeout, WASM-cache reject-eviction, activate-off-local-participant, init/build leak fixes.
- [ ] **Open verification:** real-call by-ear **A/B** — model choice, floor value, AGC on/off (RNNoise known-weak historically). `LOTUS_TESTING.md` §D2-1 / J2.
- [ ] **GTCRN (RESEARCHED — DEFERRED):** tiny MIT 16 kHz model that beats RNNoise, but **no drop-in browser package** — needs a ~1-week from-scratch build: `onnxruntime-web` (WASM, 1 thread) in a **Web Worker** (ORT can't run in an AudioWorklet — issue #13072) behind a custom AudioWorklet ring-buffer node presenting as an `AudioNode`; model `gtcrn_simple.onnx` (~300 KB, stateful — thread `conv/tra/inter` caches per frame); we write STFT/iSTFT (n_fft 512/hop 256). Assets ~34 MB via the `lotusDenoise()` vite plugin. Registration checklist known (both repos, incl. the 2nd `denoisePipeline.ts` used by the DenoiseTester). **Revisit only if low-power quality is insufficient after validating the current tuning.**
- [ ] **Desktop-only / HW-gated (future):** FRCRN or NVIDIA Maxine (RTX/Tensor only) — impossible in-browser; would run in the Tauri Rust backend + bridge a virtual mic into the webview. Detect capability; web falls back to RNNoise.
- **Excluded:** Krisp (LiveKit Cloud only); FRCRN/Maxine on web (GPU/server-bound).
---
### [ ] P5-31 · Granular Voice & Screenshare Quality Controls (Discord-style)
### [~] P5-31 · Granular Voice & Screenshare Quality Controls — IMPLEMENTED (⚠️ awaiting live verification, D2-8)
**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.
**What:** Let users (and room admins) adjust audio bitrate and screenshare bitrate/framerate.
**🔱 [EC-FORK] Fork side + client side DONE.** The fork ships `io.lotus.set_quality` (`LotusWidgetActions.SetQuality`) that applies audio/screenshare encoding params (`RTCRtpSender.setParameters`, all simulcast encodings, re-applied on `TrackUnmuted`/republish) inside EC. cinny now drives it.
**Shipped (cinny):**
1. **User settings** (Settings → General → Calls): Microphone Bitrate, Screenshare Bitrate, Screenshare Framerate (`callAudioBitrate` / `screenshareBitrate` / `screenshareFramerate`).
2. **Room-admin caps**: `io.lotus.room_quality` state event (`StateEvent.LotusRoomQuality`) + `RoomQuality.tsx` in Room Settings → General → Voice (mirrors `RoomVoiceLimit`).
3. **Apply logic**: `useCallQuality` (wired in `CallEmbedProvider`'s `CallUtils`) builds `min(user setting, room cap)` and sends `io.lotus.set_quality` on join / when settings change (`utils/callQuality.ts`, unit-tested).
**Server-side enforcement (DONE — matrix repo):** extended `voice-limit-guard.py` (LXC 151) to also read `io.lotus.room_quality` and hard-enforce a **publish-source policy** for ALL clients.
- **Reality (researched, primary-source, LiveKit 1.9.11):** numeric bitrate/fps caps **cannot** be hard-enforced server-side — LiveKit is a pure SFU (forwards, never transcodes); there is NO bitrate/fps field in the JWT grant, `RoomConfiguration`, server `limit:` config, or any admin RPC, and stock Element Call ignores room metadata / custom claims for publish quality. So numeric caps stay **cooperative** (our fork honors them via `min()``set_quality`, already shipped).
- **What IS hard-enforced cross-client:** `VideoGrant.canPublishSources`. The guard holds the LiveKit secret, so when `io.lotus.room_quality` sets `allow_screenshare:false` / `allow_camera:false` it re-signs the issued JWT with a narrowed source list → the SFU refuses those tracks for **every** client (Element, FluffyChat, our fork). Mic always kept. Fail-open; unit-tested (`livekit/test_voice_limit_guard.py`). Admin UI: Room Settings → Voice → **Call Permissions** switches. cinny also hides the blocked buttons.
- **Live (mid-call) enforcement — DONE:** the JWT re-sign covers new joins; for participants **already in the call**, a background reconcile loop in the guard calls LiveKit `UpdateParticipant` every ~3 s to narrow `canPublishSources`, which unpublishes an in-progress screenshare/camera **server-side for all clients** and blocks re-publish (verified LiveKit 1.9.11 auto-unpublishes on permission narrowing). Only removes forbidden sources (never grants), preserves other permission flags, no-ops once compliant. So flipping a room audio-only kills live cameras/screenshares within ~one interval.
- **Not enforceable / deferred:** numeric server enforcement (impossible — see above); screenshare **resolution** control (`set_quality` covers bitrate + framerate; resolution needs a `getDisplayMedia` hook inside the fork).
**Complexity:** DONE — client (cooperative numeric caps) + server (hard publish-source policy). Only the physically-impossible numeric server enforcement is out of scope.
---
### [ ] P5-35 · Desktop — Notification Click Opens Room (DEFERRED)
### [~] P5-35 · Desktop — Notification Click Opens Room — IMPLEMENTED (Tier B, via the P5-41 WinRT toast: click → open room, reply → send); native CI-compile-pending, runtime-verify on Windows
**What:** Clicking a system tray notification navigates to the relevant room. Quick-reply from the notification toast would send the reply without opening the window.
**Status:** Deferred `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.
**Status:** Deferred (Tier B). Note: the "can't compile-test without a Windows build environment" premise is **outdated** — CI now compiles Windows (Gitea self-hosted `windows` runner + GitHub `windows-latest`), and `windows`-crate/COM code already ships (e.g. `set_badge_count`, and the Tier A jump list). This still depends on P5-41 (the WinRT toast + custom activator), so it rides with that; it was not part of the Tier A wave.
**Note:** Tray icon and `matrix:` deep links already bring the window forward on most interactions. Revisit when tauri-plugin-notification gains click handler support upstream.
**Complexity:** High (platform-specific native code required).
---
### [ ] P5-36 · Desktop — Windows Jump List (DEFERRED)
### [~] P5-36 · Desktop — Windows Jump List — IMPLEMENTED (Tier A); native CI-compile-pending, runtime-verify on Windows
**What:** Right-clicking the taskbar icon shows a jump list with recent/favorite rooms for quick navigation.
**Status:** Deferred — implementing the Windows COM jump list API in Tauri requires iterating on C++/COM code that can only be compile-checked on Windows, making blind CI iteration impractical.
@@ -328,78 +366,86 @@ Features:
---
### [ ] P5-41 · Desktop — Native WinRT Toast Notifications
### [~] P5-41 · Desktop — Native WinRT Toast Notifications — IMPLEMENTED (Tier B; ToastNotification + reply input + in-process Activated; falls back to tauri-plugin-notification); native CI-compile-pending. Runtime needs a Start-menu shortcut + matching AppUserModelID to surface
**What:** Replace emulated notifications with native WinRT Toast notifications.
**Approach:** Implement native WinRT Toast integration using `windows-rs` to enable full Action Center integration, including native Quick Reply functionality.
### [ ] P5-42 · Desktop — Persistent Background Sync
### [~] P5-42 · Desktop — Persistent Background Sync — IMPLEMENTED (Batch 3, pragmatic keep-alive); native CI-compile-pending, runtime-verify on Windows
**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.
**What:** Keep receiving messages/notifications instantly while the app is closed to the tray.
**Shipped approach (80/20):** rather than a multi-sprint headless Rust sync client, disable Chromium background throttling via WebView2 `additional_browser_args` (`--disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows`, added to the existing Tauri default args) so the existing JS Matrix `/sync` loop keeps running full-speed in the tray. Windows/WebView2 only; does not block system sleep. See `cinny-desktop/src-tauri/src/lib.rs` (WebviewWindowBuilder).
**Deferred (not needed):** the full headless Rust sidecar — only revisit if WebView2 ever hard-suspends despite these flags (would require a second Matrix client with its own /sync + push-rule eval + E2EE-aware notification content).
### [ ] P5-43 · Desktop — System Media Transport Controls (SMTC)
### [~] P5-43 · Desktop — System Media Transport Controls (SMTC) — IMPLEMENTED (Tier A); CI-compile-pending; SMTC may need an active media/audio session to surface — verify on Windows
**What:** Integrate with Windows SMTC for volume flyout call/media control.
**Approach:** Use Windows SMTC API to expose call status, mic mute/unmute, and media controls to the Windows volume flyout/media overlay.
### [ ] P5-44 · Desktop — Taskbar Thumbnail Toolbar
### [~] P5-44 · Desktop — Taskbar Thumbnail Toolbar — IMPLEMENTED (Tier A); native CI-compile-pending, runtime-verify on Windows
**What:** Add persistent call controls to the taskbar preview.
**Approach:** Implement a COM thumbnail toolbar in the application preview window, featuring Mute/Deafen/End Call buttons.
### [ ] P5-46 · Desktop — System Power Management (Call Continuity)
### [~] P5-46 · Desktop — System Power Management (Call Continuity) — IMPLEMENTED (Tier A reference); web verified; native CI-compile-pending (Windows only; macOS/Linux no-op TODO)
**What:** Prevent system sleep/hibernate during active calls.
**Approach:** Use Tauri/Rust `power-manager` or platform-specific APIs to block system power saving states while a voice/video session is active.
### [ ] P5-47 · Desktop — TDS-Styled Native Window Chrome
### [~] P5-47 · Desktop — TDS-Styled Native Window Chrome — IMPLEMENTED (Tier A, OPT-IN/default-off, runtime-reversible); web verified; native CI-compile-pending
**What:** Replace system titlebar with custom Lotus TDS chrome.
**Approach:** Configure Tauri window (`decorations: false`) and implement custom, TDS-token compliant titlebar controls (Close/Max/Min) for a cohesive UI.
### [ ] P5-48 · Desktop — Native File System Drag-and-Drop Improvements
### [~] P5-48 · Desktop — Native File System Drag-and-Drop Improvements — IMPLEMENTED (recursive folder upload, web-verified: tsc/build/tests). SCOPED-OUT: `.lnk` shortcut resolution (webview never exposes a dropped file's OS path → native can't resolve the target) and "Send To" (installer/registry shell integration) — deferred
**What:** Enhance drag-and-drop support for Windows.
**Approach:** Improve handling for Windows file shortcuts, recursive folder uploads, and shell-integrated "Send To" context menu actions.
### [ ] P5-49 · Desktop — Network Awareness (NCSI Integration)
### [~] P5-49 · Desktop — Network Awareness (NCSI Integration) — IMPLEMENTED (Tier A; INetworkListManager poll → mx.retryImmediately); native CI-compile-pending, runtime-verify on Windows
**What:** Proactively detect Windows network connectivity changes.
**Approach:** Integrate with the Windows Network Connectivity Status Indicator (NCSI) API to improve offline mode transition latency and network recovery.
### [ ] P5-50 · Desktop — Windows Hardware-Accelerated Media Pipeline
### [WON'T FIX] 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.
**Why won't-fix (researched):** WebRTC media (the call pipeline) lives entirely inside WebView2/Chromium — you cannot inject Media Foundation/DirectShow into WebRTC's decode path from the Tauri host. Chromium already uses the platform's hardware decoders (D3D11VA/MF) where the GPU supports the codec, so there is no separate CPU pipeline to offload. Not actionable as described.
### [ ] P5-51 · Desktop — Federated "Identity Contexts" (Isolation Manager)
### [DEFERRED] 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.
**What:** Compartmentalize sessions, local databases, and caches into isolated "Contexts" (e.g. Work vs. Personal) with a zero-leak boundary.
**Decision:** Deferred (reviewed 2026-07). Multi-sprint, and it touches the auth/crypto/storage core — not worth the risk/effort for a niche need right now. Kept here with a concrete spec so it's actionable later.
**Future-work spec (why it's big):** the app is currently **single-session**.
- Session lives in `src/app/state/sessions.ts` under fixed localStorage keys — `cinny_access_token`, `cinny_device_id`, `cinny_user_id`, `cinny_hs_base_url`, plus the OIDC keys (`cinny_refresh_token`, `cinny_expires_at`, `cinny_oidc_*`).
- Persistence lives in `src/client/initMatrix.ts`: two fixed IndexedDB stores — `web-sync-store` (`IndexedDBStore`) and `crypto-store` (`IndexedDBCryptoStore`) — feeding one `createClient(...)`.
True per-context isolation would require: (1) namespace every localStorage key per context (`ctx:<id>:cinny_*`); (2) per-context IndexedDB dbNames for **both** the sync store and the crypto store; (3) a context registry + switcher UI (create/rename/delete/switch); (4) full client teardown + re-init on switch (`initMatrix` currently assumes one global client); (5) per-context settings + notification/quiet-hours state; (6) careful crypto-store isolation so device keys never bleed across contexts. **Smaller intermediate step** if demand appears: plain multi-account (fast account switch) *without* the hard isolation boundary — much less risky, reuses most of the login flow.
**Priority:** Extreme Low (Multi-sprint/Architectural).
### [~] P5-52 · Desktop — Room-Level Sync Governor (Performance Control) [STILL_CONSIDERING]
### [DROPPED] P5-52 · Desktop — Room-Level Sync Governor (Performance Control)
**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.
**What:** Granular per-room sync tuning (frequency, event-type filtering).
**Why dropped (reviewed 2026-07):** matrix-js-sdk can't do **true** per-room sync filtering — all room events still come down the single `/sync` stream, so "disable typing/receipts in heavy rooms" can only be a **cosmetic client-side hide**, not an actual performance/bandwidth win. That, plus the UX-complexity risk flagged originally, makes it not worth building. If per-room quieting is ever wanted, add a simple "mute typing & receipts in this room" toggle to normal room settings — not a "governor."
### [ ] P5-53 · Desktop — Local-Only "Scripting" Plugin System (Tampermonkey-like)
### [DEFERRED] 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.
**Decision:** Deferred (reviewed 2026-07). A full WASM execution engine + script registry + management UI + security model is a large surface for a very small (power-user) audience.
**Recommended lighter alternative (the ~80/20) if we ever want event automation:** a built-in **automation-rules** feature — declarative "when an incoming event matches X (room / sender / keyword / type) → notify / play sound / highlight / auto-react" rules, configured in Settings. Covers the realistic use cases (custom alerts, keyword pings) with **no arbitrary code execution**, so no sandbox/security burden. Build that instead of a scripting engine if the need arises.
### [ ] P5-55 · Desktop — Composer Toolbar Drag-and-Drop Reordering
### [~] P5-55 · Desktop — Composer Toolbar Drag-and-Drop Reordering — IMPLEMENTED (Tier A); web-verified (tsc/build/tests). Awaiting live UX check
**What:** Allow users to reorder toolbar icons via drag-and-drop.
**Approach:** Extend the current settings-based toolbar toggle system to include a drag-and-drop UI mode in the composer settings, allowing users to personalize their icon order.
### [ ] P5-56 · Desktop — Windows "Focus Assist" (DND) Sync
### [~] P5-56 · Desktop — Windows "Focus Assist" (DND) Sync — IMPLEMENTED (Tier B; SHQueryUserNotificationState poll → suppresses notifications+sounds via the quiet-hours gate); native CI-compile-pending, runtime-verify on Windows
**What:** Automatically toggle notification state based on Windows Focus Assist.
**Approach:** Integrate with the Windows `NotificationCenter` / `Focus` state via Tauri/Rust to automatically enable/disable Lotus Chat's internal notification suppression mode when Windows Focus Assist is toggled.
### [ ] P5-57 · Desktop — Visual Draft Persistence Indicator
### [~] P5-57 · Desktop — Visual Draft Persistence Indicator — IMPLEMENTED (Tier A); web-verified. Awaiting live UX check
---
@@ -450,9 +496,9 @@ Check back after each Synapse upgrade — re-run `/matrix/client/versions` and `
## Pending Audits
### [ ] Audit-3 · Profile banner image — Matrix protocol support
### [DEFERRED] Audit-3 · Profile banner image — Matrix protocol support — RESEARCHED (2026-07)
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.
**Finding:** [MSC4427 — Custom banners for user profiles](https://github.com/matrix-org/matrix-spec-proposals/pull/4427) defines a `banner_url` profile field on top of the MSC4133 extensible-profile system (which our server supports, `uk.tcpip.msc4133.stable = true`, and which became stable in Matrix v1.16). However MSC4427 is an **open proposal, not merged** — no cross-client standard yet, so per this item's own rule: do not implement. **Revisit when MSC4427 merges** (implementation would then be small: read/write the field via the MSC4133 profile API + render a banner in UserHero/profile popouts).
---
@@ -460,26 +506,35 @@ Research whether Matrix spec or MSC4133 (v1.16) defines a standard profile banne
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)
### P3-8 · Thread Panel (Full Side Drawer) — 🟢 FULL DESIGN (2026-07, ready to execute)
**Architecture:** Mirror the `MembersDrawer` pattern but with a specialized timeline.
**Decisions (each backed by SDK evidence in node_modules/matrix-js-sdk):**
- **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.
| Question | Decision |
|---|---|
| Thread rendering | **New lean `ThreadTimeline`** reusing `Message`, `useVirtualPaginator`, and RoomTimeline's exported timeline helpers (lines 156-227). Do NOT refactor 2214-line RoomTimeline (its ~35 hooks are hardwired to the room live timeline). |
| threadSupport | **Enable `threadSupport: true`** in `initMatrix.ts` (~line 39). ⚠️ Thread replies then LEAVE the main timeline (`room.js eventShouldLiveIn``shouldLiveInRoom:false`), retroactively on reload — MUST ship the "N replies" summary chip in the same release. Roots stay in both timelines. |
| State | `roomIdToActiveThreadIdAtomFamily` (per-room, mirrors `roomIdToReplyDraftAtomFamily`) in new `state/room/thread.ts` + `getThreadDraftKey(roomId, threadRootId)` = `` `${roomId}::${threadRootId}` `` |
| Composer | **Reuse RoomInput**: add optional `threadRootId` prop; scope its 3 atom-family lookups by draftKey (isolates thread drafts from the main composer); pass `threadRootId ?? null` at all 7 `mx.sendMessage/sendEvent` call sites — the SDK's `addThreadRelationIfNeeded` then emits spec-correct `m.thread` relations incl. reply-in-thread. Separate `useEditor()` instance in the panel. Hide schedule + commands in thread mode v1. |
| Unreads | v1 = unread badge on the summary chip (`room.getThreadUnreadNotificationCount` — counts already synced independent of threadSupport) + `markThreadAsRead` threaded receipt when panel open at bottom. |
| Mobile | Pure CSS like `MembersDrawer.css.ts`: fixed width toRem(360) desktop, `position:fixed; inset:0` under 750px. |
**Critical side-effect fixes (one-liners, land FIRST):**
1. `initMatrix.ts` → `threadSupport: true`.
2. `utils/notifications.ts:24` → `sendReadReceipt(latestEvent, type, /*unthreaded*/ true)` — otherwise markAsRead becomes `main`-scoped and room badges stick permanently unread (room unread total includes thread counts).
**Known SDK traps (verified):**
- **Local echo gap:** chronological pending ordering means the thread timelineSet never receives pending events (`canContain` rejects; `room.getPendingEvents()` THROWS in this mode) — ThreadTimeline must render its own pending strip via `RoomEvent.LocalEchoUpdated` filtering on `threadRootId`, deduped against `thread.findEventById`.
- **Bootstrap:** `room.getThread(id) ?? room.createThread(id, room.findEventById(id), [], false)` — the SDK auto-fetches via `/relations` and inserts the root at top; gate rendering on `thread.initialEventsFetched`; decrypt with `decryptAllTimelineEvent` after init + each pagination.
- **Deep links:** `getEventTimeline(mainSet, threadEventId)` returns undefined for thread events — redirect jump-to-event to the panel (best-effort v1).
- **Summary chip** must render from the server-aggregated bundle (`unsigned['m.relations']['m.thread']`) so it works before any Thread object exists.
- Room-list "latest message" preview may show the root, not the newest reply — cosmetic, accept v1.
**File inventory — new:** `state/room/thread.ts` (+test), `features/room/thread/{useThread.ts, threadSummary.ts(+test), ThreadTimeline.tsx(+css), ThreadPanel.tsx(+css), ThreadSummary.tsx, index.ts}`, `hooks/useThreadSummary.ts`. **Edited:** `initMatrix.ts` + `utils/notifications.ts` (coordinator, step 0), `RoomInput.tsx` (threadRootId prop), `RoomTimeline.tsx` (handleReplyClick startThread → open panel; ThreadSummary chips at the two Message call sites; Reply onThreadClick; deep-link redirect), `components/message/Reply.tsx`, `Room.tsx` (render panel after MediaGallery block, gated `!callView && activeThreadId`, `key={roomId+threadId}`).
**4-agent partition:** step 0 (coordinator one-liners) → A: state+SDK glue (+tests) · B: ThreadTimeline (largest; copies the `useTimelinePagination` pattern rather than exporting it) · C: RoomInput changes · D: panel shell + RoomTimeline/Reply integration — all parallel against pinned interface contracts → coordinator wires Room.tsx + gates.
**Verification:** gates (tsc/eslint/build/tests) + post-merge manual QA: open thread via chip/menu/indicator; pending→confirmed echo; `is_falling_back:false` on reply-in-thread; main timeline shows root+chip only; badge clears; reload keeps partitioning; encrypted threads decrypt. **Release note required:** threaded replies no longer render inline in the main timeline.
---
@@ -540,7 +595,7 @@ Exhaustive, low-level implementation details for backlog items. Follow these pat
> ⚠️ **[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.
> 🔱 **[EC-FORK — RESOLVED]** Both the original claim and the earlier "practical blocker still holds" correction are now **outdated**. EC is same-origin **and** we own the source, so we no longer reach into EC's module scope from cinny — instead the fork **exposes the inject point itself**: the `io.lotus.inject_audio` widget action (`LotusWidgetActions.InjectAudio`) publishes a clip as a separate LiveKit track from inside EC. A **real** in-call soundboard (mixed into the call, not local-only) is therefore unblocked, and the cinny-side soundboard UI is now **built** (P5-15 above): uploadable clips played into the call via this action, stored in `io.lotus.soundboard` account data.
---
@@ -611,7 +666,7 @@ See shipped implementation in LOTUS_FEATURES.md → "Noise Suppression (Advanced
---
### P5-40 · Desktop — Proactive Update Notifications (Tauri)
### [x] P5-40 · Desktop — Proactive Update Notifications (Tauri) — DONE (already shipped: `TauriUpdateFeature` in ClientNonUIFeatures.tsx polls every 12h + fires the sticky update toast)
**Key Files:** `src/app/hooks/useTauriUpdater.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx`, `src/app/features/toast/LotusToastContainer.tsx`.
+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 full plan and integration map is in
**[`HANDOFF_ELEMENT_CALL_FORK.md`](HANDOFF_ELEMENT_CALL_FORK.md)**; 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
+54 -40
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,9 +49,10 @@
"immer": "11.1.8",
"is-hotkey": "0.2.0",
"jotai": "2.20.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",
@@ -74,7 +73,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,6 +83,7 @@
"@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/react": "19.2.15",
@@ -2695,9 +2696,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 +3919,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 +3965,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",
@@ -4042,7 +4040,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",
@@ -5541,12 +5539,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": {
@@ -6187,15 +6189,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",
@@ -9087,6 +9080,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 +9955,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",
@@ -13194,7 +13212,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 +13252,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 +13288,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 +13297,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"
+5 -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,9 +74,10 @@
"immer": "11.1.8",
"is-hotkey": "0.2.0",
"jotai": "2.20.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",
@@ -99,7 +98,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,6 +108,7 @@
"@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/react": "19.2.15",
+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';
@@ -584,6 +585,7 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
useCallMemberSoundSync(embed);
useCallJoinLeaveSounds(embed);
useCallThemeSync(embed);
useCallQuality(embed);
useCallHangupEvent(
embed,
useCallback(() => {
@@ -0,0 +1,25 @@
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';
/**
* 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
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>
);
}
@@ -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,
+41
View File
@@ -0,0 +1,41 @@
import React from 'react';
import katex from 'katex';
import 'katex/dist/katex.min.css';
type KaTeXProps = {
/** Raw LaTeX source (without `$`/`$$` delimiters). */
latex: string;
/** Render as block (display) math when true, inline otherwise. */
displayMode?: boolean;
};
/**
* Lazily-loaded KaTeX renderer.
*
* This module statically imports `katex` and its stylesheet, so both only enter
* the bundle via the dynamic `import()` of this file (see the `lazy()` wrapper
* in `react-custom-html-parser.tsx`). They are therefore NOT part of the eager
* import graph.
*
* We render with `throwOnError: false`, so KaTeX itself renders a parse error
* inline (in its error colour) rather than throwing. The HTML returned by
* `renderToString` is produced by our own trusted call from a fixed options
* object — it is safe to inject via `dangerouslySetInnerHTML`.
*/
export default function KaTeX({ latex, displayMode = false }: KaTeXProps) {
const html = katex.renderToString(latex, {
displayMode,
throwOnError: false,
output: 'htmlAndMathml',
});
const Wrapper = displayMode ? 'div' : 'span';
return (
<Wrapper
// KaTeX output is generated by our own render call (trusted-safe).
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
+3 -1
View File
@@ -61,6 +61,7 @@ type ReplyProps = {
replyEventId: string;
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"
/>
)}
@@ -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,407 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import {
Box,
Button,
Chip,
Icon,
IconButton,
Icons,
Input,
PopOut,
Spinner,
Text,
color,
config,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { EmojiBoard } from '../emoji-board';
import { SoundboardClip, SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
import { uniqueShortcode } from '../../plugins/soundboard/utils';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import {
playClipLocally,
resolveClipObjectUrl,
SOUNDBOARD_ACCEPT,
SOUNDBOARD_MAX_CLIP_BYTES,
SOUNDBOARD_MAX_CLIPS,
} from '../../utils/soundboardClips';
import { stopPropagation } from '../../utils/keyboard';
type ClipDraft = {
url: string;
body: string;
emoji: string;
volume: number;
info?: SoundboardClip['info'];
};
type SoundboardPackEditorProps = {
pack: SoundboardPack;
canEdit?: boolean;
onUpdate: (content: SoundboardContent) => Promise<void>;
};
/**
* Reusable single-pack soundboard manager (used by the settings page and the
* in-call management mode). Mirrors image-pack-view/ImagePackContent's staged-
* edit + batched-save pattern, but per-clip fields are name + emoji + volume.
*/
export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPackEditorProps) {
const mx = useMatrixClient();
// Staged, unsaved state:
const [drafts, setDrafts] = useState<Map<string, ClipDraft>>(new Map()); // shortcode -> edits
const [deleted, setDeleted] = useState<Set<string>>(new Set());
const [uploads, setUploads] = useState<Array<{ shortcode: string } & ClipDraft>>([]);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string>();
const [emojiFor, setEmojiFor] = useState<string>(); // shortcode currently picking an emoji
const [busyPreview, setBusyPreview] = useState<string>();
const fileInputRef = useRef<HTMLInputElement>(null);
const emojiAnchorRef = useRef<HTMLElement | null>(null);
const existing = useMemo(() => pack.getClips(), [pack]);
const clipCount = existing.filter((c) => !deleted.has(c.shortcode)).length + uploads.length;
const dirty = drafts.size > 0 || deleted.size > 0 || uploads.length > 0;
const draftFor = (shortcode: string, base: { body: string; emoji: string; volume: number }) =>
drafts.get(shortcode) ?? { url: '', ...base };
const setDraft = (shortcode: string, patch: Partial<ClipDraft>, base: ClipDraft) => {
setDrafts((prev) => {
const next = new Map(prev);
next.set(shortcode, { ...base, ...(next.get(shortcode) ?? {}), ...patch });
return next;
});
};
const preview = useCallback(
async (id: string, mxc: string, volume: number) => {
setBusyPreview(id);
try {
const url = await resolveClipObjectUrl(mx, mxc);
playClipLocally(url, volume / 100);
} catch {
/* ignore preview errors */
} finally {
setBusyPreview(undefined);
}
},
[mx],
);
const handleFiles = useCallback(
async (files: FileList | null) => {
if (!files || files.length === 0) return;
setUploading(true);
setError(undefined);
try {
const taken = new Set<string>([
...existing.map((c) => c.shortcode),
...uploads.map((u) => u.shortcode),
]);
for (let i = 0; i < files.length; i += 1) {
const file = files[i];
if (clipCount + uploads.length >= SOUNDBOARD_MAX_CLIPS) {
throw new Error(`Soundboard is full (max ${SOUNDBOARD_MAX_CLIPS} clips).`);
}
if (file.size > SOUNDBOARD_MAX_CLIP_BYTES) {
throw new Error(`"${file.name}" is too large (max 1 MB).`);
}
// eslint-disable-next-line no-await-in-loop
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
const mxc = res.content_uri;
if (!mxc) throw new Error('Upload failed.');
const name = file.name.replace(/\.[^/.]+$/, '');
const shortcode = uniqueShortcode(name, taken);
taken.add(shortcode);
setUploads((prev) => [
...prev,
{
shortcode,
url: mxc,
body: name,
emoji: '',
volume: 100,
info: { mimetype: file.type || undefined, size: file.size },
},
]);
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Upload failed.');
} finally {
setUploading(false);
}
},
[mx, existing, uploads, clipCount],
);
const [saveState, save] = useAsyncCallback(
useCallback(async () => {
const clips: Record<string, SoundboardClip> = {};
existing.forEach((c) => {
if (deleted.has(c.shortcode)) return;
const d = drafts.get(c.shortcode);
clips[c.shortcode] = {
url: c.url,
body: d ? d.body : c.body,
emoji: d ? d.emoji || undefined : c.emoji,
volume: d ? d.volume : c.volume,
info: c.info,
};
});
uploads.forEach((u) => {
clips[u.shortcode] = {
url: u.url,
body: u.body,
emoji: u.emoji || undefined,
volume: u.volume,
info: u.info,
};
});
await onUpdate({ pack: pack.meta.content, clips });
setDrafts(new Map());
setDeleted(new Set());
setUploads([]);
}, [existing, deleted, drafts, uploads, onUpdate, pack]),
);
const saving = saveState.status === AsyncStatus.Loading;
const renderRow = (key: string, base: ClipDraft, isUpload: boolean, markedDeleted: boolean) => {
const d = isUpload ? base : draftFor(key, base);
const rowVolume = isUpload ? base.volume : d.volume;
const rowBody = isUpload ? base.body : d.body;
const rowEmoji = isUpload ? base.emoji : d.emoji;
const commit = (patch: Partial<ClipDraft>) => {
if (isUpload) {
setUploads((prev) => prev.map((u) => (u.shortcode === key ? { ...u, ...patch } : u)));
} else {
setDraft(key, patch, base);
}
};
return (
<Box
key={key}
alignItems="Center"
gap="200"
style={{
padding: config.space.S200,
borderRadius: config.radii.R400,
background: color.SurfaceVariant.Container,
opacity: markedDeleted ? 0.5 : 1,
}}
>
<IconButton
size="300"
radii="300"
variant="Secondary"
disabled={busyPreview === key}
onClick={() => preview(key, base.url, rowVolume)}
aria-label={`Preview ${rowBody}`}
>
{busyPreview === key ? <Spinner size="100" /> : <Icon size="100" src={Icons.Play} />}
</IconButton>
<IconButton
size="300"
radii="300"
variant="Secondary"
disabled={!canEdit || markedDeleted}
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
emojiAnchorRef.current = evt.currentTarget;
setEmojiFor(key);
}}
aria-label="Pick emoji"
>
<Text size="T400">{rowEmoji || '🔊'}</Text>
</IconButton>
<Box grow="Yes">
<Input
variant="Surface"
size="300"
defaultValue={rowBody}
readOnly={!canEdit || markedDeleted}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => commit({ body: e.target.value })}
aria-label="Clip name"
/>
</Box>
<Box alignItems="Center" gap="100" shrink="No" style={{ width: toRem(120) }}>
<Icon size="50" src={Icons.VolumeHigh} />
<input
type="range"
min={0}
max={100}
step={5}
defaultValue={rowVolume}
disabled={!canEdit || markedDeleted}
onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })}
style={{ flexGrow: 1 }}
aria-label="Clip volume"
/>
</Box>
{canEdit && !isUpload && (
<IconButton
size="300"
radii="300"
variant={markedDeleted ? 'Success' : 'Critical'}
onClick={() =>
setDeleted((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
})
}
aria-label={markedDeleted ? 'Undo delete' : 'Delete clip'}
>
<Icon size="100" src={markedDeleted ? Icons.Plus : Icons.Delete} />
</IconButton>
)}
{canEdit && isUpload && (
<IconButton
size="300"
radii="300"
variant="Critical"
onClick={() => setUploads((prev) => prev.filter((u) => u.shortcode !== key))}
aria-label="Remove upload"
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
)}
</Box>
);
};
return (
<Box direction="Column" gap="300">
<input
ref={fileInputRef}
type="file"
accept={SOUNDBOARD_ACCEPT}
multiple
hidden
onChange={(e) => {
handleFiles(e.target.files);
e.target.value = '';
}}
/>
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="H4">{pack.meta.name ?? 'Soundboard'}</Text>
{canEdit && (
<Chip
variant="Secondary"
radii="Pill"
disabled={uploading || clipCount >= SOUNDBOARD_MAX_CLIPS}
onClick={() => fileInputRef.current?.click()}
before={uploading ? <Spinner size="100" /> : <Icon size="100" src={Icons.Plus} />}
>
<Text size="B300">Upload</Text>
</Chip>
)}
</Box>
<Box direction="Column" gap="100">
{existing.map((c) =>
renderRow(
c.shortcode,
{ url: c.url, body: c.body ?? c.shortcode, emoji: c.emoji ?? '', volume: c.volume },
false,
deleted.has(c.shortcode),
),
)}
{uploads.map((u) => renderRow(u.shortcode, u, true, false))}
{existing.length === 0 && uploads.length === 0 && (
<Text size="T200" priority="300">
No clips yet. Upload a short audio clip (max 1 MB){canEdit ? '' : ' — ask an admin'}.
</Text>
)}
</Box>
{error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{error}
</Text>
)}
{canEdit && dirty && (
<Box gap="200">
<Button
size="300"
variant="Success"
radii="300"
disabled={saving}
onClick={() => save()}
before={saving ? <Spinner size="100" fill="Solid" /> : undefined}
>
<Text size="B300">Save changes</Text>
</Button>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
disabled={saving}
onClick={() => {
setDrafts(new Map());
setDeleted(new Set());
setUploads([]);
setError(undefined);
}}
>
<Text size="B300">Reset</Text>
</Button>
</Box>
)}
<PopOut
anchor={emojiFor ? emojiAnchorRef.current?.getBoundingClientRect() : undefined}
position="Bottom"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setEmojiFor(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<EmojiBoard
imagePackRooms={[]}
returnFocusOnDeactivate={false}
onEmojiSelect={(unicode: string) => {
const key = emojiFor;
setEmojiFor(undefined);
if (!key) return;
const up = uploads.find((u) => u.shortcode === key);
if (up) {
setUploads((prev) =>
prev.map((u) => (u.shortcode === key ? { ...u, emoji: unicode } : u)),
);
} else {
const c = existing.find((x) => x.shortcode === key);
if (c)
setDraft(
key,
{ emoji: unicode },
{
url: c.url,
body: c.body ?? c.shortcode,
emoji: c.emoji ?? '',
volume: c.volume,
},
);
}
}}
requestClose={() => setEmojiFor(undefined)}
/>
</FocusTrap>
}
>
<span />
</PopOut>
</Box>
);
}
@@ -0,0 +1,32 @@
import React, { useCallback, useMemo } from 'react';
import { SoundboardPackEditor } from './SoundboardPackEditor';
import { SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { useUserSoundboardPack } from '../../hooks/useSoundboardPacks';
export function UserSoundboardPack() {
const mx = useMatrixClient();
const defaultPack = useMemo(
() =>
new SoundboardPack(
mx.getUserId() ?? '',
{ pack: { display_name: 'My Soundboard' } },
undefined,
),
[mx],
);
const pack = useUserSoundboardPack() ?? defaultPack;
const handleUpdate = useCallback(
async (content: SoundboardContent) => {
await mx.setAccountData(
AccountDataEvent.LotusSoundboard as unknown as keyof import('matrix-js-sdk').AccountDataEvents,
content as never,
);
},
[mx],
);
return <SoundboardPackEditor pack={pack} canEdit onUpdate={handleUpdate} />;
}
@@ -0,0 +1,3 @@
export * from './SoundboardPackEditor';
export * from './RoomSoundboardPack';
export * from './UserSoundboardPack';
+45 -17
View File
@@ -37,6 +37,10 @@ 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;
@@ -88,6 +92,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)
@@ -334,29 +351,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"
+254
View File
@@ -0,0 +1,254 @@
import React, { MouseEventHandler, useCallback, useMemo, 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>();
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 = () => 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),
height: toRem(76),
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" truncate style={{ maxWidth: '100%' }}>
{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>
);
}
@@ -4,6 +4,7 @@ export * from './RoomHistoryVisibility';
export * from './RoomJoinRules';
export * from './RoomProfile';
export * from './RoomPublish';
export * from './RoomQuality';
export * from './RoomShareInvite';
export * from './RoomUpgrade';
export * from './RoomVoiceLimit';
@@ -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 };
+62 -409
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,67 +113,25 @@ 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 = (
@@ -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;
@@ -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,7 @@ import {
RoomLocalAddresses,
RoomPublishedAddresses,
RoomPublish,
RoomQuality,
RoomShareInvite,
RoomUpgrade,
RoomVoiceLimit,
@@ -58,6 +59,7 @@ export function General({ requestClose }: GeneralProps) {
<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>
);
}
+52 -4
View File
@@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import { Box, Line } from 'folds';
import { useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey';
@@ -22,6 +22,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,6 +35,8 @@ 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 [hideActivity] = useSetting(settingsAtom, 'hideActivity');
@@ -45,15 +49,46 @@ 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;
// Thread panel and media gallery are mutually exclusive on every screen size:
// opening one closes the other. Detect the just-opened transition so whichever
// was opened most recently wins.
const prevThreadRef = useRef(activeThreadId);
const prevGalleryRef = useRef(galleryOpen);
useEffect(() => {
const threadJustOpened = Boolean(activeThreadId) && !prevThreadRef.current;
const galleryJustOpened = galleryOpen && !prevGalleryRef.current;
if (threadJustOpened && galleryOpen) {
setGalleryOpen(false);
} else if (galleryJustOpened && activeThreadId) {
setActiveThreadId(null);
}
prevThreadRef.current = activeThreadId;
prevGalleryRef.current = galleryOpen;
}, [activeThreadId, galleryOpen, setGalleryOpen, setActiveThreadId]);
// On non-desktop screens at most one right-side panel may show, priority
// thread > gallery > members. On desktop thread + members may coexist while
// thread + gallery stay mutually exclusive (via the effect above).
const isDesktop = screenSize === ScreenSize.Desktop;
const showThreadPanel = !callView && Boolean(activeThreadId);
const showGallery = !callView && galleryOpen && (isDesktop || !activeThreadId);
const showMembers = !callView && isDrawer && (isDesktop || (!activeThreadId && !galleryOpen));
return (
<PowerLevelsContextProvider value={powerLevels}>
<Box grow="Yes">
@@ -82,7 +117,7 @@ export function Room() {
<CallChatView />
</>
)}
{!callView && galleryOpen && (
{showGallery && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
@@ -90,7 +125,20 @@ export function Room() {
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
</>
)}
{!callView && isDrawer && (
{showThreadPanel && activeThreadId && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<ThreadPanel
key={`${room.roomId}${activeThreadId}`}
room={room}
threadId={activeThreadId}
requestClose={() => setActiveThreadId(null)}
/>
</>
)}
{showMembers && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
+316 -210
View File
@@ -1,9 +1,11 @@
import React, {
KeyboardEventHandler,
ReactNode,
RefObject,
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -98,7 +100,11 @@ import { safeFile } from '../../utils/mimeTypes';
import { fulfilledPromiseSettledResult } from '../../utils/common';
import { useSetting } from '../../state/hooks/settings';
import { useAlive } from '../../hooks/useAlive';
import { settingsAtom } from '../../state/settings';
import {
ComposerToolbarButtonKey,
normalizeComposerToolbarOrder,
settingsAtom,
} from '../../state/settings';
import {
getAudioMsgContent,
getFileMsgContent,
@@ -128,7 +134,9 @@ import { PollCreator } from './PollCreator';
import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus';
import { ScheduleMessageModal } from './ScheduleMessageModal';
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
import { DraftIndicator } from './DraftIndicator';
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
import { getThreadDraftKey } from '../../state/room/thread';
const GifPicker = React.lazy(() =>
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
@@ -142,9 +150,10 @@ interface RoomInputProps {
fileDropContainerRef: RefObject<HTMLElement>;
roomId: string;
room: Room;
threadRootId?: string;
}
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
({ editor, fileDropContainerRef, roomId, room }, ref) => {
({ editor, fileDropContainerRef, roomId, room, threadRootId }, ref) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
@@ -177,8 +186,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const setScheduledMessages = useSetAtom(scheduledMessagesAtom);
const alive = useAlive();
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
// Scope drafts/replies/uploads by thread so a thread composer stays fully
// isolated from the main room composer (and from other threads).
const draftKey = threadRootId ? getThreadDraftKey(roomId, threadRootId) : roomId;
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(draftKey));
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(draftKey));
const replyUserID = replyDraft?.userId;
const powerLevelTags = usePowerLevelTags(room, powerLevels);
@@ -199,7 +211,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor;
const [uploadBoard, setUploadBoard] = useState(true);
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(draftKey));
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
roomUploadAtomFamily,
selectedFiles.map((f) => f.file),
@@ -218,7 +230,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const showLocation = composerToolbarButtons?.showLocation ?? true;
const showPoll = composerToolbarButtons?.showPoll ?? true;
const showVoice = composerToolbarButtons?.showVoice ?? true;
const showSchedule = composerToolbarButtons?.showSchedule ?? true;
// Schedule-send is hidden in thread mode (v1 reduction).
const showSchedule = (composerToolbarButtons?.showSchedule ?? true) && !threadRootId;
const composerButtonOrder = useMemo(
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
[composerToolbarButtons?.order],
);
const [locating, setLocating] = React.useState(false);
const [locationError, setLocationError] = React.useState<string | null>(null);
const handleShareLocation = useCallback(() => {
@@ -233,7 +250,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
setLocating(false);
const { latitude, longitude } = pos.coords;
const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
mx.sendMessage(roomId, {
mx.sendMessage(roomId, threadRootId ?? null, {
msgtype: 'm.location',
body: `Location: ${geoUri}`,
geo_uri: geoUri,
@@ -252,7 +269,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
},
{ timeout: 10000 },
);
}, [mx, roomId]);
}, [mx, roomId, threadRootId]);
const handleVoiceSend = useCallback(
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
@@ -268,7 +285,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (room.hasEncryptionStateEvent()) {
const { encInfo, file: encBlob } = await encryptFile(blob);
const uploadResult = await mx.uploadContent(encBlob);
mx.sendMessage(roomId, {
mx.sendMessage(roomId, threadRootId ?? null, {
...baseContent,
file: { ...encInfo, url: uploadResult.content_uri },
} as any);
@@ -277,13 +294,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
name: 'voice-message.ogg',
type: mimeType,
});
mx.sendMessage(roomId, {
mx.sendMessage(roomId, threadRootId ?? null, {
...baseContent,
url: uploadResult.content_uri,
} as any);
}
},
[mx, room, roomId],
[mx, room, roomId, threadRootId],
);
const [autocompleteQuery, setAutocompleteQuery] =
@@ -353,33 +370,37 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
} else {
// Jotai draft is empty (page reload) — try localStorage fallback
try {
const stored = localStorage.getItem(`draft-msg-${roomId}`);
const stored = localStorage.getItem(`draft-msg-${draftKey}`);
if (stored) {
const nodes = JSON.parse(stored);
if (Array.isArray(nodes) && nodes.length > 0) {
Transforms.insertFragment(editor, nodes);
// Mirror the restored draft into the atom so the draft indicator
// (reads roomIdToMsgDraftAtomFamily) reflects a persisted draft
// after a page reload — not only on same-session room re-entry.
setMsgDraft(nodes);
}
}
} catch {
// Ignore malformed stored draft
}
}
}, [editor, msgDraft, roomId]);
}, [editor, msgDraft, draftKey, setMsgDraft]);
useEffect(
() => () => {
if (!isEmptyEditor(editor)) {
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
setMsgDraft(parsedDraft);
localStorage.setItem(`draft-msg-${roomId}`, JSON.stringify(parsedDraft));
localStorage.setItem(`draft-msg-${draftKey}`, JSON.stringify(parsedDraft));
} else {
setMsgDraft([]);
localStorage.removeItem(`draft-msg-${roomId}`);
localStorage.removeItem(`draft-msg-${draftKey}`);
}
resetEditor(editor);
resetEditorHistory(editor);
},
[roomId, editor, setMsgDraft],
[draftKey, editor, setMsgDraft],
);
const handleFileMetadata = useCallback(
@@ -472,15 +493,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
});
handleCancelUpload(uploads);
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
contents.forEach((content) => mx.sendMessage(roomId, content as any));
contents.forEach((content) => mx.sendMessage(roomId, threadRootId ?? null, content as any));
},
[mx, roomId, selectedFiles, handleCancelUpload],
[mx, roomId, threadRootId, selectedFiles, handleCancelUpload],
);
const submit = useCallback(() => {
uploadBoardHandlers.current?.handleSend();
const commandName = getBeginCommand(editor);
// Slash-command interpretation is disabled in thread mode (v1): "/foo"
// sends literally rather than being parsed as a command.
const commandName = threadRootId ? undefined : getBeginCommand(editor);
let plainText = toPlainText(editor.children, isMarkdown).trim();
let customHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, {
@@ -553,13 +576,24 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
content['m.relates_to'].is_falling_back = false;
}
}
mx.sendMessage(roomId, content as any);
mx.sendMessage(roomId, threadRootId ?? null, content as any);
resetEditor(editor);
resetEditorHistory(editor);
localStorage.removeItem(`draft-msg-${roomId}`);
localStorage.removeItem(`draft-msg-${draftKey}`);
setReplyDraft(undefined);
sendTypingStatus(false);
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
}, [
mx,
roomId,
threadRootId,
draftKey,
editor,
replyDraft,
sendTypingStatus,
setReplyDraft,
isMarkdown,
commands,
]);
/**
* Build a text message content object from the current editor state.
@@ -628,11 +662,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
});
resetEditor(editor);
resetEditorHistory(editor);
localStorage.removeItem(`draft-msg-${roomId}`);
localStorage.removeItem(`draft-msg-${draftKey}`);
setReplyDraft(undefined);
sendTypingStatus(false);
},
[setScheduledMessages, roomId, editor, setReplyDraft, sendTypingStatus],
[setScheduledMessages, roomId, draftKey, editor, setReplyDraft, sendTypingStatus],
);
const handleKeyDown: KeyboardEventHandler = useCallback(
@@ -645,15 +679,23 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
submit();
}
if (isKeyHotkey('escape', evt)) {
evt.preventDefault();
// Only consume Escape (and stop it bubbling to the thread panel / room
// window handlers) when the composer actually has something to dismiss.
// If we did nothing, let Escape propagate so those handlers can run.
if (autocompleteQuery) {
evt.preventDefault();
evt.stopPropagation();
setAutocompleteQuery(undefined);
return;
}
setReplyDraft(undefined);
if (replyDraft) {
evt.preventDefault();
evt.stopPropagation();
setReplyDraft(undefined);
}
}
},
[submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing],
[submit, replyDraft, setReplyDraft, enterForNewline, autocompleteQuery, isComposing],
);
const handleKeyUp: KeyboardEventHandler = useCallback(
@@ -727,7 +769,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
);
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
if (!mxcUrl) return;
mx.sendMessage(roomId, {
mx.sendMessage(roomId, threadRootId ?? null, {
msgtype: MsgType.Image,
body: 'image.gif',
url: mxcUrl,
@@ -742,7 +784,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (alive()) setGifUploading(false);
}
},
[mx, roomId, alive],
[mx, roomId, threadRootId, alive],
);
const handleStickerSelect = useCallback(
@@ -755,13 +797,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
await getImageUrlBlob(stickerUrl),
);
mx.sendEvent(roomId, EventType.Sticker, {
mx.sendEvent(roomId, threadRootId ?? null, EventType.Sticker, {
body: label,
url: mxc,
info,
});
},
[mx, roomId, useAuthentication],
[mx, roomId, threadRootId, useAuthentication],
);
if (room.getType() === 'm.server_notice') {
@@ -954,59 +996,33 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
<Icon src={Icons.PlusCircle} />
</IconButton>
}
after={
<>
{showFormat && (
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
aria-pressed={toolbar}
onClick={() => setToolbar(!toolbar)}
>
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton>
)}
{(showEmoji || showSticker) && (
<UseStateProvider initial={undefined}>
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
<PopOut
offset={16}
alignOffset={-44}
position="Top"
align="End"
anchor={
emojiBoardTab === undefined
? undefined
: (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
}
content={
<React.Suspense fallback={null}>
<EmojiBoard
tab={emojiBoardTab}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => {
setEmojiBoardTab((t) => {
if (t) {
if (!mobileOrTablet()) ReactEditor.focus(editor);
return undefined;
}
return t;
});
}}
/>
</React.Suspense>
}
>
{showSticker && !hideStickerBtn && (
after={(() => {
const formatButton = showFormat ? (
<IconButton
key="showFormat"
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
aria-pressed={toolbar}
onClick={() => setToolbar(!toolbar)}
>
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton>
) : null;
// Emoji and Sticker share a single EmojiBoard PopOut anchored to the
// emoji button, so they are rendered together as one unit. Their
// relative order still follows the saved order.
const emojiStickerBlock =
showEmoji || showSticker ? (
<UseStateProvider key="showEmojiSticker" initial={undefined}>
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => {
const stickerBtn =
showSticker && !hideStickerBtn ? (
<IconButton
key="showSticker"
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
aria-label="Insert sticker"
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
@@ -1020,36 +1036,76 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
filled={emojiBoardTab === EmojiBoardTab.Sticker}
/>
</IconButton>
)}
{showEmoji && (
<IconButton
ref={emojiBtnRef}
aria-label="Insert emoji"
aria-pressed={
) : null;
const emojiBtn = showEmoji ? (
<IconButton
key="showEmoji"
ref={emojiBtnRef}
aria-label="Insert emoji"
aria-pressed={
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
>
<Icon
src={Icons.Smile}
filled={
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
>
<Icon
src={Icons.Smile}
filled={
hideStickerBtn
? !!emojiBoardTab
: emojiBoardTab === EmojiBoardTab.Emoji
}
/>
</IconButton>
)}
</PopOut>
)}
/>
</IconButton>
) : null;
const emojiFirst =
composerButtonOrder.indexOf('showEmoji') <
composerButtonOrder.indexOf('showSticker');
return (
<PopOut
offset={16}
alignOffset={-44}
position="Top"
align="End"
anchor={
emojiBoardTab === undefined
? undefined
: (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
}
content={
<React.Suspense fallback={null}>
<EmojiBoard
tab={emojiBoardTab}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => {
setEmojiBoardTab((t) => {
if (t) {
if (!mobileOrTablet()) ReactEditor.focus(editor);
return undefined;
}
return t;
});
}}
/>
</React.Suspense>
}
>
{emojiFirst ? [emojiBtn, stickerBtn] : [stickerBtn, emojiBtn]}
</PopOut>
);
}}
</UseStateProvider>
)}
{!!gifApiKey && showGif && (
<UseStateProvider initial={false}>
) : null;
const gifButton =
!!gifApiKey && showGif ? (
<UseStateProvider key="showGif" initial={false}>
{(gifOpen: boolean, setGifOpen) => (
<PopOut
offset={16}
@@ -1101,113 +1157,163 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</PopOut>
)}
</UseStateProvider>
)}
{gifError && (
<Text
size="T200"
style={{
color: color.Critical.Main,
padding: '2px 6px',
alignSelf: 'center',
whiteSpace: 'nowrap',
}}
>
{gifError}
</Text>
)}
{locationError && (
<Text
size="T200"
style={{
color: color.Critical.Main,
padding: '2px 6px',
alignSelf: 'center',
whiteSpace: 'nowrap',
}}
>
{locationError}
</Text>
)}
{showLocation && (
<IconButton
onClick={handleShareLocation}
disabled={locating}
aria-label="Share location"
variant="SurfaceVariant"
size="300"
radii="300"
title="Share location"
style={touchTarget}
>
{locating ? (
<Spinner variant="Secondary" size="100" />
) : (
<Icon src={Icons.SpaceGlobe} size="100" />
)}
</IconButton>
)}
{showPoll && (
<IconButton
onClick={() => setPollOpen(true)}
aria-label="Create poll"
variant="SurfaceVariant"
size="300"
radii="300"
title="Create poll"
style={touchTarget}
>
<Icon src={Icons.OrderList} size="100" />
</IconButton>
)}
{showVoice && (
<VoiceMessageRecorder
onSend={handleVoiceSend}
onError={(err) => {
setLocationError(err);
setTimeout(() => setLocationError(null), 4000);
}}
/>
)}
{charCount > 0 && (
<Text
size="T200"
priority="300"
style={{
padding: `0 ${config.space.S100}`,
alignSelf: 'center',
userSelect: 'none',
minWidth: '2rem',
textAlign: 'right',
}}
>
{charCount}
</Text>
)}
{showSchedule && (
<IconButton
onClick={handleScheduleClick}
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
aria-label="Schedule message"
title="Schedule message"
>
<Icon src={Icons.Clock} size="100" />
</IconButton>
)}
) : null;
const locationButton = showLocation ? (
<IconButton
onClick={submit}
key="showLocation"
onClick={handleShareLocation}
disabled={locating}
aria-label="Share location"
variant="SurfaceVariant"
size="300"
radii="300"
title="Share location"
style={touchTarget}
>
{locating ? (
<Spinner variant="Secondary" size="100" />
) : (
<Icon src={Icons.SpaceGlobe} size="100" />
)}
</IconButton>
) : null;
const pollButton = showPoll ? (
<IconButton
key="showPoll"
onClick={() => setPollOpen(true)}
aria-label="Create poll"
variant="SurfaceVariant"
size="300"
radii="300"
title="Create poll"
style={touchTarget}
>
<Icon src={Icons.OrderList} size="100" />
</IconButton>
) : null;
const voiceButton = showVoice ? (
<VoiceMessageRecorder
key="showVoice"
onSend={handleVoiceSend}
onError={(err) => {
setLocationError(err);
setTimeout(() => setLocationError(null), 4000);
}}
/>
) : null;
const scheduleButton = showSchedule ? (
<IconButton
key="showSchedule"
onClick={handleScheduleClick}
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
aria-label="Send message"
aria-label="Schedule message"
title="Schedule message"
>
<Icon src={Icons.Send} />
<Icon src={Icons.Clock} size="100" />
</IconButton>
</>
}
) : null;
const orderedButtons: ReactNode[] = [];
let emojiStickerRendered = false;
composerButtonOrder.forEach((key: ComposerToolbarButtonKey) => {
switch (key) {
case 'showFormat':
if (formatButton) orderedButtons.push(formatButton);
break;
case 'showEmoji':
case 'showSticker':
// Rendered once as a combined unit at whichever of the two
// keys comes first in the order.
if (!emojiStickerRendered) {
emojiStickerRendered = true;
if (emojiStickerBlock) orderedButtons.push(emojiStickerBlock);
}
break;
case 'showGif':
if (gifButton) orderedButtons.push(gifButton);
break;
case 'showLocation':
if (locationButton) orderedButtons.push(locationButton);
break;
case 'showPoll':
if (pollButton) orderedButtons.push(pollButton);
break;
case 'showVoice':
if (voiceButton) orderedButtons.push(voiceButton);
break;
case 'showSchedule':
if (scheduleButton) orderedButtons.push(scheduleButton);
break;
default:
break;
}
});
return (
<>
{orderedButtons}
{gifError && (
<Text
size="T200"
style={{
color: color.Critical.Main,
padding: '2px 6px',
alignSelf: 'center',
whiteSpace: 'nowrap',
}}
>
{gifError}
</Text>
)}
{locationError && (
<Text
size="T200"
style={{
color: color.Critical.Main,
padding: '2px 6px',
alignSelf: 'center',
whiteSpace: 'nowrap',
}}
>
{locationError}
</Text>
)}
<DraftIndicator roomId={draftKey} />
{charCount > 0 && (
<Text
size="T200"
priority="300"
style={{
padding: `0 ${config.space.S100}`,
alignSelf: 'center',
userSelect: 'none',
minWidth: '2rem',
textAlign: 'right',
}}
>
{charCount}
</Text>
)}
<IconButton
onClick={submit}
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
aria-label="Send message"
>
<Icon src={Icons.Send} />
</IconButton>
</>
);
})()}
bottom={
toolbar && (
<div>
+83 -25
View File
@@ -18,9 +18,11 @@ import {
IContent,
MatrixClient,
MatrixEvent,
RelationType,
Room,
RoomEvent,
RoomEventHandlerMap,
ThreadEvent,
} from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
import classNames from 'classnames';
@@ -103,6 +105,8 @@ import * as css from './RoomTimeline.css';
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
import { ThreadSummary } from './thread/ThreadSummary';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
import { useKeyDown } from '../../hooks/useKeyDown';
@@ -245,13 +249,26 @@ const useEventTimelineLoader = (
room: Room,
onLoad: (eventId: string, linkedTimelines: EventTimeline[], evtAbsIndex: number) => void,
onError: (err: Error | null) => void,
onThreadRedirect: (threadRootId: string) => void,
) => {
const loadEventTimeline = useCallback(
async (eventId: string) => {
const [err, replyEvtTimeline] = await to(
mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId),
);
// Thread events aren't locatable in the main timeline set (getEventTimeline
// returns undefined / no abs index). Best-effort: redirect to the thread panel
// when the fetched event belongs to a thread instead of surfacing an error.
const redirectToThread = () => {
const threadRootId = room.findEventById(eventId)?.threadRootId;
if (threadRootId) {
onThreadRedirect(threadRootId);
return true;
}
return false;
};
if (!replyEvtTimeline) {
if (redirectToThread()) return;
onError(err ?? null);
return;
}
@@ -259,13 +276,14 @@ const useEventTimelineLoader = (
const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId);
if (absIndex === undefined) {
if (redirectToThread()) return;
onError(err ?? null);
return;
}
onLoad(eventId, linkedTimelines, absIndex);
},
[mx, room, onLoad, onError],
[mx, room, onLoad, onError, onThreadRedirect],
);
return loadEventTimeline;
@@ -460,6 +478,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId));
// Thread summary chips only mount for events that already carry thread data
// (perf: a chip subscribes room-level listeners, so mounting one per rendered
// message would exceed the SDK's emitter cap). This single room-level
// ThreadEvent.New subscription re-renders the timeline once when a brand-new
// thread appears, so the root's chip shows up without unrelated activity.
const [, setThreadNewTick] = useState(0);
useEffect(() => {
const handleThreadNew = () => setThreadNewTick((c) => c + 1);
room.on(ThreadEvent.New, handleThreadNew);
return () => {
room.removeListener(ThreadEvent.New, handleThreadNew);
};
}, [room]);
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
@@ -622,6 +654,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false;
}, [alive, room]),
useCallback(
(threadRootId: string) => {
if (!alive()) return;
setActiveThreadId(threadRootId);
},
[alive, setActiveThreadId],
),
);
useLiveEventArrive(
@@ -982,14 +1021,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
console.warn('Button should have "data-event-id" attribute!');
return;
}
if (startThread) {
// Open the thread panel instead of arming an m.thread reply in the main composer.
setActiveThreadId(replyId);
return;
}
const replyEvt = room.findEventById(replyId);
if (!replyEvt) return;
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
const { body, formatted_body: formattedBody } = content;
const { 'm.relates_to': relation } = startThread
? { 'm.relates_to': { rel_type: 'm.thread', event_id: replyId } }
: replyEvt.getWireContent();
const { 'm.relates_to': relation } = replyEvt.getWireContent();
const senderId = replyEvt.getSender();
if (senderId && typeof body === 'string') {
setReplyDraft({
@@ -1002,7 +1044,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
setTimeout(() => ReactEditor.focus(editor), 100);
}
},
[room, setReplyDraft, editor],
[room, setReplyDraft, setActiveThreadId, editor],
);
const handleReactionToggle = useCallback(
@@ -1090,6 +1132,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
onThreadClick={setActiveThreadId}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
@@ -1097,16 +1140,23 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
)
}
reactions={
reactionRelations && (
<Reactions
style={{ marginTop: config.space.S200 }}
room={room}
relations={reactionRelations}
mEventId={mEventId}
canSendReaction={canSendReaction}
onReactionToggle={handleReactionToggle}
/>
)
<>
{reactionRelations && (
<Reactions
style={{ marginTop: config.space.S200 }}
room={room}
relations={reactionRelations}
mEventId={mEventId}
canSendReaction={canSendReaction}
onReactionToggle={handleReactionToggle}
/>
)}
{(!threadRootId || threadRootId === mEventId) &&
(mEvent.getThread() !== undefined ||
mEvent.getServerAggregatedRelation(RelationType.Thread) !== undefined) && (
<ThreadSummary rootEvent={mEvent} room={room} onOpen={setActiveThreadId} />
)}
</>
}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
@@ -1175,6 +1225,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
onThreadClick={setActiveThreadId}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
@@ -1182,16 +1233,23 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
)
}
reactions={
reactionRelations && (
<Reactions
style={{ marginTop: config.space.S200 }}
room={room}
relations={reactionRelations}
mEventId={mEventId}
canSendReaction={canSendReaction}
onReactionToggle={handleReactionToggle}
/>
)
<>
{reactionRelations && (
<Reactions
style={{ marginTop: config.space.S200 }}
room={room}
relations={reactionRelations}
mEventId={mEventId}
canSendReaction={canSendReaction}
onReactionToggle={handleReactionToggle}
/>
)}
{(!threadRootId || threadRootId === mEventId) &&
(mEvent.getThread() !== undefined ||
mEvent.getServerAggregatedRelation(RelationType.Thread) !== undefined) && (
<ThreadSummary rootEvent={mEvent} room={room} onOpen={setActiveThreadId} />
)}
</>
}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
+56 -34
View File
@@ -33,6 +33,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
const [scheduledMessages, setScheduledMessages] = useAtom(scheduledMessagesAtom);
const [expanded, setExpanded] = useState(false);
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
const [cancelErrors, setCancelErrors] = useState<Set<string>>(new Set());
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
@@ -68,12 +69,17 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
async (msg: ScheduledMessage) => {
if (cancelling.has(msg.delayId)) return;
setCancelling((prev) => new Set(prev).add(msg.delayId));
setCancelErrors((prev) => {
if (!prev.has(msg.delayId)) return prev;
const next = new Set(prev);
next.delete(msg.delayId);
return next;
});
try {
await cancelScheduledMessage(mx, msg.delayId);
} catch {
// If cancellation fails on the server, still remove locally
// since the user intends to remove it
} finally {
// Only prune local state once the server confirms cancellation. If we
// removed it optimistically the still-live delayed event would fire and
// the "cancelled" message would send anyway.
setScheduledMessages((prev) => {
const next = new Map(prev);
const current = next.get(roomId) ?? [];
@@ -85,6 +91,11 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
}
return next;
});
} catch {
// Keep the item (still cancellable) and surface an inline error; the
// delayed event is still scheduled on the server.
setCancelErrors((prev) => new Set(prev).add(msg.delayId));
} finally {
setCancelling((prev) => {
const next = new Set(prev);
next.delete(msg.delayId);
@@ -131,41 +142,52 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
{messages.map((msg) => (
<Box
key={msg.delayId}
alignItems="Center"
gap="200"
direction="Column"
style={{
padding: `${config.space.S100} ${config.space.S300}`,
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
}}
>
<Text
size="T200"
priority="400"
style={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)'}
</Text>
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
{formatSendAt(msg.sendAt)}
</Text>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
aria-label="Cancel scheduled message"
disabled={cancelling.has(msg.delayId)}
onClick={(e) => {
e.stopPropagation();
handleCancel(msg);
}}
>
<Icon src={Icons.Cross} size="50" />
</IconButton>
<Box alignItems="Center" gap="200">
<Text
size="T200"
priority="400"
style={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{typeof msg.content.body === 'string'
? (msg.content.body as string)
: '(message)'}
</Text>
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
{formatSendAt(msg.sendAt)}
</Text>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
aria-label="Cancel scheduled message"
disabled={cancelling.has(msg.delayId)}
onClick={(e) => {
e.stopPropagation();
handleCancel(msg);
}}
>
<Icon src={Icons.Cross} size="50" />
</IconButton>
</Box>
{cancelErrors.has(msg.delayId) && (
<Text
size="T200"
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
>
Could not cancel this message. Try again.
</Text>
)}
</Box>
))}
</Box>
@@ -1,8 +1,9 @@
import React, { ChangeEvent, useCallback, useState } from 'react';
import React, { ChangeEvent, useCallback, useMemo, useRef, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Avatar,
Box,
color,
config,
Header,
Icon,
@@ -28,6 +29,7 @@ import { mDirectAtom } from '../../../state/mDirectList';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
import { getEditedEvent, trimReplyFromBody, trimReplyFromFormattedBody } from '../../../utils/room';
type RoomRowProps = {
room: Room;
@@ -86,35 +88,83 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
const modalStyle = useModalStyle(400);
const directs = useAtomValue(mDirectAtom);
const useAuthentication = useMediaAuthentication();
const searchInputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState('');
const [sending, setSending] = useState(false);
const [sentTo, setSentTo] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const allRooms = mx
.getRooms()
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0));
const allRooms = useMemo(
() =>
mx
.getRooms()
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0)),
[mx],
);
const filtered = query
? allRooms.filter((r) => r.name.toLowerCase().includes(query.toLowerCase()))
: allRooms;
const filtered = useMemo(() => {
if (!query) return allRooms;
const q = query.toLowerCase();
return allRooms.filter((r) => r.name.toLowerCase().includes(q));
}, [allRooms, query]);
/**
* Build the content to forward:
* - undecryptable events are refused (would forward `m.bad.encrypted` junk)
* - edited messages forward the LATEST edit (`m.new_content`), not the
* original pre-edit body
* - reply fallbacks (`> <@user> …` quote + `<mx-reply>` block) are stripped
* along with the `m.relates_to` reply/thread relation, so the forwarded
* message stands alone in the target room
*/
const buildForwardContent = useCallback((): Record<string, unknown> | undefined => {
if (mEvent.isDecryptionFailure()) return undefined;
let content = { ...mEvent.getContent() };
const eventId = mEvent.getId();
const room = mx.getRoom(mEvent.getRoomId());
if (eventId && room) {
const editedEvent = getEditedEvent(eventId, mEvent, room.getUnfilteredTimelineSet());
const newContent = editedEvent?.getContent()['m.new_content'];
if (newContent && typeof newContent === 'object') {
content = { ...(newContent as Record<string, unknown>) };
}
}
delete content['m.relates_to'];
if (typeof content.body === 'string') {
content.body = trimReplyFromBody(content.body);
}
if (typeof content.formatted_body === 'string') {
content.formatted_body = trimReplyFromFormattedBody(content.formatted_body);
}
return content;
}, [mx, mEvent]);
const forward = useCallback(
async (room: Room) => {
if (sending) return;
const fwdContent = buildForwardContent();
if (!fwdContent) {
setError('This message could not be decrypted, so it cannot be forwarded.');
return;
}
setSending(true);
const fwdContent: Record<string, unknown> = { ...mEvent.getContent() };
delete fwdContent['m.relates_to'];
setError(null);
try {
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (mx as any).sendEvent(room.roomId, mEvent.getType(), fwdContent);
await mx.sendEvent(room.roomId, null, mEvent.getType() as any, fwdContent);
setSentTo(room.name);
setTimeout(onClose, 1400);
} catch {
setSending(false);
setError(`Failed to forward to ${room.name}. Try again.`);
}
},
[mx, mEvent, onClose, sending],
[mx, mEvent, onClose, sending, buildForwardContent],
);
return (
@@ -122,7 +172,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
initialFocus: () => searchInputRef.current ?? false,
onDeactivate: onClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
@@ -153,8 +203,13 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
</IconButton>
</Header>
{!sentTo && (
<Box shrink="No" style={{ padding: `${config.space.S200} ${config.space.S400}` }}>
<Box
shrink="No"
direction="Column"
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
>
<Input
ref={searchInputRef}
variant="Background"
size="400"
radii="400"
@@ -163,6 +218,14 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
value={query}
onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
/>
{error && (
<Text
size="T200"
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
>
{error}
</Text>
)}
</Box>
)}
<Line size="300" />
@@ -1,8 +1,9 @@
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
color,
config,
Dialog,
Header,
@@ -43,15 +44,25 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
const modalStyle = useModalStyle(320);
const { addReminder } = useReminders();
const presets = useMemo(() => getPresets(), []);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const handlePick = async (ms: number) => {
await addReminder({
roomId,
eventId,
timestamp: Date.now() + ms,
message: previewText || 'Reminder',
});
onClose();
if (busy) return;
setBusy(true);
setError(null);
try {
await addReminder({
roomId,
eventId,
timestamp: Date.now() + ms,
message: previewText || 'Reminder',
});
onClose();
} catch {
setBusy(false);
setError('Could not set reminder. Try again.');
}
};
return (
@@ -108,6 +119,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
variant="Secondary"
fill="Soft"
radii="300"
disabled={busy}
onClick={() => handlePick(p.ms)}
>
<Text size="B300" truncate>
@@ -115,6 +127,14 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
</Text>
</Button>
))}
{error && (
<Text
size="T200"
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
>
{error}
</Text>
)}
</Box>
</Dialog>
</FocusTrap>
@@ -0,0 +1,30 @@
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
export const ThreadPanel = style({
width: toRem(360),
'@media': {
'(max-width: 750px)': {
position: 'fixed',
inset: 0,
width: '100%',
zIndex: 500,
},
},
});
export const ThreadPanelHeader = style({
flexShrink: 0,
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
borderBottomWidth: config.borderWidth.B300,
});
export const ThreadPanelContent = style({
position: 'relative',
overflow: 'hidden',
});
export const ThreadPanelInput = style({
padding: config.space.S200,
borderTopWidth: config.borderWidth.B300,
});
@@ -0,0 +1,206 @@
import React, { useCallback, useEffect, useRef } from 'react';
import {
Box,
Header,
Icon,
IconButton,
Icons,
Spinner,
Text,
Tooltip,
TooltipProvider,
} from 'folds';
import { Room, RoomEvent, ThreadEvent } from 'matrix-js-sdk';
import { isKeyHotkey } from 'is-hotkey';
import classNames from 'classnames';
import * as css from './ThreadPanel.css';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import { ThreadTimeline } from './ThreadTimeline';
import { markThreadAsRead, useThreadInstance } from './useThread';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useEditor } from '../../../components/editor';
import { useKeyDown } from '../../../hooks/useKeyDown';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { RoomInput } from '../RoomInput';
import {
getThreadNotificationModeIcon,
ThreadNotificationModeSwitcher,
} from '../../../components/ThreadNotificationModeSwitcher';
import { useThreadNotificationMode } from '../../../hooks/useThreadNotifications';
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
type ThreadPanelHeaderProps = {
room: Room;
threadId: string;
requestClose: () => void;
};
function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) {
const mode = useThreadNotificationMode(room.roomId, threadId);
return (
<Header className={css.ThreadPanelHeader} variant="Background" size="600">
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes" direction="Column">
<Text size="H5" truncate>
Thread
</Text>
<Text size="T200" truncate style={{ opacity: 0.65 }}>
{room.name}
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="100">
<ThreadNotificationModeSwitcher roomId={room.roomId} threadId={threadId} value={mode}>
{(handleOpen, opened) => (
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Notifications</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Background"
aria-label="Thread notifications"
aria-pressed={opened}
onClick={handleOpen}
>
<Icon
src={getThreadNotificationModeIcon(mode)}
filled={mode !== ThreadNotificationMode.Default}
/>
</IconButton>
)}
</TooltipProvider>
)}
</ThreadNotificationModeSwitcher>
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Background"
aria-label="Close thread"
onClick={requestClose}
>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
</Box>
</Header>
);
}
export type ThreadPanelProps = {
room: Room;
threadId: string;
requestClose: () => void;
};
export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps) {
const mx = useMatrixClient();
const editor = useEditor();
const thread = useThreadInstance(room, threadId);
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts');
const fileDropContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
useKeyDown(
window,
useCallback(
(evt) => {
if (isKeyHotkey('escape', evt)) {
// The composer preventDefaults Escape when it consumes it (dismissing
// autocomplete / clearing a reply draft). Don't close the panel in
// that case — only when Escape wasn't already handled.
if (evt.defaultPrevented) return;
evt.preventDefault();
evt.stopPropagation();
requestClose();
}
},
[requestClose],
),
);
// Mark the thread read when the panel is open and on each new thread event.
// Deduped on the latest event id: RoomEvent.Timeline re-emits per event during
// backfill and for every edit/reaction, and sendReadReceipt POSTs
// unconditionally — without the guard, opening a thread with N replies would
// fire up to N receipt requests at the same event.
const lastReadEventIdRef = useRef<string | undefined>(undefined);
useEffect(() => {
lastReadEventIdRef.current = undefined;
if (!thread) return undefined;
const markRead = () => {
const events = thread.liveTimeline.getEvents();
let latestId: string | undefined;
for (let i = events.length - 1; i >= 0; i -= 1) {
const evt = events[i];
if (evt && !evt.isSending()) {
latestId = evt.getId() ?? undefined;
break;
}
}
if (!latestId || latestId === lastReadEventIdRef.current) return;
lastReadEventIdRef.current = latestId;
markThreadAsRead(mx, thread, privateReadReceipts).catch(() => {
// Allow a retry on the next event if the receipt POST failed.
if (lastReadEventIdRef.current === latestId) {
lastReadEventIdRef.current = undefined;
}
});
};
markRead();
thread.on(ThreadEvent.NewReply, markRead);
thread.on(RoomEvent.Timeline, markRead);
return () => {
thread.off(ThreadEvent.NewReply, markRead);
thread.off(RoomEvent.Timeline, markRead);
};
}, [mx, thread, privateReadReceipts]);
return (
<Box
className={classNames(css.ThreadPanel, ContainerColor({ variant: 'Background' }))}
shrink="No"
direction="Column"
>
<ThreadPanelHeader room={room} threadId={threadId} requestClose={requestClose} />
{!thread ? (
<Box grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
<Spinner size="400" variant="Secondary" />
<Text size="T300">Loading thread</Text>
</Box>
) : (
<>
<Box grow="Yes" className={css.ThreadPanelContent} direction="Column">
<ThreadTimeline room={room} thread={thread} editor={editor} />
</Box>
<Box className={css.ThreadPanelInput} shrink="No" direction="Column">
<RoomInput
room={room}
roomId={room.roomId}
threadRootId={threadId}
editor={editor}
fileDropContainerRef={fileDropContainerRef}
/>
</Box>
</>
)}
</Box>
);
}
@@ -0,0 +1,51 @@
import React from 'react';
import { Badge, Box, Chip, Icon, Icons, Text, config } from 'folds';
import { MatrixEvent, Room } from 'matrix-js-sdk';
import { useThreadSummary } from '../../../hooks/useThreadSummary';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { timeDayMonthYear, timeHourMinute, today } from '../../../utils/time';
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
type ThreadSummaryProps = {
rootEvent: MatrixEvent;
room: Room;
onOpen: (threadId: string) => void;
};
export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) {
const { summary, unread, mode } = useThreadSummary(rootEvent, room);
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
if (!summary || summary.count === 0) return null;
const { count, latestTs } = summary;
const latestStr =
latestTs !== undefined
? today(latestTs)
? timeHourMinute(latestTs, hour24Clock)
: timeDayMonthYear(latestTs)
: undefined;
return (
<Box style={{ marginTop: config.space.S200 }}>
<Chip
variant="SurfaceVariant"
radii="300"
before={<Icon size="50" src={Icons.Thread} />}
after={
unread > 0 ? <Badge variant="Success" fill="Solid" radii="Pill" size="200" /> : undefined
}
onClick={() => {
const threadId = rootEvent.getId();
if (threadId) onOpen(threadId);
}}
>
<Text size="T200">
{count === 1 ? '1 reply' : `${count} replies`}
{latestStr ? ` · ${latestStr}` : ''}
</Text>
{mode === ThreadNotificationMode.Mute && <Icon size="50" src={Icons.BellMute} />}
</Chip>
</Box>
);
}
@@ -0,0 +1,48 @@
import { style } from '@vanilla-extract/css';
import { color, config } from 'folds';
export const ThreadTimeline = style({
height: '100%',
position: 'relative',
});
export const ThreadTimelineContent = style({
minHeight: '100%',
padding: `${config.space.S400} 0`,
});
export const ThreadTimelineFloat = style({
position: 'absolute',
bottom: config.space.S400,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1,
minWidth: 'max-content',
});
export const ThreadCentered = style({
height: '100%',
padding: config.space.S700,
});
export const RootMessage = style({
backgroundColor: color.SurfaceVariant.Container,
borderRadius: config.radii.R400,
marginBottom: config.space.S100,
});
export const RepliesDivider = style({
padding: `${config.space.S200} ${config.space.S400}`,
});
export const NoReplies = style({
padding: config.space.S400,
});
export const PendingMessage = style({
opacity: 0.6,
});
export const PendingFailed = style({
opacity: 1,
});
@@ -0,0 +1,982 @@
import React, {
Dispatch,
MouseEventHandler,
ReactNode,
SetStateAction,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
Direction,
EventStatus,
EventTimeline,
EventTimelineSet,
EventTimelineSetHandlerMap,
MatrixClient,
MatrixEvent,
RelationType,
Room,
RoomEvent,
Thread,
ThreadEvent,
} from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Editor } from 'slate';
import { ReactEditor } from 'slate-react';
import to from 'await-to-js';
import { useAtomValue, useSetAtom } from 'jotai';
import { Badge, Box, Chip, Icon, Icons, Line, Scroll, Spinner, Text, color, config } from 'folds';
import classNames from 'classnames';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator';
import { useAlive } from '../../../hooks/useAlive';
import { scrollToBottom } from '../../../utils/dom';
import {
DefaultPlaceholder,
MessageBase,
Reply,
RedactedContent,
MSticker,
MessageUnsupportedContent,
MessageNotDecryptedContent,
ImageContent,
} from '../../../components/message';
import {
factoryRenderLinkifyWithMention,
getReactCustomHtmlParser,
LINKIFY_OPTS,
makeMentionCustomProps,
renderMatrixMention,
} from '../../../plugins/react-custom-html-parser';
import {
decryptAllTimelineEvent,
getEditedEvent,
getEventReactions,
getMemberDisplayName,
getReactionContent,
reactionOrEditEvent,
} from '../../../utils/room';
import { useSetting } from '../../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../../state/settings';
import { Message, Reactions, EncryptedContent } from '../message';
import { RenderMessageContent } from '../../../components/RenderMessageContent';
import { Image } from '../../../components/media';
import { ImageViewer } from '../../../components/image-viewer';
import * as css from './ThreadTimeline.css';
import {
inSameDay,
minuteDifference,
timeDayMonthYear,
today,
yesterday,
} from '../../../utils/time';
import { createMentionElement, moveCursor } from '../../../components/editor';
import { roomIdToReplyDraftAtomFamily } from '../../../state/room/roomInputDrafts';
import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
import {
getIntersectionObserverEntry,
useIntersectionObserver,
} from '../../../hooks/useIntersectionObserver';
import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
import { useImagePackRooms } from '../../../hooks/useImagePackRooms';
import { useIsDirectRoom } from '../../../hooks/useRoom';
import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../../hooks/useSpace';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import {
useAccessiblePowerTagColors,
useGetMemberPowerTag,
} from '../../../hooks/useMemberPowerTag';
import { useTheme } from '../../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { EditHistoryModal } from '../message/EditHistoryModal';
import {
getLinkedTimelines,
getTimelineAndBaseIndex,
getTimelineEvent,
getTimelineRelativeIndex,
getTimelinesEventsCount,
timelineToEventsCount,
} from '../RoomTimeline';
import { getThreadDraftKey } from '../../../state/room/thread';
import { useThreadLinkedTimelines, useThreadPendingEvents } from './useThread';
// Virtual window size (how many items render around the viewport).
const PAGINATION_LIMIT = 50;
// Network page size for backward /relations pagination of the thread timeline.
const THREAD_PAGE_LIMIT = 30;
type Timeline = {
linkedTimelines: EventTimeline[];
range: ItemRange;
};
const getEmptyTimeline = (): Timeline => ({
linkedTimelines: [],
range: { start: 0, end: 0 },
});
const getInitialThreadTimeline = (thread: Thread, timelines?: EventTimeline[]): Timeline => {
const linkedTimelines =
timelines && timelines.length > 0 ? timelines : getLinkedTimelines(thread.liveTimeline);
const evLength = getTimelinesEventsCount(linkedTimelines);
return {
linkedTimelines,
range: {
start: Math.max(evLength - PAGINATION_LIMIT, 0),
end: evLength,
},
};
};
/**
* Copy of RoomTimeline's `useTimelinePagination` pattern (not exported from RoomTimeline
* as its ~35 hooks are hardwired to the room live timeline). Works transparently against
* the thread timeline's /relations pagination.
*/
const useThreadTimelinePagination = (
mx: MatrixClient,
timeline: Timeline,
setTimeline: Dispatch<SetStateAction<Timeline>>,
limit: number,
) => {
const timelineRef = useRef(timeline);
timelineRef.current = timeline;
const alive = useAlive();
const handleTimelinePagination = useMemo(() => {
let fetching = false;
const recalibratePagination = (
linkedTimelines: EventTimeline[],
timelinesEventsCount: number[],
backwards: boolean,
) => {
const topTimeline = linkedTimelines[0];
const timelineMatch = (mt: EventTimeline) => (t: EventTimeline) => t === mt;
const newLTimelines = getLinkedTimelines(topTimeline);
const topTmIndex = newLTimelines.findIndex(timelineMatch(topTimeline));
const topAddedTm = topTmIndex === -1 ? [] : newLTimelines.slice(0, topTmIndex);
const topTmAddedEvt =
timelineToEventsCount(newLTimelines[topTmIndex]) - timelinesEventsCount[0];
const offsetRange = getTimelinesEventsCount(topAddedTm) + (backwards ? topTmAddedEvt : 0);
setTimeline((currentTimeline) => ({
linkedTimelines: newLTimelines,
range:
offsetRange > 0
? {
start: currentTimeline.range.start + offsetRange,
end: currentTimeline.range.end + offsetRange,
}
: { ...currentTimeline.range },
}));
};
return async (backwards: boolean) => {
if (fetching) return;
const { linkedTimelines: lTimelines } = timelineRef.current;
const timelinesEventsCount = lTimelines.map(timelineToEventsCount);
const timelineToPaginate = backwards ? lTimelines[0] : lTimelines[lTimelines.length - 1];
if (!timelineToPaginate) return;
const paginationToken = timelineToPaginate.getPaginationToken(
backwards ? Direction.Backward : Direction.Forward,
);
if (
!paginationToken &&
getTimelinesEventsCount(lTimelines) !==
getTimelinesEventsCount(getLinkedTimelines(timelineToPaginate))
) {
recalibratePagination(lTimelines, timelinesEventsCount, backwards);
return;
}
fetching = true;
const [err] = await to(
mx.paginateEventTimeline(timelineToPaginate, {
backwards,
limit,
}),
);
if (err) {
fetching = false;
return;
}
const fetchedTimeline =
timelineToPaginate.getNeighbouringTimeline(
backwards ? Direction.Backward : Direction.Forward,
) ?? timelineToPaginate;
// Decrypt all event ahead of render cycle
const roomId = fetchedTimeline.getRoomId();
const room = roomId ? mx.getRoom(roomId) : null;
if (room?.hasEncryptionStateEvent()) {
await to(decryptAllTimelineEvent(mx, fetchedTimeline));
}
fetching = false;
if (alive()) {
recalibratePagination(lTimelines, timelinesEventsCount, backwards);
}
};
}, [mx, alive, setTimeline, limit]);
return handleTimelinePagination;
};
export type ThreadTimelineProps = {
room: Room;
thread: Thread;
editor: Editor;
};
export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
const mx = useMatrixClient();
const alive = useAlive();
const useAuthentication = useMediaAuthentication();
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
const [perMessageProfiles] = useSetting(settingsAtom, 'perMessageProfiles');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const direct = useIsDirectRoom();
const ignoredUsersList = useIgnoredUsers();
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
const setReplyDraft = useSetAtom(
roomIdToReplyDraftAtomFamily(getThreadDraftKey(room.roomId, thread.id)),
);
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const creatorsTag = useRoomCreatorsTag();
const powerLevelTags = usePowerLevelTags(room, powerLevels);
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
const theme = useTheme();
const accessiblePowerTagColors = useAccessiblePowerTagColors(
theme.kind,
creatorsTag,
powerLevelTags,
);
const permissions = useRoomPermissions(creators, powerLevels);
const canRedact = permissions.action('redact', mx.getSafeUserId());
const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
const mentionClickHandler = useMentionClickHandler(room.roomId);
const spoilerClickHandler = useSpoilerClickHandler();
const openUserRoomProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally();
const roomToParents = useAtomValue(roomToParentsAtom);
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
const [editId, setEditId] = useState<string>();
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
const linkifyOpts = useMemo<LinkifyOpts>(
() => ({
...LINKIFY_OPTS,
render: factoryRenderLinkifyWithMention((href) =>
renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)),
),
}),
[mx, room, mentionClickHandler],
);
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
() =>
getReactCustomHtmlParser(mx, room.roomId, {
linkifyOpts,
useAuthentication,
handleSpoilerClick: spoilerClickHandler,
handleMentionClick: mentionClickHandler,
}),
[mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication],
);
const { timelines, ready } = useThreadLinkedTimelines(mx, thread);
const pendingEvents = useThreadPendingEvents(room, thread.id, thread);
const [timeline, setTimeline] = useState<Timeline>(() =>
ready ? getInitialThreadTimeline(thread, timelines) : getEmptyTimeline(),
);
const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
const canPaginateBack =
typeof timeline.linkedTimelines[0]?.getPaginationToken(Direction.Backward) === 'string';
const rangeAtStart = timeline.range.start === 0;
const scrollRef = useRef<HTMLDivElement>(null);
const atBottomAnchorRef = useRef<HTMLElement>(null);
const [atBottom, setAtBottom] = useState(true);
const atBottomRef = useRef(atBottom);
atBottomRef.current = atBottom;
const scrollToBottomRef = useRef({ count: 0, smooth: true });
const handleTimelinePagination = useThreadTimelinePagination(
mx,
timeline,
setTimeline,
THREAD_PAGE_LIMIT,
);
const getScrollElement = useCallback(() => scrollRef.current, []);
const { getItems, scrollToItem, observeBackAnchor } = useVirtualPaginator({
count: eventsLength,
limit: PAGINATION_LIMIT,
range: timeline.range,
onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
getScrollElement,
getItemElement: useCallback(
(index: number) =>
(scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
undefined,
[],
),
onEnd: handleTimelinePagination,
});
// Seed local timeline once the thread has fetched its initial events.
const seededRef = useRef(false);
useEffect(() => {
if (!ready || seededRef.current) return;
seededRef.current = true;
setTimeline(getInitialThreadTimeline(thread, timelines));
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false;
if (room.hasEncryptionStateEvent()) {
to(decryptAllTimelineEvent(mx, thread.liveTimeline)).then(() => {
if (alive()) setTimeline((ct) => ({ ...ct }));
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- seed once when ready flips
}, [ready, thread]);
// Re-render / stick-to-bottom on live thread activity.
useEffect(() => {
const handleTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
mEvent,
eventRoom,
toStartOfTimeline,
removed,
data,
) => {
if (!data?.liveEvent) return;
if (atBottomRef.current) {
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = true;
setTimeline((ct) => ({
...ct,
range: {
start: ct.range.start + 1,
end: ct.range.end + 1,
},
}));
return;
}
setTimeline((ct) => ({ ...ct }));
};
const handleUpdate = () => setTimeline((ct) => ({ ...ct }));
// A gappy sync / updateThreadMetadata resets the thread's live timeline —
// the stored linkedTimelines would then point at a detached timeline, so
// reseed the window from the fresh liveTimeline.
const handleReset = () => {
setTimeline(getInitialThreadTimeline(thread, getLinkedTimelines(thread.liveTimeline)));
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false;
};
thread.on(RoomEvent.Timeline, handleTimeline);
thread.on(ThreadEvent.Update, handleUpdate);
thread.on(RoomEvent.TimelineReset, handleReset);
return () => {
thread.removeListener(RoomEvent.Timeline, handleTimeline);
thread.removeListener(ThreadEvent.Update, handleUpdate);
thread.removeListener(RoomEvent.TimelineReset, handleReset);
};
}, [thread]);
// atBottom detection
useIntersectionObserver(
useCallback((entries) => {
const target = atBottomAnchorRef.current;
if (!target) return;
const entry = getIntersectionObserverEntry(target, entries);
if (entry) setAtBottom(entry.isIntersecting);
}, []),
useCallback(
() => ({
root: getScrollElement(),
rootMargin: '100px',
}),
[getScrollElement],
),
useCallback(() => atBottomAnchorRef.current, []),
);
// Initial scroll to bottom on mount.
useLayoutEffect(() => {
const scrollEl = scrollRef.current;
if (scrollEl) scrollToBottom(scrollEl);
}, []);
// Scroll to bottom when requested.
const scrollToBottomCount = scrollToBottomRef.current.count;
useLayoutEffect(() => {
if (scrollToBottomCount > 0) {
const scrollEl = scrollRef.current;
if (scrollEl)
scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
}
}, [scrollToBottomCount]);
const handleJumpToBottom = useCallback(() => {
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = true;
// Flip atBottom so the layout effect re-runs (count re-read) and live
// events resume sticking to the bottom.
setAtBottom(true);
}, []);
// Scroll in-place editor into view.
useEffect(() => {
if (editId) {
const editMsgElement =
(scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ??
undefined;
editMsgElement?.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
}, [editId]);
const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => {
evt.preventDefault();
evt.stopPropagation();
const userId = evt.currentTarget.getAttribute('data-user-id');
if (!userId) return;
openUserRoomProfile(
room.roomId,
space?.roomId,
userId,
evt.currentTarget.getBoundingClientRect(),
);
},
[room, space, openUserRoomProfile],
);
const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => {
evt.preventDefault();
const userId = evt.currentTarget.getAttribute('data-user-id');
if (!userId) return;
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
editor.insertNode(
createMentionElement(
userId,
name.startsWith('@') ? name : `@${name}`,
userId === mx.getUserId(),
),
);
ReactEditor.focus(editor);
moveCursor(editor);
},
[mx, room, editor],
);
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => {
const replyId = evt.currentTarget.getAttribute('data-event-id');
if (!replyId) return;
const replyEvt = thread.findEventById(replyId) ?? room.findEventById(replyId);
if (!replyEvt) return;
const editedReply = getEditedEvent(replyId, replyEvt, thread.getUnfilteredTimelineSet());
const content = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
const { body, formatted_body: formattedBody } = content;
const senderId = replyEvt.getSender();
if (senderId && typeof body === 'string') {
setReplyDraft({
userId: senderId,
eventId: replyId,
body,
formattedBody,
relation: { rel_type: RelationType.Thread, event_id: thread.id },
});
setTimeout(() => ReactEditor.focus(editor), 100);
}
},
[room, thread, setReplyDraft, editor],
);
const handleReactionToggle = useCallback(
(targetEventId: string, key: string, shortcode?: string) => {
const timelineSet = thread.getUnfilteredTimelineSet();
const relations = getEventReactions(timelineSet, targetEventId);
const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
const reactions = reactionsSet ? Array.from(reactionsSet) : [];
const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!));
if (myReaction && !!myReaction.isRelation()) {
mx.redactEvent(room.roomId, myReaction.getId()!);
return;
}
const rShortcode =
shortcode ||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
mx.sendEvent(
room.roomId,
thread.id,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
MessageEvent.Reaction as any,
getReactionContent(targetEventId, key, rShortcode),
);
},
[mx, room, thread],
);
const handleEdit = useCallback(
(editEvtId?: string) => {
if (editEvtId) {
setEditId(editEvtId);
return;
}
setEditId(undefined);
ReactEditor.focus(editor);
},
[editor],
);
const handleOpenReply: MouseEventHandler = useCallback(
(evt) => {
const targetId = evt.currentTarget.getAttribute('data-event-id');
if (!targetId) return;
// best-effort: scroll to referenced event if it is inside the loaded thread window
let absIndex = -1;
let acc = 0;
timeline.linkedTimelines.some((tl) => {
const idx = tl.getEvents().findIndex((e) => e.getId() === targetId);
if (idx !== -1) {
absIndex = acc + idx;
return true;
}
acc += tl.getEvents().length;
return false;
});
if (absIndex >= 0) {
scrollToItem(absIndex, {
behavior: 'smooth',
align: 'center',
stopInView: true,
});
}
},
[timeline.linkedTimelines, scrollToItem],
);
const renderMessageContent = useCallback(
(mEvent: MatrixEvent, mEventId: string, timelineSet: EventTimelineSet): ReactNode => {
// Evaluated lazily so EncryptedContent can re-run it (re-reading getType())
// after MatrixEventEvent.Decrypted fires — decryption re-emits NEITHER
// RoomEvent.Timeline nor ThreadEvent.Update, so without this wrapper a
// live-arriving encrypted reply would show "Unable to decrypt" forever.
const renderByType = (): ReactNode => {
if (mEvent.isRedacted()) {
return <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />;
}
const type = mEvent.getType();
if (type === MessageEvent.Sticker) {
return (
<MSticker
content={mEvent.getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
/>
);
}
if (type === MessageEvent.RoomMessageEncrypted) {
return (
<Text>
<MessageNotDecryptedContent />
</Text>
);
}
if (type !== MessageEvent.RoomMessage) {
return (
<Text>
<MessageUnsupportedContent />
</Text>
);
}
const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
const getContent = (() =>
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;
const senderId = mEvent.getSender() ?? '';
const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
return (
<RenderMessageContent
displayName={senderDisplayName}
msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()}
edited={!!editedEvent}
onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined}
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
eventId={mEventId}
/>
);
};
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted) {
return <EncryptedContent mEvent={mEvent}>{renderByType}</EncryptedContent>;
}
return renderByType();
},
[room, mediaAutoLoad, showUrlPreview, htmlReactParserOptions, linkifyOpts, messageLayout],
);
const renderMessage = useCallback(
(
mEvent: MatrixEvent,
opts: { item?: number; collapse: boolean; highlight: boolean; editable: boolean },
): ReactNode => {
const mEventId = mEvent.getId();
if (!mEventId) return null;
const timelineSet = thread.getUnfilteredTimelineSet();
const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations?.getSortedAnnotationsByKey();
const hasReactions = !!reactions && reactions.length > 0;
const { replyEventId, threadRootId } = mEvent;
return (
<Message
key={mEventId}
data-message-item={opts.item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
messageSpacing={messageSpacing}
messageLayout={messageLayout}
collapse={opts.collapse}
highlight={opts.highlight}
edit={opts.editable && editId === mEventId}
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
onEditId={opts.editable ? handleEdit : undefined}
reply={
replyEventId && (
<Reply
room={room}
timelineSet={timelineSet}
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
/>
)
}
reactions={
reactionRelations && (
<Reactions
style={{ marginTop: config.space.S200 }}
room={room}
relations={reactionRelations}
mEventId={mEventId}
canSendReaction={canSendReaction}
onReactionToggle={handleReactionToggle}
/>
)
}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
lotusTerminal={!!lotusTerminal}
>
{renderMessageContent(mEvent, mEventId, timelineSet)}
</Message>
);
},
[
thread,
room,
messageSpacing,
messageLayout,
editId,
canRedact,
canDeleteOwn,
canSendReaction,
canPinEvent,
imagePackRooms,
handleUserClick,
handleUsernameClick,
handleReplyClick,
handleReactionToggle,
handleEdit,
handleOpenReply,
getMemberPowerTag,
accessiblePowerTagColors,
legacyUsernameColor,
direct,
hideActivity,
showDeveloperTools,
hour24Clock,
dateFormatString,
lotusTerminal,
mx,
renderMessageContent,
],
);
let prevEvent: MatrixEvent | undefined;
let isPrevRendered = false;
let dayDivider = false;
const eventRenderer = (item: number) => {
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
if (!eventTimeline) return null;
const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
const mEventId = mEvent?.getId();
if (!mEvent || !mEventId) return null;
// Skip annotations, edits, and any state/membership events (they can't be threaded).
if (reactionOrEditEvent(mEvent) || typeof mEvent.getStateKey() === 'string') {
prevEvent = mEvent;
return null;
}
const eventSender = mEvent.getSender();
if (eventSender && ignoredUsersSet.has(eventSender)) {
prevEvent = mEvent;
return null;
}
const isRoot = mEventId === thread.id;
if (!dayDivider) {
dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
}
const collapsed =
!isRoot &&
!perMessageProfiles &&
isPrevRendered &&
!dayDivider &&
prevEvent !== undefined &&
prevEvent.getId() !== thread.id &&
prevEvent.getSender() === eventSender &&
prevEvent.getType() === mEvent.getType() &&
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 5;
const eventJSX = renderMessage(mEvent, {
item,
collapse: collapsed,
highlight: false,
editable: true,
});
const dayDividerJSX =
dayDivider && eventJSX && !isRoot ? (
<MessageBase space={messageSpacing}>
<Box gap="100" justifyContent="Center" alignItems="Center">
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
<Text size="L400">
{(() => {
if (today(mEvent.getTs())) return 'Today';
if (yesterday(mEvent.getTs())) return 'Yesterday';
return timeDayMonthYear(mEvent.getTs());
})()}
</Text>
</Badge>
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
</Box>
</MessageBase>
) : null;
prevEvent = mEvent;
isPrevRendered = !!eventJSX;
if (dayDividerJSX) dayDivider = false;
// Root gets an emphasized container + a "N replies" divider under it.
if (isRoot && eventJSX) {
const replyCount = thread.length;
return (
<React.Fragment key={mEventId}>
<div className={css.RootMessage}>{eventJSX}</div>
{replyCount > 0 && (
<Box
className={css.RepliesDivider}
gap="100"
justifyContent="Center"
alignItems="Center"
>
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
<Text size="L400" priority="300">
{replyCount === 1 ? '1 reply' : `${replyCount} replies`}
</Text>
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
</Box>
)}
</React.Fragment>
);
}
if (eventJSX && dayDividerJSX) {
return (
<React.Fragment key={mEventId}>
{dayDividerJSX}
{eventJSX}
</React.Fragment>
);
}
return eventJSX;
};
const items = getItems();
const showEmptyReplies = ready && thread.length === 0;
const renderPendingEvent = (mEvent: MatrixEvent) => {
const failed =
mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED;
return (
<div
key={mEvent.getId() ?? mEvent.getTxnId()}
className={classNames(failed ? css.PendingFailed : css.PendingMessage)}
>
{renderMessage(mEvent, { collapse: false, highlight: false, editable: false })}
{failed && (
<Box style={{ padding: `0 ${config.space.S400}` }}>
<Text size="T200" style={{ color: color.Critical.Main }}>
Failed to send
</Text>
</Box>
)}
</div>
);
};
if (!ready) {
return (
<Box
className={css.ThreadCentered}
grow="Yes"
direction="Column"
justifyContent="Center"
alignItems="Center"
>
<Spinner variant="Secondary" size="600" />
</Box>
);
}
return (
<Box className={css.ThreadTimeline} grow="Yes">
<Scroll ref={scrollRef} visibility="Hover">
<Box
className={css.ThreadTimelineContent}
direction="Column"
justifyContent="End"
role="log"
aria-label="Thread timeline"
aria-live="polite"
>
{(canPaginateBack || !rangeAtStart) && (
<>
<MessageBase>
<DefaultPlaceholder />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<DefaultPlaceholder />
</MessageBase>
</>
)}
{items.map(eventRenderer)}
{showEmptyReplies && (
<Box className={css.NoReplies} justifyContent="Center">
<Text size="T300" priority="300">
No replies yet say something
</Text>
</Box>
)}
{pendingEvents.map(renderPendingEvent)}
<span ref={atBottomAnchorRef} />
</Box>
</Scroll>
{!atBottom && (
<Box className={css.ThreadTimelineFloat} justifyContent="Center" alignItems="Center">
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.ArrowBottom} />}
onClick={handleJumpToBottom}
>
<Text size="L400">Jump to Latest</Text>
</Chip>
</Box>
)}
{editHistoryEvent && (
<EditHistoryModal
room={room}
mEvent={editHistoryEvent}
onClose={() => setEditHistoryEvent(undefined)}
/>
)}
</Box>
);
}
+2
View File
@@ -0,0 +1,2 @@
export * from './ThreadPanel';
export * from './ThreadSummary';
@@ -0,0 +1,133 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { EventStatus, MatrixEvent, RelationType } from 'matrix-js-sdk';
import { getThreadSummary, isPendingThreadReply } from './threadSummaryData';
// getThreadSummary reads either the live Thread (preferred) or the
// server-aggregated `m.thread` bundle. We stub only the members it touches and
// cast through `unknown` to MatrixEvent, mirroring the light mocking used in
// the state tests.
type ThreadStub = { length: number; lastReplyTs?: number };
type BundleStub = { count: number; latestTs?: number };
const makeRootEvent = (opts: { thread?: ThreadStub; bundle?: BundleStub }): MatrixEvent => {
const thread = opts.thread
? {
length: opts.thread.length,
lastReply: () =>
opts.thread?.lastReplyTs === undefined
? null
: ({ getTs: () => opts.thread?.lastReplyTs } as unknown as MatrixEvent),
}
: undefined;
return {
getThread: () => thread,
getServerAggregatedRelation: (relType: string) => {
if (relType !== RelationType.Thread || !opts.bundle) return undefined;
return {
count: opts.bundle.count,
latest_event:
opts.bundle.latestTs === undefined
? undefined
: { origin_server_ts: opts.bundle.latestTs },
};
},
} as unknown as MatrixEvent;
};
// ---------------------------------------------------------------------------
// getThreadSummary
// ---------------------------------------------------------------------------
test('prefers the live thread: count from length, latestTs from lastReply', () => {
const rootEvent = makeRootEvent({
thread: { length: 3, lastReplyTs: 1700 },
bundle: { count: 99, latestTs: 1 },
});
assert.deepEqual(getThreadSummary(rootEvent), { count: 3, latestTs: 1700 });
});
test('live thread with no replies yields undefined latestTs', () => {
const rootEvent = makeRootEvent({ thread: { length: 0 } });
assert.deepEqual(getThreadSummary(rootEvent), { count: 0, latestTs: undefined });
});
test('falls back to the server bundle when no live thread', () => {
const rootEvent = makeRootEvent({ bundle: { count: 5, latestTs: 1234 } });
assert.deepEqual(getThreadSummary(rootEvent), { count: 5, latestTs: 1234 });
});
test('bundle without latest_event yields undefined latestTs', () => {
const rootEvent = makeRootEvent({ bundle: { count: 2 } });
assert.deepEqual(getThreadSummary(rootEvent), { count: 2, latestTs: undefined });
});
test('returns undefined when there is neither a thread nor a bundle', () => {
const rootEvent = makeRootEvent({});
assert.equal(getThreadSummary(rootEvent), undefined);
});
// ---------------------------------------------------------------------------
// isPendingThreadReply
// ---------------------------------------------------------------------------
const ROOT = '$root:server';
const makeReply = (opts: {
status: EventStatus | null;
threadRootId?: string;
relation?: { rel_type?: string; event_id?: string } | null;
}): MatrixEvent =>
({
status: opts.status,
threadRootId: opts.threadRootId,
getRelation: () => opts.relation ?? null,
}) as unknown as MatrixEvent;
test('SENDING with matching threadRootId is pending', () => {
const event = makeReply({ status: EventStatus.SENDING, threadRootId: ROOT });
assert.equal(isPendingThreadReply(event, ROOT), true);
});
test('NOT_SENT with matching threadRootId is pending', () => {
const event = makeReply({ status: EventStatus.NOT_SENT, threadRootId: ROOT });
assert.equal(isPendingThreadReply(event, ROOT), true);
});
test('SENDING resolved via the m.thread relation content is pending', () => {
const event = makeReply({
status: EventStatus.SENDING,
relation: { rel_type: RelationType.Thread, event_id: ROOT },
});
assert.equal(isPendingThreadReply(event, ROOT), true);
});
test('SENT (confirmed) event is not pending', () => {
const event = makeReply({ status: EventStatus.SENT, threadRootId: ROOT });
assert.equal(isPendingThreadReply(event, ROOT), false);
});
test('null status is not pending', () => {
const event = makeReply({ status: null, threadRootId: ROOT });
assert.equal(isPendingThreadReply(event, ROOT), false);
});
test('SENDING but for a different thread is not pending', () => {
const event = makeReply({ status: EventStatus.SENDING, threadRootId: '$other:server' });
assert.equal(isPendingThreadReply(event, ROOT), false);
});
test('SENDING with a non-thread relation is not pending', () => {
const event = makeReply({
status: EventStatus.SENDING,
relation: { rel_type: RelationType.Reference, event_id: ROOT },
});
assert.equal(isPendingThreadReply(event, ROOT), false);
});
test('SENDING with no relation and no threadRootId is not pending', () => {
const event = makeReply({ status: EventStatus.SENDING });
assert.equal(isPendingThreadReply(event, ROOT), false);
});
@@ -0,0 +1,55 @@
import { EventStatus, IThreadBundledRelationship, MatrixEvent, RelationType } from 'matrix-js-sdk';
export type ThreadSummaryData = {
count: number;
latestTs: number | undefined;
};
/**
* Summary data for a thread root's "N replies" chip.
*
* Prefers the live {@link Thread} object when it exists (it reflects local
* echo + pagination), otherwise falls back to the server-aggregated bundle
* (`unsigned['m.relations']['m.thread']`) so the chip renders before any
* Thread object has been created. Returns `undefined` when the root has no
* thread at all.
*/
export const getThreadSummary = (rootEvent: MatrixEvent): ThreadSummaryData | undefined => {
const thread = rootEvent.getThread();
if (thread) {
const lastReply = thread.lastReply();
return {
count: thread.length,
latestTs: lastReply?.getTs(),
};
}
const bundle = rootEvent.getServerAggregatedRelation<IThreadBundledRelationship>(
RelationType.Thread,
);
if (bundle) {
return {
count: bundle.count,
latestTs: bundle.latest_event?.origin_server_ts,
};
}
return undefined;
};
/**
* True when `event` is a still-in-flight (local echo) reply belonging to the
* given thread root. Used to render the pending strip, since pending thread
* sends never enter the thread's timelineSet.
*/
export const isPendingThreadReply = (event: MatrixEvent, threadRootId: string): boolean => {
const { status } = event;
if (status !== EventStatus.SENDING && status !== EventStatus.NOT_SENT) return false;
// Prefer the SDK's resolved thread root id; fall back to the raw relation
// content for events the SDK hasn't associated with a thread yet.
if (event.threadRootId === threadRootId) return true;
const relation = event.getRelation();
return relation?.rel_type === RelationType.Thread && relation.event_id === threadRootId;
};
+177
View File
@@ -0,0 +1,177 @@
import { useCallback, useEffect, useState } from 'react';
import {
EventStatus,
EventTimeline,
MatrixClient,
MatrixEvent,
ReceiptType,
Room,
RoomEvent,
RoomEventHandlerMap,
Thread,
ThreadEvent,
} from 'matrix-js-sdk';
import { getLinkedTimelines } from '../RoomTimeline';
import { isPendingThreadReply } from './threadSummaryData';
/**
* Resolve (or bootstrap) the live {@link Thread} for a root event.
*
* Uses the existing thread when present, otherwise creates one via
* `room.createThread` the SDK then auto-fetches the thread's events via
* `/relations` and inserts the root at the top. If the root event isn't loaded
* locally the Thread handles the root fetch itself, so passing `undefined` is
* safe. Re-resolves when a matching thread later appears/updates on the room.
*/
export const useThreadInstance = (room: Room, threadRootId: string): Thread | undefined => {
const getInstance = useCallback((): Thread | undefined => {
const existing = room.getThread(threadRootId);
if (existing) return existing;
const rootEvent = room.findEventById(threadRootId);
return room.createThread(threadRootId, rootEvent, [], false) ?? undefined;
}, [room, threadRootId]);
const [thread, setThread] = useState<Thread | undefined>(getInstance);
useEffect(() => {
setThread(getInstance());
const handleThread: RoomEventHandlerMap[ThreadEvent.New] = (newThread) => {
if (newThread.id === threadRootId) setThread(newThread);
};
const handleThreadUpdate: RoomEventHandlerMap[ThreadEvent.Update] = (updatedThread) => {
if (updatedThread.id === threadRootId) setThread(updatedThread);
};
room.on(ThreadEvent.New, handleThread);
room.on(ThreadEvent.Update, handleThreadUpdate);
return () => {
room.removeListener(ThreadEvent.New, handleThread);
room.removeListener(ThreadEvent.Update, handleThreadUpdate);
};
}, [room, threadRootId, getInstance]);
return thread;
};
/**
* Build the ordered list of linked {@link EventTimeline}s for a thread's live
* timeline and track readiness (`thread.initialEventsFetched`). Subscribes to
* the Thread's re-emitted timeline events so callers repaginate/re-render as
* the thread fills in.
*/
export const useThreadLinkedTimelines = (
mx: MatrixClient,
thread: Thread,
): { timelines: EventTimeline[]; ready: boolean; refresh: () => void } => {
const [timelines, setTimelines] = useState<EventTimeline[]>(() =>
getLinkedTimelines(thread.liveTimeline),
);
const [ready, setReady] = useState<boolean>(() => thread.initialEventsFetched);
const refresh = useCallback(() => {
setTimelines(getLinkedTimelines(thread.liveTimeline));
setReady(thread.initialEventsFetched);
}, [thread]);
useEffect(() => {
refresh();
const handleTimeline = () => refresh();
// Thread re-emits RoomEvent.Timeline / RoomEvent.TimelineReset from its
// timelineSet, and fires ThreadEvent.Update as it (re)populates.
thread.on(RoomEvent.Timeline, handleTimeline);
thread.on(RoomEvent.TimelineReset, handleTimeline);
thread.on(ThreadEvent.Update, handleTimeline);
return () => {
thread.removeListener(RoomEvent.Timeline, handleTimeline);
thread.removeListener(RoomEvent.TimelineReset, handleTimeline);
thread.removeListener(ThreadEvent.Update, handleTimeline);
};
}, [thread, refresh]);
return { timelines, ready, refresh };
};
/**
* Track in-flight (local echo) replies for a thread.
*
* Pending thread sends never enter the thread's timelineSet (chronological
* pending ordering rejects them; `room.getPendingEvents()` THROWS in this
* mode). We instead watch `RoomEvent.LocalEchoUpdated` on the room and keep our
* own list of events that are pending replies to this thread and not yet in the
* thread timeline. When an event's remote echo arrives (status flips to SENT,
* or it lands in the thread) it drops out of the list.
*/
export const useThreadPendingEvents = (
room: Room,
threadRootId: string,
thread: Thread | undefined,
): MatrixEvent[] => {
const [pending, setPending] = useState<MatrixEvent[]>([]);
useEffect(() => {
setPending([]);
const handleLocalEcho: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (event) => {
const eventId = event.getId();
setPending((prev) => {
// Drop any previous entry for this event (same instance across the
// temp-id -> real-id transition, or matched by id).
const without = prev.filter((e) => e !== event && e.getId() !== eventId);
const alreadyInThread =
eventId !== undefined && thread?.findEventById(eventId) !== undefined;
// Keep a tracked event through the SENT window too: the /send response
// flips status to SENT before /sync delivers the event into the thread
// timeline — dropping it there would make the message flash out of view.
// It falls out on the next LocalEchoUpdated once findEventById sees it.
const trackedAndAwaitingSync =
event.status === EventStatus.SENT &&
prev.some((e) => e === event || (eventId !== undefined && e.getId() === eventId));
const stillPending =
!alreadyInThread && (isPendingThreadReply(event, threadRootId) || trackedAndAwaitingSync);
if (stillPending) return [...without, event];
return without.length === prev.length ? prev : without;
});
};
room.on(RoomEvent.LocalEchoUpdated, handleLocalEcho);
return () => {
room.removeListener(RoomEvent.LocalEchoUpdated, handleLocalEcho);
};
}, [room, threadRootId, thread]);
return pending;
};
/**
* Send a threaded read receipt up to the latest confirmed event in the thread.
*
* The receipt is threaded by default (scoped to this thread), which clears the
* per-thread unread count. Mirrors the latest-valid-event scan in
* `utils/notifications.ts`.
*/
export const markThreadAsRead = async (
mx: MatrixClient,
thread: Thread,
privateReceipt: boolean,
): Promise<void> => {
const events = thread.liveTimeline.getEvents();
let latestEvent: MatrixEvent | undefined;
for (let i = events.length - 1; i >= 0; i -= 1) {
const evt = events[i];
if (evt && !evt.isSending()) {
latestEvent = evt;
break;
}
}
if (!latestEvent) return;
await mx.sendReadReceipt(
latestEvent,
privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read,
);
};
@@ -13,6 +13,7 @@ import {
} from '../../../components/AccountDataEditor';
import { copyToClipboard } from '../../../utils/dom';
import { AccountData } from './AccountData';
import { CryptoDiagnostics } from '../developer/CryptoDiagnostics';
type DeveloperToolsProps = {
requestClose: () => void;
@@ -109,6 +110,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
/>
</SequenceCard>
)}
{developerTools && <CryptoDiagnostics />}
</Box>
{developerTools && (
<AccountData
@@ -0,0 +1,71 @@
import React, { useCallback } from 'react';
import { Badge, Box, Button, Text } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useForceUpdate } from '../../../hooks/useForceUpdate';
import { useInterval } from '../../../hooks/useInterval';
import { buildCryptoDiagReport, getCryptoDiagEntries } from '../../../utils/cryptoDiagLog';
// Lotus E2EE investigation kit — Crypto Diagnostics settings card.
// Mirrors the surrounding Developer Tools cards (see DevelopTools.tsx).
const REFRESH_MS = 1000;
export function CryptoDiagnostics() {
const mx = useMatrixClient();
// Re-render on a light interval so the live matched-entry count stays fresh
// while the settings pane is open.
const [, forceUpdate] = useForceUpdate();
useInterval(forceUpdate, REFRESH_MS);
const count = getCryptoDiagEntries().length;
const handleDownload = useCallback(() => {
const report = buildCryptoDiagReport(mx);
const blob = new Blob([report], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `lotus-crypto-diag-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
a.click();
URL.revokeObjectURL(url);
}, [mx]);
return (
<Box direction="Column" gap="100">
<Text size="L400">Crypto Diagnostics</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Crypto Diagnostics — captures E2EE error signatures this session"
description="Ring-buffers up to 200 matched console warnings/errors for the KE-1..KE-4 bug cluster. Local only — no network calls. The downloaded report includes the matched log lines as evidence."
after={
<Box alignItems="Center" gap="200" shrink="No">
<Badge variant={count > 0 ? 'Critical' : 'Secondary'} fill="Solid" radii="Pill">
<Text as="span" size="L400">
{count}
</Text>
</Badge>
<Button
onClick={handleDownload}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
>
<Text size="B300">Download report</Text>
</Button>
</Box>
}
/>
</SequenceCard>
</Box>
);
}
+324 -34
View File
@@ -5,6 +5,7 @@ import React, {
MouseEventHandler,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -34,6 +35,19 @@ import {
import { isKeyHotkey } from 'is-hotkey';
import { HexColorPicker } from 'react-colorful';
import FocusTrap from 'focus-trap-react';
import {
draggable,
dropTargetForElements,
monitorForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { reorder } from '@atlaskit/pragmatic-drag-and-drop/reorder';
import {
attachClosestEdge,
extractClosestEdge,
Edge,
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
import { useAtom } from 'jotai';
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
import { BgSwatch as BgSwatchStyle } from './BgSwatch.css';
import { Page, PageContent, PageHeader } from '../../../components/page';
@@ -45,17 +59,27 @@ import {
} from '../../../utils/lotusDenoiseUtils';
import { useSetting } from '../../../state/hooks/settings';
import {
CallAudioBitrate,
ChatBackground,
ComposerToolbarButtonKey,
ComposerToolbarSettings,
DateFormat,
DenoiseModelId,
MessageLayout,
MessageSpacing,
NoiseSuppressionMode,
normalizeComposerToolbarOrder,
RingtoneId,
ScreenshareBitrate,
ScreenshareFramerate,
Settings,
settingsAtom,
} from '../../../state/settings';
import {
AUDIO_BITRATE_OPTIONS,
SCREENSHARE_BITRATE_OPTIONS,
SCREENSHARE_FRAMERATE_OPTIONS,
} from '../../../utils/callQuality';
import { SeasonalPreview, SeasonTheme } from '../../../components/seasonal/SeasonalEffect';
import { SEASON_DATE_RANGES } from '../../../components/seasonal/seasonSchedule';
import { SettingTile } from '../../../components/setting-tile';
@@ -78,12 +102,33 @@ import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
import { SequenceCardStyle } from '../styles.css';
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
import { isTauri as isTauriEnv } from '../../../hooks/useTauri';
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
import { useDateFormatItems } from '../../../hooks/useDateFormat';
import { playCallJoinSound } from '../../../utils/callSounds';
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
import { DenoiseTester } from './DenoiseTester';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
/**
* P5-47 opt-in TDS window chrome toggle (desktop only). Renders nothing in the
* browser. Backed by the standalone `customWindowChromeAtom`; `useTauriWindowChrome`
* (mounted in App.tsx) applies `set_decorations` when this flips.
*/
function DesktopChromeSetting() {
const [customChrome, setCustomChrome] = useAtom(customWindowChromeAtom);
if (!isTauriEnv()) return null;
return (
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Custom Window Chrome (Beta)"
description="Replace the system title bar with a Lotus-styled one. Desktop only — toggles instantly."
after={<Switch variant="Primary" value={customChrome} onChange={setCustomChrome} />}
/>
</SequenceCard>
);
}
type ThemeSelectorProps = {
themeNames: Record<string, string>;
themes: Theme[];
@@ -397,6 +442,8 @@ function Appearance() {
/>
</SequenceCard>
<DesktopChromeSetting />
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Twitter Emoji"
@@ -1017,6 +1064,165 @@ function DateAndTime() {
);
}
const COMPOSER_TOOLBAR_LABELS: Record<ComposerToolbarButtonKey, string> = {
showFormat: 'Format',
showEmoji: 'Emoji',
showSticker: 'Sticker',
showGif: 'GIF',
showLocation: 'Location',
showPoll: 'Poll',
showVoice: 'Voice',
showSchedule: 'Schedule',
};
const COMPOSER_TOOLBAR_DRAG_TYPE = 'composer-toolbar-button';
type ComposerToolbarButtonRowProps = {
buttonKey: ComposerToolbarButtonKey;
index: number;
active: boolean;
onToggle: (key: ComposerToolbarButtonKey) => void;
};
function ComposerToolbarButtonRow({
buttonKey,
index,
active,
onToggle,
}: ComposerToolbarButtonRowProps) {
const rowRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLButtonElement>(null);
const [dragging, setDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
useEffect(() => {
const element = rowRef.current;
const dragHandle = handleRef.current;
if (!element || !dragHandle) return undefined;
return combine(
draggable({
element,
dragHandle,
getInitialData: () => ({ type: COMPOSER_TOOLBAR_DRAG_TYPE, buttonKey, index }),
onDragStart: () => setDragging(true),
onDrop: () => setDragging(false),
}),
dropTargetForElements({
element,
canDrop: ({ source }) => source.data.type === COMPOSER_TOOLBAR_DRAG_TYPE,
getData: ({ input }) =>
attachClosestEdge(
{ type: COMPOSER_TOOLBAR_DRAG_TYPE, buttonKey, index },
{ element, input, allowedEdges: ['top', 'bottom'] },
),
getIsSticky: () => true,
onDrag: ({ self, source }) => {
if (source.data.buttonKey === buttonKey) {
setClosestEdge(null);
return;
}
setClosestEdge(extractClosestEdge(self.data));
},
onDragLeave: () => setClosestEdge(null),
onDrop: () => setClosestEdge(null),
}),
);
}, [buttonKey, index]);
let boxShadow: string | undefined;
if (closestEdge === 'top') boxShadow = `inset 0 2px 0 0 ${color.Primary.Main}`;
else if (closestEdge === 'bottom') boxShadow = `inset 0 -2px 0 0 ${color.Primary.Main}`;
return (
<Box
ref={rowRef}
alignItems="Center"
gap="200"
style={{
padding: `${config.space.S200} ${config.space.S400}`,
opacity: dragging ? 0.5 : undefined,
boxShadow,
}}
>
<IconButton
ref={handleRef}
size="300"
radii="300"
variant="SurfaceVariant"
style={{ cursor: 'grab' }}
aria-label={`Reorder ${COMPOSER_TOOLBAR_LABELS[buttonKey]}`}
>
<Icon size="100" src={Icons.VerticalDots} />
</IconButton>
<Text style={{ flexGrow: 1 }} size="T300">
{COMPOSER_TOOLBAR_LABELS[buttonKey]}
</Text>
<Chip
variant={active ? 'Primary' : 'Secondary'}
outlined={active}
radii="Pill"
onClick={() => onToggle(buttonKey)}
aria-pressed={active}
>
<Text size="T200">{active ? 'Shown' : 'Hidden'}</Text>
</Chip>
</Box>
);
}
type ComposerToolbarReorderProps = {
order: ComposerToolbarButtonKey[];
buttons: ComposerToolbarSettings;
onReorder: (startIndex: number, finishIndex: number) => void;
onToggle: (key: ComposerToolbarButtonKey) => void;
};
function ComposerToolbarReorder({
order,
buttons,
onReorder,
onToggle,
}: ComposerToolbarReorderProps) {
useEffect(
() =>
monitorForElements({
canMonitor: ({ source }) => source.data.type === COMPOSER_TOOLBAR_DRAG_TYPE,
onDrop: ({ location, source }) => {
const target = location.current.dropTargets[0];
if (!target) return;
const startIndex = source.data.index;
const indexOfTarget = target.data.index;
if (typeof startIndex !== 'number' || typeof indexOfTarget !== 'number') return;
const closestEdgeOfTarget = extractClosestEdge(target.data);
// Insert relative to the target row, then compensate for the source
// row being removed from its original position.
let finishIndex = closestEdgeOfTarget === 'bottom' ? indexOfTarget + 1 : indexOfTarget;
if (startIndex < finishIndex) finishIndex -= 1;
if (finishIndex === startIndex) return;
onReorder(startIndex, finishIndex);
},
}),
[onReorder],
);
return (
<Box direction="Column">
{order.map((key, index) => (
<ComposerToolbarButtonRow
key={key}
buttonKey={key}
index={index}
active={buttons[key]}
onToggle={onToggle}
/>
))}
</Box>
);
}
function Editor() {
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
@@ -1026,20 +1232,31 @@ function Editor() {
'composerToolbarButtons',
);
const toggleToolbarButton = (key: keyof ComposerToolbarSettings) => {
setComposerToolbarButtons({ ...composerToolbarButtons, [key]: !composerToolbarButtons[key] });
};
const composerToolbarOrder = useMemo(
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
[composerToolbarButtons?.order],
);
const TOOLBAR_CHIPS: Array<{ key: keyof ComposerToolbarSettings; label: string }> = [
{ key: 'showFormat', label: 'Format' },
{ key: 'showEmoji', label: 'Emoji' },
{ key: 'showSticker', label: 'Sticker' },
{ key: 'showGif', label: 'GIF' },
{ key: 'showLocation', label: 'Location' },
{ key: 'showPoll', label: 'Poll' },
{ key: 'showVoice', label: 'Voice' },
{ key: 'showSchedule', label: 'Schedule' },
];
const toggleToolbarButton = useCallback(
(key: ComposerToolbarButtonKey) => {
setComposerToolbarButtons((current) => ({ ...current, [key]: !current[key] }));
},
[setComposerToolbarButtons],
);
const reorderToolbarButtons = useCallback(
(startIndex: number, finishIndex: number) => {
setComposerToolbarButtons((current) => ({
...current,
order: reorder({
list: normalizeComposerToolbarOrder(current.order),
startIndex,
finishIndex,
}),
}));
},
[setComposerToolbarButtons],
);
return (
<Box direction="Column" gap="100">
@@ -1074,28 +1291,15 @@ function Editor() {
>
<SettingTile
title="Composer Toolbar"
description="Tap a button to show or hide it in the message composer."
description="Drag to reorder buttons, and tap a button to show or hide it in the message composer."
/>
<Box
wrap="Wrap"
gap="200"
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
>
{TOOLBAR_CHIPS.map(({ key, label }) => {
const active = composerToolbarButtons?.[key] ?? true;
return (
<Chip
key={key}
variant={active ? 'Primary' : 'Secondary'}
outlined={active}
radii="Pill"
onClick={() => toggleToolbarButton(key)}
aria-pressed={active}
>
<Text size="T300">{label}</Text>
</Chip>
);
})}
<Box direction="Column" style={{ paddingBottom: config.space.S200 }}>
<ComposerToolbarReorder
order={composerToolbarOrder}
buttons={composerToolbarButtons}
onReorder={reorderToolbarButtons}
onToggle={toggleToolbarButton}
/>
</Box>
</SequenceCard>
</Box>
@@ -1221,6 +1425,18 @@ function Calls() {
const [ringtoneVolume, setRingtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
const [ringtoneId, setRingtoneId] = useSetting(settingsAtom, 'ringtoneId');
const [callAudioBitrate, setCallAudioBitrate] = useSetting(settingsAtom, 'callAudioBitrate');
const [screenshareBitrate, setScreenshareBitrate] = useSetting(
settingsAtom,
'screenshareBitrate',
);
const [screenshareFramerate, setScreenshareFramerate] = useSetting(
settingsAtom,
'screenshareFramerate',
);
const [soundboardEnabled, setSoundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
const [soundboardVolume, setSoundboardVolume] = useSetting(settingsAtom, 'soundboardVolume');
const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
setCallJoinLeaveSound(value);
if (value !== 'off') playCallJoinSound(value);
@@ -1616,6 +1832,80 @@ function Calls() {
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Microphone Bitrate"
description="Cap the audio bitrate your mic sends in calls. Lower saves bandwidth; higher is clearer. Auto lets Element Call decide."
after={
<SettingsSelect<CallAudioBitrate>
value={callAudioBitrate}
onChange={setCallAudioBitrate}
options={AUDIO_BITRATE_OPTIONS}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Screenshare Bitrate"
description="Cap the bitrate used when you share your screen. Lower is smoother on poor connections; higher is sharper."
after={
<SettingsSelect<ScreenshareBitrate>
value={screenshareBitrate}
onChange={setScreenshareBitrate}
options={SCREENSHARE_BITRATE_OPTIONS}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Screenshare Framerate"
description="Cap the frames-per-second of your screenshare. 60 fps suits motion/gaming; 15 fps suits slides and saves bandwidth."
after={
<SettingsSelect<ScreenshareFramerate>
value={screenshareFramerate}
onChange={setScreenshareFramerate}
options={SCREENSHARE_FRAMERATE_OPTIONS}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Soundboard"
description="Show a soundboard button in the call bar. Upload short audio clips (like custom emojis) to play them into the call. Clips sync across your devices."
after={
<Switch variant="Primary" value={soundboardEnabled} onChange={setSoundboardEnabled} />
}
/>
{soundboardEnabled && (
<SettingTile
title="Soundboard Volume"
after={
<Box alignItems="Center" gap="200" style={{ minWidth: toRem(180) }}>
<input
type="range"
min={0}
max={100}
step={5}
value={soundboardVolume}
onChange={(e) => setSoundboardVolume(parseInt(e.target.value, 10))}
style={{ flexGrow: 1 }}
aria-label="Soundboard volume"
/>
<Text size="T200" style={{ minWidth: toRem(36), textAlign: 'right' }}>
{soundboardVolume}%
</Text>
</Box>
}
/>
)}
</SequenceCard>
</Box>
);
}
@@ -13,6 +13,7 @@ import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { SpaceSettingsPage } from '../../state/spaceSettings';
import { useRoom } from '../../hooks/useRoom';
import { EmojisStickers } from '../common-settings/emojis-stickers';
import { Soundboard } from '../common-settings/soundboard';
import { Members } from '../common-settings/members';
import { DeveloperTools } from '../common-settings/developer-tools';
import { General } from './general';
@@ -48,6 +49,11 @@ const BASE_SPACE_MENU_ITEMS: SpaceSettingsMenuItem[] = [
name: 'Emojis & Stickers',
icon: Icons.Smile,
},
{
page: SpaceSettingsPage.SoundboardPage,
name: 'Soundboard',
icon: Icons.Bell,
},
{
page: SpaceSettingsPage.DeveloperToolsPage,
name: 'Developer Tools',
@@ -190,6 +196,9 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
{activePage === SpaceSettingsPage.EmojisStickersPage && (
<EmojisStickers requestClose={handlePageRequestClose} />
)}
{activePage === SpaceSettingsPage.SoundboardPage && (
<Soundboard requestClose={handlePageRequestClose} />
)}
{activePage === SpaceSettingsPage.DeveloperToolsPage && (
<DeveloperTools requestClose={handlePageRequestClose} />
)}
+41
View File
@@ -0,0 +1,41 @@
import { useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { CallEmbed } from '../plugins/call';
import { settingsAtom } from '../state/settings';
import { useStateEvent } from './useStateEvent';
import { StateEvent } from '../../types/matrix/room';
import { buildQualityPayload, RoomQualityContent } from '../utils/callQuality';
/**
* [P5-31] Apply the user's call quality settings (clamped by any room-level
* cap) to the Element Call fork via the `io.lotus.set_quality` widget action.
*
* The fork stores the settings and re-applies them on every (re)publish and
* reconnect, so we only need to (re)send when the payload changes or the widget
* becomes ready no need to poll the track lifecycle here.
*/
export function useCallQuality(embed: CallEmbed): void {
const { callAudioBitrate, screenshareBitrate, screenshareFramerate } = useAtomValue(settingsAtom);
const roomQualityEvent = useStateEvent(embed.room, StateEvent.LotusRoomQuality);
const roomCaps = roomQualityEvent?.getContent<RoomQualityContent>();
// Depend on the primitive cap values (not the event object) so re-renders
// don't resend needlessly.
const audioCap = roomCaps?.audio_max_kbps;
const ssCap = roomCaps?.screenshare_max_kbps;
const fpsCap = roomCaps?.screenshare_max_fps;
useEffect(() => {
const payload = buildQualityPayload(
{ callAudioBitrate, screenshareBitrate, screenshareFramerate },
{ audio_max_kbps: audioCap, screenshare_max_kbps: ssCap, screenshare_max_fps: fpsCap },
);
const send = (): void => embed.control.setQuality(payload);
// Send now (settings are sticky fork-side even if tracks aren't up yet) and
// again once the widget signals ready, in case the transport wasn't up.
send();
const off = embed.onReady(send);
return off;
}, [embed, callAudioBitrate, screenshareBitrate, screenshareFramerate, audioCap, ssCap, fpsCap]);
}
+16 -5
View File
@@ -1,11 +1,16 @@
import { useCallback, DragEventHandler, RefObject, useState, useEffect, useRef } from 'react';
import { getDataTransferFiles } from '../utils/dom';
import { collectDroppedFiles } from '../utils/fileEntries';
export const useFileDropHandler = (onDrop: (file: File[]) => void): DragEventHandler =>
useCallback(
(evt) => {
const files = getDataTransferFiles(evt.dataTransfer);
if (files) onDrop(files);
// `collectDroppedFiles` synchronously captures the entry list from the
// DataTransfer before traversing folders asynchronously.
collectDroppedFiles(evt.dataTransfer)
.then((files) => {
if (files) onDrop(files);
})
.catch(() => undefined);
},
[onDrop],
);
@@ -24,8 +29,14 @@ export const useFileDropZone = (
dragCounterRef.current = 0;
setActive(false);
if (!evt.dataTransfer) return;
const files = getDataTransferFiles(evt.dataTransfer);
if (files) onDrop(files);
// Capture entries synchronously (inside the event) then traverse any
// dropped folders asynchronously — the DataTransferItemList is emptied
// once this handler returns.
collectDroppedFiles(evt.dataTransfer)
.then((files) => {
if (files) onDrop(files);
})
.catch(() => undefined);
};
target?.addEventListener('drop', handleDrop);
+16 -1
View File
@@ -2,11 +2,26 @@ import { useEffect, useState } from 'react';
import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { getRecentEmojis } from '../plugins/recent-emoji';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { IEmoji } from '../plugins/emoji';
import { IEmoji, loadEmojiData } from '../plugins/emoji';
export const useRecentEmoji = (mx: MatrixClient, limit?: number): IEmoji[] => {
const [recentEmoji, setRecentEmoji] = useState(() => getRecentEmojis(mx, limit));
// Recent emojis are resolved against the (now lazily loaded) emojibase data
// via getRecentEmojis. Recompute once loadEmojiData has populated it so the
// recent list fills in on first open.
useEffect(() => {
let alive = true;
loadEmojiData()
.then(() => {
if (alive) setRecentEmoji(getRecentEmojis(mx, limit));
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [mx, limit]);
useEffect(() => {
const handleAccountData = (event: MatrixEvent) => {
if (event.getType() !== AccountDataEvent.ElementRecentEmoji) return;
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useRef } from 'react';
import {
ClientEvent,
MatrixClient,
Room,
RoomEmittedEvents,
RoomEventHandlerMap,
} from 'matrix-js-sdk';
/**
* Attach `handler` for `event` on every joined/known room, including rooms
* created after mount (via `ClientEvent.Room`). All listeners are detached on
* unmount or when `mx`/`event` change.
*
* The handler is stored in a ref (mirroring `useTauriEvent`) so callers don't
* need to memoize it changing the handler identity never re-attaches the
* per-room listeners.
*
* The emitting {@link Room} is PREPENDED as the first argument, before the
* event's own args: several room-level SDK events (e.g.
* `RoomEvent.UnreadNotifications`) don't include the room in their payload,
* which callers need for per-room updates. Prepending (not appending) is
* load-bearing some SDK events emit with VARIABLE arity
* (UnreadNotifications fires with 0, 1, or 2 args), so a trailing extra arg
* would land in a different positional slot per emit.
*/
export function useRoomsListener<E extends RoomEmittedEvents>(
mx: MatrixClient,
event: E,
handler: (room: Room, ...args: Parameters<RoomEventHandlerMap[E]>) => void,
): void {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
// Track attached rooms (and their per-room trampolines) so re-emitted
// `ClientEvent.Room` (e.g. on membership changes) never double-subscribes,
// and cleanup can detach exactly what was attached.
const attached = new Map<string, (...args: unknown[]) => void>();
const attach = (room: Room) => {
if (attached.has(room.roomId)) return;
// Per-room trampoline: forwards to the current ref value with the
// emitting room PREPENDED (stable slot regardless of emit arity).
const roomHandler = (...args: unknown[]) =>
(handlerRef.current as (...a: unknown[]) => void)(room, ...args);
attached.set(room.roomId, roomHandler);
// `event`/`roomHandler` are correlated through E but TS can't prove it
// for the open generic, so we assert at the boundary.
room.on(event, roomHandler as any);
};
mx.getRooms().forEach(attach);
const handleRoom = (room: Room) => attach(room);
mx.on(ClientEvent.Room, handleRoom);
return () => {
mx.removeListener(ClientEvent.Room, handleRoom);
attached.forEach((roomHandler, roomId) => {
mx.getRoom(roomId)?.removeListener(event, roomHandler as any);
});
attached.clear();
};
}, [mx, event]);
}
+36
View File
@@ -0,0 +1,36 @@
import { useEffect } from 'react';
import { getFallbackSession, subscribeSessionChanges } from '../state/sessions';
/**
* Keep this tab in sync with session changes performed in other tabs/windows.
*
* The coordinator mounts this once inside the authenticated client shell.
* `storage` events fire only in tabs that did NOT perform the write, so the
* callback here always represents an out-of-tab change.
*
* Default action is the safest one for auth-critical state a full reload:
* - session REMOVED elsewhere (logout / localStorage.clear()) the access
* token disappears, so we reload; the router bounces to auth on next boot.
* - session APPEARED or its access token CHANGED elsewhere (a fresh login or
* a token rotation) we reload so the client re-initialises with the new
* credentials rather than running on a stale/revoked token.
*
* A change that does not alter the access token (e.g. an OIDC metadata-only
* rewrite) is ignored, which also collapses the several storage events emitted
* by a single dual-write into at most one reload.
*/
export const useSessionSync = (): void => {
useEffect(() => {
// Snapshot the credential this tab booted with; compare against it so we
// only reload on a genuine credential change.
const initialAccessToken = getFallbackSession()?.accessToken ?? null;
const unsubscribe = subscribeSessionChanges((session) => {
const nextAccessToken = session?.accessToken ?? null;
if (nextAccessToken === initialAccessToken) return;
window.location.reload();
});
return unsubscribe;
}, []);
};
+160
View File
@@ -0,0 +1,160 @@
import { Room } from 'matrix-js-sdk';
import { useCallback, useMemo, useState } from 'react';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { StateEvent } from '../../types/matrix/room';
import {
getGlobalSoundboardPacks,
getRoomSoundboardPack,
getRoomSoundboardPacks,
getUserSoundboardPack,
SoundboardPack,
} from '../plugins/soundboard';
import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
import { useStateEventCallback } from './useStateEventCallback';
// Parallels hooks/useImagePacks.ts (custom emoji). Same aggregation shape.
export const useUserSoundboardPack = (): SoundboardPack | undefined => {
const mx = useMatrixClient();
const [userPack, setUserPack] = useState(() => getUserSoundboardPack(mx));
useAccountDataCallback(
mx,
useCallback(
(mEvent) => {
if (mEvent.getType() === AccountDataEvent.LotusSoundboard) {
setUserPack(getUserSoundboardPack(mx));
}
},
[mx],
),
);
return userPack;
};
export const useGlobalSoundboardPacks = (): SoundboardPack[] => {
const mx = useMatrixClient();
const [globalPacks, setGlobalPacks] = useState(() => getGlobalSoundboardPacks(mx));
useAccountDataCallback(
mx,
useCallback(
(mEvent) => {
if (mEvent.getType() === AccountDataEvent.LotusSoundboardRooms) {
setGlobalPacks(getGlobalSoundboardPacks(mx));
}
},
[mx],
),
);
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
const roomId = mEvent.getRoomId();
const stateKey = mEvent.getStateKey();
if (
mEvent.getType() === StateEvent.LotusSoundboardRoom &&
roomId &&
typeof stateKey === 'string'
) {
const isGlobal = !!globalPacks.find(
(pack) =>
pack.address && pack.address.roomId === roomId && pack.address.stateKey === stateKey,
);
if (isGlobal) setGlobalPacks(getGlobalSoundboardPacks(mx));
}
},
[mx, globalPacks],
),
);
return globalPacks;
};
export const useRoomSoundboardPack = (room: Room, stateKey: string): SoundboardPack | undefined => {
const mx = useMatrixClient();
const [roomPack, setRoomPack] = useState(() => getRoomSoundboardPack(room, stateKey));
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
if (
mEvent.getRoomId() === room.roomId &&
mEvent.getType() === StateEvent.LotusSoundboardRoom &&
mEvent.getStateKey() === stateKey
) {
setRoomPack(getRoomSoundboardPack(room, stateKey));
}
},
[room, stateKey],
),
);
return roomPack;
};
export const useRoomSoundboardPacks = (room: Room): SoundboardPack[] => {
const mx = useMatrixClient();
const [roomPacks, setRoomPacks] = useState(() => getRoomSoundboardPacks(room));
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
if (
mEvent.getRoomId() === room.roomId &&
mEvent.getType() === StateEvent.LotusSoundboardRoom
) {
setRoomPacks(getRoomSoundboardPacks(room));
}
},
[room],
),
);
return roomPacks;
};
export const useRoomsSoundboardPacks = (rooms: Room[]): SoundboardPack[] => {
const mx = useMatrixClient();
const [roomPacks, setRoomPacks] = useState(() => rooms.flatMap(getRoomSoundboardPacks));
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
if (
rooms.find((room) => room.roomId === mEvent.getRoomId()) &&
mEvent.getType() === StateEvent.LotusSoundboardRoom
) {
setRoomPacks(rooms.flatMap(getRoomSoundboardPacks));
}
},
[rooms],
),
);
return roomPacks;
};
/** User global room packs, deduped by id, keeping only packs with clips. */
export const useRelevantSoundboardPacks = (rooms: Room[]): SoundboardPack[] => {
const userPack = useUserSoundboardPack();
const globalPacks = useGlobalSoundboardPacks();
const roomsPacks = useRoomsSoundboardPacks(rooms);
return useMemo(() => {
const packs = userPack ? [userPack] : [];
const globalPackIds = new Set(globalPacks.map((pack) => pack.id));
const relPacks = packs.concat(
globalPacks,
roomsPacks.filter((pack) => !globalPackIds.has(pack.id)),
);
return relPacks.filter((pack) => pack.getClips().length > 0);
}, [userPack, globalPacks, roomsPacks]);
};
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useRef } from 'react';
// Tauri v2 injects `__TAURI_INTERNALS__` into the webview at runtime; we use it
// directly so cinny doesn't need `@tauri-apps/api` as a dependency. Native Rust
// modules push data back to the web by dispatching DOM CustomEvents (see
// `emit_to_web` in cinny-desktop's `native` module), which `useTauriEvent`
// subscribes to. This module is the single source for the desktop bridge that
// every `useTauri*` feature hook builds on.
type Invoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
export const tauriInvoke = (): Invoke | undefined =>
(window as unknown as { __TAURI_INTERNALS__?: { invoke: Invoke } }).__TAURI_INTERNALS__?.invoke;
export const isTauri = (): boolean => tauriInvoke() !== undefined;
/** Fire-and-forget invoke that no-ops (and never throws) outside Tauri. */
export const invokeTauri = (cmd: string, args?: Record<string, unknown>): void => {
tauriInvoke()?.(cmd, args).catch(() => undefined);
};
/**
* Subscribe to a CustomEvent dispatched from the Rust side via `emit_to_web`.
* The handler is kept in a ref so callers don't need to memoize it to avoid
* re-subscribing. No-op outside Tauri.
*/
export function useTauriEvent<T = unknown>(name: string, handler: (detail: T) => void): void {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
if (!isTauri()) return undefined;
const listener = (e: Event): void => handlerRef.current((e as CustomEvent<T>).detail);
window.addEventListener(name, listener);
return () => window.removeEventListener(name, listener);
}, [name]);
}
+18
View File
@@ -0,0 +1,18 @@
import { useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { callEmbedAtom } from '../state/callEmbed';
import { invokeTauri } from './useTauri';
/**
* P5-46 keep the system awake during calls (call continuity). Mirrors the
* call-embed atom (undefined = no active call) onto the native `set_call_active`
* command, which holds a `SetThreadExecutionState` request on Windows while a
* voice/video call is active and releases it when the call ends. No-op in the
* browser.
*/
export function useTauriCallPower(): void {
const callEmbed = useAtomValue(callEmbedAtom);
useEffect(() => {
invokeTauri('set_call_active', { active: callEmbed !== undefined });
}, [callEmbed]);
}
+24
View File
@@ -0,0 +1,24 @@
import { useSetAtom } from 'jotai';
import { focusAssistActiveAtom } from '../state/focusAssist';
import { useTauriEvent } from './useTauri';
/** Detail shape of the `focus-assist-changed` event emitted by the native side. */
type FocusAssistChangedDetail = {
active: boolean;
};
/**
* P5-56 Windows Focus Assist Do-Not-Disturb sync (desktop). Subscribes to
* the native `focus-assist-changed` event (Windows `SHQueryUserNotificationState`
* poll, `{ active }`) and mirrors it into `focusAssistActiveAtom`, which the
* notification gate reads to suppress notifications while the shell is in Focus
* Assist / Quiet Hours, presenting, gaming full-screen, or busy. Inert in the
* browser, since `useTauriEvent` only listens under Tauri.
*/
export function useTauriFocusAssist(): void {
const setFocusAssist = useSetAtom(focusAssistActiveAtom);
useTauriEvent<FocusAssistChangedDetail>('focus-assist-changed', ({ active }) =>
setFocusAssist(active),
);
}
+58
View File
@@ -0,0 +1,58 @@
import { useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { Room } from 'matrix-js-sdk';
import { allRoomsAtom } from '../state/room-list/roomList';
import { useMatrixClient } from './useMatrixClient';
import { isTauri, invokeTauri } from './useTauri';
/** Cap the Jump List to a small, glanceable set of rooms. */
const MAX_ITEMS = 8;
/** Wait for room activity to settle before re-publishing the (native) list. */
const DEBOUNCE_MS = 1500;
type JumpItem = { title: string; uri: string };
/**
* Build the `matrix:` deep link the desktop deep-link handler understands (see
* `useDeepLinkNavigate`): `matrix:r/<alias>` for a canonical alias, otherwise
* `matrix:roomid/<id>`. The sigil is dropped and the remainder is percent-encoded
* because the handler decodes each segment with `decodeURIComponent`.
*/
const roomToUri = (room: Room): string => {
const alias = room.getCanonicalAlias();
if (alias && alias.startsWith('#')) {
return `matrix:r/${encodeURIComponent(alias.slice(1))}`;
}
return `matrix:roomid/${encodeURIComponent(room.roomId.slice(1))}`;
};
/**
* P5-36 publish a Windows taskbar Jump List of the most recently-active rooms.
* Rooms come from `allRoomsAtom` (the joined-room list), sorted by
* `getLastActiveTimestamp` (mirroring the sort used elsewhere, e.g. the forward
* dialog), with spaces excluded. The list is pushed to the native
* `set_jump_list` command, debounced so bursts of activity don't thrash the
* shell. No-op outside Tauri.
*/
export function useTauriJumpList(): void {
const mx = useMatrixClient();
const allRooms = useAtomValue(allRoomsAtom);
useEffect(() => {
if (!isTauri()) return undefined;
const timeout = setTimeout(() => {
const items: JumpItem[] = allRooms
.map((roomId) => mx.getRoom(roomId))
.filter((room): room is Room => room !== null && !room.isSpaceRoom())
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0))
.slice(0, MAX_ITEMS)
.map((room) => ({ title: room.name || room.roomId, uri: roomToUri(room) }));
invokeTauri('set_jump_list', { items });
}, DEBOUNCE_MS);
return () => clearTimeout(timeout);
}, [mx, allRooms]);
}
+38
View File
@@ -0,0 +1,38 @@
import { useRef, useState } from 'react';
import { useMatrixClient } from './useMatrixClient';
import { useTauriEvent } from './useTauri';
/** Detail shape of the `network-changed` event emitted by the native side. */
type NetworkChangedDetail = {
online: boolean;
};
/**
* P5-49 Network awareness (desktop). Subscribes to the native
* `network-changed` event (Windows Network List Manager poll, `{ online }`) and,
* on a transition back to online, calls `mx.retryImmediately()` so the sync loop
* retries its backed-off `/sync` at once instead of waiting out the backoff
* timer. Returns the last known connectivity (`undefined` until the first
* event). Inert in the browser, since `useTauriEvent` only listens under Tauri.
*/
export function useTauriNetwork(): boolean | undefined {
const mx = useMatrixClient();
const [online, setOnline] = useState<boolean | undefined>(undefined);
// Track the previous value in a ref so we can detect an offline -> online
// transition without adding it to a dependency list.
const onlineRef = useRef<boolean | undefined>(undefined);
useTauriEvent<NetworkChangedDetail>('network-changed', ({ online: next }) => {
const previous = onlineRef.current;
onlineRef.current = next;
setOnline(next);
// Only nudge the client when connectivity is (re)gained. The initial event
// (previous === undefined) also triggers a retry, which is safe: it's a
// no-op if nothing is backed off.
if (next && previous !== true) {
mx.retryImmediately();
}
});
return online;
}
+38
View File
@@ -0,0 +1,38 @@
import { useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { callEmbedAtom } from '../state/callEmbed';
import { useCallControlState } from '../plugins/call';
import { invokeTauri, useTauriEvent } from './useTauri';
/**
* P5-43 expose the active call to the Windows System Media Transport Controls
* (the volume-flyout / media overlay). Mirrors the call-embed atom (undefined =
* no active call) and the current mic state onto the native
* `set_smtc_call_state` command, and translates SMTC button presses back into
* call actions:
* - Play/Pause (`smtc-action` `mute`) toggles the microphone.
* - Stop (`smtc-action` `end`) hangs up the call.
* No-op in the browser (the native command and events only fire under Tauri).
*/
type SmtcAction = { action: 'mute' | 'end' };
export function useTauriSmtc(): void {
const callEmbed = useAtomValue(callEmbedAtom);
// `microphone` reflects mic-enabled; muted is its inverse while in a call.
const { microphone } = useCallControlState(callEmbed?.control);
const active = callEmbed !== undefined;
const muted = active && !microphone;
useEffect(() => {
invokeTauri('set_smtc_call_state', { active, muted });
}, [active, muted]);
useTauriEvent<SmtcAction>('smtc-action', ({ action }) => {
if (!callEmbed) return;
if (action === 'mute') {
callEmbed.control.toggleMicrophone().catch(() => undefined);
} else if (action === 'end') {
callEmbed.hangup().catch(() => undefined);
}
});
}
+44
View File
@@ -0,0 +1,44 @@
import { useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { callEmbedAtom } from '../state/callEmbed';
import { useCallControlState } from '../plugins/call';
import { invokeTauri, useTauriEvent } from './useTauri';
type ThumbbarAction = { action: 'mute' | 'deafen' | 'end' };
/**
* P5-44 Taskbar thumbnail toolbar (call controls). While a call is active,
* mirrors the mic/sound state onto the native `set_thumbbar` command (three
* Mute / Deafen / End-Call buttons on the Windows taskbar thumbnail toolbar) and
* hides them when the call ends. Thumb-button clicks come back as the
* `thumbbar-action` event and drive the real call controls. No-op in the browser.
*/
export function useTauriThumbbar(): void {
const callEmbed = useAtomValue(callEmbedAtom);
const { microphone, sound } = useCallControlState(callEmbed?.control);
const active = callEmbed !== undefined;
// Muted / deafened only make sense while a call is active; report false
// otherwise so the buttons render in a sane (hidden) state.
const muted = active && !microphone;
const deafened = active && !sound;
useEffect(() => {
invokeTauri('set_thumbbar', { active, muted, deafened });
}, [active, muted, deafened]);
useTauriEvent<ThumbbarAction>('thumbbar-action', ({ action }) => {
if (!callEmbed) return;
if (action === 'mute') {
// toggleMicrophone flips the mic; `microphone === false` means muted.
// Async transport send — swallow rejection (widget mid-teardown), as SMTC does.
callEmbed.control.toggleMicrophone().catch(() => undefined);
} else if (action === 'deafen') {
// toggleSound flips local audio; `sound === false` means deafened. It also
// mutes the mic while deafened, matching the in-app Deafen control.
callEmbed.control.toggleSound();
} else if (action === 'end') {
callEmbed.hangup().catch(() => undefined);
}
});
}
+39
View File
@@ -0,0 +1,39 @@
import { useNavigate } from 'react-router-dom';
import { MsgType } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useTauriEvent } from './useTauri';
/** Payload of the `lotus-notification-activate` event (a plain body click). */
interface ActivateDetail {
path?: string;
}
/** Payload of the `lotus-notification-reply` event (the inline reply box). */
interface ReplyDetail {
roomId?: string;
text?: string;
}
/**
* P5-41 / P5-35 wire the native WinRT toast's click + quick-reply back into the
* client. The Rust side (`show_rich_toast`) dispatches DOM CustomEvents via
* `emit_to_web`:
* - `lotus-notification-activate` route to the room the toast was for, reusing
* the same `useNavigate(path)` mechanism the web `notificationclick` path uses
* (see ClientNonUIFeatures).
* - `lotus-notification-reply` send the typed reply straight to the room.
* No-op outside Tauri (the events never fire).
*/
export function useTauriToastActions(): void {
const navigate = useNavigate();
const mx = useMatrixClient();
useTauriEvent<ActivateDetail>('lotus-notification-activate', ({ path }) => {
if (path) navigate(path);
});
useTauriEvent<ReplyDetail>('lotus-notification-reply', ({ roomId, text }) => {
if (!roomId || !text) return;
mx.sendMessage(roomId, { msgtype: MsgType.Text, body: text }).catch(() => undefined);
});
}
+22
View File
@@ -0,0 +1,22 @@
import { useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { customWindowChromeAtom } from '../state/customWindowChrome';
import { invokeTauri, isTauri } from './useTauri';
/**
* P5-47 drive the native window frame from the `customWindowChromeAtom`.
*
* On mount and whenever the atom changes, pushes the value onto the native
* `set_custom_chrome` command: `enabled = true` strips the OS decorations so the
* web `<TitleBar/>` can take over, `enabled = false` restores the native frame.
* No-op in the browser (`isTauri()` guard), so it's safe to call unconditionally
* from the app shell.
*/
export function useTauriWindowChrome(): void {
const enabled = useAtomValue(customWindowChromeAtom);
useEffect(() => {
if (!isTauri()) return;
invokeTauri('set_custom_chrome', { enabled });
}, [enabled]);
}
+97
View File
@@ -0,0 +1,97 @@
import { useCallback } from 'react';
import { useAtomValue } from 'jotai';
import { MatrixClient } from 'matrix-js-sdk';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { threadNotificationsAtom } from '../state/threadNotifications';
import {
getThreadNotificationMode,
pruneThreadNotifications,
ThreadNotificationEntry,
ThreadNotificationMode,
ThreadNotificationsContent,
} from '../utils/threadNotifications';
import { useMatrixClient } from './useMatrixClient';
import { AsyncState, useAsyncCallback } from './useAsyncCallback';
/** Read the current notification mode for a thread from the bound atom. */
export function useThreadNotificationMode(
roomId: string,
threadRootId: string,
): ThreadNotificationMode {
const content = useAtomValue(threadNotificationsAtom);
return getThreadNotificationMode(content, roomId, threadRootId);
}
const readContent = (mx: MatrixClient): ThreadNotificationsContent =>
((mx as any).getAccountData(AccountDataEvent.LotusThreadNotifications)?.getContent() as
| ThreadNotificationsContent
| undefined) ?? {};
const getJoinedRoomIds = (mx: MatrixClient): Set<string> => {
const joined = new Set<string>();
mx.getRooms().forEach((room) => {
if (room.getMyMembership() === 'join') {
joined.add(room.roomId);
}
});
return joined;
};
const writeThreadNotificationMode = async (
mx: MatrixClient,
roomId: string,
threadRootId: string,
mode: ThreadNotificationMode,
): Promise<void> => {
const current = readContent(mx);
const now = Date.now();
// Work on a mutable clone; prune produces a fresh object so the mutations
// below never touch the atom's/account-data's current content.
const next: ThreadNotificationsContent = {
...current,
rooms: Object.fromEntries(
Object.entries(current.rooms ?? {}).map(([rid, entries]) => [rid, { ...entries }]),
),
};
const rooms = next.rooms as Record<string, Record<string, ThreadNotificationEntry>>;
if (mode === ThreadNotificationMode.Default) {
if (rooms[roomId]) {
delete rooms[roomId][threadRootId];
if (Object.keys(rooms[roomId]).length === 0) {
delete rooms[roomId];
}
}
} else {
if (!rooms[roomId]) {
rooms[roomId] = {};
}
rooms[roomId][threadRootId] = { mode, ts: now };
}
// ALWAYS prune before persisting to keep account data bounded.
const finalContent = pruneThreadNotifications(next, getJoinedRoomIds(mx), now);
await (mx as any).setAccountData(AccountDataEvent.LotusThreadNotifications, finalContent);
};
export function useSetThreadNotificationMode(
roomId: string,
threadRootId: string,
): {
modeState: AsyncState<void, Error>;
setMode: (mode: ThreadNotificationMode) => Promise<void>;
} {
const mx = useMatrixClient();
const [modeState, setMode] = useAsyncCallback<void, Error, [ThreadNotificationMode]>(
useCallback(
(mode: ThreadNotificationMode) => writeThreadNotificationMode(mx, roomId, threadRootId, mode),
[mx, roomId, threadRootId],
),
);
return { modeState, setMode };
}
+67
View File
@@ -0,0 +1,67 @@
import { useEffect, useState } from 'react';
import { useAtomValue } from 'jotai';
import {
MatrixEvent,
NotificationCountType,
Room,
RoomEvent,
RoomEventHandlerMap,
ThreadEvent,
} from 'matrix-js-sdk';
import { getThreadSummary, ThreadSummaryData } from '../features/room/thread/threadSummaryData';
import { threadNotificationsAtom } from '../state/threadNotifications';
import { getThreadNotificationMode, ThreadNotificationMode } from '../utils/threadNotifications';
/**
* Reactive thread summary + unread count for a root event's "N replies" chip.
*
* Re-computes the summary on `ThreadEvent.Update` (the SDK re-emits this on the
* root MatrixEvent) and the unread count on `RoomEvent.UnreadNotifications`.
*/
export const useThreadSummary = (
rootEvent: MatrixEvent,
room: Room,
): { summary: ThreadSummaryData | undefined; unread: number; mode: ThreadNotificationMode } => {
const threadId = rootEvent.getId();
const threadNotifications = useAtomValue(threadNotificationsAtom);
const mode = threadId
? getThreadNotificationMode(threadNotifications, room.roomId, threadId)
: ThreadNotificationMode.Default;
const [summary, setSummary] = useState<ThreadSummaryData | undefined>(() =>
getThreadSummary(rootEvent),
);
const [unread, setUnread] = useState<number>(() =>
threadId
? (room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Total) ?? 0)
: 0,
);
useEffect(() => {
const refreshSummary = () => setSummary(getThreadSummary(rootEvent));
const refreshUnread = () => {
if (!threadId) return;
setUnread(room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Total) ?? 0);
};
refreshSummary();
refreshUnread();
const handleUnread: RoomEventHandlerMap[RoomEvent.UnreadNotifications] = (_counts, tId) => {
if (tId && tId !== threadId) return;
refreshUnread();
};
rootEvent.on(ThreadEvent.Update, refreshSummary);
room.on(RoomEvent.UnreadNotifications, handleUnread);
return () => {
rootEvent.removeListener(ThreadEvent.Update, refreshSummary);
room.removeListener(RoomEvent.UnreadNotifications, handleUnread);
};
}, [rootEvent, room, threadId]);
const muted = mode === ThreadNotificationMode.Mute;
return { summary, unread: muted ? 0 : unread, mode };
};
+38 -2
View File
@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { ReactNode, useEffect } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { Provider as JotaiProvider, useAtomValue } from 'jotai';
import {
@@ -25,6 +25,10 @@ import { useCompositionEndTracking } from '../hooks/useComposingCheck';
import { settingsAtom } from '../state/settings';
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
import { isTauri } from '../hooks/useTauri';
import { TitleBar } from '../features/desktop/TitleBar';
import { customWindowChromeAtom } from '../state/customWindowChrome';
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
import { zIndices } from '../styles/zIndex';
@@ -88,6 +92,36 @@ function TauriEffects() {
return null;
}
// P5-47 — opt-in TDS window chrome. `useTauriWindowChrome` keeps the native OS
// window decorations in sync with the setting; when a desktop user enables
// custom chrome we replace the OS titlebar with <TitleBar/>. When off (the
// default, and always in the browser) this returns children unchanged, so there
// is zero layout impact for everyone else.
function DesktopChrome({ children }: { children: ReactNode }) {
const customChrome = useAtomValue(customWindowChromeAtom);
useTauriWindowChrome();
const useChrome = isTauri() && customChrome;
// Keep the wrapper element structure STABLE across the toggle so flipping the
// setting never changes the element type in `children`'s ancestry — otherwise
// React would unmount/remount the whole RouterProvider subtree (losing scroll,
// menus, unsaved composer state). When off, both wrappers use `display:contents`
// so they generate no box → zero layout impact (also the browser default path).
return (
<div
style={
useChrome
? { display: 'flex', flexDirection: 'column', height: '100vh' }
: { display: 'contents' }
}
>
{useChrome && <TitleBar />}
<div style={useChrome ? { flexGrow: 1, minHeight: 0 } : { display: 'contents' }}>
{children}
</div>
</div>
);
}
function NightLightOverlay() {
const settings = useAtomValue(settingsAtom);
if (!settings.nightLightEnabled) return null;
@@ -160,7 +194,9 @@ function App() {
<JotaiProvider>
<AppearanceEffects />
<TauriEffects />
<RouterProvider router={createRouter(clientConfig, screenSize)} />
<DesktopChrome>
<RouterProvider router={createRouter(clientConfig, screenSize)} />
</DesktopChrome>
<SeasonalEffect />
<NightLightOverlay />
<LotusToastContainer />
+149 -50
View File
@@ -1,7 +1,15 @@
import { useAtomValue, useSetAtom } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import {
MatrixEvent,
Room,
RoomEvent,
RoomEventHandlerMap,
Thread,
ThreadEvent,
} from 'matrix-js-sdk';
import { focusAssistActiveAtom } from '../../state/focusAssist';
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
import LogoSVG from '../../../../public/res/lotus.png';
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
@@ -33,6 +41,15 @@ import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { toastQueueAtom } from '../../state/toast';
import { useReminders } from '../../hooks/useReminders';
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
import { useRoomsListener } from '../../hooks/useRoomsListener';
import { threadNotificationsAtom } from '../../state/threadNotifications';
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
import {
getThreadNotificationMode,
shouldNotifyThreadReply,
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
} from '../../utils/threadNotifications';
function isInQuietHours(start: string, end: string): boolean {
const now = new Date();
@@ -109,6 +126,7 @@ function InviteNotifications() {
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
const [inviteSoundId] = useSetting(settingsAtom, 'inviteSoundId');
@@ -167,7 +185,8 @@ function InviteNotifications() {
useEffect(() => {
if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') {
const quietActive = quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd);
const quietActive =
focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
if (!quietActive) {
if (showNotifications && notificationPermission('granted')) {
notify(invites.length - perviousInviteLen);
@@ -189,6 +208,7 @@ function InviteNotifications() {
quietHoursEnabled,
quietHoursStart,
quietHoursEnd,
focusAssistActive,
inviteSoundId,
]);
@@ -207,11 +227,14 @@ function PresenceUpdater() {
function MessageNotifications() {
const audioRef = useRef<HTMLAudioElement>(null);
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map());
// Per-thread dedupe: threadId -> last notified eventId.
const lastNotifiedThreadRef = useRef<Map<string, string>>(new Map());
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
const [messageSoundId] = useSetting(settingsAtom, 'messageSoundId');
@@ -236,6 +259,8 @@ function MessageNotifications() {
const navigate = useNavigate();
const notificationSelected = useInboxNotificationsSelected();
const selectedRoomId = useSelectedRoom();
const threadPrefs = useAtomValue(threadNotificationsAtom);
const activeThreadId = useAtomValue(roomIdToActiveThreadIdAtomFamily(selectedRoomId ?? ''));
const notify = useCallback(
({
@@ -246,6 +271,7 @@ function MessageNotifications() {
eventId,
body,
encrypted,
threadId,
}: {
roomName: string;
roomAvatar?: string;
@@ -254,6 +280,7 @@ function MessageNotifications() {
eventId: string;
body?: string;
encrypted?: boolean;
threadId?: string;
}) => {
const roomPath = mDirects.has(roomId)
? getDirectRoomPath(roomId, eventId)
@@ -288,7 +315,9 @@ function MessageNotifications() {
silent: true,
// Coalesce repeated notifications for the same room (replaces the old
// manual notifRef.close() dedup, which a SW notification can't hold).
tag: roomId,
// For thread replies widen the tag to room:thread so each thread
// coalesces independently instead of clobbering the room's bucket.
tag: threadId ? `${roomId}:${threadId}` : roomId,
data: { path: roomPath },
},
() => {
@@ -320,6 +349,69 @@ function MessageNotifications() {
audioElement?.play();
}, []);
// Shared delivery tail for both the main timeline and per-thread paths:
// room-level unread dedup → avatar resolution → OS/toast notify → sound, all
// behind the quiet-hours / focus-assist gate. `threadId` (when set) widens the
// OS coalescing tag so each thread notifies independently; the click path
// stays the room path (RoomTimeline deep-links thread events into the panel).
const deliverNotification = useCallback(
(room: Room, mEvent: MatrixEvent, threadId?: string) => {
const sender = mEvent.getSender();
const eventId = mEvent.getId();
if (!sender || !eventId) return;
const unreadInfo = getUnreadInfo(room);
const cachedUnreadInfo = unreadCacheRef.current.get(room.roomId);
unreadCacheRef.current.set(room.roomId, unreadInfo);
if (unreadInfo.total === 0) return;
if (
cachedUnreadInfo &&
unreadEqual(unreadInfoToUnread(cachedUnreadInfo), unreadInfoToUnread(unreadInfo))
) {
return;
}
const quietActive =
focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
if (quietActive) return;
if (showNotifications && notificationPermission('granted')) {
const avatarMxc =
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
notify({
roomName: room.name ?? 'Unknown',
roomAvatar: avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined,
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
roomId: room.roomId,
eventId,
body: (mEvent.getContent().body as string | undefined) ?? '',
encrypted: room.hasEncryptionStateEvent(),
threadId,
});
}
if (notificationSound && messageSoundId !== 'none') {
playSound();
}
},
[
mx,
notify,
playSound,
showNotifications,
notificationSound,
useAuthentication,
quietHoursEnabled,
quietHoursStart,
quietHoursEnd,
focusAssistActive,
messageSoundId,
],
);
useEffect(() => {
const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = (
mEvent,
@@ -343,59 +435,65 @@ function MessageNotifications() {
const sender = mEvent.getSender();
const eventId = mEvent.getId();
if (!sender || !eventId || mEvent.getSender() === mx.getUserId()) return;
const unreadInfo = getUnreadInfo(room);
const cachedUnreadInfo = unreadCacheRef.current.get(room.roomId);
unreadCacheRef.current.set(room.roomId, unreadInfo);
// Single-owner rule: thread replies are delivered by the ThreadEvent.NewReply
// handler below (per-thread gating), so ignore them here — a reply notifies once.
if (mEvent.threadRootId && mEvent.getId() !== mEvent.threadRootId) return;
if (unreadInfo.total === 0) return;
if (
cachedUnreadInfo &&
unreadEqual(unreadInfoToUnread(cachedUnreadInfo), unreadInfoToUnread(unreadInfo))
) {
return;
}
const quietActive = quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd);
if (!quietActive) {
if (showNotifications && notificationPermission('granted')) {
const avatarMxc =
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
notify({
roomName: room.name ?? 'Unknown',
roomAvatar: avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined,
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
roomId: room.roomId,
eventId,
body: (mEvent.getContent().body as string | undefined) ?? '',
encrypted: room.hasEncryptionStateEvent(),
});
}
if (notificationSound && messageSoundId !== 'none') {
playSound();
}
}
deliverNotification(room, mEvent);
};
mx.on(RoomEvent.Timeline, handleTimelineEvent);
return () => {
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
};
}, [
mx,
notificationSound,
notificationSelected,
showNotifications,
playSound,
notify,
selectedRoomId,
useAuthentication,
quietHoursEnabled,
quietHoursStart,
quietHoursEnd,
messageSoundId,
]);
}, [mx, notificationSelected, selectedRoomId, deliverNotification]);
const handleNewReply = useCallback(
// useRoomsListener prepends the emitting Room; the thread's own room lookup
// below is kept as the authority (identical object in practice).
(_room: Room, thread: Thread, mEvent: MatrixEvent) => {
if (mx.getSyncState() !== 'SYNCING') return;
const room = mx.getRoom(thread.roomId);
if (!room || room.isSpaceRoom()) return;
if (!isNotificationEvent(mEvent) || mEvent.isSending()) return;
const sender = mEvent.getSender();
if (!sender || sender === mx.getUserId()) return;
// Suppress when the user is actively looking at this thread (or the inbox).
if (
document.hasFocus() &&
(notificationSelected || (selectedRoomId === thread.roomId && activeThreadId === thread.id))
) {
return;
}
// Per-thread dedupe: a NewReply can re-fire for the same event as the
// thread (re)populates; notify at most once per (thread, event).
const eventId = mEvent.getId();
if (eventId) {
if (lastNotifiedThreadRef.current.get(thread.id) === eventId) return;
lastNotifiedThreadRef.current.set(thread.id, eventId);
}
const content = threadPrefs;
const mode = getThreadNotificationMode(content, room.roomId, thread.id);
const actions = mx.getPushActionsForEvent(mEvent);
const decision = shouldNotifyThreadReply({
mode,
defaultBehavior: content.default ?? THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
participated: thread.hasCurrentUserParticipated,
highlight: !!actions?.tweaks?.highlight,
notify: !!actions?.notify,
roomMuted: getNotificationType(mx, room.roomId) === NotificationType.Mute,
});
if (decision === 'none') return;
// E2EE caveat: NewReply can fire before decryption, so MentionsOnly may
// under-notify in encrypted rooms (same class as the main timeline path).
// Plaintext body suppression for encrypted rooms is handled inside notify().
deliverNotification(room, mEvent, thread.id);
},
[mx, notificationSelected, selectedRoomId, activeThreadId, threadPrefs, deliverNotification],
);
useRoomsListener(mx, ThreadEvent.NewReply, handleNewReply);
return (
<audio ref={audioRef} style={{ display: 'none' }}>
@@ -555,6 +653,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<MessageNotifications />
<ReminderMonitor />
<TauriUpdateFeature />
<TauriDesktopFeatures />
<LotusDenoiseFeature />
<DeepLinkNavigator />
{children}
+10
View File
@@ -43,8 +43,15 @@ import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
import { useSessionSync } from '../../hooks/useSessionSync';
import { installCryptoDiagLog } from '../../utils/cryptoDiagLog';
import { AutoDiscovery } from './AutoDiscovery';
// Capture-only E2EE diagnostics ring buffer (KE-1→4 signatures) — installed at
// module load so it sees crypto warnings from the very first sync. Idempotent;
// report download lives in Settings → Developer Tools → Crypto Diagnostics.
installCryptoDiagLog();
function ClientRootLoading() {
return (
<SplashScreen>
@@ -178,6 +185,9 @@ export function ClientRoot({ children }: ClientRootProps) {
);
useLogoutListener(mx);
// Cross-tab session sync: another tab logging out / in (access token changed
// in localStorage) reloads this tab so it never runs with stale credentials.
useSessionSync();
useEffect(() => {
if (loadState.status === AsyncStatus.Idle) {
+38
View File
@@ -7,6 +7,17 @@ export enum CallControlEvent {
StateUpdate = 'state_update',
}
/**
* [lotus #7 / P5-31] Payload for the fork's `io.lotus.set_quality` action.
* All fields optional; `null` clears that cap. Bits/sec for bitrates, fps for
* framerate.
*/
export type LotusQualityPayload = {
audioMaxBitrate?: number | null;
screenshareMaxBitrate?: number | null;
screenshareMaxFramerate?: number | null;
};
export class CallControl extends EventEmitter implements CallControlState {
private state: CallControlState;
@@ -358,6 +369,33 @@ export class CallControl extends EventEmitter implements CallControlState {
this.call.transport.send('io.lotus.focus_participant', { userId: null }).catch(() => undefined);
}
/**
* [lotus #3 / P5-15] Inject a soundboard clip into the call so other
* participants hear it. The fork publishes it as a separate LiveKit audio
* track (`io.lotus.inject_audio`) rather than splicing the mic. `url` must be
* an https/blob URL the widget can fetch WITHOUT credentials the host
* resolves an mxc clip to a `blob:` object URL first (authenticated media
* can't be fetched cross-realm by the widget). `volume` is 01.
*
* The local user does not hear their own published track, so callers should
* also play the clip locally for feedback.
*/
public injectAudio(url: string, volume = 1): void {
this.call.transport.send('io.lotus.inject_audio', { url, volume }).catch(() => undefined);
}
/**
* [lotus #7 / P5-31] Apply audio/screenshare encoding limits to the local
* published tracks (the fork's `io.lotus.set_quality` action, via
* `RTCRtpSender.setParameters` no republish). Bitrates are bits/sec,
* framerate is fps. A field set to `null` clears that cap. Settings are
* sticky fork-side (re-applied on every re-publish / reconnect). Values are
* clamped fork-side, so out-of-range input can't brick the encoder.
*/
public setQuality(settings: LotusQualityPayload): void {
this.call.transport.send('io.lotus.set_quality', settings).catch(() => undefined);
}
public dispose() {
this.bodyMutationObserver.disconnect();
this.controlMutationObserver.disconnect();

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