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

62 KiB
Raw Permalink Blame History

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. Manual test steps live in 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 expiresInMsexpiresAt 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, 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 §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, then remove this note.

  • PERF-1 — presence: 3 client listeners PER avatar → one shared presence store (3 listeners total). 8a154051.
  • 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.
  • PERF-3 — read-receipt rows: ~6 global Members listeners per row → one shared member-change store (useRoomMemberChange). 1b8f5545.
  • PERF-4 — message-search room filter re-sorted every renderuseMemo. 4708a179.
  • PERF-5 — DM-preview Decrypted listener ran for every nav item → gated on direct. 4708a179.
  • SEC-1 / SEC-2 — scheduled-message plaintext + recent searches survived logoutclearPlaintextCaches() on both logout paths, extended to the whole recent_* family + nav-paths. 726cefb5.
  • SEC-3 — window.open(_blank) without noopenernoopener,noreferrer at 5 sites (SSOStage excluded — needs the handle). 3e1106b2.
  • SEC-4 — /acl self-lockout footgun → shared serverAcl.ts validation, no-brick allow default, fail-closed self-ban guard. 3e1106b2.
  • COR-1 — space-child UNLINK over-deleted → targeted UNLINK reducer action. fd3b8b42.
  • COR-2 — useCallJoined stuck true on 2nd-call embed swap → re-seed on [embed]. 1f80d1d1.
  • COR-3 — incoming-call lifetime guard only corrected future clock-skewMath.abs(...) (±20s). ab01d27a.
  • COR-4 — shared notify-dedupe slot double-notified → key by roomId|threadId. 1f80d1d1.
  • COR-5 — upload cancel ignored during retry back-offAbortSignal threaded into the retry loop. ab01d27a.
  • 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 (initClientrequestPersistentStorage() + 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

  • [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.
  • [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.
  • [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

  • [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).
  • [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).
  • [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.
  • [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

  • [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.
  • [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).
  • [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.
  • [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).
  • [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

  • [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).
  • [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.
  • [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.)
  • [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.
  • [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.
  • [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).
  • [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.
  • [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.
  • [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.
  • [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.tshandleReceipt 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):

  • 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.
  • 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):

  • 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).
  • 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):

  • 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-apiextend 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 (allRoomsAtommx.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.confnginx -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 LegacyCompatibility/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 (MSC3892false)
  • Room preview before joining (MSC3266)DONE (client was always built; unstable im.nheko.summary endpoint returns 200 — verified on 1.156)
  • Thread subscriptions (MSC4306false)

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.1lotus.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.
  • 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).
  • 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.
  • 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 (JoinBeforeNavigateRoomCard 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):

  • Concurrencycancel-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.
  • 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 triggertrigger-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).