npm audit --omit=dev started failing every PR: brace-expansion <=1.1.20
(via @eslint/eslintrc → minimatch 3) has three high-severity DoS
advisories. Lockfile-only patch bump; nothing else changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
When the homeserver, voice calls or sign-in break, or maintenance is under
way, say so in the app — driven by the Kuma status page
(isitup.lotusguild.org/status/matrix), managed from Kuma's UI. The client
asks Kuma directly (not via our servers) so it still hears "the server is
down" when our servers can't tell it.
- config.json `statusPages`, keyed by homeserver: users of other servers
never contact Kuma.
- utils/kumaStatus.ts (pure, unit-tested): parse Kuma 2.x's public JSON;
a group is down when any monitor fails two checks in a row (down+down or
pending+down); maintenance = windows under way; announcements = incidents.
One strip at a time: server down (connection lost AND Kuma confirms) >
server having problems > maintenance > calls down > announcement;
sign-in problems on the login screen only.
- Wording about the user's own connection: "Connection lost … our status
checks say the server is up, so it may be your internet connection" ONLY
when Kuma checked the server after this client's connection dropped and
it passed; a stale "up" (Kuma needs a minute or two to notice an outage)
keeps the plain "Connection Lost!".
- useServerStatus: polls only while visible; 5 min, 60 s while something is
wrong or the connection is lost, at once when it drops; backoff; any
failure = no banner (Kuma being unreachable never looks like Matrix
being down); GET only, no cookies.
- UI in the existing banner slot and style (ContainerColor/Line like the
sync and clock banners); Details expands; dismiss for calls-down and
announcements (an edited announcement comes back); calls-down note above
Join; phone: one line + Details.
Needs the CSP connect-src to allow https://isitup.lotusguild.org (matrix
repo) before it can fetch in production; until then it fails quiet.
Tests: 15 unit tests (live page layout, two-check rule, any-monitor rule,
unknown/garbage, UTC beat times, stale-vs-fresh "up", priorities, login vs
client, maintenance, announcements + dismiss/edit); e2e (fixtures for Kuma):
calls-down strip + dismiss across reload, other homeservers make no
requests, Kuma 500 → nothing, lost connection + Kuma down → critical strip
instead of "Connection Lost", + fresh "up" → "may be your connection",
+ stale "up" → plain "Connection Lost". Unit 1315, Playwright 26 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Step 1 of moving the desktop app's login out of the webview's plaintext
localStorage: keep a verified copy of the tokens in the OS keychain
(Windows Credential Manager, via cinny-desktop's new secure_session_*
commands). The session is still read from localStorage exactly as before,
so nothing about login changes and a keychain problem can't log anyone out.
Step 2 (a later release, once this has run on real installs) switches reads
to the keychain and drops the tokens from localStorage.
- sessions.ts: onSessionPersisted — listeners told about every session
write (login, token rotation) and removal (logout); a throwing listener
can't break the write.
- keychainMirror.ts: desktop only. Mirrors userId/deviceId/accessToken/
refreshToken (not the rest of the session); reads first and writes only
when the copy differs, then verifies by reading back; serialized, 5 s
timeouts; no session → clear (also covers a logout whose reload beat the
clear). Every failure is a status, never an exception. A desktop build
without the commands reads as "unsupported", so this can ship before the
desktop side.
- Settings → General (desktop): "Login in the system keychain" status.
Tested: unit tests (fake keychain: store, no rewrite when current, rotation,
clear, unsupported, missing commands, denied write, read-back mismatch,
timeout); a simulated desktop app with a fake keychain, 14/14 (login
mirrors only the secrets, Settings status, reload verifies without
rewriting, logout clears, an existing session is mirrored after upgrade,
Linux/denied show an honest status and stay logged in); the real Linux
desktop binary (commands answer "unsupported", login unaffected, Settings
says so). Unit 1295 pass, Playwright 20 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The desktop app loads the bundled Element Call page from its own origin
(http://localhost:<port>), so the call frame can read the app's storage
(login token) and DOM — the hole #43 closed on the web by moving the page
to call.chat.lotusguild.org.
The desktop's local server can also answer on http://127.0.0.1:<port>: the
same server and bundle, but a different origin (and still a secure
context). resolveDesktopCallPageUrl loads the bundled page from there when
the desktop config sets `desktopCallOrigin`:
- only a loopback http origin on the SAME port as the app, no path, query
or credentials;
- only when the app itself runs on http://localhost (release builds; debug
builds on tauri:// keep the same-origin page);
- unset (every desktop build until cinny-desktop opts in, together with the
server bind, CSP and permission changes it needs): unchanged.
The web app is unchanged (elementCallUrl as before).
Tested in a simulated desktop app (Tauri bridge stub + the desktop
config.json, served on localhost and 127.0.0.1) against a local Synapse +
LiveKit, two users: call page from http://127.0.0.1:<port>, parentUrl =
the app origin; the frame gets SecurityError on parent.localStorage and
parent.document (same-origin control: readable); join, speaking indicator,
mic off/on, screenshare start/stop, layout switch and hang-up all work, no
page errors — 12/12 in 5 of 6 runs, like the same-origin control (3 of 4;
the misses on both sides were the local LiveKit connection).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
"Ahead" is now reported only once it has held for a minute of fresh
samples (a stalled server delivers late and reads as ahead). The test sends
its ticks, checks nothing is shown yet, fast-forwards the page clock past a
minute and sends two more.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Incident 2026-09-29: the homeserver's host ran out of memory and stalled for
~2 minutes. The /sync that finally went out carried events whose `age` was
computed ~30 s before it arrived, so every client showed "Your computer's
clock is 30 seconds ahead of the server" while the real problem was the
server (all host clocks were within 0.25 s the whole evening).
The skew estimate was the median of the last 5 samples, and a sample is
local skew + delivery delay, so one late /sync with a handful of events
tripped it.
- Estimate = the LOWEST sample of the last 5 minutes: delay only ever adds,
so the fastest-delivered event is the truest.
- "Behind" (which a delay can't cause) is reported as soon as there are 3
samples, like before. "Ahead" must hold across samples received at least
a minute apart, so a single late burst never trips it.
- Samples are aged on the monotonic clock, and a change of the local clock
(someone fixing it) resets the measurement, so the warning clears at once.
- Only events stamped by our own homeserver are sampled: a federated event's
origin_server_ts is the other server's clock.
- Wording: "This device's clock is … Voice calls and encrypted messages can
fail until it's corrected." / call bar "Device clock … : calls may fail"
(was "will fail").
Unit tests: the incident (late burst after normal traffic, and a fresh
client whose first samples are all late), mixed slow/fast deliveries,
ahead only after a minute, behind at once, hysteresis, clock fixed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Until now a send that failed (offline, homeserver down, a blip) went
straight to "Failed to send": nothing retried it, and a reload dropped it
without trace (chronological pending ordering keeps local echoes in memory
only).
- Outbox (utils/outbox.ts + features/outbox/OutboxFeature): own message
sends (text, stickers, reactions, polls; not call signalling or
redactions) are mirrored to localStorage from their first local echo until
the server confirms them or the user cancels.
- After a reload they come back as local echoes via room.addPendingEvent,
same shape as the SDK's own. Recent ones (< 1 h) are sent again with the
same txnId; older ones come back as "Failed to send" for the user to
retry or cancel. Ones the server already has (transaction id seen in
/sync) are dropped, so no duplicates.
- Retries: network failures (ConnectionError, 408/429/5xx) are re-sent when
the connection returns (sync recovers or the browser goes back online),
and after a blip while online (5 s, backing off, max 10 per message).
Oldest first, in order per room. 4xx / consent / encryption failures are
left to the user.
- UI: a network failure while offline shows a clock, "Queued. Will send
when you're back online" (thread view too), not the red ✕. The ✕ is now
a button: click to retry.
- Logout wipes the outbox with the other plaintext caches (the content is
decrypted, like drafts).
Tested end to end against a local Synapse (Chromium): offline → queued →
sent once on reconnect; homeserver unreachable → queued → sent once; failed
send → reload → sent once and shown once; server accepted but response lost
→ reload → no duplicate; 2 h old entry → failed, not sent, click ✕ → sent;
cancel → gone after reload; encrypted room → restored message goes out as
m.room.encrypted with no plaintext and decrypts; one-off failure retried by
itself in ~5 s; no page errors. Unit tests for the pure parts; Playwright
20 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Lotus permalinks (#130) were built from window.location.origin. In the
desktop app that's the local tauri-plugin-localhost server (hash-routed), so
"Copy Lotus Link" copied e.g. http://localhost:…/#/home/!room…, which works
for nobody else. And the link recogniser only knew that local base, so a real
https://chat.lotusguild.org/home/… link in a message opened the browser
instead of the room.
- useLotusShareBase: in the desktop app, links for other people use config
`webAppUrl` (https only, set by cinny-desktop #23) in web path-routing
form; otherwise the origin, as before. Used by "Copy Lotus Link" on
messages, the space menu and space tabs.
- The recogniser accepts several bases: the origin, plus `webAppUrl` in the
desktop app.
- The web app is unchanged.
Verified with a simulated desktop (Tauri bridge + webAppUrl) against a local
Synapse. Copied links are https://chat.lotusguild.org/home/<room>/<event> and
https://chat.lotusguild.org/<space>. A public link in a message renders as
the room pill and clicking it opens the room in-app, with nothing sent to the
system browser. The web app still copies origin links. Unit tests for the
base selection; Playwright 20 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Rendering only; the vote logic (tally, optimistic votes, end poll, keyboard
radiogroup, voters) is unchanged.
- Every row shows "N votes · P%". Multiple choice looked broken as
"100% / 100%" with no counts; the footer now says "voters" there.
- Results are a thin progress bar under each answer: accent for your pick,
success for the winner, neutral otherwise. The old full-row fill was the
same grey as the row, so a 100% answer just looked disabled.
- Radio and checkbox indicators are 18px with a 2px border in a colour mixed
from the theme's text colour. Primary.ContainerLine was nearly invisible,
especially in dark themes.
- The winner shows a star and "Winner" instead of a second check mark.
- Bordered card, 460px wide (max 100%, same width for every poll). The header
is now "Poll · Single choice | Pick up to N | Results hidden until the end |
Final results", replacing the letter-spaced "◉ POLL · …" line.
- Footer: plain muted text that wraps, and chips ("Who voted", "End poll")
that never wrap. Previously the chip label broke onto two lines inside a
one-line chip and spilled out of it. Reproduced before/after in Lotus
Terminal at 150% zoom, a 360px phone, and dark at 125%.
- Lotus Terminal theme: data-winner gets its own green border rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The Linux desktop app runs on WebKitGTK, which ships without WebRTC (2.52
has no RTCPeerConnection; 2.54 disables it outright pending a libwebrtc
backend around 2.56), so calls can't work there. Until now the call button
just disappeared, the call room said "Your browser does not support WebRTC"
with Join disabled, and an incoming call couldn't be answered.
In the desktop app (isTauri) without WebRTC:
- call rooms: "Calls aren't available in the desktop app on Linux yet: its
web engine has no WebRTC" + an "Open in browser" button;
- incoming-call overlay: the same, with "Answer in browser";
- room header: the call button stays, and opens the room in the browser.
The link is the room in the web app (config.json `webAppUrl`, https only,
new key); the user presses Join there. Deliberately not an auto-join link:
a crafted URL must not be able to join a call and open someone's mic. It
opens through the desktop's new-window handler (web/mail schemes only → the
system browser). Without `webAppUrl` the explanation shows with no button;
browsers without WebRTC keep the old message.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
lotus.22 draws the "Share your screen?" prompt inside the call frame on
request and drops the corner button. Verified against the published
package: no-delegation (Firefox path) cross- and same-origin — bar button,
in-frame prompt, Cancel, Share → screenshare tracks, bar Stop; Chromium
unchanged; picture-in-picture prompt fits and Share works; room policy
hides the button and refuses the prompt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
With a fork that reports `screensharePrompt` (element-call lotus-screenshare-
prompt), the call bar and status bar keep their screenshare button on every
engine. Where the click can't be delegated, starting asks the fork to show
"Share your screen?" inside the call frame (io.lotus.prompt_screenshare)
instead of our own confirm; its Share click starts the share. Stopping works
from the bar as before (no click needed in the frame). Chromium is unchanged.
- useScreenshareMode: hidden (older fork's corner button) | prompt | direct.
- The room's call policy is also pushed to the fork in prompt mode, so the
prompt never opens where sharing is forbidden.
- Picture-in-picture: the "Return to call" overlay covers the frame; while
the fork reports the prompt open it lets clicks through to it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
lotus.21 checks widget messages against the host's origin (works same- and
cross-origin) and accepts soundboard clip bytes. Same-origin behaviour is
unchanged; verified against the published package: join, both screenshare
paths, PTT/deafen in the frame, layout/reactions/settings, speaking and mic
level, soundboard, avatars, muted-speech warning, per-person volume, and the
foreign-frame spoof stays blocked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Groundwork for serving the call page from its own origin
(call.chat.lotusguild.org). Inert until config.json sets `elementCallUrl`:
without it the bundled same-origin page is used exactly as today.
- callPageUrl: resolves `elementCallUrl` — absolute https only (http only on
localhost for development); anything else, and the desktop app, fall back
to the bundled page so a bad value can't break calls. Set once from the
loaded client config.
- CallEmbed builds the widget URL from it; the widget origin (used by the
message guard and Capability Delegation) follows automatically.
- Soundboard: a host blob: URL can't be fetched from another origin, so
io.lotus.inject_audio now also carries the clip's bytes (`audio`). Forks
that predate it ignore the field and use `url`, so this is safe on the
released fork.
Needs element-call's lotus-call-origin branch (host-origin message check +
inject_audio bytes) released and pinned before `elementCallUrl` is set.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
matrix-widget-api's host transport handled a message from ANY window on the
page as long as it carried the widget's id; its strictOriginCheck only
compares with the host's own origin and is off by default. The call's id is
the fixed 'call-embed', so any other frame (a room widget, a URL-preview
embed) could post fromWidget actions as the call. Reproduced locally: an
opaque-origin frame posting one io.lotus.hotkey keydown for the PTT key
turned a push-to-talk user's mic on ("● Live").
restrictWidgetMessages() swaps each ClientWidgetApi transport's listener
for one that requires ev.source === the widget iframe's window and
ev.origin === the widget's origin. Applied to the call and to room widgets
(so one widget can't impersonate another). Verified: the spoof no longer
opens the mic; PTT/deafen from inside the call, screenshare, speaking
indicator and room widgets (capability prompt, send, live events) unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Firefox, Safari and the WebKitGTK desktop app can't hand the user's click to
the call frame (no Capability Delegation), and getDisplayMedia needs it. The
host used to click EC's hidden footer button through the DOM instead, which
dies with same-origin. Now (pins element-call-embedded 0.25.0-lotus.20):
- those engines get `lotusFrameScreenshare`, the fork shows EC's own
screenshare button in the frame, and the call bar and status bar hide
theirs once controls_state reports `frameScreenshare`; the
screenshare-audio mute stays;
- the room's call policy is pushed with io.lotus.set_frame_screenshare, so
the frame button hides where the server would refuse a share, like ours;
- Chromium keeps the delegated io.lotus.set_screenshare from the host bar.
Removed the fallbacks for forks older than lotus.14, which read or clicked
EC's DOM: the screenshare/layout/settings/reactions/leave button lookups
and their MutationObservers, the frame-window hotkey binding, and the
speaking/muted tile scrape in useCallSpeakers (io.lotus.call_state is the
only source now). getCallDocument is gone; the host's only handle on the
frame is postMessage.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The SDK routes thread replies out of every room timeline set, the gallery's
detached one included, into the room's Thread objects, so photos posted in
a thread never reached the gallery. The gallery now merges media from the
loaded threads (deduped, newest first) and refreshes on ThreadEvent.NewReply,
waiting for decryption in encrypted rooms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
markAsRead ran on every room visit (timeline at the bottom and focused) and
sent a threaded receipt for every unread thread, so a reply in a thread you
started or replied in lost its unread badge the moment you glanced at the
room, without opening the thread.
Reads from just viewing the timeline are now "passive":
- threads you follow (started, replied in, or were mentioned in) stay unread
until their panel is opened; other threads are still cleared so they don't
keep the room dot lit forever;
- while a followed thread has an unread reply, the main receipt is scoped to
the main timeline instead of unthreaded, because an unthreaded receipt
also reads every older thread reply (the next main message would clear the
thread anyway). The check also asks whether the latest reply is read, since
the thread's count lags when the reply and a main message share a sync;
- the thread open in the panel is skipped, as the panel sends its own
receipt (was two identical receipts per reply).
Explicit "mark as read" (room menu, Escape, bulk actions) still clears
everything. Unit tests for each rule plus a local-homeserver e2e that checks
the server's per-thread count survives a reply + newer main message.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Thread images opened the room media lightbox, which looks the event up in
the room's detached media timeline. Thread replies never reach that
timeline, so a thread image always showed "1 / 1" with no prev/next, after
paging the room's media up to six times. The thread panel now builds the
viewer's items from its own root + loaded replies, and "Go to message"
scrolls the thread panel instead of the room.
The viewer gains a "Copy image" button: fetches the displayed media (blob
URL for E2EE, authenticated URL otherwise), re-encodes to PNG when needed,
and writes it via ClipboardItem with a promise so Safari keeps the click's
user activation. Hidden where ClipboardItem is missing. No "open in new
tab": an E2EE blob URL is revoked when the viewer closes and authenticated
media 401s in a bare tab.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Pins @lotusguild/element-call-embedded 0.25.0-lotus.19.
- Styles: the fork now hides its own footer (`lotusHostControls=1`) and
sets its root color-scheme from the theme, so the host no longer
injects `#lotus-ec-styles` or sets inline styles on EC's DOM. The two
other injected rules matched nothing in EC 0.25 (dead). The
transparent background was already the fork's (`lotusTransparent`).
- Hotkeys: PTT / deafen keys pressed with focus inside the call frame now
arrive as `io.lotus.hotkey` (the host sends the codes via
`io.lotus.set_hotkeys`), instead of listeners on the frame's window.
The window binding stays only for a fork that doesn't report `hotkeys`.
- Fixes (with lotus.19): pressing the deafen key M with focus in the call
also hit EC's own "M = toggle mic" shortcut, so the first press turned
the mic ON instead of deafening — even in push-to-talk mode.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The call bar's Start/Stop Screenshare no longer clicks EC's hidden button
on Chromium (incl. WebView2): it sends io.lotus.set_screenshare with
postMessage `{ delegate: "display-capture" }` inside the user's click, so
the frame can call getDisplayMedia on engines that require the click.
matrix-widget-api has no postMessage options, so its sendInternal is
swapped for that one synchronous send; delegation needs the frame's real
origin, not `*`.
Firefox, Safari and WebKitGTK (Linux desktop) have no delegation and
still click EC's button (needs same-origin, which is still on). Gated on
the fork reporting `screenshareAction` in controls_state.
Pins @lotusguild/element-call-embedded 0.25.0-lotus.17.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Probed how real providers fail before picking signals:
- X renders an EMPTY frame for a deleted/private/suspended post and says
so only via postMessage `twttr.private.no_results`. The post embed now
swaps to "This post isn't available…" with an "Open on X" link.
- A hung frame never fires `load`. After 20 s every player (media, rich
posts, TikTok, Steam widget, X) overlays "This embed is taking too long
to load" with Retry (remounts the iframe) and "Open on <site>". A late
`load` clears it.
- Instagram and Bluesky show their own "removed / not found" page, and a
refused request still fires `load` (browser error page), so neither needs
or can use a guess. A missing height message is not treated as failure.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The reaction button's aria-label used getShortcodeFor(), which returns
undefined until the lazily loaded emoji data arrives. The same button read
"🎉 reaction, 1 person" on first render and "tada reaction, 2 people" after
any later re-render. It now always uses the emoji itself (screen readers
speak it by its proper name, e.g. "party popper").
Custom (mxc) emoji were labelled just "custom emoji"; they now use the
shortcode carried on the reaction event (":lotus_blob: reaction").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Adds 530 decorations in 19 new categories (631 total). Decorations that
contain Discord branding (the Clyde-visor helmets) are excluded.
The decorations are ~1 MB animated PNGs, so a picker that rendered every
one would pull ~590 MB while scrolling. The picker now:
- shows static 144px WebP thumbnails (~9.5 KB each, `thumbs/` on the CDN)
and loads the animated file only on hover, focus or selection, with a
fallback to the full file if a thumbnail is missing;
- mounts one category at a time behind tabs, plus a name search across
all categories.
scripts/makeDecorationThumbs.py builds the thumbnails from the busiest
frame of each animation (many start on an empty frame).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
d2503332 made Enter pick the mention suggestion, which also caught
"/kick @alice:server" + Enter (the e2e "/kick failure is reported" test):
typing a complete ID opens the mention list too, and Enter picked it instead
of running the command. Enter now picks only while a partial name is being
typed (no ':' in the query); a complete @user:server or #alias:server sends
as typed, like before.
Chromium e2e 19 passed; "@bo" + Enter still picks, ":smi" + Enter still
sends.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
With the mention list open, pressing Enter sent the half-typed text
("hey @bo") instead of inserting the highlighted person — found while
investigating the "composer hit a snag" report; only Tab picked. Enter now
picks the top suggestion for people, rooms and commands, as in Discord,
Slack and Element.
useAutocompleteEnter listens in the window capture phase (so it runs before
the composer's submit handler) and only while the list has suggestions —
with no command match Enter still sends. Emoji suggestions are deliberately
left on Tab only, so "lol :p" + Enter still sends rather than inserting an
emoji. IME composition and Shift/Ctrl+Enter are untouched. Tab now inserts
the member's display name, same as a click.
Verified: "@bo" + Enter inserts the mention and sends nothing; the next
Enter sends "hey @bob hi" with m.mentions; ":smi" + Enter still sends.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Reported on chat.lotusguild.org: picking a name from the @-mention list
showed "The message composer hit a snag." — clicking OK continued with the
draft intact. The composer's known failure mode is a transient render error
right after an autocomplete insert (slate-react's DOM selection sync racing
the model; 477df4ae fixed one such path). It did not reproduce locally in 60
attempts (click / Enter / Tab, fast and slow, display names with spaces and
emoji, Chromium and WebKit), so this makes it harmless and diagnosable:
- ComposerErrorBoundary: the first failure clears the selection and remounts
the composer immediately, with no notice (the draft is intact). A second
failure within 5 s shows the notice, now saying the draft is safe, with
"Reload composer" and "Copy details" (time, error, stack, component stack,
browser). Every failure is logged to the console as "[composer] render
error".
- Used for the room composer and, newly, the thread panel composer (which had
no boundary, so the same error took the whole panel down).
Verified with a temporary injected crash (not committed): one crash recovers
with the draft kept and no notice; two within 5 s show the notice, Copy
details copies the report, Reload composer restores the full draft.
Chromium e2e 19 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The GIF button needs a Giphy key in the client config. Without one (the
desktop app until now), switching "GIF Picker" on did nothing and nothing
said why. With no key the tile now explains it and the switch is disabled;
with a key it is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Jared felt the first redesign was too sparse for a full theme: most of the
time only two webs and a spider were on screen.
- Will-o'-the-wisps: six soft green/orange glows wandering and pulsing,
always present while animating (not in the still scene, where static glows
would sit on text).
- A witch on a broom crosses just below the header about once a minute
(redrawn so she reads as a seated figure, not a blob).
- Bats every 20 s (was 30), plus a faster swarm of eight every 60 s.
- The big spider now climbs up its thread and drops back with a bounce.
- A second, small spider on the left web; a tiny jack-o'-lantern with a
flickering candle hangs by the right web (desktop).
Rendered and inspected at the witch/swarm moments in light, dark and phone,
plus reduced; still 60 fps on vs off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Draft for human review (#239 must be approved and closed by a human).
- Removed the full-screen purple wash, fog and the blurred green "moon" that
sat over the member list and made it unreadable.
- Corner cobwebs (generated radial threads + sagging rings), thin and
readable in both themes, smaller on phones.
- A spider on a thread in the empty space under the member list (near the
top on phones), bobbing and swaying.
- Every 30 s a group of four bats flaps across the top of the screen (a real
bat silhouette; wing beat without flattening them into dashes).
- Every 50 s a small, see-through ghost rises and fades (outlined so it
shows on light themes; text stays readable through it).
- Theme-aware ink via --hw-ink/--hw-web: dark silhouettes on light themes,
pale dusk-violet on dark ones.
- Reduced motion: webs and the spider at rest. Settings swatch: night sky,
crescent moon with a bat across it, a corner web.
Rendered and inspected: light/dark/phone at the bat and ghost moments,
reduced on desktop and phone, the Settings swatch; 60 fps on vs off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The main chunk had crept to 356.9 kB gzip against a 349.9 kB budget (a
hard gate on pull requests, only a warning on pushes, so it went unnoticed
until PR #241). jsQR (camera scanning) and qrcode (drawing the QR) were
statically imported by the device-verification components, so every
startup loaded them. Both are now dynamic imports: jsQR when the scanner
opens (fetched in parallel with the camera permission), qrcode when a QR is
drawn.
Main chunk 356.9 → 302.5 kB gzip (largest chunk is now matrix-sdk at
304.7 kB, within budget); chromium e2e 19 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Draft for human review (#237 must be approved and closed by a human).
- Removed the full-screen amber wash, sun-shaft bands and vignette: they
turned the user's theme beige (muddy brown on dark) and lowered contrast
everywhere. The leaves carry the season on their own. Also removed the
dust motes (invisible specks) and the now-unused keyframes.
- Gust: every 42 s (first ~8 s after load) five leaves blow across the screen
together, rising and spinning, in ~5 s. Linear timing — an eased curve is
applied per keyframe segment and made them hang mid-air for a second.
- Softer flutter (leaves flattened into slivers mid-flip), stronger leaf
colour (opacity 0.58–0.73, less pale gold tip), brighter on dark themes.
- Reduced motion on phones: no icon rail there, so the resting leaves tuck
further off-edge and smaller instead of landing on the timeline icons.
- Settings swatch: new optional `preview` prop on seasonal overlays; Autumn
draws a warm backdrop with arranged leaves (the reduced scene rendered in
the tile as a black square with two edge fragments).
Rendered and inspected: light/dark/phone at 10/15/20 s, reduced on desktop
and phone, gust tracked frame by frame, the Settings swatch; 60 fps with the
theme on vs off (p95 frame 16.7 ms both).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Draft for human review (#237 must be approved and closed by a human).
- Bug: every falling leaf sat in a leaf-sized wrapper pinned at the top of
the screen with `contain: layout paint style`. Paint containment clips
children to that box, so a leaf stopped being drawn as soon as it fell out
of its first ~40 px: the animated theme showed almost nothing while the DOM
reported 10–16 leaves on screen. Now `contain: layout style`.
- Leaf shapes: a flag-style maple (bold lobes, stem) that stays recognisable
at 30 px (the old outline read as a star/splat), an oak with a stem, and an
aspen; gradient body with darker veins.
- Palette: crimson, pumpkin orange, gold, russet, burnt orange (was muted
tans that read as beige on the light theme).
- 12 leaves at 28–52 px, opacity 0.5–0.65 so text under a passing leaf stays
readable; brightened on the dark themes, where they sank into the wash.
- Reduced motion: resting leaves moved into empty layout areas (middle of
the left rail, under the member list), partly off-edge, instead of on the
sidebar, members, composer and corner controls.
Rendered and inspected at 10/15/20 s in light, dark, phone and reduced
motion (screenshots on #237).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Typed operators alongside the existing from:, each one setting the filter the
buttons already set (so typing and clicking end in the same state, shown in
the filter bar):
in:general in:"voice lounge" room by name / alias / id (unique partial
name ok); a room outside this page's scope
switches to all rooms
after:2026-09-01 before:7d date range; relative h/d/w/m/y
has:link | image | video | file contains-link / media filters
is:pinned pinned-only filter
- searchOperators.ts: pure parser + room resolver (unit-tested); quoted
values; keys case-insensitive; only at a word start, so URLs and times
aren't mistaken for operators; anything it doesn't understand stays in the
searched text with a notice listing the valid operators.
- Notices: no matching room; operators with no words or from: ("Add a word to
search for, or a from:@user.") — then the text is kept in the box.
- from:bob now resolves to a known @bob:server (this homeserver first); the
old path sent "@bob", which matched nobody unless picked from autocomplete.
Verified in Chromium on a local Synapse: `raid in:"Thread Lab" from:bob`
sets rooms=!…Thread Lab, senders=@bob:localhost and finds bob's message;
dates, an unknown room, operators-only and has:gif give the expected params
and notices.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
While unmuted, the microphone button (call view and the bottom call bar)
shows three small green bars in the icon's corner driven by the fork's
io.lotus.mic_level (element-call v0.25.0-lotus.16), so you can see the mic is
actually picking you up. Nothing is drawn while muted or silent; the button's
label is unchanged (the bars are decorative).
Your own name no longer appears in the "… is speaking" line: that is now for
everyone else, and your voice is on the meter. Avatar speaking rings still
include you, and the "You're muted" talking-while-muted notice is unchanged.
Verified in a local call with a fake-tone mic: data-mic-level cycles 3/2/1
with the tone on both buttons, disappears on mute and returns on unmute; the
status line reads "bob is speaking..." only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
When a multi-line paste looks like code, a chip above the composer asks
"That looks like code. Format it as a code block (js)?" with Format as code /
No thanks. It never converts on its own; typing, No, or 8 s dismiss it, and
Settings → Editor → "Offer to Format Pasted Code" turns it off.
- utils/looksLikeCode.ts (pure, tested): ≥ 3 lines and ≥ 2 of indentation
with depth changes, statement terminators, operators + brackets, keywords,
monospace/<pre> clipboard HTML, or SQL clause lines. Prose guards: quoted
replies, URL lists, long sentence-punctuated lines, plain-word lines,
markdown lists. Language guess only when fairly sure (js/ts/python/sql/
rust/c/php).
- Accept rebuilds the pasted paragraphs as one code block from their plain
text (code lines hold text only) and leaves the caret after it.
- Code blocks carry an optional lang → <code class="language-js"> (whitelisted
identifier only).
Verified in Chromium: pasting a JS function shows the chip; Format as code →
sent formatted_body is <pre><code class="language-js"> with indentation intact;
a three-line prose paste shows no chip; typing after a paste dismisses it.
Unit tests: 14 detector fixtures + 2 output tests; chromium e2e 19 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Timeline images were focusable (tabIndex 0) but only opened on click, so a
keyboard user could Tab to one and do nothing with it, and screen readers
heard a plain image. They are now role="button" labelled "View image: <name>"
and open the viewer on Enter or Space. Focus already returns to the image when
the viewer closes (checked for mouse and keyboard).
Verified in Chromium: Tab → Enter opens the viewer, Escape returns focus to
"View image: red.png", Space opens it again; chromium e2e suite 19 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The SDK gives a thread root its own id as threadRootId, so the
`!isThreadedMessage` guard hid the thread action on exactly the message that
has a thread; the "N replies" chip was the only way in. Roots now get the
action in both the hover bar and the ⋮ menu, labelled "View thread" /
"View Thread (14 replies)" with the thread icon; other messages keep
"Reply in Thread". Same handler, which opens the thread panel.
Verified in Chromium: on a 14-reply root both entries show and open the panel;
an ordinary message still shows "Reply in thread".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Windows' emoji font has no flag glyphs, so 🇺🇸 (:flag_us:) showed as the
letters "US". Two fixes:
- A TwemojiFlags @font-face over the bundled Twemoji file, limited by
unicode-range to the regional-indicator letters (U+1F1E6–1F1FF), is put in
the font stack on Windows only. Every other emoji stays native, other
platforms keep their own flags, and the file is fetched only when a flag is
on screen.
- The Appearance font setting overwrote --font-secondary with a stack that
had no emoji families at all, so neither this nor the existing "Twitter
emoji" switch reached message text. The font map now keeps
var(--font-flags), var(--font-emoji) before the generic family.
Verified in Chromium via CDP platform fonts: with a Windows user agent 🇺🇸 is
drawn by "Twemoji Mozilla" while 😀 stays on the system emoji font; with a
Linux user agent both stay native.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The thread header subtitle now says who started the thread and when
(64af736c); the snapshot's room-name line is a pattern for that.
CI run 2104's Playwright job failed on this; the chromium suite passes
locally (19 passed, 3 skipped).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Once the fork reports io.lotus.controls_state (element-call
v0.25.0-lotus.14), the call bar's Spotlight/Grid View, Reactions and Settings
send io.lotus.set_layout / toggle_reactions / open_settings instead of
clicking Element Call's hidden buttons, and the call bar's screenshare and
layout state come from that report instead of a MutationObserver on EC's DOM.
Older forks never send the report and keep the DOM path.
Screensharing itself is still started/stopped via EC's button: it needs the
user's click to reach the frame (Capability Delegation, step 3 of #43).
Verified in a two-person local call (Synapse + LiveKit) against a locally
built fork: controls_state arrives on join; Spotlight View / Grid View flip
EC's layout and the reported layout; Reactions opens EC's reactions menu;
Settings opens EC's settings modal; the four actions go over postMessage;
starting and stopping a screenshare flips the host's button via the report.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
A row in the Threads list only opened the thread panel; there was no way to
get to the root message in the room's timeline. Each row now has a small
"Go to message" button (a sibling of the row button, laid over its corner —
no nested buttons) that navigates the room to the root event. On a phone,
where the list covers the timeline, it also closes the list, and the button
is the bigger touch size.
Verified in Chromium: desktop jumps to the root with the list still open;
Pixel 7 jumps and closes the list (34 px target).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The subtitle was only the room name. It now reads e.g. "Thread Lab · started
by alice, Fri 09:18 PM" (the user's clock/date preferences via the #139
formatter), which is what you want to know when arriving from the Threads
list. Verified in the 360 px desktop panel: fits without clipping.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
A captioned image/video (MSC2530: `filename` set and `body` differs) put the
caption in the header, truncated to one line, so a long caption was
unreadable. The header now shows the file name; the caption is shown in full
under the media (wrapped, scrolls past 25vh). The image alt text uses the
caption, and Download now saves under the real file name instead of the
caption.
Verified in Chromium: header "lake-sunset.png", the 150-character caption
fully visible below the image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The viewer's arrows were 32 px at the screen edges on a phone, under the
44 px target the rest of the mobile work uses. On a coarse pointer they use
the next IconButton size (48 px measured on a Pixel 7); desktop is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- sendRoomMessage (composer's fire-and-forget sends: text, location, voice,
files, GIFs) and sendRoomEvent (polls, poll responses/ends, forwards,
reactions, edits) in utils/room.ts carry the one cast each needs
(`keyof TimelineEvents` / `RoomMessageEventContent`, no `any`).
- sendRoomMessage also swallows the rejected promise: a failed send already
shows on the local echo (Failed to send + Retry, or the consent prompt), so
it no longer surfaces as an unhandled error in the console.
- getAccountData narrows to `keyof AccountDataEvents`; ForwardMessageDialog's
guard now narrows `contentToSend` itself (same behaviour).
- `as any` 39 → 23; eslint warnings 46 → 36, ratchet tightened to 36.
Verified in Chromium on a local Synapse: a text message, a quick reaction and
an edit all reach the server with the right content; a consent-blocked send
no longer logs an unhandled MatrixError. 1219 unit tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Zoom on a phone was only the +/- buttons; the pinch people try first did
nothing. usePinchZoom tracks a two-finger gesture on the viewer's media area
and scales from the zoom it started at (clamped 1×–5×, snapping back to
exactly 1× when released near it, so one-finger swipe navigation re-arms).
The area gets touch-action: none so the browser doesn't zoom the page
instead. One-finger swipe and pan already ignore multi-touch.
Verified on an emulated Pixel 7 with CDP two-point touch: spread 80→200 px
gives 250 %, closing to 120 px gives 150 %, closing fully returns to 100 %,
and a one-finger swipe afterwards still moves 2/2 → 1/2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Room widgets were limited to display-only capabilities because the driver
couldn't serve anything else. Now:
- classifyWidgetCapabilities: display caps are still granted silently; reading
or sending events/state in the widget's OWN room is offered to the user;
everything else (other rooms' timelines, to-device, account data, uploads,
user directory, delayed events) stays denied. Writing protected state
(power levels, join rules, encryption, membership, ACLs, widgets, …) is
never offered, and the driver refuses it again at send time.
- WidgetPermissionPrompt names the widget, the host that runs it and who added
it; each request in plain words ("Send messages of type m.text in this
room · as you"); reading is pre-ticked, sending is not; Deny / Escape grant
nothing extra. "Remember my choice" stores it per viewer (localStorage),
tied to the widget URL, so a changed URL asks again.
- GeneralWidgetDriver implements sendEvent / readRoomTimeline / readRoomState
/ readEventRelations, each refusing any room but the widget's own;
RoomWidgetView feeds the room's live (decrypted) events and state updates,
which ClientWidgetApi forwards only if the widget holds the capability.
Verified in Chromium with a cross-origin test widget against a local Synapse:
power-levels and timeline:* requests are never shown; after allowing
send+read the widget's message lands on the server, its power-levels write
is rejected, it receives live messages, and after a reload the remembered
choice skips the prompt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
A homeserver with a consent requirement (Synapse user_consent) rejects sends
with 403 M_CONSENT_NOT_GIVEN until the user accepts its current terms. The
message just showed "Failed to send" with no reason.
Listen for the SDK's HttpApiEvent.NoConsent and show a dialog naming the
user's own homeserver (the client works with any server, so no Lotus-specific
wording), with "Review and accept" opening the server's consent_uri (http(s)
only; anything else is dropped) and "I've accepted — retry sending" resending
every event that failed for this reason. "Later" snoozes it for 10 s so
background retries don't re-open it immediately.
Verified in Chromium against a local Synapse with the send endpoint answering
M_CONSENT_NOT_GIVEN: dialog shows, link opens, retry delivers the message.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
16 menus (room header, Home/Direct/Space sidebar tabs, create tab, lobby,
room-nav item, history visibility, client root) set
returnFocusOnDeactivate: false, so pressing Escape dropped focus to
<body> and a keyboard user had to start over from the top of the page.
That option was there so focus wouldn't be pulled back to the menu button
when an item opens a dialog; since ce8ed89f dialogs move focus into
themselves (their initial focus runs after the menu's return), so it's no
longer needed. The autocomplete menu (focus must stay in the editor) and
the two menus whose opener disappears are left as they were.
Checked: room header menu → Escape → focus on "More options" (was
<body>); menu → Leave Room → focus inside the Leave Room dialog on
Cancel, same as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Follow-up to ce8ed89f for the modals that have no visible heading to
point at: the three "Seen by" reader lists (read-receipt pill, "is
following", message menu), View source, the text/PDF file viewers, the
room-card join error and the user-profile modal get role="dialog",
aria-modal and an aria-label; their traps move focus in (fallbackFocus
on the dialog) where the trap is local.
Verified: the receipt pill opens a "Seen by" dialog with focus on
Close; Escape closes it and focus returns to the pill.
The remaining unnamed Modal/Dialog uses are startup/loading and error
screens (config, feature check, spec versions, client root, password
reset) and wrappers around components that carry their own role (image
viewer).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Most modals rendered a folds Dialog/Modal with no role and no name, and
their focus traps used `initialFocus: false`, so focus stayed behind the
modal and a screen reader never announced it.
- 32 dialogs with a visible heading: role="dialog", aria-modal,
aria-labelledby → the heading (given an id), tabIndex=-1.
- 4 dialogs that already had a name (Leave Room, room topic viewer,
server ACL, room-nav prompt): role + aria-modal.
- Their focus traps drop `initialFocus: false` for focus-trap's default
(keep an already-focused autoFocus field, else the first tabbable
element) with the dialog itself as fallbackFocus, so a dialog without
a tabbable node can't crash the trap. Traps that live in a parent
(UIA stages, Logout, Forward, Invite) get the semantics only.
- The file drop overlay is deliberately left alone (not a dialog).
Checked at runtime: Join with Address, Delete Message, Report Message,
Leave Room and Logout open as named dialogs with focus inside and close
with Escape (Tab first when a text field has focus — the shared
stopPropagation keeps Escape from discarding typed text, by design).
The axe e2e spec (6 tests) passes; eslint warnings unchanged (46).
17 modals with no heading (image/file viewers, loading screens) remain.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The first time the window is closed, a dialog asks "Keep Lotus Chat
running?" — Keep running in the tray (default, focused) / Quit when I
close the window — with an opt-in "Start Lotus Chat when I sign in"
checkbox in the same moment (per the approved design: one dialog, no
wizard). The choice is saved natively; Settings → General → "When I
close the window" changes it later (tray / quit / ask me).
The dialog is role="dialog" aria-modal, labelled and described, with
focus on the default button. Web-side flow verified with a stubbed
native side: event → dialog → checkbox + Quit sends autostart enable +
resolve_close_request("quit"); the Settings select saves "tray".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The "N replies · <time>" chip under a thread root was named only by its
visible text, so a screen reader never said it opens the thread, and the
unread dot / muted bell were visual-only. Its accessible name is now the
visible text first (WCAG 2.5.3) plus "view thread", "unread replies" and
"muted" as applicable, e.g. "1 reply · 12:16 PM, view thread".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The logo sat on a pure-black (#000) square inside the icon's #0A0A0A
background, so the adaptive (circle/squircle) home-screen icon showed a
faint square around the logo — the "floating in an odd box" the
checklist warns about. Recoloured that backing square to the background.
Checked: both icons load in production (200, image/png); the logo's
furthest pixel is 29 % of the width from centre (Android safe zone: 40 %),
corners opaque — so no clipping in any launcher mask.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- Skip link: following #main-content left focus on <body> (the <main>
target wasn't focusable), so nothing was announced and the URL got a
stray fragment. <main> is now tabIndex=-1 and the link focuses it.
- Keyboard-shortcuts dialog (?): no role, and focus stayed in the
timeline, so it opened silently. Now role="dialog" aria-modal, and focus
moves into it (Escape still returns focus to where you were).
- Reaction viewer (both the reaction context-menu path and "View
Reactions"): same — role="dialog" aria-modal aria-label="Reactions",
focus moves in.
Keyboard-only checks (Playwright): Tab → skip link → Enter focuses
<main>; Tab reaches the room list, Enter opens a room, typing lands in the
composer, Enter sends (verified on the server); focus ring visible. "?"
opens the dialog with focus inside, Escape returns to the same element,
"?" in the composer stays text. Topic viewer, Search dialog and reaction
viewer all return focus to their opener.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The call-bar and status-bar toggles change their label with the state
("Deafen"/"Undeafen", "Turn off/on microphone", "Start/Stop Video", …)
AND set aria-pressed, which made screen readers say "Deafen, pressed"
while you were not deafened, and gave mic/screenshare a pressed state in
one bar but not the other. Per the ARIA practice, a toggle uses either a
changing action label or aria-pressed with a fixed label; these use the
former, so aria-pressed is removed from all nine.
Checked in a live call: every control now announces only its action, in
both states (deafen → "Undeafen", mic → "Turn on microphone", …).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The full-screen incoming-call overlay (shown when you're not already in a
call — the common case) had no role or accessible name on any ancestor of
its buttons, so a screen reader landed on an unlabelled button with no
hint that a call was ringing. The in-call banner variant already had
role="alert" + a label. The overlay's Dialog is now role="alertdialog",
aria-modal, labelled "Incoming voice|video call from <caller>".
Found while verifying #187 DP6 (decline still dismisses when the decline
send fails — it does).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Status messages are saved per device and re-sent with every presence
heartbeat (a presence write without status_msg clears it on Synapse). A
device never receives its own user's presence changes made on other
devices, so the DP3 fix in db864326 — mirroring remote changes from the
Profile page — could never fire: device B kept a status that device A had
cleared and re-published it on its next state change.
Heartbeats now reconcile with the server first: GET our own presence,
send the server's current status_msg and bring the local copy in line.
Falls back to the local copy when the read fails, when the server shows
us offline (invisible mode clears the status by design), and for 15 s
after this device saved/cleared its own status (a server read that
hasn't caught up yet can't override a fresh save).
Verified with two sessions of the same user against local Synapse:
B sets "dp3 old status" → A clears it → B goes hidden→visible → server
stays "" and B's local copy is removed (before: back to "dp3 old status").
A sets "dp3 new from A" → B heartbeat keeps it and adopts it locally.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Steam double-encodes its meta tags (`&quot;`), so after Synapse's
single decode the card showed "The "Perpetual Testing
Initiative" …". The preview's og:title / og:description /
og:site_name are now decoded once where the preview is fetched, so every
card (about 20 read those fields directly) gets clean text. Rendered as
React text only, so decoding can't inject markup; exactly one level is
decoded.
Verified on the Portal 2 store link: "The \"Perpetual Testing
Initiative\" has been expanded…", no literal " left on the page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
RoomView focuses the composer on any "typing" key pressed while nothing
editable has focus, and Escape counted as typing. Closing the GIF picker
with Esc (its focus trap returns focus to the GIF button) therefore
landed in the composer instead, and so did Esc on any other room control.
Escape, CapsLock, Insert, ContextMenu, PrintScreen and Pause are now
excluded like the other non-typing keys.
Verified at 1300 px and 320 px: Esc closes the picker and focus is back
on "Insert GIF"; the 320 px picker fits with no page overflow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Removed every `as any` in src/, then restored only the ones tsc still
needs: 163 → 36. 112 were in lotus-terminal.css.ts (`'… !important' as
any` etc. — vanilla-extract's style types accept these strings as-is),
so its file-wide eslint-disable goes too. The casts were type-only, so
emitted code and generated CSS are unchanged.
eslint warnings 49 → 46; the ratchet is tightened to match.
tsc clean; 1202 unit tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Shown only while Launch on login is on, and only when the native side
answers `get_start_minimized` (older desktop builds just don't show it).
Toggling calls `set_start_minimized`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
register() rejects on non-http(s) origins, and the rejection was
unhandled. The desktop app's debug build loads from tauri://localhost,
which surfaced as a Sentry "serviceWorker.register() must be called with
a script URL whose protocol is either HTTP or HTTPS". Skip registration
there, and catch any other failure (e.g. SWs disabled) with a warning.
The app works without a SW.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The comment said on.soundcloud.com links need an oEmbed round-trip. They
don't: Synapse follows the redirect for the preview and the og:url
fallback re-parses the canonical track URL. Checked with a real short
link (the play facade loads the w.soundcloud player).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Upload atoms now also report into an app-wide `uploadProgressAtom`, and
`aggregateUploadProgress` turns every in-flight upload into one taskbar
state: byte-weighted percentage while uploading, indeterminate until a
size is known, red after a failure (held 4 s, then cleared), none when
done. Cancelled uploads just disappear. `useTauriTaskbarProgress` sends it
to the native `set_taskbar_progress`, at most ~4 times a second, always
ending on the latest state.
Verified with a throttled 6 MB upload: 0% → 99% at 250 ms steps, then
cleared; with the upload request aborted: 0% → error → cleared after 4 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Sends `set_tray_call_state` from the same effect as the taskbar thumbbar,
so tray and thumbbar can't disagree. Verified through a real local call:
idle → active → active+muted → active+muted+deafened → active+muted → idle.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
useCallControlState kept the previous control's state (the all-off
default before a call) for one render after the control changed, until
its effect caught up. The thumbbar, SMTC and tray hooks pushed that render
to the OS, so every join flashed "muted + deafened" first (recorded:
idle → active+muted+deafened → active). The state now remembers which
control it belongs to and reads the new control directly when they differ
(now: idle → active).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
While an update is available the web client asks the native side to show
"Restart to update (vX)" in the tray menu and an "update ready" tooltip;
it's cleared once a check reports we're current, and kept while
installing or after a failed attempt. Clicking the tray item runs the
same install flow as the toast, so progress and failures show in-app.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
A friend's update failed ten times in a row ("Update check failed: error
sending request for url (…nsis.zip)") before the 11th went through, and
he didn't know what to do. The label was also wrong: the check had
worked; the download failed.
- Progress: "Downloading update… 28% (14.3 MB of 49.9 MB)", "The update
server didn't respond. Trying again in 3 s (attempt 2 of 4)…", from the
native `lotus-update-progress` events (cinny-desktop retries itself).
- Failures name the step (check / download / install, from the native
error prefix) in plain language, with Try again and a Download
installer button (Windows: the setup .exe; else the release page), and
the raw error under "Details".
- Installing from the update toast now shows a "Downloading update"
toast, and on failure a sticky "Update didn't install" toast that
retries on click and points at Settings → General → App Updates.
Before, the toast vanished and the failure was only visible in Settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Notification sounds called `audio.play()` without handling the promise,
so every message that arrived before the user interacted with the page
(e.g. right after launch) logged an uncaught NotAllowedError — 15 in a
short test run. Same pattern in the video thumbnail loader, the voice
preview (which now also resets its Play button) and useMediaPlay.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Handles the native `lotus-notification-mark-read` event through the
same `markAsRead` path as the room menu's "Mark as Read", honouring the
private-receipt settings. Verified: 3 unread → 0 on the server.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- useTauriFocusAssist queries the new `get_focus_assist` command on mount.
The native poll's first reading is emitted during app setup, before the
page listens, and the atom resets on every reload — so with Focus Assist
already on, notifications leaked until the OS state flipped
(cinny-desktop #15).
- The toast quick reply takes the real `threadId` from the notification
data and replies inside the thread (cinny-desktop #17).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
public/res/ carried two DIFFERENT files whose names differ only in case —
Lotus.png (19 897 B, the 256px logo used by the welcome page, auth layout,
OIDC config and About) and lotus.png (2 073 B, the notification icon).
Windows and macOS filesystems are case-insensitive, so a checkout there
collapses them into one path: one of the two references then resolves to
the wrong bytes or to nothing, which is why the desktop client's home
logo rendered as alt text from
http://localhost:44548/public/res/Lotus.png.
Renamed the logo to lotus-logo.png and updated its four references;
verified no case-only filename collisions remain anywhere in the repo,
and that both logos still load (256x256 on the welcome page and About).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Two sizing bugs, both visible the moment a video starts:
1. The video/TikTok embed columns had no width. UrlPreview is a flex ROW,
so they shrink-to-fit: the facade's <img> supplied the width, but the
player <iframe> is absolutely positioned and supplies none, so pressing
play collapsed the whole embed to the iframe's ~200px intrinsic size
(measured 606x341 -> 204x115 in a 608px card). Both columns are now
width: 100%.
2. EmbedMediaPortrait's 9:16 came from a 177.78% padding-top, but a
padding percentage resolves against the CONTAINING BLOCK's width, not
the element's — so inside a wide card a Short/TikTok rendered
300x1077 instead of 300x533. Capped with min() so it is exact at
>= 300px and still correct on narrower phones.
Audited every provider before/after play at 1300 px, 500 px and Pixel 7:
YouTube, Vimeo, Twitch 16:9 (0.56); Shorts, TikTok 9:16 (1.78); Spotify
152 px and SoundCloud 166 px fixed-height — all stable across play.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The quick-status auto-clear removed its localStorage record BEFORE the
presence write and swallowed any error, so a single failure dropped the
timer permanently and the status stayed set forever. Synapse rate-limits
presence to ~1 write / 10 s per user (#226) and the heartbeat spends that
budget, so 429s here are routine — especially right after startup, when
the monitor's first check runs.
The clear now goes through setPresenceWithRetry (honours retry_after_ms)
and only forgets the status once the server has taken it; a re-entry guard
stops overlapping attempts and the poll is 15 s so a retry lands promptly.
Reproduced and verified with three injected 429s: before, the status
stayed on the server forever with the local record gone; now it clears.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
A "Mark all read" chip appears in the Threads panel header whenever any
thread in the room has unread replies, and sends one threaded receipt per
unread thread (never the root — a root lives in the main timeline and a
receipt there would drag the MAIN read marker backwards, the P6
regression). Honours the private-receipt settings.
Verified: two threads with unread replies → the chip appears, the rows
say "unread" in their labels; after clicking, no unread rows and the chip
is gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Touch had no way to move between images except the small arrow buttons.
A one-finger horizontal swipe now goes next/previous, and is inert while
the image is zoomed in (where the same gesture pans). The classifier is
pure and unit-tested: ≥ 60 px horizontal, not mostly vertical, under
800 ms, single finger.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
io.lotus.screenshare_notice → "Screen sharing stopped — the shared window
was closed.", "Your screen share is showing nothing — the shared window
may be minimised or hidden.", and a sticky "Still sharing?" after 30 min
with nobody else in the call (fork ≥ 0.25.0-lotus.12).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
trigger-desktop checks the age of cinny-desktop's last main commit and
skips the submodule bump when it is under 60 minutes old; cinny-desktop's
new nightly catch-up workflow (or a manual dispatch) moves the submodule
to lotus HEAD for whatever landed inside the window. The desktop cadence
stops tracking every web commit, which was the biggest runner-load source.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Verifying the KaTeX checklist: `$x^2$` in inline code was turned into
math and the backticks were left as literal text, because the math split
runs before markdown. The splitter now skips backtick code spans (N ticks
close with N), so markdown's inline code wins: wire is
<code>$y^2$</code>. Fenced blocks were already fine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Verified first: the embed hides Element Call's footer, so its in-call
settings sheet (which has the output picker) is unreachable from Lotus;
the cinny call bar had no output control.
Now a speaker button next to Deafen (desktop bar only; hidden where
setSinkId is unavailable — Firefox, Safari, Android Chrome) opens a menu
of enumerateDevices() audio outputs with the current one checked; picking
one sends io.lotus.set_audio_output to the fork (≥ 0.25.0-lotus.11),
which selects it, and the choice is re-sent with the rest of the sticky
fork state after an EC remount.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Room Settings → General now reads e.g. "Encrypted · Invite only ·
History visible to members since they joined" under the name — derived
from m.room.encryption, m.room.join_rules and m.room.history_visibility
with the same words the preview-card chips and the history setting use.
One Text line, no card, no icons, no controls; spec defaults (invite-only,
shared history) when a state event is absent. Unit-tested.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
utils/haptics.ts: tick('ptt-on' | 'ptt-off' | 'reaction') → 10/10/8 ms
navigator.vibrate, a no-op without the API (iOS), when the system prefers
reduced motion, or when the new Settings → Calls "Haptic Feedback" switch
(default on, only rendered where the API exists) is off. PTT is observed
once through pttActiveAtom so the keyboard, global-hotkey and on-screen
paths all tick; reactions tick where the reaction event is sent in the
room and thread timelines (quick bar, hover bar, sheet and emoji board
all funnel there).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Verified first: after a paste the focus stayed in the composer and the
caption needed a click. Now, when exactly one image is pasted or dropped
into an empty composer, its upload card's caption input takes focus;
Enter there sends the board (with any composer text) and Escape returns
focus to the composer. Multi-file drops and non-empty composers are
unchanged. The target card is matched by file name + mtime because the
metadata strip re-wraps the File.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Room context menu → "Add to Section" submenu: every u.<name> tag in use
across your rooms as a checkable item, plus a "New section…" field
(validated: non-empty, ≤ 40 chars, no dots, unique). Toggling writes or
deletes the standard u.<name> room tag (order 0.5), so sections sync
across devices and other clients see the same tags. The menu row reads
"Sections: Raids, Off-topic" once a room is in any.
Home renders each section as a collapsible category between Favorites
and Rooms (alphabetical; members by tag order then name; the same
closed-state store and unread-only-when-collapsed behaviour as the built-in
categories). A sectioned room leaves the plain Rooms list but keeps a
Favorite / Low Priority placement. Empty sections don't exist by
construction; rename is retag (v2). Derivation in utils/roomSections.ts
with unit tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
One tile under Settings → General → Storage: total local usage vs the
browser's quota from navigator.storage.estimate(), the Chromium
usageDetails breakdown when available (IndexedDB = sync cache + encryption
keys + search index; Cache Storage = offline app files), and whether the
browser granted persistent storage — green "Protected" or amber "May be
evicted" with a "Keep my data" button that calls storage.persist(). That
last bit is the useful diagnostic for the KE-1 storage-eviction cluster.
No clear button: media lives in the browser's own HTTP cache (not in the
estimate, and not clearable from a page), the crypto store must never be
casually cleared, and the search-index clear already lives in Message
Search. The About page's "Clear Cache & Reload" now says it deletes this
device's encryption keys too. Hidden entirely when estimate() is missing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
While joined, a device-local record {roomId, deviceId, joinedAt,
lastSeen, mic, video} is written and refreshed every 30 s; a deliberate
hangup (HangupCall/Close) and logout clear it. On the next start, once
the first sync is in and the room's MatrixRTC session has reported its
members (waited for up to 10 s — it fills a moment after sync), a fresh
record (< 10 min) for this device with someone still in the call yields
either a sticky "Rejoin voice?" toast (tap to rejoin) or an automatic
rejoin, per the new Settings → Calls → After a Restart (Ask / Rejoin
automatically / Do nothing; default Ask). Skipped when our own membership
is already live from another device, or only our stale one is left.
Mic/camera state comes from the record (camera still gated by
cameraOnJoin); PTT is applied by startCall as usual.
Pure decision in utils/callRejoin.ts with tests. Verified headless:
reload mid-call → toast → tap → back in the call; hangup → reload → no
toast; auto mode → back in without a prompt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
A reply to an image, video or sticker used to quote just the filename.
The quote (timeline) and the composer's reply-draft preview now carry a
36 px thumbnail from the event's own thumbnail, decrypted for E2EE media
via the same hook the gallery uses — never the full-size file. Clicking
still jumps to the original.
useDecryptedMediaUrl and getThumbMxc moved out of MediaGallery into
hooks/ and utils/ so components/message can use them without a cycle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Metadata was only dropped as a side effect of opt-in compression, so a
phone photo carried its GPS fix, camera model and timestamp into the room
and the media store. utils/stripImageMetadata.ts now removes it at the
container level, without touching pixels: JPEG drops APP1/APP13/COM
(writing back a minimal EXIF holding only Orientation when it isn't 1,
so sideways-stored photos still display upright), PNG drops eXIf and the
text chunks XMP lives in, WebP drops EXIF/XMP and clears the VP8X flags.
Other types pass through.
Applied before encryption on every composer path (attach, paste, drop,
share target) and to user/room avatar picks; GIF upload is excluded.
Setting → General → Privacy "Remove Photo Metadata Before Sending",
default on. The upload card says "Photo metadata removed".
Unit tests on generated fixtures with a GPS IFD (JPEG orientation 6,
JPEG + comment, PNG with eXIf + XMP, WebP with EXIF); verified end to
end: the bytes stored by Synapse decode fine and carry only Orientation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Event handlers typed as React.MouseEvent, join errors as Error, the
service-worker session setter as strings, the UIA policy map and the
webkitAudioContext probe as narrow shapes. No behaviour change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
One line in the existing toast style when a call you were in ends: the
duration from our own join clock, plus the fork's io.lotus.call_summary
readout (fork ≥ 0.25.0-lotus.10) when it arrives — "connection was
good", "3 reconnects", "connection was poor for 4 min". Nothing is
stored or sent; the summary is one postMessage at hangup. Without the
fork summary the toast still shows the duration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
public/manifest.json declares share_target (POST multipart to
/share-target: title/text/url + image/video/audio/pdf/text files). The
service worker answers that POST itself: it stashes the form in a Cache
API bucket and 303s to the in-app /share page, which lists what arrived,
offers a room search, and on pick writes the files into that room's
upload-board atom (encrypting first for E2EE rooms via the composer's
shared filesToUploadItems) and the title/text/url into its draft, then
opens the room — the user still presses Send. The stash is cleared once
placed; reopening /share afterwards says so.
nginx/caddy examples and the prod image config gain a 303 for
/share-target so a POST that reaches the origin before the worker
controls the page lands on /share instead of a 405. iOS has no share
target support and ignores the manifest entry.
Verified headless against the built preview: SW-controlled page → POST
/share-target (two PNGs + title + text) → /share lists both files and the
text → pick the DM → composer shows both files on the upload board and
the text in the draft → /share reports nothing pending.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
e2e/a11y.spec.ts runs @axe-core/playwright (WCAG 2.x A/AA) over the login
page, room timeline + composer, message options menu, thread panel, user
settings and room settings, failing on critical/serious findings other
than colour contrast (reported, not gated: generated avatar colours and
portal false positives). Aria snapshots of the composer, message menu,
thread panel and settings nav catch lost names/roles/live regions.
Burned down what the first run found:
- NavItem: callers' aria-selected is not valid on a div (axe critical);
it now drives data-selected for styling and aria-current="page".
- Composer placeholder at 0.5 opacity was ~2.3:1; now P300.
- Voice-limit and explore custom-limit number inputs had no label.
- Thread panel is an <aside aria-label="Thread">; the settings modal is a
role=dialog; the settings sections are a <nav>; the message action
menu carries data-message-menu + a label.
Also allows WebKit's CI wording for the well-known probe ("Could not
connect … Connection refused") that failed run #2003.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Two new Playwright projects — 'webkit' (Desktop Safari) for tests tagged
@webkit and 'iphone' (iPhone 14 descriptor) for @ios — covering boot,
login + send/receive, the thread panel and the gallery lightbox. The CI
e2e job installs webkit next to chromium.
WebKit reports handled fetch failures (well-known probes, a wasm fetch cut
short by our own navigation) as page errors with its own wording, so the
benign allowlist now applies to page errors as well.
Locally: 8/8 green twice in a row against the local Synapse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Client-side only: every file is still its own standard m.image/m.video
event, so Element and friends keep seeing N plain images. In Lotus a run
of media from one sender — contiguous, ≤ 60 s apart, no reply/thread/edit
relation, up to 10 — renders once, at its last event, as a 2–4 column
grid of square thumbnails (blurhash placeholder, video play badge,
tap-to-load when media auto-load is off). A member with reactions or a
thread closes its group so those stay visible under the rendered event.
Tapping a tile opens the lightbox on just that group in send order
(←/→, zoom, download, jump). "Show separately" splits a group back into
individual messages for the session; "Show as gallery" undoes it.
Planning is lazy per render pass (utils/mediaGroups.ts, unit-tested):
the first media event met plans its whole run in both directions, so a
virtual window that starts mid-run agrees with one that starts before it.
Verified: 5 files dropped at once in an encrypted room — both sender and
recipient see one 5-tile grid with decrypted thumbnails; desktop + phone;
a reaction on photo 3 yields [1–3]+👍 and [4–5].
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Audit of every rendered time found five families of ad-hoc formatting:
the shared Time component + copies of its today/yesterday branch
(forwarded header, thread summary, read receipts, device tile, moderation
alerts, edit history), locale-default toLocale*String calls that ignored
the user's 12/24 h and date-format settings (scheduled tray, reminders,
schedule preview, notification snooze, bookmarks, threads list, search
cache line, room insights, media gallery), a hard-coded en-US date in the
activity log, and three relative-age variants.
utils/formatTimestamp.ts now holds the rules — today → time; yesterday /
tomorrow → day word + time; last 6 days → weekday + time; older → date +
time in dateFormatString — plus autoDate / time / date / dateTime styles,
formatDayDivider (full weekday), formatShortAge (room list) and
formatRelativeAge (list rows). useTimestampFormatter binds them to the
settings. 11 unit tests with an injected 'now'.
Visible changes are limited to consistency: 12 h times keep the existing
zero-padded hh:mm A; the a11y label and Created-by line use the user's
date format instead of a fixed long month.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Fat-finger guard for phones. On coarse-pointer devices the first tap on a
sticker (emoji board Sticker tab) or a GIF parks it in a small bar with a
thumbnail, name, Send and Cancel; a second tap on the same item or Send
sends it; tapping a different item switches the preview; tapping empty
picker space or Cancel clears it. Mouse clicks, keyboard activation and
screen-reader activation (bare click without touch events) still send in
one step, and the emoji tab is untouched.
useRecentTouch records touches inside the picker at the document level so
it survives the Sticker tab remount, and reports false without a coarse
pointer.
Also fixes the compact composer's GIF picker opening mostly off-screen:
end-aligning the 312px popout to a button near the left edge of the
overflow row pushed it to x≈-95; it now anchors to the row itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Many distinct reactions used to wrap into a tall stack (16 reactions =
4 rows at phone width). Now only the first row is shown, ending in a
"+N" chip; tapping it expands inline and a "less" chip collapses again.
Expanded state is remembered per message for the session. Nothing changes
when the reactions already fit on one row.
Overflowing chips stay in the DOM (invisible, aria-hidden, untabbable,
clipped by max-height) so the container keeps its natural width — which
keeps shrink-to-fit bubble layout stable — and each chip stays measurable.
utils/reactionOverflow.ts holds the unit-tested fit calculation; a
ResizeObserver re-fits on width changes. "+N" is forced LTR for RTL UIs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Hovering or focusing "(edited)" shows a tooltip with only the most recent
edit as a word diff — removed words struck, added words bold — plus a
+N/−N summary. Clicking still opens the full history viewer. On touch, a
long-press on the label shows the same diff as a popout (a plain tap opens
the viewer; the message's own long-press action sheet is not triggered).
utils/wordDiff.ts is a unit-tested LCS over words that ignores whitespace-
only changes and gives up past 400 words. Only plain-text bodies are
diffed; formatted edits fall back to the viewer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
A small red dot on a space tab when any room in that space (recursively)
has an active MatrixRTC session, so a live call is visible even when you
are looking at a different space or your DMs. Hidden while the space is
selected — the room list already shows the Live badge there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
One line under the handle in the user profile popover, from the presence the
SDK already receives (currently_active + last_active_ago): 'Active now',
'Active just now', 'Last active 12 min ago / 3 hours ago / yesterday / 5 days
ago / over a month ago'. Nothing is shown without presence data or for an
offline user with no timestamp (Hide Online Status users), so nothing new is
exposed; re-renders on the existing presence events, no polling. Wording
unit-tested; verified headless ('Active now' for an online member).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Clicking a reply quote (or arriving via a thread-panel / search / permalink
jump, i.e. an eventId route change) remembers the message that was in the
middle of the viewport and shows a Primary chip next to Jump to Latest —
'Back to where you were' — which scrolls/navigates back to it. Nothing is
rendered normally; the chip expires after 20 s or once used. Verified headless:
reply quote → original 120 messages up is shown + chip; chip → back at the
reply, chip gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Verified upstream first: the Copy chip (with a 'Copied' state) already exists
on every code block. Added the missing half: a Wrap chip that switches long
lines to pre-wrap (default stays no-wrap + horizontal scroll); the choice is
remembered for the session, not persisted. Markdown/HTML output, layout widths
and the syntax theme are untouched. Verified headless: scrollWidth 1016 →
612 px with wrapping on, 'Copied' state on copy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
useSensitiveCopy: the recovery key's Copy button becomes 'Copied · clears in
60 s' and counts down; at zero the clipboard is cleared only if it still holds
the key (readText() where permitted — if the browser refuses to read, nothing
is wiped rather than risk eating something else). Any other copy made in the
app cancels the timer. No setting. Verified headless with a fake clock:
countdown ticks, clipboard emptied at 0; copying something else mid-countdown
cancels and leaves that content untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
utils/linkSafety.ts compares the registrable domain the visible text claims
(when it looks like a URL/host) with the href's; a mismatch, or a punycode
(IDN) destination, renders the anchor as SuspiciousLink, whose click opens a
confirm — "It shows matrix.lotusguild.org but goes to evil.example." with the
full URL, Cancel / Open anyway (opens in a new tab with noopener). Honest links
are untouched: same registrable domain (youtube.com text over www.youtube.com,
bbc.co.uk over news.bbc.co.uk), plain-word text, mailto:, matrix.to and Lotus
permalinks, anchors with non-text children. Comparator unit-tested (incl. a
Cyrillic-а paypal homograph); verified headless that the phish and IDN
messages are flagged, the honest ones are not, the click shows the confirm and
does not navigate, Cancel keeps you put, Open anyway opens the real target.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Host half of the talking-while-muted feature. The fork (0.25.0-lotus.9)
reports speakingWhileMuted for the local participant in io.lotus.call_state —
a level tap on the muted track, so cinny captures no audio. useMutedTalkWarning
polls that flag while muted; after 1.5 s of it being true it shows one sticky
toast, "You're muted — click to unmute", whose click unmutes; it fires at most
once per mute session (re-arms after unmute → mute), is retired the moment the
mic comes back on by any means, and never runs in Push-to-Talk mode. Setting
'Warn me when I talk while muted' (default on) sits next to AFK Auto-Mute.
Verified headless with a tone mic: no toast at +1 s, toast at +4 s, click
unmutes and clears it, second mute re-arms, unmute via the button clears it,
PTT mode → nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The e2e job now runs scripts/dev-homeserver.sh start + dev-seed.py before
Playwright (installing python3-venv if the runner lacks it) and stops it
afterwards. e2e/local-homeserver.spec.ts registers its own users and rooms
over the CS API and drives the built client — no prod secrets — covering the
fixes that were reproduced with scratch scripts this week: login/send/receive,
own-message scroll (#212), /kick toast (#216), upload 413 wording (#213),
forward provenance, thread panel at 1400px (#218), timeline lightbox (#219),
clock-skew banner via page.clock (#158), status save under the presence limit
(#226), long-press action sheet on a Pixel 7 emulation (#166). Skips itself
when no homeserver answers, so
> lotus-chat@4.12.7-lotus test:e2e
> playwright test
Running 16 tests using 1 worker
✓ 1 [chromium] › e2e/boot.spec.ts:8:3 › boot › client boots to the login screen without errors (1.5s)
✓ 2 [chromium] › e2e/boot.spec.ts:27:3 › boot › service worker script is served and registers (1.4s)
✓ 3 [chromium] › e2e/boot.spec.ts:54:3 › boot › bundled Element Call loads in a frame (3.8s)
- 4 [chromium] › e2e/e2ee-composer.spec.ts:67:3 › E2EE composer › logs in with a password and reaches the client
- 5 [chromium] › e2e/e2ee-composer.spec.ts:84:3 › E2EE composer › creates a private encrypted room and sends a text message
- 6 [chromium] › e2e/e2ee-composer.spec.ts:146:3 › E2EE composer › attaches a compressed image and it is sent encrypted
✓ 7 [chromium] › e2e/local-homeserver.spec.ts:31:3 › local homeserver regression › logs in, opens a room, sends and receives (4.5s)
✓ 8 [chromium] › e2e/local-homeserver.spec.ts:58:3 › local homeserver regression › your own message scrolls into view even after scrolling up (#212) (11.4s)
✓ 9 [chromium] › e2e/local-homeserver.spec.ts:76:3 › local homeserver regression › /kick failure is reported, not swallowed (#216) (5.4s)
✓ 10 [chromium] › e2e/local-homeserver.spec.ts:90:3 › local homeserver regression › upload failure shows a plain sentence, never the raw MatrixError (#213) (3.7s)
✓ 11 [chromium] › e2e/local-homeserver.spec.ts:122:3 › local homeserver regression › forwarded message carries its provenance header (6.7s)
✓ 12 [chromium] › e2e/local-homeserver.spec.ts:143:3 › local homeserver regression › thread panel: opens from the chip and yields the member drawer at 1400px (#218) (4.7s)
✓ 13 [chromium] › e2e/local-homeserver.spec.ts:172:3 › local homeserver regression › timeline image opens the gallery lightbox (#219) (3.9s)
✓ 14 [chromium] › e2e/local-homeserver.spec.ts:204:3 › local homeserver regression › warns when the local clock is far off the server (#158) (6.5s)
✓ 15 [chromium] › e2e/local-homeserver.spec.ts:221:3 › local homeserver regression › status save survives the presence rate limit (#226) (12.6s)
✓ 16 [chromium] › e2e/local-homeserver.spec.ts:265:3 › local homeserver regression › touch: long-press opens the message action sheet (#166) (5.0s)
3 skipped
13 passed (1.2m) still works cold. 13 pass
locally against dist + the dev homeserver in 1.8 min.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
One small Surface chip next to the header controls, only when a rule exists:
'0/10' (participants / io.lotus.voice_limit) and/or a muted-camera glyph when
the room's io.lotus.room_quality forbids camera and screenshare; the tooltip
and aria-label carry the words ('Voice limit 10 (0 in call) · Audio-only
room'). Desktop only — the mobile header is already tight. Verified headless:
appears with the rules set, disappears when they are cleared.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Reported: 'Failed to save status — server may be rate limiting. Try again.'
on the first status change in a month. Root cause: Synapse rate-limits
PUT /presence/{user}/status to ONE request per 10 s per user by default
(rc_presence.per_user: per_second 0.1, burst_count 1), shared across all of
the user's devices, and our presence heartbeat (online/away on visibility and
activity changes, from every open tab/device) spends that budget — so a manual
save that lands within 10 s of a heartbeat gets a 429, which the form showed
as a dead end.
Two fixes: (1) the status save waits out Retry-After (bounded to ~25 s) via
setPresenceWithRetry instead of failing, and the error text now says what
actually happened (rate limit / server text / offline); (2) the heartbeat
dedupes — it only sends when presence or status actually changes (/sync
already keeps us online), so it stops burning the budget in the first place.
Unit-tested; reproduced headless with a routed 10 s limiter: heartbeat ok →
save 429 → retried 8 s later → saved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
CollapsibleBody kept its collapsed flag in component state, which the
timeline's windowed rendering throws away when a row unmounts. A module-level
Set<eventId> (never persisted, cleared on reload) now seeds the state, so
scrolling away and back, or a re-render, keeps an expanded message open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Verified headless that pasting an image copied from a web page (bitmap +
text/html <img src alt> + text/plain URL) already produced only the upload
card and an empty composer; make that explicit with a preventDefault so a
future editor change can't regress it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The only join path that skips the prescreen and can start with the camera on
is answering a video call with 'Join with Camera On' enabled. The incoming
dialog and the in-call banner now say 'Answering turns your camera on', label
the primary button 'Answer with camera' and add 'Answer without camera'.
Nothing changes for voice calls or when the setting is off. The other
no-prescreen paths already can't start on camera: the sidebar join uses a
session video pref that is only ever true after the user turned it on
themselves, and it is guarded by the same setting.
Verified headless: video call + setting on → notice + both buttons, 'Answer
without camera' joins with videoEnabled=false; voice call → plain Answer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
On deafen the participant set is snapshotted; on undeafen it is diffed and,
only if it changed and you were deafened for at least 10 s, one auto-dismissing
toast says 'While you were deafened: Alice, Bob joined · Cole left' (names
capped at 3 + N more). Rides the membership stream that already drives the
join/leave sounds — no new subscriptions; PTT holds don't touch deafen so they
can't trigger it. Verified headless: short deafen → nothing; bob leaves during
an 11 s deafen → 'bob left'; long deafen with no change → nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Elbow-on-the-keyboard guard: a PTT hold longer than 5 min (fixed, not a
setting) is released exactly like a keyup — pttActive off, mic muted — with a
toast 'Push to talk released after 5 minutes — press the key again to keep
talking.' A fresh press re-engages normally. One watchdog shared by the DOM
path and the desktop-global hotkey path; keydown auto-repeat no longer
restarts anything; non-PTT mode, deafen and the mic button are untouched.
Helper unit-tested with fake timers; verified headless with Playwright's clock:
held → still open at +4 min → released + toast at +5 min → re-press works.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Picks up the fork change that shows the SFU token service's refusal reason
("Can't join this call — This voice channel is full.") instead of the generic
OPEN_ID_ERROR page. Verified headless with a routed 403 on /sfu/get.
(lotus.6/7 were tagged but failed CI lint; lotus.8 is the published one.)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Room Settings → General → Privacy: 'Your messages in this room' with a
Delete all… flow. The confirm dialog first counts your events with a
server-side sender-filtered /messages walk (live count), then asks to confirm
with the number — typing the room name above 50 — and offers 'Leave the room
afterwards'. Files are called out as not purged by a redaction.
The job runs outside React (closing settings is fine): sequential redactEvent
with 429 back-off, 404/already-redacted skipped, progress on the tile with
Cancel, pending ids persisted per room so a reload shows Resume/Discard, a
toast when done. State events are never touched; reactions, edits and thread
replies you sent are included; encrypted rooms work the same (nothing is
decrypted). Own events need no power level, so it is purely self-service.
Unit tests cover candidate filtering, the server filter, pagination, 429/404
handling and cancel. Verified headless: 62 of bob's events (60 messages, a
reaction, a thread reply) redacted in ~34 s while alice's 10 stayed; cancel at
17/40 → reload → Resume → 'Deleted 40 messages.'
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Message notifications shown through the service worker now carry a text-input
'Reply' action (Chrome desktop/Android). On notificationclick with
action==='reply' the SW sends the typed text itself — it already holds the
newest session's access token for authenticated media — as m.room.message
(threaded when the notification was for a thread), so it works with the tab in
the background or closed; a failed send shows a 'Reply not sent' notification
that opens the room. Not offered for encrypted rooms (the SW cannot encrypt).
The sender lives in swReply.ts so it is unit-tested; verified headless that
the SW notification carries actions + {roomId, threadId}.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
On coarse-pointer devices the hover action bar is unreachable and iOS Safari
has no contextmenu on long-press. useLongPress (450 ms, < 10 px movement,
cancelled by scroll/lift) now opens an ActionSheet — slides up from the
bottom, drag handle, swipe-down or backdrop tap to dismiss — with the quick
reactions row + 'Add reaction' on top and the same folds Menu the desktop
popout uses (extracted to a shared menuJSX) beneath. Android's contextmenu
echo of the same press is dropped; the press-induced text/image selection is
cleared; the finger-lift's synthetic click is swallowed so it can't hit a menu
item; the sheet ignores 'outside' clicks for its first 600 ms. Desktop is
unchanged (right-click → anchored popout).
Verified with Playwright touch emulation (Pixel 7, CDP touch events):
long-press → sheet with 👍 / Add Reaction / Reply / Forward / Bookmark /
Remind Me / Reply in Thread / Copy Link / Pin / Delete; swipe-down dismisses;
a scroll gesture does not open it; tapping Reply closes it and arms the
composer. Still wants a real iPhone + Android pass before it's called done.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Of the two plaintext-localStorage items in #204, composer drafts were already
swept on logout (#41); the presence status message + expiry were deliberately
kept. They are PII with an authoritative copy in server presence, so sweep
them too. The test's localStorage mock now enumerates keys like the real
Storage object, so the prefix sweeps (drafts, status) are actually exercised —
the old 'draft preserved' assertion only passed because Object.keys() saw
nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The ring event (m.rtc.notification) now renders — only once the ring is over
(its lifetime lapsed or the caller hung up) and only if we did not join between
the ring and the hang-up — as a red 'Missed call from bob' line with the ring's
time, in the same style as the call-membership summaries. Derived entirely from
events already in the timeline; nothing is sent; a call we answered shows
nothing extra, and a later separate call cannot retro-actively 'answer' an
earlier missed one.
Verified headless in a DM: ring ignored → line appears once bob hangs up; a
second call that alice answers adds no line and leaves the first one in place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
'Unable to decrypt message' now carries one sentence per matrix-js-sdk
DecryptionFailureCode (describeDecryptionFailure, unit-tested against every
code so no raw code can leak into the copy) and, where something fixes it,
one button: no key backup → 'Set up key backup'; backup exists but this
session can't open it / key withheld for an unverified session → 'Unlock key
backup' / 'Verify this session' (both open Settings → Devices via a new
settingsRequestAtom that SettingsTab consumes); backup working or unknown
session (rust-crypto re-requests keys itself) → 'Retry', which re-runs
decryptEventIfNeeded. Sender-side problems are plain text. The raw code sits
in the placeholder's tooltip for support.
Verified headless on a fresh session in the encrypted seed room: each event
shows 'Sent before you signed in here, and no key backup exists…' with
tooltip HISTORICAL_MESSAGE_NO_KEY_BACKUP; the button opens Settings → Devices.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Every page load contacted fonts.googleapis.com / fonts.gstatic.com (IP +
referrer to Google, GDPR exposure) for the Lotus Terminal font. VT323 (OFL)
now ships in public/fonts as latin + latin-ext woff2 subsets with @font-face
declarations in the existing custom-fonts.css; the three Google tags are gone
from index.html. Also deletes three unreferenced JetBrainsMono-*.woff2 files
that were actually 1.6 kB Google 404 HTML pages.
Verified headless: no third-party host on a plain load; document.fonts.load
('16px VT323') resolves from /fonts/VT323-latin.woff2. CSP tightening
(nginx + tauri) follows in their repos.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Clicking an image in the room or thread timeline now opens the same viewer
the media gallery uses — dark backdrop, sender + date, 1/N counter, ←/→ across
the room's media, +/-/0 and wheel/double-click zoom, download, jump to
message — positioned at the clicked event. RoomMediaLightbox feeds it from the
detached media timeline (#163); when the event isn't in the loaded window it
pages back (bounded, 6 pages) and shows the clicked image alone meanwhile, so
the viewer opens instantly. ImageContent gains onOpenViewer (RenderMessageContent
passes onOpenImageViewer); its built-in viewer remains for stickers, search
results, pins, notifications and avatars.
Verified headless: click → 'Media viewer' dialog focused, counter 1/2, '+' →
120 %, Jump to message present, Esc closes; an older image 10 pages up →
found at 10/45 after paging.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Two causes. (1) Three right-hand columns don't fit under ~1500 px: the member
drawer now yields to an open content panel (thread / gallery / widgets /
threads list) on desktops ≤ 1500 px and returns when it closes, like Element.
(2) The thread composer rendered the full ten-button toolbar inside a 360 px
panel, leaving 24 px for the input ('S…') at every width — RoomInput gains a
compactLayout prop (the phone one-row '+ | input | emoji | send' layout) and
ThreadPanel uses it.
Measured headless: 1400 px thread open → drawer hidden, composers 328/168 px
(was 422 → 'Send a mes…' + 24 px); 1600 px → drawer stays, thread composer 168 px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
All 11 seasonal overlays (particles + vanilla-extract keyframes) were in the
main JS and CSS for everyone year-round. Each is now a React.lazy chunk fetched
only when that season is active (auto date or override) or previewed in
Settings → Appearance; the schedule/override decision stays in the main bundle.
check-bundle-size: largest chunk 332.4 kB → 320.1 kB gzip; main CSS 68.4 kB →
56.5 kB; total gzip 1551.6 kB → 1561.3 kB (per-chunk overhead, only paid when
a season is on). Verified headless: no theme chunk requested at startup with
'auto' out of season; the Settings grid fetches the 11 previews; picking
Halloween renders the full-screen overlay (20 particle nodes).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
A visually hidden aria-live=polite region (rendered by CallEmbedProvider so it
outlives the embed) announces joins/leaves — batched over 1.5 s: 'alice
joined', 'alice and bob joined', '3 people joined' — your own mute/unmute,
deafen/undeafen and screenshare start/stop, and 'Call ended'. Nothing visible,
nothing audible for anyone else, no setting. Verified headless by observing
the region: bob joined → You are muted → … → bob left → Call ended.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Incident 2026-09-17: a wrong Windows clock broke calls and media keys while
the server answered 200 to everything, with no hint in the UI.
Measurement needs no extra requests and no CORS-exposed headers: every live
event carries origin_server_ts and unsigned.age (our server's now − ts at
response time), so localTimestamp − origin_server_ts is the skew. Only
RoomEvent.Timeline live events count (cache replays have stale age and are
already flagged liveEvent=false by the SDK); the initial network sync
qualifies, so a wrong clock is flagged within seconds of startup. Median of
the last 5 samples, ≥3 needed; warn at |skew| > 30 s, clear below 15 s.
UI: a banner in the sync-status slot — "Your computer's clock is 14 minutes
ahead of the server. Encrypted messages and voice calls will fail until it is
fixed." with a per-OS How-to-fix hint and Dismiss for 24 h — plus the same
line in the call status bar while in a call. Never auto-corrects anything.
Unit-tested (median, hysteresis, stale-age rejection, wording); verified
headless with Playwright's clock skewed +14 min and −3 h (banner, hint,
in-call line, dismiss) and in sync (nothing shown).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Auto-scroll on a live event only ran while the at-bottom flag was true, so a
stale flag (in-place growth like a poll gaining a row briefly pushes the anchor
out of view; or simply having scrolled up) left your own just-sent message
below the fold behind 'Jump to Latest'. An own non-relation, non-state,
non-reaction event now always brings the live end into view: advance the
range + smooth scroll when the window is at the live end, otherwise reset to
the live timeline like the Jump to Latest button. Others' messages keep the
current behaviour.
Verified headless: scrolled up 600 px → Send → message visible, no Jump pill
(before: Jump pill, message off-screen).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The upload card printed the SDK's toString — 'MatrixError: [413] nope
(http://<hs>/_matrix/media/v3/upload?filename=…)'. describeUploadError() maps
the common cases to one sentence: 413/M_TOO_LARGE → 'This file is larger than
the server allows (limit N)' using m.upload.size when known, 429 → 'Slow down —
try again in a moment.', 401/403 → 'The server refused this upload: <server
text>', 5xx/transport after the retry loop → 'Couldn't reach the server. Check
your connection and retry.', other 4xx → the server's own sentence, URL
stripped. Both card renderers use it; the raw error is still console.warn-ed
by uploadContent for debugging. Unit-tested; verified headless with routed
413/403/503 responses.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
rateLimitedActions now collects non-429 failures (and a 429 that exhausted its
retries) and returns them instead of swallowing them; existing callers ignore
the return. /kick and /ban turn the list into a CommandError whose message
names who and why, using the server's own sentence (MatrixError.data.error),
never the URL-bearing toString(); RoomInput's toast shows it verbatim.
Verified headless as a non-moderator: '/kick @alice' → "Could not kick
@alice:localhost: You cannot kick user @alice:localhost."; '/ban @nobody
@alice' → "Could not ban @nobody:localhost, @alice:localhost: You don't have
permission to ban".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Tooltip (folds TooltipProvider, 300 ms) on the room-list Live badge listing the
call members' display names — deduped per user, capped at 6 + 'and N more' —
plus an aria-label with the same text so keyboard/screen-reader users get it
too (the badge is focusable). Names come from the same MatrixRTC memberships
that produce the count; no new data.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Re-measured the revoke on the current stack (guard fix + fork lotus.4): the
SFU drops the sharer's tracks and EC's own button, the captured tracks and our
control bar all follow within ~2-4 s — the 30 s lag is gone. What remained was
that the button simply vanished with no explanation, so a call-lifetime hook
now toasts 'Screen sharing was turned off by a room admin.' (or the camera
variant) when a track ends while the room policy forbids it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Answering from the in-call banner is the only call-to-call switch path; it
started the new embed and let the atom dispose the old one, which just removed
the iframe — our m.call.member in the old room lingered ~17 s (until the
delayed leave expired), so everyone there still saw us in the call.
hangupAndWait moves out of LogoutDialog into plugins/call/hangup.ts and
handleAnswer now hangs up, waits for our membership to clear (bounded 4 s),
disposes the old embed itself (its HangupCall echo would otherwise land after
startCall and clear the NEW embed from the atom — seen in testing), then joins
the new call. Measured headless: old membership gone in 1 s, new call live
with both participants.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
CallControl.setSound()/applyScreenshareAudioMuted() no longer poke <audio>.muted
inside the EC frame, and the per-membership re-apply (useCallMemberSoundSync)
is gone: with the pin at 0.25.0-lotus.4 the fork applies deafen and the
screenshare-audio mute through its own renderer, including for late joiners.
Verified headless: deafen set before a second participant joins mutes their
track on arrival; deafen/undeafen leaves the screenshare-audio mute in place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The fork renders EC transparent so the room wallpaper shows through the
full-size embed; in PiP the element behind the frame is whatever room you
navigated to, so the timeline bled through the participant tiles. The PiP
container now gets the surface colour (the full embed keeps the wallpaper).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Picks up the fork fix where "Mute Screenshare Audio" was undone whenever the
sharer stopped and re-shared (EC's own volume controls wrote volume 1 through
the same setter). The mute now rides the renderer's `muted` prop like deafen
does. Docs: fork feature table gains the deafen/screenshare-audio row; the
soundboard + quality rows are marked verified end-to-end.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
When the Element Call frame's navigation fails (offline, blocked) it becomes a
cross-origin error page and `iframe.contentWindow.document` throws a
SecurityError. Every DOM-driven call hook (CallControl selectors, deafen DOM
fallback, useCallSpeakers/useRemoteAllMuted observers, CallEmbed theme
injection) read it unguarded, so the moment the 25 s load watchdog fired the
page logged a burst of uncaught SecurityErrors. Reproduced headless by aborting
the EC index request. One `getCallDocument()` helper now treats "can't read" as
"not loaded yet"; the watchdog overlay itself was already correct.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Verified first on the local calls stack: a participant who left and rejoined
~3 s later played "leave" then "join" every time. Cues now go through
createCallSoundDebouncer (per USER, not per device):
- a leave cue waits 5 s; if the same user is back before it fires, the leave is
cancelled and no join cue is played either;
- a join cue is suppressed for a user who left < 60 s ago;
- same-kind cues within 250 ms collapse, so a batch of leaves still sounds once;
- a second device of someone already present is not a new arrival, and a
device switch is quiet.
Only the sound is debounced; membership UI is unchanged. Sound style, volume
and PTT interplay untouched (the style is read at play time). Timers are
injected — unit-tested with a manual clock. Re-ran the flap scenario headless:
join → flap → (silence) → real leave → one "leave" 5 s later → rejoin within
60 s → silence.
Also enables msc4133 (custom profile fields → in-call avatar decorations) on
the dev Synapse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Root cause of the 'went deaf while typing' reports: the deafen key (default
M) and Cinny's type-anywhere-to-focus-the-composer both listen on window,
so the first letter of a message typed after clicking the timeline toggled
deafen and was swallowed (reproduced: typing 'mom' → deafened, composer
shows 'om'). Now: (1) Settings → Calls → Push to Deafen has an on/off
switch (deafenHotkey); (2) a letter/digit/Space deafen key only toggles
where no composer is on screen — typing wins; (3) such keys are never
bound system-wide on desktop — only F-keys, numpad and the lock/navigation
cluster qualify (isSafeGlobalToggleKey) — so 'm' typed in another app
can't deafen you. Verified in live calls: M still toggles in the call
view, is ignored on a chat screen, switch off disables it entirely.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The incoming-call dialog only went away on Ignore/Answer/Reject on THAT
device or when the notification lifetime expired, so a DM call answered
on the desktop kept the phone ringing for up to two minutes, and a caller
who gave up left everyone ringing. While a ring is showing we now watch
the room's MatrixRTC session and timeline: our own membership from any
device (answered elsewhere), our own RTCDecline for this ring (declined
elsewhere), or an empty session after it has settled (caller hung up)
all dismiss it. Verified with two alice devices + bob on the local
LiveKit stack: answer elsewhere → dismissed; decline elsewhere →
dismissed; caller End → both dialogs gone in 0.5 s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Logout stopped the client with the call still joined; the m.call.member
state (expires 4 h) stayed and everyone saw the user 'in call'. The
logout dialog now sends HangupCall and waits (≤4 s) until our own
membership is gone from the room's RTC session before stopping the
client. Verified on the local LiveKit stack: membership count 1 → 0,
logout completes in ~2 s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Deafen muted the mic (correct) but undeafen left you muted, so every
deafen cycle silently turned into a mute. Remember whether the mic was on
when deafening and turn it back on when undeafening (Discord semantics).
Verified in a real two-party LiveKit call on the local stack.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Gallery lightbox: FocusTrap had initialFocus:false, so focus stayed on the
tile behind the overlay and ←/→/Esc/+/- were dead until the user clicked
inside. Timeline ImageViewer: no keyboard zoom, no role/aria-modal/label,
focus landed on the <img>; now mirrors the lightbox (+ = / - / 0,
double-click toggles 1×↔2×, role=dialog aria-modal labelled by the file
name, focuses on open).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Inside the thread panel each reply rendered a 'Thread ↩ <root author> <root
text…>' quote because the spec's fallback reply relation (is_falling_back
+ m.in_reply_to root) was treated like a real reply — noise on every row
when the root is already pinned at the top. Genuine reply-to-a-reply
quotes are kept. The Threads list and Widgets panel had desktop-only
header buttons and no way to open them on a phone; both are now in the
mobile ⋮ menu next to Members / Media Gallery.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Forwarded events carried no origin at all; they read as the forwarder's own
words. buildForwardContent now stamps io.lotus.forwarded (sender, ts,
room_id, event_id; re-forwards keep the original stamp) and the main and
thread timelines render a reply-style header above the message that jumps
to the original when the viewer is in the source room (sender + time only
otherwise — the source room's name is not leaked). Unit-tested; verified
end to end with Playwright (header text, event content, jump, re-forward).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
vite-plugin-pwa's dev-sw.js imports workbox as a module; registering it
as a classic script failed with 'script evaluation failed', leaving the
dev client with no SW — authenticated media 401'd (broken images in every
dev screenshot) and SW notification routing was untestable. Production
sw.js is unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
All targets were sent concurrently through matrix-js-sdk's message queue;
when the send to a room you cannot post to failed with 403 the scheduler
clearQueue()'d every send still waiting, so 'Send to 3 rooms' with one
read-only room reported 'Failed to forward' for all three and left a
half-sent comment in the first. Rooms are now sent one at a time.
Verified: 'Forwarded to 2/3. Failed: Read Only Room.' and both good rooms
receive comment + forwarded message.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
RoomViewTyping's live region fell into the >3-names branch when nobody
was typing, so screen readers heard that string on every room load and
whenever typing stopped. Empty list now announces nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Every settings toggle was an icon-only role=switch with no accessible
name (36 axe 'button-name' criticals in User Settings, 3 in Room
Settings). SettingTile now gives the title an id and points any unlabelled
switch/input/select in its 'after' slot at it via aria-labelledby.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
axe-core flagged 18 'button-name' criticals on the room view: the space
tabs, Home/Direct/Inbox/Search/Saved/Explore/Add Space/User Settings/
Unverified sidebar buttons, and each message's avatar button had no text
for screen readers. Labels mirror the existing tooltips; message avatars
read '<name>, open profile'. Room view now has zero button-name findings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Clearing bumps cacheVersion so the search re-runs, and the re-run wrote
the scanned rows straight back to IndexedDB, so the index was never empty
while the button was visible. Skip persistence for that one re-run.
Verified: IDB stores go to 0/0 after Clear; logout still deletes the DB.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
In markdown mode each paragraph line is serialised before parseBlockMD
joins them, so $x$ inside a ``` fence became data-mx-maths markup
inside the resulting <pre><code> (rendered as math in a code block).
Track fence state across lines and skip math for fenced lines and for
backtick code spans. Unit tests added; verified in the browser.
Also: scripts/dev-homeserver.sh enables MSC4140 delayed events so
scheduled messages work locally.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
CustomEditor passed one module-level initialValue array to every <Slate>.
slate-react keys its node→path weak maps by node identity, so mounting
the thread composer re-mapped the shared nodes to the new editor and the
main composer threw "Unable to find the path for Slate node" on its next
render, taking the whole client to the error boundary. Anything that had
already edited the main editor (typing, a restored draft) replaced its
nodes and masked the bug, which is why it was intermittent.
Reproduced with Playwright (fresh login → room → click "N replies"):
crashed every time; typing one character first avoided it. Fixed by
creating the initial value per instance (useState).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
matrix-js-sdk rejects an upload whose XHR ends with status 0 (offline,
connection reset, DNS) with DOMException('AbortError') to mimic fetch,
the same name mx.cancelUpload() produces. isRetryableUploadError bailed
on any AbortError, so the one failure class the retry loop was built
for was never retried. Decide by our own cancel AbortSignal instead.
Verified with Playwright routing the upload endpoint: 502 → network drop
→ ok now completes in 3 attempts (1 s, 2 s back-off) and the image sends;
413 still fails fast after 1 attempt; persistent 503 gives up after 4.
Unit tests in utils/uploadRetry.test.ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Room.tsx showed the MembersDrawer on phone widths whenever the persisted
desktop preference isPeopleDrawer was true — which is the default — so a
new mobile session opened every room behind a full-screen member list.
Mobile now uses a transient mobileMembersPanelAtom (header ⋮ → Members
toggles it, the drawer's X closes it, leaving the room resets it); the
desktop drawer keeps using the setting. Verified with Playwright at 400px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
a899d7d3 moved the Room Settings share QR to qrcode.react but the
InviteUserPrompt (header ⋮ → Invite, Room Intro, space menu) kept the
third-party <img>, which leaked the room link and — since the prod CSP
img-src no longer allows that host — rendered as a broken image. Same
QRCodeSVG + white quiet zone as RoomShareInvite. Verified with
Playwright: SVG present, no remote <img>, zero external requests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
matrix.to cannot target this deployment (Cinny adapter hard-codes
app.cinny.in; web-instance[] is Element-only, allowlisted), so add a
"Copy Lotus Link" next to every Copy Link (message menu, space header
menu, sidebar space tab) producing https://<this origin>/home/<room>/<event>
?viaServers=… via plugins/lotus-permalink.ts. Lotus links in messages are
rewritten to their matrix.to form inside the HTML parser so they render as
room/event mentions and navigate in place.
/home/<room> for a joined room that belongs to a space or Direct now
redirects to its own route (was a preview card with a View button; also
the form matrix.to → Cinny links use). ?via= is accepted as an alias of
?viaServers= (what the matrix.to Cinny adapter emits). A deep link
visited while logged out is now honoured after an OIDC login: the OIDC
callback reloads at the app root, which discarded the stored path — the
index loader consumes it via the shared takeAfterLoginPath().
Verified end-to-end with Playwright on a local Synapse: logged-out cold
link → login → lands on the event under the space route; menu copies the
expected link; a pasted Lotus link renders as a mention and jumps in
place; both space menus copy the space link.
Closes#130
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
RoomTimeline renders a numeric index window into the live timeline's
event arrays; SDK back-pagination prepends, so any side panel calling
paginateEventTimeline(room.getLiveTimeline()) shifted the visible
messages into the past on the next render and broke at-bottom tracking.
New utils/detachedTimeline.ts builds a timeline set that mirrors the
already-loaded history and paginates independently: a room-registered
filtered set (server-side contains_url / types filter) when the filter
is usable, else a private EventTimelineSet seeded from the live timeline.
useRoomMediaTimeline wraps it for the gallery (live events + redactions
handled); RoomActivityLog uses a type filter (safe in encrypted rooms);
ExportRoomHistory pages a private set so a full export no longer parks
thousands of events in the live timeline.
Verified with Playwright against a local Synapse in a 400-message plain
room and a 200-message encrypted room: timeline stays at the bottom
through gallery pages, activity load-more and a full export; live
messages keep auto-scrolling; all media found in both rooms.
Also adds scripts/dev-homeserver.sh + scripts/dev-seed.py (local
throwaway Synapse for driving the real UI) and documents them.
Closes#163
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
LOTUS_TODO.md → LOTUS_REFERENCE.md (design laws, decided deferrals,
server-blocked features, operational reference only). Every open task was
filed: cinny #195–#210, cinny-desktop #15–#18, matrix #8–#10.
LOTUS_TESTING.md keeps the automated-coverage map, the Playwright notes and
the deploy tip; every manual checklist is now a `qa` issue under the
'Manual QA backlog' (cinny #170–#194, #198) and 'Desktop QA backlog'
(cinny-desktop #11–#14, #18) milestones.
Repointed the README, LOTUS_FEATURES, CI and source comments that
referenced LOTUS_TODO.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Same number as the tab title (leaf-room highlights), cleared at zero.
Skipped under Tauri where the native set_badge_count owns the badge, and
silently absent where the Badging API is not available.
Closes#154
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
PTT and deafen were DOM key handlers and only fired while Lotus (or the EC
iframe) had focus — alt-tab into a fullscreen game and the voice controls
stopped working.
- useCallHotkeys: while a call is joined and the new device-local
`globalCallHotkeys` setting is on, register {ptt, deafen} bindings with
the desktop (`set_global_hotkeys`, cleared on leave) and act on its
`lotus-global-hotkey` press/release events. Events are ignored while
`document.hasFocus()` so the DOM handlers keep owning the in-focus case
(editable-field and interactive-element checks, no double toggles). PTT
press engages the mic exactly like the DOM path (pttActive set before
unmute), release restores; the existing blur/focus release covers a hold
that spans a focus change. Same modifier rules as the DOM path
(`shouldActOnGlobalHotkey`, tested).
- Settings → Calls: "Hotkeys Work Outside the Window" toggle, Tauri only.
- settingsSync: `globalCallHotkeys` is device-local (never synced).
- LOTUS_FEATURES: desktop section entry.
Native side lands in cinny-desktop (src-tauri/src/native/hotkeys.rs).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The client never handled beforeinstallprompt and showed no install hint, so
phone users only got the PWA if they knew to dig through Share → Add to
Home Screen (iOS never prompts; Chromium's mini-infobar is easy to miss).
- utils/pwaInstall.ts (pure, 4 tests): show from the second visit, never in
Tauri or an installed PWA (display-mode standalone / navigator.standalone),
30-day snooze after a dismissal; kind = real prompt when the browser
handed us a deferred beforeinstallprompt, Share-sheet instructions on iOS
Safari, nothing elsewhere (Firefox desktop has no install path).
- hooks/usePwaInstallPrompt.ts: captures beforeinstallprompt/appinstalled,
counts one visit per browser session, waits 6s for the prompt event before
deciding, then enqueues a sticky toast; tap → prompt(), X → snooze.
- ToastNotif gains onDismiss (fired by the X button only) so the snooze is
recorded however the toast is closed. Mounted from ClientNonUIFeatures for
signed-in users only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Every Lotus setting was localStorage-only, so a user on web + desktop + phone
configured theme, composer toolbar, quiet hours, call keys… three times.
- utils/settingsSync.ts (pure, 7 tests): DEVICE_LOCAL_KEYS denylist (zoom,
media auto-load, animation pause, glassmorphism, denoise tier/model,
bitrates, volumes, notification permission, developer tools, PTT mode,
camera-on-join, drawer state, and the sync toggle itself), pickSyncable,
mergeRemoteSettings (unknown keys, device-local keys and wrong-shaped
values are skipped), buildSyncedContent, shouldApplyRemote (LWW on
updatedAt; equal stamp = our own echo).
- hooks/useSettingsSync.ts: on start applies a newer remote snapshot or
pushes local if it differs; debounced push on any settingsAtom write,
skipped when the syncable subset equals the last pushed/applied snapshot
so a remote apply never echoes back; AccountData listener for live
updates; stamps forced monotonic per device; per-account lastSyncedAt
marker so another user on the same device can't inherit it; failed pushes
roll the marker back so the next change retries. Remote values are re-read
through getSettings() so enum coercion applies.
- Settings → General → Sync: toggle (device-local), "Push now", "Clear
synced copy". AccountDataEvent.LotusSettings registered.
- ClientNonUIFeatures: the #103 tracking-param subscriber moves out of
PageZoomFeature into its own TrackingParamsFeature next to
SettingsSyncFeature.
- Docs: LOTUS_FEATURES entries for #103/#104; LOTUS_TODO links the new
Features 2026-Q4 milestone and #108.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Shared links routinely carry ad/analytics identifiers (utm_*, fbclid, gclid,
YouTube si=, Amazon ref=/tag=, X s=/t=, TikTok _r/_t, …) that tie every
recipient's click back to the person who shared the link. New
src/app/utils/urlTracking.ts is a pure, local stripper: a global list +
utm_/pk_/matomo_ prefixes, plus host-scoped rules so e.g. `si` is only
removed on youtube/spotify. matrix.to and non-http(s) schemes are never
rewritten; unparseable input is returned unchanged; Amazon's `th`/`psc`
variant selectors are deliberately kept. 13 unit tests.
Wired at three points, all behind a new Settings → Privacy toggle
(`stripTrackingParams`, default on):
- paste: plain-text pastes are cleaned and re-inserted through Slate's own
insertData so multi-line pastes still split into paragraphs;
- send: RoomInput submit + schedule paths and MessageEditor saves clean both
`body` and `formatted_body` (the HTML variant unescapes `&` around each
URL and re-escapes it so the markup is untouched);
- render: linkify `formatHref`/`format` and explicit `<a href>` in
formatted_body are cleaned, so links sent from other clients are safe to
click too. LINKIFY_OPTS is spread into memoised per-timeline objects, so
the toggle is a module flag kept current by ClientNonUIFeatures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The composer looked off in two ways, both confirmed by rendering CustomEditor
with RoomInput's exact props and measuring the buttons headlessly:
Desktop: the Lotus additions (location, poll, voice, schedule) used
`Icon size="100"` (18px) inside the same `IconButton size="300"` as the
upstream Aa/sticker/emoji/send buttons (24px icons), so one row mixed
32×32, 26×26 and a 28×19 "GIF" text stub. Every button is now 32×32: the
four small icons use the default icon size and the GIF label sits in a
1.5rem box, the same footprint as an icon. The mic's idle button in
VoiceMessageRecorder gets the same treatment since it lives in this row.
Phones: d6159997 let the before|editable|after row flex-wrap at <=750px, but
folds' Scroll (the editable's wrapper) is `width: 100%`, so the row ALWAYS
broke into three stacked lines — "+" alone on top, the input flush against
the left edge on its own line (the :first-child padding selectors no longer
matched), and emoji/draft/send left-aligned underneath. e1bb8301's "+"
overflow menu was meant to produce [ + | input | emoji | send ] but never
could while the row wrapped. The row no longer wraps (upstream behaviour);
instead the collapse into the "+" overflow is keyed on the viewport
(ScreenSize.Mobile) as well as the touch UA, so a phone-width window on a
desktop UA — iPad desktop mode, split-screen PWA, docked window — also
collapses instead of rendering ten controls inline and clipping Send behind
the editor's overflow:hidden. The "Draft saved" label moves into the overflow
row in compact mode so the inline row stays [ + | input | emoji | count |
send ]. The editable's vertical padding grows to 19px at phone width (only
when the row actually has buttons) so the text sits level with the 44px
touch targets instead of hugging the top of the row. Those touch targets now
also apply the shared MobileTouchTarget class, matching the recorder button.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Every io.lotus.call_state push allocated a new Set, re-rendering the
app-wide call bar for the whole call. nextSpeakerSet() returns the
previous reference when membership is unchanged (pure helpers in
utils/speakerSet.ts, unit-tested), and the DOM MutationObserver fallback
in useCallSpeakers/useRemoteAllMuted is attached only while the fork's
participant list is unavailable.
Fixes#32
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The "should we make noise" predicate used for message sounds is extracted
into useNotificationsQuiet() (unit-tested) and applied to the ringtone in
both the full-screen incoming-call overlay and the compact in-call banner.
The overlay/banner still show so the call can be answered; only the audio
is skipped. Join/media paths untouched.
Fixes#28
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
deleteSearchCacheDatabase() resolved after a 3 s "blocked" timeout while
another tab still held the DB, so decrypted rows could survive logout.
It now broadcasts lotus-logout first; every tab closes its handle and
refuses to reopen, then the delete proceeds. A boot with no session
re-runs the wipe once in case a race was still lost. Unit-tested.
Fixes#45
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The settings atom was a load-time snapshot with no storage listener and
wrote the whole blob, so two tabs silently reverted each other. It now
re-reads on storage events and writes only the keys that changed
relative to the previous value. Unit-tested.
Fixes#42
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Every text message subscribed to the whole translation cache array, so
one translation re-rendered the entire timeline. Messages now subscribe
to their own keyed entry, the cache key is memoised, the auto-translate
"already tried" guard is module-scoped (no re-detect on virtualised
remount), and both atom families are FIFO-capped and evicted on logout.
Unit-tested.
Fixes#39
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- ReadPositionsContext is provided once at Room level so the thread
panel (a sibling of RoomView) gets real positions instead of the empty
default; own thread messages no longer sit on "Sent" forever (#38).
- Receipt events only recompute the users they name, merged into the
previous map with reference equality preserved for untouched rows, so
a receipt no longer re-renders every message (#40). Unit-tested.
Fixes#38Fixes#40
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Drafts were written solely in the effect cleanup, so F5 in the open room
lost them and the draft indicator never showed for the current room.
Persist on a 500 ms debounce and on pagehide with the same {userId,
nodes} shape; an empty editor clears the stored draft. Also passes the
full content to the schedule modal (#36).
Fixes#37
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The schedule modal took only a plain body and rebuilt {body, msgtype},
dropping formatted_body, m.mentions and m.relates_to. It now receives
the full IContent; an unedited body is sent verbatim, an edited body
drops the now-stale formatted_body but keeps mentions and the reply/
thread relation. Reschedule from the tray preserves them too.
Unit-tested (mergeScheduledBody).
Fixes#36
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Brings the 4.12.7 fixes: "%" sign wrapping below the composer input and
emoji autocompletion overwriting the preceding element. The release's
security half (sanitize-html, react-router-dom) was already applied in
91def3ad (we are on newer versions than upstream ships).
Resolved: kept our package.json/lockfile (the fork is ahead on every
dependency — React 19, router 7, i18next 26…), version 4.12.7-lotus, our
README/CONTRIBUTING, our "Lotus Chat vN" branding via pkg.version, and
the deleted GitHub-only workflows stay deleted. Note upstream announces
it is replacing matrix-js-sdk with its own SDK and pausing PRs; future
syncs will get harder.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
* chore: Update actionable issues note in CONTRIBUTING.md
Clarify that only issues with numbers greater than #2876 are actionable.
* Update pull request template for clarity
Simplified the pull request template by removing unnecessary details.
The status was component state in the settings tab, so closing Settings
threw away "update available" and forced another check. Move it to a
module-level atom shared by the settings panel and the update toast.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
81a6d9c9 wired getRtcTransports() but its .well-known fallback read
mx.getClientWellKnown(), which the SDK only populates when the client
is started with clientWellKnownPollPeriod — cinny never sets it, so on
the live client the fallback returned [] and calls still failed with
MISSING_MATRIX_RTC_TRANSPORT after deploy. (The unit test faked a
populated cache and hid this.)
The driver now fetches the client .well-known itself, once, via
AutoDiscovery.getRawClientConfig(domain) — the same resolver Element
Call used before 0.22 — and only when the SDK has nothing cached. Test
added for the uncached path (asserts a single fetch); verified the
resolver returns the livekit focus for matrix.lotusguild.org.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Since the upstream v0.25.0 merge, Element Call in widget mode no longer
reads .well-known for its LiveKit transport; it asks the HOST via the
widget API (org.matrix.msc4515.get_rtc_transports, capability
org.matrix.msc4515.rtc_transports). cinny never granted the capability
nor implemented WidgetDriver.getRtcTransports(), so discovery returned
nothing and every join failed with "Call is not supported"
(MISSING_MATRIX_RTC_TRANSPORT) — the [LocalMembership] Multiple
Transport Errors line in the browser console.
- matrix-widget-api 1.17.0 -> 1.18.0 (adds MSC4515; also changes the
sendDelayedEvent driver signature, adapted — parent delay ids were
removed from the draft).
- Grant MSC4515RtcTransports in getCallCapabilities.
- CallWidgetDriver.getRtcTransports(): homeserver /rtc/transports
(MSC4143) first, then the .well-known org.matrix.msc4143.rtc_foci list
(what matrix.lotusguild.org advertises today). Unit-tested.
Server side needs no change: the well-known already carries the livekit
focus and the JWT service answers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- gitleaks flagged `STORAGE_KEY = 'cinny_recent_gifs_v1'` (a localStorage
key name) as a generic API key. Allowlist namespaced cinny_/lotus- key
names on *_KEY assignment lines, and skip dist/, node_modules/ and the
vendored Element Call bundle (minified matrix-js-sdk crypto identifiers
trip the same rule locally after a build). Verified: 0 findings on this
tree with and without dist/ present.
- The runner has no `docker` binary and Gitea ignores job-level
continue-on-error for the run conclusion, so the docker job painted the
run red. It now runs only when the Actions variable CI_HAS_DOCKER is
"true"; the weekly renovate job likewise only when RENOVATE_ENABLED is
"true" (set alongside the RENOVATE_TOKEN secret).
- The Playwright e2e job passed on the runner, so it's a hard gate now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Second CI-published fork release: focus works in grid/1:1 and clears on
leave, decoration ring sized/hidden correctly and CDN-pinned, denoise
prefetch + fallback + suspend-while-muted, screenshare cap as a real
budget, soundboard refuses while muted, shared clip AudioContext,
call_state at ≤2/s, transparent-theme contrast guard, plus the
request_state / denoise_state fromWidget actions the host now handles.
Verified: bundle in dist/public/element-call carries all eight
io.lotus.* actions; boot check passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- io.lotus.request_state: when the fork's lotus handlers (re)register
(an EC-side remount that doesn't unmount us) we re-send deafen, quality
and the focus pin, and the decoration pusher re-pushes its roster —
decorations and the pin no longer vanish for the rest of the call
(element-call#17).
- focus_participant carries the per-device media id from call_state
(speaking device preferred) so a multi-device user pins the right
device (element-call#30).
- injectAudio returns the fork's reply; when it refuses with
reason:"muted" the soundboard shows "Unmute your microphone…" instead
of playing the clip locally as if it went out (element-call#13).
All backwards compatible with the 0.25.0-lotus.1 bundle (unknown action
is acked; missing reply fields default to "played").
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Boot tier (runs against vite preview of dist/): login screen renders with
no page or console errors, sw.js is served and registers, the bundled
Element Call mounts in a frame with no failed asset requests. E2EE tier
(skipped without E2E_HOMESERVER/E2E_USER/E2E_PASSWORD): password login,
create an encrypted room, send text, attach a compressed JPEG, and assert
at the network level that every send is m.room.encrypted with no
plaintext body/url/file — the regression test #6/#7/#11 lacked.
Secrets and local usage documented in LOTUS_TESTING.md.
Fixes#90
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- "Verify lockfile is in sync" (git diff --exit-code package-lock.json
after npm ci) replaces the deleted GitHub lockfile workflow (#99).
- gitleaks 8.30.1 binary scan on push + PR with a small allowlist for
the public homeserver/registry URLs (#95).
- docker job builds the image, runs it and asserts 200 + the security
headers; continue-on-error until the runner is confirmed to have a
Docker daemon (#93). .dockerignore keeps the context small.
- docker-nginx.conf now sends a CSP (frame-src allowlist matching
videoEmbed.ts), frame-ancestors 'none', Referrer-Policy and nosniff —
shipped config, verify against the live chat.lotusguild.org headers
before adopting in prod nginx (#95, #44 shipped-config half).
- renovate.json + weekly renovate workflow for cinny and element-call;
needs a RENOVATE_TOKEN secret (names starting GITEA_ are reserved) and
stays continue-on-error until it exists (#94).
- e2e job appended for the Playwright smoke test (#90), continue-on-error
until green on the runner.
Fixes#93Fixes#94Fixes#95
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The language detector switched the few upstream-translated strings to
the browser locale while every Lotus surface stayed English, producing a
mixed UI. supportedLngs is now ['en']; the detector, backend and locale
files stay so re-enabling is one line plus routing Lotus strings through
useTranslation.
Fixes#53
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Home's categorisation memo keyed only on the room list, and nothing
observed m.tag changes. The SDK emits RoomEvent.Tags on the room and
re-emits it on the client (room.js addTags, sync.js reEmit); a small
client-level hook bumps a version that the memo depends on.
Fixes#20
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The redirect back with ?error=invalid_client is the only place a stale
dynamic client id is ever rejected; the callback now resolves the issuer
from the SDK's stored mx_oidc_<state> entry and invalidates the cache so
the next attempt re-registers. Degrades to a no-op if the state entry is
gone. Unit-tested.
Fixes#102
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- syncDecorations.mjs tracks which slugs its regex actually removed and
exits 1 without writing if that set doesn't match the missing assets,
instead of silently no-op'ing on a reformatted catalog.
- patch-folds.mjs matches a 4-line context block, reports the installed
folds version and expected snippet when the target is missing, and
distinguishes "already patched" (exit 0) from "pattern not found"
(exit 1). Verified against all three states.
Fixes#88Fixes#55
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- engines.node >=20.0.0 and .nvmrc mirroring .node-version (#54)
- npm audit --audit-level=high is a hard gate (tree is at 0) (#91)
- scripts/boot-check.mjs serves dist/ with vite preview and asserts /,
config.json, the entry chunk and the Element Call bundle all load (#92)
- scripts/check-bundle-size.mjs enforces gzip budgets from
scripts/bundle-budget.json (seeded +10%); fails PRs, warns on push (#96)
- check:eslint runs with --max-warnings 68 so the count can only go down;
7 unused eslint-disable directives removed to get there (#97)
Fixes#54Fixes#91Fixes#92Fixes#96Fixes#97
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
m.replace events no longer appear as their own "* text" line — the
original row gets m.new_content and an (edited) marker. Media rows carry
a [Image]/[Video]/[Audio]/[File] prefix and the JSON export includes
mediaUrl; the UI notes attachments aren't included. Unit-tested.
Fixes#50Fixes#81
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- seasonalThemeOverride is coerced to 'auto' when the persisted value is
outside the union, like every other enum setting (tested).
- PageZoomInput commits on blur as well as Enter.
- New gifPickerEnabled setting (default off) with a disclosure that GIF
searches go to Giphy; the picker doesn't mount and the composer button
is hidden while off.
Fixes#74Fixes#75Fixes#68
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
m.location mounted an openstreetmap.org iframe on render, acting as a
read beacon; it's now behind a "Load map" button. CollapsibleBody uses
useReducedMotion() instead of a one-time matchMedia snapshot.
Fixes#66Fixes#85
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
maxSize 10, maxExpand 100, trust false, strict ignore, and a 5000-char
cap (rendered as plain text beyond it), so a remote sender can't blow out
layout or CPU with \rule{99999em} or deep macro expansion.
Fixes#65
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
buildForwardContent copied content.file (AES key/iv/hashes) verbatim, so
forwarding from an E2EE room into an unencrypted one published the key.
For unencrypted destinations the attachment is now downloaded, decrypted
and re-uploaded as plaintext (url instead of file, thumbnail key
stripped); if that fails the forward is refused rather than leaking.
Encrypted destinations unchanged. Needs a manual check on a live
encrypted -> plaintext forward.
Fixes#63
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
RoomMemberEvent.Membership is emitted on RoomMember/MatrixClient, never
on Room, so the (room as any).on() handler was dead. Subscribe on the
client filtered by roomId; drop the cast.
Fixes#61
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Decrypted clip blob: URLs lived in an unbounded module Map for the page
lifetime and survived logout. Add clearSoundboardClipCache() (called from
both logout paths next to clearPlaintextCaches) and a 64-entry LRU that
revokes on evict. Unit-tested.
Fixes#57
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
clearFocusParticipant() had no callers, so a spotlight pin was permanent.
CallControl now tracks focusedUserId, the member menu toggles, and
dispose() clears the pin. Removes _pipMode/setPipMode (never read).
Fixes#56Fixes#59
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
npm audit --omit=dev went from 11 advisories (6 high) to 0:
- pdfjs-dist 5.7.284 -> 6.3.289 (GHSA-hq66-cqwq-w95j, arbitrary JS on
opening a malicious PDF; the client has an inline PDF viewer). Only API
change for us: getDocument() no longer accepts a bare string.
- react-router-dom 7.15.1 -> 7.18.3 (open redirect, DoS, RSC advisories)
- sanitize-html 2.17.4 -> 2.17.7 (javascript: URI bypasses; not
exploitable in our config per the audit, bumped anyway)
- katex 0.16.11 -> 0.16.47 (\htmlData attribute validation)
- uuid under @giphy/js-util overridden to >= 11.1.1 (giphy imports v4,
which is unchanged)
- npm audit fix for transitive brace-expansion / js-yaml / nanoid / postcss
Verified: tsc clean, 1000 tests pass, production build succeeds and
dist/pdf.worker.min.js is still copied from the new package.
Fixes#24
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The guard double-counted staged uploads and read a stale count for every
file in a batch, so a 60-file drop bypassed the cap while a nearly-full
pack refused early. Partition the batch with a running count.
Fixes#31
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
channelFull was computed only in the prescreen; a second click on the
channel in the room nav joined a full channel. Extract
useVoiceChannelFull, use it in both places, and refuse with a
"Channel full (N/N)" toast.
Fixes#30
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The persistent call-status bar exposed Video and ScreenShare with no
io.lotus.room_quality check and no share confirmation, bypassing the
in-room bar's gating. Add useRoomCallPolicy and apply the same hiding
plus a "Share your screen?" confirm.
Fixes#26
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Pronouns, timezone and avatar decoration applied the mount-time fetch
result unconditionally, overwriting a value the user had already edited;
the decoration panel also showed "None" on any fetch failure and let the
user save over a real decoration. Add cancelled/dirty guards (mirroring
ProfileStatus) and an explicit load-error state with Retry.
Fixes#46Fixes#47
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
matrix-widget-api 1.17.0's WidgetParser rejects every URL (it compares
URL.protocol "https:" to "https"), so the widgets panel was always empty.
Build Widget objects from the raw state events with a correct scheme
check plus the existing origin check. Unit-tested against a real state
event.
Fixes#15
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
A transient network/discovery failure invalidated the cached dynamic
client and registered a fresh one on every retry. Invalidate only on
invalid_client / unauthorized_client or a 400/401 from the provider.
Unit-tested.
Fixes#67
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
useSessionSync reloaded on any out-of-tab session change, so a routine
refresh in one tab hard-reloaded the others mid-call. Classify the
change: removed → reload, user/device changed → reload, same device with
a new token → swap it into the running client (setAccessToken + the
shared refresh token) in place. The refresher takes a Web Lock and adopts
tokens another tab already rotated instead of racing the issuer.
Unit-tested classifier.
Fixes#16
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Nothing ever removed an indexed row: redacted messages stayed searchable
with full plaintext and rendered as normal results. Now: a client-level
RoomEvent.Redaction listener deletes the row, leave/ban clears the room
(clearRoom finally has a caller), m.replace edits upsert the original
row instead of indexing the "* fallback" separately, and cached rows
whose local event is redacted render through the existing
redacted_because placeholder. Unit-tested.
Fixes#14
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
For encrypted rooms, bookmarks persist only {roomId, eventId, savedAt}
and reminders only their non-text fields; the preview, room name and
sender resolve locally at render/fire time from the timeline (with a
"Message unavailable" fallback). A one-time, loop-guarded cleanup strips
text from existing entries in currently-encrypted rooms. Unencrypted
rooms are unchanged. Unit-tested.
Fixes#10
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
They were wired inside CallControls, which only renders while the call
room is selected, so navigating away (PiP) or opening the in-call chat on
mobile silently disabled all three — AFK auto-mute exactly when it
mattered. Move them into useCallHotkeys + useAfkAutoMute mounted from the
embed-lifetime CallUtils, gated on joined; CallControls keeps only the
PTT chip (pttActiveAtom).
Also: window blur/focus release the mic only while a PTT key is actually
held, so a deliberate hands-free unmute survives a click into the iframe
(#27); iframe-side listeners re-bind on the iframe load event so they
survive an EC reload (#60). The #23 modifier/interactive guards are
preserved and unit-tested.
Fixes#9Fixes#27Fixes#60
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
First CI-published fork release: upstream v0.25.0 base plus the audit
fixes (deafen via global output mute, denoise dead-mic fallback, single
denoise processor, strict widget origin check, quality-cap clearing,
call_state standalone guard) — LotusGuild/element-call #1#2#10#11#15#31. Verified locally: the bundle lands in dist/public/element-call/
with all six io.lotus.* actions present.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The MSC4133 io.lotus.avatar_decoration value was interpolated into the
CDN URL verbatim, letting a room member steer the path/query of a request
every viewer's browser makes. Accept only slugs present in the catalog;
anything else is treated as no decoration. Unit-tested.
Fixes#64
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
from_ts/to_ts are not Matrix filter fields; the server dropped them, so
the range only worked for the local encrypted-room search. Stop sending
them and post-filter server results by origin_server_ts with the same
inclusive predicate. Unit-tested; docs corrected.
Fixes#13
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The store-sync effect re-applied the stored note on every notification,
including the echo of the note's own save, reverting text typed after
the debounce fired; and closing the panel inside the 800ms debounce
dropped the pending save. Add a dirty ref that suppresses the resync
while there are unsaved edits, and flush the pending save on unmount.
Fixes#18
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Local room names did a read-modify-write of io.lotus.room_names against
the SDK's local cache, which is stale until the /sync echo, so a second
rename issued before the first echoed overwrote it. Route through
createAccountDataListStore like user notes. Unit-tested with a client
whose setAccountData does not update the local store.
Fixes#17
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- useKeyBind refuses Tab/Enter/arrows/Home/End/Page*/Escape and bare
modifier codes, and refuses a code equal to the other call key, with an
inline message (isBindableCallKey, unit-tested).
- PTT and deafen handlers ignore events with Ctrl/Alt/Meta held (deafen
also Shift), so Cmd+M / Ctrl+M no longer toggle deafen.
- PTT only preventDefault()s when the target is not an interactive
control, so Space still activates focused buttons during a call.
Fixes#23
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
draft-msg-<roomId> was unscoped and deliberately skipped on logout, then
hydrated into whoever logged in next. Wipe drafts in clearPlaintextCaches,
and only hydrate a draft whose stored userId matches the current user.
Drafts written before this change carry no userId and are dropped on
first load (a one-time loss of unsent drafts, accepted for the leak fix).
Fixes#41
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
PollCreator used the legacy 3-arg sendEvent (threadId null). Thread the
composer's threadRootId through and send like the sticker path.
Also (#41, same file): composer drafts are persisted as { userId, nodes }
and the restore path drops any draft with a different or missing userId.
Fixes#35
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
ThreadPanel only honoured the separate Private Read Receipts switch, so
the privacy switch still emitted public m.read for every opened thread.
Match markAsRead: hideActivity || privateReadReceipts.
Fixes#34
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Non-thread relations targeting a thread root live only in the room's main
timeline set (Room.eventShouldLiveIn), so the panel never saw root edits
or reactions and re-reacting duplicated instead of toggling. Use the room
set for the root event, the thread set for replies.
Fixes#12
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Records the toolchain decisions taken with the 2026-09 upstream sync of
LotusGuild/element-call (accept upstream: pnpm 11 + Node >= 22.13,
oxlint/oxfmt replacing eslint/prettier, matrix-js-sdk develop pin), the
tag-driven publish flow, and the pending 0.25.0-lotus.1 bundle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
unmuteRoom unconditionally reset the room to Unset when a timed mute expired
(in-session timer and boot-time restore alike), silently reverting a mode the
user had changed by hand during the window. Mute-timer helpers move to
muteTimers.ts; unmuteRoom now reads the live push-rule mode and only resets
when it is still Mute, always dropping the persisted timer. Unit-tested.
Fixes#21
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- Image compression in an encrypted room re-encoded the *plaintext* original,
uploaded it unencrypted, and reused the original's encInfo, so the media sat
on the server in the clear AND the attachment was undecryptable. The
compressed bytes are now run through encryptFile and the synthetic upload
item carries the new encInfo (buildCompressedUploadItem, unit-tested; it can
never inherit the stale encInfo).
- Scheduled messages (MSC4140) are PUT as raw m.room.message, bypassing the
SDK encryption pipeline. The Schedule button is now hidden in encrypted
rooms, handleScheduleClick no-ops there, and scheduleMessage() itself
refuses with a clear error so no caller can regress this. README notes the
limitation.
- The GIF picker uploaded the Giphy blob unencrypted into E2EE rooms; it now
mirrors the voice/attachment path (encryptFile -> upload ciphertext ->
content.file).
Fixes#6Fixes#7Fixes#11
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
RuleModeSwitcher rebuilt a rule's actions from scratch, so changing the
mode of e.g. .m.rule.is_user_mention silently dropped its highlight tweak
account-wide. Derive the existing highlight/custom-sound tweaks from the
rule's current actions and pass them through as NotificationModeOptions.
Adds getNotificationModeOptionsFromActions + unit tests.
Fixes#19
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
handleAnswer hard-coded { microphone: true, video, sound: true }, so
pressing Answer on a video call published the camera even with the
"Join with Camera On" setting off (the default) and ignored a persisted
muted/deafened preference. Build the answer prefs the same way every other
join path does; PTT's forceAudioOff still applies downstream.
Fixes#8
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
`enabled` means sound is on; the tooltip said "Deafen" but the aria-label
said "Undeafen". Align the label with the tooltip and add aria-pressed to
match the call-status bar's equivalent button.
Fixes#33
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Arcade:
- perspective grid is masked through the central timeline column so it
frames the chat instead of striping messages; lower opacity, thinner
non-scaling strokes (also fixes the grid vanishing in the 76px swatch)
- neon horizon line is broken through the middle so it never underlines
a message; softer sun glow
- "1UP" score + "INSERT COIN" moved from top-left/bottom-centre (space
bar / composer) to a single bottom-right HUD stack; font-size clamps
to 0 via cqw inside the settings swatch so no more clipped glyphs
- scanlines: 4px pitch at lower opacity (light theme was fully striped);
ambient wash slightly lighter
Deep Space:
- starfield 28 → 62 stars across the two parallax layers
- base violet wash slightly lighter for light themes
SeasonalPreview wrapper is now a size container (container-type) so
overlays can scale/hide fixed-px details with cqw. LOTUS_FEATURES.md
theme table now describes what both overlays actually render.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Replaces the single generic releases-page link with direct downloads
for Windows (.exe), Linux (AppImage/.deb/.pkg.tar.zst), plus a note on
the webkit2gtk/GStreamer WebRTC dependency needed for calls to work.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
RoomNavItem.tsx already statically imported getRoomNotificationModeIcon
and RoomNotificationMode from the same module, so the two
`await import('../../hooks/useRoomsNotificationPreferences')` calls
(in unmuteRoom and handleMuteFor) never achieved real code-splitting —
verified by building and grepping dist/assets: setRoomNotificationPreference
landed in the same eager entry chunk regardless, since Rolldown can't
split a module already reachable via a static import elsewhere. Just
import it statically alongside its siblings instead. No behavior
change — confirmed via 3 independent investigations before starting
and 3 independent reviews of this diff before committing.
ClosesLotusGuild/cinny#5
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Swaps #homelabbing/#proxmox for their parent #homelab:codestorm.net
space, so browsing it surfaces the whole space rather than two
hand-picked children.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Populates the previously-empty featuredCommunities block so the Explore
tab's Featured page shows the Lotus Guild Space and a few community
favorites by default, and adds matrixrooms.info as a browsable server
in the Explore sidebar (it speaks enough of the Matrix federation API
to serve as an aggregated public-room search across the network).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix getPrevWorldRange to exclude empty text children
this prevents the range from encroaching on the node of elements like emojis and pings to prevent them from being overwritten
* fix typo of "word" as "world"
* trigger pr checks
The act_runner cache server is now reachable from job containers: jobs were
landing on isolated per-job docker networks and couldn't reach the runner's
cache server on docker0 (getCacheEntry ETIMEDOUT, ~5 min wasted/build). Fixed
runner-side by putting the runner + all job containers on a shared dedicated
network (`act-cache-net`, runner at 172.30.0.2) and pointing cache.host at it —
verified a container on that network reaches the cache port.
Restores `cache: npm` on Setup Node and the actions/cache node_modules step
(restore + save-on-miss-and-success). Reverts 10270b75 now that the underlying
network issue is resolved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The act_runner's internal cache server (172.17.0.2:46367) can't be reached
from job containers: `setup-node` with `cache: npm` spends ~4m42s on
`getCacheEntry failed: connect ETIMEDOUT` every build, then reports "npm cache
is not found" — ~5 min of pure cost for zero caching. The `actions/cache`
node_modules steps added in 79258668 would hit the same dead server and hang
too, so they're removed here as well.
Removing the cache usage reclaims ~5 min/build with no loss (nothing was being
cached). The fast-gates-before-build reorder is kept. Re-enable caching once
the runner's cache server is reachable from job containers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI (.gitea/workflows/ci.yml):
- Cache node_modules keyed on package-lock + .node-version (actions/cache
restore/save). An unchanged lockfile now skips `npm ci` (extraction +
postinstall folds patch) and just restores the tree. Save runs only on a
cache miss and only when install succeeded (`success()`), so a failed
`npm ci` can't poison the cache. setup-node's existing `cache: npm` still
warms the download cache on the miss path.
- Run prettier/eslint/typecheck/tests BEFORE the ~minutes-long build so a
format/lint/type/test error fails in seconds instead of after the build.
DX (.husky/pre-commit):
- Enable the pre-commit hook (`npx lint-staged`). husky + lint-staged were
already installed with a config (eslint + `prettier --write` on staged
files), just commented out — so formatting kept reaching CI. It's now
auto-applied on commit. (typecheck left out of the hook — too slow per commit.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The forked-in Cinny issue setup funneled reporters to the upstream project:
- config.yml disabled blank issues and linked to cinnyapp/cinny GitHub
Discussions
- preapproved.md was Cinny's "do not open an issue, use Discussions"
template (Gitea has no Discussions anyway)
Replace it with a Lotus setup: enable blank issues, drop the upstream
contact link, and add Bug Report + Feature Request templates. Now
/issues/new/choose offers Lotus templates and files against this repo.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Swap the logo/favicon URL constants from the inline
`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/res/...` form to the
repo's existing `withOriginBaseUrl(getOriginBaseUrl(), '/public/res/...')`
helper (already used here for the OIDC callback URL). Functionally equivalent —
same /public/res/ target, resolves in dev and the static-copied prod build — and
it keeps the logo URL absolute and consistent with clientUri for the OIDC
logoUri. No build-config change (publicDir stays false).
Co-authored-by: Nathan Vititoe <nathanvititoe@gmail.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Importing images from public/ (`import X from '../../public/res/*.png'`) is
fragile under Vite with `publicDir: false` and can white-screen the dev app —
Vite tries to resolve the public/ path as a module. Switch the five logo/
favicon call sites to the repo's existing BASE_URL URL pattern
(`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/res/...`), matching how
config.json, locales, and the Element Call widget are referenced. Resolves
identically in dev and the static-copied prod build (public/res -> dist/public/res).
Sounds under public/sound/ stay ESM-imported: that folder is not copied to
dist/ by vite-plugin-static-copy, so a URL reference would 404.
Also add a Local Development section to the README (no-backend model, npm ci /
npm start on :8080, which homeserver to log in against, OIDC-on-localhost note).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A room could show a permanent unread that survives every cold start even
though the server considers it fully read (notification_count 0, unthreaded
read receipt at the tail). matrix-js-sdk's fixNotificationCountOnDecryption
only ever INCREMENTS an encrypted room's Total, and addReceipt's auto-clear
fires only when the tail event is the user's own — so a count inflated in an
earlier state (before a receipt covered the tail, e.g. by a since-corrupted
undecryptable event) is never decremented and keeps a genuinely-read room lit.
This is aggravated by mixing threaded-receipt clients (Element X) with
unthreaded ones (Lotus/Cinny), which split the read marker.
Add readReceiptCoversTail(room, userId): walking the live timeline newest→
oldest, if we reach the user's read-receipt event without crossing any
notification-worthy event, the room is genuinely read and a lingering Total is
suppressed to {0,0} in getUnreadInfo / getUnreadInfos. Safe by construction —
a real unread sits AFTER the receipt and stops the walk at isNotificationEvent
— and guarded against unread threads (markAsRead clears threads unconditionally)
and off-window receipts (can't confirm → don't suppress). Self-correcting: a
new message becomes the tail and the walk stops suppressing.
Also recognize polls (m.poll.start / msc3381) as notification events so a
poll-only unread is never walked past (closes a pre-existing gap in the
tail scans), and factor the unread-thread guard into roomHasUnreadThread.
Reviewed by 3 agents (false-suppression safety, unread-system regression,
SDK behavior): no real unread is hidden for any standard content, no
regression to the atom/PUT-DELETE paths, and the fix produces {0,0} for the
target scenario and stays resolved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A completed in-room device-verification request is a plain m.room.message
(msgtype m.key.verification.request) that matches the default DM push rule
with no recency gate, so the server/SDK notification count stays > 0 and the
DM re-lights as unread on every fresh sync until the room is opened twice.
Two-part fix:
- Display suppression: getUnreadInfo/getUnreadInfos return {0,0} for a room
whose ENTIRE unread span (tail -> read receipt) is verification-flow events,
via new pure helpers isVerificationFlowEvent + unreadIsOnlyVerification.
Conservative: never suppresses when the read marker is off-window, the tail
is still encrypted, or a highlight is present.
- Durable auto-read: useAutoMarkVerificationRead sends a read receipt covering
the request (the only SDK-durable lever), once per room per session, gated on
the same verification-only predicate so it can never ack a real message.
unreadIsOnlyVerification also rejects any room with an unread thread, because
markAsRead clears every thread unconditionally — otherwise a verification-only
main timeline with a genuine unread thread reply would be hidden/auto-acked.
Reviewed by 5 agents; the thread-scope guard closes the one bug they found.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Picking an autocomplete item (mention/emoji/command — all inline voids)
occasionally tripped the composer error boundary, forcing a page refresh, even
though the element had already inserted. Root cause (traced through slate-react):
moveCursor deferred its cursor work to setTimeout(0), leaving the caret on the
just-inserted void's zero-width edge whose DOM (a U+FEFF node) isn't populated on
that tick. slate-react's commit-phase selection sync then calls
setBaseAndExtent(voidEdge, 1) and throws IndexSizeError mid-render → boundary.
Prevention: do the cursor work SYNCHRONOUSLY, in the same commit as the insert —
Transforms.move (escapes the void into the real trailing text node) then
insertText(' '). The caret is then always a resolvable text point when the
selection sync runs. (moveCursor's focus stays deferred+guarded, unchanged.)
Recovery (belt-and-suspenders): the composer error boundary is now recoverable —
a "Reload composer" button (resetErrorBoundary) + onReset Transforms.deselect
clears a transient bad selection so it remounts with the draft intact, no page
refresh. + role="alert" for screen readers.
Three review agents: two root-caused the exact slate-react throw and proved the
try/catch-only version merely recovered; a third reproduced the transforms
headlessly and caught that a first "sync insertText WITHOUT move" attempt hit
Slate's void guard (space dropped, caret trapped) — the move is required to
escape the void. Not unit-testable (needs the live DOM + the timing race).
Gate-green (tsc, eslint, prettier, 925 tests, build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root-caused the desktop notification-click-doesn't-navigate bug (SW shadowed the
Notification shim); web fix shipped. Documented the two desktop-Rust follow-ups
it activates (lost tag-coalescing, thread/invite quick-reply misroute) + a
Windows QA checklist.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On the Windows/Tauri build, clicking a message notification opened the app but
didn't navigate to the message. showOsNotification preferred the service worker
(registration.showNotification) and returned early; WebView2 has a service
worker, so the SW-owned toast always won and its click (SW notificationclick →
client.focus + postMessage → navigate) focused the app but the navigate didn't
complete in WebView2.
The desktop build injects a window.Notification shim that routes tagged message
toasts to the native rich WinRT toast, whose click emits lotus-notification-
activate with the path → useTauriToastActions → navigate. But the SW path
shadowed `new Notification()`, so that shim (and show_rich_toast) never ran on
desktop. Skipping the SW path under Tauri lets the shim take over and navigate.
Web browsers are unchanged (isDesktopApp() is false → SW path as before). Two
review agents verified the diagnosis + no web regression across both repos.
DESKTOP-QA REQUIRED — this activates a previously-dead code path. Known desktop
follow-ups it exposes (documented in LOTUS_TODO, both in cinny-desktop Rust):
- tag-coalescing is lost (rapid same-room messages stack toasts instead of
collapsing) — show_rich_toast doesn't dedupe by room.
- thread/invite quick-reply misroutes: the reply target is the coalescing tag
(roomId:threadId / 'lotus-invites'), not a real room id → sendMessage fails.
Navigation itself (body click) is correct for all cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clearing the quiet-hours start or end time silently deactivated the window
(isWithinTimeWindow → parseHHMM('') is null → returns false) while the toggle
still read "on", with no indication. Added an inline Critical-colored hint —
"Set both a start and end time — quiet hours stay inactive until both are filled
in" — shown when the toggle is on but either field is empty. Non-destructive:
it explains why rather than guessing a default time. Copy verified against
isWithinTimeWindow.
Last pure-client bug-hunt finding from LOTUS_TODO (the rest are live-call /
desktop-gated). Gate-green (tsc, eslint, prettier, build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- MLocation "Open in OpenStreetMap" permalink built its URL from the raw
parseGeoUri strings (location.latitude/longitude) while the embedded map iframe
used the parseFloat + isFinite-validated lat/lon. Use lat/lon in the permalink
too, so a malformed geo: substring can't reach the URL (they're already proven
finite a few lines above and used identically in mapSrc).
- LOTUS_FEATURES claimed the Policy List Viewer has "Subscribe (join) /
unsubscribe (leave) controls for each list" and lists subscribed lists.
Verified against PolicyListViewer.tsx: it's a room-ID/alias input viewer that
displays a joined policy room's rules read-only — no subscribe controls, no
subscribed-lists listing. Corrected the doc to match.
Two low-tail bug-hunt findings from LOTUS_TODO. Gate-green (tsc, eslint,
prettier, build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In picture-in-picture with an active screenshare, spotlight is auto-enabled so
the share fills the pip window (tracked via pipAutoSpotlightRef). The release
branch sat behind `if (!pipMode) return`, so navigating BACK to the call room
(pipMode → false) early-returned and never released it — the spotlight stayed
stuck on with the ref latched true.
The effect now guards only on `!callEmbed`, computes wantSpotlight = pipMode &&
pipScreenshare, and releases whenever that's false (screenshare ends OR pip
ends). The ref still gates release so we only ever undo a spotlight we enabled,
never the user's. Two reviewer-prescribed hardenings folded in: reset the ref
when callEmbed is torn down (kills a stale cross-call latch), and a comment that
control.spotlight is deliberately not a dep (re-adding it would fight the user).
Bug-hunt finding from LOTUS_TODO. Two review agents verified against
CallControl.ts (ref-gating, deps, idempotency, cross-embed self-heal); [live] —
the code fix is unambiguous but confirming screenshare→pip→back wants a real
call. Gate-green (tsc, eslint, prettier, 925 tests, build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Exporting a date range from an ENCRYPTED room over-paginated and mislabeled
"truncated": oldestRawTs (the how-far-back-have-we-paged boundary) was updated
only after the RoomMessage + decryption-failure filters, so undecryptable or
non-message old events never advanced it, the fromTs break never fired, and the
loop ran to MAX_EXPORT_PAGES. getTs() is unencrypted envelope metadata, so the
boundary update now runs for every event, above the filters.
Guarded with `ts > 0` so a bogus 0/negative origin_server_ts can't collapse the
boundary and cause the opposite failure — a silent early break / under-paginated
export (per review, silent omission in an export is worse than the loud
over-pagination this fixes). oldestTs (oldest collected in-range message) is
unchanged.
Two review agents (both confirmed getTs is decryption-independent, no
intra-page collection regression, oldestRawTs feeds only the fromTs break, no
plaintext regression); the second surfaced the 0-ts under-pagination edge, hence
the guard. Not unit-testable (embedded component + needs an E2EE room with
undecryptable history). Gate-green (tsc, eslint, prettier, 925 tests, build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The on-device search index grew unbounded over a long session. putRows now
prunes each touched room to MAX_ROWS_PER_ROOM (5000) — deleting the oldest rows
by [roomId, ts] via a self-chaining IDB cursor within the same write tx (never
awaits a non-IDB promise mid-tx, so the transaction can't auto-commit and
truncate the prune). Exposed a pure, unit-tested evictCount() for the decision;
the cursor path itself is browser-only (node --test has no IndexedDB).
Deliberate tradeoff (documented in code): the coverage window keeps claiming the
evicted tail so the search doesn't re-fetch → re-evict it forever. Net effect —
in a room past 5000 cached rows, an evicted old message is silently unsearchable
rather than churning. Clear cached index / logout still wipe everything.
Two review agents verified the IndexedDB-spec correctness (cursor delete+continue
semantics, put-then-count ordering, roomRange bracketing with no prefix bleed,
tx liveness, abort→cache-miss) since CI can't. Gate-green (tsc, eslint, prettier,
925 tests, build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Maps manual test items to the unit tests that now pin their LOGIC, so a human
tester can trust the deterministic parts and spend manual time on the
genuinely-human surface (visual rendering, live calls, desktop build, E2EE,
cross-device sync). Each row splits "logic pinned by a unit test" from "what
still needs you".
Every row verified against the real test assertions, then independently
audited by an agent for overclaims — the important failure mode being a tester
skipping manual QA of something not actually tested. Audit-driven corrections:
- O4 search cache: the IndexedDB round-trip test is skip'd under `npm test`
(node has no IndexedDB), so only the pure merge/coverage helpers run in CI —
said so explicitly rather than implying the round-trip is CI-covered.
- F2: relabeled — seasonSchedule.test.ts pins seasonal-theme *resolution*, NOT
F2's background↔seasonal mutual exclusion (which is untested); flagged so no
one skips the real F2 behavior.
- O5 + Q1/Q2: widened to reflect coverage that was understated.
No dangerous overclaim survived; the visual/live/device/E2EE carve-outs hold
for every row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- CallSoundboard: the 30s safety timeout (which unsticks the one-at-a-time
playingKey guard if audio never signals end) was never cleared, so it fired
~30s after every clip. It's now stored in a per-play token that done() clears
by identity — a natural 'ended' cancels it, and a stale done() from a prior
clip can't disarm a newer clip's timer (which matters because a rejected
audio.play() fires neither ended nor error, leaving the timer as the only
guard-reset). The unmount effect also clears any pending timer, and the timer
is armed only when there's an audio element.
- PrescreenControls: useMediaPermissions set PermissionStatus.onchange but never
removed it → a permission change after unmount setState'd a dead component and
retained the callback. Now guards all setState with a cancelled flag and
detaches onchange in the effect cleanup.
Bug-hunt findings from LOTUS_TODO. Three review passes (the last prescribed the
per-play token to close a shared-ref cross-play edge). Gate-green (tsc, eslint,
prettier, 922 tests, build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- The "auto" seasonal theme was computed once at mount, so a long-lived session
never crossed a season/holiday-window boundary. SeasonalEffect now re-evaluates
on an hourly ticker (auto mode only) AND refreshes on entering auto — the
interval only runs while auto, so a stale mount-time timestamp would otherwise
resurface on a pinned/off → auto switch (the exact frozen-at-mount bug, caught
in review). The decision is extracted to a pure resolveSeasonTheme(override,
now) in seasonSchedule.ts (removing an unsafe cast) and unit-tested.
- Selecting seasonal "auto" while a chat background was set was a silent no-op:
the seasonal picker only cleared the background for a *specific* theme, and the
overlay is suppressed while a background is set. Now any active seasonal mode
("auto" included) clears the background; only "off" leaves it — symmetric with
the background picker (which sets seasonal "off"). The overlay guard stays as a
backstop for legacy persisted state.
Bug-hunt findings from LOTUS_TODO. Three review passes (the 2nd caught the
switch-into-auto staleness); +2 unit tests. Gate-green (tsc, eslint, prettier,
922 tests, build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two bug-hunt findings that were doc inaccuracies, not code bugs:
- Tab title "(N)" is the mention/highlight count (+ "·" for other unread),
mirroring the favicon — intentional. LOTUS_FEATURES said "N unread messages";
corrected to describe the actual highlight-count behavior.
- Collapsible long messages use a fixed COLLAPSE_MAX_HEIGHT (320px ≈ 20 lines);
the doc claimed a Settings → Appearance control that never existed. Corrected
to describe the fixed threshold rather than build a marginal per-user setting.
Verified against ClientNonUIFeatures.tsx and MsgTypeRenderers.tsx.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Toast queue: a burst of notifications appended unboundedly and could cover the
viewport. Cap at 5 in the atom writer, dropping the OLDEST non-sticky toast
(sticky = action toasts requiring a click, never dropped). The drop scan
excludes the just-appended newest (`length - 1` bound) so a fresh toast is
never the one eaten when the cap is full of stickies — it stretches instead.
Container gains a maxHeight + overflowY safety net and scrolls the newest
(bottom) toast into view if the stack ever overflows. +4 unit tests incl. the
cap-full-of-stickies boundary.
- "Unread First" room sort left the entire read tail (all counts tie at 0) in
arbitrary Map order. factoryRoomIdByUnread now breaks ties by recent activity.
Relocated from Home.tsx (module-private) to utils/sort.ts (exported, pure) and
unit-tested (equal-count and read-tail cases fall back to activity).
Bug-hunt findings from LOTUS_TODO. Three review passes: the second caught that
the cap could silently drop the newest notification when full of stickies (real
bug, untested boundary) — fixed and covered; a third traced the corrected loop.
Gate-green (tsc, eslint, prettier, 920 tests, build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- PushRuleEditor: the enable Switch initialized its state from pushRule.enabled
once (useState initializer), so a rule toggled on another device left the
Switch stale until remount. A useEffect now resyncs on pushRule.enabled
change. pushRule flows from useAccountData(m.push_rules), which re-renders on
sync, so the resync is genuinely reached; no optimistic-update conflict (the
toggle sets state only after the PUT resolves).
- About: the "Homeserver Support" panel fetched /.well-known/matrix/support from
the client-API URL (mx.getHomeserverUrl()). Per MSC1929 that file lives at the
MXID server-name host (like /.well-known/matrix/client), which differs on
delegated/split-domain servers. Now fetched from https://{mx.getDomain()};
identical target for non-delegated servers (incl. Lotus), spec-correct for
delegated ones, and degrades gracefully (catch → panel hidden) otherwise.
Bug-hunt findings from LOTUS_TODO. Two review agents; both confirmed effective
and non-regressing (full account-data re-render chain traced; CORS/host edge
weighed). Gate-green (tsc, eslint, prettier, 914 tests, build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the avatar-decoration cluster (live-update pub/sub, CDN-override
thumbnail, profile-404), the DND badge color, and the DenoiseTester
model-node leak + async mounted-guard — commits 29ff1654 and c9d9d914.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Settings → Calls A/B denoise tester leaked audio resources:
- play() built a denoise model node (DeepFilterNet/DTLN worker/WASM) + optional
gate but stopPlayback only closed the AudioContext, never disposing them —
each A/B playback-through-a-model leaked a worker. stopPlayback now mirrors
stopLive (gate.disconnect → model.dispose → node.disconnect).
- A generation token (playGenRef, bumped by stopPlayback) makes play() discard
what it built if superseded during the async WASM/worklet load — closing the
same leak in the rapid-Play-click race, the Stop-during-load case, and the
unmount-during-load case, and stopping a superseded rejection from tearing
down the winning playback.
- A mountedRef guards the getUserMedia paths (startLive/startRecord) so closing
Settings during the mic permission prompt doesn't create untracked
resources / setState-after-unmount; its effect sets true on mount (not only
false on cleanup) so it survives a StrictMode/Activity same-fiber remount.
Bug-hunt findings from LOTUS_TODO. Three review passes: the first two confirmed
the base fixes and surfaced the concurrent-load leak + StrictMode fragility; a
third traced all six play() interleavings of the generation token. Gate-green
(tsc, eslint, prettier, 914 tests, build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Avatar decorations (useAvatarDecoration.ts / ProfileDecoration.tsx):
- invalidateDecorationCache now notifies a per-user listener set (and clears the
give-up counter), so changing your own decoration updates mounted avatars
(timeline, member list) live instead of only after a remount. Concurrent
re-fetches de-dupe via the existing `pending` map.
- Picker grid thumbnails use decorationUrl() instead of the raw DECORATION_CDN
literal, so a VITE_DECORATION_CDN override no longer breaks the grid while
real avatars work.
- Settings reads the full /profile/{userId} instead of the /{field}
sub-resource, which 404s (console error) for anyone without a decoration set
— matching the pattern already used by useAvatarDecoration.
Presence (Presence.tsx): PresenceBadge renders DND (unavailable + status 'dnd')
as red "Do Not Disturb" to match PresenceRingAvatar and the settings picker;
it was the lone outlier showing a yellow "Idle".
Bug-hunt findings from LOTUS_TODO. Two review agents (correctness +
upstream-behavior); gate-green (tsc, eslint, prettier, 914 tests, build). Both
flagged only pre-existing edge notes (in-flight piggyback staleness, 'dnd'
free-text collision shared with the ring avatar) — neither introduced here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On phones the composer's 7-8 secondary action buttons wrapped into a tall
multi-row stack ("massive height"). Mobile now shows a single compact row —
[ + | input | emoji | send ] — where "+" toggles a collapsible row (above the
formatting toolbar) holding attach, GIF, location, poll, voice, formatting and
schedule. Desktop is unchanged (isMobile === false; the mobile branches are
never entered and composerOverflow stays null).
The after-builder stashes the collapsed buttons in a render-local `let` that
the bottom slot reads; safe because JSX props evaluate in source order within
one render (verified by review). Emoji/Send stay inline; the emoji and GIF
PopOut anchors still resolve wherever their button renders.
Review fixes folded in: the "+" toggle uses aria-expanded + aria-controls
(dropped the redundant aria-pressed) pointing at the labelled role="group"
overflow row; the voice recorder's idle mic button gets the @media-gated
MobileTouchTarget 44px target so the overflow row is uniformly tappable.
Two review agents (correctness + UX/a11y); gate-green (tsc, eslint, prettier,
914 tests, build). Visual confirmation still wants a real device per
LOTUS_TESTING.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Records the 3-agent + live-infra investigation into moving MatrixRTC call
membership to sticky events. Conclusion: enabling msc4354_enabled on Synapse
is low-risk/reversible but a no-op by itself (EC stays in Legacy mode behind a
per-device dev toggle; fleet is single-hosted so upgrades atomically). The one
unverified risk is media-layer interop (lk-jwt /get_token vs /sfu/get resolving
to the same LiveKit room) — needs a two-account cross-mode test before any
default change. Parked as a scoped future rollout, not a flag flip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Mark the desktop Tauri CSP frame-src finding FIXED (cinny-desktop daba59b).
- Add the GIF-animation fix + Mixcloud/Deezer embeds write-up (4154cae5),
incl. the Deezer /show vs /podcast correction.
- New section: Matrix 2.0 call membership (MSC4354 sticky events) —
investigated across 3 agents, deliberately NOT enabled. Records why the
server flag is a no-op alone (EC gates it behind a per-device developer
setting defaulting to Legacy), what was verified safe, and the one open
risk (lk-jwt-service LiveKit-alias mapping across the two JWT endpoints)
that a two-account test call must settle before any rollout.
- Refresh Server Capabilities: Synapse 1.157.1, MSC list re-dumped live from
/_matrix/client/versions, note that msc4143 is not a real gap (LiveKit is
discovered via .well-known) and that msc3861 client code is now dead.
- Note the blocked-feature re-check found no change on 1.157.1.
- Drop a verbatim-duplicated "remaining providers" heading; provider count
16 -> 18.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GIF previews rendered but never played: Synapse's /thumbnail endpoint
flattens animated GIFs to a still first frame. GifCard and the generic OG
card now request the original via /download (no width/height) for GIFs, so
they animate. Guarded with shouldServeGifOriginal(): a matrix:image:size cap
(10 MB) keeps a huge self-hosted GIF on the frozen thumbnail, and the generic
card's eager <img> gains loading="lazy" (it was the one preview image missing
it) so originals stay off the wire until near the viewport.
Also adds Mixcloud + Deezer inline media embeds (iframe widgets via
parseMediaEmbed/MediaEmbedCard, matching the existing click-to-play pattern),
and fixes Deezer podcast links: they live at /show/<id>, not /podcast/<id>
(the latter 404s on Deezer's own oEmbed) — verified against the live API.
Reviewed by two agents; both findings (Deezer /show, GIF eager-load) fixed
and covered by tests. Desktop Tauri frame-src CSP updated separately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per-slice hunt over the LOTUS_FEATURES surface (theming / calls / messaging /
threads-presence-UX / rooms-mod-notif-infra-desktop), verified against current
code. Records ~20 residual findings (desktop-CSP missing Steam/Mixcloud/Deezer
frame-src hosts; DenoiseTester model-node leak; PiP auto-spotlight not released;
avatar-decoration no live update; DND badge shown as Idle; toast overflow;
Focus-Assist mount hydration; + Low tail).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Recognize store.steampowered.com content URLs and render each richly, within
the existing privacy-first facade. 2-agent reviewed (both SHIP).
- getSteamTarget / steamWidgetEmbedUrl (videoEmbed.ts, +tests): classify
/app/{id}, /news/app/{id}/view/{gid}, /(bundle|sub|dlc)/{id}; non-content
pages (home/search/wishlist) and other hosts fall through to the generic card.
- SteamCard now dispatches:
- app → OG capsule header + click-to-play facade → Steam's OFFICIAL store
widget iframe (store.steampowered.com/widget/{id}): live region-aware price,
discount %, Buy on Steam. Nothing loads from Steam until "Show price &
store" is pressed; gated by the inlineMediaEmbeds setting. App pages use the
wide card so the ~646px widget has room.
- news → rich announcement card (banner + headline + body preview + link) —
your example URL previously fell through to the plain generic card.
- bundle/sub/dlc → the OG store card.
Grounded in our CSP: the widget works via frame-src https: (no infra change),
images route through the homeserver (img-src excludes Steam), and there is NO
client-side Steam API call (connect-src + Steam CORS both block it) — which is
also the honest ceiling: no review scores/genres client-side, price/buy come
from the official widget.
Runtime QA still needed: the live widget iframe rendering (height/fit) can't be
verified headlessly.
Gates: tsc 0, eslint 0, prettier clean, 912 tests, build ok.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes the prettier CI gate that failed on bc608b37 — check:prettier runs
`prettier --check .` over the whole repo (incl. markdown), and prettier's
markdown style uses `_italic_`. Prior doc commits slipped `*italic*` through
because I only ran prettier on changed src files, not the .md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bugs found by a 3-agent audit of the inline-embed system (core posture —
sandbox, postMessage origin+source, XSS, noreferrer, oEmbed — verified sound);
fixes reviewed by 2 agents on the staged diff (both SHIP).
Parsing (videoEmbed.ts, + tests):
- Twitch/Kick/SoundCloud/Streamable reserved-path exclusion — their own utility
pages (twitch.tv/directory, kick.com/browse, soundcloud.com/discover/…,
streamable.com/login, bare /videos) no longer render as broken player embeds.
- SoundCloud: `/<artist>/<tab>` profile-tab listings excluded; `/<artist>/sets/<slug>`
real sets still detected.
- Vimeo: unlisted-hash capture constrained to lowercase-hex, so a normal video's
trailing segment (/likes, /settings, a slug) isn't captured as a bogus `h=`
param that Vimeo then rejects.
Rendering (UrlPreviewCard.tsx, RenderMessageContent.tsx):
- Spotify/Steam/Discord/IMDb route og:image through mxcUrlToHttp like every other
card — a raw og:image is an mxc:// URI (broken <img> on standard Synapse) or an
off-homeserver request that defeats the click-to-play facade.
- `wide` card class now follows the RESOLVED embed (incl. the og:url short-link
fallback), so an og:url-resolved player gets the wide layout, not a cramped one.
- Twitter host detection (isTwitter/isTwitterTweet) aligned with getTweetId —
mobile.twitter.com and legacy /statuses/ now route to the Twitter card/embed.
- De-dupe preview URLs so a message repeating a link doesn't render sibling
cards with identical React keys.
Gates: tsc 0, eslint 0, prettier clean, 910 tests, build ok.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Test-coverage batch 2 (2-agent reviewed, both SHIP; isolation verified — Node
runs each test file in its own process, so the console patch can't leak).
- cryptoDiagLog.test.ts: the E2EE KE-cluster diagnostics tool — KE-signature
capture vs ignore, most-specific-first match order, KE-3/KE-4 rows, Error /
object / circular-arg serialization (String() fallback never throws), the
200-entry ring-buffer eviction, getCryptoDiagEntries copy semantics,
install idempotency, and buildCryptoDiagReport's client metadata + LOCKED
PII-safe key set (no field can silently leak) + no-client/throwing-getter
fallbacks. Silences console pass-through so the ring-buffer test stays quiet.
- closedLobbyCategories.test.ts: mirrors closedNavCategories — id join,
hydrate, PUT/DELETE, idempotent PUT, no-op DELETE, array persistence,
per-user key namespacing.
Also: mark the EC in-call mobile UI audit done in LOTUS_TODO (stale entry;
shipped as element-call:lotus e36aef8a).
Gates: tsc 0, eslint 0, prettier clean, 911 tests, build ok.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reviewed by 2 agents + a focused deploy-script review (all SHIP).
- Add top-level `concurrency: cancel-in-progress`. A superseded lotus push
cancels its in-flight run, freeing the shared act_runner (web CI otherwise
queues behind long Tauri desktop builds); since `trigger-desktop` is
`needs: build`, only the newest commit kicks a desktop build.
- Promote typecheck / eslint / prettier from `continue-on-error` to hard gates
(tree held clean: tsc 0, eslint 0 errors, prettier formatted). eslint gates
on errors only; existing no-explicit-any warnings stay informational.
- Mark the bundle-size report informational (audit already is).
Cancelling superseded runs is deploy-safe only because lotus_deploy.sh now
re-resolves origin/lotus each poll iteration (companion change in the matrix
repo); the comment documents the coupling.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Test-coverage expansion (2-agent reviewed, both SHIP). The named candidates
(roomToUnread, markedUnread, serverAcl, plaintextCaches, recent*) were already
tested, so this targets genuinely-untested pure logic.
- dom.test.ts: getThumbnailDimensions (scaling math incl. just-over-cap
boundaries), tryDecodeURIComponent, syntaxErrorPosition, and the three
scroll-view geometry helpers (via duck-typed element mocks — no jsdom).
- emoji.test.ts: getHexcodeForEmoji (astral codepoints, 4-digit zero-pad,
FE0F/FE0E/200D stripping on and off, keycap sequences, degenerate inputs)
and the pre-load `undefined` contract for getShortcode(s)For.
Fix (found while writing the tests): syntaxErrorPosition required whitespace
AFTER the digits (`/position\s(\d+)\s/`), but real V8/Node JSON.parse errors
put the number at end-of-string ("... at position N"), so it returned
undefined for every real error and the three dev-tools JSON editors silently
pointed their cursor at position 0. Dropped the trailing `\s`; tests now assert
extraction at end-of-string.
Gates: tsc 0, eslint 0, prettier clean, 891 tests, build ok.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verify-then-fix batch of minor bugs; each staged diff reviewed by 2 agents
(both SHIP). Two listed items (N6 receipt-avatar refresh, H10 room-name
length reject) were already handled and left unchanged.
Threads:
- T5: a just-sent reply no longer under-notifies — `participated` also checks
the local thread timeline for our own events, since the server-bundle
`hasCurrentUserParticipated` lags.
- T6: a room set to "Mentions & Keywords only" no longer over-notifies Default
thread replies — new `roomMentionsOnly` gate (behavior-identical when false;
+4 unit tests).
- T7: thread-mode account-data writes are serialized with content carried
forward (setAccountData is a bare PUT whose result lags the /sync echo, so
plain serialization wouldn't stop the lost update); carry only on success.
Calls / audio:
- C-L2: a real incoming ring cancels a lingering Settings ringtone preview.
- C-L3: the ringtone AudioContext is primed on the first page gesture (via the
always-mounted CallEmbedProvider) so the first ring after a cold load isn't
silent.
- C-L5: useCallSpeakers depends on a stable boolean, so the tile MutationObserver
+ io.lotus.call_state subscription aren't rebuilt on every membership change.
Crypto:
- F5: the OIDC refresher forwards the freshly-refreshed token expiry
(passed on the tokens object at runtime) as expiresInMs, so the persisted
expiresAt no longer goes stale across reloads.
Gates: tsc 0, eslint 0, prettier clean, 860/860 tests, build ok.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mobile follow-ups round 2 (survey findings deferred from the mobile audit),
reviewed by 2 agents on the staged diff (both SHIP).
- URL-preview cards: the Twitch / Twitter / TikTok-fallback cards render
their thumbnail/header BESIDE the content as direct children of the
UrlPreview flex row, which squeezes both on a phone. Add `StackOnMobile`
(@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 (>750px) is unchanged. No-op for the single-column
embed cards (MediaEmbedCard/TikTokEmbedCard).
- 44px touch targets (MobileTouchTarget, @media max-width:750px) on the
otherwise ~28px controls: embed-player Close/Collapse/Fullscreen/View-post
buttons; image-viewer close/zoom/download; the read-receipt "seen by" pill.
Deferred (rationale, not built): PiP resize handles + fullscreen button —
enlarging four 24px corners to 44px would swallow a ~160px mobile PiP and
block "Return to call"; presence dot is a non-interactive status indicator.
Gates: tsc 0, eslint 0, prettier clean, 856/856 tests, build ok.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Avatar decorations are animated APNGs and were the only motion feature not
gated on prefers-reduced-motion (chat backgrounds / seasonal overlays all
suppress motion under it). Since there's no static-frame asset to freeze to,
render just the avatar (no decoration overlay) when the user prefers reduced
motion — the only motion-respecting option. Users without the preference are
unaffected; live OS-toggle is reactive via useReducedMotion. Also relieves the
mobile perf drain of dozens of live APNGs in scrolling lists.
Reviewed: correct a11y behavior, hooks-safe, no layout dependency on the overlay.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deep-audit follow-up. New shared MobileTouchTarget class (@media <=750px ->
minWidth/minHeight 44px) applied via className to the primary interactive
controls folds renders below 44px:
- in-call control bar (7 buttons) + persistent call-status bar (4 buttons)
- thread "N replies" chip
- knock Approve/Deny buttons
- server-ACL entry remove button
folds size variants set only padding (no width/height/min-*), so the class
raises the hit-area floor to 44px with the icon/label staying centered at its
normal size; desktop is untouched (@media-gated). Verified by two review passes
(no distortion, no layout overflow, counts exact).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mark the code-level mobile responsive audit done (M1-M6 + N1-N2 shipped) and
list what remains: runtime device QA, the Element Call fork in-call mobile UI,
M2/iOS touch discoverability, the sub-44px sweep, avatar-decoration reduced-
motion, and the Twitch/Twitter/TikTok card restructuring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MImage/MVideo pinned AttachmentBox to a fixed height computed for a 400px-wide
layout. The box width is responsive (maxWidth:100%) but the height was frozen,
so on a phone the box narrows below 400px while keeping desktop height ->
images crop (object-fit:cover) and videos letterbox (object-fit:contain).
Drive the box by `aspect-ratio: w/h` when intrinsic dimensions are known, so the
height tracks the responsive width. On desktop the box stays 400px wide, so the
aspect-ratio yields the identical height (algebraically 400*h/w =
scaleYDimension(w,400,h)) — pixel-identical. Falls back to the fixed height when
dimensions are unknown; the 48px floor and 600px cap are preserved.
Uses the same pattern already shipped in this codebase (TwitchThumbnailWrapper,
GalleryTile). Two review passes, one empirically measuring the rendered image in
Chromium: desktop unchanged, narrow widths keep correct aspect, no collapse.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From the 6-agent deep per-feature audit. Mobile-gated / consistency fixes;
desktop unchanged except two intentional dialog-width normalizations noted below.
- In-call control bar: wrap="Wrap" on the SequenceCard so the compact two-group
row wraps on the narrowest phones (<=390px) instead of pushing End off-screen
(M1 fixed the 500-750px band; this covers narrower). Desktop stays one row.
- In-call soundboard popout: clamp maxWidth to the viewport (like M5's screenshare
popover) so it can't overflow a narrow phone.
- Report-Message dialog + "Seen by" (EventReaders) modals (Message.tsx x2 +
RoomViewFollowing): add useModalStyle so they go full-screen on mobile like
their sibling report/receipt modals (they floated as fixed cards before).
- In-app toast container: full-width toasts inset from both edges on mobile
(ScreenSize.Mobile); a fixed 280-340px card previously overflowed a narrow
phone. Desktop byte-identical (bottom-right floating card).
- Policy-list tabs + audio-controls rows: wrap="Wrap" (inert on desktop).
Intentional desktop deltas (normalizing to existing sibling modals, verified by
two review passes as consistent, not regressions): Report dialog max-width
380->480px; EventReaders modals 460->360px.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mobile-audit batch 6. Bump the primary always-visible tap targets to a 44px
touch area on phones, via mobile-gated CSS only (desktop/tablet >750px keep the
denser sizing).
- NavItemBase (room/nav list row): minHeight 36 -> 44 at <=750px.
- SidebarItem (space-rail button): minWidth/minHeight -> 44 at <=750px (was 42).
The room lists are virtualized with ref=virtualizer.measureElement on every row,
so rows are measured to their actual height — the taller mobile rows can't
overlap/clip. Verified desktop-unchanged and virtualizer-safe by two review
passes. (A blanket app-wide size=300 button sweep was intentionally NOT done:
most such buttons are hover-gated and never appear on mobile; the primary
tap targets above are the high-value fixes.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mobile-audit batch 5. Desktop provably unchanged (two review passes).
- Translate-language control: raw <select> (crowded narrow tiles + broke under
non-default themes) -> the folds-native SettingsSelect used by every other
dropdown in the settings screen (native-cinny; keeps aria-label).
- Ringtone/Soundboard volume sliders: moved from the fixed-width tile `after`
slot (which squeezed the title on phones) to a full-width slider in the tile
body, matching the night-light slider pattern.
- Screenshare-confirm popover: clamp maxWidth to the viewport so it can't run
past the screen edges on a phone (inert on desktop).
- In-call soundboard editor rows wrap on a narrow popout instead of crushing the
clip-name field.
- GifPicker: feed the giphy Grid the measured container width (useElementSize
Observer) instead of a fixed 296px, so it doesn't overflow a <312px phone.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mobile-audit batch 4. Desktop paths unchanged (verified by two review passes).
- UserRoomProfileRenderer: the member/room profile was always an anchored,
fixed-width (340px), non-scrolling PopOut, so on a phone the moderation
actions / device list / notes fell off the bottom, unreachable. On
ScreenSize.Mobile it now renders a full-screen, internally-scrollable Modal
with an explicit Close button (the full-screen sheet covers the backdrop and
the profile has no self-close, so a tap-to-dismiss / X is required); desktop
keeps the exact same anchored PopOut. Uses the provider-free useScreenSize().
- PowersEditor: the Color/Name/Power row wraps on narrow widths (wrap="Wrap")
instead of squishing the name field; inert at desktop widths.
The mobile close button addresses a dismissal-trap both reviewers flagged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mobile-audit batch 3. All changes mobile-gated (@media <=750px) so desktop is
unchanged.
- ModalWide: fill the phone screen (100vw/100vh, no radius) at <=750px instead
of floating as an 85vw card. This also full-screens the file/PDF viewer and
the avatar-crop editor on mobile (they share ModalWide) — intended.
- UserHero avatar viewer: new mobile-only ModalMobileFull class (no desktop
effect) so it goes edge-to-edge on phones like the timeline lightbox.
- usePan: add touch support (single-finger drag, cleaned up on
touchend/cancel/unmount) alongside the unchanged mouse path, so a zoomed image
can be panned on a phone. Wired into ImageViewer and the MediaGallery lightbox.
Two review passes: mouse path byte-for-byte unchanged; desktop provably
unaffected; touch is gated to zoom!=1 so a non-zoomed image never hijacks
swipe/scroll.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mobile-audit batch 1. All fixes reuse cinny's own responsive primitives and are
mobile-gated so desktop is unchanged.
- Message tables: wrap <table> in an overflow-x container so a wide table scrolls
instead of overflowing the message column / page body.
- Composer toolbar: let the before|editable|after row and the toolbar wrap on
phones (@media <=750px) instead of squeezing the editable to zero and pushing
Send off-screen.
- In-call control bar: collapse to the compact/stacked layout on a mobile
viewport (ScreenSize.Mobile) too, not just when the bar's own container is
<500px — fixes the 500-750px band where the control row overflowed.
- URL-preview card: base width toRem(400) -> min(25rem, 92vw) so a single card
fits a narrow phone (still exactly 400px on desktop).
- Explore card grid: drop to one column at <=750px (was a fixed 3-col grid).
Two review passes: desktop behavior provably unchanged (all gated by @media /
ScreenSize.Mobile; the table wrapper only contains previously-overflowing tables).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the open Discovery-pass-2 list with a completion summary + commit refs
(PERF-1..5, SEC-1..4, COR-1..6 shipped this session, gate-green, each reviewed
by two agents). Record PERF-6 / SEC-5 as deferred-informational and note KE-1's
storage.persist() preventive is already implemented.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
COR-3 (CallEmbedProvider): the incoming-call lifetime guard distrusted a
caller's sender_ts only when it was >20s AHEAD of the server ts. A caller clock
that ran SLOW left sender_ts in the past, so the ring auto-dismissed/never
showed for a fresh invite. Trust sender_ts only within ±20s of the server ts,
else fall back to it (also fixes a NaN path when sender_ts is missing).
COR-6 (CallControl): forceState rebuilt CallControlState with 5 args, silently
defaulting screenshareAudioMuted to false; pass this.screenshareAudioMuted.
COR-5 (uploadContent + useBindUploadAtom): cancelling during the retry back-off
was a no-op (mx.cancelUpload only aborts an in-flight request), so the upload
resurrected on the next attempt. Thread an AbortSignal: the back-off sleep
resolves early on abort and the loop stops with an abort error; the hook aborts
a per-upload AbortController on cancel (alongside mx.cancelUpload for the
in-flight case).
All verified by two review passes (no double-settle / no resurrection); includes
their suggested abort-listener cleanup on normal sleep resolution.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SEC-3: add `noopener,noreferrer` to the 5 `window.open(_blank)` sites that
don't use the returned handle (UserChips, OidcManageAccount, OtherDevices x2,
Verification), closing reverse tab-nabbing. SSOStage is intentionally excluded —
it needs the window handle + intact opener for its origin-checked SSO
postMessage handshake.
SEC-4: guard the `/acl` slash command against bricking the room.
- Extract the ACL glob helpers (isValidServerPattern/globToRegExp/matchesAnyGlob)
from RoomServerACL into a shared utils/serverAcl.ts (+ unit test) so the
command and the settings editor validate identically.
- Default a MISSING allow list to `*` only when the room has NO existing ACL
(a first `/acl -d x` otherwise sent `allow: []`, which bricks the room); an
existing ACL's absent/empty allow is preserved, not silently widened.
- Reject invalid globs; fail CLOSED on the universally-catastrophic cases
(empty allow, or a `*` deny) even when the local domain is unknown; and reject
any change that would ban this homeserver (self-lockout).
Guard hardened per two review passes (fail-closed on unknown domain; no silent
federation widening).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every ReadReceiptAvatars row and every useMemberAvatar registered its own global
RoomStateEvent.Members listener — ~6 per receipt row — each firing on any
membership / display-name / avatar change in ANY room.
Add a module-level MemberChangeStore (mirroring the PERF-1 presence store) that
registers exactly ONE global Members listener and fans out to subscribers keyed
by roomId|userId. Two hooks: useRoomMemberChange (single) and
useRoomMembersChange (multi, one effect). useMemberAvatar and ReadReceiptAvatars
use them; behavior (re-render triggers) is byte-for-byte equivalent. Unsubscribe
is idempotent via a set-identity guard; the multi-hook key is order-independent.
Unit-tested (key-scoped fan-out, single shared listener, idempotent unsubscribe).
Reviewed by two passes (lifecycle/closure + behavioral equivalence) — clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PERF-2 (RoomMentionAutocomplete): the #-mention list did
`useAtomValue(allRoomsAtom).sort(...)` inline — `.sort()` MUTATED the shared
allRoomsAtom array in place (reordering it for ~27 other consumers) and re-ran
the O(N log N) getRoom compare every keystroke. Copy then memoize:
`useMemo(() => [...allRoomsList].sort(factoryRoomIdByActivity(mx)), ...)`.
PERF-4 (SearchFilters): the room-filter A-Z sort ran every render; wrap in
useMemo keyed on [searchResult, roomList, mx].
PERF-5 (useRoomLatestRenderedEvent + RoomNavItem): the hook registered a GLOBAL
client `Decrypted` listener for every nav item, but its result is only used for
DM rows. Add an `enabled` param (default true) that skips all work + listeners
when false; RoomNavItem passes `!!direct`. The only other caller keeps the
default.
Verified behavior-preserving by two review passes (PERF-2 also fixes a real
shared-atom mutation bug).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
useUserPresence registered 3 client listeners (Presence / CurrentlyActive /
LastPresenceTs) PER hook instance. On a large room that meant 100-250 global
listeners, every presence event fanning out across all of them, with add/remove
churn on every fast scroll.
Replace with a module-level PresenceStore singleton that registers exactly 3
listeners total (lazily, on first subscriber) and fans out to per-user
subscribers itself. The hook keeps the same public API (useState + a subscribe
effect); consumers are unchanged. Cache + subscriber sets stay bounded to
currently-mounted users; the mx-swap branch re-homes listeners on re-login.
Reviewed by two passes (SDK mutate-before-emit ordering and handler signatures
independently verified). Includes their recommended hardening: the unsubscribe
is made idempotent via a set-identity check so a double-invoke / re-subscribe
can't evict a newer subscriber.
Note: a User object that appears silently with no presence event no longer
re-seeds (deps are [mx, userId] not [mx, user]); the common presence-EDU case
is handled (and better than before). Reviewers rated this narrow case Low.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a single m.space.child was removed (unlinking child C from space P), the
roomToParents reducer fired the whole-room DELETE action, which wiped C's
entire parent set, stripped C as a parent from every other room, and orphaned
C's own descendants until a full resync. So removing C from space A also
dropped C's other parent B, and C's children lost C.
Add a targeted UNLINK {parent, child} action that removes only that one
parent->child edge and prunes the child entry only when its parent set
empties (matching the map's build-time invariant that zero-parent rooms have
no entry). Point the invalid-child branch of handleStateChange at it; DELETE
is unchanged for genuine room leave/delete. Unit-tested (keeps other parents,
prunes on last parent, does NOT orphan descendants, unknown pair no-op).
Verified correct + consumer-safe by two review passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
COR-2 (useCallEmbed): useCallJoined only reset `joined` when the embed became
undefined. Answering a 2nd call swaps the embed A->B directly (embed stays
truthy), so `joined` stayed true and call B rendered as already-joined,
skipping the loading/watchdog UI. Re-seed from `embed?.joined ?? false` on
every embed identity change.
COR-4 (ClientNonUIFeatures): the notify-dedupe used one Map<roomId,eventId>
slot shared by the main-timeline and per-thread paths, so a thread reply
overwrote the room's slot and a re-fired main message (decrypt/edit re-emit,
common in E2EE) then mismatched and double-notified. Key the slot by
`${roomId}|${threadId ?? 'main'}` so each path dedupes independently.
Both verified correct by two review passes (no missed-notification or
missed-join regressions).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Several localStorage caches held decrypted message content or user PII and
survived a normal logout, leaving residue on a shared device (the search
index was already wiped; these were not):
- cinny_scheduled_messages_v1 - decrypted IContent.body of pending sends
- cinny_recent_searches_v1 - search query text
- cinny_recent_forward_targets_v1 - recent forward contact/room graph
- cinny_recent_gifs_v1 / cinny_recent_stickers_v1 - media the user sent
- navToActivePath<userId> - per-space last-visited room paths
- (plus the translation cache added earlier)
Add a clear function per module and a single auditable clearPlaintextCaches()
aggregator, called from both logout paths (logoutClient + the server-forced
SessionLoggedOut handler) alongside the existing session/search-index wipes.
Unit-tested.
Deliberately NOT cleared (documented in the aggregator): unsent composer
drafts and the presence status message (preserved by product decision N98);
SDK sync/crypto store + io.lotus.* account data (reminders/bookmarks/notes),
already wiped by mx.clearStores(); low-sensitivity UI/metadata residue.
The forward-targets/gifs/stickers/nav-path additions and the accurate
"not covered" documentation address findings from two review passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the feature to README (Messaging) and LOTUS_FEATURES (new
On-Device Message Translation section under Messaging Enhancements),
matching the existing style.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up hardening from two review passes on the on-device translation
feature:
- Privacy (HIGH): the translation cache is decrypted message plaintext,
but logout did not clear it (unlike the search index), leaving up to
300 cleartext bodies in localStorage on shared devices. Add
clearTranslationCache() and call it from both logout paths
(logoutClient and the server-forced SessionLoggedOut handler).
- Edited messages (MEDIUM): the cache key was eventId:target with no
content dependence, so an edit reused the pre-edit translation. Fold a
content fingerprint into the key, and re-arm the auto-translate
one-shot when the text changes.
- Settings (LOW): coerce a persisted translateTargetLang to a supported
curated code so the hook never targets a language the engine can't
produce (previously only the UI clamped it).
- Chinese (LOW): restore canonical BCP-47 case (zh-Hant / zh-Hans) at
the Translator API boundary, since normalizeLang lowercases the script
subtag for internal keys.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add per-message translation that runs entirely on-device via the
Chromium built-in Translator + LanguageDetector APIs. Message text
never leaves the machine and never touches a cloud service, preserving
the E2EE guarantee. When the on-device engine is unavailable
(non-Chromium / mobile) the feature simply hides itself; there is no
network fallback.
- Engine abstraction (utils/translation): TranslationEngine interface
plus a chromeTranslationEngine implementation (feature-detected,
caches translator/detector instances, download-progress monitor).
Pure lang-code helpers (normalize/sameLanguage/curated targets) with
unit tests.
- Settings: translateTargetLang (default English) + autoTranslate
(opt-in), with a Messages settings tile — a target-language select
and an auto-translate switch, disabled with a note where unsupported.
- useMessageTranslation hook + shared per-event toggle atom-family and a
persisted LRU cache so scrollback never re-translates.
- UI: a Translate / Show Original message-menu action, an inline
"Translated from <lang> - Show original" chip, and a body swap in
m.text/m.emote/m.notice that renders the translated text through the
plain-text path (linkify + emoji) inside a dir=auto span for RTL.
- Auto-translate flips foreign messages whose model is already
downloaded; first-time downloads keep the manual chip (user gesture).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The status-message emoji picker listed the guild's custom/image-pack emojis, but
clicking one did nothing — the field only wires onEmojiSelect (unicode), not
onCustomEmojiSelect, so custom picks were silently dropped (the room composer
works because it wires both). A custom emoji is an mxc image and a status is
plain-text presence status_msg, so it can't render there anyway.
Add an EmojiBoard hideCustomEmojis (unicode-only) mode that zeroes the image
packs (removing pack groups, sidebar icons, and search results) and filters
custom entries out of Recent, and enable it on the status field. Now every emoji
shown actually inserts. Additive prop, default off — no change to other pickers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On cinny-desktop (Tauri/WebView2) a clicked notification navigated the web
content but never raised the OS window (a service-worker/WebView2 client.focus()
only focuses the document). The service-worker notificationClick path now calls
a new focus_main_window Tauri command via invokeTauri (no-op outside Tauri) in
addition to navigating; the rich-toast path is focused natively on the Rust side.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review noted the number input had both an htmlFor-associated visible label
("Voters can pick up to") and an aria-label, so the aria-label won and the
visible label was not announced. Remove the aria-label so the accessible name
matches the visible label.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The poll creator only offered single (max_selections 1) or multiple = pick ALL
options — no way to run a "pick your top 2" poll, even though the display side
already enforces an arbitrary max_selections ("Select up to N"). Add a "Voters
can pick up to N of M options" control shown for multiple-choice polls. Defaults
to the option count (preserving the old select-all behavior) until lowered;
clamped to [2, filled option count] on submit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
check:prettier was not part of my gate routine, so formatting drift accumulated
across the session's touched files (and a few older ones). Run prettier --write
to bring the repo back to 'All matched files use Prettier code style!'.
Formatting only — no logic changes. tsc/tests/build all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ScheduleMessageModal had a local formatSendAt(Date) byte-equivalent to the
tested formatFriendlyDateTime (utils/datetimeInput). Reuse the shared, unit-
tested helper instead of a second copy — identical output. (Also prettier-clean.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The emoji and GIF pickers both have a "Recent" row, but the sticker tab of the
shared EmojiBoard did not — you had to hunt through packs to re-send a sticker.
Add recent stickers, mirroring recentGifs:
- New state/recentStickers.ts (localStorage cinny_recent_stickers_v1, deduped by
url, capped 16) + pure addRecentSticker with 4 unit tests.
- EmojiBoard: a "Recent" group in stickerGroupItems and a RecentClock sidebar
icon in StickerSidebar, shown only when recents exist. Entries are rebuilt into
minimal PackImageReaders (StickerItem needs only url/shortcode/body) so they
render + re-send like pack stickers.
- Recorded on select in the shared delegated click handler, covering both the
grouped and search paths.
Blast radius is the sticker tab only (reactions/status use the emoji tab).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The thread composer already showed the /command autocomplete (RoomInput.tsx:954
was never gated), but the interpreter was disabled (:523), so /me, /shrug,
/invite, etc. sent literally in threads - a confusing inconsistency and the
other half of the threads "v1" limitation.
Remove the thread gate: content-transform commands (/me, /notice, /shrug,
/tableflip, /unflip) flow into the normal send path, which already routes to the
thread via threadRootId; the rest are room-level actions. No command sends a
mis-routed timeline message (verified against useCommands). Scheduling stays
disabled in threads for now.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From review: the per-answer voter line sat inside the radiogroup with no
association, so a screen-reader user on the radio didn't hear who voted. Add
aria-describedby from each answer to its voter line (id poll-voters-<eventId>-
<answerId>) and prefix the line with "Voted by" for a clearer announcement.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The poll card showed vote counts but never who voted, even though
computePollState already parses a sender for every response. Surface it:
- tallyResponses now also returns voters: Map<answerId, senderId[]>, built in the
same latest-response-per-sender loop as the counts, so voters can never disagree
with the numbers (voters.get(id).length === counts.get(id)). +5 unit tests.
- PollContent adds a "Show who voted" toggle, shown only when results are visible
(disclosed live, or undisclosed after end — so a secret ballot stays secret).
When on, each answer lists its voters' display names (getMemberName), rendered
as a sibling of the answer button so the radiogroup keyboard model is untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
isInQuietHours was a hand-rolled, untested duplicate of the overnight-window
logic. Replace it with the shared, unit-tested isWithinTimeWindow (utils/
timeWindow.ts) - identical behavior for valid HH:MM inputs, more robust on
malformed ones (returns false rather than doing NaN math), and now covered by
timeWindow.test.ts. One implementation instead of two.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The thread composer reused RoomInput's hardcoded editableName="RoomInput", so
the main timeline's global up-arrow "edit last message" handler fired while
focused in a thread composer and targeted the MAIN timeline's last message
(wrong), and there was no up-arrow edit for the thread itself.
- Make editableName a RoomInput prop (default "RoomInput"); the thread composer
passes "ThreadInput", so the two up-arrow handlers never cross-fire.
- Add an up-arrow-edit handler to ThreadTimeline (parity with RoomTimeline):
empty thread composer + Up -> edit the latest editable reply in that thread,
using thread.liveTimeline + canEditEvent + setEditId.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Manual DND only existed via the desktop tray (manualDndAtom), so web/mobile
users had no way to pause notifications, and there was no snooze-for-a-duration
anywhere. Add a "Pause Notifications" control in Settings > Notifications:
- Presets: 30 min / 1 hour / 4 hours / Until 8 AM / Until I resume, plus Resume;
live "Paused until ..." status that flips back on when the snooze lapses.
- Persisted snooze instant (cinny_notification_snooze_until_v1) so it survives a
reload; 0 = off, SNOOZE_INDEFINITE = until resumed.
- Feeds the existing notification gate (ClientNonUIFeatures, both the message and
invite monitors) alongside Focus Assist / manual DND / Quiet Hours, suppressing
notify() and playSound().
- Pure helpers isSnoozeActive/nextTimeAtHour/SNOOZE_INDEFINITE in utils/snooze.ts
(+5 unit tests); persisted atom in state/notificationSnooze.ts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The timeline image viewer only zoomed via the -/+ buttons and the % toggle. Add
scroll-to-zoom (wheel up = in, down = out) for parity with the media-gallery
lightbox.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The timeline image viewer's transform applied translate inside scale(), so
dragging a zoomed image moved it by zoom x the cursor distance (panning outran
the pointer). Divide the pan offset by zoom so it tracks 1:1 - matching the
media-gallery lightbox fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Jump to Time uses timestampToEvent (MSC3030). On a homeserver that doesn't
support it, the dialog showed the raw "M_UNRECOGNIZED: Unrecognized request"
error. Show a clear explanation instead when errcode is M_UNRECOGNIZED.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review noted media without a caption has a filename body, so Copy Text showed
and copied the filename. Gate to m.text/m.emote/m.notice so it only appears for
actual text messages (matching the intended behavior).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The message menu had Copy Link (permalink) but no way to copy the message text
itself. Add a Copy Text item that copies the plain-text body with the reply
fallback stripped (trimReplyFromBody). It renders nothing when there is no
usable text body (e.g. media without a caption), so the caller can list it
unconditionally next to Copy Link. Uses Icons.Text.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Constrain the rendered location description width and break long tokens so an
oversized description from another client can't overflow the message bubble.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The m.location renderer only read the legacy top-level geo_uri, so a location
from a client that sends only the MSC3488 shape (uri under
org.matrix.msc3488.location / m.location) showed as broken. Fall back to that
uri, and display an MSC3488 description above the coordinates when present.
Closes the consume-side gap noted in review of the send-side MSC3488 change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From review: equal start/end silently disables the tint (zero-length window).
Show an inline hint so the user isn't left wondering why nothing happens.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Location sharing sent only a legacy geo_uri with a "Location: geo:..." body, so
other clients often rendered it as plain text. Include the MSC3488 blocks
(org.matrix.msc3488.location/asset/ts + m.ts) alongside geo_uri and a readable
body, so Element and others render a proper location pin. Local rendering is
unchanged (still reads geo_uri).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Night Light was all-or-nothing. Add an optional schedule with From/To time
inputs so the warm overlay only shows during set hours and toggles itself on/off
automatically (the overlay re-checks every minute; no reload). Overnight windows
that wrap midnight (e.g. 21:00 -> 07:00) are handled.
Window logic is the pure, unit-tested isWithinTimeWindow/parseHHMM in
utils/timeWindow.ts. New settings: nightLightSchedule/Start/End (default
21:00-07:00).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From review: sanitizeFilename now denylists only path-hostile + control chars
(preserving Unicode room names instead of collapsing CJK/emoji names to "room"),
converts whitespace to dashes, trims separator runs, and caps length. Also
surface an inline error when canvas.toBlob returns null so a failed PNG encode
isn't a silent no-op.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The invite QR was display-only. Add a "Download QR" button that exports it as a
PNG via an offscreen high-resolution (1024px, spec 4-module quiet zone)
QRCodeCanvas + canvas.toBlob, saved through useSaveFile (filename from the room
name) with the standard download toast. The visible code stays an SVG so it
renders crisply at any size/theme.
Also corrects the stale LOTUS_FEATURES note (the QR is generated locally via
qrcode.react, not api.qrserver.com).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shared account-data store removes optimistically with no rollback, so the
reminder row vanished the instant Cancel was clicked. Showing a "could not
cancel" error beside the already-gone row (and it reappearing on next sync) was
self-contradictory. Match the removeBookmark convention: fire-and-forget
optimistic removal, no inline error. Drops the now-moot cancelling busy-guard
and uses a collision-safe React key for same-minute custom reminders.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was no way to see or cancel a reminder once set (removeReminder was only
called by the fire-and-forget monitor), and addReminder didn't dedupe, so a
message could silently accumulate duplicate reminders. The Remind Me dialog now
lists the reminders already set on that message (soonest first) each with a
cancel button.
- New shared, tested formatFriendlyDateTime(ts, now?) in utils/datetimeInput.ts
(Today/Tomorrow/date + time).
- Per-row cancel busy-guard; inline "Could not cancel" on failure.
Also applies two nits from the custom-time review: focus the date input when the
custom picker is revealed, and clear the error when editing date/time.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Remind Me dialog only offered four fixed presets, so you couldn't set a
reminder for an arbitrary time. Add a "Custom time…" option that reveals date +
time pickers (validated >= 1 minute in the future) and sets the reminder at that
absolute timestamp.
Also extract the local date/time <input> helpers (toLocalDate, toLocalTime,
parseLocalDateTime, pickerInputStyle) into a shared, unit-tested
utils/datetimeInput.ts and reuse them in ScheduleMessageModal (deduped from an
inline copy) — identical output, now covered by tests.
Documents the previously-undocumented Message Reminders feature in
LOTUS_FEATURES.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review noted the 500-entry io.lotus.bookmarks blob approaches the ~65KB event
limit. The senderId I stored was never read back (the panel re-resolves the
live sender via the event and uses the senderName snapshot otherwise), so it
was dead payload. Keep only senderName.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Saved-message cards showed the room and time but not the author, so in a busy
room you couldn't tell who said it without jumping. Now each card shows
"{sender} - {time ago}":
- Bookmark gains optional senderId/senderName (snapshotted at save time in
Message.tsx from the already-computed sender display name); optional so
existing stored bookmarks stay valid.
- The panel re-resolves the author's current display name live from the event
when the room is joined, falling back to the stored snapshot for left rooms.
- Search now also matches the author name.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- mediaFilename: only skip appending an extension when the body already ends in
a plausible short alphanumeric extension, so "Screenshot 2024.01.05" still
gets a real extension appended for the saved file.
- Lightbox pan: divide the translate by zoom (it runs nested inside scale), so
dragging tracks the cursor 1:1 instead of moving `zoom`x too far.
- Wheel: ignore deltaY === 0 (pure horizontal scroll no longer zooms out).
- Zoom-out button disables at the real reachable minimum (0.2, not dead 0.1).
- Zoom-controls group gets role="group" so its aria-label is announced.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The timeline image viewer supports zoom/pan, but the gallery's own lightbox
rendered a plain object-fit:contain image. Add the same affordances for images
(videos keep their native controls):
- scroll wheel or header -/+ buttons to zoom; +/-/0 keys; double-click or the
% chip toggles 1x<->2x
- drag to pan when zoomed; zoom/pan reset when navigating to another item
Reuses the shared useZoom/usePan hooks (usePan already cleans up drag listeners
on unmount and resets pan when zoom returns to 1x).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The gallery's File and Audio tabs already had download buttons, but images and
videos could only be saved by jumping to the source message. Add:
- a Download button in the lightbox header (full-resolution source), and
- a hover/focus download button on each image/video grid tile
Both reuse the shared FileDownloadButton (decrypts E2EE media client-side, saves
via useSaveFile, spinner/check/retry states). The tile download control is a
sibling of the tile button (not nested — avoids interactive-in-interactive) and
stays visible on touch (hover:none) devices. Download always targets the
full-res file/url, not the thumbnail.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
handleEdit already clears cancelErrors for the old row; also clear sendErrors
so a prior failed "Send now" doesn't leave a stale inline error after editing.
Cosmetic hygiene, matching the existing cancelErrors handling.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The folds Input container is display:flex with no width, so inside the row's
grow="Yes" wrapper the wrapper grew but the input collapsed to content width.
Set width:100% on the Input so the name field uses the space the row gives it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fire a pending scheduled message immediately via MSC4140 action:'send'
(the server dispatches the stored delayed event now, as a normal timeline
event) instead of having to cancel and retype.
- sendScheduledMessageNow(mx, delayId) mirrors cancel/restart with action:'send'
- handleSendNow reuses the per-row busy guard; prunes local state only once the
server confirms; a failed send shows an inline "Could not send now" error with
the message still sendable/editable/cancellable
- Send-now IconButton (Icons.Send) added before Edit/Cancel in each row
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 2 review agents (pause/resume duration model +
meter lifecycle were verified correct):
- Mic-stream leak (HIGH, pre-existing but in this change's blast radius):
the mic tracks were only stopped inside mr.onstop, which cancel() nulls
and the unmount effect never triggered — so cancelling or unmounting
mid-recording/pause left the mic live (OS indicator on). Hold the stream
in a ref and release its tracks explicitly (stopStream) on cancel and on
unmount, independent of onstop. Normal stop still releases via onstop.
- Defensive: startMeters now cancels any existing rAF/interval before
starting, so it can never spawn a second loop.
- a11y/UX: the finish button (checkmark, advances to the preview step) is
relabeled "Finish recording"/"Finish" so the label matches the check
glyph (was "Stop recording" with the old pause icon). The three
recording-control buttons get flexShrink:0 so they don't squish the
waveform at narrow composer widths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The voice recorder was a single continuous take — an interruption meant
stopping early or starting over (and the Stop button confusingly used a
Pause icon). Add real pause/resume.
- MediaRecorder.pause()/resume() with a new 'paused' state.
- Duration now accumulates only active-recording time: an accumulate-on-
pause model (accumulatedMsRef + segmentStartRef) replaces the wall-clock
startTime, so paused time is excluded from both the live timer and the
finalized preview duration.
- Extracted startMeters/stopMeters so the waveform rAF + timer interval are
reused across start/resume; stopMeters keeps the audio graph alive for
resume while stopAll tears it down.
- Recording view now also renders the 'paused' state: a Pause/Resume toggle
(Pause vs Play icon), the record dot stops pulsing (dimmed), and the
waveform/timer freeze. Stop/Cancel/unmount all handle a paused recorder.
- Fixed the mislabeled finish button: it now shows a checkmark (it advances
to the preview step) instead of a pause icon.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 2 review agents (core edit path was verified
correct — media preservation incl. encrypted, threading, no-op guard):
- Require a filename (utils/room.ts): canEditCaption now also checks the
MSC2530 `filename` exists. Fixes media from clients that omit filename,
where the editor prefilled the filename as a caption and clearing it
wrote an empty body. Such media simply isn't caption-editable (matches
renderCaption never showing a caption for it).
- Carry m.mentions (MessageEditor): a caption edit now unions typed
@-mentions with prior mentions like the text-edit path, so mentioning
someone in a caption edit notifies them.
- Double caption: revert to the editor replacing the content while editing
(as text edits do) instead of rendering the media + its caption above an
editor prefilled with the same caption — removes the confusing duplicate.
- Removed-caption "(edited)" marker (RenderMessageContent): when a media
message is edited but has no caption (e.g. the caption was removed),
render the standalone "(edited)" affordance so Edit History stays
reachable (previously it lived only inside the caption and vanished).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Captions could be attached at upload but never changed — canEditEvent
only allowed m.text/emote/notice, so a typo in an image caption meant
delete + re-upload. Add caption editing for image/video messages.
- utils/room.ts: canEditCaption (own image/video RoomMessage, no
non-thread relation) + canEditEventOrCaption. canEditEvent unchanged.
- Message.tsx: gate the Edit affordance (quick-actions + menu) on
canEditEventOrCaption; label it "Edit caption" for media; keep the media
rendered above the editor while editing.
- MessageEditor.tsx: for a media message, seed the editor from the caption
(not the filename), allow an empty caption (removes it), and build the
m.replace so m.new_content spreads the original media content
(url/info/encrypted file/filename/msgtype) and only sets body +
format/formatted_body. Outer content is the full media (not a "* text"
fallback) so non-edit-aware clients still render the media. No-op guard
when the caption is unchanged. Placeholder "Add a caption…".
Rendering + Edit History need no changes: getEditedEvent's m.new_content
flows to renderCaption, and the word-diff already diffs body (the
caption). Encrypted media keeps its file/key (no re-upload).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 2 review agents on the recent-GIFs row:
- Motion/perf: the Recent row rendered up to 16 full animated GIFs at
once (autoplaying). Capture a small still image at pick time
(fixed_width_small_still / *_still) into RecentGif.previewUrl and render
that for the thumbnail, so recents no longer autoplay. Pre-existing
recents without a preview fall back to the animated url. Re-send still
uses the animated url, so the sent m.image is unchanged.
- a11y: the recent buttons all had the identical label "Send recent GIF".
Give them positional labels ("Send recent GIF N of M") and wrap the grid
in a role="group" labelled by the "Recent" section heading, so the row
is a distinguishable, announced group.
Correctness review found no bugs (write-before-unmount, term gating,
dedupe, re-send fidelity all verified).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The GIF picker was a bare Giphy search grid with no memory of what you've
sent, so re-sending a go-to reaction GIF meant re-typing the search every
time. Add a "Recent" row at the top of the picker (default view; hidden
while searching) for one-click re-sending.
- New persisted state state/recentGifs.ts: recentGifsAtom (localStorage,
cinny_recent_gifs_v1, getOnInit) + pure addRecentGif (dedupe-by-url
move-to-front, cap 16, ignore empty url), with 5 unit tests.
- GifPicker records every sent GIF (from search or the Recent row) to the
front, and renders a 3-up thumbnail grid of recents above the search
grid when there are recents and no active search term. Section label
matches the picker's existing `// GIF_SEARCH` treatment (lotusTerminal)
or a muted label otherwise.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 2 review agents on the room-nav draft indicator:
- Icon semantics (a11y): Icons.Message read as message activity and
collided with two existing bubble uses in the same row (call-chat
toggle, mark-unread), especially next to the unread badge. Replace it
with the composer's shared DraftDot (a small color.Success.Main dot),
so both draft surfaces share one visual language. Rendered as a
role="img" span with aria-label "Unsent draft" (reliably announced,
unlike a bare aria-labelled svg).
- Precise thread-key filter: hydration skipped any draftKey containing
'::', which would also skip an IPv6-literal server name in a roomId.
Match '::$' (thread root is an event id) so only real thread drafts are
skipped.
- Defensive hasMsgDraft: guard toPlainText so a corrupted/foreign draft
value can't throw during a nav render.
- Clear the draft atom on send: the send / scheduled-send handlers reset
the editor and localStorage but left the jotai draft atom set, so the
composer DraftIndicator could show a stale dot after sending a restored
draft. Add setMsgDraft([]) to both.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Composer drafts persist per room, but nothing in the room list showed
which OTHER rooms had an unsent draft. Add a subtle chat-bubble icon on a
room's nav item when it has a message draft (and isn't the open room), so
half-written messages elsewhere are visible at a glance.
- Shared pure helper hasMsgDraft (utils/draft.ts, unit-tested) replaces
the inline emptiness check; the composer DraftIndicator now reuses it.
- RoomNavItem reads a memoized selectAtom(draftAtom, hasMsgDraft) so a row
re-renders only when its draft flag flips (the draft atom is written on
room-leave, not per keystroke). Uses Icons.Message (pencil is reserved
for the custom-name marker), muted, aria-label "Unsent draft".
- useHydrateMsgDrafts (mounted in ClientNonUIFeatures) pre-fills the
per-room draft atoms from draft-msg-* localStorage on startup, so
indicators are correct after a page reload, not only after revisiting a
room. Thread drafts (key contains ::) are skipped; room-level only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 2 review agents on the edit-history diff:
- Perf: diffWords is O(n*m); cap at 2000 tokens/side and fall back to a
coarse whole-block replaced diff above that, so a very large multi-edit
message can't freeze the main thread. Memoize the per-row diff in
DiffText. (Added a unit test for the coarse fallback.)
- Perceivability (a11y/design): the added-word <ins> highlight was
color-fill only, which is faint against the modal surface in the lotus
themes. Add a Success.ContainerLine border + horizontal padding (so the
rounded corners read as a chip) + box-decoration-break: clone for clean
wrapping, so the "added" cue survives low fill contrast.
- Consistency: a media/no-body edit now renders "(no text)" in diff mode
too (matched the toggle-off view; was blank).
- Softened the code comment's screen-reader claim (bare <ins>/<del> aren't
announced by default).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Edit History modal listed each version's full text with no indication
of what changed. Add a word-level diff: each edit highlights the words
added (green) and removed (struck-through red) relative to the previous
version, so a one-word fix is obvious at a glance.
- New pure, dependency-free diffWords (LCS over word/whitespace tokens) in
utils/textDiff.ts, with 8 unit tests (insert/delete/replace, whitespace
preserved, empty, no-mutation, word-not-char granularity).
- EditHistoryModal renders each edit via a DiffText component using
semantic <ins>/<del> (screen-reader-meaningful) styled with folds
Success/Critical tokens. A "Highlight changes" header toggle (default
on) switches back to full text, which keeps the rich formatted render;
the Original row is always the plain baseline.
- Diff is plain-text (body) only by design; formatted markup isn't diffed
(the toggle restores the rich view), and media/no-body edits diff as
empty strings gracefully.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 3 review agents on the Threads list panel:
- Last-activity accuracy (SDK): sort key and the "last reply <time>"
label now use thread.replyToEvent.getTs() (server bundle latest_event)
instead of lastReply(), which returns the ROOT time until each thread's
replies lazily paginate (or permanently on fetch error). Applied to the
hook signature too.
- Live-refresh completeness (correctness): the useRoomThreads signature
now includes thread.length and the root event's replacingEventId, so a
mid-thread redaction (reply count) and a root-message edit (row snippet)
refresh the row live instead of going stale.
- a11y: the row's aria-label was the button's whole accessible name,
hiding the snippet/count/unread from screen readers. It now describes
the thread ("Open thread by <name>, unread, N replies, last reply ..").
- Unread badge: replaced the bare green dot (Success = the mention color)
with the app-wide UnreadBadge, using the Highlight count so mentions
render red and ordinary unread renders secondary, matching room-nav.
- Hover/focus affordance: the clickable row moved its inline styles to a
css class with token-based :hover / :active backgrounds.
- Participant pile now also includes the last replier from the bundle.
- Stabilized the panel's onClose/onOpenThread with useCallback so its
Escape listener isn't re-subscribed every Room render. Added
filter->sort pipeline + all/participating immutability tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lotus could view one thread at a time but had no overview of a room's
threads. Add a Threads list side panel, opened from a new Threads toggle
in the room header (mirrors the gallery/widgets toggles).
- Lists every thread with a rich row: root sender + snippet, unread dot,
"N replies - last reply <time>", and a participant avatar pile.
- Segmented filter (All / Unread / Participating) and sort (Recent /
Oldest by last-reply time), both persisted in localStorage
(cinny_threads_filter_v1 / cinny_threads_sort_v1) and normalized via
type guards.
- Clicking a row opens the existing single-thread ThreadPanel by reusing
setActiveThreadId; reading it clears the row's unread badge live.
- Stays live via ThreadEvent.New/NewReply/Update/Delete +
RoomEvent.UnreadNotifications, with a signature guard to avoid churn,
and is virtualized (@tanstack/react-virtual) for busy rooms.
Reuses room.getThreads()/fetchRoomThreads(), thread.hasCurrentUser-
Participated / lastReply() / length, getThreadUnreadNotificationCount
(muted threads zeroed), useMemberAvatar/StackedAvatar/UserAvatar,
scaleSystemEmoji/trimReplyFromBody, UnreadBadge, and the Bookmarks-panel
segmented-control + localStorage-atom patterns. Filter/sort logic is pure
in utils/threadList.ts with 8 unit tests. New panel is wired into
Room.tsx's mutually-exclusive content-panel switching.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 2 review agents on the forward upgrades:
- Duplicate comment on retry (correctness): if a room's comment message
sent but the forward then failed, retrying re-posted the comment. Track
rooms whose comment already delivered (commentSentRef) and skip it on
retry, sending only the missing forward. An already-commented room won't
get the comment again even if the text is later edited (no-duplicate
choice).
- a11y: give the message-preview box role="group" + aria-label
("Message to forward"), add aria-label to the comment and search inputs
(placeholder is not a label), and match the RecentChip's RoomIcon
fallback size (100) to the room-row convention for a size-200 avatar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Forward dialog forwarded blind. Add three things (design-system
cleanup included):
- Preview: a compact read-only preview at the top of the dialog shows the
sender + body, with a thumbnail for image/video (reuses ThumbnailContent
and the getMemberName/getMemberAvatarMxc/trimReplyFromBody helpers). We
already hold mEvent, so nothing is fetched.
- Comment: an optional "Add a comment" field sends a short m.text note to
each target room, sequenced BEFORE the forwarded message per room so the
note reads above the quoted content. The existing per-room failure /
retry logic is preserved (a room fails if either send rejects).
- Recent targets: a "Recent" chip row (hidden while searching) offers
one-tap selection of rooms you last forwarded to. Successful targets are
recorded most-recent-first, deduped, capped at 8, in localStorage via the
pure, unit-tested addRecentForwardTarget (state/recentForwardTargets.ts).
Rooms you've since left are filtered out of the row.
Also replaces the hardcoded rgba(0,0,0,0.35) sending scrim with a
token-free opacity dim of the list (design-system rule: no hardcoded
colors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Selecting an emoji (or any unsaved edit) in the profile Status Message
field could vanish because the presence-sync effect re-applied the remote
status_msg on every presence heartbeat (which fire every few seconds).
The dirty-edit guard alone left a window where a heartbeat carrying the
previous status overwrote the just-inserted emoji, so there was no way to
add emoji to a status.
Track the last remote status we synced and only react when the remote
value actually changes, instead of on every heartbeat. Repeated
heartbeats with an unchanged status are now ignored, so an unsaved local
edit is preserved regardless of the dirty flag's timing. Cross-device
status changes (a genuinely new remote value) and clears still sync, and
the pending-applied stale-echo guard is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two console-noise / glitch fixes surfaced while a room with link
previews was open:
- URL preview og:image thumbnails 400 when Synapse can't thumbnail a
cached preview image (SVG/animated), leaving a broken image that the
browser keeps re-requesting. GenericCard now falls back to the full
image on error, then hides the image (and shows the link icon) if that
also fails, so no broken image and no repeated failing requests.
- Extend the existing console.warn filter to drop matrix-js-sdk's
high-volume, benign timeline bookkeeping warnings ("EventTimelineSet…"
and "Decrypted event … is not in room …"), which fire constantly in
E2EE rooms with threads. Real warnings still log.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 2 review agents on the status-presets feature:
- Presence-echo race: a heartbeat can echo the previous status just after
a new one is saved, reverting the input. Track the last-applied value
and ignore non-matching echoes until our own echo lands or a 15s window
elapses (bounded so a dropped echo can't block real cross-device
updates). Applies to Save, preset apply, and Clear.
- Duplicate chips: a saved custom preset that matches a built-in is now
hidden from "Your presets" (it already shows under Quick statuses).
- a11y: the two preset rows use aria-labelledby tied to their visible
headings instead of mismatched hardcoded aria-labels.
- Visual grouping: a custom preset's chip and its delete X now sit with
gap=0 as one unit while the row separates presets with gap=200, so a
chip and its delete no longer read as two separate presets. Delete/Plus
icons bumped to size=100 to match the folds chip-icon convention.
- Parity: addPreset/removePreset promises are now caught like the other
account-data call sites.
Docs updated to the exact 11-preset built-in list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Status Message field required typing every status from scratch. Add
a one-click preset row:
- Built-in "Quick statuses" spanning gaming, social, life and work
(Gaming, In a party, Ranked grind, AFK, Watching, In a meeting,
Working remotely, Lunch, On vacation, Out sick...), each carrying a
suggested auto-clear so a click sets the message and the timer at once.
- Custom presets: save the current status as a reusable preset, stored
in io.lotus.status_presets account data (synced across devices via the
shared account-data list store), de-duped by normalized label, capped
at 20, deletable inline.
The existing save path is factored into a shared applyStatus() used by
the Save button and by preset apply, so server writes, the status
localStorage keys, and the auto-clear expiry bookkeeping stay identical.
Ordering/de-dupe logic is pure in utils/statusPresets.ts (upsertPreset,
normalizeLabel) with unit tests; no change to the presence wire format,
expiry monitor, or presence-mode selector.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 2 review agents on the bookmark sort/group feature:
- Flash on open: the persisted-sort atom now uses getOnInit so the saved
sort applies on the first render instead of briefly showing Newest and
reordering after mount.
- Stale collapse state: prune collapsed roomIds that no longer have any
bookmark, so a room re-saved later doesn't reappear pre-collapsed and
the Set can't grow unbounded across a session.
- Corrupt persisted value: validate the stored sort with a new
isBookmarkSort type guard, normalizing anything unexpected to Newest so
exactly one sort button is always active.
- a11y: room group headers now expose an explicit aria-label
("<room>, N saved messages") instead of announcing the avatar alt and
the visible name twice with a bare count, plus aria-controls linking the
header to its collapsible content region.
- Layout: move the sort control to its own toolbar row so the three
buttons don't crowd the count text in the narrow (266px) panel.
- Memoize filtered/sortedItems/groups for consistency with renderItem.
Adds unit tests for isBookmarkSort, group-order tie-break, and
groupBookmarksByRoom immutability.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Saved Messages panel showed bookmarks in one fixed order (newest
save first) with no way to reorganize. Add a Newest / Oldest / By-room
segmented sort control to the panel toolbar. In "By room" mode the list
renders collapsible per-room sections, with groups ordered by their most
recently saved message so active rooms float to the top. The chosen sort
persists across panel opens via a localStorage-backed atom.
Ordering and grouping are pure functions in utils/bookmarks.ts
(sortBookmarks, groupBookmarksByRoom) with deterministic eventId
tie-breaks, covered by bookmarks.test.ts (9 tests). No change to the
bookmark data model, account-data schema, useBookmarks, or how bookmarks
are created; search still feeds the sorter/grouper unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 3 review agents on the edit/reschedule feature:
- Race (correctness): handleEdit now marks the old message as cancelling
while its cancel is in flight, so its Edit/Cancel buttons are disabled.
Previously the old row stayed live during the fire-and-forget cancel, so
a fast second edit could orphan a still-scheduled event and send twice.
- Durability (correctness): on a failed cancel-old, re-insert the old
message if auto-prune removed its row while the modal was open, so the
still-live delayed event stays visible and retriable instead of failing
silently. Also clear any stale cancel error when starting an edit.
- a11y: per-row Edit/Cancel buttons now carry distinct aria-labels that
include the message preview and send-time, so screen-reader users can
tell which of several scheduled messages each button targets.
- UX: modal gains a submitLabel prop; the edit flow shows "Reschedule"
instead of "Schedule". Modal now focuses the message body on open.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The scheduled-messages tray was cancel-only. Add an inline edit button
that re-opens ScheduleMessageModal seeded with the existing body and
send-time, letting the user change the text and/or when it sends.
MSC4140 has no in-place edit, so an edit is schedule-new + cancel-old.
Order matters: the modal schedules the new delayed event first, then we
cancel the old one and only prune it from local state once the server
confirms. A failed cancel therefore leaves a visible, retriable copy in
the tray instead of silently letting the stale message fire or losing
the edit. Edits go through the plain-text composer, so rich content
collapses to m.text (acceptable for v1).
ScheduleMessageModal gains optional initialSendAt (seed the pickers) and
title props so it is reusable for both scheduling and editing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three review agents (no regressions found). Applied:
- Keyboard arrow-seek now reads the live media currentTime, not the throttled
~500ms state, so rapid presses accumulate instead of dropping steps.
- Scrubbing the waveform (or the fallback seek bar) BEFORE first play now loads
the media and plays from the clicked position (was a silent no-op).
- Unplayed bars use a dimmed accent (color-mix 32%) instead of a faint surface
token, for consistent contrast across TDS-dark/light + normal themes.
- Fixed first-bar always-lit off-by-one ((i+1)/len), and added overflow:hidden so
the strip clips rather than overflows in a very narrow drawer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Voice messages carry an MSC1767 waveform (org.matrix.msc1767.audio.waveform) and the
recorder draws a live one, but AudioContent playback only showed a plain seek bar.
Now the player renders the waveform as bars that fill with the accent (TDS green under
Lotus Terminal) as the clip plays, and the waveform itself is the seek control —
click, drag, or keyboard (arrows +/-5s, Home/End) with role=slider + ARIA value text.
- AudioContent: new optional "waveform" prop + a WaveformSeek sub-component
(downsamples to 48 bars, mirrors the recorder's bar styling); falls back to the
plain Range seek bar when there's no waveform.
- Threaded through MAudio (RenderAudioContentProps) so timeline voice messages get it
automatically; the Media Gallery Audio tab passes it directly.
Improves both the timeline and the new gallery Audio tab at once.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review of 28cb004e (3 agents) surfaced two real issues:
- Downloads used a raw mxc→http anchor, so encrypted media (voice messages are
almost always in E2EE DMs) downloaded ciphertext. Reuse FileDownloadButton for
both the audio AND file rows — it decrypts before saving, adds loading/success
state + a toast, and derives a filename extension.
- Audio playback bypassed MAudio's mimetype sanitization; pass the value through
getBlobSafeMimeType (e.g. application/ogg → audio/ogg) so encrypted/odd-mimetype
audio actually plays. Give voice messages a real filename+extension.
Also corrected the docs: AudioContent renders a seek bar, not an MSC3245 waveform.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- New Audio tab (m.audio) listing voice messages + audio files with an inline player
(reuses AudioContent + MediaControl: play/seek/speed, MSC3245 voice waveform,
decrypt-on-play), sender/date, and download. Added to the tab counts.
- Jump-to-message: a 'Go to message' action on file rows, audio rows, and in the
lightbox navigates the timeline to the source event (useRoomNavigate) and closes
the drawer; threaded eventId into LightboxItem.
- Update the (stale) LOTUS_FEATURES.md Media Gallery entry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Upgrade the MSC3381 poll feature from a leaky half-implementation to a complete,
cross-client-correct one:
- End/close a poll (creator or redact-PL mod) via inline confirm → m.poll.end;
locks voting, reveals results, marks winner(s); only pre-end responses count.
- Honor poll kind: undisclosed polls hide counts/percent/bars/total until ended
(creator gets a Show-live-results vs Hidden-until-ended toggle; default live).
Previously every poll was created undisclosed yet the UI leaked live results.
- Enforce max_selections for multi-choice; radiogroup/checkbox a11y with arrow-key
roving and an AT-announced winner.
- Robust, dual-namespace wire handling: parse BOTH stable (m.poll/m.id/m.selections)
and unstable (org.matrix.msc3381.poll.*) by hand — matrix-js-sdk 41.7.0's
PollStart/Response parsers only understand the unstable bodies, so delegating to
them broke every stable poll (caught in agent review). Use the SDK Poll model only
for end validation + before-end filtering.
- Pure tally/visibility/winner/parse logic extracted to utils/poll.ts with 14 tests
incl. a stable/unstable wire-format round-trip.
Reviewed by 3 agents (spec/cross-client, logic, a11y/UI); findings applied.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The inline video embed container (EmbedMediaLandscape/Portrait) used CSS
aspect-ratio for its 16:9 / 9:16 box. An absolutely-positioned *replaced* element
— the player <iframe> (position:absolute; inset:0; width/height:100%) — collapses
to its ~200px intrinsic size inside an aspect-ratio box rather than filling it, so
after pressing play the YouTube/Vimeo player rendered at ~202x114 in the top-left of
the (correct) 600x340 facade box, leaving a wide gray gap. The click-to-play
facade uses an <img>, which doesn't hit this, so the pre-play preview looked fine.
Switch the video containers to the padding-top percentage hack (56.25% / 177.78%),
which derives a definite height from the definite width so the absolutely-positioned
iframe fills it reliably. Fullscreen override sets padding-top:0 + height:100vh.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three agents swept fresh lenses (performance, security/privacy, correctness in
under-covered subsystems). 16 verified items filed: PERF-1..6, SEC-1..5 (no
exploitable XSS found — sanitization surface is hardened), COR-1..6. Single-pass,
not yet TPVR'd.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The discovery-pass items DP1-DP18 are implemented + TPVR-reviewed, so move their
manual QA steps into LOTUS_TESTING.md (new section R) and replace the DP backlog
block in LOTUS_TODO.md with a done-breadcrumb pointing at §R + the commits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- DP2: the earlier seed fix was ineffective (allInvitesAtom populates post-mount,
so first render is still empty). Rewrite to track invite room ids and stay
'unarmed' until the initial sync settles (+3s grace), notifying only for ids that
first appear after arming — robust to the async population race.
- DP4: batch the mutually-exclusive tag writes via Promise.all so a failure surfaces
a single toast instead of one per operation.
- DP15: route the two remaining StateEvent writes (RoomSoundboardPack / RoomImagePack,
which used an 'as unknown as keyof StateEvents' idiom the sweep missed) through the
typed sendStateEvent helper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace literal-glyph UI icons in the URL preview cards with folds
Icon/Icons components, and hoist hardcoded provider brand hex values
into a single named BRAND_COLORS map.
Glyph -> folds icon:
- TikTok/Spotify musical note (♫) -> Icons.VolumeHigh
- Portrait play button (▶) -> Icons.Play
- Reddit comment count (💬) -> Icons.Message
- Steam gear (⚙) -> Icons.Setting
All use size="Inherit" so they keep the surrounding font-size/color.
Brand hex now referenced via BRAND_COLORS (tiktok, spotify, steam,
twitch, reddit, discord, npm, stackOverflow) instead of scattered
literals; colors are byte-for-byte identical and remain fixed brand
identities (not converted to TDS theme vars).
The game controller glyph (🎮) is left as-is: folds has no game/
controller icon and no close semantic match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a typed `sendStateEvent(mx, roomId, eventType, content, stateKey?)`
helper in utils/room.ts that mirrors the DP16 account-data helper pattern.
The SDK's typed `sendStateEvent` overload rejects the fork's custom
`StateEvent` enum values, so every call site cast arg 2 to `any` (which
also collapsed the content type). The single `as any` cast now lives inside
the helper; a generic `content: T extends object` keeps each call site's
content type checked.
Route all 32 `mx.sendStateEvent(..., StateEvent.X as any, ...)` casts across
20 files through the helper. The 2 dynamic-string casts in developer-tools
(SendRoomEvent, StateEventEditor) pass a runtime string, not an enum value,
so they stay as-is.
No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
useBookmarks / useReminders / useUserNotes were near-verbatim copies of a
concurrency-critical engine (module-scoped singleton, per-client
subscribe/teardown, a serialized write-queue that prevents lost-update
clobbering, a listener Set, and the account-data subscription).
Extract it into createAccountDataListStore<T, C>({ eventType, read, write }) in
src/app/hooks/createAccountDataListStore.ts. The write-serialization semantics
are preserved identically (still the lost-update fix). The differing payload
shapes are parameterized via read/write: bookmarks/reminders wrap a list
({bookmarks}/{reminders}); notes is a flat Record passed through unchanged.
The three hooks become thin wrappers with their exact public APIs unchanged
(same exported names, signatures, return shapes, and mutators), so no call site
changes. The DP16 setAccountData helper is used inside the queue.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add centralized typed helpers in src/app/utils/accountData.ts:
- getAccountData<T>(mx, eventType): T | undefined (returns content)
- setAccountData<T>(mx, eventType, content): Promise<void>
These wrap the single `as any` cast needed because matrix-js-sdk's typed
overloads reject the fork's custom account-data event names. Every call site
now stays fully typed on its content shape.
Route all account-data reads/writes that previously used
`(mx as any).getAccountData/setAccountData` or `mx.getAccountData(... as any)`
through the helpers (or, where a MatrixEvent is needed, through the existing
utils/room.ts getAccountData whose param is widened to accept string keys).
No behavior change: same event types, same content shapes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a reactive `useMemberAvatar(room, userId, w?, h?, resize?)` hook
returning { name, avatarUrl }, standardizing the member name + avatar
trio and the RoomStateEvent.Members reactivity pattern (N6). Convert
ReadReceiptAvatars and EventReaders to render per-user avatars via a
small child component using the hook, preserving exact sizes, fallback
rendering, TDS pill/tooltip styling and behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a pure `getMemberName(room, userId): string` helper in utils/room.ts
(= getMemberDisplayName ?? getMxIdLocalPart ?? userId) and replace the
inline `getMemberDisplayName(room, id) ?? getMxIdLocalPart(id) ?? id`
fallback across the codebase. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DP14: Message send-status icon and ReadReceiptAvatars pill hardcoded
dark-theme accent hex/rgba, so TDS light mode kept bright cyan/red
instead of the theme-overridden darker values. Route all colors/glows
through the theme-aware --lt-* CSS variables (mirroring EventReaders),
using color-mix for translucent tints and accent-alpha icon colors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DP7: fix inverted Deafen/Undeafen aria-label on the sound button so it
matches the action and tooltip.
DP8: add aria-pressed to Sound/Video/ScreenShare toggles and align the
Microphone toggle to the same "pressed = feature on/active" semantic.
DP9: return focus to the trigger when the GifPicker closes and cap its
fixed width to min(312px, calc(100vw - 16px)) so it can't overflow
narrow viewports.
DP10: drop redundant mouse-only clear onClick nested inside the search
filter Chip buttons (parent chip/menu already performs the clear);
the cross icons are now purely decorative.
DP11: constrain the voice recorder widget and let the waveform shrink so
it fits a narrow composer, and expose the live duration as a role="timer"
snapshot instead of spamming a screen reader every 100ms.
DP12: announce the live-call participant count via a visually-hidden
role="status" region.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Invites: seed the notify baseline with the invite count at mount instead
of 0, so a warm reload (allInvitesAtom populates synchronously from cache
while SYNCING) no longer re-fires the toast+sound for pre-existing invites.
Only a genuine increase notifies.
- Status: the cross-device sync effect gated on a truthy presence.status, so
a remote CLEAR never reset the input or localStorage[STATUS_MSG_KEY] and
usePresenceUpdater.readStatus() re-sent the stale status. Now mirror an
empty status as a clear (skipping offline/invisible, which carries an empty
status_msg by design).
- Soundboard: useState initializers ran once and never recomputed when the
room/rooms arg changed. Add a resync effect keyed on the arg while keeping
the live state-event update path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Several fire-and-forget promises failed silently:
- RoomInput slash-command exe() rejections reset the editor as if they
succeeded; now caught and shown via an error toast.
- Favourite / low-priority room-tag toggles (setRoomTag/deleteRoomTag)
swallowed rejections; now surfaced via the same error toast.
- Call-decline sendEvent(RTCDecline) was uncaught; now logged best-effort
while the local UI still dismisses.
Adds a shared createErrorToast builder mirroring createDownloadToast.
/kick and /ban route through rateLimitedActions, whose to() helper
swallows non-429 errors, so those two can still resolve on failure — noted
in code; the top-level exe() catch covers everything that does reject.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three discovery agents swept src/ for correctness/a11y/tech-debt issues beyond the
tracked backlog; a separate TPVR pass independently confirmed all 18 (5 refined to
partial with count/scope corrections). Filed as DP1-DP18 under Open - Actionable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The room-name (settings + create-room) and display-name inputs had no client-side
length guard, so an over-long value only failed after a server round-trip. Add
maxLength={255} (matching the existing inline-rename cap in RoomNavItem; under
Synapse's 256 max_displayname_length). Also clamp the emoji-picker prepend in the
two room-name fields, since programmatic setState isn't constrained by the DOM
maxLength and could otherwise push past the cap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ReadReceiptAvatars read avatar + display-name from room member state at render
time, so they only refreshed when the parent Message re-rendered (on a receipt
change). A reader changing their avatar/display-name left a stale avatar/name
until an unrelated re-render. Subscribe to RoomStateEvent.Members (the only signal
that fires for name, avatar AND membership changes — RoomMemberEvent has no Avatar
event) scoped to the displayed readers, and force a re-render so the pill updates
live. Listener is cleaned up; userIds identity is stable (memoized in the read-
positions map) so no subscribe churn.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
useAvatarDecoration GET /profile/{user}/io.lotus.avatar_decoration returns 404 for
every user without a decoration (most users), which the browser logs as a failed
request — a console 404 per member. Fetch the whole profile (GET /profile/{user},
200 with all MSC4133 fields) and read the decoration field out of it instead. Same
negative-caching behavior; no functional change, just no 404 storm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The static-copy step landed the android icons at dist/public/android/ (stripBase
dropped the res/ segment), never copied public/res/apple/ or public/favicon.ico at
all, and the manifest pointed at /res/android/ — so every PWA icon, apple-touch
icon, og:image, and the favicon 404'd on the live server.
Copy all of public/res -> dist/public/res and public/favicon.ico -> dist/public/,
and point manifest.json icons at ./public/res/android/ — matching the /public/res
and /public/favicon.ico paths index.html already uses. Verified in dist/.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The IndexedDB crypto store is evictable while the localStorage session survives,
so the browser can drop it out from under a live login -> the device resurrects
with a blank key store and re-uploads a one-time key at an id Synapse already
holds -> a permanent '400 M_UNKNOWN: One time key ... already exists' upload
storm (and undecryptable to-device/media keys downstream).
initClient now calls navigator.storage.persist() before creating the crypto
store, so the origin's storage is marked persistent and won't be evicted.
Best-effort (granted by engagement/PWA-install, no prompt; denial is non-fatal).
Preventive only -- an already-diverged device still needs a clean re-login.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
embed.reddit.com only renders the /r/<sub>/comments/<id> path (verified against
reddit's embed widgets.js + curl: frame-ancestors * , no X-Frame-Options, so
iframing itself is fine). But getRedditPostEmbed built a bare
embed.reddit.com/comments/<id> for redd.it short links — which serves a 'not
found' page — and the i. host strip routed i.redd.it/*.jpg image links into the
same branch. Returning a (broken) URL also suppressed renderContent's og:url
fallback that would resolve the short link to its canonical /r/<sub>/... form.
Fix: getRedditPostEmbed returns null for any non-reddit.com host, so redd.it /
i.redd.it / v.redd.it fall through to the og:url fallback (working embed or a
normal preview card, never a blank 'not found' iframe). The reddit.com post path
is unchanged. Tests updated (22 pass).
Note: the live 'broken' symptom is mostly the deploy gap — live still runs the
old www.redditmedia.com embed code while the live CSP only allows
embed.reddit.com; deploying the current lotus branch (which emits
embed.reddit.com) resolves it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two review agents found the preview card degrades to 'id + logo + Join' for real
reasons. Fixes:
- **via was dropped at the summary fetch** (root cause). RoomSummaryLoader now
accepts + forwards `via` to getRoomSummary (and keys the query on it);
JoinBeforeNavigate passes viaServers; the lobby Preview chip carries data-via and
Lobby.handleOpenRoom appends it to the navigated URL. Without this, previews of
rooms the HS isn't already in came back sparse/404.
- **No loading/error state** -> RoomSummaryLoader now surfaces {loading,error};
JoinBeforeNavigate shows a Spinner while loading instead of the degraded card.
- **Room id leaked as name AND topic AND header** -> stop using the raw `!id` as
the topic fallback (show 'No description'); name falls back to canonical_alias
then alias-localpart; the page header shows the summary name.
- **Richer, membership-aware card**: forward canonical_alias (shown under the name),
world_readable ('Readable' badge), and membership -> the button now shows Accept
invite / Requested / Banned correctly instead of a Join that lies; a 'hero' layout
for the single-preview page (primary Join button, unclamped topic). Removed dead
`|| undefined ||`.
IRoomSummary re-adds canonical_alias (the SDK type Omit<>s it though MSC3266
returns it). 738 tests pass, build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the room-preview work so you can actually reach a preview from the
room lists, not just via a matrix.to link:
- Space lobby (RoomItem): un-joined child rooms now show an Eye 'Preview' chip
next to Join. It routes through the existing onOpen (data-room-id -> space/room
path), which renders the full JoinBeforeNavigate preview card because the room
isn't joined.
- Explore grids (Server + Featured): pass join_rule (and encryption, where the
summary provides it) to RoomCard, so the directory/featured cards — which already
are full preview cards — now show the join-rule chip and a Request-to-join button
for knock rooms instead of a Join that fails.
738 tests pass, build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Room preview (JoinBeforeNavigate -> RoomCard via getRoomSummary) was already
built and, verified after the Synapse 1.156 upgrade, works via the SDK's unstable
im.nheko.summary endpoint (the old 'blocked' flag tested the wrong /v1 path).
Polish the preview card with the summary fields the endpoint returns:
- join-rule chip (Restricted / Ask to join / Invite only / Private; public shows
none) + an Encrypted badge (from im.nheko.summary.encryption).
- knock-rule rooms now show a 'Request to join' button (mx.knockRoom) instead of a
plain Join that would fail — mirrors the RoomIntro knock flow.
Props are optional so other RoomCard usages are unaffected. LOTUS_TODO updated:
Room Preview BLOCKED -> done; Synapse 1.155 -> 1.156.0. 738 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Math rendering already shipped (both the $…$ shorthand and the spec
data-mx-maths form render incoming). But the composer emitted no math, so a
Lotus user's $E=mc^2$ went out as raw text — it rendered on Lotus (both sides
parse $…$) but showed as literal text on Element and other clients.
toMatrixCustomHTML now converts $…$/$$…$$ to
<span|div data-mx-maths="LATEX"><code>LATEX</code></span|div>
(spec CS-API §11.5), reusing the existing splitMathSegments parser. Math is
extracted BEFORE markdown so LaTeX (_, *, \, {}) isn't mangled, and the emitted
span survives the block-markdown pass via the existing ignoreHTMLParseInlineMD
HTML-tag guard. A new allowMath opt threads through the top-level call sites;
code-line/code-block paths use empty opts so math is off inside code. The plain
body keeps literal $…$ as the fallback.
Scope: inline $…$ + single-line $$…$$. Multi-line block $$ (spans editor
paragraph nodes) deferred — still renders on Lotus via the plain-body path.
New output.test.ts (8 cases): span/div emission, escaping, markdown-bypass,
currency non-match, code-mark + code-block exclusion. 738 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In-room verification requests arrive as m.room.message with
msgtype 'm.key.verification.request', so isNotificationEvent() counted them
(type is m.room.message). A stale/old request at the tail of a DM therefore
re-lit the room's unread dot — and could fire a toast/OS notification — on every
fresh sync (app update / CTRL+F5 cache clear); opening the room only cleared it
via the local read-receipt echo, so it returned on the next reload.
Exclude that msgtype from isNotificationEvent so verification control messages
never drive unread/notifications (mirrors the existing member/redaction/edit
exclusions). The rest of the verification flow already uses distinct event types
that aren't notification events.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From the coverage-review agent's ranked recommendations (clean iframes, ids in
the URL, one CSP host each):
- Bluesky: bsky.app/profile/{authority}/post/{rkey} → embed.bsky.app (rich, self-
resizing like the other post embeds).
- Loom: loom.com/share|embed/{id} → www.loom.com/embed/{id} (16:9).
- Kick: kick.com/{channel} → player.kick.com/{channel} (live channels only; VODs/
clips have no clean embed and fall back to a link).
Parsers unit-tested. CSP frame-src gains embed.bsky.app / www.loom.com /
player.kick.com (desktop + live web, both updated). Needs live verification once
deployed since the embeds themselves can't be exercised from here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From the quality-review agents:
- Revert on.soundcloud.com support: the w.soundcloud widget doesn't follow the
redirect (needs an oEmbed resolve, deferred).
- Add a Close button to playing video/TikTok embeds and a Collapse button to the
expanded X post — playback was previously one-way (only escapable by scrolling).
- focus-visible ring on the embed facade (folds resets outline:none, leaving
keyboard users with no indicator).
- Only subscribe to resize postMessages while the iframe is mounted (was attaching
a global listener per Instagram/Reddit facade before play).
- TikTok oEmbed fetch now uses AbortController (abort on unmount) + aria-busy /
'Loading…' label on the resolving spinner.
- Decorative facade thumbnails use alt="" (parent already names them); drop the
dangling-colon aria-labels when there's no title.
- Cap URL previews at 6 per message so a link-dump can't spawn dozens of fetches.
Tests 728. No CSP change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- TikTok player URL trimmed to ?autoplay=1&rel=0 (the control params were all
default-on no-ops).
- SoundCloud on.soundcloud.com share short links now embed (widget follows the
redirect via w.soundcloud.com).
- redd.it short links now embed via embed.reddit.com/comments/<id>/ (the redirect
target is reddit.com/comments/<id>, no subreddit needed).
No CSP change (hosts already allowlisted). Vimeo event/ondemand deferred — the
embed format/host couldn't be verified from a fake id and would need a CSP
change; leaving it for the review agents to research. Tests 21.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From the second review pass (agents inspected providers' live embed scripts):
- FIX Reddit auto-height (was broken): embed.reddit.com posts
{ type:'resize.embed', data:<height> } — height is under 'data', not 'height',
so it never resized and clipped taller posts (scrolling=no). Add that shape.
- Align TweetEmbed sandbox with EMBED_SANDBOX (adds allow-popups-to-escape-sandbox)
so links/login popups opened from inside a tweet aren't crippled.
- Vimeo: resolve channel/group/album video forms (vimeo.com/channels/{n}/{id} etc.),
not just paths starting with the id.
- Mobile Shorts: m.youtube.com/shorts/{id} now renders portrait 9:16, not landscape.
- Move extractEmbedHeight into videoEmbed.ts and unit-test all three resize shapes
(Instagram MEASURE / Reddit resize.embed / Twitter twttr.private.resize).
Agents confirmed everything else current & robust (Dailymotion geo host, Tidal
gridify, sandbox tokens are a safe superset, X still on platform.twitter.com,
550px cap). Tests 21 in this suite.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From the review-agent audit:
- Dailymotion: move off the Sept-2024-deprecated /embed/video path to
geo.dailymotion.com/player.html.
- Reddit: point at embed.reddit.com (www.redditmedia.com now 301s there).
- Vimeo: parse the unlisted hash (vimeo.com/{id}/{hash}) and pass &h=…, add dnt=1.
- Tidal: layout=gridify + ~275px height for albums/playlists (fixes narrow player).
- YouTube/Shorts: playsinline=1 (iOS keeps playback inline); parse /live/ +
music.youtube.com.
- Apple Music: /music-video/ renders 16:9 instead of a fixed audio height.
- Re-add a minimal sandbox to all media iframes (omits allow-top-navigation →
blocks phishing redirects) — defense-in-depth atop the CSP frame-src allowlist.
- Self-resize Instagram + Reddit post embeds via a shared useIframeAutoHeight hook
(also now covers the Tweet embed; matches platform.x.com origin too); drop the
fixed 720/480 heights. Cap tweet/post columns at ~550px, centered.
Also from user feedback: TikTok portrait player dropped music_info/description,
which forced TikTok's wide 'video + info panel' layout and left empty space
beside the video — now a clean 9:16 player that fills the box.
Tests 726 pass. CSP frame-src gains embed.reddit.com (separate desktop commit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TikTok 'copy-link' share URLs (vm.tiktok.com, tiktok.com/t/…) carry no video id
and the homeserver's link preview is bot-walled (generic 'TikTok - Make Your
Day', no og:url/og:image), so they fell through to the static fallback card with
no play button.
New TikTokEmbedCard resolves the id client-side via TikTok's CORS-enabled oEmbed
API on click (keeps the facade privacy model), then plays the player/v1 embed
(portrait, autoplay + full controls + our fullscreen button). Canonical
/video/<id> links skip the lookup. Also added a general og:url fallback so other
short/redirect links resolve to their canonical form when the raw URL doesn't.
Web CSP connect-src gains www.tiktok.com for the oEmbed fetch (desktop already
allows https:). Tests 19/19.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Widen the interactive-embed card to min(38rem, 94vw) so the Tidal player (and
other audio/video embeds) has enough width.
- Hide the title/description caption while a video embed is playing, so the text
below it stops squeezing the player small (Twitch).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Feedback fixes + new platforms:
- Fullscreen: add a universal 'Fullscreen' button on video embeds (requests
fullscreen on the media container) so Shorts/TikTok/etc. can go fullscreen
regardless of each player's own controls. Portrait media enlarged (220->300).
- Drop the iframe sandbox (CSP-allowlisted trusted hosts; sandbox was breaking
player features and likely TikTok).
- Wider, responsive embed/tweet cards (min(34rem,92vw)) so Twitch player chrome
isn't cramped and X posts stop getting clipped.
- Instagram (p/reel/tv), Tidal (track/album/playlist/video), and Reddit posts
now embed. Reddit uses redditmedia.com to bypass the homeserver's blocked
preview (Reddit serves it a bot-check 'please wait for verification' page);
a bot-wall title filter keeps that garbage out of any caption.
New pure parsers unit-tested (videoEmbed.test.ts, 17 cases). Desktop + live web
CSP frame-src updated for instagram.com, embed.tidal.com, redditmedia.com.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
X/Twitter: the static OG card was a snapshot — no video, no galleries/threads.
Keep it as the (reliable) facade and add a 'View post' button that loads the
official platform.twitter.com interactive embed on click: playable video/GIF,
image galleries, quote tweets. The embed self-sizes via a scoped postMessage
resize listener (matched to our iframe + the platform.twitter.com origin). Nothing
loads from X until the user clicks, and the link still works if X blocks the frame.
Apple Music: music.apple.com album/playlist/song links now play inline via the
embed.music.apple.com player (compact for a single song, tall for collections),
through the existing MediaEmbedCard audio path.
Pure parsers/builders unit-tested (13 cases). Desktop CSP frame-src adds
platform.twitter.com + embed.music.apple.com (separate commit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Generalize the inline-embed system: parseMediaEmbed() resolves any supported URL
to an embed spec (provider + kind + embed URL), and a single MediaEmbedCard
renders the media-forward click-to-play facade for all of them — landscape 16:9
(YouTube, Vimeo, Dailymotion, Streamable, Twitch), portrait 9:16 (Shorts, TikTok),
and short fixed-height audio players (Spotify, SoundCloud). Same privacy facade as
before: nothing loads from the third party until the user presses play, gated by
the 'Inline Media Players' setting.
Twitch embeds pass the current page hostname as the required parent param.
Non-embeddable fallbacks (e.g. vm.tiktok.com short links, non-media tweets) keep
their existing rich OG cards; X/Twitter intentionally keeps its rich tweet card.
All parsers/builders are pure + unit-tested (videoEmbed.test.ts, 10 cases).
Desktop Tauri CSP frame-src updated for the new hosts (separate commit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Video link tiles (YouTube, Shorts, Vimeo) now play in place instead of only
opening a browser tab. Adds a media-forward 16:9 (9:16 for Shorts) tile with a
privacy-friendly click-to-play facade: the homeserver's cached og:image thumbnail
+ a play button, and only on click does it swap in the cookie-less
youtube-nocookie / player.vimeo iframe — so nothing loads from Google/Vimeo until
the user presses play. Gated by a new 'Inline Media Players' setting (default on);
when off it falls back to a link that opens the video in a new tab.
Also sources YouTube thumbnails from the homeserver og:image instead of
img.youtube.com, which fixes the existing broken YouTube thumbnails on the web
build (nginx img-src has no YouTube host) and removes the pre-click Google request.
Pure URL parsing + embed-URL building moved to utils/videoEmbed.ts (unit-tested).
Note: the desktop app's Tauri CSP frame-src must allow the video hosts (separate
commit in cinny-desktop).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Declining a remote invite could show a raw 'MatrixError: [500] Internal server
error' and appear to do nothing. Root causes were client-side: decline called
mx.leave unconditionally, so re-clicking after a slow federated leave hit an
already-left remote room that Synapse 500s on; the room was never forgotten so a
'leave' ghost lingered and re-invited a click; and the raw error string was shown.
Add a shared declineInvite(mx, roomId) helper that only leaves when still in the
room (invite/join/knock) and then forgets it (best-effort, first use of forget in
the app). Route the InviteCard decline and both 'Decline All' paths through it,
and replace the raw error with a friendly message (real error kept in console).
Tests: declineInvite covered (6 cases); typecheck + full suite + build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Editor (SoundboardPackEditor): show each clip's length in seconds (stored on
upload via getAudioDurationMs, and captured on preview for existing clips); the
preview button now toggles play/stop with a 'now playing' equalizer indicator;
reworked the volume control into a fixed cell with a % readout so the slider's
max no longer collides with the delete button.
Call soundboard: clip names wrap (up to 3 lines, word-break) instead of being
truncated with an ellipsis; cards grow to fit.
TODO: logged the basic audio-editor / video->audio-extractor as a large project.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audio frequently arrives as m.file (bridges, other clients, or when the browser
reported a non-audio/* mime on upload) and only got a download button. Detect
audio in the m.file branch (by info.mimetype or filename extension) and render
the existing MAudio inline player, falling back to the file card otherwise.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The desktop (Tauri) app has no native download UI, so FileSaver.saveAs saved
files silently — no visual or audio confirmation. Users re-clicked because
nothing said it worked (one report: 5 copies of the same file). Add a small
useSaveFile() hook that saves AND raises a 'Downloaded <filename>' toast, and
route every download call site through it (file attachments, image viewer, PDF
viewer, plus the recovery-key / key-backup exports). The file-message download
button also shows a green check on success.
Toast system extended with an optional iconSrc so system toasts render an icon
instead of an avatar/initials, and an empty roomName is no longer rendered.
Tests: createDownloadToast covered; 701/701 pass; typecheck + build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
handleReceipt recomputed unread from getUnreadNotificationCount, which is
server-computed and stale on the synchronous synthetic receipt echo (the SDK
only zeroes it immediately when the last event is our own message). Reading
someone else's message therefore PUT the stale non-zero count back -> dot stuck
or resurrected on the ack-sync ordering race. Restore upstream cinny's
optimistic DELETE on our own receipt; the UnreadNotifications listener re-asserts
the accurate badge on the server ack.
Also collapse a {total:0,highlight:0} PUT to a DELETE in the reducer (a present
map entry lights the dot via hasUnread=!!unread, so phantom {0,0} PUTs from the
UnreadNotifications listener left stuck dots).
Mark-as-Unread (MSC2867): clear the flag directly in markAsRead (opening an
already-read room sends no receipt, so the receipt-driven auto-clear never
fired), and gate the receipt auto-clear to main/unthreaded receipts so reading
one thread no longer wipes the whole-room flag.
Tests: 700/700 pass; typecheck + prod build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Full-surface protocol survey. Flags each remaining gap by what unblocks it:
buildable now (custom room tags/sections — the only substantive client-only one
left), needs infra (email/3PID invites → identity server; MSC4108/3814), and
blocked-until-Synapse-upgrade (live location 3489/3672, reaction redaction 3892,
room preview 3266, thread subs 4306). Space reordering already works (drag) — not
a gap. Corrected per user.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three research passes concluded ~10% confidence a full rollout wouldn't
break/regress (js-sdk SlidingSync is _internal_/experimental + labs-only at
Element, presence not delivered over sliding sync, no upstream Cinny reference,
and Cinny's nav is built from the full local room set — ~14 subsystems assume
completeness). Server side is GA. Parked; revisit on Rust SDK adoption or large
accounts. Full assessment in the plan history.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase C.1 of the protocol-gaps roadmap, gate-green (693 tests). Generalizes the
Element Call widget host into a general room-widget feature:
- StateEvent.Widget + widgetsPanelAtom + useRoomWidgets (WidgetParser).
- RoomWidgetView: sandboxed-iframe host via ClientWidgetApi with a conservative
GeneralWidgetDriver (approves only benign display caps — no room-event
send/read/to-device). Blocks same-origin widget URLs (sandbox breakout guard).
- WidgetsPanel: list / open / add / remove, PL-gated on im.vector.modular.widgets,
https + non-same-origin URL validation. Mounted like the media gallery (header
toggle + 3-way content-panel exclusivity + mobile full-screen overlay).
- Tested URL/capability/id helpers.
Requires the prod CSP frame-src widening (matrix repo) for external widgets.
v1 cuts (capability consent prompt, Jitsi/sticker types, user widgets) noted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
B2 of the Matrix protocol-gaps roadmap, gate-green (688 tests):
- Enable QR verification methods (show/scan/reciprocate) in initMatrix.
- Extend DeviceVerification: the Ready step offers your own QR (byte-mode encode
via qrcode), a camera 'Scan their QR code' flow, and an emoji fallback; the
Started step routes reciprocate → a confirm step (useVerifierShowReciprocateQr)
or SAS as before.
- New QrScanner component: getUserMedia + jsQR, handing the raw binaryData bytes
to request.scanQRCode (BarcodeDetector is string-only, so can't be used).
- Adds qrcode + jsqr (small, pure-JS, client-only); build-verified under rolldown.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
B1 of the Matrix protocol-gaps roadmap, gate-green (688 tests):
- StateEvent.RoomRetention + a shared utils/retention.ts (presets, isExpired,
getRoomRetentionMs) with tests.
- RoomRetention settings control (PL-gated preset buttons Off/1d/1w/1m) in Room
Settings → General → Message Retention.
- Timeline hides events past the room's max_lifetime (gated behind Show Hidden
Events, like redactions) — messages visually disappear, losslessly.
- Opt-in setting enforceRetentionLocally (default OFF) + a headless
RetentionSweeper that permanently redacts the user's OWN expired messages
(own-only, loaded-timeline scope, dedupe + retry). Nothing auto-deletes unless
the user opts in.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two Matrix protocol gaps (Phase A), gate-green (683 tests):
- Mark as Unread: m.marked_unread room account data (+ com.famedly.marked_unread
fallback), a new markedUnreadAtom binder that seeds from account data and
clears on our own read receipt (MSC2867). RoomNavItem gains Mark as Unread /
Read menu items and lights the row dot for a marked room. Tested.
- Low Priority: m.lowpriority room tag mirroring favourites — a context-menu
toggle (mutually exclusive with Favorite) and a collapsed Low Priority
category sorted to the bottom of the Home room list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Six confirmed client-buildable gaps + server-gated items from a spec/MSC audit:
Mark as Unread (MSC2867), Low Priority rooms (m.lowpriority), Disappearing
Messages (MSC1763), QR Device Verification, Room Widgets (MSC1236), Sliding Sync
(MSC3575/4186). Phased build order.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Removed resolved audit-wave finding tables and shipped-feature narratives (now
in LOTUS_FEATURES.md + git history); kept every open/blocked/deferred item, the
E2EE + Web Push backlog, and the reference tables (server caps, key files, EC
fork ops, CI/CD).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clears the clean 🟡 remainders from the feature audit (gate-green, 677 tests):
- F3: getFallbackSession prefers the session-blob/legacy source with the later
expiresAt (a downgrade→upgrade could boot on a stale blob's dead token).
- F6: server-forced logout (SessionLoggedOut) now mirrors logoutClient —
pushSessionToSW() + best-effort revokeOidcTokens for OIDC sessions (the search
plaintext wipe was already added).
- N5: deleteUnreadInfo parent fallback `?? roomId` → `?? []` (latently spread the
roomId string into chars).
- P10: useUserPresence re-seeds when the User object appears after first render.
- forward: strip m.mentions so forwarding doesn't re-ping the original mentions.
Left open: F5 (OIDC expiry not reachable in persistTokens), N6/H10/D7 (minor /
runtime-verify). See LOTUS_TODO.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Share Room QR was fetched from the third-party api.qrserver.com, leaking
which rooms a user shares (and failing offline / under strict CSP). Now rendered
locally via qrcode.react (QRCodeSVG) — no network request, works offline. Added a
white quiet-zone container so the code scans on any theme; dropped the qrError
fallback (local generation can't fail the same way). Removed api.qrserver.com
from the prod CSP img-src (matrix repo). Build verified (rolldown interop OK).
Verification steps added to LOTUS_TESTING.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wave-3 bug-hunt fixes (findings in LOTUS_TODO), reviewed + gate-green:
- 🔴 ACL editor [H1–H4]: block saving an empty allow-list (was a one-click
federation brick), warn on self-ban (case-insensitive glob match of
mx.getDomain() vs allow/deny), accept real globs (1.2.3.*, *.evil.*), and
gate Save behind a confirm dialog.
- 🔴 [P1] room context menu no longer acts on the wrong room after a live
reorder (key by roomId, not list index). 🔴 [P2] status writes no longer
force presence to online over Invisible/DND (shared presenceStateFromSetting).
- 🟠 [P3] timed mutes restored on boot; [P4] custom-status auto-clear now fires
(always-mounted StatusExpiryMonitor); [P5] timezone also PUT to the m.tz
profile field so it's visible to others; [H6] RoomInsights single-pass
min/max (was Math.min(...spread) stack overflow); [H7/H8] mod-log labels.
- 🟡 [P6/P7] favorites collapse+filter, [P8] charCount reset, [P9] DM preview
refresh on decrypt; theming [T-P1] lazy decorations, [T-P2] drop the redundant
always-on body animation, [T-P4] live useReducedMotion, [T-P5] decoration key.
- NATIVE-CINNY LAW: notification presets + Powers permissions use folds icons.
DEFERRED: [H5] invite-QR is fetched from api.qrserver.com (third-party leak);
local generation needs a bundled QR lib (not added). tsc/eslint/prettier clean,
build OK, 677 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Notification profile presets (P5-27) used literal emoji (🎮/💼/🌙) instead of
folds Icons → Gaming=Ball, Work=Monitor, Sleep=BellMute.
- Permissions "Powers" list used ✅/❌ text emoji for has/no-power → folds
Icons.Check / Icons.Cross (colored via the row).
Reviewed the rest of the UI: seasonal-theme picker emoji kept (folds has no
holiday-icon equivalents; a distinctly-Lotus visual feature), soundboard clip
emoji kept (user-chosen clip identity), URL-preview brand glyphs + upstream
device-verification emoji + keyboard key-symbols left as-is.
(Also records the F2 URL-preview decision: keep default-on.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Web fixes from the Wave-2 bug-hunt (findings in LOTUS_TODO):
- F1 (security): wipe the decrypted-plaintext search index on SERVER-FORCED
logout too (token expiry / remote sign-out) — only manual logout did before.
F4: the delete no longer reports success while onblocked (waits, 3s cap).
- M1/M2 (data-loss): useBookmarks + useUserNotes account-data writes are now
serialized at MODULE scope (single queue + latestRef per client, echo-driven),
fixing the cross-instance lost-update clobber (useBookmarks mounts per message
row, so a per-instance queue was insufficient — caught in review).
- M6: room-history export gets a 200-page cap + Cancel + unmount-abort +
correct date-range early-break (raw paginated ts). M4: image compression
skips PNG (was flattening transparency to black), bakes EXIF orientation via
createImageBitmap, .jpg-renames, and falls back to the original on decode
failure instead of dropping the file. M5: MediaGallery lightbox opens the
right item (shared thumb guard). M8: audio speed survives async decrypt.
- Desktop web wiring: D2 badge sums leaf rooms only (space double-count, like
the favicon fix); D3 useTauriDnd re-hydrates from get_tray_dnd on mount; D5
updater has a terminal state.
Reviewed; M7 reverted (past-time clamp is an intentional, tested contract).
tsc/eslint/prettier clean, build OK, 678 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- C-H1: forceState only on FIRST join; on EC reconnect re-arm the fork handlers
(resendForkState — deafen+quality only) instead of clobbering live mic/video/
deafen back to the join-time snapshot.
- C-H2: AFK auto-mute reads the fork's io.lotus.call_state VAD of the LOCAL
published track instead of getUserMedia on the browser DEFAULT mic (which could
measure silence while the user spoke on another device → auto-mute an active
speaker). Fails safe (never mutes) when call_state is null OR empty.
- C-H3: control observer re-binds after EC re-renders (body subtree:true + 100ms
debounce) with an early-return so unchanged state doesn't re-render.
- C-M3 setQuality join-gated; C-M4 hangup 4s fallback dispose (idempotent);
C-M5 PTT no longer silently un-deafens; C-M6 screenshare-audio mute resets on
stop; C-L4 deafen key works in the iframe; C-L6 setState-after-unmount guards.
Reviewed (C-H2 [] fail-safe + C-H3 re-render guard applied). tsc/eslint/prettier
clean, build OK, 677 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- T1 (🔴): markThreadAsRead no longer receipts the thread ROOT (a 2nd instance
of the read-marker-corruption regression — opening a thread whose root is old
re-lit the whole room). Extracted to a pure threadReceipt.ts + 5 regression
tests.
- N1 (🔴): favicon/tab-title unread count now sums only leaf rooms (was double-
counting every ancestor-space aggregate in roomToUnread).
- N2 (🔴): notifications/sounds dedupe on the event id, not the unread count —
fixes "read a DM, next message never notifies again".
- T4 (🟠): the thread notification path no longer re-gates on the room count, so
an explicit per-thread "All replies" override in a Mentions-only room fires.
- N3 (🟠): getUnreadInfos skips phantom {0,0} entries (muted-thread-only rooms no
longer light the nav row / pollute unread filters).
- N4 (🟠): the Receipt handler recomputes unread instead of blanket-DELETE, so a
threaded receipt can't wipe a room's valid main-timeline badge.
- T2 (🟠): thread "Jump to Latest" re-anchors the virtual window (was landing on
a stale mid/old event).
Gates: tsc/eslint/prettier clean, build OK, 678 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 parallel deep-audit agents over the Tier-1 high-risk areas. Findings only (no
source changes). Top 🔴: markThreadAsRead corrupts the main read marker via a
thread-root receipt (a SECOND instance of the P6 read-receipt regression, likely
a live cause of "unread won't clear"); favicon/title count double-counts space
aggregates; deliverNotification dedupe cache never cleared on read → missed
notifications/sounds. Plus 🟠 (thread "All" override defeated, phantom
muted-thread dot, receipt-DELETE badge race, thread jump-to-latest, call
forceState-on-reconnect clobber, AFK wrong-mic auto-mute, stale control observer)
and a long 🟡 tail. Recorded in LOTUS_TODO for prioritized fix passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI check:eslint failed with 28 errors in two test files: callSounds.test.ts
(lines-between-class-members on mock classes) and lotusDenoiseUtils.test.ts
(prefer-arrow-callback on `function AudioWorkletNode(){}` constructor mocks —
arrows aren't constructable, so auto-fixing would break the test). Both are
stylistic false-positives for test code; relax them in the existing test-file
override next to max-classes-per-file. `npm run check:eslint` now exits 0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The prior thread-receipt change (8192da5a) broke read receipts globally. Exact
cause: markAsRead used `thread.lastReply() ?? thread.rootEvent`. When a thread's
replies weren't loaded (lastReply() null — common on room open), it sent a
receipt for the thread ROOT. Since roots are "in the main timeline",
threadIdForReceipt() makes that a MAIN receipt at an old event; when the root
isn't in the loaded timeline the SDK's backward-guard falls back to timestamp
and applies it, moving the main read receipt onto an event we don't have, so
getEventReadUpTo() returns null and roomHaveUnread() reports the room unread —
re-broken on every mark-read, amplified by the bulk mark-all-orphan-rooms-read
callers.
Fix: main unthreaded receipt unchanged; the thread loop now sends a threaded
receipt ONLY for a genuine loaded thread reply (thread.lastReply()), never the
root — if replies aren't loaded, skip. New notifications.test.ts locks the
regression (null lastReply → no root receipt) + the main/threaded/no-op cases.
Gates: tsc/eslint/prettier clean, build OK, 672 tests (7 new).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two federated-room bugs surfaced by the desktop build:
1. markAsRead only sent one unthreaded receipt at the main-timeline tail. With
threadSupport enabled, thread replies leave the main timeline, so a reply
newer than that tail was never covered — its per-thread notification count
(which the room dot sums) lingered, so the unread dot never cleared even
after reading. It also early-returned when the main timeline was already
read. Now also send a threaded receipt at each unread thread's latest reply.
2. useAvatarDecoration never cached non-404 failures, so every avatar mount
re-requested io.lotus.avatar_decoration for federated users whose homeserver
403s/502s the field — a refetch storm that spammed the console and hammered
our homeserver's federation. Now cache definitive rejections (400/403/404)
and give up after ~2 transient (429/5xx) attempts per session.
Gates: tsc/eslint/prettier clean, build OK, 665 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Toggling custom chrome expanded the screen and sent the message feed
auto-scrolling into the past. Two causes:
- DesktopChrome used height:100vh while html/#root use 100dvh; in the Tauri
webview 100vh can exceed the visible height after decorations are stripped,
making the timeline's scroll container taller than the viewport → the virtual
paginator runs away paginating backwards. Switched to 100dvh.
- Toggling live reflowed the whole app while the timeline was mounted. The
setting now persists + reloads so the layout is rebuilt cleanly (description
updated: "reloads to apply").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- index.tsx: request navigator.storage.persist() for logged-in sessions so the
browser can't evict the IndexedDB rust-crypto store (eviction while the
localStorage session survives resurrects the device with a blank store → the
KE-1 "one time key already exists" upload storm). Guarded, checks persisted()
first, best-effort.
- Docs: remove HANDOFF_ELEMENT_CALL_FORK.md, LOTUS_E2EE_INVESTIGATION.md, and
LOTUS_BUGS.md. Port their live content into the three kept docs — verification
backlog → LOTUS_TESTING; open bugs + E2EE (KE-1..4) + an Element Call fork
operational reference (publish steps + io.lotus action catalog) → LOTUS_TODO.
Fix all dangling references (README, code comments, cross-doc links). Full
history of the removed docs remains in git.
Gates: tsc/eslint/prettier clean, build OK, 665 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Code-read + upstream-issue triage this session:
- 41.7.0 / crypto-wasm 18.3.1 does NOT fix KE-1 (no OTK/upload change; #5200
still open) — the SDK-pin remediation lever is closed.
- Confirmed root cause = rust-crypto store <-> Synapse OTK divergence; the
leading web trigger is that cinny never requests persistent storage, so the
IndexedDB crypto store is evictable while the localStorage session survives.
- New buildable preventive mitigation: navigator.storage.persist() on login
(+ multi-tab guard, 400-loop recovery prompt). Added as §6 with a secondary
KE-2 to-device-validation hypothesis and capture discriminators.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add HSTS + Permissions-Policy + the standard X-Frame/X-Content/Referrer set to
the contrib nginx (443 block) and caddy examples; fix the caddy SPA try_files
fallback (stray space). Generic (no homeserver-specific CSP). The real prod
config lives in the matrix repo. P6-4 trimmed to headers only — patch-package /
types-drift / build-config skipped (see LOTUS_TODO).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Forward: checkbox multi-select room picker + "Send to N rooms" batch send
(Promise.allSettled). Full success auto-closes; partial failure keeps the dialog
open with a "Forwarded to X/N — failed: …" summary and prunes the selection to
only the failures (retry won't duplicate to already-sent rooms). Content builder
extracted to a unit-tested forwardContent.ts (edit-forwarding, reply-strip,
undecryptable-refused; 4 tests).
Bookmarks: BookmarksPanel resolves each saved message's live event (useRoomEvent)
so previews reflect edits and show a deleted indicator for redactions; the stored
snapshot stays as the fallback while loading, on fetch failure, or after leaving
the room. Stored bookmark shape unchanged.
Gates: tsc/eslint/prettier clean, build OK, 665 tests. Reviewed (dup-resend on
retry + Checkbox readOnly fixed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CallControl now sends the new io.lotus.set_deafen action (join-gated via
forceState) on every deafen / screenshare-audio-mute toggle + on join, ALONGSIDE
the retained iframe-DOM .muted hack (transitional). Against the current pinned
bundle the action is immediately error-replied + swallowed by .catch — inert, no
timeout. Reordered toggleSound() to commit state before setSound() so the sent
deafen value isn't inverted.
Phase 2 (after the fork is published): bump the pin lotus.1 -> lotus.2 and delete
the DOM hack. Docs: HANDOFF §12.4, LOTUS_TODO P6-2, LOTUS_BUGS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- `?` shortcut now stopImmediatePropagation so RoomView's type-to-focus handler
doesn't steal focus into the composer behind the dialog (and swallow Escape) —
CONFIRMED review finding.
- Typing live region stays mounted (empty when idle) so the FIRST "X is typing"
is reliably announced (a status region added with its text isn't always read).
- Removed a stray empty `{}` JSX expression in MediaGallery (leftover from an
auto-fix).
Reviewer verified the rest: collapsed-message labels, focus-return
classification (4 dialogs fixed, popouts correctly left), and all aria fixes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enables ARIA-correctness rules (aria-props/proptypes/role/unsupported-elements,
role-has/supports-aria-props, no-redundant-roles, anchor/heading-has-content)
+ label-has-associated-control as errors — a regression gate for accessible
names + valid ARIA. control-has-associated-label deliberately NOT enabled (the
repo's <Text as="label" htmlFor> component pattern defeats its static analysis);
the real gaps it surfaced were fixed directly. Also disable max-classes-per-file
for test files (mock classes).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Focus returns to the trigger when closing 4 genuine dialogs (room-topic
viewer, reaction viewer, header topic, Search) — 20 inline popouts/menus
correctly left as-is (returning focus to a hover target would be wrong).
- Typing indicator announced via a visually-hidden role="status" region;
the visual text is aria-hidden to avoid double announcement.
- New keyboard-shortcuts help dialog (press ?, ignored while typing),
mounted in ClientNonUIFeatures.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Accessible names for ~15 controls that lacked them: invite/join/create-room/
account-data/image-pack/private-note/power-level inputs (visible <label htmlFor>
where a label exists, else aria-label); the two range sliders (night-light
intensity, noise-gate threshold); the soundboard file input; media <video>
elements; and the Media Gallery (region) + Search (dialog) overlays. Hidden
notification/preview <audio> marked aria-hidden.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Each message is role="article"; collapsed messages (consecutive from one
sender) now carry an aria-label with sender + time — previously a screen
reader heard only the body with no attribution (the biggest a11y gap).
Pure messageAriaLabel() reuses the existing time utils (+3 tests).
- Editing a message announces "Editing message from <sender>" (ariaLabel
threaded MessageEditor → CustomEditor; the main composer is unaffected).
- System emoji get role="img" + aria-label from the shortcode; custom
emoticons always have an accessible name.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fills the gap where LOTUS_BUGS referenced test IDs (P3-8/P4-1/P4-4/P4-8/N97a/
AW-1..4) with no matching procedures in the testing guide.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Dep triage recorded (zero shipped exposure; SDK now 41.7.0 stable; dompurify
removed); Needs Verification rows for the audit-wave fixes (scheduled-cancel,
emoji lazy-load, SW precache, desktop CSP smoke).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
HEAD-checks the copied denoise worklet/wasm/model assets for the selected model
and console.warns a single line listing anything missing — a silent asset skew
between the EC fork's expectations and vite's copied files would otherwise
disable noise suppression with no signal. Fire-and-forget; never blocks call
setup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- emojibase (~965 KB) is now fully lazy: plugins/emoji.ts loads compact data +
shortcode maps via a memoized dynamic import (rejections reset the memo so a
mid-deploy chunk 404 can retry); reaction labels degrade to the raw glyph
until loaded. Consumers get FRESH array references on load (the module arrays
populate in place — same-ref state updates would skip re-render and leave
emoji search empty; reviewer-caught). Verified out of the eager graph.
- Service worker precaches hashed assets (workbox precacheAndRoute, 82 entries
~10.8 MB incl. the crypto wasm): repeat visits stop re-downloading the app.
index.html is NOT precached — navigations stay network-first so deploys are
picked up immediately; the media-auth fetch handler is untouched.
- ReactPrism: curated 21-language set — chunk 574 KB → 71 KB.
- Timeline inline images get loading="lazy".
- Removed dead dompurify (+types); sanitize-html is the real sanitizer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ScheduledMessagesTray: cancel prunes local state ONLY on confirmed server
cancel; failures keep the item + show an inline error (was: a failed cancel
looked cancelled but still sent at the scheduled time).
- Escape semantics: the composer consumes Escape (preventDefault+stopPropagation)
iff autocomplete is open or a reply draft is set; the thread panel and Room's
markAsRead act only on unconsumed Escape, and markAsRead defers entirely while
a thread panel is open (listener order made it fire before the panel closed).
- Room: thread panel / media gallery are mutually exclusive (most-recently-
opened wins); on mobile at most one right panel renders (thread > gallery >
members) instead of stacked fullscreen overlays.
- RemindMeDialog: busy-disabled presets (no more double-click duplicates),
try/catch with inline error, close only on success.
- ThreadTimeline: "Jump to Latest" floating chip when scrolled up (RoomTimeline
idiom).
From the 4-auditor deep-audit wave; reviewer-verified.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The deletions from the git-mv in 992d2b83 were unstaged by a concurrent
worktree operation before commit, so the pushed tree contained BOTH
threadSummary.ts and threadSummaryData.ts (and the Windows case-collision
persisted). This commit removes the stale originals; caseCollision.test.ts
would have failed CI on the incomplete state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
threadSummary.ts (pure helpers) and ThreadSummary.tsx (chip component) lived in
the same directory differing only by case. On the case-insensitive Windows
release runner, RoomTimeline's extensionless import of ./thread/ThreadSummary
resolved .ts BEFORE .tsx and matched the helper module → rolldown
MISSING_EXPORT "ThreadSummary" — invisible on every Linux/macOS build (and the
cause of the earlier masked pdf.worker failure). Helper module renamed to
threadSummaryData.ts (+ test), 3 importers updated.
Prevention: new caseCollision.test.ts walks src/ and fails on any same-directory
names differing only by case (extensionless compare, so Foo.tsx vs foo.ts is
caught) — verified it fails on the pre-rename tree. Runs in the hard CI gate.
Gates: tsc clean, eslint/prettier clean, build OK, 658/659 tests (1 IDB skip).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Soundboard v2 — a near-parallel of the custom-emoji image-pack system for
in-call audio clips.
- Data model: 3-tier packs mirroring MSC2545 — room/space pack (state event
io.lotus.soundboard, inherited by child rooms via parent-space aggregation),
global refs (io.lotus.soundboard_rooms), and the personal pack
(io.lotus.soundboard account data; the v1 flat-list content is migrated to the
pack shape on read). New plugins/soundboard/ (readers, SoundboardPack, utils) +
hooks/useSoundboardPacks (useRelevantSoundboardPacks = user U global U room,
deduped). Unit-tested (migration + slug).
- Management: reusable SoundboardPackEditor (name + emoji + per-clip volume +
delete + upload + batched save), power-level-gated for room packs like emoji
packs; a Soundboard page wired into Room + Space settings.
- In-call: CallSoundboard rewritten as a Discord-style grid grouped by pack
(emoji + name tiles), sourcing room+parent-space U personal clips; a Manage
toggle embeds the editors; per-clip volume x master volume on playback.
- Spam guard: host gates on a playing key (fork enforces one clip at a time).
- Control bar: Mute-Screenshare moved next to the Screenshare button.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Audit of ForwardMessageDialog, fixes:
- Search input was intrinsic-width (sat in a default Row Box with no grow) —
now a Column Box stretches it full-width, matching every other search input.
- Search field is auto-focused on open (FocusTrap initialFocus; was nothing).
- Edited messages now forward the LATEST edit (m.new_content via
getEditedEvent) instead of the stale pre-edit body.
- Reply fallbacks stripped (trimReplyFromBody + <mx-reply> block) along with
m.relates_to, so forwards stand alone instead of quoting the old room.
- Undecryptable events are refused with an inline error (previously forwarded
m.bad.encrypted junk); send failures now show an error instead of silently
resetting.
- sendEvent uses the typed threadId-aware overload (explicit null) instead of
an untyped (mx as any) call relying on the SDK's legacy arg-sniffing.
- Room list + filter memoized (was re-sorting all rooms every keystroke).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
closeBundle also runs when the build FAILED mid-render (dist/ absent); the
plugin's copyFileSync then threw ENOENT and vite reported THAT instead of the
actual render error — exactly what hid the real failure in the Windows desktop
CI run. Now: warn-and-skip on any error, mkdir the dest dir when copying.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- useRoomsListener now PREPENDS the emitting Room (was appended): the SDK emits
RoomEvent.UnreadNotifications with VARIABLE arity (0/1/2 args), so a trailing
extra arg landed in the wrong positional slot on the most common room-count
sync path — room.isSpaceRoom() threw inside the SDK emit loop and the badge
PUT never ran. Both consumers updated (CONFIRMED HIGH review finding).
- roomToUnread: SpaceChild RESET now passes the thread prefs so muted-thread
subtraction survives space-child state changes.
Reviewer also verified: badge subtraction math exact (no double-subtraction),
encrypted thread replies caught by the timeline guard (m.relates_to is
cleartext), fresh prefs flow to handlers, single-owner wiring load-bearing.
Documented-acceptable: hasCurrentUserParticipated can lag until the server
bundle refreshes after your first reply; dedupe maps grow per-session only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Default = Participating: thread replies notify only when you've posted in the
thread or are @mentioned; per-thread override to All / Mentions-only / Mute via
a bell menu in the thread panel header. Modes sync across devices in
io.lotus.thread_notifications account data (pruned on write: left rooms, >180d,
cap 200/room). Muted threads: no notifications/sounds, chip badge suppressed
(+BellMute glyph), and their counts are subtracted from the room's sidebar
badge (client-side; clamped ≥0).
Also fixes the thread notification path itself: thread replies are now owned by
exactly ONE handler (room-level ThreadEvent.NewReply via a new useRoomsListener
hook, with per-thread dedupe, panel-aware focus suppression, and per-thread OS
tag coalescing) — the existing RoomEvent.Timeline handlers in the notifier and
the unread binder are explicitly thread-guarded, eliminating the previously
un-gated/double path. Room badges now also refresh live on
RoomEvent.UnreadNotifications (surgical per-room PUT; fixes thread-badge lag).
Pure decision core shouldNotifyThreadReply (13-case matrix) + prune + unread
subtraction: +32 tests (648 total). E2EE caveat documented: mentions-only may
under-notify pre-decryption (same class as the existing path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two-reviewer audit of the thread stack; confirmed findings fixed:
- ThreadTimeline: wrap encrypted events in EncryptedContent so a live-arriving
E2EE reply re-renders when its key decrypts (decryption emits neither
RoomEvent.Timeline nor ThreadEvent.Update — previously stuck at "Unable to
decrypt").
- ThreadPanel: mark-read deduped on the latest event id (RoomEvent.Timeline
re-emits per backfilled event/edit/reaction; previously up to N receipt POSTs
per panel open) + rejection handled with retry.
- RoomTimeline: ThreadSummary chips now mount only for events carrying thread
data (each chip holds a room-level listener; one per rendered message would
blow the SDK's 100-listener emitter cap) with a single room-level
ThreadEvent.New tick for new-thread liveness.
- useThreadPendingEvents: keep a sent reply visible through the /send-response→
/sync window (was flashing out of the pending strip before landing).
- ThreadTimeline: reseed the window on RoomEvent.TimelineReset (gappy sync left
a detached timeline).
Documented-acceptable (reviewer-noted): thread typing shows as room typing (no
per-thread typing in the spec; Element matches), thread panel + members drawer
can be open together, scheduled-send is thread-unaware but unreachable there.
Gates: tsc clean, eslint 0 errors, build OK, 616/617 tests (1 IDB skip).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Right-side thread drawer (MembersDrawer pattern; mobile fullscreen):
- ThreadPanel: header + close/Escape, ThreadTimeline, its own RoomInput
(threadRootId prop; drafts/replies/uploads isolated per roomId::threadId;
schedule + slash-commands off in threads v1) and threaded mark-as-read.
- ThreadTimeline: lean reimplementation over thread.liveTimeline — copied
useTimelinePagination pattern (/relations back-pagination + decryption),
virtualized, root event emphasized + "N replies" divider, reactions/edits/
redactions, and a pending strip (chronological local echo never enters the
thread timelineSet — rendered from LocalEchoUpdated instead).
- ThreadSummary chips on root messages (server-aggregated bundle or live
Thread; unread badge via getThreadUnreadNotificationCount) keep threads
discoverable now that replies leave the main timeline.
- Reply-in-Thread menu + thread indicators open the panel; deep links to
thread events redirect into it.
- State: roomIdToActiveThreadIdAtomFamily + getThreadDraftKey (+18 tests).
Gates: tsc clean, eslint 0 errors, build OK, 616/617 tests (1 IDB skip).
Awaiting live QA; release note: threaded replies no longer render inline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
threadSupport:true makes matrix-js-sdk partition m.thread relations into Thread
objects (replies leave the main timeline; roots stay). markAsRead now sends
UNTHREADED receipts so one receipt still clears room + thread notification
counts — without this, badges would stick unread. The thread panel + summary
chips land in the same push.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TODO: P4-7 already-implemented [x]; P4-6 mozilla test enablement verified live;
Audit-3 researched → deferred tracking MSC4427 (banner_url proposal, unmerged);
P3-8 Thread Panel now carries the complete SDK-evidence-backed build plan
(threadSupport side effects, local-echo gap, receipt fix, 4-agent partition) —
ready for its own session. BUGS: N127 removed, Big #5 (backgrounds/seasonal)
done, CDN env-var closed (VITE_DECORATION_CDN exists), test count updated, KE
section points at the new investigation kit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LOTUS_E2EE_INVESTIGATION.md: per-KE capture runbook (console signatures, synapse
log greps + SQL against the documented LXC deployment, the KE-1⇒KE-2 causality
decision tree, ranked remediations incl. what a crypto-store reset wipes; SDK
finding: stable 41.6.0 has no OTK fix over our RC pin). Client: capture-only
console ring buffer (cryptoDiagLog, KE-signature-matched, max 200) + a Crypto
Diagnostics card in Developer Tools with a download-report button. ClientRoot
installs the capture hook at module load and mounts useSessionSync (cross-tab
sessions, prior commit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Session now persists as ONE atomic cinny_session_v1 JSON write (blob-wins read,
transparent migration from the ~10 legacy keys, dual-write kept one release for
rollback). subscribeSessionChanges + useSessionSync reload a tab whose session
was changed/removed by another tab (logout/login/token rotation). OIDC refresher
already routes through setFallbackSession, so rotations stay atomic. Tests 7→22.
Full token-protection redesign remains tracked in LOTUS_BUGS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Raw-IndexedDB cache (lotus-search-cache: messages keyed [roomId,eventId] +
per-room coverage) merged into local search with in-memory-wins dedupe. OPT-IN
(default off) via a standalone atom — stores decrypted text at rest, so it ships
with a privacy note, a Clear button, and an unconditional wipe on logout
(initMatrix). All IDB errors degrade to cache-miss. +8 tests (1 IDB skip in node).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Renders LaTeX via spec data-mx-maths spans/divs (KaTeX render of the attr,
children as fallback) and conservative $…$ / $$…$$ text detection (escape-aware,
currency-guarded, never inside code/pre). KaTeX + CSS load lazily on first math
(ReactPrism pattern) — verified absent from the eager bundle. Sanitizer
unchanged by design (we render post-sanitize from attr/text; no incoming MathML
accepted). +14 unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The accent previously only overrode the folds Primary.* family; links kept the
hardcoded --tc-link blue, ::selection was browser-default, and focus rings were
neutral grey (Other.FocusRing). Now all three derive from the chosen base color:
- --tc-link → accent hex (messages, topics, URL previews)
- ::selection via an injected <style id="lotus-accent-style"> (accent bg +
WCAG-contrasting text)
- Other.FocusRing → rgba(accent, 0.5)
Deliberately NOT recolored: Secondary.* (doubles as the neutral text/button/
badge palette), Success.* + mention pills (semantic mention/notification green),
scrollbar thumbs (folds styles them per-component; a global rule would only
half-apply). removeCustomAccent() clears everything — no residue when switching
off or to the TDS theme. +2 unit tests (561 total).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
data-tauri-drag-region only fires when the exact element is the event target
and was never runtime-verified; replace it with the official Tauri custom-
titlebar recipe — primary-button mousedown starts an OS drag, detail===2
toggles maximize. Works across the whole region (brand text included, which
already passes pointer events through).
Pairs with cinny-desktop set_custom_chrome Mica fix (clear backdrop before
undecorating; window-state no longer restores the decorated flag).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
if:(github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
# the below token should have repo scope and must be manually added by you in the repository's secret
PERSONAL_ACCESS_TOKEN:${{ secrets.CLA_PAT }}
with:
path-to-signatures:'signatures.json'
path-to-document:'https://github.com/cinnyapp/cla/blob/main/cla.md'# e.g. a CLA or a DCO document
# branch should not be protected
branch:'main'
allowlist:ajbura,bot*
#below are the optional inputs - If the optional inputs are not given, then default values will be taken
remote-organization-name:cinnyapp
remote-repository-name:cla
#create-file-commit-message: 'For example: Creating file for storing CLA Signatures'
#signed-commit-message: 'For example: $contributorName has signed the CLA in #$pullRequestNo'
#custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign'
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
#use-dco-flag: true - If you are using DCO instead of CLA
| **A6** — avatar decorations in-call | Our profile-decoration overlays don't appear on in-call video tiles | The video grid is rendered by EC's React app inside the iframe. We can only inject overlay DOM (fragile) — we can't make it a first-class part of the tile. |
| **A5** — focus camera / fullscreen during screenshare | Can't reliably spotlight a participant's camera while someone screenshares | EC's **layout logic** (screenshare priority, spotlight) is compiled JS we don't control. We currently DOM-click tiles as a hack. |
| **A7** — mic dead after EC's "Reconnect" | After EC's own mid-call reconnect, the local mic isn't re-published | EC's reconnect/track-republish path is internal. (Partly entangled with our denoise shim — see §6.) |
| Native theming | EC's UI doesn't match Lotus design; we inject CSS hacks | Real theming needs source-level component/token changes. |
| Decorations, custom controls, custom layouts, branding | all blocked | all require source access |
**Bottom line:** the iframe is **same-origin** (we self-host it), so we can read
and even write its DOM — but we **do not own its source**, so we can't change its
**behavior/logic**, only poke at its rendered output. Forking removes that wall.
---
## 2. How EC is integrated TODAY (the current architecture)
Understand this fully before changing it — the fork must slot into the same
integration seams.
### 2.1 Where the EC bundle comes from
- npm package: **`@element-hq/element-call-embedded`**, pinned to **`0.20.1`** in
`cinny/package.json` (line ~104).
- It ships a **pre-built `dist/`**. At cinny build time,
`vite-plugin-static-copy` copies that `dist/` flat into
**`public/element-call/`** (see `cinny/vite.config.js`, the `copyFiles`
target with `rename: { stripBase: 4 }` — note the stripBase gotcha documented
there; getting this wrong 404s the widget).
- It is **NOT committed** to git (`git ls-files public/element-call` → 0). It's a
build artifact materialized from `node_modules`.
### 2.2 How EC is loaded & controlled
- The widget iframe `src` is **same-origin**:
`${BASE_URL}/public/element-call/index.html?<params>` (see
| N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room |
**Verified working in live testing (2026-06):** A2, B1–B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.
---
## 🧩 Element Call source-level items — now actionable via the fork
> 🔱 **[EC-FORK]** **UPDATE 2026-06-30: Phase 2 IMPLEMENTED.** We own and
> self-build Element Call (`LotusGuild/element-call` →
- ~~**N127 — ML denoise shim is never injected in `vite dev`.**~~ **RESOLVED (dissolved by the A7 denoise cutover).**`vite.config.js` no longer injects a getUserMedia shim at all — the forked Element Call runs ML denoise in-source as a LiveKit `TrackProcessor` (activated by `lotusDenoiseSource=1`), so there is no build-time injection that could be missing in dev. Nothing to fix.
`[MembershipManager] Network local timeout error while sending event, immediate
retry … AbortError: Restart delayed event timed out before the HS responded`,
with repeated `org.matrix.msc4157.update_delayed_event`. MSC4140/4157
delayed-event reliability against `matrix.lotusguild.org` — can cause stale/ghost
call membership and missed leave events. May be partly **homeserver
responsiveness**; correlate with synapse latency/load. Include in the same
planning session since it shares the call-reliability + HS-interaction surface.
### Security & Privacy
- **N97 — Access token stored in plaintext `localStorage`** (`state/sessions.ts`), vulnerable to XSS; device ID likewise. Architectural — needs a token-protection / session-storage redesign.
- **Session writes are non-atomic and not cross-tab synced** (`state/sessions.ts`) — risks inconsistent state / races across tabs.
- **Persisted PII without encryption:** user status message + expiry (`settings/account/Profile.tsx`), unsent composer drafts (`room/RoomInput.tsx`). Leak risk on shared devices.
### PWA / Offline / Notifications
- **N107 — SW has no `push` handler** — Web Push delivery is entirely non-functional. Needs a `push` listener + a Matrix push-gateway integration.
- **No app-asset caching strategy** (`src/sw.ts`) — no offline capability.
- ~~**`manifest: false`** may block PWA install~~ — **verified OK (2026-06):**`index.html` links `/manifest.json`, which exists in `public/` and is copied to `dist/`; VitePWA intentionally doesn't generate one. Not a bug.
### Dependencies & Build
- **`matrix-js-sdk` pinned to a Release Candidate** (`41.6.0-rc.0`); `@atlaskit` and build tools (`vite`, `typescript`, `eslint`) on unstable/experimental pins — review for stable versions; RC SDK is a tree-shaking/bundle-size risk.
- **Build-time overhead:** `lotusDenoise` does heavy sequential `fs` work in `closeBundle`; `viteStaticCopy` config is complex with redundant renames — could be streamlined.
### Code Hygiene / DevEx
- **Automated test suite — 545 tests across 62 modules, a hard CI gate.** `npm test` runs Node's built-in runner via `tsx` (not vitest — Vite 8 is ahead of vitest's range) and **blocks the build job on failure**. Broad pure-logic coverage: utils (common, regex, sanitize/XSS, time, matrix, matrix-uia, mimeTypes, sort, accentColor, findAndReplace, AsyncSearch, ASCIILexicalTable, keyboard, room, matrix-crypto, featureCheck, syntaxHighlight, imageCompression, user-agent, callSounds), state (settings, sessions, recentSearches, upload, typingMembers, lists, room-list, toast, scheduledMessages, backupRestore, callEmbed/callPreferences, spaceRooms, …), plugins (matrix-to, call/utils, via-servers, bad-words, recent-emoji, custom-emoji, markdown block/inline/utils), OIDC (cs-api, useParsedLoginFlows, oidcState), lotus/avatarDecorations, message-search, search filters. Prevention work has caught + fixed **4 real bugs** (`findAndReplace` infinite-loop; `getSettings` crash-on-load when storage is blocked; `isMacOS` never matching modern Macs; `isMLDenoiseSupported` throwing `ReferenceError` instead of returning false on browsers lacking the `AudioWorkletNode` binding). **Next:** component/integration tests (the untestable-under-tsx DOM/React surface).
- **`types/matrix/` mirrors SDK types** instead of importing them — drift risk.
- **Hardcoded CDN URL** should move to an env var (the decoration CDN is now single-sourced in `avatarDecorations.ts`, but the literal is still in-repo).
- **Infra docs:** `contrib/nginx` lacks security headers (HSTS/CSP) + uses rewrites over `try_files`; `contrib/caddy` has a placeholder path. CI/CD (`prod-deploy.yml`): sequential deploy, aggressive 1-min Netlify timeout, `package-manager-cache: false`.
- **README:** keep the fork-sync version + logo path current. (`CONTRIBUTING.md` is intentionally left as upstream Cinny's — not a Lotus concern.)
- **Architecture notes (low priority):** deep `features/` + `hooks/` nesting, many small coupled hooks, possible dead CSS/components, `SpacingVariant` / `DropTarget` recipe simplification.
- **Git workflow (forward-looking):** keep commits scoped — past monolithic "fix all bugs" commits and inconsistent prefixes hurt `git bisect`.
### Big Projects
- **#5 — Seasonal themes & chat-background redesign.** Current backgrounds are basic CSS; goal is high-fidelity, research-backed, GPU-accelerated designs (layered `oklch`, `backdrop-filter`, `contain:paint`) with WCAG-AA overlay contrast. Treat each as its own design sprint.
@@ -153,13 +155,13 @@ A "Pause Background Animations" toggle is exposed in **Settings → Appearance**
### Animation Improvements (June 2026)
All five animated backgrounds were rewritten for smoother, more organic motion:
All five animated backgrounds were rewritten for smoother, more organic motion. Each background drives its own single drift/scroll keyframe (no secondary glow or blink layers):
- **Digital Rain** — added a phosphor glow flicker (`animRainGlowKeyframe`, 2.1 s) layered on top of the column scroll; stripe opacity increased for better visibility
- **Star Drift** — each of the three dot layers now moves by exactly its own tile width/height per cycle (`−130 px`, `−190 px`, `−260 px`), eliminating the visible seam on loop
- **Grid Pulse** — independent brightness oscillation (`animGridBrightnessKeyframe`, 3.3 s) runs alongside the size breathe (4 s) at a prime period ratio so they never synchronise
- **Grid Pulse** — size breathe keyframe (4 s)
- **Aurora Flow** — four gradient layers now have individual `backgroundSize` values (`200%`, `250%`, `300%`, `220%`); the keyframe drives each layer through a distinct 5-stop path, replacing the robotic single back-and-forth
- **Fireflies** — glow pulse (`animFirefliesGlowKeyframe`, 2.3 s `filter: brightness`) and opacity blink (`animFirefliesBlinkKeyframe`, 1.7 s) added on top of the position drift; prime periods create unsynchronised bioluminescence
- **Fireflies** — position drift keyframe with per-firefly duration/delay so motion stays unsynchronised
### Files
@@ -174,19 +176,19 @@ Decorative CSS-only overlays that activate automatically on holidays and events.
- **Controls:** Toggle to enable/disable + intensity slider ranging from 5% to 80% opacity
- **Schedule (auto on at night):** an optional schedule with From/To time inputs; when on, the overlay only shows during that window and turns itself on/off automatically (the overlay re-checks every minute, no reload). Overnight windows that wrap midnight (e.g. 21:00 → 07:00) are handled. Window logic is the pure, unit-tested `isWithinTimeWindow`/`parseHHMM` in `src/app/utils/timeWindow.ts` (`timeWindow.test.ts`). Defaults: 21:00–07:00.
- Settings persisted via the standard Lotus settings store
---
@@ -329,7 +332,7 @@ Users can set a custom background color for `@mention` chips that highlight thei
> pre-built npm bundle. Several in-call behaviors below are now first-class
> source changes rather than DOM/widget hacks. Background, plan, and the Phase-2
| A7 | **Denoise in-source** | ML noise suppression runs inside EC as a LiveKit `TrackProcessor<Audio>` (flag `lotusDenoiseSource=1`); re-applied on every (re)publish | the build-time `getUserMedia` monkeypatch injected into `index.html` — **removed**. Fixes mic-dead-after-reconnect. |
| #2 | **Speaking / mute events** | EC emits `io.lotus.call_state` (throttled); cinny reads speaker + mute state from it (flag `lotusCallState=1`) | scraping EC's DOM for `[data-lk-speaking]` (kept only as fallback) |
| A5 | **Focus participant** | host sends `io.lotus.focus_participant` to pin a tile, coexisting with / overriding the screenshare spotlight | the `.click()`-the-tile DOM hack in `CallControl.ts` — **removed** |
| #6 | **In-call avatar decorations** | host pushes `io.lotus.decorations` (per-user APNG URLs); the fork renders them on EC's video-tile avatars | previously impossible — decorations only showed on our pre-join lobby roster |
| #5 | **Native transparent background** | flag `lotusTransparent=1` makes EC's surface transparent so the host wallpaper shows through | the injected `background:none !important` CSS |
| A7 | **Denoise in-source** | ML noise suppression runs inside EC as a LiveKit `TrackProcessor<Audio>` (flag `lotusDenoiseSource=1`); re-applied on every (re)publish | the build-time `getUserMedia` monkeypatch injected into `index.html` — **removed**. Fixes mic-dead-after-reconnect. |
| #2| **Speaking / mute events** | EC emits `io.lotus.call_state` (throttled); cinny reads speaker + mute state from it (flag `lotusCallState=1`) | scraping EC's DOM for `[data-lk-speaking]` (kept only as fallback) |
| A5 | **Focus participant** | host sends `io.lotus.focus_participant` to pin a tile, coexisting with / overriding the screenshare spotlight | the `.click()`-the-tile DOM hack in `CallControl.ts` — **removed** |
| #6| **In-call avatar decorations** | host pushes `io.lotus.decorations` (per-user APNG URLs); the fork renders them on EC's video-tile avatars | previously impossible — decorations only showed on our pre-join lobby roster |
| #5| **Native transparent background** | flag `lotusTransparent=1` makes EC's surface transparent so the host wallpaper shows through | the injected `background:none !important` CSS |
| P6-2 | **Deafen / screenshare-audio mute** | host sends `io.lotus.set_deafen {deafened, screenshareAudioMuted}`; both become the `muted` prop of EC's audio elements (server stops sending), so they hold across re-renders, late joiners and a sharer stopping + re-sharing (fixed in `0.25.0-lotus.4`) | the iframe-DOM `.muted` hack (kept only as a transitional fallback) and `setVolume(0, ScreenShareAudio)`, which EC's own volume controls reset on every new share |
**Now wired (cinny drives them — ⚠️ awaiting live verification):**
**Now wired (cinny drives them — verified end-to-end on the local calls stack, 2026-09-18):**
- E2EE fix: the "Original" entry uses `getClearContent()` to retrieve the decrypted content rather than the encrypted payload
- **Word-level diff**: a "Highlight changes" toggle (on by default) renders each edit as a word diff against the previous version — added words highlighted (green), removed words struck-through (red) — using semantic `<ins>`/`<del>`. Toggle off to see full text (formatted messages render rich there; the diff is plain-text only). Diff logic is the pure, unit-tested `diffWords` (LCS) in `src/app/utils/textDiff.ts`.
- Accessible from the message context menu
### Inline GIF Preview
@@ -672,31 +679,106 @@ The indicator is hidden once the server confirms the event (when the internal st
- Giphy-powered picker accessible from the composer toolbar
- The button is only shown when `gifApiKey` is set in `config.json`
- Selected GIFs are sent as `m.image` events
- **Recently used**: a "Recent" row at the top of the picker (shown on the default view, hidden while searching) surfaces the GIFs you last sent for one-click re-sending — no re-searching. Persisted in localStorage (`cinny_recent_gifs_v1`), deduped by url, most-recent-first, capped at 16, via the pure/unit-tested `addRecentGif` (`src/app/state/recentGifs.ts`).
- Picker UI is styled with TDS variables when the TDS theme is active
- Located at `src/app/components/GifPicker.tsx`
### Sticker Picker — Recently used
The sticker tab of the shared `EmojiBoard` now has a **"Recent" group** (a sidebar
`RecentClock` icon + top group), matching the emoji and GIF pickers — the stickers you last sent
surface for one-click re-sending instead of hunting through packs. Only shown once you've sent at
least one sticker (hidden otherwise). Persisted in localStorage (`cinny_recent_stickers_v1`),
deduped by url, most-recent-first, capped at 16, via the pure/unit-tested `addRecentSticker`
(`src/app/state/recentStickers.ts`). Recent entries are rebuilt into minimal `PackImageReader`s
(`StickerItem` only needs `url`/`shortcode`/`body`) so they render and re-send exactly like pack
stickers. Recorded on select for both the grouped and search paths (shared delegated click).
### Message Forwarding
Context menu → **Forward** allows forwarding a message to any room the user is a member of.
### Copy Message Text
Context menu → **Copy Text** copies a message's plain-text body to the clipboard (reply fallback stripped via `trimReplyFromBody`), complementing the existing **Copy Link** (permalink) action. It renders only when the event has a usable text body, so media without a caption doesn't show an empty action.
### On-Device Message Translation
Translate chat messages written in other languages into a language you choose,
inline in the timeline — running **entirely on your device** so message text
never leaves it.
- **Per-message translate** — a foreign-language message shows a **Translate**
action in its message menu; once translated, the message displays an inline
**"Translated from <language> · Show original"** toggle that swaps between
the translation and the original text.
- **Fully on-device / E2EE-preserving** — translation and language detection run
through the browser's built-in **Translator** and **Language Detector** APIs
(the Chromium on-device AI translation models). Message text is **never** sent
to any cloud translation service — no Google / DeepL / Microsoft, not even a
self-hosted server — and there is **no network fallback**, so end-to-end
encryption is preserved. That privacy guarantee is the whole point of the
feature.
- **Automatic detection** — the language of each message is detected
automatically; messages already in your target language are skipped (no
Translate action is shown).
- **Settings (Settings → General → Messages):**
- **Translate Messages Into** — your target language (default **English**;
translates foreign-language messages whose on-device language model is
already downloaded.
- **One-time model download** — the first time you translate from a given
language, a small on-device model (a few MB) downloads once. Because the
browser requires a user gesture for that first download, the initial
translation needs a click.
- **Availability** — Chromium desktop browsers (**Chrome / Edge 138+**) and the
**Lotus desktop app** (WebView2 / Chromium). Not available in Firefox, Safari,
or mobile browsers; where the APIs are unavailable the feature hides itself and
the settings tile shows a note.
### Draft Persistence
- Composer drafts are stored in `localStorage` keyed by `roomId`
- Draft is cleared on successful send
- The Jotai atom is the primary source of truth; `localStorage` is only read on room mount
- **Room-nav draft indicator**: a subtle green dot (the composer's shared `DraftDot`, `color.Success.Main`) appears on a room's nav item when it has an unsent message draft (and isn't the open room), so you can see at a glance where you left half-written messages. The dot reuses the composer draft affordance's vocabulary (`role="img"`, aria-label "Unsent draft"). It reacts to the shared draft atom via a memoized `selectAtom(…, hasMsgDraft)` (re-renders only when the flag flips; the atom is written on room-leave, not per keystroke). `useHydrateMsgDrafts` (mounted in `ClientNonUIFeatures`) pre-fills the draft atoms from `draft-msg-*` localStorage on startup so indicators are correct after a reload. Emptiness check shared via the pure, unit-tested `hasMsgDraft` (`src/app/utils/draft.ts`), also used by the composer's `DraftIndicator`.
### Message Search Date Range
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds) passed to the search API
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds); server results are filtered client-side by `origin_server_ts` (they are not Matrix filter fields), matching the local encrypted-room search
- A chip shows the active date range with an **×** button to clear it
### Encrypted Search Cache (P4-8, opt-in)
Persistent local index for encrypted-room search, so coverage survives page reloads instead of requiring re-pagination + re-decryption every session.
- Raw IndexedDB (`lotus-search-cache`): message rows keyed `[roomId, eventId]` + per-room coverage markers; merged into local search results with in-memory-wins dedupe
- **Opt-in, default OFF** (it stores decrypted text at rest): toggle + "Clear cached index" live in the search panel's Encrypted Rooms section, with the privacy note "Stores decrypted text on this device"
- Always wiped on logout; any IndexedDB error degrades to a cache-miss (never breaks search)
- **Spec path (CS-API §11.5):** `<span/div data-mx-maths="…">` in `formatted_body` renders the attribute's LaTeX (block for div, inline for span); on render failure the element's child fallback content shows instead
- **Plain-text path:** `$…$` (inline) and `$$…$$` (block) with conservative rules — escape-aware (`\$`), currency-guarded (`$5 and $10` stays text), never inside `code`/`pre`
- **Outgoing interop:** on send, the composer converts `$…$`/`$$…$$` to spec `<span/div data-mx-maths>` HTML in `formatted_body` (extracted before markdown so LaTeX isn't mangled; off inside code), so math renders on Element and every other client — not just Lotus. The plain `body` keeps literal `$…$` as the fallback
- KaTeX + its CSS load lazily on first math encountered — zero cost to the main bundle
Images and videos can be sent with a caption. The caption and media are sent as a single event.
Images and videos can be sent with a caption. The caption and media are sent as a single event (caption = the event `body` when it differs from `filename`).
- **Edit caption**: an image/video you sent shows an **Edit caption** action (quick-actions pencil + message menu). It opens the message editor seeded with the current caption; saving sends an `m.replace` whose `m.new_content` preserves the media (`url`/`info`/encrypted `file`/`filename`) and only changes `body`/`formatted_body`. An empty caption removes it (`body` falls back to `filename`). Gated by `canEditCaption` (`utils/room.ts`) to your own image/video messages that carry an MSC2530 `filename`. Caption edits carry `m.mentions` (an @-mention in a caption notifies) and appear in Edit History (diffed by the word-diff) — even after a caption is removed, the "(edited)" marker remains so history stays reachable. No re-upload — encrypted media keeps its original file/key.
### Location Sharing
`m.location` events render an inline map tile using the coordinates from the event content.
`m.location` events render an inline map tile using the coordinates from the event content. The renderer reads the top-level `geo_uri` and **falls back to the MSC3488 `org.matrix.msc3488.location`/`m.location` `uri`**, so locations from clients that send only the new shape still render (previously they showed as broken); an MSC3488 `description`, if present, is shown above the coordinates.
Sharing your location (composer → location button) sends an **MSC3488-compliant**`m.location` event: the legacy `geo_uri` plus the `org.matrix.msc3488.location` (uri), `org.matrix.msc3488.asset` (`m.self`), and `org.matrix.msc3488.ts`/`m.ts` blocks, and a human-readable `body`. This makes Lotus-shared locations render as proper pins on Element and other clients instead of falling back to plain text.
### Deleted Message Placeholders
@@ -707,15 +789,27 @@ Redacted events display "This message has been deleted" along with the redaction
- Bookmarks are stored in `io.lotus.bookmarks` account data, syncing across all devices
- Maximum of 500 bookmarked entries
-`BookmarksPanel.tsx` is a sidebar panel accessible from the navigation rail
- Live-renders edits/redactions, text search, jump-to-message, and remove
- **Author attribution**: each saved-message card shows who wrote it (`{sender} · {time ago}`). The author is snapshotted at save time (`senderId`/`senderName` on the bookmark, optional for backward compatibility) and re-resolved live from the event when the room is joined; search also matches the author name.
- **Sort & group**: a Newest / Oldest / By-room segmented control sorts the list; "By room" renders collapsible per-room sections (groups ordered by most-recent save). The chosen sort persists across panel opens (`cinny_bookmarks_sort_v1`). Ordering/grouping logic is pure and unit-tested in `src/app/utils/bookmarks.ts` (`bookmarks.test.ts`).
- Hook: `src/app/hooks/useBookmarks.ts`
### Message Scheduling
- Implements MSC4140 delayed events for scheduling messages to be sent at a future time
-`ScheduleMessageModal.tsx` provides the date/time picker UI
- A collapsible "Scheduled" tray in the room shows all pending scheduled messages with individual cancel buttons
- A collapsible "Scheduled" tray in the room shows all pending scheduled messages with individual send-now, edit, and cancel buttons
- **Send now**: the tray's send button fires a pending message immediately via MSC4140 `action: 'send'` (the server dispatches the stored delayed event now, as a normal timeline event — no cancel+retype). The row is pruned only once the server confirms; a failed send leaves an inline "Could not send now" error with the message still sendable/editable/cancellable.
- **Edit / reschedule**: the tray's edit button re-opens `ScheduleMessageModal` (seeded with the existing body + send-time) to change the text and/or time. Since MSC4140 has no in-place edit, this is implemented as schedule-new-then-cancel-old; the old copy is only removed once the server confirms cancellation, so a failed cancel leaves a visible, cancellable copy rather than losing the message. Edits go through the plain-text composer (rich content becomes `m.text`).
- Utilities in `src/app/utils/scheduledMessages.ts`
### Message Reminders
- Message context menu → **Remind Me** sets a personal reminder to revisit a message; reminders are stored in `io.lotus.reminders` account data (sync across devices) via `useReminders`, and fire from `ClientNonUIFeatures`.
-`RemindMeDialog.tsx` offers quick presets (in 20 min / 1 hour / 3 hours / tomorrow 9am) **plus a "Custom time…" option** that reveals date + time pickers for an arbitrary reminder time (validated to be ≥ 1 minute in the future).
- **Manage existing reminders**: opening the dialog on a message that already has reminders lists them (soonest first, friendly time via `formatFriendlyDateTime`) each with a cancel (×) button, so you can see and remove pending reminders instead of silently stacking duplicates.
- The date/time input helpers (`toLocalDate`, `toLocalTime`, `parseLocalDateTime`, `pickerInputStyle`) are shared, pure, and unit-tested in `src/app/utils/datetimeInput.ts` (`datetimeInput.test.ts`) — also used by `ScheduleMessageModal` (deduped from a prior inline copy).
### File Upload Compression (opt-in)
- Implemented in `UploadCardRenderer.tsx`
@@ -748,20 +842,130 @@ Redacted events display "This message has been deleted" along with the redaction
Generic (non-domain-specific) cards display a Google S2 favicon. Empty or unparseable preview responses are suppressed entirely rather than showing a blank card.
### Inline Media Embeds
Media links play/render **in place** instead of opening a browser tab. A pure
resolver, `parseMediaEmbed(url, host)` in `src/app/utils/videoEmbed.ts`, maps a
-Supports both single-choice and multiple-choice modes
-`PollCreator.tsx` creates stable `m.poll.start` events (with a text fallback body for non-poll clients)
- Supports 2 to 10 answer options; single-choice or multiple-choice
-**Max selections** — for a multiple-choice poll, a "Voters can pick up to N of M options" control sets `max_selections` (2 … option count), so you can run "pick your top 2" polls rather than only "select all". Defaults to the option count (unchanged "select all that apply" behavior) until you lower it; the display side already enforces the cap ("Select up to N")
- **Results visibility toggle** — _Show live results_ (disclosed, default) vs _Hidden until ended_ (undisclosed)
- Accessible via the `Icons.OrderList` button in the composer toolbar
### Poll Display
### Poll Display & Voting (MSC3381, full lifecycle)
`PollContent.tsx`renders polls in read-only mode. Handles both the stable `m.poll` format and the legacy MSC3381 unstable `org.matrix.msc3381.poll.start` format. Displays current vote counts and a note directing users to Element to cast votes.
`PollContent.tsx`is a fully interactive, spec-correct poll card:
### Voice Message Playback Speed
- **Vote / change / clear** in place — sends stable `m.poll.response` (`m.selections`); latest response per voter wins; clearing removes you from the tally. Multi-choice enforces `max_selections` ("Select up to N").
- **Disclosed vs undisclosed** — undisclosed polls hide counts/percentages/bars (and the vote total) until the poll ends; disclosed polls show live results.
- **See who voted** — a "Show who voted" toggle (shown only when results are visible, i.e. disclosed live or undisclosed-after-end) reveals the voter names under each answer. The voter list rides the same tally as the counts (`voters: Map<answerId, senderId[]>` populated in `tallyResponses`'s latest-response-per-sender loop), so it can never disagree with the numbers; a re-vote moves the voter, and a cleared vote drops them. Names via `getMemberName`; undisclosed polls stay secret until close.
- **End a poll** — the poll's creator or a moderator (redact power) can end it via an inline confirm; sends stable `m.poll.end`. Ended polls lock voting, show "Poll closed · Final results", reveal results, and highlight the winner(s) (ties supported). Only responses cast on/before the end event count.
- **Cross-client** — reads **both** the stable (`m.poll`/`m.id`/`m.selections`) and unstable (`org.matrix.msc3381.poll.*`) wire formats by hand (matrix-js-sdk 41.7.0's poll parsers only speak unstable), and uses the SDK `Poll` model for end-event validation (creator / redact-PL) + before-end response filtering. Polls authored in Element render/vote/end correctly and vice-versa.
- **Accessibility** — `radiogroup`/`radio` (single, with arrow-key roving) or `group`/`checkbox` (multi) semantics, `aria-checked`/`aria-disabled`, winner announced to AT.
- Pure tally/visibility/winner/voters + wire-format parsing live in `utils/poll.ts` (+ `poll.test.ts`, 18 tests incl. the stable/unstable round-trip and voter attribution).
`AudioContent.tsx` adds a playback speed cycle button to voice message players. Available speeds: `[0.75, 1, 1.5, 2]×`. A `useEffect` sets `audioElement.playbackRate` whenever the speed selection changes.
### Voice Message Playback (waveform + speed)
`AudioContent.tsx` is the shared audio player (timeline + Media Gallery Audio tab):
- **Waveform scrubbing** — voice messages carry an MSC1767 waveform (`org.matrix.msc1767.audio.waveform`, sent by the recorder). Playback renders it as bars that fill with the accent as the clip plays (TDS green under Lotus Terminal), and the waveform **is** the seek control — click, drag, or keyboard (arrows ±5s, Home/End) to scrub (`role="slider"`, ARIA value text). Threaded through `MAudio` (`MsgTypeRenderers.tsx`) + passed directly by the gallery. Regular audio with no waveform keeps the plain seek bar.
- **Playback speed** — a cycle button (`[0.75, 1, 1.5, 2]×`); a `useEffect` sets `audioElement.playbackRate` and re-applies it on (re)load (the browser resets it).
- **Recording pause / resume** — `VoiceMessageRecorder.tsx` supports a `paused` state via `MediaRecorder.pause()/resume()`, so you can pause mid-recording and continue without a gap. The duration timer accumulates only active-recording time (paused time is excluded), the waveform/meters freeze while paused and the record dot stops pulsing, and the finish button (which advances to the preview/review step) is a checkmark distinct from the Pause control.
---
## Threads (P3-8)
Full threaded-conversation support (`m.thread`, matrix-js-sdk `threadSupport`), Element-consistent.
### Thread Panel
A right-side drawer (mirrors the members drawer; fullscreen on mobile) with the thread's root message emphasized at top, an "N replies" divider, the full reply timeline (virtualized, back-paginates via `/relations`, decrypts E2EE threads), reactions/edits/redactions, and its own composer. Open it from **Reply in Thread** in the message menu, a reply's thread indicator, or a summary chip; close with **×** or Escape. Reading the panel sends threaded read receipts so per-thread unread counts clear.
### Threads List Panel
A room-level overview of **all** threads, opened from a **Threads** button (🧵) in the room header (mirrors the gallery/widgets toggles). Each row shows the root sender + message snippet, an unread dot, a meta line ("N replies · last reply 5m ago") and a **participant avatar pile**. A segmented **filter** (All / Unread / Participating — the latter via `thread.hasCurrentUserParticipated`) and **sort** (Recent / Oldest, by last-reply time) sit in the toolbar; both persist in localStorage (`cinny_threads_filter_v1` / `cinny_threads_sort_v1`). Clicking a row opens the existing single-thread `ThreadPanel` (reuses `setActiveThreadId`), and reading it clears the row's unread badge live. The list stays live via `ThreadEvent.New/NewReply/Update/Delete` + `RoomEvent.UnreadNotifications` and is virtualized for busy rooms.
- Files: `features/room/thread/ThreadsListPanel.tsx`, `hooks/useRoomThreads.ts` (populates via `room.fetchRoomThreads()` + `room.getThreads()`; last-activity + reply-count + root-edit signature so rows refresh live), `state/threadsList.ts`, pure filter/sort in `utils/threadList.ts` (`filterThreads`/`sortThreads`, unit-tested). Unread mirrors `useThreadSummary`'s logic (`getThreadUnreadNotificationCount`, muted threads zeroed) and renders the app-wide `UnreadBadge` (red for mentions via the Highlight count). Reuses `StackedAvatar`/`useMemberAvatar` for the participant pile and the Bookmarks-panel segmented-control pattern.
### Summary Chips
Root messages in the main timeline show a **"N replies · time"** chip (server-aggregated `m.thread` bundle, or the live Thread once loaded) with an unread badge — threaded replies no longer render inline in the main timeline, so the chip is how conversations stay discoverable.
### Thread Composer
The panel embeds the full composer (uploads, emoji, stickers, GIFs, voice, location, polls) with drafts, reply state, and upload queues **isolated per thread** (`roomId::threadRootId` keys). Replies-to-replies produce spec-correct `m.thread` + `m.in_reply_to` (`is_falling_back: false`). **Slash commands work in threads** — content-transform commands (`/me`, `/notice`, `/shrug`, `/tableflip`, `/unflip`) route into the thread via the normal send path, and room-level commands (`/invite`, `/kick`, …) act on the room; this also matches the command autocomplete, which was already shown in the thread composer. Scheduling is still disabled inside threads (v1).
**↑ to edit last reply**: pressing Up-arrow in the empty thread composer opens the editor on your most recent editable reply _in that thread_ — parity with the main timeline. The thread composer carries a distinct `editableName="ThreadInput"` so the main timeline's global up-arrow handler and the thread's no longer cross-fire (previously the thread composer had the same name, so Up-arrow there wrongly targeted the main timeline's last message).
### Notifications (Slack-style, P4-1)
By default you're notified for a thread reply only when you **participate** in that thread (you've posted in it) or the reply **@mentions** you — other threads accumulate quietly behind their chip badges. Every thread can be overridden from the bell menu in the panel header: **Default (participating) / All replies / Mentions only / Mute**. Modes sync across your devices (`io.lotus.thread_notifications` account data, auto-pruned). Muting a thread silences notifications and sounds, removes the chip's unread badge (a small bell-mute glyph shows instead), and subtracts that thread from the room's sidebar unread badge (client-side — other Matrix clients on the account still count it).
### Under the Hood
-`threadSupport: true` (startClient) partitions thread events into SDK `Thread` timelines; markAsRead sends **unthreaded** receipts so room badges keep clearing
- Thread replies are notified via exactly one path (room-level `ThreadEvent.NewReply` w/ per-thread dedupe + panel-aware focus suppression); the main timeline notifier is thread-guarded, and room badges refresh live on `RoomEvent.UnreadNotifications`
- Pending sends render via a `LocalEchoUpdated` strip (chronological local echo never enters thread timelineSets)
- Deep links to thread events redirect into the panel
- Files: `features/room/thread/*`, `state/room/thread.ts`, `hooks/useThreadSummary.ts` (+35 tests across the stack)
---
@@ -785,9 +989,11 @@ A presence status selector in the user panel offering five modes:
### Custom Status Message
- Up to 64 characters of free text plus an emoji
- **Emoji picker is unicode-only** (`EmojiBoard hideCustomEmojis`): a status is plain-text presence (`status_msg`) that can't render a custom mxc-image emoji, so the picker hides custom/image-pack emojis (packs, sidebar icons, search, and custom entries in Recent) — every emoji shown actually inserts. (Previously custom emojis were listed but silently did nothing when clicked, since there was no `onCustomEmojiSelect` on this field.)
- Optional auto-clear timer with presets: 30 minutes, 1 hour, 4 hours, 1 day, 3 days, 7 days
- Status is broadcast via `mx.setPresence({ status_msg: ... })`
- Character counter appears at 56/64 characters remaining to warn of the limit
- **Status presets**: a "Quick statuses" row of 11 built-in presets spanning gaming/social/life/work — 🎮 Gaming, 🎧 In a party, 🏆 Ranked grind, 😴 AFK, 🍿 Watching, 🍽️ Lunch, 🗓️ In a meeting, 🏠 Working remotely, 🎯 Focusing, 🌴 On vacation, 🤒 Out sick (see `BUILT_IN_STATUS_PRESETS`). Clicking a preset applies its message + suggested auto-clear in one click. Users can also save the current status as a reusable custom preset (stored in `io.lotus.status_presets` account data, synced across devices, de-duped by label, capped at 20; a saved preset matching a built-in is hidden to avoid a duplicate chip) and delete presets inline. Built-in list + pure `upsertPreset` de-dupe/cap logic live in `src/app/utils/statusPresets.ts` (unit-tested); persistence in `src/app/hooks/useStatusPresets.ts`.
### Presence Badges
@@ -817,10 +1023,12 @@ Fixed by replacing the single read with a `readStatus()` function called inside
The browser tab title updates to reflect unread state:
-`(N) Lotus Chat` — N unread messages
-`· Lotus Chat` — unread activity without a specific count
-`(N) Lotus Chat` — N mentions / keyword highlights (the count is highlights, not total unread)
-`· Lotus Chat` — unread messages without a mention (activity, no specific count)
-`Lotus Chat` — no unread items
The favicon mirrors this (highlight badge / unread dot / default).
### Extended Profile Fields
Supports MSC4133 custom profile fields via `PUT /_matrix/client/unstable/uk.tcpip.msc4133/{userId}/{field}`:
The Forward Message dialog is a checkbox multi-select: pick any number of rooms (search + select persist across queries) and **"Send to N rooms"** forwards in one batch (`Promise.allSettled`). Full success auto-closes; a partial failure keeps the dialog open with a "Forwarded to X/N — failed: …" summary. The forwarded content (latest edit via `m.new_content`, reply-quote stripped, undecryptable refused) is built by the shared, unit-tested `forwardContent.ts`.
- **Message preview**: a compact preview at the top of the dialog shows the sender + body (and a thumbnail for image/video) so you can see what you're forwarding.
- **Optional comment**: an "Add a comment" field sends a short `m.text` note to each target room _before_ the forwarded message (sequenced per room; a room counts as failed if either send fails).
- **Recent targets**: a "Recent" chip row (hidden while searching) surfaces the rooms you last forwarded to for one-tap selection. Successful targets are recorded most-recent-first, deduped, capped at 8, in localStorage (`cinny_recent_forward_targets_v1`) via the pure, unit-tested `addRecentForwardTarget` (`state/recentForwardTargets.ts`); rooms you've since left are dropped from the row.
### Live Bookmark Previews (P6-3)
`BookmarksPanel` resolves each saved message's **live event** (`useRoomEvent`) so previews reflect **edits** and show a **deleted** indicator for redactions, instead of the save-time snapshot. The stored snapshot (`previewText`) remains the fallback while loading, on fetch failure, or when you've **left the room**.
### Message Length Counter
A character count indicator is shown in the composer when `charCount > 0`. The counter resets to zero when switching rooms.
@@ -880,10 +1100,9 @@ OS-level notifications are unchanged and still fire when the window is not focus
### Collapsible Long Messages
Messages exceeding a configurable line threshold are truncated with a "Show more" toggle.
Messages exceeding a fixed height threshold are truncated with a "Show more" toggle.
-Default threshold: 20 lines
- Threshold is configurable in **Settings → Appearance**
-Threshold: a fixed `COLLAPSE_MAX_HEIGHT` of 320px (≈ 20 lines) — not currently user-configurable
- Uses CSS `max-height` + `overflow: hidden` with a smooth transition
- Transition is disabled when `prefers-reduced-motion: reduce` is active
@@ -946,7 +1165,8 @@ Persists via the `homeRoomSort` setting.
`RoomShareInvite.tsx` provides a shareable invite UI:
- 160×160px QR code generated via `api.qrserver.com`
- 160×160px QR code generated locally via `qrcode.react` (`QRCodeSVG`) — no third-party service, works offline and under strict CSP, on a white quiet-zone so it scans on any theme
- **Download QR**: exports the code as a PNG via an offscreen high-resolution (1024px, spec 4-module margin) `QRCodeCanvas` + `canvas.toBlob`, saved through `useSaveFile` (filename derived from the room name) with the standard download toast
- "Copy Link" button to copy the `matrix.to` URI
- Also accessible via a toggle button (⊞) in the Invite modal
@@ -954,14 +1174,41 @@ Persists via the `homeRoomSort` setting.
A toggle in **Settings → Privacy** switches between sending `m.read` (public receipts) and `m.read.private` (private receipts visible only to the sender and the server).
### Tracking-Parameter Stripping (Gitea #103)
Links you paste, send, edit, or merely _see_ lose ad/analytics identifiers — `utm_*`, `fbclid`, `gclid`, YouTube `si=`, Amazon `ref=`/`tag=`, X `s=`/`t=`, TikTok `_r`/`_t`, and ~40 more, plus host-scoped rules so e.g. `si` is only removed on YouTube/Spotify. Runs entirely on the device (`src/app/utils/urlTracking.ts`, unit-tested). Wired at paste (re-inserted through Slate so multi-line pastes still split into paragraphs), at send/schedule/edit on both `body` and `formatted_body`, and at render (linkify + explicit `<a href>` in formatted HTML), so links from other clients are cleaned locally too. `matrix.to` and non-http(s) schemes are never touched; Amazon's `th`/`psc` variant selectors are kept. Toggle in **Settings → Privacy → Strip Tracking Parameters from Links** (default on).
### Settings Sync Across Devices (Gitea #104)
The syncable subset of Lotus settings (theme, composer toolbar order, notification/quiet-hour preferences, call keys, privacy toggles, …) is mirrored to the `io.lotus.settings` account-data event on the user's own homeserver and applied on every other device. Device-bound keys stay local (`DEVICE_LOCAL_KEYS` in `src/app/utils/settingsSync.ts`: page zoom, media auto-load, animation pause, glassmorphism, noise-suppression tier/model, bitrates, volumes, notification permission, developer tools, PTT mode, camera-on-join, drawer state). Conflicts are last-write-wins on an `updatedAt` stamp forced monotonic per device; a per-account `lastSyncedAt` marker in localStorage stops a device from echoing a snapshot it just applied. **Settings → General → Sync** has the toggle (itself device-local), **Push now** (make this device win everywhere) and **Clear synced copy**. Hook: `src/app/hooks/useSettingsSync.ts`, mounted from `ClientNonUIFeatures`.
### Push to Deafen: off switch + typing-safe
**Settings → Calls → Push to Deafen** now has a switch (off = no key toggles deafen; the call-bar headphone button remains). Root cause of the "I went deaf while typing" reports: Cinny's type-anywhere-to-focus-the-composer and the deafen key both listen on `window`, so with the default `M` the first letter of a message typed after clicking the timeline toggled deafen and was swallowed (`mom` → `om`). Rules now: a typable key (letter/digit/Space/…) only toggles deafen in the call view — on any screen with a composer, typing wins; and such keys are never bound system-wide on desktop (`isSafeGlobalToggleKey`: F-keys, numpad, lock/navigation cluster qualify), so the letter `m` typed in Discord or a game can't deafen you either. The tile explains this and suggests an F-key/Numpad key for an everywhere binding.
### Forwarded messages show their provenance
A forwarded message used to arrive as if the forwarder had written it. `buildForwardContent` now stamps `io.lotus.forwarded` (`sender`, `origin_server_ts`, `room_id`, `event_id`; forwarding a forward keeps the _original_ stamp) and the timeline (main + threads) renders a reply-style line above the message — **↪ Forwarded from bob in Other Room · 9:05 PM** — which is a button that jumps to the original when you are in the source room; if you are not, it shows only the sender and time (the source room's name is deliberately not shared). Other Matrix clients ignore the key and see the plain content. Component: `src/app/components/message/ForwardedHeader.tsx`.
### Copy Lotus Link — direct permalinks (Gitea #130)
`matrix.to` cannot be pointed at this deployment (its Cinny adapter is hard-coded to `app.cinny.in`; `web-instance[]` only works for Element), so every **Copy Link** (message ⋯ menu, space header menu, sidebar space-tab menu) has a **Copy Lotus Link** beside it that yields `https://chat.lotusguild.org/home/<room>/<event>?viaServers=…` (spaces: `/<space>/`). Helpers in `src/app/plugins/lotus-permalink.ts` (unit-tested). Lotus links pasted into a room render and click like matrix.to links (`toMatrixToHref` in the HTML parser rewrites them into the existing mention pipeline). Supporting fixes: `/home/<room>` for a room you are already in but that lives under a space or in Direct now redirects to its own route instead of a preview card (this is also the form matrix.to → "Continue in Cinny" produces); `?via=a,b` is accepted as an alias of `?viaServers=` (the matrix.to Cinny adapter emits `via`); and a deep link opened while logged out is honoured after an **OIDC/SSO** login too — the OIDC callback reloads at the app root, which previously discarded the stored path (`takeAfterLoginPath` is now consumed by the index route as well as the password flow). matrix.to stays the default, interoperable link and the Share Room QR is unchanged.
### PWA App-Icon Badge (Gitea #154)
When Lotus Chat is installed as a PWA (Android Chrome, desktop Chrome/Edge), the app icon carries a numeric badge via the Badging API (`navigator.setAppBadge`). The number is the same highlight count (mentions/DMs, leaf rooms only) that the tab title shows, so the two can never disagree; it clears when the count reaches zero. Lives in `FaviconUpdater` (`ClientNonUIFeatures.tsx`) next to the title/favicon logic. No-op in a plain browser tab (the API is absent) and under Tauri, where the native `set_badge_count` already owns the badge.
### Media Gallery
`MediaGallery.tsx` — a right-side drawer for browsing room media.
-Three tabs: **Images**, **Videos**, **Files**
-Reads already-decrypted events from the room timeline
-Encrypted images show a lock placeholder rather than an error
-"Load More" button triggers `mx.paginateEventTimeline()` to fetch older media
-Four tabs: **Images**, **Videos**, **Audio**, **Files** (each with a live count)
-**Images/Videos** — a month-grouped grid; tiles decrypt on demand (lazy, near-viewport), open a keyboard-navigable **lightbox** (←/→/Esc, prev/next). Each grid tile has a hover/focus **download** button, and the lightbox header has a **Download** action — both reuse the shared `FileDownloadButton` (full-resolution source, decrypts E2EE media client-side, spinner/✓/retry states), so images and videos can be saved without jumping to the message. On touch (no-hover) devices the tile download button stays visible. In the lightbox, **images support zoom & pan** (scroll wheel or −/+ header buttons, `+`/`-`/`0` keys, double-click or the % chip to toggle 1×↔2×; drag to pan when zoomed) via the shared `useZoom`/`usePan` hooks; zoom resets when navigating to another item.
-**Audio** — voice messages + audio files (`m.audio`) with an inline player (reuses `AudioContent`: **waveform scrubbing** for voice messages, play/seek/**speed control**; decrypts on play)
-**Files** — name/size/sender rows with download
- **Jump to message** — a "Go to message" action on file rows, audio rows, and in the lightbox navigates the timeline to the source event (`useRoomNavigate`) and closes the drawer
- Encrypted media is decrypted client-side on demand (no lock placeholder); download works for all types
- **Auto-pagination** — an `IntersectionObserver` sentinel pulls older media as you scroll (manual retry on error). Since Gitea #163 this pages through the gallery's **own timeline set** (`useRoomMediaTimeline` → `utils/detachedTimeline.ts`), never the room's live timeline: unencrypted rooms use a server-side `contains_url` filter (a page is 100 media events, not 100 events), encrypted rooms page raw history into a private set and filter after decrypting. The message list behind the drawer no longer jumps into the past; the Activity log and history Export use the same helper.
A leading emoji in a room name is rendered at 1.15× size in the sidebar for visual hierarchy. An emoji picker button (😊) is added to all room name input fields, prepending the selected emoji to the room name.
An emoji picker button (😊) is added to all room name input fields, prepending the selected emoji to the room name.
### Configurable Composer Toolbar (P3-6)
@@ -1070,9 +1317,9 @@ Features:
Accessible via **Room/Space Settings → Policy Lists** (admin only).
-Displays the room's subscribed policy lists in read-only format
-Subscribe (join) and unsubscribe (leave) controls for each list
- Enforcement is delegated to Draupnir or equivalent tooling; Lotus only manages list membership
-Enter a policy-list room's **ID or alias** (one you have already joined) to view its `m.policy.rule.user` / `.room` / `.server` rules in read-only format
-Viewer only — there are **no** subscribe/unsubscribe controls and no listing of "subscribed" lists; join or leave the policy-list room itself the normal way
- Enforcement is delegated to Draupnir or equivalent tooling
- Gates both `notify()` (visual/OS notifications) and `playSound()` (audio alerts)
- When active, notifications are silently dropped rather than queued
### Pause Notifications (snooze)
- A **cross-platform** "Pause Notifications" control in **Settings → Notifications** (the desktop-tray Do Not Disturb only worked on the desktop app; web/mobile had no manual pause).
- Quick presets: 30 minutes / 1 hour / 4 hours / Until 8 AM / **Until I resume** (indefinite), plus a **Resume** button; the tile shows the live "Paused until …" status (via `formatFriendlyDateTime`) and flips back to "on" the moment the snooze lapses.
- Persisted (`cinny_notification_snooze_until_v1`) as the epoch-ms instant to pause until (`0` = off), so a snooze survives a reload. Feeds the same notification gate as Focus Assist / Quiet Hours (`ClientNonUIFeatures`), suppressing both `notify()` and `playSound()`.
- Pure, unit-tested helpers `isSnoozeActive` / `nextTimeAtHour` / `SNOOZE_INDEFINITE` in `src/app/utils/snooze.ts` (`snooze.test.ts`); persisted atom in `src/app/state/notificationSnooze.ts`.
### Full Push Rule Editor
A complete UI for managing Matrix push notification rules:
@@ -1130,6 +1384,18 @@ Three one-tap presets at the top of **Settings → Notifications** that apply a
---
## Accessibility (P3-4)
WCAG 2.1 AA hardening of the golden path (find room → read → reply → send) for keyboard and screen-reader users.
- **Timeline for screen readers:** each message is `role="article"`; **collapsed messages announce their sender + time** (they drop the visible header, so AT would otherwise hear the body with no attribution). The timeline is a `role="log"``aria-live="polite"` region so new messages are announced; emoji/emoticons carry text labels.
- **Live status:** typing indicators announce via a `role="status"` region; editing a message announces "Editing message from <sender>".
- **Forms & overlays:** all inputs have associated labels (visible `<label htmlFor>` or `aria-label`); the Media Gallery and Search overlays are named.
- **Focus management:** skip-to-content link + `nav`/`main` landmarks; genuine dialogs return focus to their trigger on close (inline popouts intentionally keep focus in context).
- **Keyboard-shortcuts help:** press <kbd>?</kbd> for a dialog of the existing shortcuts (Escape, type-to-focus composer, Enter/Shift+Enter send, message actions).
- **Regression gate:** a curated `eslint-plugin-jsx-a11y` rule set (ARIA correctness + label association) runs in CI. Files: `components/message/*`, `features/room/RoomViewTyping.tsx`, `features/shortcuts/*`, `utils/a11y.ts`, `eslint.config.mjs`.
- _Known limitation:_ list virtualization keeps far-scrolled history out of the a11y tree (perf trade-off); newly-arriving messages are announced.
## Infrastructure
### Authenticated Media
@@ -1160,6 +1426,30 @@ The `useAuthentication` parameter was previously mispositioned, causing unauthen
The `encUrlPreview` setting defaults to `true` rather than `false`. A security advisory chip in **Settings → Privacy** explains the tradeoff (the homeserver can see which URLs are being previewed) so users can make an informed choice.
The session persists as ONE atomic `cinny_session_v1` JSON write (previously ~10 separate localStorage keys written non-atomically). Reads prefer the blob with transparent migration from the legacy keys (dual-written one release for rollback). Cross-tab sync: logging out or in from one tab reloads the others so no tab runs with stale credentials. `state/sessions.ts` (22 tests), `hooks/useSessionSync.ts`.
### Crypto Diagnostics (E2EE investigation kit)
**Settings → Developer Tools → Crypto Diagnostics**: a capture-only ring buffer (max 200) hooks `console.warn/error` for E2EE failure signatures (OTK upload conflicts, missing call media keys, decryption errors, delayed-event timeouts) and downloads a JSON report — the evidence input for the KE-1→4 investigation. Companion diagnosis: the Encryption / E2EE section of [`LOTUS_REFERENCE.md`](./LOTUS_REFERENCE.md). `utils/cryptoDiagLog.ts`, `features/settings/developer/CryptoDiagnostics.tsx`.
---
## Localization
Lotus Chat is **English-only for now**, by explicit decision (Sept 2026 audit, #53). The i18next
mechanism (`i18next-browser-languagedetector` + `public/locales/{en,de}.json`, wired in
`src/app/i18n.ts`) is real and still used by the ~11 upstream-inherited files that call
`useTranslation()`, but none of the Lotus-added UI (presence picker, calls/soundboard, avatar
decorations, seasonal settings, keyboard shortcuts help, toasts, etc.) is routed through it. Letting
the language detector pick a non-English browser locale therefore produced a UI that was only
partially translated. `src/app/i18n.ts` now sets `supportedLngs: ['en']` so the whole app renders
consistently in English regardless of browser locale, while leaving the detector/backend/`de.json`
in place. Re-enabling another language requires two things: (1) route Lotus strings through
`useTranslation()`/`public/locales/<lng>.json` like the existing localized files, then (2) drop (or
extend) `supportedLngs` in `src/app/i18n.ts` — a one-line change.
---
## Desktop App Features
@@ -1198,6 +1488,19 @@ Windows toasts with **click-to-open-room** and **inline quick reply** (WinRT `To
When Windows Focus Assist / Quiet Hours is active, Lotus suppresses its own notifications + sounds (reuses the quiet-hours gate). `useTauriFocusAssist` + `focusAssistActiveAtom` ↔ `native/focus_assist.rs` (`SHQueryUserNotificationState`).
### Linux parity + cross-platform extras (P6-1)
Rounds out the native app beyond Windows (macOS out of scope):
- **No-sleep during calls on Linux** — a D-Bus `org.freedesktop.ScreenSaver` inhibit (zbus) keeps the display awake mid-call, matching the Windows behavior. `native/power.rs`.
- **Launcher unread badge on Linux** — best-effort Unity `LauncherEntry` D-Bus signal (Ubuntu/Dash-to-Dock/KDE), mirroring the Windows taskbar badge.
- **Launch on login** — `tauri-plugin-autostart` + a **Settings → General "Launch on login"** toggle (desktop-only).
- **Tray "Do Not Disturb"** — a tray checkbox that silences Lotus notifications (feeds `manualDndAtom` into the same quiet-gate as Focus Assist). `useTauriDnd`.
### System-Wide Voice Hotkeys (cinny-desktop #2)
Push to Talk and Push to Deafen keep working while a game or any other app has focus. The desktop does **not** register a global shortcut (that would swallow the key from every app — a bare `Space` PTT would stop other apps typing spaces); instead, only while a call is joined, a native thread polls `GetAsyncKeyState` for the two configured keys every ~8 ms and emits a `lotus-global-hotkey` DOM event on each press/release transition (`src-tauri/src/native/hotkeys.rs`). `useCallHotkeys` ignores those events while the Lotus window itself has focus (the DOM handlers own that case with their editable-field checks), so nothing double-fires. Windows only — Linux/Wayland has no non-consuming path; `global_hotkeys_supported` reports it and the toggle is hidden outside Tauri. Toggle: **Settings → Calls → Hotkeys Work Outside the Window** (device-local, default on).
### Custom Window Chrome (P5-47)
Opt-in (Settings → General → **Custom Window Chrome**): replaces the OS title bar with a TDS-styled titlebar (min / max / close + drag region), runtime-reversible via `set_decorations`. `features/desktop/TitleBar.tsx` + `useTauriWindowChrome` ↔ `native/chrome.rs`.
**Repo:**`lotus` branch at `https://code.lotusguild.org/LotusGuild/cinny`
**Deploy:** push to `lotus` → CI → auto-deploy to `chat.lotusguild.org` (~11 min)
> **There is no backlog in this file.** All open work lives in Gitea issues — [cinny](https://code.lotusguild.org/LotusGuild/cinny/issues), [element-call](https://code.lotusguild.org/LotusGuild/element-call/issues), [cinny-desktop](https://code.lotusguild.org/LotusGuild/cinny-desktop/issues), [matrix](https://code.lotusguild.org/LotusGuild/matrix/issues) (infra/CI) — grouped by milestone (`Features 2026-Q4`, `Desktop 2026-Q4`, `Manual QA backlog`, `Desktop QA backlog`, the `Audit 2026-09 · *` set). Shipped features are documented in [LOTUS_FEATURES.md](./LOTUS_FEATURES.md); how to run the automated tests is in [LOTUS_TESTING.md](./LOTUS_TESTING.md). The former `LOTUS_TODO.md` backlog was migrated to issues on 2026-09-17 (full history in git).
This file keeps only what a contributor needs to have open while working: the two design laws, decisions already made, what the server blocks, and the operational reference.
---
## ⚠️ 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).
---
## Deferred / dropped (decided — kept for context)
- **[DEFERRED] P5-51** Federated "Identity Contexts" (session isolation) — multi-sprint, touches auth/crypto/storage core; smaller intermediate step = plain multi-account switch. **[DROPPED] P5-52** per-room sync governor — js-sdk can't truly per-room filter `/sync`; only a cosmetic hide. **[DEFERRED] P5-53** local scripting plugin — prefer a declarative automation-rules feature (no arbitrary code). **[DEFERRED] Audit-3** profile banner — MSC4427 open/unmerged; revisit on merge. **[WON'T FIX] P5-50** Windows HW media pipeline (WebRTC decode lives in WebView2; not injectable). **[MOVED] P5-9** LFG → LotusBot `!lfg`.
---
## 🚫 Blocked Features (server / upstream gated)
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.157.1 (2026-07-23): no change — all four below are still `false`.** The 1.156.0→1.157.1 delta unblocked nothing (it's a bugfix release; the only feature-bearing release in the gap was 1.156.0, which we were already running).
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
- **[DONE 2026-07] Room Preview before joining** (MSC3266) — the client was always built (`JoinBeforeNavigate` → `RoomCard` via `mx.getRoomSummary`). The earlier "blocked" flag was a **misdiagnosis**: it tested `/v1/rooms/{id}/summary` (404), but the SDK calls the _unstable_`im.nheko.summary/summary/{id}` path, which returns **200** with name/topic/members/join_rule. Verified live after the 1.156 upgrade; also added a join-rule/encryption chip + Request-to-join for knock rooms to the preview card.
- **[BLOCKED] Thread Subscriptions** (MSC4306 `false`) — "Follow thread" button (depends on the shipped Thread Panel).
---
## 📖 Reference
### Server Capabilities (as of 2026-07)
- **Homeserver** `matrix.lotusguild.org` · **Synapse**`1.157.1+trixie1` (upgraded 2026-07-23 from **1.156.0** — note the host was found on 1.156.0 while the docs claimed 1.155.0, so **always verify with `dpkg-query -W matrix-synapse-py3`**, don't trust the docs; apt package on Debian 13, LXC 151) · **Matrix spec** up to `v1.12` (Synapse still advertises v1.12; MSC features via `unstable_features`).
- **MSC ON** (re-dumped live from `/_matrix/client/versions` on 1.157.1): `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` (room summary live at unstable `im.nheko.summary/summary/{id}` — 200; the `/v1/rooms/{id}/summary` path is still 404) · `msc3401_matrix_rtc` · `msc2285.stable` · `msc3827.stable` · `msc3981` · `msc4380.stable` · `msc4445` · `msc2659.stable` · `msc2666` · `msc2432` · `e2e_cross_signing` · `label_based_filtering`. **OFF/blocked:**`msc4306` · `msc3882` · `msc3912` · `msc4155` · `msc3489`/`msc3672` · `msc3892` · `msc4028` · `msc4069` · `msc4108` · `msc3391` · `msc4354` (sticky events — **deliberately off**, see the Matrix 2.0 section above) · `msc4143` (RTC foci — **not a gap**: LiveKit is discovered via `.well-known``org.matrix.msc4143.rtc_foci`, confirmed live, not this flag).
- **Dead client code:** Synapse 1.157.0 **removed**`msc3861` (MAS auth delegation) entirely — the ~6 `msc3861`/`msc2965` references in `src/` can never activate against this homeserver (we auth via Authelia `oidc_providers`). Harmless, but cleanup material.
Fork = `LotusGuild/element-call` (branch `lotus`, upstream base **v0.25.0** since the 2026-09 sync — was v0.20.1); cinny consumes the npm package `@lotusguild/element-call-embedded` (built bundle copied into `public/element-call/`).
**Toolchain (upstream-driven, accepted 2026-09):** Node ≥ 22.13 (`.node-version` = 24) and **pnpm 11**, installed directly (`npm i -g pnpm@<packageManager version>`, currently 11.21.0) — **not** via `corepack enable`: `matrix-js-sdk` is a git dependency pnpm builds from source, and its own devEngines pins pnpm 11.9.0; a corepack-shimmed pnpm refuses to switch for that nested install and `pnpm install` fails (fork CI run #1854). pnpm 10 rejects the lockfile and Node 20 cannot build. Lint is **oxlint + oxfmt** (upstream dropped eslint/prettier in v0.25.0): `pnpm lint` (tsc + oxlint + knip) and `pnpm format:check` / `pnpm format`. `matrix-js-sdk` is pinned to a `matrix-org/matrix-js-sdk#develop` commit in the lockfile, as upstream ships it. Fork CI (`.gitea/workflows/ci.yml`) hard-gates lint + format + `pnpm test:unit` before build, with `concurrency: cancel-in-progress`.
**Publish a new version (CI on tag push; needs the `NPM_PUBLISH_TOKEN` org secret):** the published version is derived from the git tag — bump `embedded/web/package.json` (currently `0.25.0-lotus.12`, published by CI; the secret is `NPM_PUBLISH_TOKEN`, names starting `GITEA_` are reserved), push `lotus`, then `git push lotus v0.25.0-lotus.1`; the `publish` job builds and publishes to the Gitea registry. Always push (never delete) the annotated `vX.Y.Z-lotus.N` tag for every published version. Then in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.25.0-lotus.12`) → `npm install` → build. Manual fallback: `pnpm run build:embedded && cd embedded/web && npm version <ver> --no-git-tag-version && npm publish`.
**`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):
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)
(a push that lands while a deploy is mid-build is queued and deployed right after — matrix@b6ea4a3; before that it was dropped)
```
Before marking a feature complete: `npx tsc --noEmit` (0 errors) · `npx eslint src/` (0 new) · `npx prettier --check src/` · `npm test` (Node runner via tsx, hard CI gate — colocated `*.test.ts`) · update `README.md`/`landing/index.html` for Lotus-custom features · visually verify on `chat.lotusguild.org`.
**CI hardening (2026-07, reviewed):**
- [x]**Concurrency** — `cancel-in-progress` on cinny `ci.yml` and cinny-desktop `release.yml` (`386a2979` / `c5461ce`): a superseded lotus push cancels its in-flight web CI and collapses queued ~30-min Tauri desktop builds to just the newest. Safe for deploys because `lotus_deploy.sh` now **follows origin/lotus HEAD** each poll iteration + resets to the gated SHA (`matrix``c15a489`) — closes the latched-SHA freeze race.
- [x]**Hard quality gates** — typecheck/eslint/prettier promoted from `continue-on-error` to blocking (tree held clean). eslint gates on errors only; `no-explicit-any` warnings stay informational.
**CI follow-ups (open):**
**CI follow-ups** are tracked in the `matrix` repo: [#8](https://code.lotusguild.org/LotusGuild/matrix/issues/8) dedicated `desktop-linux` runner, [#9](https://code.lotusguild.org/LotusGuild/matrix/issues/9) debounce the desktop trigger, [#10](https://code.lotusguild.org/LotusGuild/matrix/issues/10) verify Gitea `concurrency`. Build-once/deploy-the-artifact was considered and deferred (noted on #8).
**Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first.
> **Manual QA checklists no longer live here.** Every shipped-but-unverified behaviour is a Gitea issue labelled **`qa`** — cinny milestone [Manual QA backlog](https://code.lotusguild.org/LotusGuild/cinny/milestones), cinny-desktop milestone [Desktop QA backlog](https://code.lotusguild.org/LotusGuild/cinny-desktop/milestones). Each issue carries the full steps + expected results; tick items as they pass, comment on FAIL (what you saw vs expected, browser/OS, web vs desktop, theme, console errors), close when green. Migrated from this file on 2026-09-17 (full text in git history).
> **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual.
This file keeps what a contributor needs to run and extend the **automated** coverage, plus the ops tip at the bottom.
## Environment notes
-You push from your own machine; these commits are local on `lotus` until you do.
-Test the **web** build (LXC 106 / `code.lotusguild.org`) first; re-run the **call** + **poll** sections on the **desktop (Tauri)** build too, since CSP and the EC iframe behave differently there.
- Several call features need a **second participant** (second account on another device/browser, or a colleague). Items that need this are marked **👥 2 people**.
- A couple of call items need a **third room/call** in parallel — marked **👥👥**.
-Test the **web** build (`chat.lotusguild.org`, LXC 106) first; re-run call + poll items on the **desktop (Tauri)** build too, since CSP and the EC iframe behave differently there.
-Several call checks need a **second participant** (marked **👥 2 people** in the issues); a couple need a third room/call in parallel (**👥👥**).
---
## Commits covered
## Local dev environment — drive the real UI against a throwaway homeserver
scripts/dev-homeserver.sh start # Synapse in .dev-homeserver/ (venv, SQLite), open registration, no rate limits, :8008
python3 scripts/dev-seed.py 400 # alice + bob, "Busy Room": 400 messages, an image every 10th
npm start # Vite on :5173
```
**Calls too:**`scripts/dev-homeserver.sh calls` adds a LiveKit SFU, a JWT issuer, the real `voice-limit-guard` from the `matrix` checkout and an https well-known — real two-party calls in headless Chromium with fake mic/camera (`--use-fake-device-for-media-stream`, `--use-file-for-fake-audio-capture=<tone.wav>` to trigger speaking detection, `--auto-select-desktop-capture-source="Entire screen"` for screenshare; `ignoreHTTPSErrors: true`). This is how #29, #161, #173/#174 and the guard's live-revoke bug were found and fixed on 2026-09-18.
Log in at `http://127.0.0.1:5173/login/http%3A%2F%2Flocalhost%3A8008/` as `alice` / `password123` (bob is the second participant; both can also be driven over the client API with their tokens). Playwright is installed (`npm run test:e2e:install`), so a scripted reproduction is `node` + `chromium.launch()` against `:5173` — this is how Gitea #163 was reproduced and its fix verified in both plain and encrypted rooms. `scripts/dev-homeserver.sh reset` wipes the database; `stop` shuts it down.
## Automated coverage map — what the unit tests already pin (2026-07)
**Read this before working a `qa` issue.** Much of the _logic_ the manual checks were written to catch is now locked by deterministic unit tests (`npm test`, 920+ cases, green in CI). Unit tests do **not** prove visual rendering, real-call behavior, the desktop build, E2EE, or cross-device sync — those still need a human. But where a decision is pure logic, you can **trust the test and spend your manual time on the human-only part**. For each row below, the middle column is "don't bother re-deriving this by hand"; the right column is "this is what your manual pass is actually for."
| QA item | Logic **pinned by a unit test** (trust it) | What still needs **you** (manual) |
| **O3** math / LaTeX | `mathParse.test.ts` (14) — inline `$…$`, block `$$…$$`, **currency guard** (`$5 and $10`), escaped/unbalanced stay text, adjacency rules | KaTeX **renders** visually + lazy-chunk load; code-block-literal is the markdown pipeline |
| **O4/P4-8** encrypted search cache | `searchCache.test.ts` — the pure helpers `mergeSearchResults` (merge/dedupe/sort) + `computeCoverage` (window widening) + resilient-when-IDB-absent. **The IDB round-trip test is `skip`ped under `npm test`** (node has no IndexedDB), so it runs only in a browser-like env, not CI | the actual IndexedDB persist-across-**reload**, Clear button, **logout wipe** (integration — and the round-trip itself) |
| **M1**`has:image/file/video` | `useMessageSearch.test.ts` — `filterGroupsByMsgType` union filter, drops empty groups, ignores non-string msgtype | the chips render + compose with room/sender/date filters |
| **M4** pinned-only filter | `useMessageSearch.test.ts` — `filterGroupsByPinned` keeps pinned, drops empty | chip renders; needs a room with actual pins |
| **Seasonal theme resolution** (part of F2) | `seasonSchedule.test.ts` — `resolveSeasonTheme` (off→none, auto→active season, pinned→that) + `getActiveSeason` priority/boundary days. **NB: this pins _which_ theme shows for a date, NOT F2's background↔seasonal mutual exclusion** — that write-side logic is untested | all of **F2**: the picker actually clearing the _other_ setting live, and the overlay suppression when a background is set |
Everything else in the guide (calls, screen readers, desktop/Tauri, chat backgrounds, animated visuals, PWA install, real E2EE) is genuinely manual — no unit test substitutes for it. Items already verified live were dropped when the checklists moved to issues.
---
## A. Calls — new ringtone + notification work (highest priority)
## Playwright smoke test (Gitea #90) — `npm run test:e2e`
### A1. Ringtone selection — preview in Settings
Browser-level smoke tests under `e2e/` (config: `playwright.config.ts`). They boot the **built**`dist/` through `vite preview` on port 4173, so run `npm run build` first (one-time: `npm run test:e2e:install` downloads the pinned Chromium). Three tiers:
| **Boot** (always) | `e2e/boot.spec.ts` | every CI run (`e2e` job in `.gitea/workflows/ci.yml`) and locally | login page renders with `#root` populated and **no**`pageerror` / unexpected `console.error` (allowlist in `e2e/helpers.ts`: the README's avatar-thumbnail 404, the login page's `POST /register` 401 probe, offline discovery), `sw.js` is served and registers, bundled Element Call mounts in a frame with every `/public/element-call/` asset returning 200 |
| **E2EE composer** (gated) | `e2e/e2ee-composer.spec.ts` | only when `E2E_HOMESERVER`, `E2E_USER`, `E2E_PASSWORD` are all set | password login → `/home/create/` with the End-to-End Encryption switch on (asserts `createRoom` carries `m.room.encryption`) → text message renders → attach a generated JPEG with "Compress image before uploading" ticked, image renders → every `PUT …/rooms/*/send/*` was `m.room.encrypted` with `ciphertext` and no plaintext `body` / `url` / `file` / `mxc://` |
| **Local homeserver** (Gitea #220) | `e2e/local-homeserver.spec.ts` | when a Synapse answers at `E2E_LOCAL_HS` (default `http://localhost:8008`); the CI `e2e` job starts one with `scripts/dev-homeserver.sh start` + `dev-seed.py`, locally run the same two commands | registers its own `e2e_alice_*`/`e2e_bob_*` users and rooms over the CS API, then drives the built client: login + send/receive, own message scrolls into view (#212), `/kick` failure toast (#216), upload 413 sentence (#213), forward provenance header, thread panel + drawer at 1400 px (#218), timeline image → gallery lightbox (#219), clock-skew banner via `page.clock` (#158), status save under the presence rate limit (#226), long-press action sheet on a Pixel 7 emulation (#166). Helpers in `e2e/localHs.ts`; add a test here whenever a fix was reproduced with a scratch Playwright script |
1. Open **Settings → General**, scroll to the **Calls** section.
2. Find the new **Ringtone** dropdown (just above **Ringtone Volume**).
3. Select each option in turn: **Classic, Chime, Soft, Retro, Silent**.
**Accessibility gate** (Gitea #222): `e2e/a11y.spec.ts` runs `@axe-core/playwright` (WCAG 2.x A/AA tags) over the login page, room timeline + composer, message options menu, thread panel, user settings and room settings, and fails on any **critical/serious** finding except `color-contrast` (reported in the log, not gated — generated avatar colours and portal false positives). It also keeps accessibility-tree snapshots (`e2e/a11y.spec.ts-snapshots/*.aria.yml`) of the composer, message menu, thread panel and settings nav, so a lost name/role/live-region shows as a diff; update them deliberately with `npx playwright test e2e/a11y --update-snapshots` and keep dynamic bits as regexes. A real NVDA/VoiceOver pass is still manual.
**Expected**
**Browsers** (Gitea #221): everything runs under Chromium; the tests tagged `@webkit` (boot, login + send/receive, thread panel, lightbox) also run under Playwright's WebKit as desktop Safari, and those tagged `@ios` under the `iPhone 14` descriptor — the closest CI gets to Safari/iOS. It catches WebKit-only breakage (CSS, `dvh`, IndexedDB, media decode) but does not emulate the on-screen keyboard or Home-Screen install; a real iPhone pass (#166/#199) stays manual. Locally: `npx playwright install --with-deps webkit` once, then `npx playwright test --project=webkit --project=iphone`. WebKit words handled fetch failures as page errors (`TypeError: Load failed`, `due to access control checks`), so the allowlist in `e2e/helpers.ts` applies to page errors too.
- Selecting **Classic** plays the existing `call.ogg` clip (cut off after a few seconds).
- **Chime / Soft / Retro** each play a short, distinct synthesized preview.
- **Silent** plays nothing.
- Changing **Ringtone Volume** then re-selecting a ringtone previews at the new volume.
- No console errors.
**CI secrets** (Gitea → repo → Settings → Actions → Secrets; the `e2e` job forwards them via `env:`; until they exist the E2EE tier reports `skipped`, the boot tier still runs):
> ⚠️ **Known browser limitation:** the synthesized tones use WebAudio. If a preview is ever silent, click anywhere on the page once (a "user gesture") and retry — browsers suspend audio until the page has been interacted with. The Settings preview is _after_ a click so it should always sound; this note matters more for A3.
-`E2E_HOMESERVER` — server name as typed on the login page (e.g. `matrix.example.org`). Must offer `m.login.password`; a next-gen-auth (MAS/OIDC-issuer) server shows only the OIDC button and the tier will fail at the username field.
-`E2E_USER` / `E2E_PASSWORD` — a **throwaway** account: each run logs in as a new device and creates a new `e2e-smoke-<timestamp>` room. Prune devices/rooms occasionally.
### A2. Ringtone selection persists
1. Set Ringtone to **Retro**, reload the app.
2.**Expected:** the dropdown still shows **Retro** (setting persisted).
3. Bonus: in devtools, set `localStorage.settings` to a bogus `ringtoneId` and reload → it should fall back to **Classic**, not break.
### A3. Incoming call uses the selected ringtone — 👥 2 people
**Setup:** Account A (you) and Account B in a **DM** or a **private (invite-only) group** room.
1. As A, pick a non-silent ringtone (e.g. **Chime**).
2. From B, **start a call** in that DM/room. Do **not** answer on A.
- The **selected ringtone loops** until you answer/reject/ignore (at the set volume).
- Answer → joins the call. Reject (DM) / Ignore (group) → dialog dismisses and ring stops.
- Set ringtone to **Silent** and repeat → dialog still appears, **no sound**.
### A4. In-call banner for a second incoming call — 👥👥 (the trickiest one)
**Setup:** You (A) already **in a call** in Room 1. Account B can call you in a **different** Room 2 (a DM or private group you share). Ideally a third account C, or B leaves Room 1's call first.
1. While A is **actively in Room 1's call**, trigger an incoming call to A from **Room 2**.
**Expected on A**
- **No** full-screen takeover. Instead a **compact banner appears in the top-right corner** with the caller's avatar, room name, "Incoming voice/video call", and **Answer / Reject (or Ignore)** buttons.
- It plays a **single soft ping**, _not_ a looping ring (so it doesn't talk over your active call).
- The banner does **not** cover your active call's controls/PiP in a way that blocks them.
- **Answer** → switches you into Room 2's call. **Reject/Ignore** → banner disappears.
- The banner auto-dismisses if the caller hangs up / the call times out.
**Also verify the no-op case:** while in Room 1's call, if a notification for **Room 1 itself** arrives, **nothing** should pop up (no banner, no dialog).
### A5. Camera focus during screenshare (#1) — 👥 2 people
**Setup:** You (A) and B in a call; B (or another participant) **sharing their screen**, and at least one person with **camera on**.
1. As A, open the **participant glance** (the stacked avatars / member list for the call) and click a participant who has their **camera on**.
2. In the menu, click **"Focus camera"**.
**Expected**
- The view switches to **spotlight** and **pins that person's camera tile**, overriding the auto-spotlighted screenshare.
- It **stays** on that camera (doesn't immediately snap back to the screenshare).
- If you pick someone with their camera **off**, it should at worst just toggle spotlight (graceful fallback), not error.
### A6. Avatar decorations on call tiles (#3) — 👥 2 people
**Setup:** A participant in the call has an **avatar decoration** set (Settings → Profile decoration).
1. Join a call with that participant.
2. Look at **our** participant roster / prescreen tiles (not the avatars rendered inside the Element Call video grid — those are EC's and out of scope).
**Expected:** the decoration ring/overlay renders around that participant's avatar on the call tile, the same way it does in member lists.
This guards against a permanently-stuck "Loading…" call. Also covers the N96 button-label fix (the old "Retry" and "Leave" buttons were identical — now there is a single **"Back"** button).
1. Normal case: **join a call** → it should connect within a few seconds as usual (the watchdog stays invisible).
2. Failure case (best-effort to reproduce): throttle your network hard (devtools → Network → Offline) **right as** you click join, or block the Element Call origin, so the iframe can't finish loading.
**Expected**
- On a genuine failure/timeout (~25s), instead of an endless spinner you get a **visible error overlay with a single "Back" button** (the old "Retry" + "Leave" pair is gone — they did the same thing and "Retry" was misleading).
- Clicking **Back** returns you to the call prescreen, where you can manually click Join to try again.
- Normal joins must **not** trigger the error overlay (no false positives) — this is the important part to confirm.
- **Self-heal:** if the error overlay appears on a slow network but EC then finishes loading anyway, the overlay should **dismiss itself** and drop you into the live call. Worth confirming on a deliberately throttled-but-not-blocked connection.
The `e2e` job is `continue-on-error: true` for now because `playwright install --with-deps` needs `apt` on the runner image — promote it to a hard gate once it is green on the runner. Locally: `npm run test:e2e` (boot tier only), or `E2E_HOMESERVER=… E2E_USER=… E2E_PASSWORD=… npm run test:e2e` for both; on failure look in `test-results/` (screenshot + trace) and `playwright-report/`.
---
## B. Polls (N4) — render correctly on non-TDS themes
This was the actual bug: poll buttons used undefined CSS variables, so on the **default (non-Lotus-Terminal) themes** they rendered with invisible borders / no selected state.
### B1. Poll renders on a default theme — ✅ PASS
1. Switch to a **default Cinny theme** (Settings → Appearance — **not** Lotus Terminal / TDS). Test both a **dark** and a **light** theme.
2. In any room, create a poll (composer → poll button): a **single-choice** poll with 3 options.
**Expected**
- Each option is a clearly **bordered** button with visible rounded corners.
- A **radio circle** indicator is visible on the left of each option.
- Text, and (after votes) the percentage, are legible.
### B2. Voting + selected/progress state
1.**Vote** on an option.
**Expected**
- The selected option shows a **filled accent border + filled radio**, and an **accent progress-bar fill** grows behind it proportional to the vote %.
- The percentage and total vote count update.
- Click again / pick another option → selection moves correctly (single-choice replaces; the bar redraws).
### B3. Multiple-choice poll
1. Create a poll allowing **multiple selections**.
**Expected**
- Indicators are **square checkboxes** (not circles); selected ones show a **✓** that's legible against the filled box.
- You can select **several** options; each shows its own progress fill.
### B4. Lotus Terminal theme regression — ✅ PASS
1. Switch to **Lotus Terminal / TDS** theme and re-open a poll.
**Expected:** still looks correct (the fix uses theme tokens, so the TDS accent should now drive it) — no worse than before.
---
## C. Robustness / background behavior
### Green CI but the fix isn't live?
### C1. Presence updates on tab close
1. Open the app, then **close the tab** (or quit the browser).
2. From another session/device, check your **presence** shortly after.
**Expected:** you go **offline/away** reliably (the unload now uses `fetch({keepalive})`). Previously this could be missed.
### C2. Upload retry on flaky network (best-effort)
1. In devtools → Network, set a throttle that drops/slows requests, or toggle Offline briefly **during** a file upload.
**Expected**
- A transient failure **retries** (up to 3×, with backoff) and the upload can still succeed once the network recovers.
- A genuine, permanent rejection (e.g. file too large / 4xx) still **fails fast** with the usual error — it should **not** spin retrying.
### C3. General timeline/composer perf (no functional regression)
The memoization changes are invisible if correct. Just confirm **nothing broke**:
- Open a busy room; scrolling, jump-to-latest, mark-as-read all still work.
- Composer: send a message, upload a file, share a location, pick an emoji and a sticker — all still work.
---
## D. Element Call 0.20.1 merge — regression sweep (👥 2 people)
The upstream bump changed EC's internals and DOM selectors; our call controls drive that iframe, so sweep them. In a live call with 2 people, confirm **each** of our control-bar buttons works:
- [ ]**Screenshare** start/stop (and the "Share your screen?" confirm)
- [ ]**Screenshare audio** mute toggle
- [ ]**Fullscreen** toggle
- [ ]**⋮ More** menu → **Spotlight/Grid**, **Reactions**, **Settings** each open the right EC panel
- [ ]**End** call leaves cleanly
- [ ]**PTT** (push-to-talk) if enabled: hold key = transmit, release = mute; releasing on blur works
- [ ]**AFK auto-mute** if enabled: goes muted after the timeout
- [ ]**PiP** (picture-in-picture) mini window: drag, resize, fullscreen button, return-to-call; the "You muted" / "All muted" badges show on the right person
- [ ]**Denoise** (if ML noise suppression enabled): call audio still flows, no silence
If any control does nothing, that usually means an EC DOM selector changed — capture the console and tell me which button.
This is enforced by the `voice-limit-guard` on the server (re-signs the LiveKit JWT), so it applies to
**every** client, not just Lotus Chat. Set in **Room Settings → General → Voice → Call Permissions**.
_(Requires the guard deployed on LXC 151 — auto-deploys on a `matrix` repo push.)_
- [ ]**Disable screenshare:** as admin, turn **Allow Screen Sharing** off. In a call, the
**screenshare button disappears** in Lotus Chat. ✅ good if no one can screenshare.
- [ ]**Cross-client (the important one):** have someone join the **same room from stock Element / Element
X** and try to screenshare → the server **refuses** the track (it won't publish). This proves it's
not just our client hiding a button.
- [ ]**Audio-only room:** turn **Allow Camera** off too → the camera button disappears and cameras are
server-blocked for all clients; **microphones still work**.
- [ ]**⭐ Live kill (mid-call):** while someone is **actively screensharing**, an admin turns **Allow
Screen Sharing** off. Within a few seconds their screenshare should **stop for everyone** on its own
(no rejoin needed) — this is the server reconcile loop revoking it live. Works even if the sharer is
on stock Element. ✅ good if the share drops within ~3–5 s; ❌ tell us if it keeps going.
- [ ]**Turning it back on** restores the ability to screenshare/camera (start a new share).
- [ ]**No policy = no change:** a room with Call Permissions left on defaults behaves exactly as before.
> If any D2 item fails, grab the **EC iframe console** (right-click the call → inspect the iframe) — a
> widget-action/payload mismatch shows up there as a `io.lotus.*` rejection or a `MissingKey`/transport log.
---
# Backlog of previously-fixed-but-unverified items
> Sections A–D above are **this session's** work. Everything below was fixed in earlier waves and is still flagged **⚠️ UNTESTED** in `LOTUS_BUGS.md` / `LOTUS_TODO.md`. They're grouped by what kind of environment you need (mobile, desktop, screen reader, etc.) so you can knock out a whole category at once. None of these are urgent the way A–D are; do them as you have the right device handy.
## E. Mobile / responsive (needs a real phone, or devtools device emulation)
### E1. Composer toolbar touch targets (#7)
On a phone, open a room and the composer toolbar. Tap each button (attach, format, sticker, emoji, GIF, location, poll, schedule, send).
**Expected:** every button is comfortably tappable (≥44×44px), no mis-taps hitting the wrong icon.
### E2. Room Settings — no horizontal overflow (#8)
On a narrow phone screen, open **Room Settings**.
**Expected:** the settings nav panel fills the full width; **no** horizontal scrollbar / sideways scrolling anywhere in the panel.
### E3. Modals go fullscreen on mobile (#9)
On a phone, open several dialogs: Leave Room, Create Room, Create Space, Invite User, Report (room/user/message), Edit History, Forward Message, Remind Me, Schedule Message, Device Verification, Poll Creator.
**Expected:** each opens **fullscreen** (no floating box, no rounded corners / max-width margins). On desktop the same modals should still be the normal centered boxes.
### E4. Composer not hidden by the keyboard (#10) — iOS Safari especially
On a phone (priority: **iOS Safari**), tap into the composer so the on-screen keyboard appears.
**Expected:** the composer input stays **visible above** the keyboard; the layout shrinks rather than the composer sliding under the keyboard.
### E5. Mobile "Saved Messages" access (Mobile Bookmarks)
On a phone, **inside a room**, open the room header **··· More Options** menu.
**Expected:** a **"Saved Messages"** item is present; tapping it opens the bookmarks panel. (This was the only in-room access point missing on mobile.)
---
## F. Visual / theming
### F1. Animated chat background — no flicker (#2)
Settings → set an **animated** chat background (e.g. anim-rain / anim-aurora / anim-stars). Watch the message text and composer while it animates.
**Expected:** smooth animation, **no flickering / shimmering** on message text or the composer, especially after scrolling. Note your GPU/browser if you see artifacts.
### F2. Background vs. Seasonal theme are mutually exclusive (#6)
In Settings → Appearance:
1. Pick a **chat background** → confirm any **seasonal theme** auto-switches off.
2. Pick a **seasonal theme** → confirm the **chat background** auto-clears to none.
3. (Edge) If you have old data with both set, after reload only one should visibly apply (no double-overlay clutter).
In Settings → Appearance, look at the **Chat Background** and **Seasonal Theme** swatch grids; resize the window narrow→wide.
**Expected:** swatches reflow to fill each row evenly (responsive grid), with no lopsided/orphaned last row at any width.
---
## G. Calls — additional unverified (👥 2 people)
### G1. PiP mute badges point at the right person (#12)
In a call with at least one other person, pop out the **Picture-in-Picture** mini window.
- **You** mute your own mic → a **"You"/muted badge appears bottom-left** (your status).
- A **remote** participant (or all of them) mutes → an **"All muted"** badge appears **top-right** (clearly about other people).
**Expected:** the bottom-left badge is **never** triggered by someone else muting — that was the original bug (it looked like your own mic was muted when it wasn't).
### G2. Full-screen camera broadcasts
1. In a **camera-only** call (no screenshare), confirm the **Fullscreen** button is available (previously only showed during screenshare).
2. Use **MemberGlance → Focus camera** to full-screen/spotlight a specific person's camera. (Overlaps **A5**; if you've done A5 you can skip.)
### G3. PTT badge renders on all themes (N53)
Enable **Push-to-talk** (Settings → Calls) and join a call. Hold the PTT key.
**Expected:** the floating PTT badge above the controls shows "PTT — Hold KEY" when idle and "● Live" (green) while held — on **both** a default theme and Lotus Terminal (it's now a single folds Chip; the old terminal-only variant was removed).
---
## H. Media / performance (needs a room with many images)
Open a room / media gallery with **many images** (ideally encrypted). Scroll down through them.
**Expected:** images decrypt/load as they **approach the viewport**, not all at once on open; scrolling stays smooth and memory doesn't balloon. Off-screen images shouldn't all decode up front.
### H2. Thumbnail framing (P5-6)
Look at **tall portrait** images in the timeline and in the media gallery.
**Expected:** thumbnails are framed **center-top** (so faces/subjects at the top aren't cropped out); no awkward stretching. Opening the full-size viewer still shows the **whole** image (contain, not cropped).
---
## I. Accessibility (needs a screen reader: VoiceOver / NVDA / TalkBack)
With a screen reader on, navigate message hover-actions and content and confirm each control **announces a meaningful label** (not "button" / blank):
- [ ]**Reply** (jump to original) announces "Jump to original message".
---
## J. Desktop / Tauri build only
### J1. Proactive update notifications (P5-40)
In the **desktop (Tauri)** build, with an update available, launch the app (and/or leave it running ~12h).
**Expected:** an in-app toast/badge alerts you that an update is available, without manually checking Settings. (Needs an actual newer release to point at.)
### J2. DTLN noise suppression sanity
In Settings → Calls, enable **ML noise suppression** with the **DTLN** model, then join a call.
**Expected:** your mic audio still flows (no silence/robotic dropouts) and background noise is reduced. Confirmed working earlier but flagged for a final real-call check; verify on **both** web and desktop.
---
## K. Features — end-to-end unverified
### K1. Remind Me Later
On a message, **··· → Remind Me**, pick a short preset (the 20-min one, or wait one out).
**Expected:** when due, a Lotus toast fires linking to that message; the reminder then clears itself. Survives a reload while pending (stored in account data).
### K2. Advanced search filters (P4-9)
In message search: use the **sender picker** (instead of typing `from:@user`), the **date-range** quick presets (Today / Last week / Last month / Last year), and the **Has link** toggle.
**Expected:** each narrows results correctly and reflects in the search.
Trigger a desktop/browser notification for a new message.
**Expected:** it shows the **real message body** (`username: message`, not "New inbox notification from…"); **clicking it** brings the window to front and navigates **directly to that message** (not just the inbox).
---
## L. Fixed — verify
### L1. AFK auto-mute releases the OS microphone indicator on mute (N95) — 👥 live call
**Context (now FIXED):**`useAfkAutoMute.ts` opened its own `getUserMedia` level-monitor capture for the whole call, so the OS recording indicator (green dot on macOS, mic icon on Windows/Linux) stayed lit even when muted. The capture is now gated on the reactive mic-on state — it runs only while unmuted, so muting releases the stream.
**To verify:**
1. Enable **AFK auto-mute** in Settings → Calls and **join a call**.
2. Manually **mute your mic** using the call controls → the **OS recording indicator should clear** within ~a second.
3.**Unmute** → the indicator should re-appear (capture re-acquired).
4. Also confirm AFK still works end-to-end: stay unmuted and silent past the configured timeout → mic auto-mutes with the "muted after inactivity" toast, and the indicator clears.
1. On **Android Chrome**, install Lotus Chat as a PWA (Add to Home Screen).
2. Look at the **home-screen icon**.
**Expected:** the icon fills the adaptive-icon shape cleanly (the logo centered with safe-zone padding on the dark background), **not** clipped at the corners or floating in an odd box. Also worth a quick check in Chrome DevTools → Application → Manifest that the two `purpose: maskable` icons load without a 404 (this also validates the manifest's icon paths resolve in production — a pre-existing path convention I couldn't verify statically).
1. Open message search (in a room with shared images/files/videos in history).
2. Run a broad search, then toggle the **Images**, **Files**, **Video** chips (in the filter bar, next to "Has link").
**Expected:**
- Each chip narrows the visible results to that message type; multiple active chips = union (any of them).
- Toggling them off restores the full results. The existing room/sender/date/has-link filters still work alongside.
- **Known limitation (by design):** filtering is client-side over already-fetched results, so the visible count can be lower than the server's total for that query — paginating/loading more pulls in more to filter. Confirm this reads acceptably.
### M2. Search: recent searches
1. Run a few different searches, then **clear the search box** and focus it.
**Expected:** your last (up to 10) distinct searches appear as clickable chips; clicking one re-runs it. A **Clear** affordance wipes the list. The list **persists across a page refresh** (localStorage).
### M3. Custom accent color (non-TDS themes) — ⚠️ needs your visual judgment
1. Make sure **Lotus Terminal (TDS)** is **off**. Settings → Appearance → **Custom Accent Color** → pick a color.
**Expected:**
- The app's accent (buttons, selected/active states, links, primary chips) recolors to your choice **live**.
- **Look critically at quality** (this is the part I can't verify): button **text legibility** (OnMain contrast) on the accent buttons; **hover/active** shades; and **selected-row / chip** backgrounds (the translucent "Container" tints). Try a **light** color and a **dark** color and a **saturated** one.
- If a dark accent makes selected-row text (OnContainer) hard to read, tell me — that's the one spot in the auto-derived palette most likely to need tuning.
- **Reset** clears it back to the theme default.
- Turn **Lotus Terminal ON** → the custom accent should be **ignored** (TDS fixed palette wins) and the picker shows a "non-TDS only" note; turn it back off → custom accent returns.
- Reload → the chosen accent **persists**.
---
### M4. Search: "Pinned only" filter
In message search, toggle the **Pinned** chip.
**Expected:** results narrow to messages currently pinned in their room; composes with the Images/Files/Video chips and room/sender/date filters; toggling off restores results. It also narrows the **encrypted/local-cache** results section (not just server results). Needs a room with actually pinned messages.
### M5. New theme presets (Cyberpunk / Ocean / Blood Red / Classic Matrix / Midnight) — ⚠️ visual judgment
Settings → Appearance → theme picker → try each of the 5 new themes.
**Expected:** each applies a complete, legible dark palette. Code review computed WCAG contrast and all pass AA, but **eyeball these specifically**: **Midnight** (lowest-contrast accent `#6b7ca8` — selected/focus states), **Classic Matrix** (green accents, light-green body text on near-black), **Blood Red** (white-ish text on bright-red buttons). Confirm Success/Warning/Critical (save/leave/delete) still look correctly green/amber/red, not recolored. Switching back to a stock theme should fully revert.
---
## N. OIDC / Next-Gen Auth login (MSC3861) — P4-6
The Lotus client can now sign into OIDC-native homeservers (ones that delegate
auth to a Matrix Authentication Service / MAS), e.g. mozilla.org. lotusguild's
own server is **not** MSC3861, so test EITHER against a **local MAS dev loop**
(full setup in `dev/oidc-test/README.md` — docker-compose + Synapse `msc3861`
delta + a `config.json` override) OR against **mozilla.org** with a real account.
### N1. OIDC login flow (the core test) — needs a MAS homeserver
1. On the login screen, select the OIDC homeserver (local `localhost:8008`, or `mozilla.org`).
2.**Expected:** instead of the username/password form, a single **"Continue with single sign-on"** button appears (password + legacy-SSO are suppressed for that server).
3. Click it → redirected to the provider's login page (MAS / `chat.mozilla.org`).
4. Authenticate there → redirected back to `…/auth/oidc/callback` → a brief "Signing you in…" spinner → you land in the app, logged in.
**Expected:** no console CSP violations; you reach the room list as the OIDC user.
### N2. Session persists across reload (token storage)
After N1, hard-refresh the page.
**Expected:** you stay logged in — the OIDC session (access + refresh token + issuer/clientId/claims) was persisted (`cinny_refresh_token`, `cinny_oidc_*` keys in localStorage).
### N3. Token refresh (long-lived session)
Leave the session past the access-token lifetime (MAS default is short — or revoke the access token in the MAS admin UI to force a 401).
**Expected:** the client refreshes transparently (no logout); the stored access token rotates (reactive 401 refresh via the wired `OidcTokenRefresher`).
### N4. Logout revokes at the issuer
Log out from Settings.
**Expected:** back to login; OIDC tokens are revoked at the issuer's `revocation_endpoint` (best-effort) and all `cinny_*` / `cinny_oidc_*` keys are cleared. Logging back in works.
### N5. Account-management deep-link
Settings → Account.
**Expected:** on an OIDC server a **"Manage account"** card appears (opens the provider's account page in a new tab). On a non-OIDC server (lotusguild) the card is **absent**.
Log into **matrix.lotusguild.org** (password) and **matrix.org**.
**Expected:** identical to before — username/password form (+ SSO button where offered). The OIDC path only activates when discovery advertises an issuer, so nothing changes for these servers.
---
## Priority if you're short on time
1.**A4** (in-call banner) + **A3** (ringtone) — newest, most logic, hardest to reproduce.
2.**B1–B3** (polls on a default theme) — the confirmed visual bug.
3.**D** (EC 0.20.1 control sweep) — guards against the upstream merge breaking calls.
4.**A7** false-positive check (normal joins don't show the error overlay).
5. Everything else.
`curl -s https://chat.lotusguild.org/index.html | grep -o 'assets/index-[^"]*\.js'` gives the deployed entry chunk; grep it for a string unique to your change (`curl -s https://chat.lotusguild.org/<that path> | grep -c <string>`). If it's 0 after ~15 min, the deploy trigger was lost — push again (any commit) to re-fire the `lotus-deploy` webhook. The deploy log lives at `/var/log/lotus-deploy.log` on LXC 106.
**Repo:**`lotus` branch at `https://code.lotusguild.org/LotusGuild/cinny`
**Deploy:** push to `lotus` → CI → auto-deploy to `chat.lotusguild.org` (~11 min)
---
## ⚠️ 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. Do NOT deviate from the design tokens defined in that file.
> The canonical variable reference: `--lt-accent-orange`, `--lt-accent-cyan`, `--lt-accent-green`, `--lt-glow-orange`, `--lt-box-glow-*`, `--lt-border-color`, etc.
> Reference implementation for code patterns: `/root/code/tinker_tickets/` (markdown.js, base.js, ticket.css)
> This rule applies to EVERY task in this file without exception.
---
## 🧩 NATIVE-CINNY LAW — EVERY FEATURE MUST FEEL LIKE STOCK CINNY
> **Every feature we implement must feel native to the upstream Cinny app — indistinguishable from something the Cinny team would have shipped.** Reference: <https://github.com/cinnyapp/cinny>.
>
> Concretely this means:
>
> - **Use the `folds` design system, not bespoke UI.** Build with folds primitives (`Button`, `Chip`, `IconButton`, `Menu`, `MenuItem`, `Dialog`, `Modal`, `Input`, `Switch`, `Badge`, `SettingTile`, `SequenceCard`, etc.) and folds tokens (`color.*`, `config.space.*`, `config.radii.*`, `config.borderWidth.*`). No hardcoded hex/`rgba()` for UI chrome, no invented/undefined CSS variables.
> - **Match Cinny's existing patterns.** Before adding UI, find the closest existing Cinny component/flow and mirror it (e.g. a new dropdown uses `Button`+`PopOut`+`Menu`+`MenuItem` like the rest; a new modal has a `Header` with a close `IconButton`; a new setting is a `SettingTile` inside a `SequenceCard`). Consistency with stock Cinny beats personal style.
> - **Lotus-custom additions should be unobtrusive** and fit Cinny's visual language, spacing, and interaction conventions — a stranger using Cinny should not be able to tell which features are ours.
>
> **The ONE exception:** explicit **Lotus Terminal Design System (TDS)** features, which intentionally have their own distinct look and follow the **TDS Design Law** above. TDS styling is opt-in (only active in Lotus Terminal mode); everything else must look and feel like native Cinny.
---
Completed features are documented in [LOTUS_FEATURES.md](./LOTUS_FEATURES.md).
---
## ✅ Done — Awaiting Verification
Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then they graduate to LOTUS_FEATURES.md. (Bug-side fixes awaiting verification live in LOTUS_BUGS.md.)
| **MSC flags ON:**`msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` · `msc3401_matrix_rtc` | All safe to use now |
| **MSC flags OFF:**`msc4306` (thread subscriptions) · `msc3882` · `msc3912` · `msc4155` | These features are BLOCKED |
| **MSC3266** room summary: flag `msc3266_enabled: true` set but `GET /v1/rooms/{id}/summary` still returns 404 (M_UNRECOGNIZED) | Room Preview BLOCKED — endpoint not implemented in Synapse 1.155 |
| **MSC3892** relation redaction: not in flags | Reaction Redaction feature BLOCKED |
| **MSC4260** report user: `POST /_matrix/client/v3/users/{userId}/report` returns **200** ✅ | **Report User UNBLOCKED** — endpoint live since Synapse 1.133; ready to build |
| **MSC4151** report room: HTTP 405 on GET = endpoint exists (POST only) | Report Room live ✅ |
| `folds AvatarImage` does NOT accept children | Add frame/overlay inside `UserAvatar.tsx` itself — optional `frameName` prop |
| No in-app toast system exists (was) | Built `ToastProvider` + Jotai queue; at `App.tsx:65` |
| Voice player: `AudioContent.tsx:44-223` | Playback rate on hidden `<audio>` at line 217 |
| `CallControl.setMicrophone(bool)` at `CallControl.ts:206-212` | For AFK auto-mute |
| `CallControl.toggleSound()` at `CallControl.ts:230-251` | Push-to-deafen — just wire a hotkey to this |
| matrix-js-sdk has NO arbitrary profile field methods | Use `mx.http.authedRequest()` for MSC4133 |
| Sanitizer (`sanitize.ts`) allows table, div, span, a, code, hr | LFG HTML card is safe locally; test on Element/FluffyChat |
| Sanitizer STRIPS `<math>`/MathML tags | Math/LaTeX task must also modify sanitizer |
| Service worker EXISTS at `src/sw.ts` | Quick-reply task: add `notificationclick` handler |
| `knockSupported()` utility exists at `matrix.ts:376-391` | Knock UX: only need "Request to Join" in `RoomIntro.tsx` |
| `KeywordMessages.tsx` already has custom keyword push rules | Full push rule editor: only non-keyword rule types need new UI |
| `getMatrixToRoom()` in `matrix-to.ts` generates invite URLs | Invite link: just add QR code to room settings |
| ~~Cindy CANNOT inject audio into EC call stream~~**UNBLOCKED by EC fork** — `io.lotus.inject_audio` widget action publishes a clip as a real call track | In-call soundboard CAN now mix into the call (no longer local-only); needs cinny UI to drive the action |
| Folds uses vanilla-extract in non-TDS, NOT CSS custom properties | Custom accent color: must create new vanilla-extract theme variant dynamically |
| Theme presets need ~50 CSS custom properties each | Significant design work before coding |
| `useCallSpeakers.ts` CSS MutationObserver polling | Visual speaking indicator: TDS ring animation on top of existing data |
| MSC3489/3672 live location: BOTH false on server | Live Location BLOCKED |
**What:** Comprehensive audit and fix pass targeting the critical user paths:
- Room list navigation (keyboard-only)
- Reading messages in the timeline (screen reader announces new messages)
- Composing and sending a reply
- Opening and closing modals (focus trap, return focus)
- ARIA labels on all icon-only buttons
**Scope:** Do NOT attempt to make every corner of the app AA-compliant in one pass — focus on the golden path (open app → find room → read → reply → send).
**[AUDIT REQUIRED]** — Run an automated audit first: `npx axe-core` or browser DevTools accessibility tree. Document every violation before writing a single line of code. Prioritize by severity (critical > serious > moderate).
**Investigation Findings:**
- **Root Cause:** Inconsistent focus management, missing `aria-live` regions for dynamic timeline updates, and sparse global keyboard shortcuts.
- **Approach:** Standardize `focus-trap-react` usage (reference `RoomNavItem.tsx`). Add `aria-live` regions to the timeline. Expand `useKeyDown.ts` for section navigation shortcuts.
- **Complexity:** Medium-High (audit is the main work).
---
### [ ] P3-8 · Thread Panel (full side drawer)
**⚠️ LARGEST FEATURE — requires its own planning session before implementation.**
**What:** A right-side drawer for threaded conversations. Currently "Reply in Thread" exists but there is no panel to read or write thread replies.
Features:
- Click "Reply in Thread" → opens thread drawer on the right
- Thread root event shown at the top of the panel
- Full message rendering for all in-thread replies (reuse timeline components)
- Reply input at the bottom (full composer with formatting, emoji, etc.)
- Unread count badge on the thread button in the main timeline
- Keyboard shortcut to close thread panel
**Architecture:**
- New Jotai atom: `activeThreadEventId: string | null`
- New component: `src/app/features/room/thread/ThreadPanel.tsx`
- Rendered alongside `RoomView` as a conditional right panel (mirror the members drawer pattern)
- Filter events in timeline to `m.thread` relation for the active root event ID
- Shares the same `mx` client and room reference as the main timeline
**[AUDIT REQUIRED]** — Deeply audit how `m.thread` relation events are currently stored and retrieved in the matrix-js-sdk. Understand the thread aggregation API: `GET /rooms/{roomId}/relations/{eventId}/m.thread`. Check if `RoomTimeline.tsx` currently filters out thread replies from the main timeline (it should — confirm).
**Investigation Findings:**
- **Root Cause:** Current `m.thread` events are treated as standard `m.room.message` events and rendered in the main timeline.
- **Approach:** Introduce new Jotai atom `activeThreadEventId`. Create `ThreadPanel.tsx`. Update `RoomTimeline.tsx` to filter out thread relations (`m.relates_to`). Implement aggregation fetch using `GET /rooms/{roomId}/relations/{eventId}/m.thread`. Use `thread.timelineSet` directly for the most accurate thread view.
- **Complexity:** High.
---
## Priority 4 — Specialized, high complexity, or low priority
**Spec:** MSC3771 (stable). Depends on Thread Panel (#P3-8).
**What:** Per-thread notification toggle: "All messages" vs "Mentions only". Accessible from the thread panel header. Tracks unread counts separately per thread.
**[AUDIT REQUIRED]** — Implement after Thread Panel. Requires understanding how the SDK tracks per-thread unread counts.
**Complexity:** Medium (after thread panel exists).
**Spec:** MSC4306 (Synapse experimental). Depends on Thread Panel (#P3-8).
**What:** "Follow thread" button to receive notifications for a thread you haven't posted in. Uses MSC4306 subscription endpoint.
**[SERVER CHECK]** — `org.matrix.msc4306 = false` on `matrix.lotusguild.org` — BLOCKED until server enables it.
**Complexity:** Medium (after thread panel exists).
---
### [ ] P4-4 · Math / LaTeX Rendering in Messages (LOW PRIORITY)
**Spec:** CS-API §11.5 (stable) — `formatted_body` can contain LaTeX.
**What:** Render `$...$` or `$$...$$` LaTeX expressions in message bodies. Use KaTeX (lightweight, ~100KB, renders server-side-compatible CSS). Must gracefully fall back to raw LaTeX text if KaTeX fails.
**Note:** This is LOW PRIORITY — only useful for academic/technical communities. Implement last.
**[AUDIT REQUIRED]** — Confirm KaTeX bundle size impact on the Vite bundle. Check if matrix-js-sdk's HTML sanitizer strips LaTeX before it reaches the renderer. The formatted_body sanitization pipeline is the main risk here. (Confirmed: sanitizer STRIPS `<math>` tags — must be patched alongside the renderer.)
**Complexity:** Low-Medium.
---
### [ ] P4-5 · Live Location Sharing (MSC3489 + MSC3672) (LOW PRIORITY, HIGH COMPLEXITY) [BLOCKED]
**Spec:** MSC3489 + MSC3672. Implemented in Element Web.
**Note:** Static location sharing is already implemented. This adds live/real-time GPS beacons. Very low priority per user preference.
**What:** Start sharing live location → creates `m.beacon_info` state event → client posts `m.beacon` events on a timer → other users see your position update live on a map.
**[SERVER CHECK]** — `org.matrix.msc3489 = false` AND `org.matrix.msc3672 = false` on `matrix.lotusguild.org` — BLOCKED.
**Complexity:** High. Requires background geolocation API + live map rendering.
**Spec:** MSC3861 / MSC2965, Matrix spec v1.15. OAuth2-native auth via a Matrix Authentication Service (MAS).
**Scope decision (2026-06):** CLIENT-ONLY. We implemented OIDC login _in the Lotus client_ so it can sign into next-gen homeservers (mozilla.org, eventually matrix.org). We deliberately did **not** convert lotusguild's own Synapse to MAS (no account migration; lotusguild keeps password + legacy Authelia SSO).
**Built (matrix-js-sdk already ships the OIDC API; this was wiring):**
- 13 unit tests (discovery/flow/session/cache/callback parsing). All gates green.
**Awaiting verification (needs a real MSC3861 server — lotusguild is NOT one):** deploy + log into **mozilla.org** (requires adding mozilla to the deployed `config.json` homeserverList + its domains to the CSP `connect-src`/`img-src` — see below), OR run a local `matrix-authentication-service` + Synapse `msc3861` dev loop.
**To enable the mozilla.org test:** add to `matrix/cinny/config.json` homeserverList `"mozilla.org"`, and to the nginx CSP `connect-src`/`img-src`: `https://mozilla.org https://mozilla.modular.im https://chat.mozilla.org https://vector.im`.
**Decision:** Implemented as `!lfg` in LotusBot rather than a client slash command. Bot-side rendering works consistently across all Matrix clients; client-side enhanced cards would only be visible to Lotus Chat users and require sanitizer auditing. The bot can also support richer flows (list active LFGs, DM interested players, auto-expire posts).
**What:** Soundboard button in the call controls bar → popout grid of the user's clips; clicking one plays it **into the call** as a real published track (peers hear it) and locally (presser hears it). Clips are **user-uploadable, just like custom emojis/stickers**.
**🔱 [EC-FORK] Fork side + cinny side DONE.** The fork ships `io.lotus.inject_audio` (`LotusWidgetActions.InjectAudio`, allow-listed in `widget.ts`), armed via the `lotusAudioInject=1` flag; it publishes a clip as a separate LiveKit track — a **real** in-call soundboard mixed into the call, not local-only. cinny now drives it.
**Shipped (cinny):**
- Clips stored in `io.lotus.soundboard` account data → **synced across devices like emoji/sticker packs** (`useSoundboard` hook; `AccountDataEvent.LotusSoundboard`).
- Upload audio (≤1 MB, ≤40 clips) → `mx.uploadContent` → mxc; play resolves mxc → authed download → `blob:` object URL (the widget can't fetch authenticated media itself) → `control.injectAudio(url, volume)` + local playback.
-`CallSoundboard.tsx` popout in the call bar (upload / play / delete), gated on the `soundboardEnabled` setting (Settings → General → Calls, + volume slider).
**Remaining:** a dedicated Settings management page (optional — upload/delete already live in the popout); a small default clip set; live verification (D2-7). Files: `utils/soundboardClips.ts`, `hooks/useSoundboard.ts`, `features/call/CallSoundboard.tsx`, `plugins/call/CallControl.ts#injectAudio`.
**Complexity:** Medium — done.
---
### [~] P5-20 · Quick Reply from Browser Notification
**What:** Inline reply field in browser notification toasts via Notification Actions API. Reply sends as threaded reply to the triggering message.
**[AUDIT REQUIRED]** (1) Verify browser Notification Actions API support in target browsers. (2) Confirmed: service worker EXISTS at `src/sw.ts` — add `notificationclick` handler there.
**Complexity:** Medium-High.
**Partial Fix Applied ⚠️ UNTESTED:** Notifications now (a) show the real message body (`username: message` instead of "New inbox notification from..."), (b) click navigates directly to the room at the specific event (not the inbox), (c) `window.focus()` called on click so the tab comes to front, (d) reminder toasts also link to the specific event. Full inline-reply via Notification Actions API still needs the SW `push`+`notificationclick` pipeline (requires switching from `new Notification()` to `showNotification()` through the SW).
---
### [x] P5-30 · Advanced ML Noise Suppression (Krisp-style)
**What:** High-end background noise cancellation using a pre-trained ML model (RNNoise) running in the browser. Removes dogs, fans, and keyboard clicks from the mic stream.
**Shipped:** 3-tier setting (Off / Browser-native / ML) in Settings → General → Calls.
**🔱 [EC-FORK] DONE — moved in-source (2026-06).** ML denoise is now a first-class audio stage **inside** the forked Element Call: a LiveKit `TrackProcessor<Audio>` activated by `lotusDenoiseSource=1` (cinny sets it when ML is selected). The old build-time `getUserMedia`/`index.html` monkeypatch is **removed**. Because EC re-runs the processor on every (re)publish, denoise now **survives reconnects and mic-device switches** — this is the A7 fix (see `LOTUS_BUGS.md` A7, `LOTUS_TESTING.md` §D2-1). The processor degrades to the raw mic rather than going silent.
**Key decision:** LiveKit's Krisp filter is LiveKit-Cloud-only (we self-host the SFU); EC's own RNNoise PR #3892 is unmerged. Owning the fork let us implement the in-source stage directly.
**Models — all in-source in the fork:**
- [x]**DeepFilterNet 3** (48 kHz, **ML default**) · **DTLN** (16 kHz) · **RNNoise** (48 kHz) · **Speex** (48 kHz) — all four wired and selectable; dropdown ordered best-quality first. Tier default is **Browser-native**.
- [x]**Quality tuning (2026-07):** dry/wet **attenuation floor** (~-16 dB, RNNoise/Speex only — the "robotic" fix; DTLN/DFN would comb-filter), **gate-after-ML**, **DFN level 80→60**. Floor tunable via `lotusDenoiseFloor`.
- [x]**AEC/AGC (2026-07):** echo-cancellation ON; **AGC OFF for the ML tier** (`autoGainControl=false`, threaded through EC `UrlParams`→`ConnectionFactory`) so browser AGC doesn't fight the model; playback confirmed no AEC-defeat.
- [ ]**GTCRN (RESEARCHED — DEFERRED):** tiny MIT 16 kHz model that beats RNNoise, but **no drop-in browser package** — needs a ~1-week from-scratch build: `onnxruntime-web` (WASM, 1 thread) in a **Web Worker** (ORT can't run in an AudioWorklet — issue #13072) behind a custom AudioWorklet ring-buffer node presenting as an `AudioNode`; model `gtcrn_simple.onnx` (~300 KB, stateful — thread `conv/tra/inter` caches per frame); we write STFT/iSTFT (n_fft 512/hop 256). Assets ~3–4 MB via the `lotusDenoise()` vite plugin. Registration checklist known (both repos, incl. the 2nd `denoisePipeline.ts` used by the DenoiseTester). **Revisit only if low-power quality is insufficient after validating the current tuning.**
- [ ]**Desktop-only / HW-gated (future):** FRCRN or NVIDIA Maxine (RTX/Tensor only) — impossible in-browser; would run in the Tauri Rust backend + bridge a virtual mic into the webview. Detect capability; web falls back to RNNoise.
- **Excluded:** Krisp (LiveKit Cloud only); FRCRN/Maxine on web (GPU/server-bound).
**What:** Let users (and room admins) adjust audio bitrate and screenshare bitrate/framerate.
**🔱 [EC-FORK] Fork side + client side DONE.** The fork ships `io.lotus.set_quality` (`LotusWidgetActions.SetQuality`) that applies audio/screenshare encoding params (`RTCRtpSender.setParameters`, all simulcast encodings, re-applied on `TrackUnmuted`/republish) inside EC. cinny now drives it.
2.**Room-admin caps**: `io.lotus.room_quality` state event (`StateEvent.LotusRoomQuality`) + `RoomQuality.tsx` in Room Settings → General → Voice (mirrors `RoomVoiceLimit`).
3.**Apply logic**: `useCallQuality` (wired in `CallEmbedProvider`'s `CallUtils`) builds `min(user setting, room cap)` and sends `io.lotus.set_quality` on join / when settings change (`utils/callQuality.ts`, unit-tested).
**Server-side enforcement (DONE — matrix repo):** extended `voice-limit-guard.py` (LXC 151) to also read `io.lotus.room_quality` and hard-enforce a **publish-source policy** for ALL clients.
- **Reality (researched, primary-source, LiveKit 1.9.11):** numeric bitrate/fps caps **cannot** be hard-enforced server-side — LiveKit is a pure SFU (forwards, never transcodes); there is NO bitrate/fps field in the JWT grant, `RoomConfiguration`, server `limit:` config, or any admin RPC, and stock Element Call ignores room metadata / custom claims for publish quality. So numeric caps stay **cooperative** (our fork honors them via `min()` → `set_quality`, already shipped).
- **What IS hard-enforced cross-client:** `VideoGrant.canPublishSources`. The guard holds the LiveKit secret, so when `io.lotus.room_quality` sets `allow_screenshare:false` / `allow_camera:false` it re-signs the issued JWT with a narrowed source list → the SFU refuses those tracks for **every** client (Element, FluffyChat, our fork). Mic always kept. Fail-open; unit-tested (`livekit/test_voice_limit_guard.py`). Admin UI: Room Settings → Voice → **Call Permissions** switches. cinny also hides the blocked buttons.
- **Live (mid-call) enforcement — DONE:** the JWT re-sign covers new joins; for participants **already in the call**, a background reconcile loop in the guard calls LiveKit `UpdateParticipant` every ~3 s to narrow `canPublishSources`, which unpublishes an in-progress screenshare/camera **server-side for all clients** and blocks re-publish (verified LiveKit 1.9.11 auto-unpublishes on permission narrowing). Only removes forbidden sources (never grants), preserves other permission flags, no-ops once compliant. So flipping a room audio-only kills live cameras/screenshares within ~one interval.
- **Not enforceable / deferred:** numeric server enforcement (impossible — see above); screenshare **resolution** control (`set_quality` covers bitrate + framerate; resolution needs a `getDisplayMedia` hook inside the fork).
**Complexity:** DONE — client (cooperative numeric caps) + server (hard publish-source policy). Only the physically-impossible numeric server enforcement is out of scope.
---
### [~] P5-35 · Desktop — Notification Click Opens Room — IMPLEMENTED (Tier B, via the P5-41 WinRT toast: click → open room, reply → send); native CI-compile-pending, runtime-verify on Windows
**What:** Clicking a system tray notification navigates to the relevant room. Quick-reply from the notification toast would send the reply without opening the window.
**Status:** Deferred (Tier B). Note: the "can't compile-test without a Windows build environment" premise is **outdated** — CI now compiles Windows (Gitea self-hosted `windows` runner + GitHub `windows-latest`), and `windows`-crate/COM code already ships (e.g. `set_badge_count`, and the Tier A jump list). This still depends on P5-41 (the WinRT toast + custom activator), so it rides with that; it was not part of the Tier A wave.
**Note:** Tray icon and `matrix:` deep links already bring the window forward on most interactions. Revisit when tauri-plugin-notification gains click handler support upstream.
**Complexity:** High (platform-specific native code required).
---
### [~] P5-36 · Desktop — Windows Jump List — IMPLEMENTED (Tier A); native CI-compile-pending, runtime-verify on Windows
**What:** Right-clicking the taskbar icon shows a jump list with recent/favorite rooms for quick navigation.
**Status:** Deferred — implementing the Windows COM jump list API in Tauri requires iterating on C++/COM code that can only be compile-checked on Windows, making blind CI iteration impractical.
**Action when unblocked:** Revisit when a Tauri plugin abstracts the Windows Shell `ICustomDestinationList` interface, or when a Windows build environment is available for local iteration.
**Complexity:** High (Windows-only native COM).
---
### [~] P5-41 · Desktop — Native WinRT Toast Notifications — IMPLEMENTED (Tier B; ToastNotification + reply input + in-process Activated; falls back to tauri-plugin-notification); native CI-compile-pending. Runtime needs a Start-menu shortcut + matching AppUserModelID to surface
**What:** Replace emulated notifications with native WinRT Toast notifications.
**Approach:** Implement native WinRT Toast integration using `windows-rs` to enable full Action Center integration, including native Quick Reply functionality.
### [~] P5-42 · Desktop — Persistent Background Sync — IMPLEMENTED (Batch 3, pragmatic keep-alive); native CI-compile-pending, runtime-verify on Windows
**What:** Keep receiving messages/notifications instantly while the app is closed to the tray.
**Shipped approach (80/20):** rather than a multi-sprint headless Rust sync client, disable Chromium background throttling via WebView2 `additional_browser_args` (`--disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows`, added to the existing Tauri default args) so the existing JS Matrix `/sync` loop keeps running full-speed in the tray. Windows/WebView2 only; does not block system sleep. See `cinny-desktop/src-tauri/src/lib.rs` (WebviewWindowBuilder).
**Deferred (not needed):** the full headless Rust sidecar — only revisit if WebView2 ever hard-suspends despite these flags (would require a second Matrix client with its own /sync + push-rule eval + E2EE-aware notification content).
### [~] P5-43 · Desktop — System Media Transport Controls (SMTC) — IMPLEMENTED (Tier A); CI-compile-pending; SMTC may need an active media/audio session to surface — verify on Windows
**What:** Integrate with Windows SMTC for volume flyout call/media control.
**Approach:** Use Windows SMTC API to expose call status, mic mute/unmute, and media controls to the Windows volume flyout/media overlay.
### [~] P5-44 · Desktop — Taskbar Thumbnail Toolbar — IMPLEMENTED (Tier A); native CI-compile-pending, runtime-verify on Windows
**What:** Add persistent call controls to the taskbar preview.
**Approach:** Implement a COM thumbnail toolbar in the application preview window, featuring Mute/Deafen/End Call buttons.
### [~] P5-46 · Desktop — System Power Management (Call Continuity) — IMPLEMENTED (Tier A reference); web verified; native CI-compile-pending (Windows only; macOS/Linux no-op TODO)
**What:** Prevent system sleep/hibernate during active calls.
**Approach:** Use Tauri/Rust `power-manager` or platform-specific APIs to block system power saving states while a voice/video session is active.
### [~] P5-47 · Desktop — TDS-Styled Native Window Chrome — IMPLEMENTED (Tier A, OPT-IN/default-off, runtime-reversible); web verified; native CI-compile-pending
**What:** Replace system titlebar with custom Lotus TDS chrome.
**Approach:** Configure Tauri window (`decorations: false`) and implement custom, TDS-token compliant titlebar controls (Close/Max/Min) for a cohesive UI.
### [~] P5-48 · Desktop — Native File System Drag-and-Drop Improvements — IMPLEMENTED (recursive folder upload, web-verified: tsc/build/tests). SCOPED-OUT: `.lnk` shortcut resolution (webview never exposes a dropped file's OS path → native can't resolve the target) and "Send To" (installer/registry shell integration) — deferred
**What:** Enhance drag-and-drop support for Windows.
**Approach:** Improve handling for Windows file shortcuts, recursive folder uploads, and shell-integrated "Send To" context menu actions.
**What:** Proactively detect Windows network connectivity changes.
**Approach:** Integrate with the Windows Network Connectivity Status Indicator (NCSI) API to improve offline mode transition latency and network recovery.
### [WON'T FIX] P5-50 · Desktop — Windows Hardware-Accelerated Media Pipeline
**What:** Replace standard browser decoding with native Windows Media Foundation.
**Why won't-fix (researched):** WebRTC media (the call pipeline) lives entirely inside WebView2/Chromium — you cannot inject Media Foundation/DirectShow into WebRTC's decode path from the Tauri host. Chromium already uses the platform's hardware decoders (D3D11VA/MF) where the GPU supports the codec, so there is no separate CPU pipeline to offload. Not actionable as described.
**What:** Compartmentalize sessions, local databases, and caches into isolated "Contexts" (e.g. Work vs. Personal) with a zero-leak boundary.
**Decision:** Deferred (reviewed 2026-07). Multi-sprint, and it touches the auth/crypto/storage core — not worth the risk/effort for a niche need right now. Kept here with a concrete spec so it's actionable later.
**Future-work spec (why it's big):** the app is currently **single-session**.
- Session lives in `src/app/state/sessions.ts` under fixed localStorage keys — `cinny_access_token`, `cinny_device_id`, `cinny_user_id`, `cinny_hs_base_url`, plus the OIDC keys (`cinny_refresh_token`, `cinny_expires_at`, `cinny_oidc_*`).
- Persistence lives in `src/client/initMatrix.ts`: two fixed IndexedDB stores — `web-sync-store` (`IndexedDBStore`) and `crypto-store` (`IndexedDBCryptoStore`) — feeding one `createClient(...)`.
True per-context isolation would require: (1) namespace every localStorage key per context (`ctx:<id>:cinny_*`); (2) per-context IndexedDB dbNames for **both** the sync store and the crypto store; (3) a context registry + switcher UI (create/rename/delete/switch); (4) full client teardown + re-init on switch (`initMatrix` currently assumes one global client); (5) per-context settings + notification/quiet-hours state; (6) careful crypto-store isolation so device keys never bleed across contexts. **Smaller intermediate step** if demand appears: plain multi-account (fast account switch) *without* the hard isolation boundary — much less risky, reuses most of the login flow.
**Why dropped (reviewed 2026-07):** matrix-js-sdk can't do **true** per-room sync filtering — all room events still come down the single `/sync` stream, so "disable typing/receipts in heavy rooms" can only be a **cosmetic client-side hide**, not an actual performance/bandwidth win. That, plus the UX-complexity risk flagged originally, makes it not worth building. If per-room quieting is ever wanted, add a simple "mute typing & receipts in this room" toggle to normal room settings — not a "governor."
**What:** A sandboxed environment for local execution of user scripts on Matrix events.
**Decision:** Deferred (reviewed 2026-07). A full WASM execution engine + script registry + management UI + security model is a large surface for a very small (power-user) audience.
**Recommended lighter alternative (the ~80/20) if we ever want event automation:** a built-in **automation-rules** feature — declarative "when an incoming event matches X (room / sender / keyword / type) → notify / play sound / highlight / auto-react" rules, configured in Settings. Covers the realistic use cases (custom alerts, keyword pings) with **no arbitrary code execution**, so no sandbox/security burden. Build that instead of a scripting engine if the need arises.
**What:** Allow users to reorder toolbar icons via drag-and-drop.
**Approach:** Extend the current settings-based toolbar toggle system to include a drag-and-drop UI mode in the composer settings, allowing users to personalize their icon order.
### [~] P5-56 · Desktop — Windows "Focus Assist" (DND) Sync — IMPLEMENTED (Tier B; SHQueryUserNotificationState poll → suppresses notifications+sounds via the quiet-hours gate); native CI-compile-pending, runtime-verify on Windows
**What:** Automatically toggle notification state based on Windows Focus Assist.
**Approach:** Integrate with the Windows `NotificationCenter` / `Focus` state via Tauri/Rust to automatically enable/disable Lotus Chat's internal notification suppression mode when Windows Focus Assist is toggled.
**Blocked by:**`org.matrix.msc3892` = false on `matrix.lotusguild.org`
**What it would do:** Cleanly remove a reaction without redacting the parent message.
**Current behavior:** Full event redaction — acceptable fallback, no user-facing issue.
**Action when unblocked:** Find `onReactionToggle` redaction call site; swap in MSC3892 endpoint with fallback.
### [BLOCKED] · Room Preview Before Joining (MSC3266)
**Blocked by:**`GET /_matrix/client/v1/rooms/{roomId}/summary` returns `M_UNRECOGNIZED` 404 — endpoint not implemented in Synapse 1.155. Config flag `msc3266_enabled: true` is set but has no effect; Synapse appears not to have shipped a stable implementation at the v1 path. Verified 2026-06-18.
**What it would do:** Show room name, topic, avatar, member count before joining.
**Action when unblocked:** Re-test after each future Synapse upgrade.
### [BLOCKED] · Thread Subscriptions (MSC4306)
**Blocked by:**`org.matrix.msc4306` = false on `matrix.lotusguild.org`
**What it would do:** Follow a thread without posting; get notifications for replies.
**Action when unblocked:** Add "Follow thread" button in the thread panel header (depends on #P3-8 Thread Panel).
### [DONE] · Report User (MSC4260) ✅
**Previously blocked by:** Server spec v1.12, but `POST /_matrix/client/v3/users/{userId}/report` was confirmed **200** on 2026-06-18 (live since Synapse 1.133.0).
**What it does:** Reports a specific user to homeserver admins (separate from reporting a message).
**Note:** Report Message already exists in upstream Cinny. This adds Report User to the profile panel.
**Implemented 2026-06-18:**`ReportUserModal.tsx` added at `src/app/features/room/ReportUserModal.tsx`. Button wired into `UserRoomProfile.tsx` between UserModeration and UserDeviceSessions (hidden for own profile). Category dropdown + reason text, inline success/error feedback, auto-close 1500ms after success.
Research whether Matrix spec or MSC4133 (v1.16) defines a standard profile banner field. `uk.tcpip.msc4133.stable = true` on our server — check if a `banner_url` or similar field is defined. If no cross-client standard exists, do not implement.
---
## 📚 Implementation Reference
Exhaustive, low-level implementation details for backlog items. Follow these patterns to ensure code is "Lotus-perfect" (idiomatic, performant, and TDS-compliant).
### P3-8 · Thread Panel (Full Side Drawer)
**Architecture:** Mirror the `MembersDrawer` pattern but with a specialized timeline.
- **Component (`src/app/features/room/thread/ThreadPanel.tsx`):** Use `room.getThread(threadId)` from the SDK. Render a `Header` with a "Close" button that sets `activeThreadIdAtom` to `null`. Reuse `RoomTimeline` but pass a filtered `EventTimelineSet`. Use `thread.timelineSet` directly for the most accurate thread view.
---
### P4-4 · Math / LaTeX Rendering
**Mechanism:** KaTeX injection into the HTML parser.
- **Sanitizer (`src/app/utils/sanitize.ts`):** Allow KaTeX-specific tags and classes (e.g., `span`, `annotation`, `math`). Use a specialized allowed list for math blocks.
> [Gemini_Found] `sanitize.ts` uses **`sanitize-html`** (not DOMPurify) with an explicit allowlist (`allowedTags`) and `disallowedTagsMode: 'discard'`. All MathML tags are currently absent from the allowlist and are silently stripped. Update `permittedHtmlTags` to include: `<math>`, `<mi>`, `<mo>`, `<mn>`, `<ms>`, `<mtext>`, `<mspace>`, `<mrow>`, `<mfrac>`, `<msqrt>`, `<mroot>`, `<mstyle>`, `<merror>`, `<mpadded>`, `<mphantom>`, `<mfenced>`, `<menclose>`, `<msub>`, `<msup>`, `<msubsup>`, `<munder>`, `<mover>`, `<munderover>`, `<mmultiscripts>`, `<mtable>`, `<mtr>`, `<mtd>`, `<maligngroup>`, `<malignmark>`, and `annotation`. Also add the required MathML attributes (e.g. `xmlns`, `display`, `mathvariant`) to `permittedTagToAttributes`.
- **Parser (`src/app/plugins/react-custom-html-parser.tsx`):** Detect `$ ... $` and `$$ ... $$` patterns in text nodes:
```tsx
if (node.type === 'text') {
const parts = node.data.split(/(\$\$.*?\$\$|\$.*?\$)/g);
return parts.map((p) => {
if (p.startsWith('$')) return <KaTeX math={p.replace(/\$/g, '')} />;
return p;
});
}
```
- **CSS (`src/app/styles/CustomHtml.css.ts`):** Import `katex/dist/katex.min.css` only when a math block is rendered to save initial bundle size.
---
### P4-6 · OIDC / SSO Next-Gen Auth (MSC3861)
**Mechanism:** Matrix Authentication Service (MAS) Integration.
- **Architecture:** Shift from password-based `/login` to OAuth2 `authorization_code` flow.
- **Key Files:** `src/app/pages/auth/Login.tsx` and `src/app/hooks/useAuth.ts`.
- **Implementation:** Use `oidc-client-ts` or a similar lightweight OIDC library. Check for `m.authentication` in `/.well-known/matrix/client`. Redirect to the MAS authorization endpoint. Handle the callback in a new `OidcCallback` route and store the OIDC `refresh_token`.
---
### P5-1 · Custom Accent Color Picker (Non-TDS only)
- **UI (`src/app/features/settings/general/General.tsx`):** Use `<Input type="color">`. Hide this section if `lotusTerminal` is `true`.
---
### P5-15 · In-Call Soundboard
**Mechanism:** Local-to-Global Audio Bridge via Web Audio API.
- Create an `AudioContext` and a `MediaStreamDestinationNode`.
- Create an `AudioBufferSourceNode` for each clip.
- Route the mic `MediaStream` and the clip source to the destination node.
- Pass the destination's `.stream` to the call bridge.
> ⚠️ **[Gemini_Found — CORRECTED]** Gemini originally suggested using LiveKit's `LocalAudioTrack.replaceTrack()` to mix audio into the call stream. This is **not possible** from Lotus Chat's realm: Element Call runs in a **cross-origin iframe** controlled via `matrix-widget-api` (postMessage). LiveKit's JS SDK and its `LocalAudioTrack` live inside EC's sandboxed context — inaccessible from our code. This directly contradicts the confirmed constraint already listed in the Server Capabilities table: _"Cindy CANNOT inject audio into EC call stream — In-call soundboard must be redesigned as local-only."_ The soundboard must be a local-playback-only feature (output through the user's speakers, not mixed into the call audio stream).
>
> 🔱 **[EC-FORK — RESOLVED]** Both the original claim and the earlier "practical blocker still holds" correction are now **outdated**. EC is same-origin **and** we own the source, so we no longer reach into EC's module scope from cinny — instead the fork **exposes the inject point itself**: the `io.lotus.inject_audio` widget action (`LotusWidgetActions.InjectAudio`) publishes a clip as a separate LiveKit track from inside EC. A **real** in-call soundboard (mixed into the call, not local-only) is therefore unblocked, and the cinny-side soundboard UI is now **built** (P5-15 above): uploadable clips played into the call via this action, stored in `io.lotus.soundboard` account data.
---
### P5-20 · Quick Reply from Browser Notification
**Mechanism:** Service Worker `notificationclick` Action.
> [Gemini_Found] Implementation detail: `serviceWorkerRegistration.showNotification()` should be used instead of `new Notification()` so that the service worker can listen to the `notificationclick` event. `new Notification()` creates notifications that are bound to the client page, not the SW.
- **DTLN** (@workadventure, 16 kHz) — 🟡 wired; sample-rate fix applied (was robotic at 48 kHz). **TODO: verify in a real call.** Narrowband (16 kHz) = slightly telephone-y even when correct.
**Constraints:** client-side AudioWorklet, fully self-hosted, no GPU, self-hosted SFU (no LiveKit Cloud).
**Roadmap:**
- [ ] Verify DTLN 16 kHz fix in a real call.
- [ ] **DeepFilterNet 3** — best self-hostable upgrade: Rust→WASM, CPU real-time, 48 kHz fullband. Self-host `df_bg.wasm` + DFN3 ONNX model; wire a 48 kHz worklet. Audio quality unverifiable without a real-call test.
- [ ] **Desktop-only / HW-gated:** FRCRN (Alibaba) or NVIDIA Maxine (RTX/Tensor only). Runs in Tauri Rust backend + bridges a virtual mic into the webview. Must detect capability; web + weak HW falls back to RNNoise/DTLN.
- **Backend Sidecar:** Extend `voice-limit-guard.py` (LXC 151) to fetch `io.lotus.room_quality` and inject limits into the LiveKit JWT or return them as an authorized config packet.
---
### [x] P5-40 · Desktop — Proactive Update Notifications (Tauri) — DONE (already shipped: `TauriUpdateFeature` in ClientNonUIFeatures.tsx polls every 12h + fires the sticky update toast)
1. Create a `TauriUpdateFeature` component. Use `useTauriUpdater()` to get the `check` function and `status`.
2. In a `useEffect`, call `check()` on mount and then on a `setInterval` (every 12 hours).
3. When status transitions to `{ state: 'available', version: '...' }`, fire a Lotus Toast: "Lotus Chat v[version] is available!" with an "Update" button that calls `install()`.
4. Store `lastCheck` timestamp in `localStorage` to prevent redundant checks on refresh.
---
### Mobile Bookmarks Visibility Fix
**Issue:** `ClientLayout.tsx` explicitly restricts `BookmarksPanel` to `ScreenSize.Desktop` (lines 51-56).
```tsx
// ClientLayout.tsx
{
bookmarksOpen && (
<BookmarksPanel
onClose={() => setBookmarksOpen(false)}
isMobile={screenSize !== ScreenSize.Desktop}
/>
);
}
```
`BookmarksPanel.tsx` already supports the `isMobile` prop (line 127) to enable full-screen absolute positioning. No other changes required.
---
### Remind Me Later (Slack-style)
**Mechanism:** Account Data + Timer/Service Worker.
- **Storage (`src/app/hooks/useReminders.ts`):** Store in account data `io.lotus.reminders` as `Array<{ id: string, roomId: string, eventId: string, timestamp: number }>`.
- **Context Menu (`src/app/features/room/message/MessageContextMenu.tsx`):** Add "Remind me" option → opens date/time picker modal (reuse `JumpToTime.tsx` logic).
- **Trigger (foreground):** `setTimeout` in a hook inside `ReminderMonitor` in `ClientNonUIFeatures.tsx` → pushes to `toastQueueAtom` in `state/toast.ts` when due.
- **Trigger (background):** Use Service Worker — `setTimeout` in the main thread will not fire when the PWA is suspended.
---
### Mobile Usability Audit — Methodology
1. **Viewport & Touch:** All interactive elements must have at least `44px × 44px` touch targets. Audit for horizontal overflow (horizontal scrolling must be disabled).
2. **Modal Responsiveness:** All modals (Settings, Profile, etc.) MUST cover the full screen on mobile, not float as overlays.
3. **Sidebar / Panels:** On mobile, sidebar panels (Members, Bookmarks, Media) must become full-screen overlays (using a `Drawer` or `Modal` pattern) rather than side-by-side flexbox panels.
4. **Input & Composer:** Ensure the composer doesn't get obscured by the mobile keyboard. Test focus trap and blur behaviors.
---
## Implementation Notes
### ⚠️ TDS DESIGN LAW (repeated here for emphasis)
> Every TDS color, animation, glow, border, shadow, and font value MUST come from `/root/code/web_template/base.css`.
> Never hardcode hex values. Never invent CSS variable names.
A Matrix chat client built for Lotus Guild — fast, private, and packed with the features you actually want.
**Deployed at [chat.lotusguild.org](https://chat.lotusguild.org)** | Forked from [Cinny](https://github.com/cinnyapp/cinny), synced through v4.12.3
**Deployed at [chat.lotusguild.org](https://chat.lotusguild.org)** | Forked from [Cinny](https://github.com/cinnyapp/cinny), synced through v4.12.7
---
@@ -18,9 +18,11 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
### Messaging
- Threads: reply in a thread and read/write the whole conversation in a side panel — root messages show a "N replies" chip with an unread badge (threaded replies live in the panel now, not inline in the room)
- Slack-style thread notifications: by default you're only pinged for threads you're in or where you're @mentioned; set any thread to All / Mentions-only / Mute from the panel's bell menu (muted threads stop bumping badges; syncs across devices)
- See who has read each message, and track delivery status (sending / sent / failed)
- Bookmark any message and revisit saved messages from the sidebar
- Schedule messages to send at a specific time
- Schedule messages to send at a specific time (unencrypted rooms only — MSC4140 delayed events cannot be end-to-end encrypted, so the option is hidden in E2EE rooms)
- Click "edited" on any message to see the full edit history
- Drafts are saved automatically and survive page reloads
- Long messages collapse automatically — click "Read more" to expand
@@ -33,10 +35,14 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
- Search for and send GIFs from a built-in GIF picker
- Optional persistent search index for encrypted rooms (off by default — stores decrypted text on your device; clearable, wiped on logout)
- On-device message translation — foreign-language messages show a "Translate" action, then an inline "Translated from <language> · Show original" toggle. Runs entirely on your device via the browser's built-in translation models, so message text never leaves your device or touches a cloud service (no Google/DeepL/Microsoft) — preserving end-to-end encryption. Pick your target language and optionally auto-translate incoming messages at Settings → General → Messages. Chromium desktop (Chrome/Edge 138+) and the Lotus desktop app only; hidden where unavailable (Firefox, Safari, mobile)
- Write math with LaTeX: `$inline$` and `$$block$$` render via KaTeX (spec `data-mx-maths` supported)
- Room topics support rich formatting (bold, links, italics)
- Deleted messages show a placeholder instead of disappearing
- Code blocks highlight syntax for JS/TS, Python, and Rust
- Rich link preview cards for YouTube, GitHub, Twitter/X, Reddit, Spotify, Twitch, Steam, Wikipedia, Discord, npm, Stack Overflow, and IMDb
- Inline media embeds — play videos and posts in place instead of opening a browser tab: YouTube/Shorts, Vimeo, Dailymotion, Streamable, Twitch, Loom, and Kick as video players; TikTok, X/Twitter, Instagram, Reddit, and Bluesky as inline posts; Spotify, SoundCloud, Apple Music, and Tidal as a built-in audio player. A privacy-friendly facade shows the homeserver's cached thumbnail and only loads the third-party player when you press play. Toggle at Settings → General → "Inline Media Players" (on by default)
### Calls & Voice
@@ -67,7 +73,6 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
- Toggle to pause background animations
- Glassmorphism sidebar — frosted glass effect that lets the background show through
- Night Light / blue light filter with an adjustable intensity slider
- Emoji prefixes on room names render larger in the sidebar (e.g. 🎮 general)
- Rename any room for yourself only — other members see the original name
- Emoji picker on all room name inputs
@@ -114,6 +119,7 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
- Pending knock requests shown in the members list for room admins with a live badge count on the Members button
- Homeserver support contact displayed in Help & About (MSC1929)
- Server notice rooms are visually distinct from regular DMs
- Known limitation: the UI is English-only for now — Lotus-added surfaces aren't yet localized, so language selection is restricted to English rather than showing a partially-translated UI (see [`LOTUS_FEATURES.md`](./LOTUS_FEATURES.md#localization))
---
@@ -123,7 +129,16 @@ Lotus Chat has a desktop app for Windows, macOS, and Linux. It wraps the same we
### Download
Download the latest release from the [Releases page on code.lotusguild.org](https://code.lotusguild.org).
| Windows | [Get the installer (.exe)](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64-setup.exe) |
| Linux (AppImage, any distro) | [Get the AppImage](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.AppImage) |
| Linux (Debian/Ubuntu) | [Get the .deb](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.deb) |
| Linux (Arch/CachyOS/EndeavourOS) | [Get the .pkg.tar.zst](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.pkg.tar.zst) — install with `pacman -U LotusChat-x86_64.pkg.tar.zst` |
All Linux builds need `webkit2gtk-4.1` and, for calls to work, GStreamer's `good`/`bad`/`ugly`/`libav` plugin sets (the pacman package pulls these in automatically; on the AppImage/.deb, install them via your package manager if joining a call shows "browser does not support WebRTC").
See the full [Releases page on code.lotusguild.org](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases) for signatures and older builds.
### SmartScreen Warning (Windows)
@@ -159,12 +174,29 @@ Beyond the web client, the desktop app adds native OS integration (Windows-focus
The source code lives in `/root/code/cinny`. All changes should be made on the `lotus` branch. Push to `origin/lotus` and CI will automatically build and deploy to [chat.lotusguild.org](https://chat.lotusguild.org) in approximately 11 minutes — no manual build or deploy steps required.
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_REFERENCE.md](LOTUS_REFERENCE.md) for the design laws and operational reference (open work is in [Gitea issues](https://code.lotusguild.org/LotusGuild/cinny/issues)).
### Local Development
Lotus Chat is a **pure client — there is no backend of its own to run.** It talks directly to a Matrix homeserver (Synapse) over HTTPS, so the only thing you run locally is the Vite dev server; it connects to a real homeserver for all data. If you were looking for "the backend to pair with it," there isn't one — that's the homeserver.
**Prerequisites:** Node 20+ (CI builds on Node 24) and npm.
```bash
npm ci # deps; @lotusguild/* come from our Gitea npm registry (public read — no auth/token needed)
npm start # Vite dev server → http://localhost:8080
```
The dev server defaults to **port 8080** (`vite.config.js`); if 8080 is already in use it falls through to 8081+, so check the "Local:" URL Vite prints on startup. If it boots but the page renders blank, it's almost always a failed module/asset resolution, not a "missing backend" — open the devtools console and read the first error.
**Which homeserver / logging in:**`config.json` sets `defaultHomeserver: 0` → `matrix.lotusguild.org`, so you sign in with your normal `@you:matrix.lotusguild.org` account. That homeserver is **live production** — anything you send is real, so keep test traffic to a DM with yourself or a throwaway room. To develop fully isolated instead, point `config.json` at a throwaway `matrix.org` account (already in `homeserverList`) or a local Synapse.
- **SSO / OIDC works from localhost.** Login goes through Authelia via OIDC dynamic registration; the provider redirects back to `http://localhost:8080/…` and the client registers that redirect on the fly, so no server-side allow-listing is needed. After the callback you may see a `GET …/_matrix/media/v1/thumbnail/… 404` — that's just a missing avatar thumbnail, **not** a login failure.
### 🔱 Element Call fork ("Lotus Call") — LIVE
Voice/video channels embed **Element Call**, which is now our **self-built fork**
(`@lotusguild/element-call-embedded``0.20.1-lotus.1`, source at
(`@lotusguild/element-call-embedded``0.25.0-lotus.12`, upstream base v0.25.0, source at
`LotusGuild/element-call`), published to our private Gitea npm registry and served
same-origin. We no longer depend on the upstream prebuilt bundle, so in-call
behavior is editable source instead of fragile DOM/widget hacks.
@@ -176,10 +208,10 @@ avatar decorations on EC video tiles, and a native transparent background.
(`io.lotus.inject_audio` → in-call soundboard) and quality controls
infra/hosting + build-pipeline notes live in the `LotusGuild/matrix` repo README.
Search the docs for the **`[EC-FORK]`** tag to find every related note.
### Build
@@ -198,3 +230,5 @@ NODE_OPTIONS=--max_old_space_size=6144 npm run build
```
edit → commit → git push → ~11 min → live at chat.lotusguild.org
```
CI (`.gitea/workflows/ci.yml`) also runs a gitleaks secret scan, builds and smoke-tests the Docker image (`docker build`, boot + security-header checks against `docker-nginx.conf`), and dependency updates are proposed weekly by Renovate (`.gitea/workflows/renovate.yml`, config in `renovate.json`).
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
"$comment":"Gitea #96. Budgets are seeded from the dist/assets gzip sizes on the tree at seed time, +10% headroom. Regenerate deliberately (not just to silence a failure) when a real feature addition grows the bundle: measure the new gzip sizes and bump these with the same +10% margin.",
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.