Files
cinny/LOTUS_TODO.md
jaredandClaude Opus 4.8 1176bea0ee
CI / Build & Quality Checks (push) Successful in 10m45s
CI / Trigger Desktop Build (push) Successful in 7s
docs(todo): record composer autocomplete-insert crash fix (477df4ae)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 16:13:34 -04:00

413 lines
62 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Lotus Chat — Work Backlog
**Repo:** `lotus` branch at `https://code.lotusguild.org/LotusGuild/cinny`
**Deploy:** push to `lotus` → CI → auto-deploy to `chat.lotusguild.org` (~11 min)
> Completed features are documented in [LOTUS_FEATURES.md](./LOTUS_FEATURES.md). Manual test steps live in [LOTUS_TESTING.md](./LOTUS_TESTING.md). This file is **open work only** — resolved audit findings and shipped-feature write-ups were removed 2026-07 (full history in git).
Status legend: `[ ]` pending · `[~]` in progress / shipped-awaiting-QA · `[x]` done · `[BLOCKED]` server/upstream-gated · `[DEFERRED]`/`[DROPPED]`/`[WON'T FIX]` decided.
---
## ⚠️ TDS DESIGN LAW — READ BEFORE TOUCHING ANY UI
> **ALL Lotus Terminal Design System (TDS) styling — colors, animations, glows, borders, fonts, spacing — MUST come exclusively from `/root/code/web_template/base.css` CSS variables.**
> Do NOT hardcode hex values. Do NOT invent new variable names. Canonical tokens: `--lt-accent-orange`, `--lt-accent-cyan`, `--lt-accent-green`, `--lt-glow-*`, `--lt-box-glow-*`, `--lt-border-color`, `--lt-font-mono`. Syntax-highlight token classes: `.tok-kw .tok-str .tok-num .tok-cmt .tok-fn`.
> Reference patterns: `/root/code/tinker_tickets/` (markdown.js, base.js, ticket.css). Applies to every task without exception.
> New components must respect both TDS dark (`LotusTerminalTheme`) and TDS light (`LotusTerminalLightTheme`); non-TDS theme work uses vanilla-extract (match `src/lotus-terminal.css.ts`).
## 🧩 NATIVE-CINNY LAW — EVERY FEATURE MUST FEEL LIKE STOCK CINNY
> **Every feature must feel native to upstream Cinny — indistinguishable from what the Cinny team would ship.** Reference: <https://github.com/cinnyapp/cinny>.
>
> - **Use the `folds` design system, not bespoke UI** (`Button`, `Chip`, `IconButton`, `Menu`, `MenuItem`, `Dialog`, `Modal`, `Input`, `Switch`, `Badge`, `SettingTile`, `SequenceCard`, …) and folds tokens (`color.*`, `config.space.*`, `config.radii.*`). **Use folds `Icon`/`Icons`, never literal emoji, in UI chrome.** No hardcoded hex/`rgba()`, no invented CSS variables.
> - **Match Cinny's existing patterns** — find the closest existing component/flow and mirror it before adding UI.
> - **The ONE exception:** explicit **TDS** features, which follow the TDS Design Law above (opt-in, only in Lotus Terminal mode).
---
## ✅ Audit (2026-07) — closed out
A three-wave feature bug-hunt (~15 parallel agents, each batch independently reviewed) plus a low-tail cleanup. All confirmed 🔴/🟠 and the clean 🟡 tail are **fixed, reviewed, and gate-green**; details in git history + LOTUS_FEATURES. Only the minor items below remain open.
**Still open (low tail — all 🟡 minor):**
-**Low-tail batch FIXED** (`a267e9e9`, 2-agent-reviewed, gate-green): **T5** (`participated` now also scans the local thread timeline, not just the server bundle → no under-notify), **T6** (room "Mentions & Keywords" honored for Default thread replies via a new `roomMentionsOnly` gate → no over-notify; +4 tests), **T7** (thread-mode account-data writes serialized with content carried forward → no lost update), **C-L2** (a real incoming ring cancels a lingering Settings preview), **C-L3** (ringtone AudioContext primed on first page gesture → first ring after cold load not silent), **C-L5** (`useCallSpeakers` depends on a stable boolean → no observer churn on membership change), **F5** (OIDC refresher forwards the refreshed token `expiry` as `expiresInMs``expiresAt` no longer stale across reloads). **Verified already-handled, no change:** **N6** (`useMemberAvatar` already subscribes via `useRoomMemberChange`), **H10** (`RoomProfile` already has `maxLength={255}` + surfaces the submit error).
- **Calls host (still open):** C-M1 deafen DOM-fallback leaks late-added `<audio>` tracks; C-M2 `.click()`-by-testid toggles no-op if EC renames — **both retire via EC-fork P6-2**. C-L1 AFK mic not released if EC elides the echo; C-L7 all-muted DOM miscount if EC label format differs; C-L8 PiP sw/nw resize anchor jitter at min size. **All four are EC-DOM/echo-behavior or visual-jitter items — need a real call + the EC iframe to verify; deferred.**
- **Native/desktop:** D7 Unity badge `application://cinny.desktop` id may not match the installed `.desktop` basename — **runtime-verify** on the `.deb`/AppImage.
- **EC fork (EC1EC6 fixed on `element-call:lotus`, needs a republish):** re-apply `setTimeout` cleanup, remote-gated subscription → `allConnections$`, per-call decoration state leak, re-subscribe-every-render, focus-clear on missing `userId`. Rides with **P6-2 phase 2**.
---
## ✅ Shipped — Awaiting Live Verification
Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then graduate to LOTUS_FEATURES.md. Includes the **desktop/native Tier A/B stack** (P5-35/36/41/42/43/44/46/47/48/49/55/56/57, P6-1 Linux parity) — all **CI-compile-verified, runtime-verify on Windows/Linux** — plus:
| Area | Test guide |
| :-------------------------------------------------------------------------- | :-------------------- |
| Full-Screen Camera Broadcasts (per-participant focus) | A5 / G2 |
| Advanced search filters + virtualized infinite scroll | K2 / M1 / M2 / M4 |
| Custom Accent Color Picker (non-TDS) · 5 Color Theme Presets | M3 / M5 |
| Intersection lazy media loading · context-aware thumbnails | H1 / H2 |
| Thread Panel (side drawer) + per-thread notification modes (P4-1) | (thread QA) |
| Encrypted message search indexing/caching (opt-in, default OFF) | search backlog |
| Remind Me Later · Mobile Bookmarks access | K1 / E5 |
| In-Call Soundboard (P5-15) · Quality Controls (P5-31) · Permissions (P5-31) | D2-7 / D2-8 / D2-9 |
| Desktop proactive update notifications (P5-40) | J1 |
| OIDC/SSO login (P4-6, needs an MSC3861 server — pick mozilla.org on login) | OIDC |
| Windows native WinRT toast quick-reply / click-to-open (D6, AUMID) | rich-toast (§backlog) |
| Inline media embeds (16 providers: video/audio/post + click-to-play facade) | Q1 / Q2 / Q3 / Q4 |
---
## 🔴 Open — Actionable
### ✅ Discovery pass (2026-07) — DP1DP18 DONE
Agent-surveyed + TPVR-verified correctness / a11y / tech-debt fixes (DP1DP18) are implemented, review-fixed, and gate-green (tsc + eslint + 737 tests + build). Verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md) §R, then remove this note. Full detail in git history — commits `8eb961b6` `db864326` `6cf18c3b` `165714e1` `8c0e2b42` `e545706c` `b1ee3ada` `4fc3f7a3` `101e4116` `4fa4327a` `c2598d21`.
### ✅ Discovery pass 2 (2026-07) — perf / security / correctness — DONE
Agent-surveyed findings, each **verified against the code before fixing**, then implemented and gate-green (tsc + eslint + prettier + 857 tests + build), with **two review agents on every staged diff before commit**. Verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then remove this note.
- [x] **PERF-1 — presence: 3 client listeners PER avatar → one shared presence store** (3 listeners total). `8a154051`.
- [x] **PERF-2 — `#`-mention autocomplete mutated + re-sorted the shared `allRoomsAtom` every keystroke** → copy + `useMemo` (also fixed a real shared-array mutation hitting ~27 consumers). `4708a179`.
- [x] **PERF-3 — read-receipt rows: ~6 global `Members` listeners per row → one shared member-change store** (`useRoomMemberChange`). `1b8f5545`.
- [x] **PERF-4 — message-search room filter re-sorted every render**`useMemo`. `4708a179`.
- [x] **PERF-5 — DM-preview `Decrypted` listener ran for every nav item** → gated on `direct`. `4708a179`.
- [x] **SEC-1 / SEC-2 — scheduled-message plaintext + recent searches survived logout**`clearPlaintextCaches()` on both logout paths, extended to the whole `recent_*` family + nav-paths. `726cefb5`.
- [x] **SEC-3 — `window.open(_blank)` without `noopener`**`noopener,noreferrer` at 5 sites (SSOStage excluded — needs the handle). `3e1106b2`.
- [x] **SEC-4 — `/acl` self-lockout footgun** → shared `serverAcl.ts` validation, no-brick allow default, fail-closed self-ban guard. `3e1106b2`.
- [x] **COR-1 — space-child UNLINK over-deleted** → targeted `UNLINK` reducer action. `fd3b8b42`.
- [x] **COR-2 — `useCallJoined` stuck true on 2nd-call embed swap** → re-seed on `[embed]`. `1f80d1d1`.
- [x] **COR-3 — incoming-call lifetime guard only corrected future clock-skew**`Math.abs(...)` (±20s). `ab01d27a`.
- [x] **COR-4 — shared notify-dedupe slot double-notified** → key by `roomId|threadId`. `1f80d1d1`.
- [x] **COR-5 — upload cancel ignored during retry back-off**`AbortSignal` threaded into the retry loop. `ab01d27a`.
- [x] **COR-6 — `CallControl.forceState` dropped `screenshareAudioMuted`** → passes it. `ab01d27a`.
**Deferred / decided (not built):**
- [DEFERRED] **PERF-6 — avatar-decoration `/profile` fetch** — well-guarded (module cache + in-flight dedupe + backoff); a network/HS-load note only. Batch/skip only if it proves costly.
- [DEFERRED] **SEC-5 — embeds' `allow-popups-to-escape-sandbox`** — informational; main-app hijack already prevented (no `allow-top-navigation`), and popups are arguably needed for "open in provider." Revisit with per-provider verification if dropped.
- **KE-1 preventive (`navigator.storage.persist()`)** is **already implemented** (`initClient``requestPersistentStorage()` + `src/index.tsx` boot). The rest of the KE cluster stays under **Encryption / E2EE** below (needs live capture).
### 🔍 Feature bug hunt (2026-07, 5-agent, LOTUS_FEATURES surface) — open findings
Per-slice bug hunt (5 agents: theming · calls · messaging · threads/presence/UX · rooms/mod/notif/infra/desktop), each **verified against current code** (already-fixed items not re-flagged; the heavily-audited hot paths came back clean). Residual findings below. `[live]` / `[desktop]` = needs a real call / the desktop app to confirm.
**Embeds / URL previews**
- [x] **[Med] Desktop (Tauri) CSP `frame-src` was missing `store.steampowered.com`, `www.mixcloud.com`, `widget.deezer.com`** → the Steam widget (shipped) + new Mixcloud/Deezer embeds were silently blocked (blank iframe) **in the desktop app**. **FIXED** (`cinny-desktop` `daba59b`): all three added to `frame-src` (no `connect-src` — these don't do a client oEmbed fetch). Web was always fine (`frame-src 'self' https:`). Needs desktop-app QA to confirm the widgets render.
- [x] **[Low]** `searchCache.ts` encrypted-search index has no size/count cap — unbounded on-disk growth (mitigated by the manual "Clear cached index" + logout wipe). **FIXED** (`fff811cb`): per-room cap of 5000 rows, oldest-by-ts evicted on write via a self-chaining IDB cursor + pure unit-tested `evictCount`. IDB-spec correctness (cursor delete/continue, tx liveness, range bracketing) confirmed by 2 review agents since CI can't run IndexedDB.
- [x] **[Low]** `MsgTypeRenderers.tsx` `MLocation` OSM permalink uses raw `geo:` lat/lon substrings, not the validated floats — harmless (URL context, malformed input only). **FIXED** (`8a461610`): permalink uses the `parseFloat`+`isFinite` validated `lat`/`lon` (as the map iframe already did).
**Voice / video calls**
- [x] **[Med]** `DenoiseTester.play()` (Settings → Calls A/B model test) leaks the denoise model node — calls `ctx.close()` but never `denoise.dispose()` (inconsistent with `stopLive`, which disposes) → leaks the DeepFilterNet/DTLN worker/WASM per press. **FIXED** (`c9d9d914`): `stopPlayback` now mirrors `stopLive` (dispose model + gate), and a generation token also closes the rapid-click / stop-during-load / unmount-during-load leak windows (3 review passes, all 6 interleavings traced).
- [x] **[Med] [live]** PiP auto-spotlight never released on return to the call room — the release branch sits inside the `if (!pipMode) return` guard, so screenshare→PiP→back leaves spotlight forced on and `pipAutoSpotlightRef` stuck `true`. `CallEmbedProvider.tsx:733-744`. **FIXED** (`08e19100`, code-level; still wants live QA): effect guards only on `!callEmbed`, releases whenever `pipMode && pipScreenshare` is false; + ref-reset on embed teardown + deps comment (2-agent reviewed).
- [x] **[Low]** DenoiseTester async paths (`getUserMedia`) have no mounted-guard → ctx/stream leak + setState-after-unmount if Settings closes during the mic prompt. **FIXED** (`c9d9d914`): a `mountedRef` guards `startLive`/`startRecord` after the `getUserMedia` await (and `play()` after its model load); the ref is set on mount, not only cleared on unmount, so it survives a StrictMode/Activity remount.
- [x] **[Low]** Soundboard 30s safety timeout never cleared on natural clip end (`CallSoundboard.tsx:115`); `PrescreenControls` `PermissionStatus.onchange` not removed on unmount (`PrescreenControls.tsx:22-28`). **FIXED** (`56561627`): per-play timer token cleared on end/unmount (identity-guarded so a stale clip can't disarm a newer one); permission `onchange` detached + `cancelled`-guarded setState.
- [ ] **[Low] [live]** Call-to-call switch disposes the embed without an explicit `HangupCall` → possible transient ghost RTC membership until EC's unload-leave fires.
**Theming / visuals**
- [x] **[Med]** `invalidateDecorationCache` clears the module cache but has no pub/sub → changing **your own** avatar decoration doesn't update live in already-mounted avatars (timeline/members) until remount. Add a listener set / bump counter. `useAvatarDecoration.ts:67`. **FIXED** (`29ff1654`): per-user listener set notified on invalidation (+ clears the give-up counter); concurrent re-fetches de-dupe via the existing `pending` map.
- [x] **[Med/Low]** Decoration picker grid thumbnails use the raw `DECORATION_CDN` constant instead of `decorationUrl()`, ignoring the `VITE_DECORATION_CDN` override → broken thumbnails if decorations are repointed. `ProfileDecoration.tsx:51`. **FIXED** (`29ff1654`): grid uses `decorationUrl(slug)`.
- [x] **[Low]** Seasonal "Auto" is computed once at mount (no ticker, unlike NightLight) → won't flip across a holiday-window boundary in a long-lived session. `SeasonalEffect.tsx:100`. **FIXED** (`d416c62b`): hourly re-eval ticker (auto only) + refresh on entering auto; decision extracted to pure `resolveSeasonTheme` + tested.
- [x] **[Low]** Selecting seasonal "Auto" while a chat background is set is a silent no-op (asymmetric mutual exclusion — SeasonalEffect early-returns when `chatBackground !== 'none'`). `General.tsx:550`. **FIXED** (`d416c62b`): any active seasonal mode (incl. auto) now clears the chat background; only "off" leaves it (symmetric with the bg picker).
- [x] **[Low]** Decoration settings fetch the `/{field}` sub-resource → console 404 for users with no decoration set. `ProfileDecoration.tsx:79`. **FIXED** (`29ff1654`): reads the full `/profile/{userId}` (matching `useAvatarDecoration`); PUT/save path unchanged.
**Threads / presence / UX**
- [x] **[Med]** `PresenceBadge` renders DND (`unavailable` + `status_msg:'dnd'`) as a **yellow "Idle"** badge + label, while `PresenceRingAvatar` correctly shows **red** — inconsistent. Give the badge the same `status === 'dnd' → Critical` + "Do Not Disturb" branch. `Presence.tsx:17-59`. **FIXED** (`29ff1654`): badge now matches the ring + settings picker (Critical / "Do Not Disturb", `'dnd'` sentinel line suppressed).
- [x] **[Med]** Collapsible-message threshold is hardcoded (`COLLAPSE_MAX_HEIGHT = 320`), but the docs claim it's "configurable in Settings → Appearance (default 20 lines)" — unimplemented. Add the setting + control, or fix the doc. `MsgTypeRenderers.tsx:38`. **FIXED** (doc): LOTUS_FEATURES now describes the fixed 320px (≈20-line) threshold; the full 320px is sensible and a per-user setting wasn't worth the surface — reconciled the doc rather than build a marginal setting.
- [x] **[Med/Low]** In-app toast container has no visible cap / scroll — a burst of messages across rooms while focused stacks toasts unbounded and can cover the viewport. Cap visible N or `overflow-y:auto` + max-height. `LotusToastContainer.tsx:223-247`. **FIXED** (`1963222d`): queue capped at 5 in the atom writer (drops oldest non-sticky, never the newest or a sticky action toast) + container maxHeight/overflow + scroll-to-newest; +4 tests. (3 review passes — the 2nd caught a newest-dropped edge when the cap is full of stickies.)
- [x] **[Low]** "Unread First" room sort leaves the (larger) read portion unordered — no activity fallback for the equal-unread case. `Home.tsx:213-222`. **FIXED** (`1963222d`): `factoryRoomIdByUnread` breaks ties by recent activity; relocated to `utils/sort.ts` (pure) + unit-tested.
- [x] **[Low]** Tab title "(N)" counts mentions, not unread messages (doc says unread) — reconcile doc vs. code. `ClientNonUIFeatures.tsx:120-123`. **FIXED** (doc): the mention-count + unread-dot behavior is intentional (mirrors the favicon); LOTUS_FEATURES now describes it accurately (N = highlights, `·` = other unread).
**Rooms / moderation / notifications / infra / desktop**
- [ ] **[Med] [desktop]** `useTauriFocusAssist` never queries the initial OS Focus-Assist state on mount (unlike `useTauriDnd`, which rehydrates via `get_tray_dnd`) → if Focus Assist is already ON at launch, notifications/sounds leak through until the OS state next flips. Add a `get_focus_assist` mount query (confirm whether the native poll emits an initial reading). `useTauriFocusAssist.ts:18-24`.
- [x] **[Low]** Push-rule enable toggle holds stale local `useState` after an external rule change (toggled on another device) — sync from the `pushRule.enabled` prop. `PushRuleEditor.tsx:55-79`. **FIXED** (`2c0cd0d2`): `useEffect` resyncs on `pushRule.enabled` change (prop flows from live `useAccountData(m.push_rules)`; no optimistic conflict).
- [x] **[Low]** Server-support `.well-known/matrix/support` is fetched from `mx.getHomeserverUrl()` (client-API host) instead of the MXID **server-name** host → silently missing on delegated/split-domain servers. `About.tsx:45-47`. **FIXED** (`2c0cd0d2`): fetched from `https://{mx.getDomain()}` (MSC1929-correct); identical for non-delegated, graceful catch otherwise.
- [x] **[Low]** Cleared/partial quiet-hours `time` input (`''` → window inactive) silently disables the window while the toggle still reads "on" — no feedback. `SystemNotification.tsx:364-382`. **FIXED** (`5175c095`): inline Critical hint when the toggle is on but a time field is empty.
- [~] **[Low] [desktop]** Native quick-reply swallows send errors (`.catch(() => undefined)`); the `show_rich_toast` trigger has no verified web-side caller. `useTauriToastActions.ts:35-38`. **ROOT CAUSE FOUND + web fix shipped** (`0ddf86c6`): `show_rich_toast` was dead because `showOsNotification` preferred the service worker (WebView2 has one), shadowing the injected `window.Notification` shim. Now skips the SW path under Tauri → notifications route to the rich toast, whose click navigates to the message.
### 🖥️ Desktop notification rich-toast — follow-ups (activated by `0ddf86c6`, need a Windows build)
The web-side nav fix (`0ddf86c6`) makes the native rich-toast path live for the first time. It fixes click→navigate, but exposes latent behaviors in the **cinny-desktop Rust** that need a Windows build to fix + verify:
- [ ] **[Med] [desktop]** **Tag-coalescing lost.** The web SW notification used `tag` to _replace_ prior notifications for the same room; `show_rich_toast` (`cinny-desktop/src-tauri/src/native/toast.rs`) ignores `tag` and shows a new WinRT toast every time → rapid same-room messages stack instead of collapsing. Fix: dedupe/replace by room in the toast store (`toast.rs:226-230`).
- [ ] **[Med] [desktop]** **Thread / invite quick-reply misroutes.** The reply target is the coalescing `tag``${roomId}:${threadId}` for thread replies, `'lotus-invites'` for invites (`ClientNonUIFeatures.tsx:471,192`) — not a real room id, so `mx.sendMessage(tag, …)` fails silently (`useTauriToastActions.ts:37`). Body-click navigation is correct (uses `path`). Fix: pass the real `roomId` separately (e.g. `data.roomId`) and have the shim (`lib.rs` `NOTIFICATION_BRIDGE`) + `toast.rs` use it for the reply target; keep `tag` for coalescing. Invite toasts should also drop the reply box (nothing to reply to).
- [ ] **[desktop QA] Windows notification checklist** (verify `0ddf86c6` + the above): (1) confirm the pre-fix symptom was focus-without-navigate; (2) message toast → click navigates to the message, quick-reply sends to the room; (3) thread toast → navigates, reply currently misroutes (until fixed above); (4) invite toast → navigates to invites; (5) rapid same-room messages → stacking until coalescing restored; (6) AUMID-missing/dev build → plain-notification fallback still shows; (7) web PWA unaffected.
- [x] **[Low]** Export-history date-range early-break can over-paginate + mislabel "truncated" in E2EE rooms (`oldestRawTs` only advances on decrypted `m.room.message`, so undecryptable old events never move it). `ExportRoomHistory.tsx:104,136`. **FIXED** (`3ff8fb8e`): boundary now advances on every event (getTs is envelope metadata), above the type/decryption filters; guarded `ts > 0` so a bogus 0-ts can't cause the opposite (silent under-pagination). 2-agent reviewed.
- [x] **[Info/doc]** `PolicyListViewer` is a manual room-ID/alias viewer with **no** subscribe/unsubscribe controls and no subscribed-lists listing — `LOTUS_FEATURES.md:1287` describes both. Docs oversell; not a runtime bug. **FIXED** (`8a461610`, doc): LOTUS_FEATURES corrected to describe the read-only room-ID/alias viewer (no subscribe controls).
### ✅ Composer autocomplete-insert crash (reported 2026-07) — FIXED (`477df4ae`)
Picking an autocomplete item (mention/emoji/command) occasionally tripped the composer error boundary ("encountered an error" → forced refresh) even though the element inserted. Root-caused (3 agents, incl. a headless slate simulation) to `moveCursor` deferring its cursor work to `setTimeout`, leaving the caret on the just-inserted inline-void's zero-width edge; slate-react's commit-phase `setBaseAndExtent(voidEdge, 1)` then threw `IndexSizeError` mid-render → boundary. **Fix:** do `Transforms.move` (escape the void) + `insertText(' ')` synchronously in the same commit as the insert, so the caret is a resolvable text point when the selection sync runs. Plus a recoverable boundary ("Reload composer" + `onReset` deselect) so any residual composer crash no longer needs a page refresh. (A first "sync insertText without move" attempt was caught in review — the void guard drops the space + traps the caret; `move` is required.)
### ✅ Unread/read-receipt flakiness (reported 2026-07) — FIXED (pending prod QA)
Room unread dots were inconsistent: reading a message sometimes cleared the dot, sometimes left it stuck, sometimes it resurrected. Root cause (confirmed by tracing + diffing upstream cinny `dev`): **our own "N4" change.** `handleReceipt` recomputed via `getUnreadInfo`, which reads `room.getUnreadNotificationCount()` — server-computed and **stale on the synchronous synthetic receipt echo** (SDK only zeroes it immediately when the last event is your own message) → it PUT the stale non-zero count back → stuck/resurrecting. Compounded by `hasUnread = !!unread` lighting the dot on any present map entry, incl. phantom `{0,0}` PUTs from our `UnreadNotifications` listener. Plus a Mark-as-Unread (MSC2867) flag that never cleared on opening an already-read room (no receipt → no auto-clear).
**Fix:** `roomToUnread.ts``handleReceipt` reverts to upstream's optimistic `DELETE` on own receipt; reducer collapses `{0,0}` PUT → DELETE. `notifications.ts markAsRead` clears the marked-unread flag directly. `markedUnread.ts onReceipt` gated to main/unthreaded receipts (`myMainReceiptPresent`). Unit tests added; 700/700 pass, typecheck + build clean. Deploy + manual QA (read → dot clears & stays; thread read; mark-unread → open → clears; reconnect no resurrect).
### 🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED
Observed live in prod 2026-06-30 during a 2-person **Element Call** (E2EE). These span client rust-crypto (`matrix-js-sdk@41.7.0`) ↔ Synapse ↔ EC MatrixRTC E2EE and are **interrelated** — do NOT spot-fix. **Capture first:** run **Settings → Developer Tools → Crypto Diagnostics** during the next affected call + a synapse-side trace before any fix. (Full runbook was in `LOTUS_E2EE_INVESTIGATION.md`, now in git history.) None are caused by the EC fork work.
- **KE-1 — OTK upload conflict storm (CRITICAL, root-cause candidate).** `POST /keys/upload` returns `400 M_UNKNOWN: One time key … already exists` continuously — the rust-crypto store and Synapse have **diverged OTK state** (upstream `matrix-rust-sdk#5200`, OPEN: on the 400 the SDK never marks the request sent → re-uploads forever; **not** fixed in 41.7.0). Leading web trigger: cinny never calls **`navigator.storage.persist()`**, so the IndexedDB crypto store is evictable while the `localStorage` session survives → device resurrects with a blank store. **Buildable preventive fix (no call needed):** request persistent storage on login (+ optional multi-tab guard + a 400-loop→recovery prompt). Healing an already-diverged device still needs a clean logout+login.
- **KE-2 — EC media keys not arriving/decrypting → audio/video cut out (CRITICAL).** `MissingKey … for participant`, unexpected encrypted to-device `io.element.call.encryption_keys`. Almost certainly downstream of KE-1 (broken Olm sessions). This is the "friend's audio cuts out" symptom.
- **KE-3 — Timeline decrypt error: missing `algorithm` field (HIGH).** rust-crypto can't parse a malformed/legacy encrypted event — capture the offending event id + raw content.
- **KE-4 — MatrixRTC delayed-event / membership timeouts (MEDIUM-HIGH).** `Restart delayed event timed out`, repeated `msc4157.update_delayed_event` — may be partly HS responsiveness; correlate with synapse latency. Same planning session (shares the call-reliability surface).
### Security & Privacy
- **N97 — Access token + device id in plaintext `localStorage`** (`state/sessions.ts`), XSS-exposed. Architectural — needs a token-protection / session-storage redesign.
- **Persisted PII without encryption:** user status message + expiry (`Profile.tsx`), unsent composer drafts (`RoomInput.tsx`). Leak risk on shared devices.
### PWA / Offline / Web Push
- **N107 — Web Push is non-functional:** `src/sw.ts` has no `push` handler. Needs a `push` listener + Matrix push-gateway integration. **The one substantive remaining feature** (session/crypto groundwork it waited on has landed).
- **No app-asset caching strategy** in `src/sw.ts` — no offline capability.
### Dependencies / Build / Hygiene
- Build-time: `lotusDenoise` does heavy sequential `fs` in `closeBundle`; `viteStaticCopy` has redundant renames — could be streamlined.
- `patch-folds.mjs` edits `node_modules` directly (robust today; `patch-package` considered but more brittle to folds restructuring — WON'T-DO unless it breaks).
- `types/matrix/` mirrors SDK types instead of importing them — drift risk; spot-fix highest-risk only.
- `contrib/nginx`/`contrib/caddy` examples: headers + `try_files` already synced with prod; the prod nginx `add_header` isn't inherited by cache `location` blocks (pre-existing; SPA entry `/` still gets all headers).
- `as any` casts across `src/` — gradual typing cleanup. Keep commits scoped (bisect-friendly). Keep README fork-sync version/logo current.
---
## 🌐 Matrix Protocol Gaps
Genuine Matrix client-spec / MSC features Lotus does **not** yet implement (audited 2026-07 against the codebase — almost everything else is built: pinning, stickers+picker, room directory, mutual rooms MSC2666, blurhash, key backup/recovery/SSSS, SAS verification, ignore list, invite spam-filter, voice messages, polls, threads, spaces, OIDC, extended profiles, delayed events, authed media). Build each **fully** — spec-correct events, native-Cinny folds UI, tests. Order = clean wins first.
**Phase A ✅ (2026-07, gate-green 683 tests):**
- [x] **Mark as Unread — MSC2867 `m.marked_unread`.** Room account data `{ unread: true }` (+ unstable `com.famedly.marked_unread`) via `mx.setRoomAccountData`; clear on read. Context-menu item in `RoomNavItem` + light the existing unread dot; integrate `state/room/roomToUnread.ts`.
- [x] **Low Priority rooms — `m.lowpriority` tag.** Mirror the favourite impl (`RoomNavItem.tsx:331-337` `setRoomTag/deleteRoomTag` + the favourites category in `home/Home.tsx`): context-menu toggle + a collapsed "Low Priority" category sorted to the bottom, excluded from normal unread nudging.
**Phase B ✅ (2026-07, gate-green 688 tests):**
- [x] **Disappearing Messages — MSC1763 `m.room.retention`.** PL-gated room-settings `SettingTile` to set `{ max_lifetime }`; retention badge; a client-side sweep hides/self-redacts own expired events (pattern like the mute-timer restore in `ClientNonUIFeatures.tsx`). True server deletion also wants Synapse `retention:` (LXC 151).
- [x] **QR Device Verification — reciprocate QR.** Add the QR path beside emoji-SAS in `components/DeviceVerification.tsx`: render with `qrcode.react` (already a dep), scan via `BarcodeDetector` (fallback `jsQR`); uses the SDK `VerificationRequest` QR/reciprocate support.
**Phase C (Room Widgets ✅ 2026-07; Sliding Sync ❌ evaluated — parked):**
- [x] **Room Widgets — MSC1236 + widget API.** No general widget UI exists (only the PL entry `im.vector.modular.widgets`; the EC call widget is hardcoded). Read `im.vector.modular.widgets`/`m.widget` state, add an Add/Manage panel + sandboxed iframe renderer via `matrix-widget-api`**extend the existing EC widget plumbing** (`plugins/call/CallEmbed.ts`). Enables Etherpad/notes/dashboards/integrations.
- **[PARKED] Sliding Sync — MSC3575 / simplified MSC4186** (evaluated 2026-07, 3 research passes). Server side is GA (`simplified_msc3575`), but the **client** side is not viable for a safe rollout: matrix-js-sdk's `SlidingSync`/`SlidingSyncSdk` are `_internal_`/`@experimental` (Element shipped labs-only, never GA in ~2 yrs, moved to the Rust SDK); **presence isn't delivered over sliding sync** (regresses Lotus presence badges/rings/status); **no upstream Cinny impl** to follow; and Cinny's whole nav (sidebar/spaces/DM/unread) is derived from the **full local room set** (`allRoomsAtom``mx.getRooms()`), so ~14 subsystems (4 core) need re-architecting to a server-windowed list. ~10% confidence a full rollout wouldn't break/regress (missing rooms/messages/unread = worst failure class). **Revisit only if we adopt the Rust SDK or accounts grow large enough that startup latency is a real complaint; an off-by-default experimental spike is possible but not recommended.** Full assessment: git plan history.
**Room Widgets v1 follow-ups:** capability-approval consent prompt (let widgets request send/read room events); Jitsi/stickerpicker special types; account-data (user/sticker) widgets; per-widget popout / always-on-screen. Requires the prod CSP `frame-src` widening (done in `matrix/cinny/nginx.conf`**`nginx -s reload`**) or external widgets are blocked.
**Server-gated / advanced (capture, don't build yet):** QR sign-in for a new device (**MSC4108** rendezvous — needs an HS-side endpoint); dehydrated devices (**MSC3814** — offline key delivery, also helps the E2EE KE cluster); E2EE history key sharing on invite (**MSC3061** `shared_history`, niche); voice broadcast (Element MSC3888, low value — skip).
### [PARKED] Matrix 2.0 call membership — MSC4354 Sticky Events (investigated 2026-07, 3 agents + live infra check)
Move MatrixRTC/Element Call call-membership from state events (MSC3401) to **sticky events** — the "Matrix 2.0" path. **Not a flag flip; a coordinated rollout. Parked deliberately.**
Findings:
- **Server (Synapse 1.157.1, LXC 151):** `msc4354_enabled` defaults `false`. Enabling is **low-risk, additive, reversible** — schema (`sticky_events` table) already ships unconditionally, no migration/backfill, all runtime paths flag-gated, residual rows self-expire ≤1h. The one historical `/sync` EDU-filter bug (#19787) was fixed in 1.155.0; SQLite guard N/A (we're Postgres).
- **The flag alone is a no-op for behavior.** Our EC fork (upstream **v0.20.1** base, `@lotusguild/element-call-embedded`, bundled into Cinny at build → fleet upgrades atomically) gates sticky mode behind BOTH server support AND a per-device **developer-settings** radio (`matrix-rtc-mode`, defaults `Legacy`). Enabling the flag only un-greys that radio; no client changes what it sends until a human toggles it.
- **Matrix-layer mixed-mode = safe:** js-sdk (v41.6.0) reads + merges sticky and state membership, so cross-mode participants see each other.
- **Open risk before any real rollout:** media layer. Sticky mode drops `livekit_alias` + uses lk-jwt-service `/get_token` (slot `m.call#ROOM`); legacy uses `/sfu/get` (`room=roomId`). Both endpoints are **live** on our lk-jwt-service, but whether they resolve to the **same LiveKit room** is unverified — must confirm with a **two-account cross-mode test call** (one device `Matrix_2_0`, one `Legacy`) before changing the default, else split-at-media.
To actually adopt (future): (1) enable `msc4354_enabled: true` + restart; (2) two-account media-interop test; (3) if unified, flip EC default mode `Legacy``Compatibility`/`Matrix_2_0` in the fork + redeploy; (4) keep legacy fallback during transition. **No user benefit until step 3.**
### [ ] Matrix 2.0 call membership — MSC4354 sticky events (INVESTIGATED 2026-07, deliberately NOT enabled)
3-agent investigation after the 1.157.1 upgrade (EC-fork behavior · Synapse/upstream readiness · client-fleet composition). **Conclusion: leave `msc4354_enabled` OFF for now** — enabling it is safe but delivers **zero user-visible benefit on its own**, and introduces a latent footgun.
**Why it's a no-op alone:** the EC fork's `doesServerSupportUnstableFeature(MSC4354)` probe feeds **exactly one thing** — whether the "Matrix 2.0" radio in **Developer Settings** is greyed out (`DeveloperSettingsTab.tsx:349-353`). The real switch is the per-device `matrixRTCMode` setting (`settings.ts:149-152`), which **defaults to `Legacy`** and never auto-enables. Sticky sending is gated at `LocalMember.ts:862` (`unstableSendStickyEvents: mode === Matrix_2_0`). So flipping the server flag changes nothing any client sends.
**Verified safe:** Synapse-side is **additive and cleanly reversible** — the `sticky_events` schema ships unconditionally (no migration/backfill on enable), every write/read/serialize/replication path is flag-gated, disabling stops it instantly and residual rows self-expire ≤1h. The one relevant bug (#19787 `/sync` EDU-filter) was fixed in 1.155.0; the SQLite<3.40 guard doesn't apply (we're on PG 17.10). Matrix-layer **mixed-mode visibility is safe**: js-sdk `collectMembersEvents` reads **both** sticky and state membership and merges them, so sticky-mode and legacy-mode participants see each other. Our `lk-jwt-service` already serves **both** JWT endpoints (legacy `/sfu/get` **and** the sticky-mode `/get_token` — both probed live, 400-with-validation-error = present). EC is bundled into cinny's build (`@lotusguild/element-call-embedded`), so the fleet upgrades **atomically** — the "all EC clients ≥ v0.17.0" precondition is structurally guaranteed for our own users.
**The one unresolved risk (blocks a real rollout, not the flag):** sticky mode drops `livekit_alias` and uses `/get_token` (slot `m.call#ROOM`) while legacy uses `/sfu/get` (`room=roomId`). **Whether both resolve to the same LiveKit room is a property of lk-jwt-service, not the client** — unverified. If they diverge, cross-mode participants appear in each other's member list but are **split at the media layer** (silent, no error). Requires a **two-account test call** (one device on Legacy, one on Matrix 2.0) to confirm before anyone relies on it.
**If we ever do this:** (1) run the two-account media-interop test; (2) only then consider enabling `msc4354_enabled: true` in `/etc/matrix-synapse/homeserver.yaml` (LXC 151) + restart; (3) treat a default-mode change as a separate coordinated EC rollout. MSC4354 is still **OPEN upstream** (not in FCP, `needs-implementation`), so this stays experimental regardless.
### Remaining spec/MSC gaps (2026-07 full-surface survey)
After Phases AC the client spec is ~complete. What's left, flagged by **what unblocks it**:
**✅ Buildable NOW (client-only, no server/infra change):**
- [ ] **Custom room tags / sections** — user-defined room categories in the sidebar via standard `u.*` room tags (beyond the built-in Favourite / Low-Priority). Mirrors the favourite/low-priority category pattern (`RoomNavItem` context-menu + `Home.tsx` categories). _Medium._ The only substantive client-only feature left.
**🔧 Needs INFRASTRUCTURE (NOT a Synapse-flag flip — you'd have to stand it up):**
- **Invite by email / 3PID invite** — we invite by Matrix user-ID only (`mx.invite` is user-ID-only). Email invites need an **identity server** (lotusguild runs none). Build only if an identity server is deployed.
- QR sign-in for a new device (**MSC4108**) — needs a **rendezvous** endpoint. Dehydrated devices (**MSC3814**) — needs server support. (Also listed above.)
**🚫 BLOCKED until a Synapse upgrade enables the flag** — re-run `/_matrix/client/versions` `unstable_features` after each upgrade; client work is ready the moment the flag flips. See the **Blocked Features** section below:
- Live Location Sharing (**MSC3489** + **MSC3672** — both `false`)
- Reaction / relation redaction (**MSC3892** — `false`)
- ~~Room preview before joining (MSC3266)~~ — **DONE** (client was always built; unstable `im.nheko.summary` endpoint returns 200 — verified on 1.156)
- Thread subscriptions (**MSC4306** — `false`)
**Niche / low-value (noted, not planned):** E2EE history-key-on-invite (MSC3061), voice broadcast (MSC3888), a native account-deactivation flow (currently delegated to the OIDC provider for OIDC accounts).
**Already implemented (verified, not gaps):** space reordering (drag — confirmed working in the desktop client), pinning, stickers + picker, room directory, mutual rooms (MSC2666), blurhash, key backup / recovery / SSSS / cross-signing / key export-import, SAS **and** QR verification, ignore list, invite spam-filter, voice messages, polls, threads + per-thread notifs, spaces, OIDC, extended profiles, delayed/scheduled events, authed media, report user/room/message, 3PID contact-info display, disappearing messages, mark-unread, low-priority, room widgets.
---
## 📋 Open Feature Backlog
### [ ] Basic in-app audio editor / video→audio extractor (LARGE PROJECT)
A minimal audio editor for soundboard clips and voice content. Scope: (1) **trim/clip** an audio file to a chosen start/end (waveform scrubber, in/out handles); (2) **upload a video file → strip and discard the video track, keep only the audio** (extract audio, then the source video is dropped — never uploaded/stored); (3) minimal edits only (trim, maybe gain/normalize, fade in/out) — not a full DAW. Likely Web Audio API (`AudioContext.decodeAudioData` → trim `AudioBuffer` → re-encode) + `MediaRecorder`/an encoder for output; video demux via a `<video>`+`MediaElementSource` capture or ffmpeg.wasm (weigh bundle cost). Feeds the soundboard uploader (`utils/soundboardClips.ts`, `SoundboardPackEditor`) and attachments. Design under TDS + native-cinny law. Big build — plan a dedicated session; evaluate ffmpeg.wasm size/CSP (wasm) before committing.
### [x] P4-4 · Math / LaTeX Rendering — DONE
Rendering shipped (KaTeX, `$…$`/`$$…$$` + spec `data-mx-maths`, lazy-loaded,
`<pre>/<code>`-guarded) — see LOTUS_FEATURES.md. **Outgoing cross-client interop
added (2026-07):** the composer now emits spec `data-mx-maths` HTML on send
(`editor/output.ts`, reusing `splitMathSegments`), so math a Lotus user types
renders on Element and every other client, not just Lotus. Deferred: multi-line
block `$$…$$` (spans editor paragraph nodes) still renders on Lotus via the
plain-body `$…$` path only.
### [~] P5-20 · Quick Reply from Browser Notification (partial)
Done: notifications show the real body, click navigates to the specific event + focuses the tab. **Remaining:** inline reply via Notification Actions API needs the SW `push`+`notificationclick` pipeline (switch `new Notification()``serviceWorkerRegistration.showNotification()` so the SW receives `notificationclick`; on `event.action==='reply'` POST `m.room.message` with the stored `{roomId, threadId}`). Ties into N107.
### [~] P5-30 · Advanced ML Noise Suppression — open verification
Shipped in the EC fork (DeepFilterNet3 default-capable / DTLN / RNNoise / Speex; AEC on, AGC off for ML tier; never-silent watchdog). **Open:** real-call by-ear **A/B** — model choice, `lotusDenoiseFloor`, AGC on/off (LOTUS_TESTING §D2-1 / J2). **GTCRN (deferred):** tiny MIT 16 kHz model beating RNNoise, but no drop-in browser package — needs `onnxruntime-web` in a Web Worker behind a custom AudioWorklet ring-buffer (ORT can't run in an AudioWorklet, issue #13072); ~1-week build. Revisit only if low-power quality proves insufficient. HW-gated (FRCRN/Maxine) = desktop-Rust-only future.
### [~] P6-2 · Element Call fork — retire remaining DOM hacks (Phase 2 needs publish)
Phase 1 shipped: `io.lotus.set_deafen` (LiveKit-source deafen/screenshare-audio-mute) replaces the brittle `<audio>.muted` iframe hack; cinny sends it join-gated alongside the transitional DOM fallback. **Phase 2 (blocked on user npm publish):** publish fork `0.20.1-lotus.2` → bump cinny pin `lotus.1``lotus.2` → delete the `CallControl.ts` `.muted` fallback + the EC1EC6 fixes ship. **Deferred pieces (P6-2b):** the `useCallSpeakers` DOM-scrape is a dormant fallback behind `io.lotus.call_state`; `.click()`-by-`data-testid` UI toggles are low-value fork surface. Divergence to confirm: deafen doesn't silence soundboard/`Unknown`-source audio (setVolume type limit).
### [~] Mobile audit — code-level pass DONE (device QA + deferred items open)
Comprehensive **code-level** responsive audit of the LOTUS_FEATURES surface (12 survey agents — 6 area slices + 6 deep per-feature dives — each finding verified, fixed in reviewed batches, then a 5-agent all-files regression+efficacy gate; gate-green tsc/eslint/857 tests/build). Shipped `lotus` commits `d6159997` `836e4a66` `4c298a36` `09415f95` `36fdbdd3` `154e35ef` `09f37f89` (M1M6 + N1N2): message-table/composer/call-bar/url-preview/explore overflow fixes; full-screen media/file/avatar viewers + touch-pan for zoomed images; full-screen scrollable member profile (+close btn); native SettingsSelect + tile-body volume sliders + measured GifPicker; full-screen Report/"Seen by" dialogs + full-width toasts + popover clamps; **image/video aspect-ratio (no crop/letterbox on phones)**; 44px room-row + space-rail touch targets. The app was found **structurally sound** on mobile (thread panel, dialogs, drawers, settings shells, ACL/widgets/search/QR/auth all already responsive).
Intentional desktop deltas (disclosed, non-regressive): volume sliders below labels; Report dialog 380→480px & "Seen by" modals 460→360px (sibling-modal normalization); translate select → folds SettingsSelect.
**NOT done — needs a real device / product decisions (open):**
- [ ] **Runtime mobile QA** — none of the above is validated on an actual phone (static analysis only). Needs device/devtools walk-through per LOTUS_TESTING §E.
- [x] **Element Call fork in-call mobile UI** — DONE (`element-call:lotus` `e36aef8a`, 3-agent survey + 2-agent review). Fixed the EC iframe's own phone UI: footer control row wraps so hangup can't clip (320500px), portrait 1:1 self-PiP safe-area inset, 44px camera-flip + reaction-picker targets, settings-tab horizontal scroll, landscape spotlight filmstrip. All mobile-gated (EC is mobile-first CSS). Rides to users on the next fork republish (P6-2). Runtime on-device QA still pending (needs a phone).
- [ ] **M2 — touch discoverability** — message quick-reactions/actions are hover-gated; long-press is the fallback but is **unreliable on iOS Safari** (deep audit). A visible touch affordance is needed but the naive fix hides unread badges / clutters messages (member-profile-style redesign).
- [~] **Sub-44px touch-target sweep** — primary controls DONE via a shared `MobileTouchTarget` `@media` class (`P1`, `8a1168bc`): in-call bar ×7, call-status bar ×4, thread "N replies" chip, knock Approve/Deny, ACL remove. Secondary batch DONE (`r2`, `72e7447d`): image-viewer close/zoom±/zoom%/download, embed-player Close/Collapse/Fullscreen/View-post, read-receipt "seen by" pill. **Deferred (rationale, not built):** PiP fullscreen/resize handles — enlarging four 24px corners to 44px would swallow a ~160px mobile PiP and block "Return to call" (needs a design rethink, not a blunt bump); presence dot is a non-interactive status indicator (no target needed).
- [x] **Avatar-decoration `prefers-reduced-motion`** — DONE (`P2`, `c3e1fbff`): renders just the avatar (no animated APNG overlay) under the preference; no static-frame asset to freeze to.
- [x] **Twitch/Twitter/TikTok preview cards** — DONE (`r2`, `72e7447d`). These fragment cards render header/thumbnail beside content as direct children of the `UrlPreview` flex row; added `StackOnMobile` (mobile-only `@media (max-width:750px){ flex-direction:column }`) scoped to those variants via `cardClass`. folds `Box` has no default `direction` so the override wins uncontested; desktop unchanged (verified by 2 review agents). Pre-existing desktop quirk (header bar beside content on Twitter/TikTok at desktop width) left as-is — the fuller fix is wrapping each card body in a column `Box`; out of scope for a mobile pass.
- [ ] **M2 — message action/quick-reaction touch discoverability** — hover-gated + iOS-long-press-unreliable; a visible touch affordance collides with unread-badge placement / per-message clutter → needs a design decision + device look.
### [ ] Inline media embeds — remaining providers (LOW PRIORITY)
The inline embed system (`videoEmbed.ts`) covers 18 providers (16 + Mixcloud/Deezer); three more were **deliberately deferred** (verified against 2026 docs by review agents):
- **Bandcamp** (highest-value audio add) — needs an **oEmbed** round-trip: the player URL requires numeric `album`/`track` item ids that aren't in the page URL (`bandcamp.com/oembed` is the resolver; mirror the `TikTokEmbedCard` on-click oEmbed pattern). CSP `frame-src`: `bandcamp.com`. Classify `kind: 'audio'`.
- **SoundCloud `on.soundcloud.com` short links** — the `w.soundcloud` widget resolver does **not** follow the redirect; needs the same on-click oEmbed resolve (`soundcloud.com/oembed`, CORS-enabled) to get the canonical URL. (Canonical `soundcloud.com/{user}/{track}` links already work.)
- **Vimeo `event/{id}` (live events) + `ondemand/…`** — event embed host is `vimeo.com` (**not** `player.vimeo.com`, so it needs a new CSP `frame-src` host); on-demand is paywalled and doesn't embed for non-purchasers. Low ROI — only do the event case if `vimeo.com` is widened for another reason.
Also open (from the quality review): a real `onError`/error-state fallback for iframes that fail to load (deleted post / region lock / X login-wall) — cross-origin frames don't fire `onError` reliably, so this needs a load-timeout heuristic; the Close button + badge link are the current escape hatch.
**✅ Steam detailed embed (2026-07, 2-agent review) — `ef82650c`.** `store.steampowered.com` content URLs get rich cards: **app** pages → OG capsule header + click-to-play facade → Steam's official `/widget/{id}` store iframe (live region-aware price / discount % / Buy on Steam, gated by `inlineMediaEmbeds`); **news/announcement** → banner + headline + body-preview card; **bundle/sub/dlc** → OG store card. `getSteamTarget`/`steamWidgetEmbedUrl` in `videoEmbed.ts` (+tests). Grounded in prod CSP (`frame-src https:` allows the widget with no infra change; images via homeserver `mxc`; NO client-side Steam API — `connect-src` + Steam CORS both block it, which is the honest ceiling: no review scores/genres/screenshots client-side). **Needs on-device QA:** the live widget iframe height/fit (can't render headlessly) — verify the price/Buy stay visible on desktop-wide and phone.
**✅ GIF previews now animate + Mixcloud/Deezer embeds (2026-07, 2-agent review) — `4154cae5`.** Reported live: a `media.giphy.com` link "shows the gif's image but doesn't play it." Root cause: **Synapse's `/thumbnail` endpoint flattens animated GIFs to a still first frame**, and every preview image went through it. `GifCard` (Giphy/Tenor) + the generic OG card now request the **original** via `/download` (`mxcUrlToHttp` with no width/height) when the preview is a GIF (`og:image:type === 'image/gif'` or a `.gif` pathname). Guarded: `shouldServeGifOriginal()` keeps the frozen thumbnail past a **10 MB** `matrix:image:size` cap, and the generic card's eager `<img>` gained the `loading="lazy"` it was the only preview image missing. Also added **Mixcloud + Deezer** audio embeds, and fixed Deezer podcasts (they live at `/show/<id>`, **not** `/podcast/<id>` — the latter 404s on Deezer's own oEmbed; verified against the live API). **Needs on-device QA:** confirm a large GIF still animates and doesn't stall the timeline.
**✅ Embed bug hunt (2026-07, 3 survey agents + 2-agent review) — `f2673eff`.** Core posture verified **sound** (iframe sandbox, `useIframeAutoHeight` postMessage origin+source trust, no XSS/`dangerouslySetInnerHTML`, `rel="noreferrer"` on all 21 links, oEmbed no-SSRF, the whole facade→iframe/abort/observer lifecycle). Fixed: Twitch/Kick/SoundCloud/Streamable reserved-path over-match (utility pages rendered as broken players), Vimeo hash over-capture (`[0-9a-f]{6,}`), Spotify/Steam/Discord/IMDb `og:image` now via `mxcUrlToHttp` (was a broken raw `mxc://` `<img>` + a pre-click 3p-request facade bypass), `wide` class follows the og:url-resolved embed, Twitter host alignment (`mobile.twitter.com`/`/statuses/`), URL de-dupe.
**Deferred / surfaced from the hunt (not fixed — decide before doing):**
- **Security-vs-functionality tradeoff (needs a call):** drop `allow-popups-to-escape-sandbox` and/or `clipboard-write` from `EMBED_SANDBOX`/`allow=` on embed iframes — real hardening against a _compromised_ provider (phishing popup / clipboard hijack), but risks breaking a legit provider popup/copy on the trusted major providers we embed. Low marginal value; not shipped blindly.
- **Defense-in-depth:** `encodeURIComponent` the Bluesky authority + Apple Music path/search interpolated into the embed `src` (not currently exploitable — host is fixed and value comes from `URL.pathname`; React escapes the attribute).
- **Out of embed scope (real, low-sev):** `LotusDenoiseFeature` (`ClientNonUIFeatures.tsx`) has a `window` `message` listener with **no origin/source check** → any frame/window can post `{type:'lotus-denoise-status', error}` and pop a forged **"System"** toast (text only, no XSS). Validate `event.source`.
- **Lifecycle Lows (cosmetic/latent):** a re-fetch flips a playing embed back to the spinner (latent — url is keyed); auto-height retained across close→reopen; `extractEmbedHeight` generic `.height` fallback accepts any allowed-origin message; `TweetEmbed` theme is a one-time `matchMedia` snapshot (no live theme switch); host-normalization gaps (`vt.tiktok.com` misses `StackOnMobile`, `m.instagram.com`, `www.youtu.be`).
### Deferred / dropped (decided — kept for context)
- **[DEFERRED] P5-51** Federated "Identity Contexts" (session isolation) — multi-sprint, touches auth/crypto/storage core; smaller intermediate step = plain multi-account switch. **[DROPPED] P5-52** per-room sync governor — js-sdk can't truly per-room filter `/sync`; only a cosmetic hide. **[DEFERRED] P5-53** local scripting plugin — prefer a declarative automation-rules feature (no arbitrary code). **[DEFERRED] Audit-3** profile banner — MSC4427 open/unmerged; revisit on merge. **[WON'T FIX] P5-50** Windows HW media pipeline (WebRTC decode lives in WebView2; not injectable). **[MOVED] P5-9** LFG → LotusBot `!lfg`.
---
## 🚫 Blocked Features (server / upstream gated)
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.157.1 (2026-07-23): no change — all four below are still `false`.** The 1.156.0→1.157.1 delta unblocked nothing (it's a bugfix release; the only feature-bearing release in the gap was 1.156.0, which we were already running).
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
- **[DONE 2026-07] Room Preview before joining** (MSC3266) — the client was always built (`JoinBeforeNavigate``RoomCard` via `mx.getRoomSummary`). The earlier "blocked" flag was a **misdiagnosis**: it tested `/v1/rooms/{id}/summary` (404), but the SDK calls the _unstable_ `im.nheko.summary/summary/{id}` path, which returns **200** with name/topic/members/join_rule. Verified live after the 1.156 upgrade; also added a join-rule/encryption chip + Request-to-join for knock rooms to the preview card.
- **[BLOCKED] Thread Subscriptions** (MSC4306 `false`) — "Follow thread" button (depends on the shipped Thread Panel).
---
## 📖 Reference
### Server Capabilities (as of 2026-07)
- **Homeserver** `matrix.lotusguild.org` · **Synapse** `1.157.1+trixie1` (upgraded 2026-07-23 from **1.156.0** — note the host was found on 1.156.0 while the docs claimed 1.155.0, so **always verify with `dpkg-query -W matrix-synapse-py3`**, don't trust the docs; apt package on Debian 13, LXC 151) · **Matrix spec** up to `v1.12` (Synapse still advertises v1.12; MSC features via `unstable_features`).
- **MSC ON** (re-dumped live from `/_matrix/client/versions` on 1.157.1): `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` (room summary live at unstable `im.nheko.summary/summary/{id}` — 200; the `/v1/rooms/{id}/summary` path is still 404) · `msc3401_matrix_rtc` · `msc2285.stable` · `msc3827.stable` · `msc3981` · `msc4380.stable` · `msc4445` · `msc2659.stable` · `msc2666` · `msc2432` · `e2e_cross_signing` · `label_based_filtering`. **OFF/blocked:** `msc4306` · `msc3882` · `msc3912` · `msc4155` · `msc3489`/`msc3672` · `msc3892` · `msc4028` · `msc4069` · `msc4108` · `msc3391` · `msc4354` (sticky events — **deliberately off**, see the Matrix 2.0 section above) · `msc4143` (RTC foci — **not a gap**: LiveKit is discovered via `.well-known` `org.matrix.msc4143.rtc_foci`, confirmed live, not this flag).
- **Dead client code:** Synapse 1.157.0 **removed** `msc3861` (MAS auth delegation) entirely — the ~6 `msc3861`/`msc2965` references in `src/` can never activate against this homeserver (we auth via Authelia `oidc_providers`). Harmless, but cleanup material.
- **Live endpoints:** Report User (MSC4260) **200** ✅ · Report Room (MSC4151) ✅.
- **Homeserver access (audits):** Synapse = LXC 151 (`pct exec 151 -- bash`), config `/etc/matrix-synapse/homeserver.yaml`. Web deploy = LXC 106. Voice guard = `voice-limit-guard.py` on LXC 151.
- **SDK notes:** no arbitrary profile-field methods (use `mx.http.authedRequest()` for MSC4133); js-sdk can't per-room filter `/sync`; sanitizer strips `<math>`/MathML; SW exists at `src/sw.ts`; `getMatrixToRoom()` builds invite URLs; EC audio-inject unblocked via the fork's `io.lotus.inject_audio`.
### Key File Reference
| What | File | Lines |
| ------------------------------ | ------------------------------------------------------------------- | ------------------- |
| Global keydown / room nav | `hooks/useKeyDown.ts` · `hooks/useRoomNavigate.ts` | whole / 19-72 |
| Room unread counts atom | `state/room/roomToUnread.ts` | `roomToUnreadAtom` |
| Overlay portal provider | `pages/App.tsx` · `index.html` | 65 / 101 |
| Room settings tabs | `features/room-settings/RoomSettings.tsx` | 27-56 |
| State event read/write pattern | `features/common-settings/general/RoomEncryption.tsx` | 42-52 |
| Power levels | `hooks/usePowerLevels.ts` | whole |
| Slash commands | `hooks/useCommands.ts` | 140-537 |
| Chat background picker/defs | `features/settings/general/General.tsx` · `lotus/chatBackground.ts` | 945-981 / whole |
| Matrix.to URL builder | `plugins/matrix-to.ts` | `getMatrixToRoom()` |
| Media URL conversion | `utils/matrix.ts` | `mxcUrlToHttp()` |
| Search pagination / virtual | `features/message-search/{useMessageSearch,MessageSearch}.tsx` | 74-121 / 234-365 |
| Call mic control | `plugins/call/CallControl.ts` | 206-212 |
| Knock support check | `utils/matrix.ts` | 376-391 |
| Notification mute push rules | `hooks/useRoomsNotificationPreferences.ts` | 110-150 |
### Element Call fork — operational reference
Fork = `LotusGuild/element-call` (branch `lotus`, from upstream tag `v0.20.1`); cinny consumes the npm package `@lotusguild/element-call-embedded` (built bundle copied into `public/element-call/`).
**Publish a new version (manual; needs the Gitea npm token):** bump `embedded/web/package.json` (current unpublished `0.20.1-lotus.2`) → `pnpm run build:embedded` (Node 24, pnpm 10.33) → `cd embedded/web && npm version <tag> --no-git-tag-version && npm publish` (Gitea registry) → in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.20.1-lotus.1`) → `npm install` → build.
**`io.lotus.*` widget actions** (add new toWidget actions to the enum + `LOTUS_TO_WIDGET_ACTIONS` in `src/lotus/lotusActions.ts`; only send AFTER call-join or a 10s timeout fires):
| Action | Dir | Purpose | Module |
| :--------------------------- | :------ | :----------------------------------------------------- | :-------------------- |
| `io.lotus.call_state` | EC→host | speaker/mute/camera stream (`lotusCallState=1`) | `lotusCallState.ts` |
| `io.lotus.focus_participant` | host→EC | spotlight (works during screenshare) | `lotusFocus.ts` |
| `io.lotus.inject_audio` | host→EC | soundboard clip mixed into call (`lotusAudioInject=1`) | `lotusAudioInject.ts` |
| `io.lotus.set_quality` | host→EC | audio/screenshare bitrate/fps caps | `lotusQuality.ts` |
| `io.lotus.decorations` | host→EC | in-call avatar decorations | `lotusDecorations.ts` |
| `io.lotus.set_deafen` | host→EC | LiveKit-source deafen (P6-2) | `lotusDeafen.ts` |
Also flag-gated: `lotusTransparent`/`lotusTheme`, `lotusDenoiseSource=1` (in-source ML denoise).
### CI/CD + per-feature checklist
```
edit → commit → git push origin lotus
→ Gitea Actions (.gitea/workflows/ci.yml): npm ci → build + npm test + tsc + eslint + prettier (ALL hard gates) → audit + bundle-size (informational)
→ lotus_deploy.sh on LXC 106 polls the "Build & Quality Checks" status → npm ci && npm run build → rsync → live (~11 min)
```
Before marking a feature complete: `npx tsc --noEmit` (0 errors) · `npx eslint src/` (0 new) · `npx prettier --check src/` · `npm test` (Node runner via tsx, hard CI gate — colocated `*.test.ts`) · update `README.md`/`landing/index.html` for Lotus-custom features · visually verify on `chat.lotusguild.org`.
**CI hardening (2026-07, reviewed):**
- [x] **Concurrency**`cancel-in-progress` on cinny `ci.yml` and cinny-desktop `release.yml` (`386a2979` / `c5461ce`): a superseded lotus push cancels its in-flight web CI and collapses queued ~30-min Tauri desktop builds to just the newest. Safe for deploys because `lotus_deploy.sh` now **follows origin/lotus HEAD** each poll iteration + resets to the gated SHA (`matrix` `c15a489`) — closes the latched-SHA freeze race.
- [x] **Hard quality gates** — typecheck/eslint/prettier promoted from `continue-on-error` to blocking (tree held clean). eslint gates on errors only; `no-explicit-any` warnings stay informational.
**CI follow-ups (open):**
- [ ] **Dedicated `desktop-linux` runner** (infra) — concurrency only collapses _burst_ stacking; a single in-flight `build-linux` (Tauri, `ubuntu-latest`) still shares the runner with web CI and can queue a web CI/deploy up to ~30 min. Fix = register a 2nd Linux act_runner labelled `desktop-linux` (root, network, RAM for a Tauri build; do NOT also label it `ubuntu-latest`) and point only `build-linux: runs-on` at it. Relabeling without a matching runner hangs the job forever.
- [ ] **Debounce the desktop trigger**`trigger-desktop` fires a full desktop build on _every_ lotus commit; consider tag/`workflow_dispatch`/schedule-gating to decouple desktop cadence from web commits (biggest remaining runner-load source).
- [ ] **Verify Gitea ≥ 1.24** actually honors workflow `concurrency` (older silently ignores it → safe no-op, but the change is then inert — confirm on a test burst).
- [ ] **Deferred (chosen-not-now):** build-once/deploy-the-artifact (kill the CI-then-deploy double build); CI-gate the `lotus-build.sh` upstream-merge path (currently builds+deploys+then pushes, bypassing CI).