# 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):**

- **Calls host:** C-M1 deafen DOM-fallback leaks late-added `<audio>` tracks; C-M2 `.click()`-by-testid toggles no-op if EC renames — **both retire via EC-fork P6-2**. C-L1 AFK mic not released if EC elides the echo; C-L2 ringtone-preview global cross-cancel; C-L3 first ring after cold load can be silent (ctx not unlocked); C-L5 speaker-observer churn on membership change; C-L7 all-muted DOM miscount if EC label format differs; C-L8 PiP sw/nw resize anchor jitter at min size.
- **Threads:** T5 `participating` detection is server-bundle-only (`thread.hasCurrentUserParticipated`) → can under-notify a thread you just replied to; T6 room "Mentions & Keywords" not honored for participated/Default thread replies (over-notify); T7 account-data thread-mute write is a lost-update race.
- **Crypto/session:** F5 OIDC refresh drops `expiresAt` on persist (`persistTokens` can't reach the expiry without SDK-internal plumbing; refresh is reactive on 401).
- **Native/desktop:** D7 Unity badge `application://cinny.desktop` id may not match the installed `.desktop` basename — **runtime-verify** on the `.deb`/AppImage. H10 room-name setter fire-and-forget/silent length reject (trivial). N6 per-message read-receipt avatars may not refresh on membership change (emitter uncertain, low impact).
- **EC fork (EC1–EC6 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) — DP1–DP18 DONE

Agent-surveyed + TPVR-verified correctness / a11y / tech-debt fixes (DP1–DP18) 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 (agent-surveyed, single-pass — NOT yet TPVR'd)

Three agents swept fresh lenses (performance, security/privacy, correctness in under-covered subsystems); each verified its own findings against the code + SDK. `Sev`/`Eff` = S/M/L. Confirm before fixing (no independent TPVR yet).

**Performance / rendering:**

- [ ] **PERF-1 — Presence: 3 global client listeners registered PER avatar (Sev High · Eff M).** `hooks/useUserPresence.ts:43-53` (via `PresenceRingAvatar.tsx:19`, `MembersDrawer.tsx:142`) — every timeline/member avatar adds `mx.on` for `Presence`/`CurrentlyActive`/`LastPresenceTs` → 100–250 global listeners; each presence event fans out across all, and fast scroll churns add/remove per Message mount. Fix: one shared presence store (context + `useSyncExternalStore` or a jotai `atomFamily`) with 3 listeners total; components select by userId.
- [ ] **PERF-2 — `#`-mention autocomplete re-sorts AND mutates the room list every keystroke (Sev Med · Eff S).** `components/editor/autocomplete/RoomMentionAutocomplete.tsx:83` — `useAtomValue(allRoomsAtom).sort(factoryRoomIdByActivity(mx))` is unmemoized (O(N log N) `mx.getRoom` per compare, N=all joined rooms, per keypress) **and `.sort()` mutates the shared `allRoomsAtom` array in place.** Fix: `useMemo(() => [...rooms].sort(...), [rooms, mx])`.
- [ ] **PERF-3 — Read-receipt rows attach per-instance global `RoomStateEvent.Members` listeners (Sev Med · Eff M).** `components/read-receipt-avatars/ReadReceiptAvatars.tsx:74` + `hooks/useMemberAvatar.ts:44` — up to ~6 global `Members` listeners per receipt row; fires on any membership/name/avatar change in any room. Fix: one shared room-member-change subscription fanning out by roomId+userId (shares with PERF-1).
- [ ] **PERF-4 — Message-search room filter re-sorts full room list every render (Sev Low–Med · Eff S).** `features/message-search/SearchFilters.tsx:155` — `Array.from(...).sort(factoryRoomIdByAtoZ(mx))` outside `useMemo` (re-sorts as the user types). Fix: `useMemo`.
- [ ] **PERF-5 — DM-preview hook subscribes a global `Decrypted` listener for EVERY nav item incl. non-DMs (Sev Low–Med · Eff S).** `hooks/useRoomLatestRenderedEvent.ts:64-67` (called at `RoomNavItem.tsx:681`) — preview is only used for `direct` rooms but the hook + its `MatrixEventEvent.Decrypted` listener run for all; wasteful, and fans out across all during bulk decryption. Fix: gate the hook/subscription on `direct`.
- [ ] **PERF-6 — Avatar-decoration profile-fetch on first render of large member/timeline views (Sev Low · Eff M, likely leave).** `hooks/useAvatarDecoration.ts` — one `/profile/{userId}` per new user (well-guarded: module cache + in-flight dedupe + backoff). Network/HS-load note only; batch/skip only if it proves costly.

**Security / privacy:** _(no exploitable XSS found — `sanitize.ts` strips `javascript:`, forces mxc `<img>` + `rel=noopener` `<a>`; embeds use host-allowlist + origin-scoped `postMessage`; KaTeX `trust:false`. These are storage-hygiene / URL items.)_

- [ ] **SEC-1 — Scheduled-message plaintext persists in `localStorage`, survives logout (Sev Med · Eff S).** `state/scheduledMessages.ts:12-19` — full `IContent` (incl. E2EE cleartext `body`) under `cinny_scheduled_messages_v1`; normal logout (`removeFallbackSession`, N98) keeps it. Leak on shared/kiosk browser. Fix: clear on logout (add to `removeFallbackSession`) — or encrypt-at-rest with N97.
- [ ] **SEC-2 — Recent search terms persist in `localStorage`, survive logout (Sev Low · Eff S).** `state/recentSearches.ts:4-12` — last 10 queries (often PII) under `cinny_recent_searches_v1`, survive normal logout. Fix: clear on logout.
- [ ] **SEC-3 — `window.open(url, '_blank')` without `'noopener'` → reverse tab-nabbing (Sev Low · Eff S).** `components/user-profile/UserChips.tsx:117` (+ `SSOStage.tsx:23`, `settings/devices/Verification.tsx:276`, `OtherDevices.tsx:36,49`, `OidcManageAccount.tsx:23`) — `window.open` doesn't null `window.opener`. UserChips opens a user/room-derived server URL. Fix: pass `'noopener,noreferrer'` (or set `w.opener = null`).
- [ ] **SEC-4 — `/acl` has no `*`/self-lockout guard or glob validation (Sev Low · Eff S–M).** `hooks/useCommands.ts:489-538` — `/acl -d *` writes `deny:['*']` (can brick the room); empty/whitespace globs pushed unvalidated into `m.room.server_acl`. PL-gated + matches upstream, but a destructive footgun. Fix: reject a `*`/empty deny matching the local server, validate glob syntax, confirm on `*`.
- [ ] **SEC-5 — Embeds get `allow-popups-to-escape-sandbox` (Sev Low, informational · Eff M).** `components/url-preview/UrlPreviewCard.tsx:590-591` (`EMBED_SANDBOX`) — a hijacked provider script could spawn an un-sandboxed popup (phishing). Main-app hijack already prevented (no `allow-top-navigation`); popups arguably needed for "open in provider." Fix (if desired): drop the escape flag + verify per-provider.

**Correctness (under-covered subsystems):**

- [ ] **COR-1 — Space-child UNLINK nukes a room's OTHER parents + orphans descendants (Sev Med · Eff M).** `state/room/roomToParents.ts:102-103` (DELETE reducer `:53-66`) — removing one `m.space.child` fires the same `DELETE` used for "room left," wiping ALL of the child's parents and stripping it as a parent everywhere. Remove C from space A only → C also loses parent B, and C's own children lose C, until a full resync. Fix: a targeted "remove one parent from one child" action. (Reducer inherited from upstream.)
- [ ] **COR-2 — `useCallJoined` stays `true` across a direct embed swap (answering a 2nd call) (Sev Med · Eff S).** `hooks/useCallEmbed.ts:140-158` — the reset effect only clears `joined` when `!embed`; answering call B from the banner swaps embed A→B directly, so `joined` stays true → B renders as live/joined before EC actually joins, skipping the loading/watchdog UI. Fix: `useEffect(() => setJoined(embed?.joined ?? false), [embed])`.
- [ ] **COR-3 — Incoming-call lifetime guard only corrects FUTURE clock-skew, not past (Sev Low · Eff S).** `components/CallEmbedProvider.tsx:476-477` — distrusts `sender_ts` only when >20 s ahead of `origin_server_ts`; a caller clock that's behind leaves `sender_ts` in the past → `remaining ≤ 0` → ring auto-dismisses/never shows for a fresh invite. Fix: `Math.abs(...) > 20000` or just use `event.getTs()`.
- [ ] **COR-4 — Shared notify-dedupe slot double-notifies a re-fired main message after an interleaving thread reply (Sev Low · Eff S).** `pages/client/ClientNonUIFeatures.tsx:521,530` — `lastNotifiedEventRef` is one `Map<roomId,eventId>` slot shared by the main + thread paths; a thread reply overwrites it, so a re-emitted main message (decrypt/edit re-fire, common in E2EE) mis-dedupes and notifies again. Fix: separate dedupe keys per path (or key by `roomId|eventId`).
- [ ] **COR-5 — Upload cancel during retry back-off is silently ignored (upload resurrects) (Sev Low · Eff M).** `utils/matrix.ts:204-238` + `state/upload.ts:125-129` — during the 1–30 s back-off the atom holds the previous (rejected) promise, so `cancelUpload` is a no-op and the loop wakes to start the next attempt. Fix: thread an abort flag into the retry loop.
- [ ] **COR-6 — `CallControl.forceState` resets `screenshareAudioMuted` to false (Sev Low · Eff S).** `plugins/call/CallControl.ts:155-162` — `forceState` passes only 5 args so the 6th defaults false; low impact today (runs at join, no screenshare yet) but silently discards the field. Fix: pass `this.screenshareAudioMuted`.

### ✅ 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).

### Remaining spec/MSC gaps (2026-07 full-surface survey)

After Phases A–C 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 EC1–EC6 fixes ship. **Deferred pieces (P6-2b):** the `useCallSpeakers` DOM-scrape is a dormant fallback behind `io.lotus.call_state`; `.click()`-by-`data-testid` UI toggles are low-value fork surface. Divergence to confirm: deafen doesn't silence soundboard/`Unknown`-source audio (setVolume type limit).

### [ ] Mobile audit

Comprehensive audit of all LOTUS_FEATURES.md features for mobile PWA usability + responsiveness. Method: 44px touch targets, no horizontal overflow, full-screen modals/drawers on mobile, composer not obscured by keyboard.

### [ ] Inline media embeds — remaining providers (LOW PRIORITY)

The inline embed system (`videoEmbed.ts`) covers 16 providers; 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.

### 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.

- **[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.156.0+trixie1` (upgraded 2026-07-07 from 1.155; 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:** `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`. **OFF/blocked:** `msc4306` · `msc3882` · `msc3912` · `msc4155` · `msc3489`/`msc3672` · `msc3892`.
- **Live endpoints:** Report User (MSC4260) **200** ✅ · Report Room (MSC4151) ✅.
- **Homeserver access (audits):** Synapse = LXC 151 (`pct exec 151 -- bash`), config `/etc/matrix-synapse/homeserver.yaml`. Web deploy = LXC 106. Voice guard = `voice-limit-guard.py` on LXC 151.
- **SDK notes:** no arbitrary profile-field methods (use `mx.http.authedRequest()` for MSC4133); js-sdk can't per-room filter `/sync`; sanitizer strips `<math>`/MathML; SW exists at `src/sw.ts`; `getMatrixToRoom()` builds invite URLs; EC audio-inject unblocked via the fork's `io.lotus.inject_audio`.

### Key File Reference

| What                           | File                                                                | Lines               |
| ------------------------------ | ------------------------------------------------------------------- | ------------------- |
| Global keydown / room nav      | `hooks/useKeyDown.ts` · `hooks/useRoomNavigate.ts`                  | whole / 19-72       |
| Room unread counts atom        | `state/room/roomToUnread.ts`                                        | `roomToUnreadAtom`  |
| Overlay portal provider        | `pages/App.tsx` · `index.html`                                      | 65 / 101            |
| Room settings tabs             | `features/room-settings/RoomSettings.tsx`                           | 27-56               |
| State event read/write pattern | `features/common-settings/general/RoomEncryption.tsx`               | 42-52               |
| Power levels                   | `hooks/usePowerLevels.ts`                                           | whole               |
| Slash commands                 | `hooks/useCommands.ts`                                              | 140-537             |
| Chat background picker/defs    | `features/settings/general/General.tsx` · `lotus/chatBackground.ts` | 945-981 / whole     |
| Matrix.to URL builder          | `plugins/matrix-to.ts`                                              | `getMatrixToRoom()` |
| Media URL conversion           | `utils/matrix.ts`                                                   | `mxcUrlToHttp()`    |
| Search pagination / virtual    | `features/message-search/{useMessageSearch,MessageSearch}.tsx`      | 74-121 / 234-365    |
| Call mic control               | `plugins/call/CallControl.ts`                                       | 206-212             |
| Knock support check            | `utils/matrix.ts`                                                   | 376-391             |
| Notification mute push rules   | `hooks/useRoomsNotificationPreferences.ts`                          | 110-150             |

### Element Call fork — operational reference

Fork = `LotusGuild/element-call` (branch `lotus`, from upstream tag `v0.20.1`); cinny consumes the npm package `@lotusguild/element-call-embedded` (built bundle copied into `public/element-call/`).

**Publish a new version (manual; needs the Gitea npm token):** bump `embedded/web/package.json` (current unpublished `0.20.1-lotus.2`) → `pnpm run build:embedded` (Node 24, pnpm 10.33) → `cd embedded/web && npm version <tag> --no-git-tag-version && npm publish` (Gitea registry) → in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.20.1-lotus.1`) → `npm install` → build.

**`io.lotus.*` widget actions** (add new toWidget actions to the enum + `LOTUS_TO_WIDGET_ACTIONS` in `src/lotus/lotusActions.ts`; only send AFTER call-join or a 10s timeout fires):

| Action                       | Dir     | Purpose                                                | Module                |
| :--------------------------- | :------ | :----------------------------------------------------- | :-------------------- |
| `io.lotus.call_state`        | EC→host | speaker/mute/camera stream (`lotusCallState=1`)        | `lotusCallState.ts`   |
| `io.lotus.focus_participant` | host→EC | spotlight (works during screenshare)                   | `lotusFocus.ts`       |
| `io.lotus.inject_audio`      | host→EC | soundboard clip mixed into call (`lotusAudioInject=1`) | `lotusAudioInject.ts` |
| `io.lotus.set_quality`       | host→EC | audio/screenshare bitrate/fps caps                     | `lotusQuality.ts`     |
| `io.lotus.decorations`       | host→EC | in-call avatar decorations                             | `lotusDecorations.ts` |
| `io.lotus.set_deafen`        | host→EC | LiveKit-source deafen (P6-2)                           | `lotusDeafen.ts`      |

Also flag-gated: `lotusTransparent`/`lotusTheme`, `lotusDenoiseSource=1` (in-source ML denoise).

### CI/CD + per-feature checklist

```
edit → commit → git push origin lotus
→ Gitea Actions: tsc --noEmit, eslint, prettier (~3 min)
→ lotus_deploy.sh on LXC 106 polls CI → 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`.
