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