Compare commits

..
11 Commits
Author SHA1 Message Date
jaredandClaude Opus 4.8 4d0e34c4cf docs(bugs): mark N118 acknowledged (inherent EC-DOM fragility, documented)
CI / Build & Quality Checks (push) Successful in 1h1m1s
CI / Trigger Desktop Build (push) Successful in 9s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 12:37:34 -04:00
jaredandClaude Opus 4.8 70ffd252bd docs(bugs): mark N100/N106/N109/N119 FIXED
CI / Build & Quality Checks (push) Failing after 30m49s
CI / Trigger Desktop Build (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 12:35:35 -04:00
jaredandClaude Opus 4.8 51d468fbcc fix(security,notifications): pre class allowlist, notification privacy + icon, sync-script safety (N100/N106/N109/N119)
- N100: restrict <pre> classes to language-* in sanitize-html allowedClasses;
  previously `class` was allowed on <pre> with no allowedClasses entry, so a
  remote sender could inject arbitrary class names that activate site CSS.
- N106: OS notifications for E2EE rooms no longer carry decrypted plaintext
  (which persists in the OS notification center / lock screen). Encrypted rooms
  show only the sender; the in-page toast still previews while focused.
- N109: OS notification icon/badge use the static app logo instead of an
  authenticated-media avatar URL the OS can't fetch (was 401 / no icon). The
  in-app toast keeps the real room avatar (it can fetch via the SW).
- N119: syncDecorations.mjs distinguishes a confirmed 404 (remove) from a
  network/5xx failure (abort) so a transient CDN outage can't silently wipe the
  whole decoration catalog from source control.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 12:35:33 -04:00
jaredandClaude Opus 4.8 1c84556600 docs(bugs): mark N98/N99 FIXED
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 11:27:39 -04:00
jaredandClaude Opus 4.8 34997bcbd1 fix(client): preserve prefs on logout; recover from initial-sync failure (N98/N99)
- N98: logoutClient and handleLogout now call removeFallbackSession() (removes
  only the 4 session credential keys) instead of window.localStorage.clear(),
  so settings, unsent drafts, PiP position, and status are preserved across a
  normal logout. localStorage.clear() stays reserved for clearLoginData() (the
  explicit factory-reset path).
- N99: the useSyncState callback now handles ERROR/STOPPED. A sync failure
  before the first PREPARED (offline at startup, homeserver unreachable) shows
  a dedicated error splash with a Retry button (startMatrix) instead of an
  endless "Heating up" spinner alongside a contradictory "Connection Lost!"
  banner. Guarded by a hasPreparedRef so post-PREPARED transient errors still
  go through <SyncStatus>; PREPARED self-heals the splash on recovery, and the
  redundant banner is suppressed while the splash is shown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 11:27:36 -04:00
jaredandClaude Opus 4.8 78cb2acd6c docs(bugs): mark N116/N117/N120/N124/N125/N128 FIXED
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 10:56:10 -04:00
jaredandClaude Opus 4.8 ce8a03ab16 fix(build,denoise): gate node leak, postMessage origin, fail-hard patch, CDN dedup (N124/N125/N128/N120)
- N124: denoise shim cleanup() now disconnects the noise gate AudioWorkletNode
  (var-scoped, guarded), releasing the gate processor thread instead of leaking
  it on every getUserMedia within a session.
- N125: denoise-status postMessage now targets the parent origin (derived from
  the parentUrl widget param via new URL(...).origin, falling back to this
  frame's origin) instead of broadcasting with '*'.
- N128: patch-folds.mjs fails hard (process.exit(1)) when the patch target is
  missing, so an unpatched folds can't silently ship. The idempotent
  "already applied" path still exits 0 (verified by re-run).
- N120: the avatar-decoration CDN URL is now single-sourced in
  avatarDecorations.ts (DECORATION_CDN); syncDecorations.mjs extracts it by
  regex (can't import across the build/app boundary) and fails hard if renamed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 10:55:19 -04:00
jaredandClaude Opus 4.8 19feca4964 fix(calls): make speaker detection scan full DOM via body observer (N116/N117)
useCallSpeakers rebuilt the speaker Set from only the mutated tiles in each
batch (so a still-speaking participant whose tile didn't mutate was dropped),
and observed a static querySelectorAll NodeList (so tiles for participants who
joined mid-call were never watched). Rewritten to mirror useRemoteAllMuted in
the same file: a single body-level MutationObserver (subtree+childList+attrs)
re-scans ALL [data-video-fit] tiles on each relevant mutation. The speaking
criterion (::before background-image !== 'none') and the id (aria-label +
isUserId) are unchanged, so behavior on real EC DOM is a strict superset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 10:55:19 -04:00
jaredandClaude Opus 4.8 adbda094e7 docs(bugs): mark N113/N114/N115/N122/N123/N126 FIXED
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 09:18:52 -04:00
jaredandClaude Opus 4.8 7013da70bc fix(reminders): RMW race, reliable removal, stable poll interval (N113/N114/N115)
- N113: mutations compute from a local ref kept in sync with server echoes, and
  writes serialize through a promise queue, so rapid add/remove no longer reads
  a stale baseline and clobbers a prior write.
- N114: ReminderMonitor shows each toast once (firedRef) but retries the
  account-data removal on later ticks if it fails (removingRef released on
  error) — a failed removal no longer permanently swallows the reminder.
- N115: the 30s poll interval reads reminders/mDirects via refs and drops them
  from the effect deps, so it's created once instead of resetting its countdown
  on every reminder sync (which could indefinitely defer a near-due reminder).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 09:17:19 -04:00
jaredandClaude Opus 4.8 49d9410e3a fix(calls): resolve EC mute hang, robust camera focus, PiP NaN guard (N122/N123/N126)
- N122: setMediaState resolves on EC's transport ACK instead of waiting for a
  DeviceMute state-echo that EC may elide or skip during teardown — which
  previously stranded the promise forever and silently skipped the initial
  deafen state + first StateUpdate on join. Dropped the single-slot
  mediaStatePromiseResolver; onMediaState remains the authoritative sync path.
- N123: focusCameraParticipant now waits for a spotlight videoTile to mount via
  a MutationObserver (with a 600ms hard-timeout fallback) instead of a fixed
  2-frame delay that EC's React commit can exceed on slower devices.
- N126: PiP position restored from localStorage is shape+finiteness validated,
  so corrupt data can't feed NaN into the position math (invalid 'NaNpx' CSS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 09:17:19 -04:00
13 changed files with 404 additions and 130 deletions
+63 -35
View File
@@ -519,6 +519,7 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
---
**N105 — Missing SW `notificationclick` handler: notification clicks broken when tab is closed**
- **File:** `src/sw.ts` (handler entirely absent); `src/app/pages/client/ClientNonUIFeatures.tsx`, lines 151–155 (`InviteNotifications`) and 277–284 (`MessageNotifications`)
- **Status:** **OPEN** [Claude_Found]
- **Issue:** All notification click handling is wired via `noti.onclick` in the main thread (`noti.onclick = () => { navigate(...); noti.close(); }`). This callback only fires while the originating tab is open and its JavaScript is running. When the browser has no open tabs for the app (or the tab is suspended/backgrounded), clicking an OS notification does nothing — there is no SW `notificationclick` handler to focus an existing window or open a new one and navigate to the correct room.
@@ -528,8 +529,9 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
---
**N106 — Decrypted E2EE message plaintext leaked to OS notification center**
- **File:** `src/app/pages/client/ClientNonUIFeatures.tsx`, line 343
- **Status:** **OPEN** [Claude_Found]
- **Status:** **FIXED** (`51d468fb`) — OS notification body for encrypted rooms shows sender only (no decrypted plaintext); in-page toast still previews while focused.
- **Issue:** The `MessageNotifications` component passes `mEvent.getContent().body` directly as the notification body: `body: (mEvent.getContent().body as string | undefined) ?? ''`. By the time `RoomEvent.Timeline` fires, `matrix-js-sdk` has already decrypted the event in memory. The fully decrypted plaintext is then handed to `new window.Notification()`, which stores it in the OS notification center. This plaintext is visible on the device lock screen (if notification previews are enabled), in the OS notification history, and may be read by any app with `READ_NOTIFICATIONS` permission (e.g., accessibility services, backup apps) — even when the room uses end-to-end encryption. The 120-character slice (`slice(0, 120)`) does not mitigate this.
- **Root Cause:** No distinction is made between encrypted and unencrypted rooms when constructing notification bodies. There is no check such as `mEvent.isEncrypted()` or `room.hasEncryptionStateEvent()` that would substitute a generic body.
- **Fix:** Check whether the room is encrypted before populating the body. For encrypted rooms, use a generic string (e.g., `"New encrypted message"`) as the body instead of the decrypted content. If message previews in notifications are intentionally desired by the user, gate them behind an explicit opt-in setting that warns about OS-level plaintext exposure.
@@ -537,6 +539,7 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
---
**N107 — SW has no `push` event handler: Web Push delivery is completely broken**
- **File:** `src/sw.ts` (handler entirely absent)
- **Status:** **OPEN** [Claude_Found]
- **Issue:** The service worker never registers a `push` event listener. If a Matrix push gateway (e.g., Sygnal) is ever configured and sends a Web Push notification, the SW silently discards the push event — no notification is shown, no in-app routing occurs. The absence of a `push` handler means the entire background-notification path (i.e., notifications when no tab is open) is non-functional, which is one of the primary requirements for a PWA.
@@ -546,6 +549,7 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
---
**N108 — No maskable icon in PWA manifest: Android adaptive icons display incorrectly**
- **File:** `public/manifest.json`, lines 12–57
- **Status:** **OPEN** [Claude_Found]
- **Issue:** The manifest lists nine `android-chrome-*.png` icons (36 × 36 through 512 × 512) but none include `"purpose": "maskable"`. Android 8+ adaptive icons apply a platform-defined shape mask (circle, squircle, teardrop, etc.) to PWA home-screen icons. Without a maskable-purpose icon, the OS either adds a white square background to prevent clipping or applies the mask directly to the regular icon, typically cropping the Lotus logo in a visually incorrect way.
@@ -555,13 +559,13 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
---
**N109 — Authenticated media URLs passed to `Notification` icon/badge: OS cannot fetch them (produces 401)**
- **File:** `src/app/pages/client/ClientNonUIFeatures.tsx`, lines 333–339 and 270–273
- **Status:** **OPEN** [Claude_Found]
- **Status:** **FIXED** (`51d468fb`) — OS notification icon/badge use static LogoSVG instead of an authenticated avatar URL the OS can't fetch; toast keeps the room avatar.
- **Issue:** When the homeserver requires authenticated media (Matrix spec v1.11+, path `/_matrix/client/v1/media/download/...`), `mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop')` returns an authenticated URL. That URL is then passed directly as `icon` and `badge` to `new window.Notification()`. The OS/browser notification subsystem fetches `icon` and `badge` URLs directly — outside the page's JavaScript context — so the service worker's `fetch` handler never fires for them (the SW only intercepts fetches with a valid `event.clientId`, which these OS-initiated fetches lack). The homeserver returns HTTP 401, and the notification shows no icon or badge.
- **Root Cause:** The SW auth-header injection is designed for page-initiated `/_matrix/client/v1/media/` fetches. It does not (and cannot) intercept fetches made by the OS notification subsystem. Room avatar URLs are passed to `Notification` without first converting them to an auth-agnostic form.
- **Fix:** Before creating a `Notification`, fetch the avatar URL in-page (via the existing authenticated fetch path where the SW can inject headers), convert the response to a Blob URL (`URL.createObjectURL(blob)`), and pass the Blob URL as `icon`/`badge`. Alternatively, skip the avatar for notifications entirely and use the static app logo (already done for invite notifications via `LogoSVG`) to avoid the authenticated-media complexity.
## 🌸 Lotus Feature Internals Audit (Wave 2)
> Deep audit of Lotus-specific hook internals, build scripts, and the avatar-decoration pipeline. All findings below are **[Claude_Found]**.
@@ -569,8 +573,9 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
---
**N113 — `addReminder`/`removeReminder` Read-Modify-Write Race Condition**
- **File:** `src/app/hooks/useReminders.ts`, lines 52–68
- **Status:** **OPEN** [Claude_Found]
- **Status:** **FIXED** (`7013da70`) — mutations compute from a ref synced to server echoes; writes serialized via a promise queue.
- **Issue:** Both `addReminder` and `removeReminder` call `readReminders(mx)` — a synchronous read from the Matrix client's local account-data cache — and then fire `setAccountData` asynchronously. If two calls overlap before either write has committed and the local cache updated (e.g. a user quickly adds two reminders, or adds one while a removal is in flight), both calls read the same stale baseline and the second write silently overwrites the first. Example: adding R1 and R2 in quick succession → both calls read `[]`, write `[R1]` and `[R2]` respectively → only R2 survives, R1 is lost.
- **Root Cause:** No optimistic locking, no serial queue, and the read source (`mx.getAccountData()`) does not reflect uncommitted in-flight writes.
- **Fix:** Use the React `reminders` state (passed as a parameter or captured in a `useRef`) as the source of truth for mutations instead of re-reading from the client cache. Alternatively, serialize writes through a promise queue so each `addReminder`/`removeReminder` awaits the previous `setAccountData` before computing the next state.
@@ -578,8 +583,9 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
---
**N114 — `ReminderMonitor` Calls `removeReminder` Fire-and-Forget; Network Failure Silently Drops the Reminder**
- **File:** `src/app/pages/client/ClientNonUIFeatures.tsx`, lines 399, 413–414
- **Status:** **OPEN** [Claude_Found]
- **Status:** **FIXED** (`7013da70`) — toast fires once (firedRef); removal retried on later ticks via removingRef, released on failure.
- **Issue:** Inside `ReminderMonitor.check()`, when a reminder fires the code immediately does `firedRef.current.add(key)` and then calls `removeReminder(r.eventId, r.timestamp)` without `await` and without a `.catch()` handler. If `removeReminder` fails (network error, 429 rate-limit, homeserver down), the reminder remains in account data but is permanently blocked from re-firing this session because its key is already in `firedRef`. The user's reminder is silently swallowed for the rest of the session; only a full page reload recovers it.
- **Root Cause:** The promise returned by `removeReminder` is discarded. There is no error path that rolls back `firedRef.current` or reschedules the reminder for retry.
- **Fix:** Make `check` an `async` function (or add a `.catch()` on the call), and only add to `firedRef` after `removeReminder` succeeds. On failure, omit the `firedRef` add so the reminder retries on the next poll tick.
@@ -587,8 +593,9 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
---
**N115 — `ReminderMonitor` 30 s Poll Interval Is Reset on Every `reminders` State Change, Delaying Near-Due Reminders**
- **File:** `src/app/pages/client/ClientNonUIFeatures.tsx`, lines 394–428
- **Status:** **OPEN** [Claude_Found]
- **Status:** **FIXED** (`7013da70`) — reminders/mDirects read via refs; dropped from effect deps so the 30s interval is created once.
- **Issue:** `reminders` is listed in the `useEffect` dependency array (`}, [mx, reminders, setToast, removeReminder, mDirects]`). Every time a reminder is added, removed, or synced back from the server, React tears down the effect (clearing `setInterval`) and re-creates it, resetting the 30 s countdown from zero. A reminder due 1 s from now will not fire for up to 30 s if a reminder state change occurs 0.5 s before the due time — for instance, when the server's account-data echo arrives and updates `reminders`. In the worst case, rapid add/remove cycles can continuously defer the poll indefinitely (as long as new mutations keep arriving faster than 30 s).
- **Root Cause:** `check()` closes over `reminders`, requiring it as a dependency; but the interval itself does not need to be recreated on every reminder change — only the closure does.
- **Fix:** Store the latest `reminders` value in a `useRef` updated on each render, and read from the ref inside `check()`. Remove `reminders` from the `useEffect` dependency array. The interval is then created once per `mx`/handler change, and `check()` always sees the current snapshot via the ref.
@@ -596,8 +603,9 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
---
**N116 — `useCallSpeakers` Speaker Set Rebuilt From Mutation Batch Only — All Other Speaking Participants Are Dropped**
- **File:** `src/app/hooks/useCallSpeakers.ts`, lines 20–44
- **Status:** **OPEN** [Claude_Found]
- **Status:** **FIXED** (`19feca49`) — body-level MutationObserver rescans all `[data-video-fit]` tiles each mutation (mirrors `useRemoteAllMuted`); no longer drops still-speaking participants.
- **Issue:** The `MutationObserver` callback builds a fresh `Set<string>` from only the tiles present in the current mutation batch, then calls `setSpeakers(s)`. If participant A has been speaking for 10 s but their tile has not mutated recently, and participant B's tile mutates for an unrelated reason (e.g. a class change), the batch contains only B's tile. Even if B is not speaking, `s` is empty and `setSpeakers(s)` replaces the entire state — A disappears from the speakers set despite still speaking. The result is a constantly-flickering or always-empty speakers indicator.
- **Root Cause:** Speaker state is derived from the delta (mutation batch) rather than the full current DOM state. Compare with `useRemoteAllMuted.syncState()` in the same file, which correctly re-scans all `[data-muted]` elements on every mutation rather than looking only at the mutated ones.
- **Fix:** Replace the per-batch iteration with a full re-scan of all observed tiles on each callback: iterate all elements in `videoContainers`, check each for the `::before` speaking indicator, and build the new `Set` from currently-speaking tiles — not just the mutated ones.
@@ -605,8 +613,9 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
---
**N117 — `useCallSpeakers` Static `querySelectorAll` NodeList Misses Video Tiles Added to EC DOM Mid-Call**
- **File:** `src/app/hooks/useCallSpeakers.ts`, lines 14–17
- **Status:** **OPEN** [Claude_Found]
- **Status:** **FIXED** (`19feca49`) — replaced the static NodeList + per-tile observers with a single body-level observer, so mid-call tiles are covered.
- **Issue:** `callEmbed.document?.querySelectorAll('[data-video-fit]')` returns a static `NodeList` snapshot at the instant the `useMemo` evaluates. When a new participant joins mid-call and EC renders their video tile, that tile is not in the captured list. No `MutationObserver` is ever attached to the new tile, so the new participant can never be detected as a speaker for the remainder of the call. `callMembers` is a memo dependency and does update on join/leave, but there is a timing gap: `callMembers` may change before EC has finished rendering the new tile inside the iframe, so `querySelectorAll` at that moment still does not find the new tile.
- **Root Cause:** Observing a static snapshot of tiles does not compose with EC's dynamically-updating DOM. `useRemoteAllMuted` avoids this entirely by watching `doc.body` with `{ subtree: true, childList: true }`, which automatically picks up new tiles without re-querying.
- **Fix:** Replace the static-NodeList + per-tile-observer approach with a single body-level observer (same as `useRemoteAllMuted`), and re-scan all `[data-video-fit]` tiles on each relevant mutation.
@@ -614,21 +623,23 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
---
**N118 — `useCallSpeakers` Relies on Three Layers of Undocumented EC Internal APIs**
- **File:** `src/app/hooks/useCallSpeakers.ts`, lines 15, 28–35
- **Status:** **OPEN** [Claude_Found]
- **Status:** **ACKNOWLEDGED (inherent)** — not a discrete bug: EC exposes no stable speaker API, so DOM/CSS reverse-engineering is unavoidable (the same applies to mute/spotlight/control poking elsewhere). The `19feca49` rewrite documents exactly which EC internals are depended on (`[data-video-fit]`, `::before` background-image, `aria-label`), which is the practical mitigation. A build-time EC-version assertion is a deferred CI/infra task; an upstream request for a stable `data-speaking` attribute is the real long-term fix.
- **Issue:** Speaker detection depends on three private Element Call implementation details that are not part of any stable EC API contract and can silently break on any EC version bump:
1. **`[data-video-fit]`** — selector for video tile wrapper elements (internal EC data attribute).
2. **`getComputedStyle(el, '::before').getPropertyValue('background-image') !== 'none'`** — speaking state is inferred from a `::before` pseudo-element's `background-image`. Any EC refactor of the speaking indicator (e.g. switching to a CSS class, `data-speaking` attribute, or canvas overlay) silently breaks detection with no error.
3. **`el.querySelector('[aria-label]')?.getAttribute('aria-label')`** — assumes the first child with an `aria-label` carries the Matrix user ID; EC could equally label that element with a display name or a button description.
When these internals change, `speakers` silently stays empty with no runtime error.
When these internals change, `speakers` silently stays empty with no runtime error.
- **Root Cause:** There is no stable programmatic API exposed by the EC iframe for speaker state; the implementation reverse-engineers EC's internal DOM/CSS.
- **Fix:** Prefer EC's `postMessage` protocol if it exposes speaker events. At minimum, add a build-time assertion that pins the EC package version this mechanism was validated against (e.g. in `lotusDenoise` or a separate CI check), and file an upstream EC issue requesting a stable `data-speaking` attribute — which would match the pattern already used by `[data-muted]` in `useRemoteAllMuted`.
---
**N119 — `syncDecorations.mjs` Treats Network Errors the Same as 404 — CDN Outage Silently Wipes Entire Catalog**
- **File:** `scripts/syncDecorations.mjs`, lines 39–46, 56–65
- **Status:** **OPEN** [Claude_Found]
- **Status:** **FIXED** (`51d468fb`) — distinguishes confirmed 404 (remove) from network/5xx (abort) so a CDN outage can't wipe the catalog.
- **Issue:** `headCheck` catches all fetch exceptions (DNS failure, timeout, CORS error, TLS failure) and returns `{ ok: false, status: 0 }`. This is structurally identical to an HTTP 404 (`{ ok: false, status: 404 }`). The script classifies all non-ok results as "missing" and removes them from `avatarDecorations.ts`. If `drive.lotusguild.org` is temporarily unreachable when a developer runs `npm run sync:decorations`, every single decoration fails the HEAD check with `status: 0`, is marked missing, and is removed. The script writes an empty `avatarDecorations.ts`, logs "Done. Removed N entries from the catalog.", and exits 0 — permanently destroying the catalog in source control with no warning.
- **Root Cause:** The `catch` block does not distinguish transient network failures from confirmed HTTP 404 responses.
- **Fix:** Return a distinct value for network errors (e.g. `{ slug, ok: false, status: 0, networkError: true }`). Before writing the updated catalog, abort with `process.exit(1)` if any result has `networkError: true` — the CDN may be unreachable and removing all entries would be data loss. Only entries with a confirmed `status: 404` (file genuinely absent from the CDN) should be removed.
@@ -636,8 +647,9 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
---
**N120 — CDN URL Hard-Coded Separately in `syncDecorations.mjs` and `avatarDecorations.ts` — Can Drift**
- **File:** `scripts/syncDecorations.mjs`, line 24; `src/app/features/lotus/avatarDecorations.ts`, lines 1–2
- **Status:** **OPEN** [Claude_Found]
- **Status:** **FIXED** (`ce8a03ab`) — CDN single-sourced as `DECORATION_CDN` in `avatarDecorations.ts`; `syncDecorations.mjs` extracts it by regex and fails hard if renamed.
- **Issue:** The Nextcloud CDN base URL (including the embedded share token `bHswJ9pNKp2t26N`) is defined twice: as `const CDN` in the sync script and as `export const DECORATION_CDN` in the runtime catalog. If the CDN is migrated (new provider, new Nextcloud share, rotated token), a developer must update both files. Missing one means the sync script probes the old URL while the runtime client fetches from the new one (or vice versa), silently producing a catalog that references unreachable assets. There is no test or lint check that enforces parity.
- **Root Cause:** `syncDecorations.mjs` is a plain `.mjs` script that cannot directly `import` from a `.ts` source file at runtime, so the constant was copied instead of shared.
- **Fix:** Extract the CDN URL into a shared `.mjs` config file (e.g. `scripts/decorationConfig.mjs`) that `syncDecorations.mjs` imports directly. Have `avatarDecorations.ts` read the same value at build time (via a Vite define/import, or by making the script write the constant into `avatarDecorations.ts` rather than hardcoding it). Alternatively, add a CI step that `grep`s both files and fails if the URLs differ.
@@ -645,8 +657,9 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
---
**N128 — `patch-folds.mjs` Emits `console.warn` Instead of `process.exit(1)` When Patch Target Is Not Found**
- **File:** `scripts/patch-folds.mjs`, lines 21–23
- **Status:** **OPEN** [Claude_Found]
- **Status:** **FIXED** (`ce8a03ab`) — `process.exit(1)` on a genuine missing patch target; idempotent already-applied path still exits 0.
- **Issue:** When the target string `children: src(filled)` is not found in `node_modules/folds/dist/index.js` — because folds shipped an update that renamed or restructured this code path — the script logs `Warning: folds Icon patch target not found - may need updating.` and exits with code 0. The `postinstall` npm hook considers the install successful. The production build then ships the unpatched folds, where passing a non-function as `src` to `<Icon>` causes a runtime `TypeError: src is not a function` at any call site that relies on the guard. The failure is invisible at build and install time; it manifests only when the affected UI is rendered in production.
- **Root Cause:** The mismatch branch uses `console.warn` (exit 0) rather than `process.exit(1)`, treating a broken build pre-requisite as a non-fatal advisory.
- **Fix:** Replace the `console.warn(...)` + implicit exit-0 with `console.error(...)` followed by `process.exit(1)`. This causes `npm install` (and CI) to fail loudly, forcing the developer to update the patch target string before the build can proceed. The "already applied" branch (line 15) correctly exits 0 and does not need to change.
@@ -668,8 +681,8 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
- `cinny_device_id` — the E2E device identifier; lets an attacker narrow the cross-signing key set needed to read encrypted history
- `cinny_user_id` — the Matrix ID (`@user:server`)
- `cinny_hs_base_url` — homeserver origin
Any XSS payload executing in this origin can exfiltrate all four with four `localStorage.getItem()` calls. There is no Content-Security-Policy in the nginx/Caddy config files (existing open finding) that would limit script injection. `getFallbackSession()` (lines 49–68) also re-reads all four keys from `localStorage` on every boot — there is no in-memory cache that would allow the token to be removed from storage after the first load, so the credential window is permanent until logout.
Additionally, `setFallbackSession()` performs **four sequential, non-atomic `localStorage.setItem()` calls** (lines 38–41). If the process is killed or the browser crashes between calls 1 and 3, `cinny_access_token` will be written to storage but the session will be incomplete; `getFallbackSession()` will return `undefined` (requires all four keys), leaving a stranded, fully-valid access token in `localStorage` that is never used or cleaned up.
Any XSS payload executing in this origin can exfiltrate all four with four `localStorage.getItem()` calls. There is no Content-Security-Policy in the nginx/Caddy config files (existing open finding) that would limit script injection. `getFallbackSession()` (lines 49–68) also re-reads all four keys from `localStorage` on every boot — there is no in-memory cache that would allow the token to be removed from storage after the first load, so the credential window is permanent until logout.
Additionally, `setFallbackSession()` performs **four sequential, non-atomic `localStorage.setItem()` calls** (lines 38–41). If the process is killed or the browser crashes between calls 1 and 3, `cinny_access_token` will be written to storage but the session will be incomplete; `getFallbackSession()` will return `undefined` (requires all four keys), leaving a stranded, fully-valid access token in `localStorage` that is never used or cleaned up.
- **Root Cause:** The original multi-account Cinny path (now commented out) used an `atomWithLocalStorage` abstraction layer. The current single-account "fallback" path bypasses all abstraction and writes directly to raw `localStorage` with no protection.
- **Fix:** Replace the four `setItem` calls with a single atomic write: serialize all four fields as one JSON object under a single key (`cinny_session`). This eliminates the partial-write window. For the XSS-resistance problem: migrate the access token to `sessionStorage` as a minimum (does not survive browser restart, limiting the exposure window on shared devices). For stronger protection: derive a per-device encryption key via `crypto.subtle.generateKey` and store it in `IndexedDB` (which already holds E2E keys via `IndexedDBCryptoStore`); encrypt the access token before writing to `localStorage`. The OIDC token-rotation flow (short-lived access tokens, refresh-token-only persistence) is the architecturally cleanest long-term fix.
@@ -678,19 +691,19 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
**N98 — Normal logout (`logoutClient` / `handleLogout`) calls `window.localStorage.clear()`, permanently wiping user preferences and unsent drafts**
- **File:** `src/client/initMatrix.ts`, line 78 (`logoutClient`); `src/app/pages/client/ClientRoot.tsx`, line 133 (`handleLogout` inside `useLogoutListener`)
- **Status:** **OPEN** [Claude_Found]
- **Status:** **FIXED** (`34997bcb`) — logoutClient/handleLogout use removeFallbackSession() (4 session keys only); window.localStorage.clear() reserved for clearLoginData().
- **Issue:** Both logout code paths call `window.localStorage.clear()`, which removes **every key** for the origin — not just the session credentials. Keys destroyed on every normal logout include:
- `settings` — theme, notification preferences, keyboard shortcuts (`pttKey`, `deafenKey`), toolbar configuration, noise-suppression mode, accessibility settings, and all other `Settings` interface fields
- `draft-msg-{roomId}` (one key per room) — unsent composer drafts for every room the user had open at logout time
- `pip-position` — saved PiP window position
- `status_msg_{userId}` / `status_expiry_{userId}` — persisted presence status message and auto-clear timestamp
- `afterLoginRedirectPath` — post-login redirect
A user who logs out and back in on the same device starts with a factory-reset app. This violates the standard expectation that app preferences persist across sessions (every comparable Matrix client and messaging app preserves preferences across logout). The `clearLoginData()` function (the explicit "wipe all data" reset path, surfaced in the UI as "Clear local data and reload") also calls `localStorage.clear()` — that usage is appropriate and expected — but `logoutClient()` / `handleLogout` should not share this behavior.
A user who logs out and back in on the same device starts with a factory-reset app. This violates the standard expectation that app preferences persist across sessions (every comparable Matrix client and messaging app preserves preferences across logout). The `clearLoginData()` function (the explicit "wipe all data" reset path, surfaced in the UI as "Clear local data and reload") also calls `localStorage.clear()` — that usage is appropriate and expected — but `logoutClient()` / `handleLogout` should not share this behavior.
- **Root Cause:** `localStorage.clear()` was chosen as a one-line logout implementation rather than selectively removing only the four session credential keys. No distinction is made between "end the session" and "factory reset."
- **Fix:** Replace `window.localStorage.clear()` in both `logoutClient` (line 78) and `handleLogout` (line 133) with targeted removal of only the session credential keys:
```typescript
['cinny_access_token', 'cinny_device_id', 'cinny_user_id', 'cinny_hs_base_url'].forEach(k =>
window.localStorage.removeItem(k)
['cinny_access_token', 'cinny_device_id', 'cinny_user_id', 'cinny_hs_base_url'].forEach((k) =>
window.localStorage.removeItem(k),
);
```
Leave `settings`, draft keys, and all other preference keys intact. Reserve `window.localStorage.clear()` for the `clearLoginData()` path only.
@@ -700,20 +713,23 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
**N99 — `useSyncState` callback in `ClientRoot.tsx` only handles `PREPARED`; a sync `ERROR` before first sync completion freezes the app on the loading screen with contradictory UI**
- **File:** `src/app/pages/client/ClientRoot.tsx`, lines 179–186; `src/app/hooks/useSyncState.ts`, lines 1–14
- **Status:** **OPEN** [Claude_Found]
- **Status:** **FIXED** (`34997bcb`) — useSyncState handles ERROR/STOPPED before first PREPARED → error splash + Retry (startMatrix); guarded by hasPreparedRef so post-PREPARED transients stay with SyncStatus.
- **Issue:** The `useSyncState` callback in `ClientRoot` only calls `setLoading(false)` for `state === 'PREPARED'`. The Matrix JS SDK can emit `SyncState.Error` before ever reaching `PREPARED` — for example when the device is offline at startup, the homeserver is unreachable, or the first `/sync` request returns a non-retryable server error. When this happens:
1. `loading` remains `true` (never set to `false`)
2. `<ClientRootLoading />` renders indefinitely, showing the "Heating up" spinner
3. `<SyncStatus mx={mx} />` — rendered unconditionally **above** the loading conditional at line 191 — fires its own `useSyncState` listener and shows a "Connection Lost!" red banner simultaneously
4. The user sees contradictory messages ("Connection Lost!" + "Heating up") with no recovery action visible from the loading screen. The only escape is the `ClientRootOptions` ⋮ menu (lines 192–125), which is a small icon button with Logout / Clear Cache — not discoverable without prior knowledge.
Note: This is **distinct from the existing race-condition finding** (which concerns the listener missing PREPARED because it registers too late). Here the listener registers correctly and fires, but it fires with `ERROR` instead of `PREPARED`, and the callback ignores it.
Note: This is **distinct from the existing race-condition finding** (which concerns the listener missing PREPARED because it registers too late). Here the listener registers correctly and fires, but it fires with `ERROR` instead of `PREPARED`, and the callback ignores it.
- **Root Cause:** The `useSyncState` callback is designed around a single happy-path terminal state (`PREPARED`). `SyncStatus` handles error states for the **post-PREPARED** reconnection UX, but does not replace the loading screen.
- **Fix:** Extend the `useSyncState` callback to handle `SyncState.Error` and `SyncState.Stopped` by setting a separate `syncError` state, then render a dedicated error splash (parallel to the existing `loadState`/`startState` error dialog at lines 193–238) that shows a descriptive message and a Retry button that calls `startMatrix(mx)`:
```tsx
useSyncState(mx, useCallback((state) => {
if (state === 'PREPARED') setLoading(false);
else if (state === 'ERROR' || state === 'STOPPED') setSyncError(true);
}, []));
useSyncState(
mx,
useCallback((state) => {
if (state === 'PREPARED') setLoading(false);
else if (state === 'ERROR' || state === 'STOPPED') setSyncError(true);
}, []),
);
```
---
@@ -721,7 +737,7 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
**N100 — `sanitize.ts` allows unrestricted CSS class names on `<pre>` elements; `allowedClasses` not configured for `pre`**
- **File:** `src/app/utils/sanitize.ts`, lines 69 and 156–163
- **Status:** **OPEN** [Claude_Found]
- **Status:** **FIXED** (`51d468fb`) — added `pre: ['language-*']` to sanitize-html allowedClasses.
- **Issue:** `permittedTagToAttributes` includes `pre: ['data-md', 'class']` (line 69), permitting the `class` attribute on `<pre>` elements in Matrix `formatted_body` messages. However, `allowedClasses` (lines 156–163) restricts class names only for `code` elements (`language-*` patterns for Prism syntax highlighting). Per `sanitize-html` documentation: when `class` is listed in `allowedAttributes` for a tag but that tag has no entry in `allowedClasses`, **all class names are permitted** on that element. This allows a remote message sender to inject arbitrary class names onto `<pre>` blocks — e.g. `<pre class="some-cinny-class admin-notice">` — which could activate site-specific or folds-generated CSS rules keyed to those class names, override visual styling, or trigger `::before`/`::after` pseudo-element content defined in any loaded stylesheet. By contrast, the `code` element (which is typically the inner child of `<pre>`) is correctly restricted to `language-*` only, making the `pre` oversight inconsistent.
- **Root Cause:** When Prism syntax-highlighting class support was added for `<code>`, the `<pre>` element was given a `class` passthrough (to allow `<pre class="language-python">` wrappers) but no corresponding `allowedClasses` whitelist entry was added for it.
- **Fix:** Add `pre` to `allowedClasses` with the same `language-*` pattern already used for `code`:
@@ -760,7 +776,7 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
**N122 — `setMediaState` promise hangs permanently when EC omits a `DeviceMute` state-echo**
- **File:** `src/app/plugins/call/CallControl.ts`, lines 185–193
- **Status:** **OPEN** [Claude_Found]
- **Status:** **FIXED** (`49d9410e`) — setMediaState resolves on EC transport ACK; dropped the single-slot mediaStatePromiseResolver.
- **Issue:** The Promise returned by `setMediaState` can never resolve if EC does not emit a `DeviceMute` `fromWidget` state-update event in response to the host's mute command. After `await this.call.transport.send(ElementWidgetActions.DeviceMute, state)` resolves (EC has ACK'd the command), the function creates an inner Promise whose resolver is stored in `this.mediaStatePromiseResolver` — a field consumed only by `onMediaState` or by the NEXT call to `setMediaState`. If EC ACKs the command but does not subsequently fire a `DeviceMute` state-report back (the most likely trigger: the requested state already matches EC's current state and EC elides the echo, or EC is shutting down before broadcasting), the inner Promise is stranded forever. `applyState()` awaits this Promise at line 118 (`await this.setMediaState({...})`); the subsequent `this.setSound(this.sound)` and `this.emitStateUpdate()` calls at lines 122–123 are never reached. Because `forceState` (which calls `applyState`) is invoked fire-and-forget from `onCallJoined`, the practical result is that the initial deafen state and the first `StateUpdate` event emission are silently skipped on every call join when EC batches or omits the echo.
- **Root Cause:** The single-slot `mediaStatePromiseResolver` architecture gates the mute operation's completion on an EC-originated event that is not guaranteed to fire for every host-initiated command.
- **Fix:** Resolve the inner Promise directly when `transport.send()` returns — EC having replied already confirms the command was received and applied. Drop the `new Promise(...)` wrapper and return `data` immediately after `await transport.send()`. Keep `onMediaState` as the authoritative state-sync path (updating `this.state` and calling `emitStateUpdate`) but remove the `mediaStatePromiseResolver` field and its invocation from that handler entirely.
@@ -770,7 +786,7 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
**N123 — `focusCameraParticipant` tile click silently drops when EC spotlight layout isn't ready in 2 animation frames**
- **File:** `src/app/plugins/call/CallControl.ts`, lines 396–401
- **Status:** **OPEN** [Claude_Found]
- **Status:** **FIXED** (`49d9410e`) — MutationObserver waits for a spotlight videoTile (600ms timeout fallback) instead of a fixed 2-frame delay.
- **Issue:** After clicking `spotlightButton` to enter spotlight mode, `focusCameraParticipant` waits exactly two `requestAnimationFrame` callbacks (~32 ms at 60 fps) before querying the EC document for the target tile. If EC's React tree has not committed new spotlight tile nodes within that window — which occurs regularly on slower devices, during animated layout transitions, or when EC is simultaneously decoding video streams — `findTile()` returns `undefined` and the focus action is silently dropped. The user sees EC switch to spotlight mode but the requested participant is never pinned. There is no retry, no surfaced error, and the only signal is a DEV-only `console.warn`.
- **Root Cause:** The double-rAF heuristic is a timing approximation, not a DOM-readiness guarantee. EC's React reconciliation and layout commit can exceed 32 ms.
- **Fix:** Replace the double-rAF with a `MutationObserver` on `this.document.body` (childList + subtree) that waits for a `[data-testid="videoTile"]` element to appear, then calls `applyFocus()` and disconnects. Add a 600 ms hard-timeout fallback that calls `applyFocus()` and disconnects regardless, so the click is always attempted at least once even when tile rendering is slow.
@@ -780,12 +796,14 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
**N124 — Denoise shim `cleanup()` leaks the noise gate `AudioWorkletNode` processor thread when `USE_GATE=true`**
- **File:** `build/lotus-denoise.js`, lines 235–244 and 267–281
- **Status:** **OPEN** [Claude_Found]
- **Status:** **FIXED** (`ce8a03ab`) — `cleanup()` now disconnects `gateNode` (guarded), releasing the gate processor thread.
- **Issue:** When the noise gate is active (`USE_GATE=true`), `processStream` creates a `gateNode` (`AudioWorkletNode`) and wires it as `source → gateNode → mlNode → dest`. The `cleanup()` closure inside the inner `.then()` callback calls `source.disconnect()` and `mlNode.disconnect()` but never `gateNode.disconnect()`. `gateNode` is declared with `var` inside the outer `if (USE_GATE)` block — hoisted via `var` to the enclosing `.then()` function scope — and IS accessible in the inner callback via closure, but is simply absent from `cleanup()`. The AudioWorklet processor thread for the orphaned gate node continues running on the audio rendering thread until the EC iframe is destroyed. If EC's LiveKit client calls `getUserMedia` more than once within a session (e.g., a device switch mid-call), a new orphaned gate processor accumulates on each call, each consuming audio-thread CPU indefinitely.
- **Root Cause:** `gateNode` is in closure scope but missing from the `cleanup()` body.
- **Fix:** Add to `cleanup()`:
```javascript
try { if (gateNode) gateNode.disconnect(); } catch (e) {}
try {
if (gateNode) gateNode.disconnect();
} catch (e) {}
```
---
@@ -793,7 +811,7 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
**N125 — Denoise shim `postMessage` uses wildcard `'*'` target origin**
- **File:** `build/lotus-denoise.js`, lines 294–306 and 317–320
- **Status:** **OPEN** [Claude_Found]
- **Status:** **FIXED** (`ce8a03ab`) — `postMessage` targets the parentUrl-derived origin (fallback to own origin) instead of `*`.
- **Issue:** Both `lotus-denoise-status` `postMessage` calls use `'*'` as the `targetOrigin` argument, broadcasting the message to any frame that currently contains the EC iframe as a child regardless of its origin. If the Lotus EC widget URL is ever embedded by a third-party page (possible since it is same-origin and publicly routable), that page receives the denoise status payload (`{ type, active, model, nativeNS, gate }`). Using `'*'` violates the MDN/W3C `postMessage` security recommendation.
- **Root Cause:** The shim has no reference to the parent origin at the point these calls are made. The `parentUrl` widget URL parameter — already present in `window.location.search` and parsed into `params` at line 27 — provides the correct target origin.
- **Fix:** Extract `parentUrl` from `params` and use it as the target origin:
@@ -807,7 +825,7 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
**N126 — PiP position restored from `localStorage` without type validation, silently producing `NaN` coordinates on corrupt data**
- **File:** `src/app/components/CallEmbedProvider.tsx`, line 723
- **Status:** **OPEN** [Claude_Found]
- **Status:** **FIXED** (`49d9410e`) — saved PiP position shape+finiteness validated before use; corrupt data falls back to default.
- **Issue:** The saved PiP position is cast without runtime validation:
```typescript
const savedPos = saved ? (JSON.parse(saved) as { left: number; top: number }) : null;
@@ -816,11 +834,21 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
- **Root Cause:** TypeScript `as` casts do not validate at runtime; the parsed value's shape is never checked.
- **Fix:** Add an explicit shape-and-finite guard:
```typescript
const raw = saved ? (() => { try { return JSON.parse(saved); } catch { return null; } })() : null;
const raw = saved
? (() => {
try {
return JSON.parse(saved);
} catch {
return null;
}
})()
: null;
const savedPos =
raw != null &&
typeof raw.left === 'number' && isFinite(raw.left) &&
typeof raw.top === 'number' && isFinite(raw.top)
typeof raw.left === 'number' &&
isFinite(raw.left) &&
typeof raw.top === 'number' &&
isFinite(raw.top)
? (raw as { left: number; top: number })
: null;
```
+19 -2
View File
@@ -30,6 +30,17 @@
return;
}
// Derive the parent origin for postMessage targetOrigin from the parentUrl
// widget param (a full URL) so denoise-status messages aren't broadcast with
// '*'. Fall back to this frame's own origin if parentUrl is missing/malformed.
var targetOrigin;
try {
var parentUrl = params.get('parentUrl');
targetOrigin = parentUrl ? new URL(parentUrl).origin : window.location.origin;
} catch (e) {
targetOrigin = window.location.origin;
}
var md = navigator.mediaDevices;
if (!md || typeof md.getUserMedia !== 'function') return;
if (typeof AudioWorkletNode === 'undefined' || typeof AudioContext === 'undefined') return;
@@ -274,6 +285,9 @@
source.disconnect();
mlNode.disconnect();
} catch (e) {}
try {
if (gateNode) gateNode.disconnect();
} catch (e) {}
try {
origTrack.stop();
} catch (e) {}
@@ -301,7 +315,7 @@
nativeNS: USE_NATIVE_NS,
gate: USE_GATE,
},
'*',
targetOrigin,
);
}
@@ -316,7 +330,10 @@
.catch(function (e) {
var msg = e instanceof Error ? e.message : String(e);
console.error('[lotus-denoise] Setup failed:', msg);
window.parent.postMessage({ type: 'lotus-denoise-status', active: false, error: msg }, '*');
window.parent.postMessage(
{ type: 'lotus-denoise-status', active: false, error: msg },
targetOrigin,
);
return stream;
});
}
+11 -2
View File
@@ -19,8 +19,17 @@ try {
writeFileSync(foldsPath, content, 'utf8');
console.log('Applied defensive Icon src guard to folds.');
} else {
console.warn('Warning: folds Icon patch target not found - may need updating.');
// Genuine "patch could not be applied" case: the target string is gone
// (folds renamed/restructured it) AND it isn't already patched. Fail hard
// so the postinstall hook / CI breaks loudly instead of silently shipping
// an unpatched folds (which crashes at render with "src is not a function").
console.error(
'ERROR: folds Icon patch target not found - folds may have updated. ' +
'Update the patch target string in scripts/patch-folds.mjs before building.',
);
process.exit(1);
}
} catch (e) {
console.warn('Warning: Could not patch folds:', e.message);
console.error('ERROR: Could not patch folds:', e.message);
process.exit(1);
}
+40 -4
View File
@@ -21,10 +21,25 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const catalogPath = join(root, 'src', 'app', 'features', 'lotus', 'avatarDecorations.ts');
const CDN = 'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
// Single source of truth: the CDN base URL lives in avatarDecorations.ts as
// `export const DECORATION_CDN`. We extract it from there at runtime rather than
// re-declaring it here, so the build script and the app can never drift. This
// .mjs script can't cleanly import the browser-side .ts module (it's outside the
// Vite/TS app graph), so we parse the constant out of the file text instead.
// If you migrate the CDN, change it ONLY in avatarDecorations.ts.
const catalog = readFileSync(catalogPath, 'utf8');
const cdnMatch = catalog.match(/export const DECORATION_CDN\s*=\s*['"]([^'"]+)['"]/);
if (!cdnMatch) {
console.error(
'Could not find `export const DECORATION_CDN` in avatarDecorations.ts — ' +
'the constant may have been renamed. Update scripts/syncDecorations.mjs.',
);
process.exit(1);
}
const CDN = cdnMatch[1];
// Extract all slugs from the catalog file
const catalog = readFileSync(catalogPath, 'utf8');
const slugMatches = [...catalog.matchAll(/slug: '([^']+)'/g)].map((m) => m[1]);
if (slugMatches.length === 0) {
@@ -41,7 +56,8 @@ async function headCheck(slug) {
const res = await fetch(`${CDN}/${slug}.png`, { method: 'HEAD' });
return { slug, ok: res.ok, status: res.status };
} catch {
return { slug, ok: false, status: 0 };
// Network/DNS/TLS failure — NOT a confirmation the file is gone.
return { slug, ok: false, status: 0, networkError: true };
}
}
@@ -53,7 +69,27 @@ for (let i = 0; i < slugMatches.length; i += BATCH) {
results.push(...batchResults);
}
const missing = results.filter((r) => !r.ok);
// Only a CONFIRMED HTTP 404 means the file is genuinely gone and safe to
// remove. A network error or any other non-ok status (5xx, 403, timeout) is
// ambiguous — the CDN may be unreachable — so refuse to remove anything and
// abort, otherwise a transient outage would wipe the whole catalog from source
// control (N119).
const transient = results.filter((r) => !r.ok && r.status !== 404);
if (transient.length > 0) {
console.error(
`Aborting: ${transient.length} decoration(s) returned a non-404 failure ` +
`(network error / server error). The CDN may be unreachable — refusing to ` +
`remove entries to avoid wiping the catalog.`,
);
transient
.slice(0, 8)
.forEach((r) =>
console.error(` ${r.slug}: ${r.networkError ? 'network error' : `HTTP ${r.status}`}`),
);
process.exit(1);
}
const missing = results.filter((r) => r.status === 404);
const found = results.filter((r) => r.ok);
if (missing.length === 0) {
+19 -1
View File
@@ -720,7 +720,25 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
if (pipMode) {
if (!wasInPip) {
const saved = localStorage.getItem('pip-position');
const savedPos = saved ? (JSON.parse(saved) as { left: number; top: number }) : null;
let savedPos: { left: number; top: number } | null = null;
if (saved) {
try {
const raw = JSON.parse(saved) as { left?: unknown; top?: unknown };
// Validate shape + finiteness: a corrupt value would otherwise feed
// NaN into Math.min and produce an invalid `NaNpx` CSS value.
if (
raw &&
typeof raw.left === 'number' &&
Number.isFinite(raw.left) &&
typeof raw.top === 'number' &&
Number.isFinite(raw.top)
) {
savedPos = { left: raw.left, top: raw.top };
}
} catch {
savedPos = null;
}
}
el.style.right = 'auto';
el.style.bottom = 'auto';
if (savedPos) {
@@ -1,3 +1,8 @@
// Single source of truth for the avatar-decoration CDN base URL.
// scripts/syncDecorations.mjs reads this exact `DECORATION_CDN` declaration out
// of this file at runtime (by regex) instead of re-declaring it, so the two can
// never drift. If you migrate the CDN, change it here ONLY — keep the
// `export const DECORATION_CDN = '...'` shape so the sync script can still parse it.
export const DECORATION_CDN =
'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
+95 -39
View File
@@ -1,60 +1,116 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { CallEmbed } from '../plugins/call';
import { useMutationObserver } from './useMutationObserver';
import { isUserId } from '../utils/matrix';
import { useCallMembers, useCallSession } from './useCall';
import { useCallJoined } from './useCallEmbed';
/**
* Returns the set of Matrix user IDs currently speaking in the Element Call
* iframe.
*
* EC renders each participant's video tile with a `[data-video-fit]` wrapper.
* When a participant is speaking, EC draws a speaking indicator via the tile's
* `::before` pseudo-element `background-image` (anything other than `none`).
* The participant's Matrix user ID is exposed on the first descendant carrying
* an `aria-label`.
*
* We watch the whole iframe document so tiles added/removed mid-call are picked
* up automatically, and on every relevant mutation we re-scan ALL `[data-video-fit]`
* tiles and rebuild the set from the full current DOM state (rather than just the
* tiles in the mutation batch).
*/
export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
const [speakers, setSpeakers] = useState(new Set<string>());
const callSession = useCallSession(callEmbed.room);
const callMembers = useCallMembers(callSession);
const joined = useCallJoined(callEmbed);
const videoContainers = useMemo(() => {
if (callMembers && joined) return callEmbed.document?.querySelectorAll('[data-video-fit]');
return undefined;
}, [callEmbed, callMembers, joined]);
const mutationObserver = useMutationObserver(
useCallback(
(mutations) => {
const s = new Set<string>();
mutations.forEach((mutation) => {
if (mutation.type !== 'attributes') return;
const el = mutation.target as HTMLElement;
const style = callEmbed.iframe.contentWindow?.getComputedStyle(el, '::before');
if (!style) return;
const tileBackgroundImage = style.getPropertyValue('background-image');
const speaking = tileBackgroundImage !== 'none';
if (!speaking) return;
const speakerId = el.querySelector('[aria-label]')?.getAttribute('aria-label');
if (speakerId && isUserId(speakerId)) {
s.add(speakerId);
}
});
setSpeakers(s);
},
[callEmbed],
),
);
useEffect(() => {
videoContainers?.forEach((element) => {
mutationObserver.observe(element, {
if (!callMembers || !joined) {
setSpeakers(new Set<string>());
return undefined;
}
const getDoc = (): Document | undefined =>
callEmbed.iframe.contentDocument ?? callEmbed.iframe.contentWindow?.document ?? undefined;
const syncState = (): void => {
const doc = getDoc();
if (!doc) {
setSpeakers(new Set<string>());
return;
}
const s = new Set<string>();
// Re-scan every tile on each mutation and build the set from the full
// current DOM state, not just the tiles that mutated this batch.
const tiles = doc.querySelectorAll<HTMLElement>('[data-video-fit]');
tiles.forEach((el) => {
const style = callEmbed.iframe.contentWindow?.getComputedStyle(el, '::before');
if (!style) return;
const tileBackgroundImage = style.getPropertyValue('background-image');
const speaking = tileBackgroundImage !== 'none';
if (!speaking) return;
const speakerId = el.querySelector('[aria-label]')?.getAttribute('aria-label');
if (speakerId && isUserId(speakerId)) {
s.add(speakerId);
}
});
setSpeakers(s);
};
let tileObserver: MutationObserver | undefined;
const attachObserver = (): void => {
const doc = getDoc();
if (!doc) return;
tileObserver?.disconnect();
// Watch the whole document for attribute changes on tiles (which carry
// the speaking indicator) and for new tiles being added/removed.
tileObserver = new MutationObserver((mutations) => {
const relevant = mutations.some(
(m) =>
m.type === 'attributes' ||
(m.type === 'childList' &&
(Array.from(m.addedNodes).some(
(n) => n instanceof Element && n.querySelector('[data-video-fit]'),
) ||
Array.from(m.removedNodes).some(
(n) => n instanceof Element && n.querySelector('[data-video-fit]'),
))),
);
if (relevant) syncState();
});
tileObserver.observe(doc.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['class', 'style'],
});
});
syncState();
};
attachObserver();
// If iframe isn't ready yet, wait for body to be available.
let bodyWatcher: MutationObserver | undefined;
if (!getDoc()?.body) {
bodyWatcher = new MutationObserver(() => {
if (getDoc()?.body) {
bodyWatcher?.disconnect();
bodyWatcher = undefined;
attachObserver();
}
});
const doc = getDoc();
if (doc) bodyWatcher.observe(doc, { childList: true });
}
return () => {
mutationObserver.disconnect();
tileObserver?.disconnect();
bodyWatcher?.disconnect();
};
}, [videoContainers, mutationObserver]);
}, [callEmbed, callMembers, joined]);
return speakers;
};
+41 -16
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
@@ -32,39 +32,64 @@ export function useReminders(): {
const mx = useMatrixClient();
const [reminders, setReminders] = useState<Reminder[]>(() => readReminders(mx));
// Authoritative local snapshot used to compute mutations. Reading
// mx.getAccountData() per-mutation is racy: two quick add/remove calls both
// read the same stale baseline and the second write clobbers the first
// (N113). We instead mutate from this ref, kept in sync with server echoes.
const latestRef = useRef<Reminder[]>(reminders);
// Serialize writes so overlapping setAccountData calls can't land out of
// order on the server (last-write-wins would otherwise drop data).
const writeQueueRef = useRef<Promise<unknown>>(Promise.resolve());
const applyServerState = useCallback((list: Reminder[]) => {
latestRef.current = list;
setReminders(list);
}, []);
useAccountDataCallback(
mx,
useCallback(
(evt) => {
if (evt.getType() === REMINDERS_KEY) {
setReminders(evt.getContent<RemindersContent>()?.reminders ?? []);
applyServerState(evt.getContent<RemindersContent>()?.reminders ?? []);
}
},
[setReminders],
[applyServerState],
),
);
// Re-read on mx change
useEffect(() => {
setReminders(readReminders(mx));
}, [mx]);
applyServerState(readReminders(mx));
}, [mx, applyServerState]);
const addReminder = useCallback(
async (r: Reminder) => {
const current = readReminders(mx);
const next = [...current, r];
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
const enqueueWrite = useCallback(
(compute: (current: Reminder[]) => Reminder[]): Promise<void> => {
const run = writeQueueRef.current.then(async () => {
const next = compute(latestRef.current);
latestRef.current = next;
setReminders(next);
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
});
// Keep the chain alive even if one write rejects, but propagate the
// rejection to this caller so it can react (e.g. retry).
writeQueueRef.current = run.catch(() => undefined);
return run;
},
[mx],
);
const addReminder = useCallback(
(r: Reminder) => enqueueWrite((current) => [...current, r]),
[enqueueWrite],
);
const removeReminder = useCallback(
async (eventId: string, timestamp: number) => {
const current = readReminders(mx);
const next = current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp));
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
},
[mx],
(eventId: string, timestamp: number) =>
enqueueWrite((current) =>
current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)),
),
[enqueueWrite],
);
const getReminders = useCallback(() => reminders, [reminders]);
+36 -8
View File
@@ -242,6 +242,7 @@ function MessageNotifications() {
roomId,
eventId,
body,
encrypted,
}: {
roomName: string;
roomAvatar?: string;
@@ -249,6 +250,7 @@ function MessageNotifications() {
roomId: string;
eventId: string;
body?: string;
encrypted?: boolean;
}) => {
const roomPath = mDirects.has(roomId)
? getDirectRoomPath(roomId, eventId)
@@ -267,10 +269,17 @@ function MessageNotifications() {
return;
}
// N109: the OS notification subsystem fetches icon/badge OUTSIDE the page,
// so the SW can't inject auth headers and authenticated-media URLs 401.
// Use the static app logo (as invite notifications already do).
// N106: never put decrypted E2EE plaintext into the OS notification (it
// persists in the notification center / lock screen / is readable by other
// apps). For encrypted rooms show only the sender; the in-page toast above
// still shows the preview while the user is actively looking at the screen.
const noti = new window.Notification(roomName, {
icon: roomAvatar,
badge: roomAvatar,
body: body ? `${username}: ${body}`.slice(0, 120) : username,
icon: LogoSVG,
badge: LogoSVG,
body: !encrypted && body ? `${username}: ${body}`.slice(0, 120) : username,
silent: true,
});
@@ -341,6 +350,7 @@ function MessageNotifications() {
roomId: room.roomId,
eventId,
body: (mEvent.getContent().body as string | undefined) ?? '',
encrypted: room.hasEncryptionStateEvent(),
});
}
@@ -390,16 +400,26 @@ function ReminderMonitor() {
const setToast = useSetAtom(toastQueueAtom);
const mDirects = useAtomValue(mDirectAtom);
const firedRef = useRef<Set<string>>(new Set());
const removingRef = useRef<Set<string>>(new Set());
// Read the latest reminders / DM map via refs so the poll interval below is
// created once — not torn down and restarted (which resets its 30s countdown
// and can indefinitely defer a near-due reminder) on every reminder sync (N115).
const remindersRef = useRef(reminders);
remindersRef.current = reminders;
const mDirectsRef = useRef(mDirects);
mDirectsRef.current = mDirects;
useEffect(() => {
const check = () => {
const now = Date.now();
reminders.forEach((r) => {
remindersRef.current.forEach((r) => {
if (r.timestamp > now) return;
const key = `${r.eventId}-${r.timestamp}`;
if (r.timestamp <= now && !firedRef.current.has(key)) {
// Show the toast exactly once.
if (!firedRef.current.has(key)) {
firedRef.current.add(key);
const room = mx.getRoom(r.roomId);
const hashPath = mDirects.has(r.roomId)
const hashPath = mDirectsRef.current.has(r.roomId)
? getDirectRoomPath(r.roomId, r.eventId)
: getHomeRoomPath(r.roomId, r.eventId);
setToast({
@@ -410,7 +430,15 @@ function ReminderMonitor() {
roomId: r.roomId,
hashPath,
});
removeReminder(r.eventId, r.timestamp);
}
// Persist the removal, retrying on a later tick if it fails — without
// re-showing the toast (N114). The server echo drops it from
// `reminders` once the write lands.
if (!removingRef.current.has(key)) {
removingRef.current.add(key);
removeReminder(r.eventId, r.timestamp).catch(() => {
removingRef.current.delete(key);
});
}
});
};
@@ -425,7 +453,7 @@ function ReminderMonitor() {
clearInterval(interval);
document.removeEventListener('visibilitychange', onVisible);
};
}, [mx, reminders, setToast, removeReminder, mDirects]);
}, [mx, setToast, removeReminder]);
return null;
}
+36 -5
View File
@@ -15,7 +15,14 @@ import {
} from 'folds';
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useState } from 'react';
import React, {
MouseEventHandler,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import {
clearCacheAndReload,
clearLoginData,
@@ -35,7 +42,7 @@ import { useSyncState } from '../../hooks/useSyncState';
import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession } from '../../state/sessions';
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
import { AutoDiscovery } from './AutoDiscovery';
function ClientRootLoading() {
@@ -130,7 +137,10 @@ const useLogoutListener = (mx?: MatrixClient) => {
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
mx?.stopClient();
await mx?.clearStores();
window.localStorage.clear();
// Remove only the session credential keys — NOT settings, drafts, and
// other preferences (N98). The SDK's IndexedDB stores are cleared above;
// window.localStorage.clear() is reserved for the explicit reset path.
removeFallbackSession();
window.location.reload();
};
@@ -146,6 +156,11 @@ type ClientRootProps = {
};
export function ClientRoot({ children }: ClientRootProps) {
const [loading, setLoading] = useState(true);
const [syncError, setSyncError] = useState(false);
// Tracks whether the initial sync has ever reached PREPARED. After that,
// transient sync errors are handled by <SyncStatus>'s reconnection banner,
// so we must NOT pop the blocking error splash for them.
const hasPreparedRef = useRef(false);
const { baseUrl, userId } = getFallbackSession() ?? {};
const [loadState, loadMatrix] = useAsyncCallback<MatrixClient, Error, []>(
@@ -180,7 +195,14 @@ export function ClientRoot({ children }: ClientRootProps) {
mx,
useCallback((state) => {
if (state === 'PREPARED') {
hasPreparedRef.current = true;
setSyncError(false);
setLoading(false);
} else if (state === 'ERROR' || state === 'STOPPED') {
// Only surface the blocking error splash when the INITIAL sync fails
// (offline at startup, homeserver unreachable, non-retryable /sync
// error). After the first PREPARED, <SyncStatus> owns reconnection UX.
if (!hasPreparedRef.current) setSyncError(true);
}
}, []),
);
@@ -188,9 +210,11 @@ export function ClientRoot({ children }: ClientRootProps) {
return (
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
<SpecVersions baseUrl={baseUrl!}>
{mx && <SyncStatus mx={mx} />}
{mx && !syncError && <SyncStatus mx={mx} />}
{loading && <ClientRootOptions mx={mx} />}
{(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (
{(loadState.status === AsyncStatus.Error ||
startState.status === AsyncStatus.Error ||
syncError) && (
<SplashScreen>
<Box
direction="Column"
@@ -223,6 +247,13 @@ export function ClientRoot({ children }: ClientRootProps) {
{startState.status === AsyncStatus.Error && (
<Text>{`Failed to start. ${startState.error.message}`}</Text>
)}
{syncError &&
loadState.status !== AsyncStatus.Error &&
startState.status !== AsyncStatus.Error && (
<Text>
Failed to sync with your homeserver. Check your connection and try again.
</Text>
)}
{('error' in loadState ? (loadState as any).error?.message : undefined) !==
IDB_VERSION_CONFLICT && (
<Button variant="Critical" onClick={mx ? () => startMatrix(mx) : loadMatrix}>
+30 -17
View File
@@ -20,8 +20,6 @@ export class CallControl extends EventEmitter implements CallControlState {
private _pipMode = false;
private mediaStatePromiseResolver: undefined | (() => void);
private get document(): Document | undefined {
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
}
@@ -183,13 +181,13 @@ export class CallControl extends EventEmitter implements CallControlState {
}
private async setMediaState(state: ElementMediaStatePayload) {
const data = await this.call.transport.send(ElementWidgetActions.DeviceMute, state);
return new Promise<typeof data>((resolve) => {
if (this.mediaStatePromiseResolver) {
this.mediaStatePromiseResolver();
}
this.mediaStatePromiseResolver = () => resolve(data);
});
// transport.send resolves once EC has ACK'd the command, which is enough to
// consider the mute applied. We deliberately do NOT gate completion on a
// follow-up DeviceMute state-echo: EC may elide it (e.g. when the requested
// state already matches its current state) or skip it during teardown,
// which would strand this promise forever and block applyState(). The echo,
// when it does arrive, is still handled authoritatively by onMediaState().
return this.call.transport.send(ElementWidgetActions.DeviceMute, state);
}
private setSound(sound: boolean): void {
@@ -233,11 +231,6 @@ export class CallControl extends EventEmitter implements CallControlState {
if (this.microphone && !this.sound) {
this.toggleSound();
}
if (this.mediaStatePromiseResolver) {
this.mediaStatePromiseResolver();
this.mediaStatePromiseResolver = undefined;
}
}
private onControlMutation() {
@@ -394,10 +387,30 @@ export class CallControl extends EventEmitter implements CallControlState {
}
// Switching to spotlight re-renders EC's layout asynchronously; clicking the
// tile in the same tick would land in the old (grid) DOM. Toggle spotlight,
// then click on a later frame once the spotlight tiles have mounted.
// tile in the same tick would land in the old (grid) DOM. A fixed frame
// delay is unreliable (EC's React commit can exceed it on slow devices), so
// watch the iframe DOM for a spotlight video tile to mount, then focus —
// with a hard timeout so the click is always attempted at least once.
this.spotlightButton?.click();
requestAnimationFrame(() => requestAnimationFrame(applyFocus));
const tileSelector = '[data-testid="videoTile"]';
let settled = false;
let observer: MutationObserver | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = () => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
observer?.disconnect();
applyFocus();
};
observer = new MutationObserver(() => {
if (doc.querySelector(tileSelector)) finish();
});
observer.observe(doc.body, { childList: true, subtree: true });
timer = setTimeout(finish, 600);
// A tile may already be present immediately after toggling spotlight.
if (doc.querySelector(tileSelector)) finish();
}
public dispose() {
+5
View File
@@ -155,6 +155,11 @@ export const sanitizeCustomHtml = (customHtml: string): string =>
allowProtocolRelative: false,
allowedClasses: {
code: ['language-*'],
// `pre` permits `class` (for `<pre class="language-*">` wrappers); without
// an allowedClasses entry, sanitize-html lets a remote sender put ARBITRARY
// class names on <pre>, activating site CSS (N100). Restrict to the same
// language-* whitelist as <code>.
pre: ['language-*'],
},
allowedStyles: {
'*': {
+4 -1
View File
@@ -2,6 +2,7 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from
import { cryptoCallbacks } from './secretStorageKeys';
import { clearNavToActivePathStore } from '../app/state/navToActivePath';
import { removeFallbackSession } from '../app/state/sessions';
import { pushSessionToSW } from '../sw-session';
type Session = {
@@ -75,7 +76,9 @@ export const logoutClient = async (mx: MatrixClient) => {
// ignore if failed to logout
}
await mx.clearStores();
window.localStorage.clear();
// Remove only the session credential keys, preserving user preferences and
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
removeFallbackSession();
window.location.reload();
};