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