Compare commits

...

24 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
94 changed files with 7564 additions and 1128 deletions
+19 -8
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.
@@ -69,12 +78,14 @@ from testing:
## 🔴 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
@@ -139,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.)
@@ -156,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.
+66 -9
View File
@@ -18,15 +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. [Desktop App Features](#desktop-app-features)
20. [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)
---
@@ -690,6 +691,24 @@ Context menu → **Forward** allows forwarding a message to any room the user is
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds) passed to the search API
- 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.
@@ -765,6 +784,36 @@ Generic (non-domain-specific) cards display a Google S2 favicon. Empty or unpars
---
## Threads (P3-8)
Full threaded-conversation support (`m.thread`, matrix-js-sdk `threadSupport`), Element-consistent.
### Thread Panel
A right-side drawer (mirrors the members drawer; fullscreen on mobile) with the thread's root message emphasized at top, an "N replies" divider, the full reply timeline (virtualized, back-paginates via `/relations`, decrypts E2EE threads), reactions/edits/redactions, and its own composer. Open it from **Reply in Thread** in the message menu, a reply's thread indicator, or a summary chip; close with **×** or Escape. Reading the panel sends threaded read receipts so per-thread unread counts clear.
### Summary Chips
Root messages in the main timeline show a **"N replies · time"** chip (server-aggregated `m.thread` bundle, or the live Thread once loaded) with an unread badge — threaded replies no longer render inline in the main timeline, so the chip is how conversations stay discoverable.
### Thread Composer
The panel embeds the full composer (uploads, emoji, stickers, GIFs, voice, location, polls) with drafts, reply state, and upload queues **isolated per thread** (`roomId::threadRootId` keys). Replies-to-replies produce spec-correct `m.thread` + `m.in_reply_to` (`is_falling_back: false`). Scheduling and slash commands are disabled inside threads (v1).
### Notifications (Slack-style, P4-1)
By default you're notified for a thread reply only when you **participate** in that thread (you've posted in it) or the reply **@mentions** you — other threads accumulate quietly behind their chip badges. Every thread can be overridden from the bell menu in the panel header: **Default (participating) / All replies / Mentions only / Mute**. Modes sync across your devices (`io.lotus.thread_notifications` account data, auto-pruned). Muting a thread silences notifications and sounds, removes the chip's unread badge (a small bell-mute glyph shows instead), and subtracts that thread from the room's sidebar unread badge (client-side — other Matrix clients on the account still count it).
### Under the Hood
- `threadSupport: true` (startClient) partitions thread events into SDK `Thread` timelines; markAsRead sends **unthreaded** receipts so room badges keep clearing
- Thread replies are notified via exactly one path (room-level `ThreadEvent.NewReply` w/ per-thread dedupe + panel-aware focus suppression); the main timeline notifier is thread-guarded, and room badges refresh live on `RoomEvent.UnreadNotifications`
- Pending sends render via a `LocalEchoUpdated` strip (chronological local echo never enters thread timelineSets)
- Deep links to thread events redirect into the panel
- Files: `features/room/thread/*`, `state/room/thread.ts`, `hooks/useThreadSummary.ts` (+35 tests across the stack)
---
## Presence
### Discord-Style Presence Selector
@@ -1160,6 +1209,14 @@ The `useAuthentication` parameter was previously mispositioned, causing unauthen
The `encUrlPreview` setting defaults to `true` rather than `false`. A security advisory chip in **Settings → Privacy** explains the tradeoff (the homeserver can see which URLs are being previewed) so users can make an informed choice.
### 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
+53 -30
View File
@@ -162,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:
@@ -196,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).
---
@@ -257,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.**
---
@@ -482,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).
---
@@ -492,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.
---
+4
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
+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",
@@ -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';
+14 -10
View File
@@ -351,10 +351,6 @@ 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 && showVideoGroup && <ControlDivider />}
{showVideoGroup && (
@@ -363,12 +359,20 @@ export function CallControls({ callEmbed }: CallControlsProps) {
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)
}
/>
<>
<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} />
+197 -163
View File
@@ -1,220 +1,254 @@
import React, { MouseEventHandler, useCallback, useRef, useState } from 'react';
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
import {
Box,
Chip,
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 { useSoundboard } from '../../hooks/useSoundboard';
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 {
SOUNDBOARD_ACCEPT,
SOUNDBOARD_MAX_CLIPS,
playClipLocally,
resolveClipObjectUrl,
} from '../../utils/soundboardClips';
import { playClipLocally, resolveClipObjectUrl } from '../../utils/soundboardClips';
type CallSoundboardProps = {
callEmbed: CallEmbed;
};
type FlatClip = {
key: string; // packId|shortcode
packId: string;
packName: string;
clip: SoundboardClipReader;
};
/**
* [P5-15] In-call soundboard: trigger user-uploaded clips into the call. Each
* clip is published to peers as a separate track by the EC fork
* (`io.lotus.inject_audio`) and also played locally for the presser's feedback.
* Clips are uploadable/managed here and synced across devices via the
* `io.lotus.soundboard` account data (like custom emoji/sticker packs).
* [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 { clips, addClip, removeClip } = useSoundboard();
const { room } = callEmbed;
const roomToParents = useAtomValue(roomToParentsAtom);
const packRooms = useImagePackRooms(room.roomId, roomToParents);
const packs = useRelevantSoundboardPacks(packRooms);
const [soundboardVolume] = useSetting(settingsAtom, 'soundboardVolume');
const master = Math.max(0, Math.min(1, soundboardVolume / 100));
const [cords, setCords] = useState<RectCords>();
const [busyId, setBusyId] = useState<string>();
const [uploading, setUploading] = useState(false);
const [manage, setManage] = useState(false);
const [playingKey, setPlayingKey] = useState<string>(); // host-side spam guard
const [error, setError] = useState<string>();
const fileInputRef = useRef<HTMLInputElement>(null);
const volume = Math.max(0, Math.min(1, soundboardVolume / 100));
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 handlePlay = useCallback(
async (id: string, mxc: string) => {
setBusyId(id);
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 objectUrl = await resolveClipObjectUrl(mx, mxc);
callEmbed.control.injectAudio(objectUrl, volume);
playClipLocally(objectUrl, volume);
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.');
} finally {
setBusyId(undefined);
done();
}
},
[mx, callEmbed, volume],
);
const handleFile = useCallback(
async (file: File | undefined) => {
if (!file) return;
setUploading(true);
setError(undefined);
try {
await addClip(file);
} catch (e) {
setError(e instanceof Error ? e.message : 'Upload failed.');
} finally {
setUploading(false);
}
},
[addClip],
[mx, callEmbed, master, playingKey],
);
return (
<>
<input
ref={fileInputRef}
type="file"
accept={SOUNDBOARD_ACCEPT}
hidden
onChange={(e) => {
handleFile(e.target.files?.[0]);
e.target.value = '';
}}
/>
<PopOut
anchor={cords}
position="Top"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ maxWidth: '320px' }}>
<Box direction="Column" gap="200" style={{ padding: config.space.S200 }}>
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="L400">Soundboard</Text>
<Chip
variant="Secondary"
radii="Pill"
disabled={uploading || clips.length >= 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>
{clips.length === 0 ? (
<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">
No clips yet. Upload a short audio clip (max 1 MB) to play it into the call.
Clips sync across your devices.
Manage
</Text>
) : (
<Box wrap="Wrap" gap="200">
{clips.map((clip) => (
<Box
key={clip.id}
direction="Column"
gap="100"
style={{ position: 'relative' }}
>
<Chip
variant="SurfaceVariant"
radii="300"
disabled={busyId === clip.id}
onClick={() => handlePlay(clip.id, clip.url)}
before={
busyId === clip.id ? (
<Spinner size="100" />
) : (
<Icon size="100" src={Icons.Play} />
)
}
after={
<Icon
size="50"
src={Icons.Cross}
style={{ cursor: 'pointer' }}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
removeClip(clip.id);
}}
/>
}
>
<Text size="B300" truncate style={{ maxWidth: '120px' }}>
{clip.name}
</Text>
</Chip>
</Box>
))}
</Box>
)}
{error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{error}
</Text>
)}
<Switch variant="Primary" value={manage} onChange={setManage} />
</Box>
</Box>
</Menu>
</FocusTrap>
<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>
}
>
<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>
</>
{(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,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';
+16 -7
View File
@@ -66,8 +66,9 @@ function ControlButton({ label, glyph, onClick, close }: ControlButtonProps) {
*
* 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 (`data-tauri-drag-region`) with the app brand, plus
* minimize / maximize / close controls that call the native window commands.
* 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
@@ -80,10 +81,18 @@ export function TitleBar() {
const mac = isMacOS();
const handleDoubleClick = (evt: MouseEvent<HTMLDivElement>): void => {
// Only the drag surface itself toggles maximize, not the brand/children.
if (evt.target !== evt.currentTarget) return;
invokeTauri('window_toggle_maximize');
// 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 = (
@@ -108,7 +117,7 @@ export function TitleBar() {
);
const dragRegion = (
<div className={css.DragRegion} data-tauri-drag-region onDoubleClick={handleDoubleClick}>
<div className={css.DragRegion} onMouseDown={handleDragMouseDown}>
<span className={css.Brand}>
<Text as="span" size="T200" truncate>
Lotus Chat
@@ -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} />
)}
+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" />
+58 -31
View File
@@ -136,6 +136,7 @@ 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 })),
@@ -149,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');
@@ -184,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);
@@ -206,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),
@@ -225,7 +230,8 @@ 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],
@@ -244,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,
@@ -263,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[]) => {
@@ -279,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);
@@ -288,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] =
@@ -364,7 +370,7 @@ 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) {
@@ -379,22 +385,22 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
// Ignore malformed stored draft
}
}
}, [editor, msgDraft, roomId, setMsgDraft]);
}, [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(
@@ -487,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, {
@@ -568,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.
@@ -643,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(
@@ -660,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(
@@ -742,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,
@@ -757,7 +784,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (alive()) setGifUploading(false);
}
},
[mx, roomId, alive],
[mx, roomId, threadRootId, alive],
);
const handleStickerSelect = useCallback(
@@ -770,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') {
@@ -1258,7 +1285,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
{locationError}
</Text>
)}
<DraftIndicator roomId={roomId} />
<DraftIndicator roomId={draftKey} />
{charCount > 0 && (
<Text
size="T200"
+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>
);
}
@@ -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} />
)}
+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;
}, []);
};
-101
View File
@@ -1,101 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
import { AccountDataEvent } from '../../types/matrix/accountData';
import {
SoundboardClip,
SoundboardContent,
SOUNDBOARD_MAX_CLIP_BYTES,
SOUNDBOARD_MAX_CLIPS,
SOUNDBOARD_NAME_MAX,
readSoundboardClips,
} from '../utils/soundboardClips';
const KEY = AccountDataEvent.LotusSoundboard;
/**
* [P5-15] Read/write the user's personal soundboard, stored in the
* `io.lotus.soundboard` account data event (synced across devices like custom
* emoji/sticker packs). Uploading writes the audio to the media repo and
* appends an mxc reference.
*/
export function useSoundboard(): {
clips: SoundboardClip[];
addClip: (file: File, name?: string) => Promise<void>;
removeClip: (id: string) => Promise<void>;
renameClip: (id: string, name: string) => Promise<void>;
} {
const mx = useMatrixClient();
const [clips, setClips] = useState<SoundboardClip[]>(() => readSoundboardClips(mx));
useAccountDataCallback(
mx,
useCallback((evt) => {
if (evt.getType() === KEY) {
const content = evt.getContent<SoundboardContent>();
setClips(Array.isArray(content?.clips) ? content.clips : []);
}
}, []),
);
useEffect(() => {
setClips(readSoundboardClips(mx));
}, [mx]);
const persist = useCallback(
async (next: SoundboardClip[]) => {
const content: SoundboardContent = { clips: next };
await (
mx as unknown as { setAccountData: (t: string, c: unknown) => Promise<void> }
).setAccountData(KEY, content);
},
[mx],
);
const addClip = useCallback(
async (file: File, name?: string) => {
const current = readSoundboardClips(mx);
if (current.length >= SOUNDBOARD_MAX_CLIPS) {
throw new Error(`Soundboard is full (max ${SOUNDBOARD_MAX_CLIPS} clips).`);
}
if (file.size > SOUNDBOARD_MAX_CLIP_BYTES) {
throw new Error('Clip is too large (max 1 MB).');
}
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
const mxc = res.content_uri;
if (!mxc) throw new Error('Upload failed.');
const label = (name ?? file.name.replace(/\.[^/.]+$/, ''))
.trim()
.slice(0, SOUNDBOARD_NAME_MAX);
const clip: SoundboardClip = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: label || 'Clip',
url: mxc,
mimetype: file.type || undefined,
size: file.size,
};
await persist([...current, clip]);
},
[mx, persist],
);
const removeClip = useCallback(
async (id: string) => {
const next = readSoundboardClips(mx).filter((c) => c.id !== id);
await persist(next);
},
[mx, persist],
);
const renameClip = useCallback(
async (id: string, name: string) => {
const trimmed = name.trim().slice(0, SOUNDBOARD_NAME_MAX);
if (!trimmed) return;
const next = readSoundboardClips(mx).map((c) => (c.id === id ? { ...c, name: trimmed } : c));
await persist(next);
},
[mx, persist],
);
return { clips, addClip, removeClip, renameClip };
}
+160
View File
@@ -0,0 +1,160 @@
import { Room } from 'matrix-js-sdk';
import { useCallback, useMemo, useState } from 'react';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { StateEvent } from '../../types/matrix/room';
import {
getGlobalSoundboardPacks,
getRoomSoundboardPack,
getRoomSoundboardPacks,
getUserSoundboardPack,
SoundboardPack,
} from '../plugins/soundboard';
import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
import { useStateEventCallback } from './useStateEventCallback';
// Parallels hooks/useImagePacks.ts (custom emoji). Same aggregation shape.
export const useUserSoundboardPack = (): SoundboardPack | undefined => {
const mx = useMatrixClient();
const [userPack, setUserPack] = useState(() => getUserSoundboardPack(mx));
useAccountDataCallback(
mx,
useCallback(
(mEvent) => {
if (mEvent.getType() === AccountDataEvent.LotusSoundboard) {
setUserPack(getUserSoundboardPack(mx));
}
},
[mx],
),
);
return userPack;
};
export const useGlobalSoundboardPacks = (): SoundboardPack[] => {
const mx = useMatrixClient();
const [globalPacks, setGlobalPacks] = useState(() => getGlobalSoundboardPacks(mx));
useAccountDataCallback(
mx,
useCallback(
(mEvent) => {
if (mEvent.getType() === AccountDataEvent.LotusSoundboardRooms) {
setGlobalPacks(getGlobalSoundboardPacks(mx));
}
},
[mx],
),
);
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
const roomId = mEvent.getRoomId();
const stateKey = mEvent.getStateKey();
if (
mEvent.getType() === StateEvent.LotusSoundboardRoom &&
roomId &&
typeof stateKey === 'string'
) {
const isGlobal = !!globalPacks.find(
(pack) =>
pack.address && pack.address.roomId === roomId && pack.address.stateKey === stateKey,
);
if (isGlobal) setGlobalPacks(getGlobalSoundboardPacks(mx));
}
},
[mx, globalPacks],
),
);
return globalPacks;
};
export const useRoomSoundboardPack = (room: Room, stateKey: string): SoundboardPack | undefined => {
const mx = useMatrixClient();
const [roomPack, setRoomPack] = useState(() => getRoomSoundboardPack(room, stateKey));
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
if (
mEvent.getRoomId() === room.roomId &&
mEvent.getType() === StateEvent.LotusSoundboardRoom &&
mEvent.getStateKey() === stateKey
) {
setRoomPack(getRoomSoundboardPack(room, stateKey));
}
},
[room, stateKey],
),
);
return roomPack;
};
export const useRoomSoundboardPacks = (room: Room): SoundboardPack[] => {
const mx = useMatrixClient();
const [roomPacks, setRoomPacks] = useState(() => getRoomSoundboardPacks(room));
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
if (
mEvent.getRoomId() === room.roomId &&
mEvent.getType() === StateEvent.LotusSoundboardRoom
) {
setRoomPacks(getRoomSoundboardPacks(room));
}
},
[room],
),
);
return roomPacks;
};
export const useRoomsSoundboardPacks = (rooms: Room[]): SoundboardPack[] => {
const mx = useMatrixClient();
const [roomPacks, setRoomPacks] = useState(() => rooms.flatMap(getRoomSoundboardPacks));
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
if (
rooms.find((room) => room.roomId === mEvent.getRoomId()) &&
mEvent.getType() === StateEvent.LotusSoundboardRoom
) {
setRoomPacks(rooms.flatMap(getRoomSoundboardPacks));
}
},
[rooms],
),
);
return roomPacks;
};
/** User global room packs, deduped by id, keeping only packs with clips. */
export const useRelevantSoundboardPacks = (rooms: Room[]): SoundboardPack[] => {
const userPack = useUserSoundboardPack();
const globalPacks = useGlobalSoundboardPacks();
const roomsPacks = useRoomsSoundboardPacks(rooms);
return useMemo(() => {
const packs = userPack ? [userPack] : [];
const globalPackIds = new Set(globalPacks.map((pack) => pack.id));
const relPacks = packs.concat(
globalPacks,
roomsPacks.filter((pack) => !globalPackIds.has(pack.id)),
);
return relPacks.filter((pack) => pack.getClips().length > 0);
}, [userPack, globalPacks, roomsPacks]);
};
+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 };
};
+141 -51
View File
@@ -1,7 +1,14 @@
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';
@@ -35,6 +42,14 @@ 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();
@@ -212,6 +227,8 @@ 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');
@@ -242,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(
({
@@ -252,6 +271,7 @@ function MessageNotifications() {
eventId,
body,
encrypted,
threadId,
}: {
roomName: string;
roomAvatar?: string;
@@ -260,6 +280,7 @@ function MessageNotifications() {
eventId: string;
body?: string;
encrypted?: boolean;
threadId?: string;
}) => {
const roomPath = mDirects.has(roomId)
? getDirectRoomPath(roomId, eventId)
@@ -294,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 },
},
() => {
@@ -326,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,
@@ -349,61 +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 =
focusAssistActive || (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,
focusAssistActive,
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' }}>
+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) {
+7
View File
@@ -27,6 +27,7 @@ import {
} from './types';
import { CallControl } from './CallControl';
import { CallControlState } from './CallControlState';
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
// Maximum time to wait for the embedded Element Call iframe to progress from
// initial load to a ready/joined state. If it hasn't by then, we assume the
@@ -205,6 +206,12 @@ export class CallEmbed {
params.append('lotusModel', denoiseModel);
params.append('lotusGate', denoiseGate.toString());
params.append('lotusGateThreshold', denoiseGateThreshold.toString());
// [lotus] Fire-and-forget: confirm the fork's ML-denoise assets are
// actually served under public/element-call/denoise/ (they're copied by
// vite.config.js at build time). Warns once if the copy step regressed;
// never blocks call start.
verifyDenoiseAssets(denoiseModel).catch(() => undefined);
}
if (CallEmbed.startingCall(intent)) {
+63
View File
@@ -0,0 +1,63 @@
import { trimTrailingSlash } from '../../utils/common';
// Denoise assets copied into public/element-call/denoise/ by vite.config.js's
// lotusDenoise() plugin. The filenames here MUST match what that plugin writes
// (and what the fork's TrackProcessor fetches at runtime). Grouped per model so
// the smoke-check only probes what the active call will actually load.
const DENOISE_ASSETS: Record<string, readonly string[]> = {
rnnoise: ['rnnoiseWorklet.js', 'rnnoise.wasm', 'rnnoise_simd.wasm'],
speex: ['speexWorklet.js', 'speex.wasm'],
dtln: ['workadventure/audio-worklet.js'],
deepfilternet: [
'deepfilternet/index.esm.js',
'deepfilternet/v2/pkg/df_bg.wasm',
'deepfilternet/v2/models/DeepFilterNet3_onnx.tar.gz',
],
};
// The noise-gate worklet is a shared asset the build ships for every model
// (loaded when the gate is enabled), so probe it regardless of the model.
const SHARED_ASSETS: readonly string[] = ['noiseGateWorklet.js'];
/**
* Fire-and-forget smoke-check for the ML-denoise asset contract.
*
* The fork's in-source denoiser (lotusDenoiseSource) loads its worklet/wasm/ESM
* from `public/element-call/denoise/` at runtime; if the build's asset copy
* step regressed, those fetches 404 and denoise silently degrades to a raw mic.
* This HEAD-fetches the critical assets for the selected model and emits a
* single console.warn listing any that are missing. No UI, no throw purely a
* developer/operator breadcrumb.
*
* @param model the selected denoise model (defaults to rnnoise)
* @returns true if every probed asset responded OK, false otherwise
*/
export async function verifyDenoiseAssets(model = 'rnnoise'): Promise<boolean> {
const base = new URL(
`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/denoise/`,
window.location.origin,
);
const names = [...(DENOISE_ASSETS[model] ?? DENOISE_ASSETS.rnnoise), ...SHARED_ASSETS];
const results = await Promise.all(
names.map(async (name): Promise<string | null> => {
try {
const res = await fetch(new URL(name, base).href, { method: 'HEAD' });
return res.ok ? null : name;
} catch {
return name;
}
}),
);
const missing = results.filter((n): n is string => n !== null);
if (missing.length > 0) {
console.warn(
`[lotus-denoise] ML denoise assets missing under ${base.href} (model="${model}"): ${missing.join(
', ',
)} the in-source denoiser will fall back to a raw mic. Check vite.config.js lotusDenoise().`,
);
return false;
}
return true;
}
+110 -63
View File
@@ -1,7 +1,4 @@
import { CompactEmoji, fromUnicodeToHexcode } from 'emojibase';
import emojisData from 'emojibase-data/en/compact.json';
import joypixels from 'emojibase-data/en/shortcodes/joypixels.json';
import emojibase from 'emojibase-data/en/shortcodes/emojibase.json';
import type { CompactEmoji } from 'emojibase';
export type IEmoji = CompactEmoji & {
shortcode: string;
@@ -24,57 +21,76 @@ export type IEmojiGroup = {
emojis: IEmoji[];
};
export const getShortcodesFor = (hexcode: string): string[] | string | undefined =>
joypixels[hexcode] || emojibase[hexcode];
export type EmojiData = {
emojis: IEmoji[];
emojiGroups: IEmojiGroup[];
};
type ShortcodeMap = Record<string, string | string[]>;
/**
* PERF (lazy emojibase split): the heavy `emojibase-data` JSON (compact emoji
* data + the joypixels/emojibase shortcode maps, ~965 KB combined) used to be
* imported statically at module top-level. Because reaction/message rendering
* (`Reaction`, `scaleSystemEmoji`) import this module eagerly, that dragged the
* whole `emojibase` chunk into the initial (eager) bundle graph.
*
* It is now loaded on demand via `loadEmojiData()` (a memoized dynamic import).
* Only lazy emoji surfaces (EmojiBoard, EmoticonAutocomplete, recent-emoji)
* trigger the load. Anything that renders eagerly (reaction/emoji tooltips and
* aria-labels via `getShortcodeFor`) gracefully degrades to `undefined` until
* the data has been loaded the visible emoji glyph itself never depended on
* this data, so on-screen UX is unchanged; the shortcode label simply resolves
* once emoji data is loaded. `getHexcodeForEmoji` is inlined below so it stays
* synchronous WITHOUT pulling the `emojibase` runtime into the eager graph.
*/
// Inlined from emojibase's `fromUnicodeToHexcode` so this synchronous helper
// does not import the `emojibase` package (and thus the emojibase chunk) into
// the eager graph. Kept byte-for-byte behaviourally identical.
const SEQUENCE_REMOVAL_PATTERN = /200D|FE0E|FE0F/g;
export const getHexcodeForEmoji = (unicode: string, strip = true): string => {
const hexcode: string[] = [];
[...unicode].forEach((codepoint) => {
let hex = codepoint.codePointAt(0)?.toString(16).toUpperCase() ?? '';
while (hex.length < 4) {
hex = `0${hex}`;
}
if (!strip || !hex.match(SEQUENCE_REMOVAL_PATTERN)) {
hexcode.push(hex);
}
});
return hexcode.join('-');
};
// Populated by loadEmojiData(); `undefined` until the data has been loaded.
let joypixelsShortcodes: ShortcodeMap | undefined;
let emojibaseShortcodes: ShortcodeMap | undefined;
export const getShortcodesFor = (hexcode: string): string[] | string | undefined => {
if (!joypixelsShortcodes || !emojibaseShortcodes) return undefined;
return joypixelsShortcodes[hexcode] || emojibaseShortcodes[hexcode];
};
export const getShortcodeFor = (hexcode: string): string | undefined => {
const shortcode = joypixels[hexcode] || emojibase[hexcode];
const shortcode = getShortcodesFor(hexcode);
return Array.isArray(shortcode) ? shortcode[0] : shortcode;
};
export const getHexcodeForEmoji = fromUnicodeToHexcode;
// Shared, stable array references. They start empty and are populated in place
// the first time loadEmojiData() resolves (mirroring the previous eager module
// side-effect). React consumers await loadEmojiData() and re-render to observe
// the populated data; non-React consumers (recent-emoji) read them after load.
export const emojiGroups: IEmojiGroup[] = [
{
id: EmojiGroupId.People,
order: 0,
emojis: [],
},
{
id: EmojiGroupId.Nature,
order: 1,
emojis: [],
},
{
id: EmojiGroupId.Food,
order: 2,
emojis: [],
},
{
id: EmojiGroupId.Activity,
order: 3,
emojis: [],
},
{
id: EmojiGroupId.Travel,
order: 4,
emojis: [],
},
{
id: EmojiGroupId.Object,
order: 5,
emojis: [],
},
{
id: EmojiGroupId.Symbol,
order: 6,
emojis: [],
},
{
id: EmojiGroupId.Flag,
order: 7,
emojis: [],
},
{ id: EmojiGroupId.People, order: 0, emojis: [] },
{ id: EmojiGroupId.Nature, order: 1, emojis: [] },
{ id: EmojiGroupId.Food, order: 2, emojis: [] },
{ id: EmojiGroupId.Activity, order: 3, emojis: [] },
{ id: EmojiGroupId.Travel, order: 4, emojis: [] },
{ id: EmojiGroupId.Object, order: 5, emojis: [] },
{ id: EmojiGroupId.Symbol, order: 6, emojis: [] },
{ id: EmojiGroupId.Flag, order: 7, emojis: [] },
];
export const emojis: IEmoji[] = [];
@@ -95,20 +111,51 @@ function getGroupIndex(emoji: IEmoji): number | undefined {
return undefined;
}
emojisData.forEach((emoji) => {
const myShortCodes = getShortcodesFor(emoji.hexcode);
if (!myShortCodes) return;
if (Array.isArray(myShortCodes) && myShortCodes.length === 0) return;
let emojiDataPromise: Promise<EmojiData> | undefined;
const em: IEmoji = {
...emoji,
shortcode: Array.isArray(myShortCodes) ? myShortCodes[0] : myShortCodes,
shortcodes: Array.isArray(myShortCodes) ? myShortCodes : emoji.shortcodes,
};
/**
* Lazily load emojibase data (dynamic import the `emojibase` chunk). Memoized:
* the JSON is fetched/parsed and `emojis`/`emojiGroups` are built exactly once.
*/
export const loadEmojiData = (): Promise<EmojiData> => {
if (!emojiDataPromise) {
emojiDataPromise = (async (): Promise<EmojiData> => {
const [emojisModule, joypixelsModule, emojibaseModule] = await Promise.all([
import('emojibase-data/en/compact.json'),
import('emojibase-data/en/shortcodes/joypixels.json'),
import('emojibase-data/en/shortcodes/emojibase.json'),
]);
const groupIndex = getGroupIndex(em);
if (groupIndex !== undefined) {
addEmojiToGroup(groupIndex, em);
emojis.push(em);
joypixelsShortcodes = joypixelsModule.default as ShortcodeMap;
emojibaseShortcodes = emojibaseModule.default as ShortcodeMap;
const emojisData = emojisModule.default as unknown as CompactEmoji[];
emojisData.forEach((emoji) => {
const myShortCodes = getShortcodesFor(emoji.hexcode);
if (!myShortCodes) return;
if (Array.isArray(myShortCodes) && myShortCodes.length === 0) return;
const em: IEmoji = {
...emoji,
shortcode: Array.isArray(myShortCodes) ? myShortCodes[0] : myShortCodes,
shortcodes: Array.isArray(myShortCodes) ? myShortCodes : emoji.shortcodes,
};
const groupIndex = getGroupIndex(em);
if (groupIndex !== undefined) {
addEmojiToGroup(groupIndex, em);
emojis.push(em);
}
});
return { emojis, emojiGroups };
})();
// Don't cache a rejection: a transient chunk-load failure (e.g. mid-deploy
// 404) would otherwise permanently disable emoji data until a full reload.
emojiDataPromise = emojiDataPromise.catch((err) => {
emojiDataPromise = undefined;
throw err;
});
}
});
return emojiDataPromise;
};
+83 -12
View File
@@ -43,9 +43,14 @@ import { onEnterOrSpace } from '../utils/keyboard';
import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom';
import { useTimeoutToggle } from '../hooks/useTimeoutToggle';
import { tokenize, tokenStyle } from '../utils/syntaxHighlight';
import { splitMathSegments } from '../utils/mathParse';
const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
// KaTeX (and its CSS) is heavy, so it is code-split behind this dynamic import
// and is NOT part of the eager import graph — see src/app/components/math/KaTeX.tsx.
const KaTeXMath = lazy(() => import('../components/math/KaTeX'));
/** Languages handled by the custom TDS tokenizer. */
const TDS_TOKENIZER_LANGS = new Set([
'js',
@@ -78,6 +83,27 @@ function renderTokenizedCode(code: string, lang: string): React.ReactNode {
));
}
/**
* Renders LaTeX via the lazily-loaded KaTeX component.
*
* `suspenseFallback` is shown while the KaTeX chunk loads (the raw LaTeX text).
* `errorFallback` is shown if rendering fails outright for the spec
* `data-mx-maths` path this is the element's original children (the spec
* fallback content); for the plain-text `$$` path it is the raw source.
*/
const renderMath = (
latex: string,
displayMode: boolean,
suspenseFallback: React.ReactNode,
errorFallback: React.ReactNode,
): JSX.Element => (
<ErrorBoundary fallback={<>{errorFallback}</>}>
<Suspense fallback={<>{suspenseFallback}</>}>
<KaTeXMath latex={latex} displayMode={displayMode} />
</Suspense>
</ErrorBoundary>
);
const EMOJI_REG_G = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`, 'g');
export const LINKIFY_OPTS: LinkifyOpts = {
@@ -503,6 +529,21 @@ export const getReactCustomHtmlParser = (
if (mention) return mention;
}
if ((name === 'span' || name === 'div') && 'data-mx-maths' in props) {
// Spec (CS-API §11.5): render the `data-mx-maths` LaTeX with KaTeX
// (block for <div>, inline for <span>). On failure fall back to the
// element's existing children, which the spec defines as the fallback
// representation.
const latex = String(props['data-mx-maths']);
const displayMode = name === 'div';
const fallback = displayMode ? (
<div {...props}>{domToReact(children as unknown as DOMNode[], opts)}</div>
) : (
<span {...props}>{domToReact(children as unknown as DOMNode[], opts)}</span>
);
return renderMath(latex, displayMode, latex, fallback);
}
if (name === 'span' && 'data-mx-spoiler' in props) {
return (
<span
@@ -536,30 +577,60 @@ export const getReactCustomHtmlParser = (
return (
<span className={css.EmoticonBase}>
<span className={css.Emoticon()}>
<img {...props} className={css.EmoticonImg} src={htmlSrc} />
<img {...props} className={css.EmoticonImg} src={htmlSrc} loading="lazy" />
</span>
</span>
);
}
if (htmlSrc) return <img {...props} className={css.Img} src={htmlSrc} />;
if (htmlSrc) return <img {...props} className={css.Img} src={htmlSrc} loading="lazy" />;
}
}
if (domNode instanceof DOMText) {
const linkify =
!(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'code') &&
!(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'a');
const parentName =
domNode.parent && 'name' in domNode.parent ? domNode.parent.name : undefined;
const linkify = parentName !== 'code' && parentName !== 'a';
// Never parse `$…$`/`$$…$$` math inside <pre>/<code> (verbatim regions).
const mathAllowed = parentName !== 'code' && parentName !== 'pre';
let jsx = scaleSystemEmoji(domNode.data);
const renderTextChunk = (text: string): (string | JSX.Element)[] | JSX.Element => {
let jsx = scaleSystemEmoji(text);
if (params.highlightRegex) {
jsx = highlightText(params.highlightRegex, jsx);
}
if (linkify) {
return <Linkify options={params.linkifyOpts}>{jsx}</Linkify>;
}
return jsx;
};
if (params.highlightRegex) {
jsx = highlightText(params.highlightRegex, jsx);
if (mathAllowed) {
const segments = splitMathSegments(domNode.data);
if (segments.some((segment) => segment.type !== 'text')) {
return (
<>
{segments.map((segment, index) => {
if (segment.type === 'text') {
// eslint-disable-next-line react/no-array-index-key
return (
<React.Fragment key={index}>{renderTextChunk(segment.value)}</React.Fragment>
);
}
const raw =
segment.type === 'block' ? `$$${segment.value}$$` : `$${segment.value}$`;
return (
// eslint-disable-next-line react/no-array-index-key
<React.Fragment key={index}>
{renderMath(segment.value, segment.type === 'block', raw, raw)}
</React.Fragment>
);
})}
</>
);
}
}
if (linkify) {
return <Linkify options={params.linkifyOpts}>{jsx}</Linkify>;
}
return jsx;
return renderTextChunk(domNode.data);
}
return undefined;
},
+22 -296
View File
@@ -2,307 +2,33 @@ import React, { MutableRefObject, ReactNode, useEffect, useRef } from 'react';
import Prism from 'prismjs';
import 'prismjs/components/prism-abap.js';
import 'prismjs/components/prism-abnf.js';
import 'prismjs/components/prism-actionscript.js';
import 'prismjs/components/prism-ada.js';
import 'prismjs/components/prism-agda.js';
import 'prismjs/components/prism-al.js';
import 'prismjs/components/prism-antlr4.js';
import 'prismjs/components/prism-apacheconf.js';
import 'prismjs/components/prism-apex.js';
import 'prismjs/components/prism-apl.js';
import 'prismjs/components/prism-applescript.js';
import 'prismjs/components/prism-aql.js';
import 'prismjs/components/prism-arff.js';
import 'prismjs/components/prism-armasm.js';
import 'prismjs/components/prism-arturo.js';
import 'prismjs/components/prism-asciidoc.js';
import 'prismjs/components/prism-asm6502.js';
import 'prismjs/components/prism-asmatmel.js';
import 'prismjs/components/prism-aspnet.js';
import 'prismjs/components/prism-autohotkey.js';
import 'prismjs/components/prism-autoit.js';
import 'prismjs/components/prism-avisynth.js';
import 'prismjs/components/prism-avro-idl.js';
import 'prismjs/components/prism-awk.js';
import 'prismjs/components/prism-bash.js';
import 'prismjs/components/prism-basic.js';
import 'prismjs/components/prism-batch.js';
import 'prismjs/components/prism-bbcode.js';
import 'prismjs/components/prism-bbj.js';
import 'prismjs/components/prism-bicep.js';
import 'prismjs/components/prism-birb.js';
import 'prismjs/components/prism-bnf.js';
import 'prismjs/components/prism-bqn.js';
import 'prismjs/components/prism-brainfuck.js';
import 'prismjs/components/prism-brightscript.js';
import 'prismjs/components/prism-bro.js';
import 'prismjs/components/prism-bsl.js';
import 'prismjs/components/prism-c.js';
import 'prismjs/components/prism-cfscript.js';
import 'prismjs/components/prism-cil.js';
import 'prismjs/components/prism-cilkc.js';
import 'prismjs/components/prism-cilkcpp.js';
// PERF: Prism used to import every bundled language (~574 KB lazy chunk). We now
// ship a curated subset covering the languages actually seen in chat. Imports
// MUST stay in dependency order (Prism component files assume their base grammar
// is already registered): base grammars (markup/css/clike/javascript) first,
// then languages that extend them (e.g. c→cpp, javascript→typescript,
// markup+javascript→jsx, jsx+typescript→tsx, markup→markdown).
import 'prismjs/components/prism-markup.js'; // markup / html / xml / svg
import 'prismjs/components/prism-css.js';
import 'prismjs/components/prism-clike.js';
import 'prismjs/components/prism-clojure.js';
import 'prismjs/components/prism-cmake.js';
import 'prismjs/components/prism-cobol.js';
import 'prismjs/components/prism-coffeescript.js';
import 'prismjs/components/prism-concurnas.js';
import 'prismjs/components/prism-cooklang.js';
import 'prismjs/components/prism-coq.js';
import 'prismjs/components/prism-javascript.js'; // js
import 'prismjs/components/prism-json.js';
import 'prismjs/components/prism-yaml.js';
import 'prismjs/components/prism-bash.js'; // bash / shell / sh
import 'prismjs/components/prism-python.js';
import 'prismjs/components/prism-rust.js';
import 'prismjs/components/prism-go.js';
import 'prismjs/components/prism-java.js';
import 'prismjs/components/prism-c.js';
import 'prismjs/components/prism-cpp.js';
import 'prismjs/components/prism-csharp.js';
import 'prismjs/components/prism-cshtml.js';
import 'prismjs/components/prism-csp.js';
import 'prismjs/components/prism-css-extras.js';
import 'prismjs/components/prism-css.js';
import 'prismjs/components/prism-csv.js';
import 'prismjs/components/prism-cue.js';
import 'prismjs/components/prism-cypher.js';
import 'prismjs/components/prism-d.js';
import 'prismjs/components/prism-dart.js';
import 'prismjs/components/prism-dataweave.js';
import 'prismjs/components/prism-dax.js';
import 'prismjs/components/prism-dhall.js';
import 'prismjs/components/prism-diff.js';
import 'prismjs/components/prism-dns-zone-file.js';
import 'prismjs/components/prism-docker.js';
import 'prismjs/components/prism-dot.js';
import 'prismjs/components/prism-ebnf.js';
import 'prismjs/components/prism-editorconfig.js';
import 'prismjs/components/prism-eiffel.js';
import 'prismjs/components/prism-ejs.js';
import 'prismjs/components/prism-elixir.js';
import 'prismjs/components/prism-elm.js';
import 'prismjs/components/prism-erb.js';
import 'prismjs/components/prism-erlang.js';
import 'prismjs/components/prism-etlua.js';
import 'prismjs/components/prism-excel-formula.js';
import 'prismjs/components/prism-factor.js';
import 'prismjs/components/prism-false.js';
import 'prismjs/components/prism-firestore-security-rules.js';
import 'prismjs/components/prism-flow.js';
import 'prismjs/components/prism-fortran.js';
import 'prismjs/components/prism-fsharp.js';
import 'prismjs/components/prism-ftl.js';
import 'prismjs/components/prism-gap.js';
import 'prismjs/components/prism-gcode.js';
import 'prismjs/components/prism-gdscript.js';
import 'prismjs/components/prism-gedcom.js';
import 'prismjs/components/prism-gettext.js';
import 'prismjs/components/prism-gherkin.js';
import 'prismjs/components/prism-git.js';
import 'prismjs/components/prism-glsl.js';
import 'prismjs/components/prism-gml.js';
import 'prismjs/components/prism-gn.js';
import 'prismjs/components/prism-go-module.js';
import 'prismjs/components/prism-go.js';
import 'prismjs/components/prism-gradle.js';
import 'prismjs/components/prism-graphql.js';
import 'prismjs/components/prism-groovy.js';
import 'prismjs/components/prism-haml.js';
import 'prismjs/components/prism-handlebars.js';
import 'prismjs/components/prism-haskell.js';
import 'prismjs/components/prism-haxe.js';
import 'prismjs/components/prism-hcl.js';
import 'prismjs/components/prism-hlsl.js';
import 'prismjs/components/prism-hoon.js';
import 'prismjs/components/prism-hpkp.js';
import 'prismjs/components/prism-hsts.js';
import 'prismjs/components/prism-http.js';
import 'prismjs/components/prism-ichigojam.js';
import 'prismjs/components/prism-icon.js';
import 'prismjs/components/prism-icu-message-format.js';
import 'prismjs/components/prism-idris.js';
import 'prismjs/components/prism-iecst.js';
import 'prismjs/components/prism-ignore.js';
import 'prismjs/components/prism-inform7.js';
import 'prismjs/components/prism-ini.js';
import 'prismjs/components/prism-io.js';
import 'prismjs/components/prism-j.js';
import 'prismjs/components/prism-java.js';
import 'prismjs/components/prism-javadoclike.js';
import 'prismjs/components/prism-javascript.js';
import 'prismjs/components/prism-javastacktrace.js';
import 'prismjs/components/prism-jexl.js';
import 'prismjs/components/prism-jolie.js';
import 'prismjs/components/prism-jq.js';
import 'prismjs/components/prism-js-extras.js';
import 'prismjs/components/prism-js-templates.js';
import 'prismjs/components/prism-json.js';
import 'prismjs/components/prism-json5.js';
import 'prismjs/components/prism-jsonp.js';
import 'prismjs/components/prism-jsstacktrace.js';
import 'prismjs/components/prism-jsx.js';
import 'prismjs/components/prism-julia.js';
import 'prismjs/components/prism-keepalived.js';
import 'prismjs/components/prism-keyman.js';
import 'prismjs/components/prism-kotlin.js';
import 'prismjs/components/prism-kumir.js';
import 'prismjs/components/prism-kusto.js';
import 'prismjs/components/prism-latex.js';
import 'prismjs/components/prism-latte.js';
import 'prismjs/components/prism-less.js';
import 'prismjs/components/prism-lilypond.js';
import 'prismjs/components/prism-linker-script.js';
import 'prismjs/components/prism-liquid.js';
import 'prismjs/components/prism-lisp.js';
import 'prismjs/components/prism-livescript.js';
import 'prismjs/components/prism-llvm.js';
import 'prismjs/components/prism-log.js';
import 'prismjs/components/prism-lolcode.js';
import 'prismjs/components/prism-lua.js';
import 'prismjs/components/prism-magma.js';
import 'prismjs/components/prism-makefile.js';
import 'prismjs/components/prism-markdown.js';
import 'prismjs/components/prism-markup-templating.js';
import 'prismjs/components/prism-markup.js';
import 'prismjs/components/prism-mata.js';
import 'prismjs/components/prism-matlab.js';
import 'prismjs/components/prism-maxscript.js';
import 'prismjs/components/prism-mel.js';
import 'prismjs/components/prism-mermaid.js';
import 'prismjs/components/prism-metafont.js';
import 'prismjs/components/prism-mizar.js';
import 'prismjs/components/prism-mongodb.js';
import 'prismjs/components/prism-monkey.js';
import 'prismjs/components/prism-moonscript.js';
import 'prismjs/components/prism-n1ql.js';
import 'prismjs/components/prism-n4js.js';
import 'prismjs/components/prism-nand2tetris-hdl.js';
import 'prismjs/components/prism-naniscript.js';
import 'prismjs/components/prism-nasm.js';
import 'prismjs/components/prism-neon.js';
import 'prismjs/components/prism-nevod.js';
import 'prismjs/components/prism-nginx.js';
import 'prismjs/components/prism-nim.js';
import 'prismjs/components/prism-nix.js';
import 'prismjs/components/prism-nsis.js';
import 'prismjs/components/prism-objectivec.js';
import 'prismjs/components/prism-ocaml.js';
import 'prismjs/components/prism-odin.js';
import 'prismjs/components/prism-opencl.js';
import 'prismjs/components/prism-openqasm.js';
import 'prismjs/components/prism-oz.js';
import 'prismjs/components/prism-parigp.js';
import 'prismjs/components/prism-parser.js';
import 'prismjs/components/prism-pascal.js';
import 'prismjs/components/prism-pascaligo.js';
import 'prismjs/components/prism-pcaxis.js';
import 'prismjs/components/prism-peoplecode.js';
import 'prismjs/components/prism-perl.js';
import 'prismjs/components/prism-php-extras.js';
import 'prismjs/components/prism-php.js';
import 'prismjs/components/prism-phpdoc.js';
import 'prismjs/components/prism-plant-uml.js';
import 'prismjs/components/prism-powerquery.js';
import 'prismjs/components/prism-powershell.js';
import 'prismjs/components/prism-processing.js';
import 'prismjs/components/prism-prolog.js';
import 'prismjs/components/prism-promql.js';
import 'prismjs/components/prism-properties.js';
import 'prismjs/components/prism-protobuf.js';
import 'prismjs/components/prism-psl.js';
import 'prismjs/components/prism-pug.js';
import 'prismjs/components/prism-puppet.js';
import 'prismjs/components/prism-pure.js';
import 'prismjs/components/prism-purebasic.js';
import 'prismjs/components/prism-purescript.js';
import 'prismjs/components/prism-python.js';
import 'prismjs/components/prism-q.js';
import 'prismjs/components/prism-qml.js';
import 'prismjs/components/prism-qore.js';
import 'prismjs/components/prism-qsharp.js';
import 'prismjs/components/prism-r.js';
import 'prismjs/components/prism-reason.js';
import 'prismjs/components/prism-regex.js';
import 'prismjs/components/prism-rego.js';
import 'prismjs/components/prism-renpy.js';
import 'prismjs/components/prism-rescript.js';
import 'prismjs/components/prism-rest.js';
import 'prismjs/components/prism-rip.js';
import 'prismjs/components/prism-roboconf.js';
import 'prismjs/components/prism-robotframework.js';
import 'prismjs/components/prism-ruby.js';
import 'prismjs/components/prism-rust.js';
import 'prismjs/components/prism-sas.js';
import 'prismjs/components/prism-sass.js';
import 'prismjs/components/prism-scala.js';
import 'prismjs/components/prism-scheme.js';
import 'prismjs/components/prism-scss.js';
import 'prismjs/components/prism-shell-session.js';
import 'prismjs/components/prism-smali.js';
import 'prismjs/components/prism-smalltalk.js';
import 'prismjs/components/prism-smarty.js';
import 'prismjs/components/prism-sml.js';
import 'prismjs/components/prism-solidity.js';
import 'prismjs/components/prism-solution-file.js';
import 'prismjs/components/prism-soy.js';
import 'prismjs/components/prism-splunk-spl.js';
import 'prismjs/components/prism-sqf.js';
import 'prismjs/components/prism-sql.js';
import 'prismjs/components/prism-squirrel.js';
import 'prismjs/components/prism-stan.js';
import 'prismjs/components/prism-stata.js';
import 'prismjs/components/prism-stylus.js';
import 'prismjs/components/prism-supercollider.js';
import 'prismjs/components/prism-swift.js';
import 'prismjs/components/prism-systemd.js';
import 'prismjs/components/prism-t4-templating.js';
import 'prismjs/components/prism-t4-vb.js';
import 'prismjs/components/prism-tap.js';
import 'prismjs/components/prism-tcl.js';
import 'prismjs/components/prism-textile.js';
import 'prismjs/components/prism-toml.js';
import 'prismjs/components/prism-tremor.js';
import 'prismjs/components/prism-diff.js';
import 'prismjs/components/prism-docker.js';
import 'prismjs/components/prism-markdown.js';
import 'prismjs/components/prism-typescript.js'; // ts
import 'prismjs/components/prism-jsx.js';
import 'prismjs/components/prism-tsx.js';
import 'prismjs/components/prism-tt2.js';
import 'prismjs/components/prism-turtle.js';
import 'prismjs/components/prism-twig.js';
import 'prismjs/components/prism-typescript.js';
import 'prismjs/components/prism-typoscript.js';
import 'prismjs/components/prism-unrealscript.js';
import 'prismjs/components/prism-uorazor.js';
import 'prismjs/components/prism-uri.js';
import 'prismjs/components/prism-v.js';
import 'prismjs/components/prism-vala.js';
import 'prismjs/components/prism-vbnet.js';
import 'prismjs/components/prism-velocity.js';
import 'prismjs/components/prism-verilog.js';
import 'prismjs/components/prism-vhdl.js';
import 'prismjs/components/prism-vim.js';
import 'prismjs/components/prism-visual-basic.js';
import 'prismjs/components/prism-warpscript.js';
import 'prismjs/components/prism-wasm.js';
import 'prismjs/components/prism-web-idl.js';
import 'prismjs/components/prism-wgsl.js';
import 'prismjs/components/prism-wiki.js';
import 'prismjs/components/prism-wolfram.js';
import 'prismjs/components/prism-wren.js';
import 'prismjs/components/prism-xeora.js';
import 'prismjs/components/prism-xml-doc.js';
import 'prismjs/components/prism-xojo.js';
import 'prismjs/components/prism-xquery.js';
import 'prismjs/components/prism-yaml.js';
import 'prismjs/components/prism-yang.js';
import 'prismjs/components/prism-zig.js';
import 'prismjs/components/prism-arduino.js';
// Broken:
//
// import 'prismjs/components/prism-bison.js';
// import 'prismjs/components/prism-chaiscript.js';
// import 'prismjs/components/prism-core.js';
// import 'prismjs/components/prism-crystal.js';
// import 'prismjs/components/prism-django.js';
// import 'prismjs/components/prism-javadoc.js';
// import 'prismjs/components/prism-jsdoc.js';
// import 'prismjs/components/prism-plsql.js';
// import 'prismjs/components/prism-racket.js';
// import 'prismjs/components/prism-sparql.js';
// import 'prismjs/components/prism-t4-cs.js';
import './ReactPrism.css';
// using classNames .prism-dark .prism-light from ReactPrism.css
+4 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { addRecentEmoji, getRecentEmojis, IRecentEmojiContent } from './recent-emoji';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { emojis } from './emoji';
import { emojis, loadEmojiData } from './emoji';
// A Map-backed MatrixClient stub supporting get/setAccountData.
const createMx = () => {
@@ -25,6 +25,9 @@ const createMx = () => {
const getStored = (store: Map<string, unknown>): IRecentEmojiContent['recent_emoji'] =>
(store.get(AccountDataEvent.ElementRecentEmoji) as IRecentEmojiContent | undefined)?.recent_emoji;
// Emoji data is now loaded lazily; populate `emojis` before the round trips.
await loadEmojiData();
// Pick two real unicode emojis to drive add->get round trips.
const u1 = emojis[0].unicode;
const u2 = emojis[1].unicode;
@@ -0,0 +1,58 @@
import { SoundboardClip, SoundboardClipInfo } from './types';
/** Parallels custom-emoji/PackImageReader, for a soundboard clip. */
export class SoundboardClipReader {
public readonly shortcode: string;
public readonly url: string;
private readonly clip: Omit<SoundboardClip, 'url'>;
constructor(shortcode: string, url: string, clip: Omit<SoundboardClip, 'url'>) {
this.shortcode = shortcode;
this.url = url;
this.clip = clip;
}
static fromClip(shortcode: string, clip: SoundboardClip): SoundboardClipReader | undefined {
const { url } = clip;
if (typeof url !== 'string' || !url.startsWith('mxc://')) return undefined;
return new SoundboardClipReader(shortcode, url, clip);
}
get body(): string | undefined {
const { body } = this.clip;
return typeof body === 'string' ? body : undefined;
}
/** Display name — the clip body, falling back to the shortcode. */
get name(): string {
return this.body ?? this.shortcode;
}
get emoji(): string | undefined {
const { emoji } = this.clip;
return typeof emoji === 'string' && emoji.length > 0 ? emoji : undefined;
}
/** Per-clip volume 0100; defaults to 100 when unset/invalid. */
get volume(): number {
const v = this.clip.volume;
if (typeof v !== 'number' || Number.isNaN(v)) return 100;
return Math.min(100, Math.max(0, v));
}
get info(): SoundboardClipInfo | undefined {
return this.clip.info;
}
get content(): SoundboardClip {
return {
url: this.url,
body: this.clip.body,
emoji: this.clip.emoji,
volume: this.clip.volume,
info: this.clip.info,
};
}
}
@@ -0,0 +1,26 @@
import { SoundboardClipReader } from './SoundboardClipReader';
import { SoundboardClips } from './types';
/** Parallels custom-emoji/PackImagesReader. */
export class SoundboardClipsReader {
private readonly rawClips: SoundboardClips;
private shortcodeToClips: Map<string, SoundboardClipReader> | undefined;
constructor(clips: SoundboardClips) {
this.rawClips = clips;
}
get collection(): Map<string, SoundboardClipReader> {
if (this.shortcodeToClips) return this.shortcodeToClips;
const shortcodeToClips: Map<string, SoundboardClipReader> = new Map();
Object.entries(this.rawClips).forEach(([shortcode, clip]) => {
const reader = SoundboardClipReader.fromClip(shortcode, clip);
if (reader) shortcodeToClips.set(shortcode, reader);
});
this.shortcodeToClips = shortcodeToClips;
return this.shortcodeToClips;
}
}
@@ -0,0 +1,29 @@
import { SoundboardMeta } from './types';
/** Parallels custom-emoji/PackMetaReader (no usage tiers for soundboard). */
export class SoundboardMetaReader {
private readonly meta: SoundboardMeta;
constructor(meta: SoundboardMeta) {
this.meta = meta;
}
get name(): string | undefined {
const displayName = this.meta.display_name;
return typeof displayName === 'string' ? displayName : undefined;
}
get avatar(): string | undefined {
const avatarURL = this.meta.avatar_url;
return typeof avatarURL === 'string' ? avatarURL : undefined;
}
get attribution(): string | undefined {
const { attribution } = this.meta;
return typeof attribution === 'string' ? attribution : undefined;
}
get content(): SoundboardMeta {
return this.meta;
}
}
@@ -0,0 +1,48 @@
import { MatrixEvent } from 'matrix-js-sdk';
import { PackAddress } from '../custom-emoji/PackAddress';
import { SoundboardClipReader } from './SoundboardClipReader';
import { SoundboardClipsReader } from './SoundboardClipsReader';
import { SoundboardMetaReader } from './SoundboardMetaReader';
import { SoundboardContent } from './types';
/** Parallels custom-emoji/ImagePack. Holds a soundboard pack's meta + clips. */
export class SoundboardPack {
public readonly id: string;
public readonly deleted: boolean;
public readonly address: PackAddress | undefined;
public readonly meta: SoundboardMetaReader;
public readonly clips: SoundboardClipsReader;
private clipsMemo: SoundboardClipReader[] | undefined;
constructor(id: string, content: SoundboardContent, address: PackAddress | undefined) {
this.id = id;
this.address = address;
this.deleted = content.pack === undefined && content.clips === undefined;
this.meta = new SoundboardMetaReader(content.pack ?? {});
this.clips = new SoundboardClipsReader(content.clips ?? {});
}
static fromMatrixEvent(id: string, matrixEvent: MatrixEvent): SoundboardPack {
const roomId = matrixEvent.getRoomId();
const stateKey = matrixEvent.getStateKey();
const address =
roomId && typeof stateKey === 'string' ? new PackAddress(roomId, stateKey) : undefined;
return new SoundboardPack(id, matrixEvent.getContent<SoundboardContent>(), address);
}
getClips(): SoundboardClipReader[] {
if (this.clipsMemo) return this.clipsMemo;
this.clipsMemo = Array.from(this.clips.collection.values());
return this.clipsMemo;
}
getAvatarUrl(): string | undefined {
if (this.meta.avatar) return this.meta.avatar;
return undefined;
}
}
+6
View File
@@ -0,0 +1,6 @@
export * from './types';
export * from './SoundboardClipReader';
export * from './SoundboardClipsReader';
export * from './SoundboardMetaReader';
export * from './SoundboardPack';
export * from './utils';
+52
View File
@@ -0,0 +1,52 @@
// [P5-15 v2] Soundboard packs — a near-parallel of the MSC2545 custom-emoji
// image packs (see ../custom-emoji/types.ts), for shareable in-call audio clips.
/** io.lotus.soundboard_rooms content (global refs) — mirrors EmoteRoomsContent. */
export type SoundboardPackStateKeyToObject = Record<string, object>;
export type SoundboardRoomIdToStateKey = Record<string, SoundboardPackStateKeyToObject>;
export type SoundboardRoomsContent = {
rooms?: SoundboardRoomIdToStateKey;
};
/** Per-clip media info (audio, so no width/height — unlike IImageInfo). */
export type SoundboardClipInfo = {
mimetype?: string;
size?: number;
/** Clip duration in milliseconds, if known. */
duration?: number;
};
/** A single soundboard clip (parallels PackImage). Keyed by shortcode in the pack. */
export type SoundboardClip = {
url: string; // mxc://
body?: string; // display name
emoji?: string; // emoji tag (like a Discord soundboard emoji)
volume?: number; // 0100, per-clip gain
info?: SoundboardClipInfo;
};
export type SoundboardClips = Record<string, SoundboardClip>;
export type SoundboardMeta = {
display_name?: string;
avatar_url?: string;
attribution?: string;
};
/** io.lotus.soundboard (user account data) + io.lotus.soundboard (room state). */
export type SoundboardContent = {
pack?: SoundboardMeta;
clips?: SoundboardClips;
};
/** Legacy v1 personal soundboard shape (flat list), migrated to a pack on read. */
export type LegacySoundboardClip = {
id: string;
name: string;
url: string;
mimetype?: string;
size?: number;
};
export type LegacySoundboardContent = {
clips?: LegacySoundboardClip[];
};
+68
View File
@@ -0,0 +1,68 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { migrateUserSoundboardContent, slugifyClipName, uniqueShortcode } from './utils';
describe('slugifyClipName', () => {
test('lowercases, replaces spaces, strips punctuation', () => {
assert.equal(slugifyClipName(' Air Horn!! '), 'air_horn');
assert.equal(slugifyClipName('Ba-Dum Tss'), 'ba-dum_tss');
});
test('falls back to "clip" when empty', () => {
assert.equal(slugifyClipName(' '), 'clip');
assert.equal(slugifyClipName('!!!'), 'clip');
});
});
describe('uniqueShortcode', () => {
test('returns the slug when free', () => {
assert.equal(uniqueShortcode('Airhorn', new Set()), 'airhorn');
});
test('suffixes on collision', () => {
assert.equal(uniqueShortcode('Airhorn', new Set(['airhorn'])), 'airhorn-2');
assert.equal(uniqueShortcode('Airhorn', new Set(['airhorn', 'airhorn-2'])), 'airhorn-3');
});
});
describe('migrateUserSoundboardContent', () => {
test('migrates the v1 flat list into a v2 pack keyed by slug', () => {
const v1 = {
clips: [
{ id: 'a', name: 'Air Horn', url: 'mxc://x/1', mimetype: 'audio/mpeg', size: 100 },
{ id: 'b', name: 'Applause', url: 'mxc://x/2' },
],
};
const out = migrateUserSoundboardContent(v1);
assert.deepEqual(Object.keys(out.clips ?? {}).sort(), ['air_horn', 'applause']);
assert.equal(out.clips?.air_horn.url, 'mxc://x/1');
assert.equal(out.clips?.air_horn.body, 'Air Horn');
assert.equal(out.clips?.air_horn.info?.mimetype, 'audio/mpeg');
assert.ok(out.pack?.display_name);
});
test('dedupes colliding v1 names', () => {
const v1 = {
clips: [
{ id: 'a', name: 'Horn', url: 'mxc://x/1' },
{ id: 'b', name: 'Horn', url: 'mxc://x/2' },
],
};
const out = migrateUserSoundboardContent(v1);
assert.deepEqual(Object.keys(out.clips ?? {}).sort(), ['horn', 'horn-2']);
});
test('skips v1 entries without a url', () => {
const out = migrateUserSoundboardContent({ clips: [{ id: 'a', name: 'Bad' } as never] });
assert.deepEqual(out.clips, {});
});
test('passes a v2 pack through unchanged', () => {
const v2 = { pack: { display_name: 'P' }, clips: { horn: { url: 'mxc://x/1', volume: 50 } } };
assert.deepEqual(migrateUserSoundboardContent(v2), v2);
});
test('handles empty / non-object input', () => {
assert.deepEqual(migrateUserSoundboardContent({}), {});
assert.deepEqual(migrateUserSoundboardContent(null), {});
assert.deepEqual(migrateUserSoundboardContent(undefined), {});
});
});
+103
View File
@@ -0,0 +1,103 @@
import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import { SoundboardPack } from './SoundboardPack';
import {
LegacySoundboardContent,
SoundboardClips,
SoundboardContent,
SoundboardRoomsContent,
} from './types';
import { StateEvent } from '../../../types/matrix/room';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { getAccountData, getStateEvent, getStateEvents } from '../../utils/room';
/** Normalize a display name into a pack shortcode key (parallels emoji shortcodes). */
export function slugifyClipName(name: string): string {
const s = name
.trim()
.toLowerCase()
.replace(/\s+/g, '_')
.replace(/[^a-z0-9_-]/g, '');
return s || 'clip';
}
/** Pick a shortcode not already present in `taken`, suffixing on collision. */
export function uniqueShortcode(base: string, taken: Set<string>): string {
const code = slugifyClipName(base);
if (!taken.has(code)) return code;
let i = 2;
while (taken.has(`${code}-${i}`)) i += 1;
return `${code}-${i}`;
}
export function makeSoundboardPacks(packEvents: MatrixEvent[]): SoundboardPack[] {
return packEvents.reduce<SoundboardPack[]>((packs, packEvent) => {
const packId = packEvent.getId();
if (!packId) return packs;
packs.push(SoundboardPack.fromMatrixEvent(packId, packEvent));
return packs;
}, []);
}
export function getRoomSoundboardPack(room: Room, stateKey: string): SoundboardPack | undefined {
const packEvent = getStateEvent(room, StateEvent.LotusSoundboardRoom, stateKey);
if (!packEvent) return undefined;
const packId = packEvent.getId();
if (!packId) return undefined;
return SoundboardPack.fromMatrixEvent(packId, packEvent);
}
export function getRoomSoundboardPacks(room: Room): SoundboardPack[] {
return makeSoundboardPacks(getStateEvents(room, StateEvent.LotusSoundboardRoom));
}
export function getGlobalSoundboardPacks(mx: MatrixClient): SoundboardPack[] {
const content = getAccountData(mx, AccountDataEvent.LotusSoundboardRooms)?.getContent() as
| SoundboardRoomsContent
| undefined;
const roomIdToPackInfo = content?.rooms;
if (typeof roomIdToPackInfo !== 'object' || !roomIdToPackInfo) return [];
return Object.keys(roomIdToPackInfo).flatMap((roomId) => {
if (typeof roomIdToPackInfo[roomId] !== 'object') return [];
const room = mx.getRoom(roomId);
if (!room) return [];
const stateKeys = roomIdToPackInfo[roomId];
const globalEvents = getStateEvents(room, StateEvent.LotusSoundboardRoom).filter((mE) => {
const stateKey = mE.getStateKey();
return typeof stateKey === 'string' ? !!stateKeys[stateKey] : false;
});
return makeSoundboardPacks(globalEvents);
});
}
/**
* Convert a personal soundboard account-data content to the v2 pack shape,
* migrating the v1 flat-list form (`{clips: [{id,name,url}]}`) on the fly.
*/
export function migrateUserSoundboardContent(raw: unknown): SoundboardContent {
if (typeof raw !== 'object' || raw === null) return {};
const legacy = raw as LegacySoundboardContent;
if (!Array.isArray(legacy.clips)) return raw as SoundboardContent; // already v2 (or empty)
const clips: SoundboardClips = {};
const taken = new Set<string>();
legacy.clips.forEach((c) => {
if (!c || typeof c.url !== 'string') return;
const shortcode = uniqueShortcode(c.name || 'clip', taken);
taken.add(shortcode);
clips[shortcode] = {
url: c.url,
body: c.name,
info: { mimetype: c.mimetype, size: c.size },
};
});
return { pack: { display_name: 'My Soundboard' }, clips };
}
export function getUserSoundboardPack(mx: MatrixClient): SoundboardPack | undefined {
const packEvent = getAccountData(mx, AccountDataEvent.LotusSoundboard);
const userId = mx.getUserId();
if (!packEvent || !userId) return undefined;
const content = migrateUserSoundboardContent(packEvent.getContent());
return new SoundboardPack(userId, content, undefined);
}
+2
View File
@@ -5,12 +5,14 @@ import { mDirectAtom, useBindMDirectAtom } from '../mDirectList';
import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread';
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications';
export const useBindAtoms = (mx: MatrixClient) => {
useBindMDirectAtom(mx, mDirectAtom);
useBindAllInvitesAtom(mx, allInvitesAtom);
useBindAllRoomsAtom(mx, allRoomsAtom);
useBindRoomToParentsAtom(mx, roomToParentsAtom);
useBindThreadNotificationsAtom(mx, threadNotificationsAtom);
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
+64 -7
View File
@@ -1,5 +1,5 @@
import { produce } from 'immer';
import { atom, useSetAtom } from 'jotai';
import { atom, useAtomValue, useSetAtom } from 'jotai';
import {
IRoomTimelineData,
MatrixClient,
@@ -9,7 +9,7 @@ import {
SyncState,
} from 'matrix-js-sdk';
import { ReceiptContent, ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts';
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import {
Membership,
NotificationType,
@@ -29,6 +29,9 @@ import { roomToParentsAtom } from './roomToParents';
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
import { useSyncState } from '../../hooks/useSyncState';
import { useRoomsNotificationPreferencesContext } from '../../hooks/useRoomsNotificationPreferences';
import { useRoomsListener } from '../../hooks/useRoomsListener';
import { threadNotificationsAtom } from '../threadNotifications';
import { getMutedThreads } from '../../utils/threadNotifications';
export type RoomToUnreadAction =
| {
@@ -169,11 +172,17 @@ export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefin
export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roomToUnreadAtom) => {
const setUnreadAtom = useSetAtom(unreadAtom);
const roomsNotificationPreferences = useRoomsNotificationPreferencesContext();
const threadNotifications = useAtomValue(threadNotificationsAtom);
// Latest thread-notification prefs for the SDK-event handlers below, read via a
// ref so changing prefs never re-attaches the (many) per-room listeners. The
// dedicated reset effect keyed on `threadNotifications` handles mute/unmute.
const threadNotificationsRef = useRef(threadNotifications);
threadNotificationsRef.current = threadNotifications;
useEffect(() => {
setUnreadAtom({
type: 'RESET',
unreadInfos: getUnreadInfos(mx),
unreadInfos: getUnreadInfos(mx, threadNotificationsRef.current),
});
}, [mx, setUnreadAtom]);
@@ -187,7 +196,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
) {
setUnreadAtom({
type: 'RESET',
unreadInfos: getUnreadInfos(mx),
unreadInfos: getUnreadInfos(mx, threadNotificationsRef.current),
});
}
},
@@ -204,6 +213,10 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
data: IRoomTimelineData,
) => {
if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
// Single-owner rule: thread replies drive the room badge via
// RoomEvent.UnreadNotifications below — ignore them here so the count is
// never double-driven / mis-attributed to the main timeline.
if (mEvent.threadRootId && mEvent.getId() !== mEvent.threadRootId) return;
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
setUnreadAtom({
type: 'DELETE',
@@ -213,7 +226,13 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
}
if (mEvent.getSender() === mx.getUserId()) return;
setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) });
setUnreadAtom({
type: 'PUT',
unreadInfo: getUnreadInfo(
room,
getMutedThreads(threadNotificationsRef.current, room.roomId),
),
});
};
mx.on(RoomEvent.Timeline, handleTimelineEvent);
return () => {
@@ -246,10 +265,48 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
useEffect(() => {
setUnreadAtom({
type: 'RESET',
unreadInfos: getUnreadInfos(mx),
unreadInfos: getUnreadInfos(mx, threadNotificationsRef.current),
});
}, [mx, setUnreadAtom, roomsNotificationPreferences]);
// Mute/unmute of a thread rewrites `threadNotificationsAtom`; recompute badges
// immediately so muted-thread subtraction takes effect without waiting for the
// next timeline / unread event (mirrors the notification-preferences reset).
useEffect(() => {
setUnreadAtom({
type: 'RESET',
unreadInfos: getUnreadInfos(mx, threadNotifications),
});
}, [mx, setUnreadAtom, threadNotifications]);
// RoomEvent.UnreadNotifications is emitted room-level only (never re-emitted
// client-side), so the main Timeline pathway misses thread-count changes and
// room badges lag. useRoomsListener PREPENDS the emitting Room (the SDK emits
// this event with variable arity — 0/1/2 args — so only a leading slot is
// positionally stable), making this a surgical per-room PUT with muted-thread
// subtraction re-applied. Room-mute keeps its DELETE semantics.
useRoomsListener(
mx,
RoomEvent.UnreadNotifications,
useCallback(
(room: Room) => {
if (room.isSpaceRoom()) return;
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
return;
}
setUnreadAtom({
type: 'PUT',
unreadInfo: getUnreadInfo(
room,
getMutedThreads(threadNotificationsRef.current, room.roomId),
),
});
},
[mx, setUnreadAtom],
),
);
useEffect(() => {
const handleMembershipChange = (room: Room, membership: string) => {
if (membership !== Membership.Join) {
@@ -272,7 +329,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
if (mEvent.getType() === StateEvent.SpaceChild) {
setUnreadAtom({
type: 'RESET',
unreadInfos: getUnreadInfos(mx),
unreadInfos: getUnreadInfos(mx, threadNotificationsRef.current),
});
}
},
+43
View File
@@ -0,0 +1,43 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createStore } from 'jotai';
import { getThreadDraftKey, roomIdToActiveThreadIdAtomFamily } from './thread';
// ---------------------------------------------------------------------------
// getThreadDraftKey
// ---------------------------------------------------------------------------
test('getThreadDraftKey joins roomId and threadRootId with "::"', () => {
assert.equal(getThreadDraftKey('!room:server', '$root'), '!room:server::$root');
});
test('getThreadDraftKey keeps the two ids distinguishable', () => {
assert.notEqual(getThreadDraftKey('!a:server', '$b'), getThreadDraftKey('!a:server', '$c'));
});
// ---------------------------------------------------------------------------
// roomIdToActiveThreadIdAtomFamily
// ---------------------------------------------------------------------------
test('returns the same atom instance for the same roomId', () => {
const a = roomIdToActiveThreadIdAtomFamily('!room:server');
const b = roomIdToActiveThreadIdAtomFamily('!room:server');
assert.equal(a, b);
});
test('returns different atoms for different roomIds', () => {
const a = roomIdToActiveThreadIdAtomFamily('!a:server');
const b = roomIdToActiveThreadIdAtomFamily('!b:server');
assert.notEqual(a, b);
});
test('the active-thread atom defaults to null and is writable', () => {
const store = createStore();
const activeThreadIdAtom = roomIdToActiveThreadIdAtomFamily('!store:server');
assert.equal(store.get(activeThreadIdAtom), null);
store.set(activeThreadIdAtom, '$root');
assert.equal(store.get(activeThreadIdAtom), '$root');
store.set(activeThreadIdAtom, null);
assert.equal(store.get(activeThreadIdAtom), null);
});
+22
View File
@@ -0,0 +1,22 @@
import { atom, PrimitiveAtom } from 'jotai';
import { atomFamily } from 'jotai/utils';
const createActiveThreadIdAtom = () => atom<string | null>(null);
export type TActiveThreadIdAtom = PrimitiveAtom<string | null>;
/**
* Per-room "which thread is open in the panel" state. Mirrors
* `roomIdToReplyDraftAtomFamily` in `roomInputDrafts.ts` the same atom
* instance is returned for the same roomId, so a room's panel state survives
* remounts.
*/
export const roomIdToActiveThreadIdAtomFamily = atomFamily<string, TActiveThreadIdAtom>(() =>
createActiveThreadIdAtom(),
);
/**
* Key used to scope a thread's composer drafts (message/reply/upload) away from
* the main room composer, e.g. `"!room:server::$rootEventId"`.
*/
export const getThreadDraftKey = (roomId: string, threadRootId: string): string =>
`${roomId}::${threadRootId}`;
+1
View File
@@ -5,6 +5,7 @@ export enum RoomSettingsPage {
MembersPage,
PermissionsPage,
EmojisStickersPage,
SoundboardPage,
DeveloperToolsPage,
ExportPage,
ActivityLogPage,
+24
View File
@@ -0,0 +1,24 @@
import {
atomWithLocalStorage,
getLocalStorageItem,
setLocalStorageItem,
} from './utils/atomWithLocalStorage';
const SEARCH_CACHE_ENABLED = 'searchCacheEnabled';
/**
* P4-8 persistent encrypted-search cache opt-in flag (default `false`).
*
* Standalone, `localStorage`-backed boolean atom kept separate from
* `state/settings.ts` on purpose. When `true`, encrypted-room search persists a
* decrypted plaintext index to IndexedDB (`lotus-search-cache`) so coverage
* survives reloads. Because this writes decrypted plaintext at rest it must be
* explicitly opted into; the cache is clearable from the search UI and wiped on
* logout. Toggling this atom off stops all reads/writes but does NOT wipe
* existing data that is the explicit "Clear cached index" button / logout.
*/
export const searchCacheEnabledAtom = atomWithLocalStorage<boolean>(
SEARCH_CACHE_ENABLED,
(key) => getLocalStorageItem<boolean>(key, false),
(key, value) => setLocalStorageItem(key, value),
);
+321 -3
View File
@@ -1,6 +1,14 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { setFallbackSession, removeFallbackSession, getFallbackSession } from './sessions';
import {
setFallbackSession,
removeFallbackSession,
getFallbackSession,
subscribeSessionChanges,
} from './sessions';
// The single-key atomic blob (kept in sync with SESSION_BLOB_KEY in sessions.ts).
const SESSION_BLOB_KEY = 'cinny_session_v1';
// The fallback-session helpers read/write specific `cinny_*` keys directly on
// `localStorage`. node has none, so install a controllable in-memory mock per
@@ -47,8 +55,9 @@ test('getFallbackSession returns undefined when nothing is stored', () => {
assert.equal(getFallbackSession(), undefined);
});
test('getFallbackSession returns undefined when a single key is missing', () => {
// Every one of the four keys is required; missing any one yields undefined.
test('legacy path: undefined when a single legacy key is missing (no blob)', () => {
// With no atomic blob, every one of the four legacy keys is required; missing
// any one yields undefined (the pre-blob behaviour).
const keys = [
'cinny_access_token',
'cinny_device_id',
@@ -59,11 +68,26 @@ test('getFallbackSession returns undefined when a single key is missing', () =>
keys.forEach((missing) => {
installStorage();
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
localStorage.removeItem(SESSION_BLOB_KEY);
localStorage.removeItem(missing);
assert.equal(getFallbackSession(), undefined, `missing ${missing} should yield undefined`);
});
});
test('blob wins: a torn legacy key does NOT tear the session while the blob exists', () => {
installStorage();
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
// Simulate a torn legacy write — the authoritative blob must still resolve.
localStorage.removeItem('cinny_access_token');
assert.deepEqual(getFallbackSession(), {
baseUrl: 'https://hs.example.org',
userId: '@alice:example.org',
deviceId: 'DEVICE1',
accessToken: 'token-1',
fallbackSdkStores: true,
});
});
test('removeFallbackSession clears all keys', () => {
const store = installStorage();
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
@@ -113,4 +137,298 @@ test('a password session carries no OIDC fields, and re-saving clears stale OIDC
assert.ok(s);
assert.equal(s.oidc, undefined);
assert.equal(s.refreshToken, undefined);
// The overwritten blob must not retain the stale OIDC state either.
const blob = JSON.parse(localStorage.getItem(SESSION_BLOB_KEY)!);
assert.equal(blob.oidc, undefined);
assert.equal(blob.refreshToken, undefined);
});
// ---------------------------------------------------------------------------
// Atomic blob: write/read round-trip
// ---------------------------------------------------------------------------
test('setFallbackSession writes a single atomic blob under cinny_session_v1', () => {
const store = installStorage();
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
const raw = store.get(SESSION_BLOB_KEY);
assert.ok(raw, 'blob key must be written');
assert.deepEqual(JSON.parse(raw!), {
accessToken: 'token-1',
deviceId: 'DEVICE1',
userId: '@alice:example.org',
baseUrl: 'https://hs.example.org',
});
});
test('blob round-trips a full OIDC session (absolute expiry stored, remaining read back)', () => {
const store = installStorage();
setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', {
refreshToken: 'refresh-xyz',
expiresInMs: 3_600_000,
oidc: {
issuer: 'https://i',
clientId: 'c',
redirectUri: 'https://cb',
idTokenClaims: { sub: '@bob:mozilla.org' },
},
});
const blob = JSON.parse(store.get(SESSION_BLOB_KEY)!);
assert.equal(blob.refreshToken, 'refresh-xyz');
assert.ok(typeof blob.expiresAt === 'number' && blob.expiresAt > Date.now());
assert.deepEqual(blob.oidc, {
issuer: 'https://i',
clientId: 'c',
redirectUri: 'https://cb',
idTokenClaims: { sub: '@bob:mozilla.org' },
});
const s = getFallbackSession();
assert.ok(s);
assert.equal(s.refreshToken, 'refresh-xyz');
assert.ok(s.expiresInMs! > 0 && s.expiresInMs! <= 3_600_000);
assert.deepEqual(s.oidc, {
issuer: 'https://i',
clientId: 'c',
redirectUri: 'https://cb',
idTokenClaims: { sub: '@bob:mozilla.org' },
});
});
// ---------------------------------------------------------------------------
// Migration: legacy-only storage → transparent read → blob persisted on write
// ---------------------------------------------------------------------------
test('legacy-only storage (no blob) is read transparently', () => {
const store = installStorage();
// Simulate an older build: legacy keys present, no blob.
store.set('cinny_access_token', 'tok');
store.set('cinny_device_id', 'DEV');
store.set('cinny_user_id', '@carol:example.org');
store.set('cinny_hs_base_url', 'https://hs');
assert.equal(store.has(SESSION_BLOB_KEY), false);
assert.deepEqual(getFallbackSession(), {
baseUrl: 'https://hs',
userId: '@carol:example.org',
deviceId: 'DEV',
accessToken: 'tok',
fallbackSdkStores: true,
});
});
test('first write after a legacy-only read persists the blob (migration)', () => {
const store = installStorage();
store.set('cinny_access_token', 'old');
store.set('cinny_device_id', 'DEV');
store.set('cinny_user_id', '@carol:example.org');
store.set('cinny_hs_base_url', 'https://hs');
// Reads are side-effect free — no blob yet.
getFallbackSession();
assert.equal(store.has(SESSION_BLOB_KEY), false);
// The next write (e.g. a token refresh) persists the atomic blob.
setFallbackSession('new', 'DEV', '@carol:example.org', 'https://hs');
assert.ok(store.has(SESSION_BLOB_KEY));
assert.equal(getFallbackSession()?.accessToken, 'new');
});
// ---------------------------------------------------------------------------
// Corruption / partial blob → legacy fallback; blob wins on disagreement
// ---------------------------------------------------------------------------
test('corrupt blob (bad JSON) falls back to the legacy keys', () => {
const store = installStorage();
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
// Corrupt the blob but keep the legacy keys intact.
store.set(SESSION_BLOB_KEY, '{not valid json');
assert.deepEqual(getFallbackSession(), {
baseUrl: 'https://hs.example.org',
userId: '@alice:example.org',
deviceId: 'DEVICE1',
accessToken: 'token-1',
fallbackSdkStores: true,
});
});
test('partial blob (missing a required field) falls back to the legacy keys', () => {
const store = installStorage();
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
// A blob missing accessToken is treated as absent.
store.set(
SESSION_BLOB_KEY,
JSON.stringify({ deviceId: 'DEVICE1', userId: '@alice:example.org', baseUrl: 'https://hs' }),
);
assert.equal(getFallbackSession()?.accessToken, 'token-1');
});
test('blob wins when blob and legacy keys disagree', () => {
const store = installStorage();
setFallbackSession('blob-token', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
// Legacy keys drift to a stale token; the blob is authoritative.
store.set('cinny_access_token', 'stale-legacy-token');
assert.equal(getFallbackSession()?.accessToken, 'blob-token');
});
// ---------------------------------------------------------------------------
// Dual-write keeps blob + legacy in sync; removal clears both
// ---------------------------------------------------------------------------
test('dual-write keeps the legacy keys in sync with the blob', () => {
const store = installStorage();
setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', {
refreshToken: 'r',
expiresInMs: 1000,
oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' },
});
// Legacy credential keys
assert.equal(store.get('cinny_access_token'), 'tok');
assert.equal(store.get('cinny_device_id'), 'DEV');
assert.equal(store.get('cinny_user_id'), '@bob:mozilla.org');
assert.equal(store.get('cinny_hs_base_url'), 'https://hs');
// Legacy OIDC keys
assert.equal(store.get('cinny_refresh_token'), 'r');
assert.ok(store.has('cinny_expires_at'));
assert.equal(store.get('cinny_oidc_issuer'), 'https://i');
assert.equal(store.get('cinny_oidc_client_id'), 'c');
assert.equal(store.get('cinny_oidc_redirect_uri'), 'https://cb');
// Blob agrees
const blob = JSON.parse(store.get(SESSION_BLOB_KEY)!);
assert.equal(blob.accessToken, 'tok');
assert.equal(blob.refreshToken, 'r');
assert.equal(store.get('cinny_expires_at'), String(blob.expiresAt));
});
test('removeFallbackSession clears BOTH the blob and every legacy key', () => {
const store = installStorage();
setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', {
refreshToken: 'r',
expiresInMs: 1000,
oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' },
});
assert.ok(store.size > 0);
removeFallbackSession();
assert.equal(store.size, 0, 'no session key may survive removal');
assert.equal(getFallbackSession(), undefined);
});
// ---------------------------------------------------------------------------
// Token-refresh update path (the path LotusOidcTokenRefresher uses)
// ---------------------------------------------------------------------------
test('token refresh via setFallbackSession updates blob + legacy atomically', () => {
const store = installStorage();
// Initial OIDC session.
setFallbackSession('access-1', 'DEV', '@bob:mozilla.org', 'https://hs', {
refreshToken: 'refresh-1',
oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' },
});
// LotusOidcTokenRefresher.persistTokens() calls setFallbackSession with the
// rotated tokens and the same identity/oidc refs.
setFallbackSession('access-2', 'DEV', '@bob:mozilla.org', 'https://hs', {
refreshToken: 'refresh-2',
oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' },
});
// Blob updated
const blob = JSON.parse(store.get(SESSION_BLOB_KEY)!);
assert.equal(blob.accessToken, 'access-2');
assert.equal(blob.refreshToken, 'refresh-2');
// Legacy keys updated in lockstep
assert.equal(store.get('cinny_access_token'), 'access-2');
assert.equal(store.get('cinny_refresh_token'), 'refresh-2');
// Reader sees the fresh token
const s = getFallbackSession();
assert.equal(s?.accessToken, 'access-2');
assert.equal(s?.refreshToken, 'refresh-2');
});
// ---------------------------------------------------------------------------
// Cross-tab sync: subscribeSessionChanges
// ---------------------------------------------------------------------------
// Minimal window/storage-event harness: node has neither.
const installWindow = (): ((evt: { key: string | null }) => void)[] => {
const listeners: ((evt: { key: string | null }) => void)[] = [];
(globalThis as { window?: unknown }).window = {
addEventListener: (type: string, cb: (evt: { key: string | null }) => void) => {
if (type === 'storage') listeners.push(cb);
},
removeEventListener: (type: string, cb: (evt: { key: string | null }) => void) => {
if (type !== 'storage') return;
const i = listeners.indexOf(cb);
if (i !== -1) listeners.splice(i, 1);
},
};
return listeners;
};
test('subscribeSessionChanges fires with the session when a session key changes', () => {
installStorage();
const listeners = installWindow();
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs');
let received: unknown = 'unset';
const unsub = subscribeSessionChanges((s) => {
received = s;
});
// Simulate another tab writing a new token, then dispatch the storage event.
setFallbackSession('token-2', 'DEVICE1', '@alice:example.org', 'https://hs');
listeners.forEach((cb) => cb({ key: SESSION_BLOB_KEY }));
assert.notEqual(received, 'unset');
assert.equal((received as { accessToken?: string })?.accessToken, 'token-2');
unsub();
assert.equal(listeners.length, 0, 'unsubscribe removes the listener');
});
test('subscribeSessionChanges fires with null when the session is removed', () => {
installStorage();
const listeners = installWindow();
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs');
subscribeSessionChanges((s) => {
assert.equal(s, null);
});
removeFallbackSession();
listeners.forEach((cb) => cb({ key: SESSION_BLOB_KEY }));
});
test('subscribeSessionChanges treats a null key (localStorage.clear) as a change', () => {
const store = installStorage();
const listeners = installWindow();
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs');
let fired = false;
subscribeSessionChanges(() => {
fired = true;
});
store.clear();
listeners.forEach((cb) => cb({ key: null }));
assert.equal(fired, true);
});
test('subscribeSessionChanges ignores unrelated storage keys', () => {
installStorage();
const listeners = installWindow();
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs');
let fired = false;
subscribeSessionChanges(() => {
fired = true;
});
listeners.forEach((cb) => cb({ key: 'some_unrelated_preference' }));
assert.equal(fired, false);
});
+230 -76
View File
@@ -26,6 +26,15 @@ export type Session = {
oidc?: OidcSessionMeta;
};
// Legacy per-field localStorage keys. Kept for dual-write (see below) so a
// rollback to an older build that only understands these keys still works.
const LEGACY_KEYS = {
accessToken: 'cinny_access_token',
deviceId: 'cinny_device_id',
userId: 'cinny_user_id',
baseUrl: 'cinny_hs_base_url',
} as const;
// OIDC-only localStorage keys (absent for password/legacy-SSO sessions).
const OIDC_KEYS = {
refreshToken: 'cinny_refresh_token',
@@ -36,6 +45,174 @@ const OIDC_KEYS = {
idTokenClaims: 'cinny_oidc_id_token_claims',
} as const;
// Single-key atomic session blob. The whole session is serialised and written
// in ONE `setItem`, so a reader can never observe a torn/partial session the
// way the multi-key legacy layout could. Bumping the schema means bumping the
// `_v1` suffix.
const SESSION_BLOB_KEY = 'cinny_session_v1';
// The exact shape stored inside SESSION_BLOB_KEY. Note it stores an ABSOLUTE
// `expiresAt` (ms since epoch) rather than a relative lifetime — identical to
// the legacy `cinny_expires_at` semantics — so reads stay drift-free.
type PersistedSession = {
accessToken: string;
deviceId: string;
userId: string;
baseUrl: string;
refreshToken?: string;
expiresAt?: number;
oidc?: OidcSessionMeta;
};
// Build the persisted shape from the public setFallbackSession arguments. This
// is the single source of truth written to BOTH the blob and the legacy keys.
const buildPersisted = (
accessToken: string,
deviceId: string,
userId: string,
baseUrl: string,
extra?: FallbackSessionExtra,
): PersistedSession => {
const persisted: PersistedSession = { accessToken, deviceId, userId, baseUrl };
if (extra?.refreshToken) persisted.refreshToken = extra.refreshToken;
// Store ABSOLUTE expiry to avoid drift across reloads.
if (typeof extra?.expiresInMs === 'number') persisted.expiresAt = Date.now() + extra.expiresInMs;
if (extra?.oidc) persisted.oidc = extra.oidc;
return persisted;
};
// Convert a persisted shape into the public Session returned to callers. Keeps
// behaviour identical to the original getFallbackSession assembly: derives the
// REMAINING lifetime from the absolute expiry, and only surfaces `oidc` when the
// three required OIDC fields are present.
const sessionFromPersisted = (p: PersistedSession): Session => {
const session: Session = {
baseUrl: p.baseUrl,
userId: p.userId,
deviceId: p.deviceId,
accessToken: p.accessToken,
fallbackSdkStores: true,
};
if (p.refreshToken) session.refreshToken = p.refreshToken;
if (typeof p.expiresAt === 'number' && Number.isFinite(p.expiresAt)) {
// Expose the REMAINING lifetime (clamped at 0); the SDK refreshes on 401.
session.expiresInMs = Math.max(0, p.expiresAt - Date.now());
}
if (p.oidc && p.oidc.issuer && p.oidc.clientId && p.oidc.redirectUri) {
session.oidc = {
issuer: p.oidc.issuer,
clientId: p.oidc.clientId,
redirectUri: p.oidc.redirectUri,
idTokenClaims: p.oidc.idTokenClaims,
};
}
return session;
};
// Read the atomic blob. Returns undefined when absent, unparseable, or missing
// any of the four required credential fields — callers then fall back to the
// legacy keys.
const readSessionBlob = (): PersistedSession | undefined => {
const raw = localStorage.getItem(SESSION_BLOB_KEY);
if (!raw) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
// Corrupt JSON — treat as absent and let the legacy path take over.
return undefined;
}
if (!parsed || typeof parsed !== 'object') return undefined;
const p = parsed as Partial<PersistedSession>;
if (
typeof p.accessToken !== 'string' ||
typeof p.deviceId !== 'string' ||
typeof p.userId !== 'string' ||
typeof p.baseUrl !== 'string'
) {
// Partial/corrupt blob — fall back to legacy assembly.
return undefined;
}
return p as PersistedSession;
};
// Assemble a session from the legacy per-field keys, or undefined when the four
// required keys are not all present. Used for transparent migration from builds
// that predate the atomic blob.
const readLegacyKeys = (): PersistedSession | undefined => {
const baseUrl = localStorage.getItem(LEGACY_KEYS.baseUrl);
const userId = localStorage.getItem(LEGACY_KEYS.userId);
const deviceId = localStorage.getItem(LEGACY_KEYS.deviceId);
const accessToken = localStorage.getItem(LEGACY_KEYS.accessToken);
if (!(baseUrl && userId && deviceId && accessToken)) return undefined;
const persisted: PersistedSession = { accessToken, deviceId, userId, baseUrl };
const refreshToken = localStorage.getItem(OIDC_KEYS.refreshToken);
if (refreshToken) persisted.refreshToken = refreshToken;
const expiresAtRaw = localStorage.getItem(OIDC_KEYS.expiresAt);
if (expiresAtRaw) {
const expiresAt = Number(expiresAtRaw);
if (Number.isFinite(expiresAt)) persisted.expiresAt = expiresAt;
}
const issuer = localStorage.getItem(OIDC_KEYS.issuer);
const clientId = localStorage.getItem(OIDC_KEYS.clientId);
const redirectUri = localStorage.getItem(OIDC_KEYS.redirectUri);
if (issuer && clientId && redirectUri) {
let idTokenClaims: Record<string, unknown> | undefined;
const claimsRaw = localStorage.getItem(OIDC_KEYS.idTokenClaims);
if (claimsRaw) {
try {
idTokenClaims = JSON.parse(claimsRaw);
} catch {
/* corrupt claims — ignore, the refresher will re-validate on use */
}
}
persisted.oidc = { issuer, clientId, redirectUri, idTokenClaims };
}
return persisted;
};
// Write the legacy per-field keys (dual-write half). Mirrors the original
// setFallbackSession body so a rollback to an older build keeps working.
const writeLegacyKeys = (p: PersistedSession): void => {
localStorage.setItem(LEGACY_KEYS.accessToken, p.accessToken);
localStorage.setItem(LEGACY_KEYS.deviceId, p.deviceId);
localStorage.setItem(LEGACY_KEYS.userId, p.userId);
localStorage.setItem(LEGACY_KEYS.baseUrl, p.baseUrl);
// OIDC fields — written only when present; otherwise cleared so a password
// session never carries stale OIDC state.
if (p.refreshToken) localStorage.setItem(OIDC_KEYS.refreshToken, p.refreshToken);
else localStorage.removeItem(OIDC_KEYS.refreshToken);
if (typeof p.expiresAt === 'number')
localStorage.setItem(OIDC_KEYS.expiresAt, String(p.expiresAt));
else localStorage.removeItem(OIDC_KEYS.expiresAt);
if (p.oidc) {
localStorage.setItem(OIDC_KEYS.issuer, p.oidc.issuer);
localStorage.setItem(OIDC_KEYS.clientId, p.oidc.clientId);
localStorage.setItem(OIDC_KEYS.redirectUri, p.oidc.redirectUri);
if (p.oidc.idTokenClaims) {
localStorage.setItem(OIDC_KEYS.idTokenClaims, JSON.stringify(p.oidc.idTokenClaims));
} else localStorage.removeItem(OIDC_KEYS.idTokenClaims);
} else {
localStorage.removeItem(OIDC_KEYS.issuer);
localStorage.removeItem(OIDC_KEYS.clientId);
localStorage.removeItem(OIDC_KEYS.redirectUri);
localStorage.removeItem(OIDC_KEYS.idTokenClaims);
}
};
export type FallbackSessionExtra = {
refreshToken?: string;
expiresInMs?: number;
@@ -56,6 +233,10 @@ export type SessionStoreName = {
// crypto: 'crypto-store',
// } as const;
// Persist the session. Writes the atomic blob FIRST (so the consistent,
// never-torn copy is established before the multi-key legacy write), then
// dual-writes the legacy keys for rollback safety. Signature is unchanged —
// callers (login/register/OIDC callback/token refresher) are untouched.
export function setFallbackSession(
accessToken: string,
deviceId: string,
@@ -63,92 +244,65 @@ export function setFallbackSession(
baseUrl: string,
extra?: FallbackSessionExtra,
) {
localStorage.setItem('cinny_access_token', accessToken);
localStorage.setItem('cinny_device_id', deviceId);
localStorage.setItem('cinny_user_id', userId);
localStorage.setItem('cinny_hs_base_url', baseUrl);
// OIDC fields — written only when present; otherwise cleared so a password
// session never carries stale OIDC state.
if (extra?.refreshToken) localStorage.setItem(OIDC_KEYS.refreshToken, extra.refreshToken);
else localStorage.removeItem(OIDC_KEYS.refreshToken);
if (typeof extra?.expiresInMs === 'number') {
// Store ABSOLUTE expiry to avoid drift across reloads.
localStorage.setItem(OIDC_KEYS.expiresAt, String(Date.now() + extra.expiresInMs));
} else localStorage.removeItem(OIDC_KEYS.expiresAt);
if (extra?.oidc) {
localStorage.setItem(OIDC_KEYS.issuer, extra.oidc.issuer);
localStorage.setItem(OIDC_KEYS.clientId, extra.oidc.clientId);
localStorage.setItem(OIDC_KEYS.redirectUri, extra.oidc.redirectUri);
if (extra.oidc.idTokenClaims) {
localStorage.setItem(OIDC_KEYS.idTokenClaims, JSON.stringify(extra.oidc.idTokenClaims));
} else localStorage.removeItem(OIDC_KEYS.idTokenClaims);
} else {
localStorage.removeItem(OIDC_KEYS.issuer);
localStorage.removeItem(OIDC_KEYS.clientId);
localStorage.removeItem(OIDC_KEYS.redirectUri);
localStorage.removeItem(OIDC_KEYS.idTokenClaims);
}
const persisted = buildPersisted(accessToken, deviceId, userId, baseUrl, extra);
// ONE setItem — the blob can never be observed half-written.
localStorage.setItem(SESSION_BLOB_KEY, JSON.stringify(persisted));
// Dual-write the legacy keys (removal of this half is a future release).
writeLegacyKeys(persisted);
}
// Clear BOTH the atomic blob and every legacy key so no reader (blob-preferring
// or legacy-fallback) can resurrect a logged-out session.
export const removeFallbackSession = () => {
localStorage.removeItem('cinny_hs_base_url');
localStorage.removeItem('cinny_user_id');
localStorage.removeItem('cinny_device_id');
localStorage.removeItem('cinny_access_token');
localStorage.removeItem(SESSION_BLOB_KEY);
Object.values(LEGACY_KEYS).forEach((key) => localStorage.removeItem(key));
Object.values(OIDC_KEYS).forEach((key) => localStorage.removeItem(key));
};
// Read the session, preferring the atomic blob. If the blob is absent or
// corrupt/partial we transparently assemble from the legacy keys (migration);
// the next setFallbackSession then persists the blob. When both exist the blob
// wins by construction.
export const getFallbackSession = (): Session | undefined => {
const baseUrl = localStorage.getItem('cinny_hs_base_url');
const userId = localStorage.getItem('cinny_user_id');
const deviceId = localStorage.getItem('cinny_device_id');
const accessToken = localStorage.getItem('cinny_access_token');
if (baseUrl && userId && deviceId && accessToken) {
const session: Session = {
baseUrl,
userId,
deviceId,
accessToken,
fallbackSdkStores: true,
};
const refreshToken = localStorage.getItem(OIDC_KEYS.refreshToken);
if (refreshToken) session.refreshToken = refreshToken;
const expiresAtRaw = localStorage.getItem(OIDC_KEYS.expiresAt);
if (expiresAtRaw) {
const expiresAt = Number(expiresAtRaw);
// Expose the REMAINING lifetime (clamped at 0); the SDK refreshes on 401.
if (Number.isFinite(expiresAt)) session.expiresInMs = Math.max(0, expiresAt - Date.now());
}
const issuer = localStorage.getItem(OIDC_KEYS.issuer);
const clientId = localStorage.getItem(OIDC_KEYS.clientId);
const redirectUri = localStorage.getItem(OIDC_KEYS.redirectUri);
if (issuer && clientId && redirectUri) {
let idTokenClaims: Record<string, unknown> | undefined;
const claimsRaw = localStorage.getItem(OIDC_KEYS.idTokenClaims);
if (claimsRaw) {
try {
idTokenClaims = JSON.parse(claimsRaw);
} catch {
/* corrupt claims — ignore, the refresher will re-validate on use */
}
}
session.oidc = { issuer, clientId, redirectUri, idTokenClaims };
}
return session;
}
return undefined;
const persisted = readSessionBlob() ?? readLegacyKeys();
if (!persisted) return undefined;
return sessionFromPersisted(persisted);
};
/**
* End of migration code for old session
*/
// Session keys whose cross-tab change indicates a login/logout/token-rotation
// in another tab. localStorage.clear() dispatches a storage event with a null
// key, which we also treat as a session change.
const SESSION_STORAGE_KEYS = new Set<string>([
SESSION_BLOB_KEY,
...Object.values(LEGACY_KEYS),
...Object.values(OIDC_KEYS),
]);
/**
* Subscribe to session changes made in OTHER tabs/windows. The browser only
* dispatches `storage` events to tabs that did NOT perform the write, so this
* is inherently guarded against reacting to our own same-tab writes no
* echo-suppression needed. The callback receives the freshly-read session, or
* `null` when the session was removed (logout in another tab, or a full
* localStorage.clear()). Returns an unsubscribe function.
*/
export const subscribeSessionChanges = (
callback: (session: Session | null) => void,
): (() => void) => {
const handleStorage = (evt: StorageEvent) => {
// A null key means localStorage.clear(); otherwise only react to our keys.
if (evt.key !== null && !SESSION_STORAGE_KEYS.has(evt.key)) return;
callback(getFallbackSession() ?? null);
};
window.addEventListener('storage', handleStorage);
return () => {
window.removeEventListener('storage', handleStorage);
};
};
// export const getSessionStoreName = (session: Session): SessionStoreName => {
// if (session.fallbackSdkStores) {
// return FALLBACK_STORE_NAME;
+1
View File
@@ -5,6 +5,7 @@ export enum SpaceSettingsPage {
MembersPage,
PermissionsPage,
EmojisStickersPage,
SoundboardPage,
DeveloperToolsPage,
PolicyListsPage,
}
+36
View File
@@ -0,0 +1,36 @@
import { atom, useSetAtom } from 'jotai';
import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { useEffect } from 'react';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { ThreadNotificationsContent } from '../utils/threadNotifications';
// Holds the parsed `io.lotus.thread_notifications` account data. Seeded and
// kept in sync by `useBindThreadNotificationsAtom`.
export const threadNotificationsAtom = atom<ThreadNotificationsContent>({});
const readContent = (mx: MatrixClient): ThreadNotificationsContent =>
((mx as any).getAccountData(AccountDataEvent.LotusThreadNotifications)?.getContent() as
| ThreadNotificationsContent
| undefined) ?? {};
export const useBindThreadNotificationsAtom = (
mx: MatrixClient,
threadNotifications: typeof threadNotificationsAtom,
) => {
const setContent = useSetAtom(threadNotifications);
useEffect(() => {
setContent(readContent(mx));
const handleAccountData = (event: MatrixEvent) => {
if (event.getType() === AccountDataEvent.LotusThreadNotifications) {
setContent(event.getContent<ThreadNotificationsContent>() ?? {});
}
};
mx.on(ClientEvent.AccountData, handleAccountData);
return () => {
mx.removeListener(ClientEvent.AccountData, handleAccountData);
};
}, [mx, setContent]);
};
+21
View File
@@ -9,6 +9,8 @@ import {
contrastingText,
varNameFromToken,
derivePrimaryPalette,
deriveAccentExtras,
buildAccentCss,
} from './accentColor';
test('hexToRgb parses 6-digit hex (with/without #, trimmed)', () => {
@@ -66,3 +68,22 @@ test('derivePrimaryPalette produces the full Primary token set', () => {
assert.match(palette.MainHover, /^#[0-9a-f]{6}$/);
assert.match(palette.MainActive, /^#[0-9a-f]{6}$/);
});
test('deriveAccentExtras derives focus ring, link and selection from one base', () => {
const base = { r: 255, g: 136, b: 0 };
const extras = deriveAccentExtras(base);
// focus ring keeps the translucent character in the accent hue
assert.equal(extras.focusRing, 'rgba(255, 136, 0, 0.5)');
// link + selection background are the solid base hex
assert.equal(extras.link, '#ff8800');
assert.equal(extras.selectionBg, '#ff8800');
// selection text is WCAG-aware contrasting text over the base
assert.equal(extras.selectionText, contrastingText(base));
});
test('buildAccentCss emits selection rules using the derived palette', () => {
const base = { r: 0, g: 0, b: 0 };
const css = buildAccentCss(base);
assert.match(css, /::selection\{background:#000000;color:#fff;\}/);
assert.match(css, /::-moz-selection\{background:#000000;color:#fff;\}/);
});
+64 -1
View File
@@ -74,6 +74,45 @@ const PRIMARY_TOKENS: Record<string, string> = {
OnContainer: color.Primary.OnContainer,
};
// The neutral focus-ring token folds uses for the outline on inputs, buttons,
// switches, checkboxes and radios. Its default is a semi-transparent grey/black,
// so tinting it in the accent hue themes every focus ring without touching the
// neutral Secondary family (see below). We keep the same translucent character
// so it reads as a ring rather than a fill.
const FOCUS_RING_TOKEN = color.Other.FocusRing;
// `--tc-link` is the global anchor color (index.css `a { color: var(--tc-link) }`);
// overriding it themes plain links inside messages, room topics and URL previews.
const LINK_VAR = '--tc-link';
// Injected stylesheet id — carries rules that cannot be expressed as a single
// CSS variable (currently text ::selection).
const ACCENT_STYLE_ID = 'lotus-accent-style';
export type AccentExtras = {
focusRing: string;
link: string;
selectionBg: string;
selectionText: string;
};
// Derive the extra (non-Primary) accent values from the single base color, using
// the same helpers as the Primary palette so everything stays in one hue.
export const deriveAccentExtras = (base: Rgb): AccentExtras => ({
focusRing: rgba(base, 0.5),
link: rgbToHex(base),
selectionBg: rgbToHex(base),
selectionText: contrastingText(base),
});
// Build the injected stylesheet body. Selection uses a solid accent fill with
// WCAG-aware contrasting text so highlighted text stays readable.
export const buildAccentCss = (base: Rgb): string => {
const { selectionBg, selectionText } = deriveAccentExtras(base);
const selection = `background:${selectionBg};color:${selectionText};`;
return `::selection{${selection}}::-moz-selection{${selection}}`;
};
// Derive the 10 Primary sub-token values from a single chosen base color.
export const derivePrimaryPalette = (base: Rgb): Record<string, string> => {
const baseHex = rgbToHex(base);
@@ -96,22 +135,46 @@ export const derivePrimaryPalette = (base: Rgb): Record<string, string> => {
};
// Apply a custom accent color by overriding the folds Primary CSS variables on
// `document.body`. Returns true when applied, false when the input is invalid.
// `document.body`, tinting the focus-ring and link vars, and injecting a small
// stylesheet for text selection. Returns true when applied, false when the input
// is invalid.
export const applyCustomAccent = (hex: string): boolean => {
const base = hexToRgb(hex);
if (!base) return false;
const palette = derivePrimaryPalette(base);
Object.entries(PRIMARY_TOKENS).forEach(([key, token]) => {
const varName = varNameFromToken(token);
if (varName) document.body.style.setProperty(varName, palette[key]);
});
const extras = deriveAccentExtras(base);
const focusRingVar = varNameFromToken(FOCUS_RING_TOKEN);
if (focusRingVar) document.body.style.setProperty(focusRingVar, extras.focusRing);
document.body.style.setProperty(LINK_VAR, extras.link);
let styleEl = document.getElementById(ACCENT_STYLE_ID) as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = ACCENT_STYLE_ID;
document.head.appendChild(styleEl);
}
styleEl.textContent = buildAccentCss(base);
return true;
};
// Remove all custom accent overrides, reverting to the active theme's defaults.
// Idempotent — safe to call even when nothing was applied.
export const removeCustomAccent = (): void => {
Object.values(PRIMARY_TOKENS).forEach((token) => {
const varName = varNameFromToken(token);
if (varName) document.body.style.removeProperty(varName);
});
const focusRingVar = varNameFromToken(FOCUS_RING_TOKEN);
if (focusRingVar) document.body.style.removeProperty(focusRingVar);
document.body.style.removeProperty(LINK_VAR);
document.getElementById(ACCENT_STYLE_ID)?.remove();
};
+43
View File
@@ -0,0 +1,43 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
/**
* Guard against same-directory filenames that differ only by case (e.g.
* `threadSummary.ts` vs `ThreadSummary.tsx`). On case-insensitive filesystems
* (the Windows release runner) an extensionless import of one can resolve to
* the OTHER file rolldown tries `.ts` before `.tsx` producing
* MISSING_EXPORT failures that never reproduce on the Linux/macOS machines the
* project is developed and web-deployed on. This broke the desktop release
* build twice before being diagnosed; this test makes the collision a local,
* immediate failure instead.
*/
const findCaseCollisions = (dir: string, collisions: string[]): void => {
const entries = readdirSync(dir, { withFileTypes: true });
const seen = new Map<string, string>();
entries.forEach((entry) => {
// Compare basenames without extension: `Foo.tsx` collides with `foo.ts`
// because module resolution is extensionless.
const stem = entry.isDirectory() ? entry.name : entry.name.replace(/\.[^.]+$/, '');
const key = stem.toLowerCase();
const existing = seen.get(key);
if (existing !== undefined && existing !== stem) {
collisions.push(`${dir}: "${existing}" vs "${stem}"`);
}
if (existing === undefined) seen.set(key, stem);
if (entry.isDirectory()) {
findCaseCollisions(join(dir, entry.name), collisions);
}
});
};
test('no same-directory filenames differing only by case under src/', () => {
const collisions: string[] = [];
findCaseCollisions('src', collisions);
assert.deepEqual(
collisions,
[],
`Case-colliding names break Windows builds:\n${collisions.join('\n')}`,
);
});
+151
View File
@@ -0,0 +1,151 @@
import type { MatrixClient } from 'matrix-js-sdk';
import pkg from '../../../package.json';
// Lotus E2EE investigation kit — capture-only console diagnostics.
//
// Installs pass-through wrappers around `console.warn` / `console.error` that
// ring-buffer any log line matching the KE-1..KE-4 bug-cluster signatures
// (see LOTUS_E2EE_INVESTIGATION.md). It NEVER swallows a log call — the
// original console method is always invoked — and it performs NO network I/O.
// The report metadata is limited to SDK version / device id / user id / sync
// state; the captured log lines themselves are intentional evidence and may
// contain event ids or matrix ids exactly as the SDK logged them.
export type CryptoDiagLevel = 'warn' | 'error';
export type CryptoDiagEntry = {
/** ISO-8601 UTC timestamp of when the line was captured. */
ts: string;
level: CryptoDiagLevel;
/** Which KE bucket the signature belongs to, e.g. `KE-1`. */
ke: string;
/** Human-readable label of the matched signature. */
signature: string;
/** The serialized console line (best-effort). */
message: string;
};
type Signature = {
ke: string;
label: string;
re: RegExp;
};
// Ordered most-specific-first so the recorded label is the tightest match.
const SIGNATURES: Signature[] = [
{ ke: 'KE-1', label: 'already exists', re: /already exists/i },
{ ke: 'KE-2', label: 'missing key at index', re: /missing key at index/i },
{
ke: 'KE-2',
label: 'io.element.call.encryption_keys',
re: /io\.element\.call\.encryption_keys/,
},
{ ke: 'KE-2', label: 'MissingKey', re: /MissingKey/ },
{ ke: 'KE-3', label: 'DecryptionError', re: /DecryptionError/ },
{ ke: 'KE-4', label: 'update_delayed_event', re: /update_delayed_event/ },
{ ke: 'KE-4', label: 'delayed event', re: /delayed event/i },
];
const MAX_ENTRIES = 200;
const entries: CryptoDiagEntry[] = [];
let installed = false;
let originalWarn: ((...data: unknown[]) => void) | undefined;
let originalError: ((...data: unknown[]) => void) | undefined;
const stringifyArg = (arg: unknown): string => {
if (typeof arg === 'string') return arg;
if (arg instanceof Error) return `${arg.name}: ${arg.message}`;
try {
return JSON.stringify(arg);
} catch {
return String(arg);
}
};
const capture = (level: CryptoDiagLevel, args: unknown[]): void => {
const message = args.map(stringifyArg).join(' ');
const sig = SIGNATURES.find((s) => s.re.test(message));
if (!sig) return;
entries.push({
ts: new Date().toISOString(),
level,
ke: sig.ke,
signature: sig.label,
message,
});
// Ring-buffer: keep only the most recent MAX_ENTRIES.
while (entries.length > MAX_ENTRIES) {
entries.shift();
}
};
/**
* Install the capture-only console wrappers. Idempotent calling it more than
* once is a no-op. Safe to call as early as possible during app boot.
*/
export const installCryptoDiagLog = (): void => {
if (installed) return;
installed = true;
originalWarn = console.warn.bind(console);
originalError = console.error.bind(console);
console.warn = (...args: unknown[]): void => {
capture('warn', args);
originalWarn?.(...args);
};
console.error = (...args: unknown[]): void => {
capture('error', args);
originalError?.(...args);
};
};
/** A snapshot copy of the current capture buffer (most-recent-last). */
export const getCryptoDiagEntries = (): CryptoDiagEntry[] => entries.slice();
const readSdkVersion = (mx?: MatrixClient): string => {
// Prefer the value the running client reports; fall back to the declared pin.
const declared = (pkg.dependencies as Record<string, string> | undefined)?.['matrix-js-sdk'];
const clientVersion = (mx as unknown as { getSdkVersion?: () => string } | undefined)
?.getSdkVersion;
if (typeof clientVersion === 'function') {
try {
return clientVersion.call(mx) || declared || 'unknown';
} catch {
// fall through to the declared pin
}
}
return declared ?? 'unknown';
};
/**
* Build a self-contained JSON diagnostic report string. Contains only the SDK
* version, device id, user id, sync state, crypto readiness, and the captured
* KE signature buffer no message content, tokens, or other PII.
*/
export const buildCryptoDiagReport = (mx?: MatrixClient): string => {
const buffer = getCryptoDiagEntries();
const countsByKe: Record<string, number> = {};
buffer.forEach((entry) => {
countsByKe[entry.ke] = (countsByKe[entry.ke] ?? 0) + 1;
});
const report = {
kind: 'lotus-crypto-diag',
generatedAt: new Date().toISOString(),
sdkVersion: readSdkVersion(mx),
deviceId: mx?.getDeviceId() ?? null,
userId: mx?.getUserId() ?? null,
syncState: mx?.getSyncState() ?? null,
cryptoReady: Boolean(mx?.getCrypto()),
entryCount: buffer.length,
maxEntries: MAX_ENTRIES,
countsByKe,
entries: buffer,
};
return JSON.stringify(report, null, 2);
};
+83
View File
@@ -0,0 +1,83 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { splitMathSegments } from './mathParse';
test('plain text with no dollars is a single text segment', () => {
assert.deepEqual(splitMathSegments('hello world'), [{ type: 'text', value: 'hello world' }]);
});
test('empty string yields no segments', () => {
assert.deepEqual(splitMathSegments(''), []);
});
test('inline $…$ is extracted between surrounding text', () => {
assert.deepEqual(splitMathSegments('a $x^2$ b'), [
{ type: 'text', value: 'a ' },
{ type: 'inline', value: 'x^2' },
{ type: 'text', value: ' b' },
]);
});
test('block $$…$$ is extracted', () => {
assert.deepEqual(splitMathSegments('$$block$$'), [{ type: 'block', value: 'block' }]);
});
test('block math may span newlines', () => {
assert.deepEqual(splitMathSegments('$$\na=b\n$$'), [{ type: 'block', value: '\na=b\n' }]);
});
test('currency "$5 and $10" is NOT treated as math', () => {
assert.deepEqual(splitMathSegments('$5 and $10'), [{ type: 'text', value: '$5 and $10' }]);
});
test('escaped \\$ never opens or closes math', () => {
assert.deepEqual(splitMathSegments('cost \\$5 today'), [
{ type: 'text', value: 'cost $5 today' },
]);
assert.deepEqual(splitMathSegments('\\$x\\$'), [{ type: 'text', value: '$x$' }]);
});
test('unbalanced single $ stays as text', () => {
assert.deepEqual(splitMathSegments('price is $ here'), [
{ type: 'text', value: 'price is $ here' },
]);
});
test('unbalanced $$ stays as text', () => {
assert.deepEqual(splitMathSegments('$$x'), [{ type: 'text', value: '$$x' }]);
});
test('inline requires non-space adjacency on both delimiters', () => {
// Space right after opening $ -> not math.
assert.deepEqual(splitMathSegments('$ x$'), [{ type: 'text', value: '$ x$' }]);
// Space right before closing $ -> not math.
assert.deepEqual(splitMathSegments('$x $'), [{ type: 'text', value: '$x $' }]);
});
test('multiple inline spans on one line', () => {
assert.deepEqual(splitMathSegments('$a$ and $b$'), [
{ type: 'inline', value: 'a' },
{ type: 'text', value: ' and ' },
{ type: 'inline', value: 'b' },
]);
});
test('escaped dollar inside inline math is preserved in LaTeX', () => {
assert.deepEqual(splitMathSegments('$a\\$b$'), [{ type: 'inline', value: 'a\\$b' }]);
});
test('closing $ followed by a digit is skipped (currency guard) then recovers', () => {
// The first candidate closer is followed by `2` so it is skipped; the later
// `$` closes the span.
assert.deepEqual(splitMathSegments('$x$2 + y$'), [{ type: 'inline', value: 'x$2 + y' }]);
});
test('block and inline mixed with text', () => {
assert.deepEqual(splitMathSegments('see $$E=mc^2$$ and $a$ ok'), [
{ type: 'text', value: 'see ' },
{ type: 'block', value: 'E=mc^2' },
{ type: 'text', value: ' and ' },
{ type: 'inline', value: 'a' },
{ type: 'text', value: ' ok' },
]);
});
+136
View File
@@ -0,0 +1,136 @@
export type MathSegmentType = 'text' | 'inline' | 'block';
export type MathSegment = {
type: MathSegmentType;
/**
* For `text` segments this is the literal text. For `inline`/`block` segments
* this is the LaTeX source WITHOUT its surrounding `$`/`$$` delimiters.
*/
value: string;
};
/**
* Attempt to match an inline `$$` span starting at `start` (the index of the
* opening `$`).
*
* Conservative rules (chosen to keep false positives low for prose that merely
* mentions currency, e.g. `$5 and $10`):
* - The char immediately AFTER the opening `$` must exist, be non-space and not
* another `$` (a lone `$` before whitespace, or `$$`, never opens inline math).
* - The char immediately BEFORE the closing `$` must be non-space (so `x $` is
* not a valid close; we keep scanning for a better `$`).
* - The char immediately AFTER the closing `$` must not be a digit (so
* `$5 and $10` reads as currency, never math).
* - A backslash escapes the following char inside the span, so `\$` is not
* treated as a delimiter and stays part of the LaTeX.
* - Inline math may not span a newline.
* - The LaTeX content must be non-empty.
*/
const matchInline = (text: string, start: number): { value: string; end: number } | null => {
const nextChar = text[start + 1];
if (nextChar === undefined || /\s/.test(nextChar) || nextChar === '$') return null;
let j = start + 1;
while (j < text.length) {
const c = text[j];
if (c === '\\') {
// Skip the escaped char (covers `\$` inside the span).
j += 2;
continue;
}
if (c === '\n') return null;
if (c === '$') {
const prev = text[j - 1];
// Closing `$` must hug non-space; otherwise this `$` cannot close, keep scanning.
if (prev !== undefined && /\s/.test(prev)) {
j += 1;
continue;
}
const after = text[j + 1];
// A `$` directly followed by a digit is treated as currency, not a closer.
if (after !== undefined && /\d/.test(after)) {
j += 1;
continue;
}
const value = text.slice(start + 1, j);
if (value.length === 0) return null;
return { value, end: j + 1 };
}
j += 1;
}
return null;
};
/**
* Split a plain-text string into text/inline-math/block-math segments.
*
* Delimiter rules:
* - `$$$$` (possibly multi-line) is block math; the first following `$$` closes it.
* - `$$` is inline math, subject to the conservative adjacency rules in
* {@link matchInline}.
* - `\$` is an escaped literal dollar: it never acts as a delimiter and is
* emitted as a plain `$` in the surrounding text.
* - Any `$`/`$$` run that cannot be balanced is left verbatim as text.
*
* This is a PURE function used by the HTML parser to render math with KaTeX. It
* must never be applied to text inside `<pre>`/`<code>` (the caller guards that).
*/
export const splitMathSegments = (text: string): MathSegment[] => {
const segments: MathSegment[] = [];
let buffer = '';
let i = 0;
const flushText = () => {
if (buffer.length > 0) {
segments.push({ type: 'text', value: buffer });
buffer = '';
}
};
while (i < text.length) {
// Escaped dollar: consume `\$` and emit a literal `$` as text.
if (text[i] === '\\' && text[i + 1] === '$') {
buffer += '$';
i += 2;
continue;
}
// Block math `$$…$$`.
if (text.startsWith('$$', i)) {
const close = text.indexOf('$$', i + 2);
if (close !== -1) {
const value = text.slice(i + 2, close);
if (value.trim().length > 0) {
flushText();
segments.push({ type: 'block', value });
i = close + 2;
continue;
}
}
// Unbalanced/empty `$$` — emit a single `$` and continue scanning.
buffer += text[i];
i += 1;
continue;
}
// Inline math `$…$`.
if (text[i] === '$') {
const match = matchInline(text, i);
if (match) {
flushText();
segments.push({ type: 'inline', value: match.value });
i = match.end;
continue;
}
buffer += text[i];
i += 1;
continue;
}
buffer += text[i];
i += 1;
}
flushText();
return segments;
};
+5
View File
@@ -21,8 +21,13 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip
const latestEvent = getLatestValidEvent();
if (latestEvent === null) return;
// Unthreaded receipt: with client threadSupport enabled the SDK would
// otherwise scope this to the main timeline (thread_id: "main"), leaving
// per-thread notification counts permanently unread. Unthreaded preserves
// the pre-threads wire behavior — one receipt clears everything.
await mx.sendReadReceipt(
latestEvent,
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read,
true,
);
}
+57
View File
@@ -388,6 +388,63 @@ test('getUnreadInfo uses highlight when it exceeds total', () => {
assert.deepEqual(getUnreadInfo(room2), { roomId: '!r:y', highlight: 1, total: 7 });
});
const mockRoomWithThreadCounts = (
total: number,
highlight: number,
threadCounts: Record<string, { total: number; highlight: number }>,
): Room =>
({
roomId: '!r:x',
getUnreadNotificationCount: (type: NotificationCountType) =>
type === NotificationCountType.Total ? total : highlight,
getThreadUnreadNotificationCount: (threadId: string, type: NotificationCountType) =>
type === NotificationCountType.Total
? (threadCounts[threadId]?.total ?? 0)
: (threadCounts[threadId]?.highlight ?? 0),
}) as unknown as Room;
test('getUnreadInfo subtracts muted thread counts from room totals', () => {
const room = mockRoomWithThreadCounts(5, 2, { $t1: { total: 3, highlight: 1 } });
assert.deepEqual(getUnreadInfo(room, new Set(['$t1'])), {
roomId: '!r:x',
highlight: 1,
total: 2,
});
});
test('getUnreadInfo subtracts multiple muted threads', () => {
const room = mockRoomWithThreadCounts(9, 3, {
$t1: { total: 3, highlight: 1 },
$t2: { total: 2, highlight: 1 },
});
assert.deepEqual(getUnreadInfo(room, new Set(['$t1', '$t2'])), {
roomId: '!r:x',
highlight: 1,
total: 4,
});
});
test('getUnreadInfo clamps subtracted counts at zero', () => {
const room = mockRoomWithThreadCounts(2, 1, { $t1: { total: 5, highlight: 4 } });
assert.deepEqual(getUnreadInfo(room, new Set(['$t1'])), {
roomId: '!r:x',
highlight: 0,
total: 0,
});
});
test('getUnreadInfo leaves counts untouched without muted threads', () => {
const room = mockRoomWithThreadCounts(4, 1, { $t1: { total: 3, highlight: 1 } });
// undefined muted set (backward compat)
assert.deepEqual(getUnreadInfo(room), { roomId: '!r:x', highlight: 1, total: 4 });
// empty muted set is a no-op too
assert.deepEqual(getUnreadInfo(room, new Set<string>()), {
roomId: '!r:x',
highlight: 1,
total: 4,
});
});
// --- getRoomIconSrc -------------------------------------------------------
test('getRoomIconSrc selects icon by room type and join rule', () => {
+24 -5
View File
@@ -29,6 +29,7 @@ import {
StateEvent,
UnreadInfo,
} from '../../types/matrix/room';
import { getMutedThreads, ThreadNotificationsContent } from './threadNotifications';
export const getStateEvent = (
room: Room,
@@ -233,9 +234,23 @@ export const roomHaveUnread = (mx: MatrixClient, room: Room) => {
return true;
};
export const getUnreadInfo = (room: Room): UnreadInfo => {
const total = room.getUnreadNotificationCount(NotificationCountType.Total);
const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
export const getUnreadInfo = (room: Room, mutedThreads?: Set<string>): UnreadInfo => {
let total = room.getUnreadNotificationCount(NotificationCountType.Total);
let highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
// Server room totals INCLUDE per-thread notification counts, so subtract any
// explicitly muted thread's counts back out (clamped at zero) to keep muted
// threads from contributing to the room badge (P4-1).
if (mutedThreads && mutedThreads.size > 0) {
mutedThreads.forEach((threadId) => {
total -= room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Total) ?? 0;
highlight -=
room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Highlight) ?? 0;
});
if (total < 0) total = 0;
if (highlight < 0) highlight = 0;
}
return {
roomId: room.roomId,
highlight,
@@ -243,14 +258,18 @@ export const getUnreadInfo = (room: Room): UnreadInfo => {
};
};
export const getUnreadInfos = (mx: MatrixClient): UnreadInfo[] => {
export const getUnreadInfos = (
mx: MatrixClient,
content?: ThreadNotificationsContent,
): UnreadInfo[] => {
const unreadInfos = mx.getRooms().reduce<UnreadInfo[]>((unread, room) => {
if (room.isSpaceRoom()) return unread;
if (room.getMyMembership() !== 'join') return unread;
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) return unread;
if (roomHaveNotification(room) || roomHaveUnread(mx, room)) {
unread.push(getUnreadInfo(room));
const mutedThreads = content ? getMutedThreads(content, room.roomId) : undefined;
unread.push(getUnreadInfo(room, mutedThreads));
}
return unread;
+130
View File
@@ -0,0 +1,130 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
computeCoverage,
mergeSearchResults,
putRows,
queryRoom,
getCoverage,
saveRoomIndex,
clearRoom,
clearAll,
deleteSearchCacheDatabase,
SearchCacheRow,
} from './searchCache';
// --- Pure helpers: mergeSearchResults ---------------------------------------
type Item = { event: { event_id: string; origin_server_ts?: number } };
const item = (eventId: string, ts?: number): Item => ({
event: { event_id: eventId, origin_server_ts: ts },
});
test('mergeSearchResults: sorts by origin_server_ts descending', () => {
const out = mergeSearchResults([item('$a', 10), item('$b', 30), item('$c', 20)], []);
assert.deepEqual(
out.map((i) => i.event.event_id),
['$b', '$c', '$a'],
);
});
test('mergeSearchResults: dedupes by event_id with in-memory winning', () => {
const memory = [{ event: { event_id: '$dup', origin_server_ts: 5 }, tag: 'memory' }];
const cached = [
{ event: { event_id: '$dup', origin_server_ts: 5 }, tag: 'cached' },
{ event: { event_id: '$only', origin_server_ts: 9 }, tag: 'cached' },
];
const out = mergeSearchResults(memory, cached);
assert.equal(out.length, 2);
const dup = out.find((i) => i.event.event_id === '$dup');
assert.equal(dup?.tag, 'memory');
});
test('mergeSearchResults: cached-only hits are included', () => {
const out = mergeSearchResults<Item>([], [item('$c1', 1), item('$c2', 2)]);
assert.equal(out.length, 2);
});
test('mergeSearchResults: missing ts sorts as 0 (last)', () => {
const out = mergeSearchResults([item('$noTs'), item('$withTs', 100)], []);
assert.deepEqual(
out.map((i) => i.event.event_id),
['$withTs', '$noTs'],
);
});
// --- Pure helpers: computeCoverage ------------------------------------------
const row = (ts: number): Pick<SearchCacheRow, 'ts'> => ({ ts });
test('computeCoverage: derives oldest/newest from rows', () => {
const cov = computeCoverage('!r', [row(30), row(10), row(20)], 3);
assert.deepEqual(cov, { roomId: '!r', oldestTs: 10, newestTs: 30, count: 3 });
});
test('computeCoverage: widens the window against previous coverage', () => {
const prev = { roomId: '!r', oldestTs: 5, newestTs: 25, count: 2 };
const cov = computeCoverage('!r', [row(15), row(40)], 4, prev);
assert.equal(cov.oldestTs, 5); // previous oldest kept
assert.equal(cov.newestTs, 40); // batch newest wins
assert.equal(cov.count, 4); // authoritative count from caller
});
test('computeCoverage: empty rows with no previous yields zeroed window', () => {
const cov = computeCoverage('!r', [], 0);
assert.deepEqual(cov, { roomId: '!r', oldestTs: 0, newestTs: 0, count: 0 });
});
// --- IDB round-trip: skip when IndexedDB is unavailable (e.g. node --test) ---
const hasIdb = typeof indexedDB !== 'undefined';
test('searchCache IDB round-trip', { skip: !hasIdb }, async () => {
await clearAll();
const rows: SearchCacheRow[] = [
{ roomId: '!r1', eventId: '$1', ts: 100, sender: '@a', body: 'hello world' },
{
roomId: '!r1',
eventId: '$2',
ts: 200,
sender: '@b',
body: 'goodbye',
formattedBody: '<b>x</b>',
},
{ roomId: '!r2', eventId: '$3', ts: 300, sender: '@a', body: 'other room' },
];
await putRows(rows);
const r1 = await queryRoom('!r1');
assert.equal(r1.length, 2);
assert.deepEqual(r1.map((x) => x.eventId).sort(), ['$1', '$2']);
await saveRoomIndex(
'!r1',
rows.filter((x) => x.roomId === '!r1'),
);
const cov = await getCoverage('!r1');
assert.equal(cov?.count, 2);
assert.equal(cov?.oldestTs, 100);
assert.equal(cov?.newestTs, 200);
await clearRoom('!r1');
assert.equal((await queryRoom('!r1')).length, 0);
assert.equal((await queryRoom('!r2')).length, 1);
await deleteSearchCacheDatabase();
});
test('resilient helpers never throw when IDB is unavailable', { skip: hasIdb }, async () => {
// In this environment IndexedDB is absent; every call must degrade to a
// cache-miss rather than throwing.
await assert.doesNotReject(
putRows([{ roomId: '!r', eventId: '$1', ts: 1, sender: '@a', body: 'x' }]),
);
assert.deepEqual(await queryRoom('!r'), []);
assert.equal(await getCoverage('!r'), null);
await assert.doesNotReject(saveRoomIndex('!r', []));
await assert.doesNotReject(clearRoom('!r'));
await assert.doesNotReject(clearAll());
await assert.doesNotReject(deleteSearchCacheDatabase());
});
+308
View File
@@ -0,0 +1,308 @@
/**
* P4-8 persistent encrypted-search cache (raw IndexedDB, no new deps).
*
* The homeserver cannot search E2EE message content, so encrypted-room search
* only ever covers what the client has paginated + decrypted this session. This
* module persists a local plaintext index so coverage survives reloads.
*
* PRIVACY: this stores decrypted plaintext at rest. It is opt-in (default OFF),
* clearable, and wiped on logout via `deleteSearchCacheDatabase()`.
*
* Resilience contract: every entry point swallows IndexedDB errors and behaves
* as a cache-miss. Nothing here ever throws to the UI.
*/
const DB_NAME = 'lotus-search-cache';
const DB_VERSION = 1;
const MESSAGES_STORE = 'messages';
const COVERAGE_STORE = 'coverage';
const ROOM_TS_INDEX = 'roomTs';
/** A single cached, decrypted message row. Keyed on `[roomId, eventId]`. */
export type SearchCacheRow = {
roomId: string;
eventId: string;
ts: number;
sender: string;
body: string;
formattedBody?: string;
pollText?: string;
};
/** Per-room coverage stats for the "X / Y cached" UI counters. */
export type SearchCacheCoverage = {
roomId: string;
oldestTs: number;
newestTs: number;
count: number;
};
// A key range that matches every `[roomId, *]` entry in a composite-key store
// or `[roomId, ts]` index: an empty array sorts after all other key types, so
// `[roomId]` .. `[roomId, []]` brackets the whole room partition.
const roomRange = (roomId: string): IDBKeyRange => IDBKeyRange.bound([roomId], [roomId, []]);
let dbPromise: Promise<IDBDatabase | null> | null = null;
const openDb = (): Promise<IDBDatabase | null> => {
if (dbPromise) return dbPromise;
dbPromise = new Promise<IDBDatabase | null>((resolve) => {
try {
if (typeof indexedDB === 'undefined') {
resolve(null);
return;
}
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(MESSAGES_STORE)) {
const store = db.createObjectStore(MESSAGES_STORE, {
keyPath: ['roomId', 'eventId'],
});
store.createIndex(ROOM_TS_INDEX, ['roomId', 'ts']);
}
if (!db.objectStoreNames.contains(COVERAGE_STORE)) {
db.createObjectStore(COVERAGE_STORE, { keyPath: 'roomId' });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => {
dbPromise = null; // allow a later retry
resolve(null);
};
req.onblocked = () => {
dbPromise = null;
resolve(null);
};
} catch {
dbPromise = null;
resolve(null);
}
});
return dbPromise;
};
/** Resolve once a write transaction commits (or reject/abort → caller swallows). */
const awaitTx = (tx: IDBTransaction): Promise<void> =>
new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
/** Upsert message rows. No-op on empty input or when IDB is unavailable. */
export const putRows = async (rows: SearchCacheRow[]): Promise<void> => {
if (rows.length === 0) return;
const db = await openDb();
if (!db) return;
try {
const tx = db.transaction(MESSAGES_STORE, 'readwrite');
const store = tx.objectStore(MESSAGES_STORE);
rows.forEach((row) => store.put(row));
await awaitTx(tx);
} catch {
// Cache write failures must never surface to the UI.
}
};
/** All cached rows for a room, ordered oldest→newest by the `[roomId, ts]` index. */
export const queryRoom = async (roomId: string): Promise<SearchCacheRow[]> => {
const db = await openDb();
if (!db) return [];
try {
return await new Promise<SearchCacheRow[]>((resolve, reject) => {
const tx = db.transaction(MESSAGES_STORE, 'readonly');
const index = tx.objectStore(MESSAGES_STORE).index(ROOM_TS_INDEX);
const req = index.getAll(roomRange(roomId));
req.onsuccess = () => resolve((req.result as SearchCacheRow[]) ?? []);
req.onerror = () => reject(req.error);
});
} catch {
return [];
}
};
/** Cursor variant: stream a room's rows through a matcher, collecting hits. */
export const searchRoom = async (
roomId: string,
matcher: (row: SearchCacheRow) => boolean,
): Promise<SearchCacheRow[]> => {
const db = await openDb();
if (!db) return [];
try {
return await new Promise<SearchCacheRow[]>((resolve, reject) => {
const hits: SearchCacheRow[] = [];
const tx = db.transaction(MESSAGES_STORE, 'readonly');
const index = tx.objectStore(MESSAGES_STORE).index(ROOM_TS_INDEX);
const req = index.openCursor(roomRange(roomId));
req.onsuccess = () => {
const cursor = req.result;
if (!cursor) {
resolve(hits);
return;
}
const row = cursor.value as SearchCacheRow;
if (matcher(row)) hits.push(row);
cursor.continue();
};
req.onerror = () => reject(req.error);
});
} catch {
return [];
}
};
/** Number of cached rows for a room. */
export const countRoom = async (roomId: string): Promise<number> => {
const db = await openDb();
if (!db) return 0;
try {
return await new Promise<number>((resolve, reject) => {
const tx = db.transaction(MESSAGES_STORE, 'readonly');
const index = tx.objectStore(MESSAGES_STORE).index(ROOM_TS_INDEX);
const req = index.count(roomRange(roomId));
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
} catch {
return 0;
}
};
export const getCoverage = async (roomId: string): Promise<SearchCacheCoverage | null> => {
const db = await openDb();
if (!db) return null;
try {
return await new Promise<SearchCacheCoverage | null>((resolve, reject) => {
const tx = db.transaction(COVERAGE_STORE, 'readonly');
const req = tx.objectStore(COVERAGE_STORE).get(roomId);
req.onsuccess = () => resolve((req.result as SearchCacheCoverage) ?? null);
req.onerror = () => reject(req.error);
});
} catch {
return null;
}
};
export const putCoverage = async (coverage: SearchCacheCoverage): Promise<void> => {
const db = await openDb();
if (!db) return;
try {
const tx = db.transaction(COVERAGE_STORE, 'readwrite');
tx.objectStore(COVERAGE_STORE).put(coverage);
await awaitTx(tx);
} catch {
// ignore
}
};
/**
* Pure helper: fold a batch of rows into a coverage record, widening the
* `oldestTs`/`newestTs` window against any previous coverage. `count` is
* supplied by the caller (authoritative store count) so dedup across sessions
* is handled correctly. Exported for testing without IDB.
*/
export const computeCoverage = (
roomId: string,
rows: ReadonlyArray<Pick<SearchCacheRow, 'ts'>>,
count: number,
previous?: SearchCacheCoverage | null,
): SearchCacheCoverage => {
let oldestTs = previous?.oldestTs ?? Number.POSITIVE_INFINITY;
let newestTs = previous?.newestTs ?? Number.NEGATIVE_INFINITY;
rows.forEach((row) => {
if (row.ts < oldestTs) oldestTs = row.ts;
if (row.ts > newestTs) newestTs = row.ts;
});
if (!Number.isFinite(oldestTs)) oldestTs = 0;
if (!Number.isFinite(newestTs)) newestTs = 0;
return { roomId, oldestTs, newestTs, count };
};
/**
* Convenience persist path used by the search hook: upsert a batch of rows for
* a room, then recompute + store the room's coverage from the authoritative
* store count. Fire-and-forget; never throws.
*/
export const saveRoomIndex = async (roomId: string, rows: SearchCacheRow[]): Promise<void> => {
if (rows.length === 0) return;
await putRows(rows);
const [count, previous] = await Promise.all([countRoom(roomId), getCoverage(roomId)]);
await putCoverage(computeCoverage(roomId, rows, count, previous));
};
/**
* Pure helper: merge in-memory result items with cache-derived result items,
* deduping by `event.event_id` (in-memory wins), sorted by `origin_server_ts`
* descending. Generic over the minimal shape it reads so it is fully testable
* without matrix-js-sdk types. Exported for testing.
*/
export const mergeSearchResults = <
T extends { event: { event_id: string; origin_server_ts?: number } },
>(
memory: ReadonlyArray<T>,
cached: ReadonlyArray<T>,
): T[] => {
const byId = new Map<string, T>();
// Seed with cached, then let in-memory overwrite so in-memory always wins.
cached.forEach((item) => byId.set(item.event.event_id, item));
memory.forEach((item) => byId.set(item.event.event_id, item));
return Array.from(byId.values()).sort(
(a, b) => (b.event.origin_server_ts ?? 0) - (a.event.origin_server_ts ?? 0),
);
};
export const clearRoom = async (roomId: string): Promise<void> => {
const db = await openDb();
if (!db) return;
try {
const tx = db.transaction([MESSAGES_STORE, COVERAGE_STORE], 'readwrite');
tx.objectStore(MESSAGES_STORE).delete(roomRange(roomId));
tx.objectStore(COVERAGE_STORE).delete(roomId);
await awaitTx(tx);
} catch {
// ignore
}
};
export const clearAll = async (): Promise<void> => {
const db = await openDb();
if (!db) return;
try {
const tx = db.transaction([MESSAGES_STORE, COVERAGE_STORE], 'readwrite');
tx.objectStore(MESSAGES_STORE).clear();
tx.objectStore(COVERAGE_STORE).clear();
await awaitTx(tx);
} catch {
// ignore
}
};
/**
* Drop the entire on-disk database. Wired into the logout path by the
* coordinator (initMatrix) so no decrypted plaintext lingers after sign-out.
* Closes any open handle first so the delete is not blocked. Never throws.
*/
export const deleteSearchCacheDatabase = async (): Promise<void> => {
try {
const existing = dbPromise ? await dbPromise : null;
if (existing) existing.close();
} catch {
// ignore
}
dbPromise = null;
return new Promise<void>((resolve) => {
try {
if (typeof indexedDB === 'undefined') {
resolve();
return;
}
const req = indexedDB.deleteDatabase(DB_NAME);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
} catch {
resolve();
}
});
};
+11 -28
View File
@@ -1,25 +1,9 @@
import { MatrixClient } from 'matrix-js-sdk';
import { downloadMedia, mxcUrlToHttp } from './matrix';
/**
* [P5-15] A user-uploaded soundboard clip. Stored (as a list) in the
* `io.lotus.soundboard` account data event, so clips sync across a user's
* devices exactly like custom emoji / sticker packs.
*/
export type SoundboardClip = {
/** Stable local id (not shared with peers). */
id: string;
/** Display name / shortcode shown on the tile. */
name: string;
/** mxc:// URI of the uploaded audio. */
url: string;
mimetype?: string;
size?: number;
};
export type SoundboardContent = {
clips?: SoundboardClip[];
};
// [P5-15 v2] Shared media helpers for the soundboard. Clip storage/metadata now
// lives in the soundboard pack plugin (plugins/soundboard); this module only
// handles resolving an mxc clip for playback + local preview.
export const SOUNDBOARD_NAME_MAX = 24;
/** Keep clips short: they publish to every peer and hold a track open. */
@@ -53,20 +37,19 @@ export const resolveClipObjectUrl = async (mx: MatrixClient, mxcUrl: string): Pr
* Play a resolved clip locally so the person who pressed it gets immediate
* feedback LiveKit doesn't loop a participant's own published track back to
* them, so without this the presser would hear nothing. `volume` is 01.
* Returns the audio element so callers can track when it ends (or undefined if
* playback couldn't start).
*/
export const playClipLocally = (objectUrl: string, volume: number): void => {
export const playClipLocally = (
objectUrl: string,
volume: number,
): HTMLAudioElement | undefined => {
try {
const audio = new Audio(objectUrl);
audio.volume = Math.max(0, Math.min(1, volume));
audio.play().catch(() => undefined);
return audio;
} catch {
/* best effort */
return undefined;
}
};
export const readSoundboardClips = (mx: MatrixClient): SoundboardClip[] => {
const content = mx.getAccountData('io.lotus.soundboard' as never)?.getContent() as
| SoundboardContent
| undefined;
return Array.isArray(content?.clips) ? content.clips : [];
};
+260
View File
@@ -0,0 +1,260 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
getMutedThreads,
getThreadNotificationMode,
pruneThreadNotifications,
shouldNotifyThreadReply,
ThreadDefaultBehavior,
ThreadNotificationMode,
ThreadNotificationsContent,
ThreadNotifyDecision,
} from './threadNotifications';
const DAY = 24 * 60 * 60 * 1000;
const decide = (
overrides: Partial<Parameters<typeof shouldNotifyThreadReply>[0]>,
): ThreadNotifyDecision =>
shouldNotifyThreadReply({
mode: ThreadNotificationMode.Default,
defaultBehavior: 'participating',
participated: false,
highlight: false,
notify: false,
roomMuted: false,
...overrides,
});
describe('shouldNotifyThreadReply', () => {
it('roomMuted trumps everything, even mode All + highlight', () => {
assert.equal(
decide({ roomMuted: true, mode: ThreadNotificationMode.All, highlight: true }),
'none',
);
});
it('roomMuted trumps Default + participating + participated', () => {
assert.equal(decide({ roomMuted: true, participated: true }), 'none');
});
it('mode Mute is none regardless of highlight/participation', () => {
assert.equal(
decide({ mode: ThreadNotificationMode.Mute, highlight: true, participated: true }),
'none',
);
});
it('mode All + highlight => loud', () => {
assert.equal(decide({ mode: ThreadNotificationMode.All, highlight: true }), 'loud');
});
it('mode All + no highlight => notify', () => {
assert.equal(decide({ mode: ThreadNotificationMode.All, highlight: false }), 'notify');
});
it('mode MentionsOnly + highlight => loud', () => {
assert.equal(decide({ mode: ThreadNotificationMode.MentionsOnly, highlight: true }), 'loud');
});
it('mode MentionsOnly + no highlight => none (even if participated)', () => {
assert.equal(
decide({ mode: ThreadNotificationMode.MentionsOnly, highlight: false, participated: true }),
'none',
);
});
it('Default + behavior all + highlight => loud', () => {
assert.equal(decide({ defaultBehavior: 'all', highlight: true }), 'loud');
});
it('Default + behavior all + no highlight => notify', () => {
assert.equal(decide({ defaultBehavior: 'all', highlight: false }), 'notify');
});
it('Default + participating + highlight => loud (even if not participated)', () => {
assert.equal(
decide({ defaultBehavior: 'participating', highlight: true, participated: false }),
'loud',
);
});
it('Default + participating + no highlight + participated => notify', () => {
assert.equal(
decide({ defaultBehavior: 'participating', highlight: false, participated: true }),
'notify',
);
});
it('Default + participating + no highlight + not participated => none', () => {
assert.equal(
decide({ defaultBehavior: 'participating', highlight: false, participated: false }),
'none',
);
});
it('ignores the `notify` input entirely in v1', () => {
// notify=true must not upgrade a "none" decision.
assert.equal(
decide({ defaultBehavior: 'participating', participated: false, notify: true }),
'none',
);
// notify=false must not downgrade an "all" mode notify.
assert.equal(decide({ mode: ThreadNotificationMode.All, notify: false }), 'notify');
});
});
describe('getThreadNotificationMode', () => {
it('returns Default for undefined content', () => {
assert.equal(getThreadNotificationMode(undefined, '!r', '$t'), ThreadNotificationMode.Default);
});
it('returns Default when room or thread is absent', () => {
const content: ThreadNotificationsContent = {
rooms: { '!r': { $other: { mode: ThreadNotificationMode.All, ts: 1 } } },
};
assert.equal(getThreadNotificationMode(content, '!r', '$t'), ThreadNotificationMode.Default);
assert.equal(getThreadNotificationMode(content, '!x', '$t'), ThreadNotificationMode.Default);
});
it('returns the stored mode', () => {
const content: ThreadNotificationsContent = {
rooms: { '!r': { $t: { mode: ThreadNotificationMode.Mute, ts: 1 } } },
};
assert.equal(getThreadNotificationMode(content, '!r', '$t'), ThreadNotificationMode.Mute);
});
it('is safe against malformed entries', () => {
const bad = {
rooms: {
'!r': {
$badMode: { mode: 'nonsense', ts: 1 },
$noTs: { mode: ThreadNotificationMode.All },
$notObj: 'oops',
$nullEntry: null,
},
},
} as unknown as ThreadNotificationsContent;
assert.equal(getThreadNotificationMode(bad, '!r', '$badMode'), ThreadNotificationMode.Default);
assert.equal(getThreadNotificationMode(bad, '!r', '$noTs'), ThreadNotificationMode.Default);
assert.equal(getThreadNotificationMode(bad, '!r', '$notObj'), ThreadNotificationMode.Default);
assert.equal(
getThreadNotificationMode(bad, '!r', '$nullEntry'),
ThreadNotificationMode.Default,
);
});
it('is safe when rooms is not an object', () => {
const bad = { rooms: 'oops' } as unknown as ThreadNotificationsContent;
assert.equal(getThreadNotificationMode(bad, '!r', '$t'), ThreadNotificationMode.Default);
});
});
describe('getMutedThreads', () => {
it('returns empty set for undefined/absent room', () => {
assert.deepEqual(getMutedThreads(undefined, '!r'), new Set());
assert.deepEqual(getMutedThreads({ rooms: {} }, '!r'), new Set());
});
it('collects only Mute entries', () => {
const content: ThreadNotificationsContent = {
rooms: {
'!r': {
$a: { mode: ThreadNotificationMode.Mute, ts: 1 },
$b: { mode: ThreadNotificationMode.All, ts: 1 },
$c: { mode: ThreadNotificationMode.Mute, ts: 1 },
},
},
};
assert.deepEqual(getMutedThreads(content, '!r'), new Set(['$a', '$c']));
});
it('ignores malformed entries', () => {
const bad = {
rooms: { '!r': { $a: { mode: 'mute-ish', ts: 1 }, $b: null } },
} as unknown as ThreadNotificationsContent;
assert.deepEqual(getMutedThreads(bad, '!r'), new Set());
});
});
describe('pruneThreadNotifications', () => {
const now = 1_000_000_000_000;
it('drops rooms not in joinedRoomIds', () => {
const content: ThreadNotificationsContent = {
rooms: {
'!keep': { $t: { mode: ThreadNotificationMode.All, ts: now } },
'!left': { $t: { mode: ThreadNotificationMode.All, ts: now } },
},
};
const out = pruneThreadNotifications(content, new Set(['!keep']), now);
assert.deepEqual(Object.keys(out.rooms ?? {}), ['!keep']);
});
it('drops entries older than 180 days', () => {
const content: ThreadNotificationsContent = {
rooms: {
'!r': {
$fresh: { mode: ThreadNotificationMode.All, ts: now - 179 * DAY },
$old: { mode: ThreadNotificationMode.All, ts: now - 181 * DAY },
},
},
};
const out = pruneThreadNotifications(content, new Set(['!r']), now);
assert.deepEqual(Object.keys(out.rooms?.['!r'] ?? {}), ['$fresh']);
});
it('caps a room at 200 entries, evicting oldest ts first', () => {
const entries: Record<string, { mode: ThreadNotificationMode.All; ts: number }> = {};
// 205 entries, ts ascending with the id index.
for (let i = 0; i < 205; i += 1) {
entries[`$t${i}`] = { mode: ThreadNotificationMode.All, ts: now - (205 - i) * 1000 };
}
const content: ThreadNotificationsContent = { rooms: { '!r': entries } };
const out = pruneThreadNotifications(content, new Set(['!r']), now);
const kept = out.rooms?.['!r'] ?? {};
assert.equal(Object.keys(kept).length, 200);
// Oldest 5 ($t0..$t4) evicted; newest retained.
assert.equal(kept.$t0, undefined);
assert.equal(kept.$t4, undefined);
assert.notEqual(kept.$t5, undefined);
assert.notEqual(kept.$t204, undefined);
});
it('drops rooms left with no entries', () => {
const content: ThreadNotificationsContent = {
rooms: {
'!r': { $old: { mode: ThreadNotificationMode.All, ts: now - 200 * DAY } },
},
};
const out = pruneThreadNotifications(content, new Set(['!r']), now);
assert.equal(out.rooms, undefined);
});
it('preserves the default behavior field', () => {
const behavior: ThreadDefaultBehavior = 'all';
const content: ThreadNotificationsContent = { default: behavior, rooms: {} };
const out = pruneThreadNotifications(content, new Set(), now);
assert.equal(out.default, 'all');
});
it('never mutates the input', () => {
const content: ThreadNotificationsContent = {
default: 'participating',
rooms: {
'!r': { $t: { mode: ThreadNotificationMode.All, ts: now } },
'!left': { $t: { mode: ThreadNotificationMode.All, ts: now } },
},
};
const snapshot = JSON.parse(JSON.stringify(content));
const out = pruneThreadNotifications(content, new Set(['!r']), now);
assert.deepEqual(content, snapshot);
// Output room objects are fresh, not shared references with the input.
assert.notEqual(out.rooms?.['!r'], content.rooms?.['!r']);
});
it('handles malformed rooms container safely', () => {
const bad = { rooms: 'oops' } as unknown as ThreadNotificationsContent;
assert.deepEqual(pruneThreadNotifications(bad, new Set(['!r']), now), {});
});
});
+196
View File
@@ -0,0 +1,196 @@
// Per-thread notification modes (P4-1). Stored in the
// `io.lotus.thread_notifications` account data event. The functions in this
// module are PURE — they never touch React or matrix-js-sdk objects so they
// can be unit-tested in isolation and reused by the pipeline/UI agents.
export enum ThreadNotificationMode {
Default = 'default',
All = 'all',
MentionsOnly = 'mentions',
Mute = 'mute',
}
export type ThreadDefaultBehavior = 'all' | 'participating';
export type ThreadNotifyDecision = 'loud' | 'notify' | 'none';
export type ThreadNotificationEntry = {
mode: Exclude<ThreadNotificationMode, ThreadNotificationMode.Default>;
ts: number;
};
export type ThreadNotificationsContent = {
default?: ThreadDefaultBehavior;
rooms?: Record<string, Record<string, ThreadNotificationEntry>>;
};
// DEFAULT behavior when the user has not chosen a global default. Fixed to
// 'participating': notify only if the current user participated in the thread
// or the reply mentions them.
export const THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR: ThreadDefaultBehavior = 'participating';
// Entries older than this are pruned on write to keep account data bounded.
const PRUNE_MAX_AGE_MS = 180 * 24 * 60 * 60 * 1000;
// Maximum stored entries per room; oldest are evicted first.
const PRUNE_MAX_ENTRIES_PER_ROOM = 200;
const STORED_MODES: ReadonlySet<string> = new Set([
ThreadNotificationMode.All,
ThreadNotificationMode.MentionsOnly,
ThreadNotificationMode.Mute,
]);
const isStoredMode = (
value: unknown,
): value is Exclude<ThreadNotificationMode, ThreadNotificationMode.Default> =>
typeof value === 'string' && STORED_MODES.has(value);
const readEntry = (
content: ThreadNotificationsContent | undefined,
roomId: string,
threadRootId: string,
): ThreadNotificationEntry | undefined => {
const entry = content?.rooms?.[roomId]?.[threadRootId];
if (!entry || typeof entry !== 'object') return undefined;
if (!isStoredMode(entry.mode) || typeof entry.ts !== 'number') return undefined;
return entry;
};
/**
* Resolve the stored notification mode for a thread. Absent or malformed
* content resolves to `ThreadNotificationMode.Default`.
*/
export function getThreadNotificationMode(
content: ThreadNotificationsContent | undefined,
roomId: string,
threadRootId: string,
): ThreadNotificationMode {
const entry = readEntry(content, roomId, threadRootId);
return entry ? entry.mode : ThreadNotificationMode.Default;
}
/**
* All thread root ids explicitly muted within a room. Malformed content yields
* an empty set.
*/
export function getMutedThreads(
content: ThreadNotificationsContent | undefined,
roomId: string,
): Set<string> {
const muted = new Set<string>();
const roomEntries = content?.rooms?.[roomId];
if (!roomEntries || typeof roomEntries !== 'object') return muted;
Object.keys(roomEntries).forEach((threadRootId) => {
const entry = roomEntries[threadRootId];
if (entry && isStoredMode(entry.mode) && entry.mode === ThreadNotificationMode.Mute) {
muted.add(threadRootId);
}
});
return muted;
}
/**
* Decide whether a thread reply should notify.
*
* NOTE: the `notify` input reflects the base matrix push rule outcome and is
* accepted for forward-compatibility, but is intentionally IGNORED in v1: the
* per-thread mode fully determines the decision, so honoring `notify` would let
* server push rules silently override an explicit "All" thread override. Kept
* in the signature so the pipeline can start plumbing it without a later break.
*/
export function shouldNotifyThreadReply(input: {
mode: ThreadNotificationMode;
defaultBehavior: ThreadDefaultBehavior;
participated: boolean;
highlight: boolean;
notify: boolean;
roomMuted: boolean;
}): ThreadNotifyDecision {
const { mode, defaultBehavior, participated, highlight, roomMuted } = input;
if (roomMuted) return 'none';
if (mode === ThreadNotificationMode.Mute) return 'none';
if (mode === ThreadNotificationMode.All) {
return highlight ? 'loud' : 'notify';
}
if (mode === ThreadNotificationMode.MentionsOnly) {
return highlight ? 'loud' : 'none';
}
// ThreadNotificationMode.Default
if (defaultBehavior === 'all') {
return highlight ? 'loud' : 'notify';
}
// defaultBehavior === 'participating'
if (highlight) return 'loud';
return participated ? 'notify' : 'none';
}
/**
* Return a NEW content object with stale/oversized data removed. Never mutates
* the input.
*
* (1) drop rooms not in `joinedRoomIds`
* (2) drop entries older than 180 days (`ts < now - PRUNE_MAX_AGE_MS`)
* (3) cap each room at 200 entries, evicting the oldest `ts` first
* (4) drop rooms left with no entries
*/
export function pruneThreadNotifications(
content: ThreadNotificationsContent,
joinedRoomIds: Set<string>,
now: number,
): ThreadNotificationsContent {
const minTs = now - PRUNE_MAX_AGE_MS;
const pruned: ThreadNotificationsContent = {};
if (content.default !== undefined) {
pruned.default = content.default;
}
const rooms = content.rooms;
if (!rooms || typeof rooms !== 'object') {
return pruned;
}
const prunedRooms: Record<string, Record<string, ThreadNotificationEntry>> = {};
Object.keys(rooms).forEach((roomId) => {
if (!joinedRoomIds.has(roomId)) return;
const roomEntries = rooms[roomId];
if (!roomEntries || typeof roomEntries !== 'object') return;
// Keep only well-formed, non-expired entries.
const kept: Array<[string, ThreadNotificationEntry]> = [];
Object.keys(roomEntries).forEach((threadRootId) => {
const entry = roomEntries[threadRootId];
if (!entry || !isStoredMode(entry.mode) || typeof entry.ts !== 'number') return;
if (entry.ts < minTs) return;
kept.push([threadRootId, { mode: entry.mode, ts: entry.ts }]);
});
if (kept.length === 0) return;
// Cap per room, evicting oldest first (ascending ts sort, keep the tail).
let capped = kept;
if (kept.length > PRUNE_MAX_ENTRIES_PER_ROOM) {
capped = [...kept]
.sort((a, b) => a[1].ts - b[1].ts)
.slice(kept.length - PRUNE_MAX_ENTRIES_PER_ROOM);
}
const nextRoom: Record<string, ThreadNotificationEntry> = {};
capped.forEach(([threadRootId, entry]) => {
nextRoom[threadRootId] = entry;
});
prunedRooms[roomId] = nextRoom;
});
if (Object.keys(prunedRooms).length > 0) {
pruned.rooms = prunedRooms;
}
return pruned;
}
+9
View File
@@ -6,6 +6,7 @@ import { getFallbackSession, removeFallbackSession, Session } from '../app/state
import { LotusOidcTokenRefresher } from './oidcTokenRefresher';
import { revokeOidcTokens } from './oidcLogout';
import { pushSessionToSW } from '../sw-session';
import { deleteSearchCacheDatabase } from '../app/utils/searchCache';
// Thrown when the local IndexedDB has a higher schema version than this SDK expects.
// This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted).
@@ -63,6 +64,11 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
export const startClient = async (mx: MatrixClient) => {
await mx.startClient({
lazyLoadMembers: true,
// P3-8: partition m.thread relations into Thread objects/timelines. Thread
// replies leave the main timeline (roots stay + get a summary chip); the
// thread panel renders them. markAsRead sends UNTHREADED receipts
// (utils/notifications.ts) so room badges keep clearing.
threadSupport: true,
});
};
@@ -87,6 +93,9 @@ export const logoutClient = async (mx: MatrixClient) => {
// ignore if failed to logout
}
await mx.clearStores();
// The opt-in local search index stores decrypted plaintext — always wipe it
// on logout. (clearLoginData below nukes all IDB databases, covering it too.)
await deleteSearchCacheDatabase();
// Remove only the session credential keys, preserving user preferences and
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
removeFallbackSession();
+33 -1
View File
@@ -1,7 +1,39 @@
/// <reference lib="WebWorker" />
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
export type {};
declare const self: ServiceWorkerGlobalScope;
declare const self: ServiceWorkerGlobalScope & {
// Replaced at build time by vite-plugin-pwa (injectManifest) with the list of
// hashed build assets to precache. See vite.config.js VitePWA injectManifest.
__WB_MANIFEST: Array<string | PrecacheEntry>;
};
/**
* PRECACHE (workbox-precaching). `self.__WB_MANIFEST` is replaced at build time
* by vite-plugin-pwa with the list of hashed build assets
* (assets/**\/*.{js,css,wasm}; see vite.config.js injectManifest.globPatterns).
*
* DEPLOY-SAFETY INVARIANTS (do not break):
* 1. index.html / navigations are NEVER precached or precache-routed. The
* manifest globs only `assets/**` (content-hashed), so index.html (served
* from the app root) is absent from it and navigation requests fall through
* to the network a new deploy is picked up immediately, no stale SPA
* shell. We deliberately do NOT register a navigation route /
* createHandlerBoundToURL fallback.
* 2. precacheAndRoute only matches its own manifest URLs (same-origin hashed
* assets). It never matches the media-auth paths handled by the fetch
* listener below those are cross-origin homeserver URLs absent from the
* manifest so the existing media fetch behaviour is fully preserved. It
* is registered before that listener; for a media request the precache
* route finds no match and does not call respondWith, so the media handler
* still runs.
* 3. Assets are content-hashed, so a changed asset ships under a new filename;
* PrecacheController drops entries no longer in the current manifest on
* activate, so the precache self-updates each deploy without unbounded
* growth.
*/
precacheAndRoute(self.__WB_MANIFEST);
type SessionInfo = {
accessToken: string;
+9 -2
View File
@@ -10,9 +10,16 @@ export enum AccountDataEvent {
PoniesUserEmotes = 'im.ponies.user_emotes',
PoniesEmoteRooms = 'im.ponies.emote_rooms',
// [P5-15] Personal, uploadable in-call soundboard clips (synced across
// devices like custom emoji/sticker packs).
// [P5-15] Personal soundboard pack (synced across devices). v2 content is a
// SoundboardContent pack ({pack, clips}); v1 was {clips: [...]} (migrated on read).
LotusSoundboard = 'io.lotus.soundboard',
// [P5-15 v2] Global refs: room soundboard packs the user enabled everywhere
// (mirrors im.ponies.emote_rooms).
LotusSoundboardRooms = 'io.lotus.soundboard_rooms',
// [P4-1] Per-thread notification mode overrides (All/Mentions/Mute) plus the
// global default behavior for threads.
LotusThreadNotifications = 'io.lotus.thread_notifications',
SecretStorageDefaultKey = 'm.secret_storage.default_key',
+3
View File
@@ -42,6 +42,9 @@ export enum StateEvent {
PowerLevelTags = 'in.cinny.room.power_level_tags',
LotusVoiceLimit = 'io.lotus.voice_limit',
LotusRoomQuality = 'io.lotus.room_quality',
// [P5-15 v2] Room/Space soundboard pack (mirrors PoniesRoomEmotes). Per
// state-key, aggregated with parent-space packs like custom emoji.
LotusSoundboardRoom = 'io.lotus.soundboard',
}
export enum MessageEvent {
+28 -4
View File
@@ -59,9 +59,21 @@ function copyPdfWorker() {
return {
name: 'copy-pdf-worker',
closeBundle() {
const src = path.resolve('node_modules/pdfjs-dist/build/pdf.worker.min.mjs');
const dest = path.resolve('dist/pdf.worker.min.js');
if (fs.existsSync(src)) fs.copyFileSync(src, dest);
// Never throw from here: closeBundle also runs when the build FAILED
// mid-render (dist/ absent) and an exception here MASKS the real build
// error in vite's report (seen on the Windows CI runner). Warn and skip.
try {
const src = path.resolve('node_modules/pdfjs-dist/build/pdf.worker.min.mjs');
const dest = path.resolve('dist/pdf.worker.min.js');
if (!fs.existsSync(src)) {
console.warn('[copy-pdf-worker] source worker missing, skipped:', src);
return;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
} catch (err) {
console.warn('[copy-pdf-worker] skipped:', err?.message ?? err);
}
},
};
}
@@ -249,7 +261,19 @@ export default defineConfig({
injectRegister: false,
manifest: false,
injectManifest: {
injectionPoint: undefined,
// PRECACHE (P5): emit `self.__WB_MANIFEST` into src/sw.ts so it can
// precacheAndRoute the hashed build assets. index.html is deliberately
// EXCLUDED from the manifest (globs only `assets/**`) so navigations
// stay network-first and a new deploy is picked up immediately — see
// the deploy-safety invariants documented in src/sw.ts.
injectionPoint: 'self.__WB_MANIFEST',
globPatterns: ['assets/**/*.{js,css,wasm}'],
// Assets are content-hashed, so the filename is the cache key — don't
// append a revision cache-busting param.
dontCacheBustURLsMatching: /assets\//,
// Raised above the 2 MB default so the ~5.5 MB matrix-sdk crypto wasm
// (hash-busted and hot on every session) is precached deliberately.
maximumFileSizeToCacheInBytes: 6 * 1024 * 1024,
// codeSplitting: false is not yet supported by vite-plugin-pwa 1.3.0;
// the inlineDynamicImports deprecation warning from Vite is from pwa internal build
},