62 KiB
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.cssCSS 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 (matchsrc/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
foldsdesign 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 foldsIcon/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 (participatednow 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 newroomMentionsOnlygate → 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 (useCallSpeakersdepends on a stable boolean → no observer churn on membership change), F5 (OIDC refresher forwards the refreshed tokenexpiryasexpiresInMs→expiresAtno longer stale across reloads). Verified already-handled, no change: N6 (useMemberAvataralready subscribes viauseRoomMemberChange), H10 (RoomProfilealready hasmaxLength={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.desktopid may not match the installed.desktopbasename — runtime-verify on the.deb/AppImage. - EC fork (EC1–EC6 fixed on
element-call:lotus, needs a republish): re-applysetTimeoutcleanup, remote-gated subscription →allConnections$, per-call decoration state leak, re-subscribe-every-render, focus-clear on missinguserId. 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) — 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 §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 sharedallRoomsAtomevery keystroke → copy +useMemo(also fixed a real shared-array mutation hitting ~27 consumers).4708a179. - PERF-3 — read-receipt rows: ~6 global
Memberslisteners per row → one shared member-change store (useRoomMemberChange).1b8f5545. - PERF-4 — message-search room filter re-sorted every render →
useMemo.4708a179. - PERF-5 — DM-preview
Decryptedlistener ran for every nav item → gated ondirect.4708a179. - SEC-1 / SEC-2 — scheduled-message plaintext + recent searches survived logout →
clearPlaintextCaches()on both logout paths, extended to the wholerecent_*family + nav-paths.726cefb5. - SEC-3 —
window.open(_blank)withoutnoopener→noopener,noreferrerat 5 sites (SSOStage excluded — needs the handle).3e1106b2. - SEC-4 —
/aclself-lockout footgun → sharedserverAcl.tsvalidation, no-brick allow default, fail-closed self-ban guard.3e1106b2. - COR-1 — space-child UNLINK over-deleted → targeted
UNLINKreducer action.fd3b8b42. - COR-2 —
useCallJoinedstuck true on 2nd-call embed swap → re-seed on[embed].1f80d1d1. - COR-3 — incoming-call lifetime guard only corrected future clock-skew →
Math.abs(...)(±20s).ab01d27a. - COR-4 — shared notify-dedupe slot double-notified → key by
roomId|threadId.1f80d1d1. - COR-5 — upload cancel ignored during retry back-off →
AbortSignalthreaded into the retry loop.ab01d27a. - COR-6 —
CallControl.forceStatedroppedscreenshareAudioMuted→ passes it.ab01d27a.
Deferred / decided (not built):
- [DEFERRED] PERF-6 — avatar-decoration
/profilefetch — 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 (noallow-top-navigation), and popups are arguably needed for "open in provider." Revisit with per-provider verification if dropped. - KE-1 preventive (
navigator.storage.persist()) is already implemented (initClient→requestPersistentStorage()+src/index.tsxboot). 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-srcwas missingstore.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-desktopdaba59b): all three added toframe-src(noconnect-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.tsencrypted-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-testedevictCount. IDB-spec correctness (cursor delete/continue, tx liveness, range bracketing) confirmed by 2 review agents since CI can't run IndexedDB. - [Low]
MsgTypeRenderers.tsxMLocationOSM permalink uses rawgeo:lat/lon substrings, not the validated floats — harmless (URL context, malformed input only). FIXED (8a461610): permalink uses theparseFloat+isFinitevalidatedlat/lon(as the map iframe already did).
Voice / video calls
- [Med]
DenoiseTester.play()(Settings → Calls A/B model test) leaks the denoise model node — callsctx.close()but neverdenoise.dispose()(inconsistent withstopLive, which disposes) → leaks the DeepFilterNet/DTLN worker/WASM per press. FIXED (c9d9d914):stopPlaybacknow mirrorsstopLive(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) returnguard, so screenshare→PiP→back leaves spotlight forced on andpipAutoSpotlightRefstucktrue.CallEmbedProvider.tsx:733-744. FIXED (08e19100, code-level; still wants live QA): effect guards only on!callEmbed, releases wheneverpipMode && pipScreenshareis 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): amountedRefguardsstartLive/startRecordafter thegetUserMediaawait (andplay()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);PrescreenControlsPermissionStatus.onchangenot 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); permissiononchangedetached +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]
invalidateDecorationCacheclears 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 existingpendingmap. - [Med/Low] Decoration picker grid thumbnails use the raw
DECORATION_CDNconstant instead ofdecorationUrl(), ignoring theVITE_DECORATION_CDNoverride → broken thumbnails if decorations are repointed.ProfileDecoration.tsx:51. FIXED (29ff1654): grid usesdecorationUrl(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 pureresolveSeasonTheme+ 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}(matchinguseAvatarDecoration); PUT/save path unchanged.
Threads / presence / UX
- [Med]
PresenceBadgerenders DND (unavailable+status_msg:'dnd') as a yellow "Idle" badge + label, whilePresenceRingAvatarcorrectly shows red — inconsistent. Give the badge the samestatus === '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):factoryRoomIdByUnreadbreaks ties by recent activity; relocated toutils/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]
useTauriFocusAssistnever queries the initial OS Focus-Assist state on mount (unlikeuseTauriDnd, which rehydrates viaget_tray_dnd) → if Focus Assist is already ON at launch, notifications/sounds leak through until the OS state next flips. Add aget_focus_assistmount query (confirm whether the native poll emits an initial reading).useTauriFocusAssist.ts:18-24. - [Low] Push-rule enable toggle holds stale local
useStateafter an external rule change (toggled on another device) — sync from thepushRule.enabledprop.PushRuleEditor.tsx:55-79. FIXED (2c0cd0d2):useEffectresyncs onpushRule.enabledchange (prop flows from liveuseAccountData(m.push_rules); no optimistic conflict). - [Low] Server-support
.well-known/matrix/supportis fetched frommx.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 fromhttps://{mx.getDomain()}(MSC1929-correct); identical for non-delegated, graceful catch otherwise. - [Low] Cleared/partial quiet-hours
timeinput (''→ 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)); theshow_rich_toasttrigger has no verified web-side caller.useTauriToastActions.ts:35-38. ROOT CAUSE FOUND + web fix shipped (0ddf86c6):show_rich_toastwas dead becauseshowOsNotificationpreferred the service worker (WebView2 has one), shadowing the injectedwindow.Notificationshim. 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
tagto replace prior notifications for the same room;show_rich_toast(cinny-desktop/src-tauri/src/native/toast.rs) ignorestagand 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, somx.sendMessage(tag, …)fails silently (useTauriToastActions.ts:37). Body-click navigation is correct (usespath). Fix: pass the realroomIdseparately (e.g.data.roomId) and have the shim (lib.rsNOTIFICATION_BRIDGE) +toast.rsuse it for the reply target; keeptagfor 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 (
oldestRawTsonly advances on decryptedm.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; guardedts > 0so a bogus 0-ts can't cause the opposite (silent under-pagination). 2-agent reviewed. - [Info/doc]
PolicyListVieweris a manual room-ID/alias viewer with no subscribe/unsubscribe controls and no subscribed-lists listing —LOTUS_FEATURES.md:1287describes both. Docs oversell; not a runtime bug. FIXED (8a461610, doc): LOTUS_FEATURES corrected to describe the read-only room-ID/alias viewer (no subscribe controls).
✅ Composer autocomplete-insert crash (reported 2026-07) — FIXED (477df4ae)
Picking an autocomplete item (mention/emoji/command) occasionally tripped the composer error boundary ("encountered an error" → forced refresh) even though the element inserted. Root-caused (3 agents, incl. a headless slate simulation) to moveCursor deferring its cursor work to setTimeout, leaving the caret on the just-inserted inline-void's zero-width edge; slate-react's commit-phase setBaseAndExtent(voidEdge, 1) then threw IndexSizeError mid-render → boundary. Fix: do Transforms.move (escape the void) + insertText(' ') synchronously in the same commit as the insert, so the caret is a resolvable text point when the selection sync runs. Plus a recoverable boundary ("Reload composer" + onReset deselect) so any residual composer crash no longer needs a page refresh. (A first "sync insertText without move" attempt was caught in review — the void guard drops the space + traps the caret; move is required.)
✅ Unread/read-receipt flakiness (reported 2026-07) — FIXED (pending prod QA)
Room unread dots were inconsistent: reading a message sometimes cleared the dot, sometimes left it stuck, sometimes it resurrected. Root cause (confirmed by tracing + diffing upstream cinny dev): our own "N4" change. handleReceipt recomputed via getUnreadInfo, which reads room.getUnreadNotificationCount() — server-computed and stale on the synchronous synthetic receipt echo (SDK only zeroes it immediately when the last event is your own message) → it PUT the stale non-zero count back → stuck/resurrecting. Compounded by hasUnread = !!unread lighting the dot on any present map entry, incl. phantom {0,0} PUTs from our UnreadNotifications listener. Plus a Mark-as-Unread (MSC2867) flag that never cleared on opening an already-read room (no receipt → no auto-clear).
Fix: roomToUnread.ts — handleReceipt reverts to upstream's optimistic DELETE on own receipt; reducer collapses {0,0} PUT → DELETE. notifications.ts markAsRead clears the marked-unread flag directly. markedUnread.ts onReceipt gated to main/unthreaded receipts (myMainReceiptPresent). Unit tests added; 700/700 pass, typecheck + build clean. Deploy + manual QA (read → dot clears & stays; thread read; mark-unread → open → clears; reconnect no resurrect).
🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED
Observed live in prod 2026-06-30 during a 2-person Element Call (E2EE). These span client rust-crypto (matrix-js-sdk@41.7.0) ↔ Synapse ↔ EC MatrixRTC E2EE and are interrelated — do NOT spot-fix. Capture first: run Settings → Developer Tools → Crypto Diagnostics during the next affected call + a synapse-side trace before any fix. (Full runbook was in LOTUS_E2EE_INVESTIGATION.md, now in git history.) None are caused by the EC fork work.
- KE-1 — OTK upload conflict storm (CRITICAL, root-cause candidate).
POST /keys/uploadreturns400 M_UNKNOWN: One time key … already existscontinuously — the rust-crypto store and Synapse have diverged OTK state (upstreammatrix-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 callsnavigator.storage.persist(), so the IndexedDB crypto store is evictable while thelocalStoragesession 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-deviceio.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
algorithmfield (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, repeatedmsc4157.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.tshas nopushhandler. Needs apushlistener + 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:
lotusDenoisedoes heavy sequentialfsincloseBundle;viteStaticCopyhas redundant renames — could be streamlined. patch-folds.mjseditsnode_modulesdirectly (robust today;patch-packageconsidered 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/caddyexamples: headers +try_filesalready synced with prod; the prod nginxadd_headerisn't inherited by cachelocationblocks (pre-existing; SPA entry/still gets all headers).as anycasts acrosssrc/— 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 }(+ unstablecom.famedly.marked_unread) viamx.setRoomAccountData; clear on read. Context-menu item inRoomNavItem+ light the existing unread dot; integratestate/room/roomToUnread.ts. - Low Priority rooms —
m.lowprioritytag. Mirror the favourite impl (RoomNavItem.tsx:331-337setRoomTag/deleteRoomTag+ the favourites category inhome/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-settingsSettingTileto set{ max_lifetime }; retention badge; a client-side sweep hides/self-redacts own expired events (pattern like the mute-timer restore inClientNonUIFeatures.tsx). True server deletion also wants Synapseretention:(LXC 151). - QR Device Verification — reciprocate QR. Add the QR path beside emoji-SAS in
components/DeviceVerification.tsx: render withqrcode.react(already a dep), scan viaBarcodeDetector(fallbackjsQR); uses the SDKVerificationRequestQR/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). Readim.vector.modular.widgets/m.widgetstate, add an Add/Manage panel + sandboxed iframe renderer viamatrix-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'sSlidingSync/SlidingSyncSdkare_internal_/@experimental(Element shipped labs-only, never GA in ~2 yrs, moved to the Rust SDK); presence isn't delivered over sliding sync (regresses Lotus presence badges/rings/status); no upstream Cinny impl to follow; and Cinny's whole nav (sidebar/spaces/DM/unread) is derived from the full local room set (allRoomsAtom←mx.getRooms()), so ~14 subsystems (4 core) need re-architecting to a server-windowed list. ~10% confidence a full rollout wouldn't break/regress (missing rooms/messages/unread = worst failure class). Revisit only if we adopt the Rust SDK or accounts grow large enough that startup latency is a real complaint; an off-by-default experimental spike is possible but not recommended. Full assessment: git plan history.
Room Widgets v1 follow-ups: capability-approval consent prompt (let widgets request send/read room events); Jitsi/stickerpicker special types; account-data (user/sticker) widgets; per-widget popout / always-on-screen. Requires the prod CSP frame-src widening (done in matrix/cinny/nginx.conf → nginx -s reload) or external widgets are blocked.
Server-gated / advanced (capture, don't build yet): QR sign-in for a new device (MSC4108 rendezvous — needs an HS-side endpoint); dehydrated devices (MSC3814 — offline key delivery, also helps the E2EE KE cluster); E2EE history key sharing on invite (MSC3061 shared_history, niche); voice broadcast (Element MSC3888, low value — skip).
[PARKED] Matrix 2.0 call membership — MSC4354 Sticky Events (investigated 2026-07, 3 agents + live infra check)
Move MatrixRTC/Element Call call-membership from state events (MSC3401) to sticky events — the "Matrix 2.0" path. Not a flag flip; a coordinated rollout. Parked deliberately.
Findings:
- Server (Synapse 1.157.1, LXC 151):
msc4354_enableddefaultsfalse. Enabling is low-risk, additive, reversible — schema (sticky_eventstable) already ships unconditionally, no migration/backfill, all runtime paths flag-gated, residual rows self-expire ≤1h. The one historical/syncEDU-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, defaultsLegacy). 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(slotm.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 deviceMatrix_2_0, oneLegacy) before changing the default, else split-at-media.
To actually adopt (future): (1) enable msc4354_enabled: true + restart; (2) two-account media-interop test; (3) if unified, flip EC default mode Legacy→Compatibility/Matrix_2_0 in the fork + redeploy; (4) keep legacy fallback during transition. No user benefit until step 3.
[ ] Matrix 2.0 call membership — MSC4354 sticky events (INVESTIGATED 2026-07, deliberately NOT enabled)
3-agent investigation after the 1.157.1 upgrade (EC-fork behavior · Synapse/upstream readiness · client-fleet composition). Conclusion: leave msc4354_enabled OFF for now — enabling it is safe but delivers zero user-visible benefit on its own, and introduces a latent footgun.
Why it's a no-op alone: the EC fork's doesServerSupportUnstableFeature(MSC4354) probe feeds exactly one thing — whether the "Matrix 2.0" radio in Developer Settings is greyed out (DeveloperSettingsTab.tsx:349-353). The real switch is the per-device matrixRTCMode setting (settings.ts:149-152), which defaults to Legacy and never auto-enables. Sticky sending is gated at LocalMember.ts:862 (unstableSendStickyEvents: mode === Matrix_2_0). So flipping the server flag changes nothing any client sends.
Verified safe: Synapse-side is additive and cleanly reversible — the sticky_events schema ships unconditionally (no migration/backfill on enable), every write/read/serialize/replication path is flag-gated, disabling stops it instantly and residual rows self-expire ≤1h. The one relevant bug (#19787 /sync EDU-filter) was fixed in 1.155.0; the SQLite<3.40 guard doesn't apply (we're on PG 17.10). Matrix-layer mixed-mode visibility is safe: js-sdk collectMembersEvents reads both sticky and state membership and merges them, so sticky-mode and legacy-mode participants see each other. Our lk-jwt-service already serves both JWT endpoints (legacy /sfu/get and the sticky-mode /get_token — both probed live, 400-with-validation-error = present). EC is bundled into cinny's build (@lotusguild/element-call-embedded), so the fleet upgrades atomically — the "all EC clients ≥ v0.17.0" precondition is structurally guaranteed for our own users.
The one unresolved risk (blocks a real rollout, not the flag): sticky mode drops livekit_alias and uses /get_token (slot m.call#ROOM) while legacy uses /sfu/get (room=roomId). Whether both resolve to the same LiveKit room is a property of lk-jwt-service, not the client — unverified. If they diverge, cross-mode participants appear in each other's member list but are split at the media layer (silent, no error). Requires a two-account test call (one device on Legacy, one on Matrix 2.0) to confirm before anyone relies on it.
If we ever do this: (1) run the two-account media-interop test; (2) only then consider enabling msc4354_enabled: true in /etc/matrix-synapse/homeserver.yaml (LXC 151) + restart; (3) treat a default-mode change as a separate coordinated EC rollout. MSC4354 is still OPEN upstream (not in FCP, needs-implementation), so this stays experimental regardless.
Remaining spec/MSC gaps (2026-07 full-surface survey)
After Phases 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 (RoomNavItemcontext-menu +Home.tsxcategories). 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.inviteis 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; unstableim.nheko.summaryendpoint 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 — 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 (M1–M6 + N1–N2): 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:lotuse36aef8a, 3-agent survey + 2-agent review). Fixed the EC iframe's own phone UI: footer control row wraps so hangup can't clip (320–500px), 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@mediaclass (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 theUrlPreviewflex row; addedStackOnMobile(mobile-only@media (max-width:750px){ flex-direction:column }) scoped to those variants viacardClass. foldsBoxhas no defaultdirectionso 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 columnBox; 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/trackitem ids that aren't in the page URL (bandcamp.com/oembedis the resolver; mirror theTikTokEmbedCardon-click oEmbed pattern). CSPframe-src:bandcamp.com. Classifykind: 'audio'. - SoundCloud
on.soundcloud.comshort links — thew.soundcloudwidget resolver does not follow the redirect; needs the same on-click oEmbed resolve (soundcloud.com/oembed, CORS-enabled) to get the canonical URL. (Canonicalsoundcloud.com/{user}/{track}links already work.) - Vimeo
event/{id}(live events) +ondemand/…— event embed host isvimeo.com(notplayer.vimeo.com, so it needs a new CSPframe-srchost); on-demand is paywalled and doesn't embed for non-purchasers. Low ROI — only do the event case ifvimeo.comis 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-sandboxand/orclipboard-writefromEMBED_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:
encodeURIComponentthe Bluesky authority + Apple Music path/search interpolated into the embedsrc(not currently exploitable — host is fixed and value comes fromURL.pathname; React escapes the attribute). - Out of embed scope (real, low-sev):
LotusDenoiseFeature(ClientNonUIFeatures.tsx) has awindowmessagelistener 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). Validateevent.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;
extractEmbedHeightgeneric.heightfallback accepts any allowed-origin message;TweetEmbedtheme is a one-timematchMediasnapshot (no live theme switch); host-normalization gaps (vt.tiktok.commissesStackOnMobile,m.instagram.com,www.youtu.be).
Deferred / dropped (decided — kept for context)
- [DEFERRED] P5-51 Federated "Identity Contexts" (session isolation) — multi-sprint, touches auth/crypto/storage core; smaller intermediate step = plain multi-account switch. [DROPPED] P5-52 per-room sync governor — js-sdk can't truly per-room filter
/sync; only a cosmetic hide. [DEFERRED] P5-53 local scripting plugin — prefer a declarative automation-rules feature (no arbitrary code). [DEFERRED] Audit-3 profile banner — MSC4427 open/unmerged; revisit on merge. [WON'T FIX] P5-50 Windows HW media pipeline (WebRTC decode lives in WebView2; not injectable). [MOVED] P5-9 LFG → LotusBot!lfg.
🚫 Blocked Features (server / upstream gated)
Re-run /_matrix/client/versions + unstable_features after each Synapse upgrade. Re-checked on 1.157.1 (2026-07-23): no change — all four below are still false. The 1.156.0→1.157.1 delta unblocked nothing (it's a bugfix release; the only feature-bearing release in the gap was 1.156.0, which we were already running).
- [BLOCKED] Live Location Sharing (MSC3489 + MSC3672 both
false) — real-time GPS beacons over the existing static share. - [BLOCKED] Reaction/Relation Redaction (MSC3892
false) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable. - [DONE 2026-07] Room Preview before joining (MSC3266) — the client was always built (
JoinBeforeNavigate→RoomCardviamx.getRoomSummary). The earlier "blocked" flag was a misdiagnosis: it tested/v1/rooms/{id}/summary(404), but the SDK calls the unstableim.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· Synapse1.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 withdpkg-query -W matrix-synapse-py3, don't trust the docs; apt package on Debian 13, LXC 151) · Matrix spec up tov1.12(Synapse still advertises v1.12; MSC features viaunstable_features). - MSC ON (re-dumped live from
/_matrix/client/versionson 1.157.1):msc4140·msc3771·msc3440.stable·msc4133.stable·simplified_msc3575·msc4222·msc3266(room summary live at unstableim.nheko.summary/summary/{id}— 200; the/v1/rooms/{id}/summarypath 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-knownorg.matrix.msc4143.rtc_foci, confirmed live, not this flag). - Dead client code: Synapse 1.157.0 removed
msc3861(MAS auth delegation) entirely — the ~6msc3861/msc2965references insrc/can never activate against this homeserver (we auth via Autheliaoidc_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.pyon 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 atsrc/sw.ts;getMatrixToRoom()builds invite URLs; EC audio-inject unblocked via the fork'sio.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):
- Concurrency —
cancel-in-progresson cinnyci.ymland cinny-desktoprelease.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 becauselotus_deploy.shnow follows origin/lotus HEAD each poll iteration + resets to the gated SHA (matrixc15a489) — closes the latched-SHA freeze race. - Hard quality gates — typecheck/eslint/prettier promoted from
continue-on-errorto blocking (tree held clean). eslint gates on errors only;no-explicit-anywarnings stay informational.
CI follow-ups (open):
- Dedicated
desktop-linuxrunner (infra) — concurrency only collapses burst stacking; a single in-flightbuild-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 labelleddesktop-linux(root, network, RAM for a Tauri build; do NOT also label itubuntu-latest) and point onlybuild-linux: runs-onat it. Relabeling without a matching runner hangs the job forever. - Debounce the desktop trigger —
trigger-desktopfires 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.shupstream-merge path (currently builds+deploys+then pushes, bypassing CI).