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
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>
* 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
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>
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>
- 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>
- 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>
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>
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>
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>
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>
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>
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>
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>