- 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>
Per-slice hunt over the LOTUS_FEATURES surface (theming / calls / messaging /
threads-presence-UX / rooms-mod-notif-infra-desktop), verified against current
code. Records ~20 residual findings (desktop-CSP missing Steam/Mixcloud/Deezer
frame-src hosts; DenoiseTester model-node leak; PiP auto-spotlight not released;
avatar-decoration no live update; DND badge shown as Idle; toast overflow;
Focus-Assist mount hydration; + Low tail).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Recognize store.steampowered.com content URLs and render each richly, within
the existing privacy-first facade. 2-agent reviewed (both SHIP).
- getSteamTarget / steamWidgetEmbedUrl (videoEmbed.ts, +tests): classify
/app/{id}, /news/app/{id}/view/{gid}, /(bundle|sub|dlc)/{id}; non-content
pages (home/search/wishlist) and other hosts fall through to the generic card.
- SteamCard now dispatches:
- app → OG capsule header + click-to-play facade → Steam's OFFICIAL store
widget iframe (store.steampowered.com/widget/{id}): live region-aware price,
discount %, Buy on Steam. Nothing loads from Steam until "Show price &
store" is pressed; gated by the inlineMediaEmbeds setting. App pages use the
wide card so the ~646px widget has room.
- news → rich announcement card (banner + headline + body preview + link) —
your example URL previously fell through to the plain generic card.
- bundle/sub/dlc → the OG store card.
Grounded in our CSP: the widget works via frame-src https: (no infra change),
images route through the homeserver (img-src excludes Steam), and there is NO
client-side Steam API call (connect-src + Steam CORS both block it) — which is
also the honest ceiling: no review scores/genres client-side, price/buy come
from the official widget.
Runtime QA still needed: the live widget iframe rendering (height/fit) can't be
verified headlessly.
Gates: tsc 0, eslint 0, prettier clean, 912 tests, build ok.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes the prettier CI gate that failed on bc608b37 — check:prettier runs
`prettier --check .` over the whole repo (incl. markdown), and prettier's
markdown style uses `_italic_`. Prior doc commits slipped `*italic*` through
because I only ran prettier on changed src files, not the .md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bugs found by a 3-agent audit of the inline-embed system (core posture —
sandbox, postMessage origin+source, XSS, noreferrer, oEmbed — verified sound);
fixes reviewed by 2 agents on the staged diff (both SHIP).
Parsing (videoEmbed.ts, + tests):
- Twitch/Kick/SoundCloud/Streamable reserved-path exclusion — their own utility
pages (twitch.tv/directory, kick.com/browse, soundcloud.com/discover/…,
streamable.com/login, bare /videos) no longer render as broken player embeds.
- SoundCloud: `/<artist>/<tab>` profile-tab listings excluded; `/<artist>/sets/<slug>`
real sets still detected.
- Vimeo: unlisted-hash capture constrained to lowercase-hex, so a normal video's
trailing segment (/likes, /settings, a slug) isn't captured as a bogus `h=`
param that Vimeo then rejects.
Rendering (UrlPreviewCard.tsx, RenderMessageContent.tsx):
- Spotify/Steam/Discord/IMDb route og:image through mxcUrlToHttp like every other
card — a raw og:image is an mxc:// URI (broken <img> on standard Synapse) or an
off-homeserver request that defeats the click-to-play facade.
- `wide` card class now follows the RESOLVED embed (incl. the og:url short-link
fallback), so an og:url-resolved player gets the wide layout, not a cramped one.
- Twitter host detection (isTwitter/isTwitterTweet) aligned with getTweetId —
mobile.twitter.com and legacy /statuses/ now route to the Twitter card/embed.
- De-dupe preview URLs so a message repeating a link doesn't render sibling
cards with identical React keys.
Gates: tsc 0, eslint 0, prettier clean, 910 tests, build ok.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Test-coverage batch 2 (2-agent reviewed, both SHIP; isolation verified — Node
runs each test file in its own process, so the console patch can't leak).
- cryptoDiagLog.test.ts: the E2EE KE-cluster diagnostics tool — KE-signature
capture vs ignore, most-specific-first match order, KE-3/KE-4 rows, Error /
object / circular-arg serialization (String() fallback never throws), the
200-entry ring-buffer eviction, getCryptoDiagEntries copy semantics,
install idempotency, and buildCryptoDiagReport's client metadata + LOCKED
PII-safe key set (no field can silently leak) + no-client/throwing-getter
fallbacks. Silences console pass-through so the ring-buffer test stays quiet.
- closedLobbyCategories.test.ts: mirrors closedNavCategories — id join,
hydrate, PUT/DELETE, idempotent PUT, no-op DELETE, array persistence,
per-user key namespacing.
Also: mark the EC in-call mobile UI audit done in LOTUS_TODO (stale entry;
shipped as element-call:lotus e36aef8a).
Gates: tsc 0, eslint 0, prettier clean, 911 tests, build ok.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reviewed by 2 agents + a focused deploy-script review (all SHIP).
- Add top-level `concurrency: cancel-in-progress`. A superseded lotus push
cancels its in-flight run, freeing the shared act_runner (web CI otherwise
queues behind long Tauri desktop builds); since `trigger-desktop` is
`needs: build`, only the newest commit kicks a desktop build.
- Promote typecheck / eslint / prettier from `continue-on-error` to hard gates
(tree held clean: tsc 0, eslint 0 errors, prettier formatted). eslint gates
on errors only; existing no-explicit-any warnings stay informational.
- Mark the bundle-size report informational (audit already is).
Cancelling superseded runs is deploy-safe only because lotus_deploy.sh now
re-resolves origin/lotus each poll iteration (companion change in the matrix
repo); the comment documents the coupling.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Test-coverage expansion (2-agent reviewed, both SHIP). The named candidates
(roomToUnread, markedUnread, serverAcl, plaintextCaches, recent*) were already
tested, so this targets genuinely-untested pure logic.
- dom.test.ts: getThumbnailDimensions (scaling math incl. just-over-cap
boundaries), tryDecodeURIComponent, syntaxErrorPosition, and the three
scroll-view geometry helpers (via duck-typed element mocks — no jsdom).
- emoji.test.ts: getHexcodeForEmoji (astral codepoints, 4-digit zero-pad,
FE0F/FE0E/200D stripping on and off, keycap sequences, degenerate inputs)
and the pre-load `undefined` contract for getShortcode(s)For.
Fix (found while writing the tests): syntaxErrorPosition required whitespace
AFTER the digits (`/position\s(\d+)\s/`), but real V8/Node JSON.parse errors
put the number at end-of-string ("... at position N"), so it returned
undefined for every real error and the three dev-tools JSON editors silently
pointed their cursor at position 0. Dropped the trailing `\s`; tests now assert
extraction at end-of-string.
Gates: tsc 0, eslint 0, prettier clean, 891 tests, build ok.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verify-then-fix batch of minor bugs; each staged diff reviewed by 2 agents
(both SHIP). Two listed items (N6 receipt-avatar refresh, H10 room-name
length reject) were already handled and left unchanged.
Threads:
- T5: a just-sent reply no longer under-notifies — `participated` also checks
the local thread timeline for our own events, since the server-bundle
`hasCurrentUserParticipated` lags.
- T6: a room set to "Mentions & Keywords only" no longer over-notifies Default
thread replies — new `roomMentionsOnly` gate (behavior-identical when false;
+4 unit tests).
- T7: thread-mode account-data writes are serialized with content carried
forward (setAccountData is a bare PUT whose result lags the /sync echo, so
plain serialization wouldn't stop the lost update); carry only on success.
Calls / audio:
- C-L2: a real incoming ring cancels a lingering Settings ringtone preview.
- C-L3: the ringtone AudioContext is primed on the first page gesture (via the
always-mounted CallEmbedProvider) so the first ring after a cold load isn't
silent.
- C-L5: useCallSpeakers depends on a stable boolean, so the tile MutationObserver
+ io.lotus.call_state subscription aren't rebuilt on every membership change.
Crypto:
- F5: the OIDC refresher forwards the freshly-refreshed token expiry
(passed on the tokens object at runtime) as expiresInMs, so the persisted
expiresAt no longer goes stale across reloads.
Gates: tsc 0, eslint 0, prettier clean, 860/860 tests, build ok.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mobile follow-ups round 2 (survey findings deferred from the mobile audit),
reviewed by 2 agents on the staged diff (both SHIP).
- URL-preview cards: the Twitch / Twitter / TikTok-fallback cards render
their thumbnail/header BESIDE the content as direct children of the
UrlPreview flex row, which squeezes both on a phone. Add `StackOnMobile`
(@media max-width:750px -> flex-direction:column) scoped to those variants
via cardClass. folds Box has no default `direction`, so the override wins
uncontested; desktop (>750px) is unchanged. No-op for the single-column
embed cards (MediaEmbedCard/TikTokEmbedCard).
- 44px touch targets (MobileTouchTarget, @media max-width:750px) on the
otherwise ~28px controls: embed-player Close/Collapse/Fullscreen/View-post
buttons; image-viewer close/zoom/download; the read-receipt "seen by" pill.
Deferred (rationale, not built): PiP resize handles + fullscreen button —
enlarging four 24px corners to 44px would swallow a ~160px mobile PiP and
block "Return to call"; presence dot is a non-interactive status indicator.
Gates: tsc 0, eslint 0, prettier clean, 856/856 tests, build ok.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Avatar decorations are animated APNGs and were the only motion feature not
gated on prefers-reduced-motion (chat backgrounds / seasonal overlays all
suppress motion under it). Since there's no static-frame asset to freeze to,
render just the avatar (no decoration overlay) when the user prefers reduced
motion — the only motion-respecting option. Users without the preference are
unaffected; live OS-toggle is reactive via useReducedMotion. Also relieves the
mobile perf drain of dozens of live APNGs in scrolling lists.
Reviewed: correct a11y behavior, hooks-safe, no layout dependency on the overlay.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deep-audit follow-up. New shared MobileTouchTarget class (@media <=750px ->
minWidth/minHeight 44px) applied via className to the primary interactive
controls folds renders below 44px:
- in-call control bar (7 buttons) + persistent call-status bar (4 buttons)
- thread "N replies" chip
- knock Approve/Deny buttons
- server-ACL entry remove button
folds size variants set only padding (no width/height/min-*), so the class
raises the hit-area floor to 44px with the icon/label staying centered at its
normal size; desktop is untouched (@media-gated). Verified by two review passes
(no distortion, no layout overflow, counts exact).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mark the code-level mobile responsive audit done (M1-M6 + N1-N2 shipped) and
list what remains: runtime device QA, the Element Call fork in-call mobile UI,
M2/iOS touch discoverability, the sub-44px sweep, avatar-decoration reduced-
motion, and the Twitch/Twitter/TikTok card restructuring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MImage/MVideo pinned AttachmentBox to a fixed height computed for a 400px-wide
layout. The box width is responsive (maxWidth:100%) but the height was frozen,
so on a phone the box narrows below 400px while keeping desktop height ->
images crop (object-fit:cover) and videos letterbox (object-fit:contain).
Drive the box by `aspect-ratio: w/h` when intrinsic dimensions are known, so the
height tracks the responsive width. On desktop the box stays 400px wide, so the
aspect-ratio yields the identical height (algebraically 400*h/w =
scaleYDimension(w,400,h)) — pixel-identical. Falls back to the fixed height when
dimensions are unknown; the 48px floor and 600px cap are preserved.
Uses the same pattern already shipped in this codebase (TwitchThumbnailWrapper,
GalleryTile). Two review passes, one empirically measuring the rendered image in
Chromium: desktop unchanged, narrow widths keep correct aspect, no collapse.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From the 6-agent deep per-feature audit. Mobile-gated / consistency fixes;
desktop unchanged except two intentional dialog-width normalizations noted below.
- In-call control bar: wrap="Wrap" on the SequenceCard so the compact two-group
row wraps on the narrowest phones (<=390px) instead of pushing End off-screen
(M1 fixed the 500-750px band; this covers narrower). Desktop stays one row.
- In-call soundboard popout: clamp maxWidth to the viewport (like M5's screenshare
popover) so it can't overflow a narrow phone.
- Report-Message dialog + "Seen by" (EventReaders) modals (Message.tsx x2 +
RoomViewFollowing): add useModalStyle so they go full-screen on mobile like
their sibling report/receipt modals (they floated as fixed cards before).
- In-app toast container: full-width toasts inset from both edges on mobile
(ScreenSize.Mobile); a fixed 280-340px card previously overflowed a narrow
phone. Desktop byte-identical (bottom-right floating card).
- Policy-list tabs + audio-controls rows: wrap="Wrap" (inert on desktop).
Intentional desktop deltas (normalizing to existing sibling modals, verified by
two review passes as consistent, not regressions): Report dialog max-width
380->480px; EventReaders modals 460->360px.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mobile-audit batch 6. Bump the primary always-visible tap targets to a 44px
touch area on phones, via mobile-gated CSS only (desktop/tablet >750px keep the
denser sizing).
- NavItemBase (room/nav list row): minHeight 36 -> 44 at <=750px.
- SidebarItem (space-rail button): minWidth/minHeight -> 44 at <=750px (was 42).
The room lists are virtualized with ref=virtualizer.measureElement on every row,
so rows are measured to their actual height — the taller mobile rows can't
overlap/clip. Verified desktop-unchanged and virtualizer-safe by two review
passes. (A blanket app-wide size=300 button sweep was intentionally NOT done:
most such buttons are hover-gated and never appear on mobile; the primary
tap targets above are the high-value fixes.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mobile-audit batch 5. Desktop provably unchanged (two review passes).
- Translate-language control: raw <select> (crowded narrow tiles + broke under
non-default themes) -> the folds-native SettingsSelect used by every other
dropdown in the settings screen (native-cinny; keeps aria-label).
- Ringtone/Soundboard volume sliders: moved from the fixed-width tile `after`
slot (which squeezed the title on phones) to a full-width slider in the tile
body, matching the night-light slider pattern.
- Screenshare-confirm popover: clamp maxWidth to the viewport so it can't run
past the screen edges on a phone (inert on desktop).
- In-call soundboard editor rows wrap on a narrow popout instead of crushing the
clip-name field.
- GifPicker: feed the giphy Grid the measured container width (useElementSize
Observer) instead of a fixed 296px, so it doesn't overflow a <312px phone.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mobile-audit batch 4. Desktop paths unchanged (verified by two review passes).
- UserRoomProfileRenderer: the member/room profile was always an anchored,
fixed-width (340px), non-scrolling PopOut, so on a phone the moderation
actions / device list / notes fell off the bottom, unreachable. On
ScreenSize.Mobile it now renders a full-screen, internally-scrollable Modal
with an explicit Close button (the full-screen sheet covers the backdrop and
the profile has no self-close, so a tap-to-dismiss / X is required); desktop
keeps the exact same anchored PopOut. Uses the provider-free useScreenSize().
- PowersEditor: the Color/Name/Power row wraps on narrow widths (wrap="Wrap")
instead of squishing the name field; inert at desktop widths.
The mobile close button addresses a dismissal-trap both reviewers flagged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mobile-audit batch 3. All changes mobile-gated (@media <=750px) so desktop is
unchanged.
- ModalWide: fill the phone screen (100vw/100vh, no radius) at <=750px instead
of floating as an 85vw card. This also full-screens the file/PDF viewer and
the avatar-crop editor on mobile (they share ModalWide) — intended.
- UserHero avatar viewer: new mobile-only ModalMobileFull class (no desktop
effect) so it goes edge-to-edge on phones like the timeline lightbox.
- usePan: add touch support (single-finger drag, cleaned up on
touchend/cancel/unmount) alongside the unchanged mouse path, so a zoomed image
can be panned on a phone. Wired into ImageViewer and the MediaGallery lightbox.
Two review passes: mouse path byte-for-byte unchanged; desktop provably
unaffected; touch is gated to zoom!=1 so a non-zoomed image never hijacks
swipe/scroll.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mobile-audit batch 1. All fixes reuse cinny's own responsive primitives and are
mobile-gated so desktop is unchanged.
- Message tables: wrap <table> in an overflow-x container so a wide table scrolls
instead of overflowing the message column / page body.
- Composer toolbar: let the before|editable|after row and the toolbar wrap on
phones (@media <=750px) instead of squeezing the editable to zero and pushing
Send off-screen.
- In-call control bar: collapse to the compact/stacked layout on a mobile
viewport (ScreenSize.Mobile) too, not just when the bar's own container is
<500px — fixes the 500-750px band where the control row overflowed.
- URL-preview card: base width toRem(400) -> min(25rem, 92vw) so a single card
fits a narrow phone (still exactly 400px on desktop).
- Explore card grid: drop to one column at <=750px (was a fixed 3-col grid).
Two review passes: desktop behavior provably unchanged (all gated by @media /
ScreenSize.Mobile; the table wrapper only contains previously-overflowing tables).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the open Discovery-pass-2 list with a completion summary + commit refs
(PERF-1..5, SEC-1..4, COR-1..6 shipped this session, gate-green, each reviewed
by two agents). Record PERF-6 / SEC-5 as deferred-informational and note KE-1's
storage.persist() preventive is already implemented.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
COR-3 (CallEmbedProvider): the incoming-call lifetime guard distrusted a
caller's sender_ts only when it was >20s AHEAD of the server ts. A caller clock
that ran SLOW left sender_ts in the past, so the ring auto-dismissed/never
showed for a fresh invite. Trust sender_ts only within ±20s of the server ts,
else fall back to it (also fixes a NaN path when sender_ts is missing).
COR-6 (CallControl): forceState rebuilt CallControlState with 5 args, silently
defaulting screenshareAudioMuted to false; pass this.screenshareAudioMuted.
COR-5 (uploadContent + useBindUploadAtom): cancelling during the retry back-off
was a no-op (mx.cancelUpload only aborts an in-flight request), so the upload
resurrected on the next attempt. Thread an AbortSignal: the back-off sleep
resolves early on abort and the loop stops with an abort error; the hook aborts
a per-upload AbortController on cancel (alongside mx.cancelUpload for the
in-flight case).
All verified by two review passes (no double-settle / no resurrection); includes
their suggested abort-listener cleanup on normal sleep resolution.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SEC-3: add `noopener,noreferrer` to the 5 `window.open(_blank)` sites that
don't use the returned handle (UserChips, OidcManageAccount, OtherDevices x2,
Verification), closing reverse tab-nabbing. SSOStage is intentionally excluded —
it needs the window handle + intact opener for its origin-checked SSO
postMessage handshake.
SEC-4: guard the `/acl` slash command against bricking the room.
- Extract the ACL glob helpers (isValidServerPattern/globToRegExp/matchesAnyGlob)
from RoomServerACL into a shared utils/serverAcl.ts (+ unit test) so the
command and the settings editor validate identically.
- Default a MISSING allow list to `*` only when the room has NO existing ACL
(a first `/acl -d x` otherwise sent `allow: []`, which bricks the room); an
existing ACL's absent/empty allow is preserved, not silently widened.
- Reject invalid globs; fail CLOSED on the universally-catastrophic cases
(empty allow, or a `*` deny) even when the local domain is unknown; and reject
any change that would ban this homeserver (self-lockout).
Guard hardened per two review passes (fail-closed on unknown domain; no silent
federation widening).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every ReadReceiptAvatars row and every useMemberAvatar registered its own global
RoomStateEvent.Members listener — ~6 per receipt row — each firing on any
membership / display-name / avatar change in ANY room.
Add a module-level MemberChangeStore (mirroring the PERF-1 presence store) that
registers exactly ONE global Members listener and fans out to subscribers keyed
by roomId|userId. Two hooks: useRoomMemberChange (single) and
useRoomMembersChange (multi, one effect). useMemberAvatar and ReadReceiptAvatars
use them; behavior (re-render triggers) is byte-for-byte equivalent. Unsubscribe
is idempotent via a set-identity guard; the multi-hook key is order-independent.
Unit-tested (key-scoped fan-out, single shared listener, idempotent unsubscribe).
Reviewed by two passes (lifecycle/closure + behavioral equivalence) — clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PERF-2 (RoomMentionAutocomplete): the #-mention list did
`useAtomValue(allRoomsAtom).sort(...)` inline — `.sort()` MUTATED the shared
allRoomsAtom array in place (reordering it for ~27 other consumers) and re-ran
the O(N log N) getRoom compare every keystroke. Copy then memoize:
`useMemo(() => [...allRoomsList].sort(factoryRoomIdByActivity(mx)), ...)`.
PERF-4 (SearchFilters): the room-filter A-Z sort ran every render; wrap in
useMemo keyed on [searchResult, roomList, mx].
PERF-5 (useRoomLatestRenderedEvent + RoomNavItem): the hook registered a GLOBAL
client `Decrypted` listener for every nav item, but its result is only used for
DM rows. Add an `enabled` param (default true) that skips all work + listeners
when false; RoomNavItem passes `!!direct`. The only other caller keeps the
default.
Verified behavior-preserving by two review passes (PERF-2 also fixes a real
shared-atom mutation bug).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
useUserPresence registered 3 client listeners (Presence / CurrentlyActive /
LastPresenceTs) PER hook instance. On a large room that meant 100-250 global
listeners, every presence event fanning out across all of them, with add/remove
churn on every fast scroll.
Replace with a module-level PresenceStore singleton that registers exactly 3
listeners total (lazily, on first subscriber) and fans out to per-user
subscribers itself. The hook keeps the same public API (useState + a subscribe
effect); consumers are unchanged. Cache + subscriber sets stay bounded to
currently-mounted users; the mx-swap branch re-homes listeners on re-login.
Reviewed by two passes (SDK mutate-before-emit ordering and handler signatures
independently verified). Includes their recommended hardening: the unsubscribe
is made idempotent via a set-identity check so a double-invoke / re-subscribe
can't evict a newer subscriber.
Note: a User object that appears silently with no presence event no longer
re-seeds (deps are [mx, userId] not [mx, user]); the common presence-EDU case
is handled (and better than before). Reviewers rated this narrow case Low.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a single m.space.child was removed (unlinking child C from space P), the
roomToParents reducer fired the whole-room DELETE action, which wiped C's
entire parent set, stripped C as a parent from every other room, and orphaned
C's own descendants until a full resync. So removing C from space A also
dropped C's other parent B, and C's children lost C.
Add a targeted UNLINK {parent, child} action that removes only that one
parent->child edge and prunes the child entry only when its parent set
empties (matching the map's build-time invariant that zero-parent rooms have
no entry). Point the invalid-child branch of handleStateChange at it; DELETE
is unchanged for genuine room leave/delete. Unit-tested (keeps other parents,
prunes on last parent, does NOT orphan descendants, unknown pair no-op).
Verified correct + consumer-safe by two review passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
COR-2 (useCallEmbed): useCallJoined only reset `joined` when the embed became
undefined. Answering a 2nd call swaps the embed A->B directly (embed stays
truthy), so `joined` stayed true and call B rendered as already-joined,
skipping the loading/watchdog UI. Re-seed from `embed?.joined ?? false` on
every embed identity change.
COR-4 (ClientNonUIFeatures): the notify-dedupe used one Map<roomId,eventId>
slot shared by the main-timeline and per-thread paths, so a thread reply
overwrote the room's slot and a re-fired main message (decrypt/edit re-emit,
common in E2EE) then mismatched and double-notified. Key the slot by
`${roomId}|${threadId ?? 'main'}` so each path dedupes independently.
Both verified correct by two review passes (no missed-notification or
missed-join regressions).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Several localStorage caches held decrypted message content or user PII and
survived a normal logout, leaving residue on a shared device (the search
index was already wiped; these were not):
- cinny_scheduled_messages_v1 - decrypted IContent.body of pending sends
- cinny_recent_searches_v1 - search query text
- cinny_recent_forward_targets_v1 - recent forward contact/room graph
- cinny_recent_gifs_v1 / cinny_recent_stickers_v1 - media the user sent
- navToActivePath<userId> - per-space last-visited room paths
- (plus the translation cache added earlier)
Add a clear function per module and a single auditable clearPlaintextCaches()
aggregator, called from both logout paths (logoutClient + the server-forced
SessionLoggedOut handler) alongside the existing session/search-index wipes.
Unit-tested.
Deliberately NOT cleared (documented in the aggregator): unsent composer
drafts and the presence status message (preserved by product decision N98);
SDK sync/crypto store + io.lotus.* account data (reminders/bookmarks/notes),
already wiped by mx.clearStores(); low-sensitivity UI/metadata residue.
The forward-targets/gifs/stickers/nav-path additions and the accurate
"not covered" documentation address findings from two review passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the feature to README (Messaging) and LOTUS_FEATURES (new
On-Device Message Translation section under Messaging Enhancements),
matching the existing style.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up hardening from two review passes on the on-device translation
feature:
- Privacy (HIGH): the translation cache is decrypted message plaintext,
but logout did not clear it (unlike the search index), leaving up to
300 cleartext bodies in localStorage on shared devices. Add
clearTranslationCache() and call it from both logout paths
(logoutClient and the server-forced SessionLoggedOut handler).
- Edited messages (MEDIUM): the cache key was eventId:target with no
content dependence, so an edit reused the pre-edit translation. Fold a
content fingerprint into the key, and re-arm the auto-translate
one-shot when the text changes.
- Settings (LOW): coerce a persisted translateTargetLang to a supported
curated code so the hook never targets a language the engine can't
produce (previously only the UI clamped it).
- Chinese (LOW): restore canonical BCP-47 case (zh-Hant / zh-Hans) at
the Translator API boundary, since normalizeLang lowercases the script
subtag for internal keys.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add per-message translation that runs entirely on-device via the
Chromium built-in Translator + LanguageDetector APIs. Message text
never leaves the machine and never touches a cloud service, preserving
the E2EE guarantee. When the on-device engine is unavailable
(non-Chromium / mobile) the feature simply hides itself; there is no
network fallback.
- Engine abstraction (utils/translation): TranslationEngine interface
plus a chromeTranslationEngine implementation (feature-detected,
caches translator/detector instances, download-progress monitor).
Pure lang-code helpers (normalize/sameLanguage/curated targets) with
unit tests.
- Settings: translateTargetLang (default English) + autoTranslate
(opt-in), with a Messages settings tile — a target-language select
and an auto-translate switch, disabled with a note where unsupported.
- useMessageTranslation hook + shared per-event toggle atom-family and a
persisted LRU cache so scrollback never re-translates.
- UI: a Translate / Show Original message-menu action, an inline
"Translated from <lang> - Show original" chip, and a body swap in
m.text/m.emote/m.notice that renders the translated text through the
plain-text path (linkify + emoji) inside a dir=auto span for RTL.
- Auto-translate flips foreign messages whose model is already
downloaded; first-time downloads keep the manual chip (user gesture).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The status-message emoji picker listed the guild's custom/image-pack emojis, but
clicking one did nothing — the field only wires onEmojiSelect (unicode), not
onCustomEmojiSelect, so custom picks were silently dropped (the room composer
works because it wires both). A custom emoji is an mxc image and a status is
plain-text presence status_msg, so it can't render there anyway.
Add an EmojiBoard hideCustomEmojis (unicode-only) mode that zeroes the image
packs (removing pack groups, sidebar icons, and search results) and filters
custom entries out of Recent, and enable it on the status field. Now every emoji
shown actually inserts. Additive prop, default off — no change to other pickers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On cinny-desktop (Tauri/WebView2) a clicked notification navigated the web
content but never raised the OS window (a service-worker/WebView2 client.focus()
only focuses the document). The service-worker notificationClick path now calls
a new focus_main_window Tauri command via invokeTauri (no-op outside Tauri) in
addition to navigating; the rich-toast path is focused natively on the Rust side.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review noted the number input had both an htmlFor-associated visible label
("Voters can pick up to") and an aria-label, so the aria-label won and the
visible label was not announced. Remove the aria-label so the accessible name
matches the visible label.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The poll creator only offered single (max_selections 1) or multiple = pick ALL
options — no way to run a "pick your top 2" poll, even though the display side
already enforces an arbitrary max_selections ("Select up to N"). Add a "Voters
can pick up to N of M options" control shown for multiple-choice polls. Defaults
to the option count (preserving the old select-all behavior) until lowered;
clamped to [2, filled option count] on submit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
check:prettier was not part of my gate routine, so formatting drift accumulated
across the session's touched files (and a few older ones). Run prettier --write
to bring the repo back to 'All matched files use Prettier code style!'.
Formatting only — no logic changes. tsc/tests/build all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ScheduleMessageModal had a local formatSendAt(Date) byte-equivalent to the
tested formatFriendlyDateTime (utils/datetimeInput). Reuse the shared, unit-
tested helper instead of a second copy — identical output. (Also prettier-clean.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The emoji and GIF pickers both have a "Recent" row, but the sticker tab of the
shared EmojiBoard did not — you had to hunt through packs to re-send a sticker.
Add recent stickers, mirroring recentGifs:
- New state/recentStickers.ts (localStorage cinny_recent_stickers_v1, deduped by
url, capped 16) + pure addRecentSticker with 4 unit tests.
- EmojiBoard: a "Recent" group in stickerGroupItems and a RecentClock sidebar
icon in StickerSidebar, shown only when recents exist. Entries are rebuilt into
minimal PackImageReaders (StickerItem needs only url/shortcode/body) so they
render + re-send like pack stickers.
- Recorded on select in the shared delegated click handler, covering both the
grouped and search paths.
Blast radius is the sticker tab only (reactions/status use the emoji tab).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The thread composer already showed the /command autocomplete (RoomInput.tsx:954
was never gated), but the interpreter was disabled (:523), so /me, /shrug,
/invite, etc. sent literally in threads - a confusing inconsistency and the
other half of the threads "v1" limitation.
Remove the thread gate: content-transform commands (/me, /notice, /shrug,
/tableflip, /unflip) flow into the normal send path, which already routes to the
thread via threadRootId; the rest are room-level actions. No command sends a
mis-routed timeline message (verified against useCommands). Scheduling stays
disabled in threads for now.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From review: the per-answer voter line sat inside the radiogroup with no
association, so a screen-reader user on the radio didn't hear who voted. Add
aria-describedby from each answer to its voter line (id poll-voters-<eventId>-
<answerId>) and prefix the line with "Voted by" for a clearer announcement.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The poll card showed vote counts but never who voted, even though
computePollState already parses a sender for every response. Surface it:
- tallyResponses now also returns voters: Map<answerId, senderId[]>, built in the
same latest-response-per-sender loop as the counts, so voters can never disagree
with the numbers (voters.get(id).length === counts.get(id)). +5 unit tests.
- PollContent adds a "Show who voted" toggle, shown only when results are visible
(disclosed live, or undisclosed after end — so a secret ballot stays secret).
When on, each answer lists its voters' display names (getMemberName), rendered
as a sibling of the answer button so the radiogroup keyboard model is untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
isInQuietHours was a hand-rolled, untested duplicate of the overnight-window
logic. Replace it with the shared, unit-tested isWithinTimeWindow (utils/
timeWindow.ts) - identical behavior for valid HH:MM inputs, more robust on
malformed ones (returns false rather than doing NaN math), and now covered by
timeWindow.test.ts. One implementation instead of two.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The thread composer reused RoomInput's hardcoded editableName="RoomInput", so
the main timeline's global up-arrow "edit last message" handler fired while
focused in a thread composer and targeted the MAIN timeline's last message
(wrong), and there was no up-arrow edit for the thread itself.
- Make editableName a RoomInput prop (default "RoomInput"); the thread composer
passes "ThreadInput", so the two up-arrow handlers never cross-fire.
- Add an up-arrow-edit handler to ThreadTimeline (parity with RoomTimeline):
empty thread composer + Up -> edit the latest editable reply in that thread,
using thread.liveTimeline + canEditEvent + setEditId.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Manual DND only existed via the desktop tray (manualDndAtom), so web/mobile
users had no way to pause notifications, and there was no snooze-for-a-duration
anywhere. Add a "Pause Notifications" control in Settings > Notifications:
- Presets: 30 min / 1 hour / 4 hours / Until 8 AM / Until I resume, plus Resume;
live "Paused until ..." status that flips back on when the snooze lapses.
- Persisted snooze instant (cinny_notification_snooze_until_v1) so it survives a
reload; 0 = off, SNOOZE_INDEFINITE = until resumed.
- Feeds the existing notification gate (ClientNonUIFeatures, both the message and
invite monitors) alongside Focus Assist / manual DND / Quiet Hours, suppressing
notify() and playSound().
- Pure helpers isSnoozeActive/nextTimeAtHour/SNOOZE_INDEFINITE in utils/snooze.ts
(+5 unit tests); persisted atom in state/notificationSnooze.ts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The timeline image viewer only zoomed via the -/+ buttons and the % toggle. Add
scroll-to-zoom (wheel up = in, down = out) for parity with the media-gallery
lightbox.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The timeline image viewer's transform applied translate inside scale(), so
dragging a zoomed image moved it by zoom x the cursor distance (panning outran
the pointer). Divide the pan offset by zoom so it tracks 1:1 - matching the
media-gallery lightbox fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Jump to Time uses timestampToEvent (MSC3030). On a homeserver that doesn't
support it, the dialog showed the raw "M_UNRECOGNIZED: Unrecognized request"
error. Show a clear explanation instead when errcode is M_UNRECOGNIZED.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review noted media without a caption has a filename body, so Copy Text showed
and copied the filename. Gate to m.text/m.emote/m.notice so it only appears for
actual text messages (matching the intended behavior).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The message menu had Copy Link (permalink) but no way to copy the message text
itself. Add a Copy Text item that copies the plain-text body with the reply
fallback stripped (trimReplyFromBody). It renders nothing when there is no
usable text body (e.g. media without a caption), so the caller can list it
unconditionally next to Copy Link. Uses Icons.Text.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Constrain the rendered location description width and break long tokens so an
oversized description from another client can't overflow the message bubble.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The m.location renderer only read the legacy top-level geo_uri, so a location
from a client that sends only the MSC3488 shape (uri under
org.matrix.msc3488.location / m.location) showed as broken. Fall back to that
uri, and display an MSC3488 description above the coordinates when present.
Closes the consume-side gap noted in review of the send-side MSC3488 change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From review: equal start/end silently disables the tint (zero-length window).
Show an inline hint so the user isn't left wondering why nothing happens.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Location sharing sent only a legacy geo_uri with a "Location: geo:..." body, so
other clients often rendered it as plain text. Include the MSC3488 blocks
(org.matrix.msc3488.location/asset/ts + m.ts) alongside geo_uri and a readable
body, so Element and others render a proper location pin. Local rendering is
unchanged (still reads geo_uri).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Night Light was all-or-nothing. Add an optional schedule with From/To time
inputs so the warm overlay only shows during set hours and toggles itself on/off
automatically (the overlay re-checks every minute; no reload). Overnight windows
that wrap midnight (e.g. 21:00 -> 07:00) are handled.
Window logic is the pure, unit-tested isWithinTimeWindow/parseHHMM in
utils/timeWindow.ts. New settings: nightLightSchedule/Start/End (default
21:00-07:00).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From review: sanitizeFilename now denylists only path-hostile + control chars
(preserving Unicode room names instead of collapsing CJK/emoji names to "room"),
converts whitespace to dashes, trims separator runs, and caps length. Also
surface an inline error when canvas.toBlob returns null so a failed PNG encode
isn't a silent no-op.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The invite QR was display-only. Add a "Download QR" button that exports it as a
PNG via an offscreen high-resolution (1024px, spec 4-module quiet zone)
QRCodeCanvas + canvas.toBlob, saved through useSaveFile (filename from the room
name) with the standard download toast. The visible code stays an SVG so it
renders crisply at any size/theme.
Also corrects the stale LOTUS_FEATURES note (the QR is generated locally via
qrcode.react, not api.qrserver.com).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shared account-data store removes optimistically with no rollback, so the
reminder row vanished the instant Cancel was clicked. Showing a "could not
cancel" error beside the already-gone row (and it reappearing on next sync) was
self-contradictory. Match the removeBookmark convention: fire-and-forget
optimistic removal, no inline error. Drops the now-moot cancelling busy-guard
and uses a collision-safe React key for same-minute custom reminders.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was no way to see or cancel a reminder once set (removeReminder was only
called by the fire-and-forget monitor), and addReminder didn't dedupe, so a
message could silently accumulate duplicate reminders. The Remind Me dialog now
lists the reminders already set on that message (soonest first) each with a
cancel button.
- New shared, tested formatFriendlyDateTime(ts, now?) in utils/datetimeInput.ts
(Today/Tomorrow/date + time).
- Per-row cancel busy-guard; inline "Could not cancel" on failure.
Also applies two nits from the custom-time review: focus the date input when the
custom picker is revealed, and clear the error when editing date/time.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Remind Me dialog only offered four fixed presets, so you couldn't set a
reminder for an arbitrary time. Add a "Custom time…" option that reveals date +
time pickers (validated >= 1 minute in the future) and sets the reminder at that
absolute timestamp.
Also extract the local date/time <input> helpers (toLocalDate, toLocalTime,
parseLocalDateTime, pickerInputStyle) into a shared, unit-tested
utils/datetimeInput.ts and reuse them in ScheduleMessageModal (deduped from an
inline copy) — identical output, now covered by tests.
Documents the previously-undocumented Message Reminders feature in
LOTUS_FEATURES.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review noted the 500-entry io.lotus.bookmarks blob approaches the ~65KB event
limit. The senderId I stored was never read back (the panel re-resolves the
live sender via the event and uses the senderName snapshot otherwise), so it
was dead payload. Keep only senderName.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Saved-message cards showed the room and time but not the author, so in a busy
room you couldn't tell who said it without jumping. Now each card shows
"{sender} - {time ago}":
- Bookmark gains optional senderId/senderName (snapshotted at save time in
Message.tsx from the already-computed sender display name); optional so
existing stored bookmarks stay valid.
- The panel re-resolves the author's current display name live from the event
when the room is joined, falling back to the stored snapshot for left rooms.
- Search now also matches the author name.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- mediaFilename: only skip appending an extension when the body already ends in
a plausible short alphanumeric extension, so "Screenshot 2024.01.05" still
gets a real extension appended for the saved file.
- Lightbox pan: divide the translate by zoom (it runs nested inside scale), so
dragging tracks the cursor 1:1 instead of moving `zoom`x too far.
- Wheel: ignore deltaY === 0 (pure horizontal scroll no longer zooms out).
- Zoom-out button disables at the real reachable minimum (0.2, not dead 0.1).
- Zoom-controls group gets role="group" so its aria-label is announced.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The timeline image viewer supports zoom/pan, but the gallery's own lightbox
rendered a plain object-fit:contain image. Add the same affordances for images
(videos keep their native controls):
- scroll wheel or header -/+ buttons to zoom; +/-/0 keys; double-click or the
% chip toggles 1x<->2x
- drag to pan when zoomed; zoom/pan reset when navigating to another item
Reuses the shared useZoom/usePan hooks (usePan already cleans up drag listeners
on unmount and resets pan when zoom returns to 1x).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The gallery's File and Audio tabs already had download buttons, but images and
videos could only be saved by jumping to the source message. Add:
- a Download button in the lightbox header (full-resolution source), and
- a hover/focus download button on each image/video grid tile
Both reuse the shared FileDownloadButton (decrypts E2EE media client-side, saves
via useSaveFile, spinner/check/retry states). The tile download control is a
sibling of the tile button (not nested — avoids interactive-in-interactive) and
stays visible on touch (hover:none) devices. Download always targets the
full-res file/url, not the thumbnail.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
handleEdit already clears cancelErrors for the old row; also clear sendErrors
so a prior failed "Send now" doesn't leave a stale inline error after editing.
Cosmetic hygiene, matching the existing cancelErrors handling.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The folds Input container is display:flex with no width, so inside the row's
grow="Yes" wrapper the wrapper grew but the input collapsed to content width.
Set width:100% on the Input so the name field uses the space the row gives it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fire a pending scheduled message immediately via MSC4140 action:'send'
(the server dispatches the stored delayed event now, as a normal timeline
event) instead of having to cancel and retype.
- sendScheduledMessageNow(mx, delayId) mirrors cancel/restart with action:'send'
- handleSendNow reuses the per-row busy guard; prunes local state only once the
server confirms; a failed send shows an inline "Could not send now" error with
the message still sendable/editable/cancellable
- Send-now IconButton (Icons.Send) added before Edit/Cancel in each row
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 2 review agents (pause/resume duration model +
meter lifecycle were verified correct):
- Mic-stream leak (HIGH, pre-existing but in this change's blast radius):
the mic tracks were only stopped inside mr.onstop, which cancel() nulls
and the unmount effect never triggered — so cancelling or unmounting
mid-recording/pause left the mic live (OS indicator on). Hold the stream
in a ref and release its tracks explicitly (stopStream) on cancel and on
unmount, independent of onstop. Normal stop still releases via onstop.
- Defensive: startMeters now cancels any existing rAF/interval before
starting, so it can never spawn a second loop.
- a11y/UX: the finish button (checkmark, advances to the preview step) is
relabeled "Finish recording"/"Finish" so the label matches the check
glyph (was "Stop recording" with the old pause icon). The three
recording-control buttons get flexShrink:0 so they don't squish the
waveform at narrow composer widths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The voice recorder was a single continuous take — an interruption meant
stopping early or starting over (and the Stop button confusingly used a
Pause icon). Add real pause/resume.
- MediaRecorder.pause()/resume() with a new 'paused' state.
- Duration now accumulates only active-recording time: an accumulate-on-
pause model (accumulatedMsRef + segmentStartRef) replaces the wall-clock
startTime, so paused time is excluded from both the live timer and the
finalized preview duration.
- Extracted startMeters/stopMeters so the waveform rAF + timer interval are
reused across start/resume; stopMeters keeps the audio graph alive for
resume while stopAll tears it down.
- Recording view now also renders the 'paused' state: a Pause/Resume toggle
(Pause vs Play icon), the record dot stops pulsing (dimmed), and the
waveform/timer freeze. Stop/Cancel/unmount all handle a paused recorder.
- Fixed the mislabeled finish button: it now shows a checkmark (it advances
to the preview step) instead of a pause icon.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 2 review agents (core edit path was verified
correct — media preservation incl. encrypted, threading, no-op guard):
- Require a filename (utils/room.ts): canEditCaption now also checks the
MSC2530 `filename` exists. Fixes media from clients that omit filename,
where the editor prefilled the filename as a caption and clearing it
wrote an empty body. Such media simply isn't caption-editable (matches
renderCaption never showing a caption for it).
- Carry m.mentions (MessageEditor): a caption edit now unions typed
@-mentions with prior mentions like the text-edit path, so mentioning
someone in a caption edit notifies them.
- Double caption: revert to the editor replacing the content while editing
(as text edits do) instead of rendering the media + its caption above an
editor prefilled with the same caption — removes the confusing duplicate.
- Removed-caption "(edited)" marker (RenderMessageContent): when a media
message is edited but has no caption (e.g. the caption was removed),
render the standalone "(edited)" affordance so Edit History stays
reachable (previously it lived only inside the caption and vanished).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Captions could be attached at upload but never changed — canEditEvent
only allowed m.text/emote/notice, so a typo in an image caption meant
delete + re-upload. Add caption editing for image/video messages.
- utils/room.ts: canEditCaption (own image/video RoomMessage, no
non-thread relation) + canEditEventOrCaption. canEditEvent unchanged.
- Message.tsx: gate the Edit affordance (quick-actions + menu) on
canEditEventOrCaption; label it "Edit caption" for media; keep the media
rendered above the editor while editing.
- MessageEditor.tsx: for a media message, seed the editor from the caption
(not the filename), allow an empty caption (removes it), and build the
m.replace so m.new_content spreads the original media content
(url/info/encrypted file/filename/msgtype) and only sets body +
format/formatted_body. Outer content is the full media (not a "* text"
fallback) so non-edit-aware clients still render the media. No-op guard
when the caption is unchanged. Placeholder "Add a caption…".
Rendering + Edit History need no changes: getEditedEvent's m.new_content
flows to renderCaption, and the word-diff already diffs body (the
caption). Encrypted media keeps its file/key (no re-upload).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 2 review agents on the recent-GIFs row:
- Motion/perf: the Recent row rendered up to 16 full animated GIFs at
once (autoplaying). Capture a small still image at pick time
(fixed_width_small_still / *_still) into RecentGif.previewUrl and render
that for the thumbnail, so recents no longer autoplay. Pre-existing
recents without a preview fall back to the animated url. Re-send still
uses the animated url, so the sent m.image is unchanged.
- a11y: the recent buttons all had the identical label "Send recent GIF".
Give them positional labels ("Send recent GIF N of M") and wrap the grid
in a role="group" labelled by the "Recent" section heading, so the row
is a distinguishable, announced group.
Correctness review found no bugs (write-before-unmount, term gating,
dedupe, re-send fidelity all verified).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The GIF picker was a bare Giphy search grid with no memory of what you've
sent, so re-sending a go-to reaction GIF meant re-typing the search every
time. Add a "Recent" row at the top of the picker (default view; hidden
while searching) for one-click re-sending.
- New persisted state state/recentGifs.ts: recentGifsAtom (localStorage,
cinny_recent_gifs_v1, getOnInit) + pure addRecentGif (dedupe-by-url
move-to-front, cap 16, ignore empty url), with 5 unit tests.
- GifPicker records every sent GIF (from search or the Recent row) to the
front, and renders a 3-up thumbnail grid of recents above the search
grid when there are recents and no active search term. Section label
matches the picker's existing `// GIF_SEARCH` treatment (lotusTerminal)
or a muted label otherwise.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 2 review agents on the room-nav draft indicator:
- Icon semantics (a11y): Icons.Message read as message activity and
collided with two existing bubble uses in the same row (call-chat
toggle, mark-unread), especially next to the unread badge. Replace it
with the composer's shared DraftDot (a small color.Success.Main dot),
so both draft surfaces share one visual language. Rendered as a
role="img" span with aria-label "Unsent draft" (reliably announced,
unlike a bare aria-labelled svg).
- Precise thread-key filter: hydration skipped any draftKey containing
'::', which would also skip an IPv6-literal server name in a roomId.
Match '::$' (thread root is an event id) so only real thread drafts are
skipped.
- Defensive hasMsgDraft: guard toPlainText so a corrupted/foreign draft
value can't throw during a nav render.
- Clear the draft atom on send: the send / scheduled-send handlers reset
the editor and localStorage but left the jotai draft atom set, so the
composer DraftIndicator could show a stale dot after sending a restored
draft. Add setMsgDraft([]) to both.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Composer drafts persist per room, but nothing in the room list showed
which OTHER rooms had an unsent draft. Add a subtle chat-bubble icon on a
room's nav item when it has a message draft (and isn't the open room), so
half-written messages elsewhere are visible at a glance.
- Shared pure helper hasMsgDraft (utils/draft.ts, unit-tested) replaces
the inline emptiness check; the composer DraftIndicator now reuses it.
- RoomNavItem reads a memoized selectAtom(draftAtom, hasMsgDraft) so a row
re-renders only when its draft flag flips (the draft atom is written on
room-leave, not per keystroke). Uses Icons.Message (pencil is reserved
for the custom-name marker), muted, aria-label "Unsent draft".
- useHydrateMsgDrafts (mounted in ClientNonUIFeatures) pre-fills the
per-room draft atoms from draft-msg-* localStorage on startup, so
indicators are correct after a page reload, not only after revisiting a
room. Thread drafts (key contains ::) are skipped; room-level only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 2 review agents on the edit-history diff:
- Perf: diffWords is O(n*m); cap at 2000 tokens/side and fall back to a
coarse whole-block replaced diff above that, so a very large multi-edit
message can't freeze the main thread. Memoize the per-row diff in
DiffText. (Added a unit test for the coarse fallback.)
- Perceivability (a11y/design): the added-word <ins> highlight was
color-fill only, which is faint against the modal surface in the lotus
themes. Add a Success.ContainerLine border + horizontal padding (so the
rounded corners read as a chip) + box-decoration-break: clone for clean
wrapping, so the "added" cue survives low fill contrast.
- Consistency: a media/no-body edit now renders "(no text)" in diff mode
too (matched the toggle-off view; was blank).
- Softened the code comment's screen-reader claim (bare <ins>/<del> aren't
announced by default).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Edit History modal listed each version's full text with no indication
of what changed. Add a word-level diff: each edit highlights the words
added (green) and removed (struck-through red) relative to the previous
version, so a one-word fix is obvious at a glance.
- New pure, dependency-free diffWords (LCS over word/whitespace tokens) in
utils/textDiff.ts, with 8 unit tests (insert/delete/replace, whitespace
preserved, empty, no-mutation, word-not-char granularity).
- EditHistoryModal renders each edit via a DiffText component using
semantic <ins>/<del> (screen-reader-meaningful) styled with folds
Success/Critical tokens. A "Highlight changes" header toggle (default
on) switches back to full text, which keeps the rich formatted render;
the Original row is always the plain baseline.
- Diff is plain-text (body) only by design; formatted markup isn't diffed
(the toggle restores the rich view), and media/no-body edits diff as
empty strings gracefully.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 3 review agents on the Threads list panel:
- Last-activity accuracy (SDK): sort key and the "last reply <time>"
label now use thread.replyToEvent.getTs() (server bundle latest_event)
instead of lastReply(), which returns the ROOT time until each thread's
replies lazily paginate (or permanently on fetch error). Applied to the
hook signature too.
- Live-refresh completeness (correctness): the useRoomThreads signature
now includes thread.length and the root event's replacingEventId, so a
mid-thread redaction (reply count) and a root-message edit (row snippet)
refresh the row live instead of going stale.
- a11y: the row's aria-label was the button's whole accessible name,
hiding the snippet/count/unread from screen readers. It now describes
the thread ("Open thread by <name>, unread, N replies, last reply ..").
- Unread badge: replaced the bare green dot (Success = the mention color)
with the app-wide UnreadBadge, using the Highlight count so mentions
render red and ordinary unread renders secondary, matching room-nav.
- Hover/focus affordance: the clickable row moved its inline styles to a
css class with token-based :hover / :active backgrounds.
- Participant pile now also includes the last replier from the bundle.
- Stabilized the panel's onClose/onOpenThread with useCallback so its
Escape listener isn't re-subscribed every Room render. Added
filter->sort pipeline + all/participating immutability tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lotus could view one thread at a time but had no overview of a room's
threads. Add a Threads list side panel, opened from a new Threads toggle
in the room header (mirrors the gallery/widgets toggles).
- Lists every thread with a rich row: root sender + snippet, unread dot,
"N replies - last reply <time>", and a participant avatar pile.
- Segmented filter (All / Unread / Participating) and sort (Recent /
Oldest by last-reply time), both persisted in localStorage
(cinny_threads_filter_v1 / cinny_threads_sort_v1) and normalized via
type guards.
- Clicking a row opens the existing single-thread ThreadPanel by reusing
setActiveThreadId; reading it clears the row's unread badge live.
- Stays live via ThreadEvent.New/NewReply/Update/Delete +
RoomEvent.UnreadNotifications, with a signature guard to avoid churn,
and is virtualized (@tanstack/react-virtual) for busy rooms.
Reuses room.getThreads()/fetchRoomThreads(), thread.hasCurrentUser-
Participated / lastReply() / length, getThreadUnreadNotificationCount
(muted threads zeroed), useMemberAvatar/StackedAvatar/UserAvatar,
scaleSystemEmoji/trimReplyFromBody, UnreadBadge, and the Bookmarks-panel
segmented-control + localStorage-atom patterns. Filter/sort logic is pure
in utils/threadList.ts with 8 unit tests. New panel is wired into
Room.tsx's mutually-exclusive content-panel switching.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 2 review agents on the forward upgrades:
- Duplicate comment on retry (correctness): if a room's comment message
sent but the forward then failed, retrying re-posted the comment. Track
rooms whose comment already delivered (commentSentRef) and skip it on
retry, sending only the missing forward. An already-commented room won't
get the comment again even if the text is later edited (no-duplicate
choice).
- a11y: give the message-preview box role="group" + aria-label
("Message to forward"), add aria-label to the comment and search inputs
(placeholder is not a label), and match the RecentChip's RoomIcon
fallback size (100) to the room-row convention for a size-200 avatar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Forward dialog forwarded blind. Add three things (design-system
cleanup included):
- Preview: a compact read-only preview at the top of the dialog shows the
sender + body, with a thumbnail for image/video (reuses ThumbnailContent
and the getMemberName/getMemberAvatarMxc/trimReplyFromBody helpers). We
already hold mEvent, so nothing is fetched.
- Comment: an optional "Add a comment" field sends a short m.text note to
each target room, sequenced BEFORE the forwarded message per room so the
note reads above the quoted content. The existing per-room failure /
retry logic is preserved (a room fails if either send rejects).
- Recent targets: a "Recent" chip row (hidden while searching) offers
one-tap selection of rooms you last forwarded to. Successful targets are
recorded most-recent-first, deduped, capped at 8, in localStorage via the
pure, unit-tested addRecentForwardTarget (state/recentForwardTargets.ts).
Rooms you've since left are filtered out of the row.
Also replaces the hardcoded rgba(0,0,0,0.35) sending scrim with a
token-free opacity dim of the list (design-system rule: no hardcoded
colors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Selecting an emoji (or any unsaved edit) in the profile Status Message
field could vanish because the presence-sync effect re-applied the remote
status_msg on every presence heartbeat (which fire every few seconds).
The dirty-edit guard alone left a window where a heartbeat carrying the
previous status overwrote the just-inserted emoji, so there was no way to
add emoji to a status.
Track the last remote status we synced and only react when the remote
value actually changes, instead of on every heartbeat. Repeated
heartbeats with an unchanged status are now ignored, so an unsaved local
edit is preserved regardless of the dirty flag's timing. Cross-device
status changes (a genuinely new remote value) and clears still sync, and
the pending-applied stale-echo guard is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two console-noise / glitch fixes surfaced while a room with link
previews was open:
- URL preview og:image thumbnails 400 when Synapse can't thumbnail a
cached preview image (SVG/animated), leaving a broken image that the
browser keeps re-requesting. GenericCard now falls back to the full
image on error, then hides the image (and shows the link icon) if that
also fails, so no broken image and no repeated failing requests.
- Extend the existing console.warn filter to drop matrix-js-sdk's
high-volume, benign timeline bookkeeping warnings ("EventTimelineSet…"
and "Decrypted event … is not in room …"), which fire constantly in
E2EE rooms with threads. Real warnings still log.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 2 review agents on the status-presets feature:
- Presence-echo race: a heartbeat can echo the previous status just after
a new one is saved, reverting the input. Track the last-applied value
and ignore non-matching echoes until our own echo lands or a 15s window
elapses (bounded so a dropped echo can't block real cross-device
updates). Applies to Save, preset apply, and Clear.
- Duplicate chips: a saved custom preset that matches a built-in is now
hidden from "Your presets" (it already shows under Quick statuses).
- a11y: the two preset rows use aria-labelledby tied to their visible
headings instead of mismatched hardcoded aria-labels.
- Visual grouping: a custom preset's chip and its delete X now sit with
gap=0 as one unit while the row separates presets with gap=200, so a
chip and its delete no longer read as two separate presets. Delete/Plus
icons bumped to size=100 to match the folds chip-icon convention.
- Parity: addPreset/removePreset promises are now caught like the other
account-data call sites.
Docs updated to the exact 11-preset built-in list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Status Message field required typing every status from scratch. Add
a one-click preset row:
- Built-in "Quick statuses" spanning gaming, social, life and work
(Gaming, In a party, Ranked grind, AFK, Watching, In a meeting,
Working remotely, Lunch, On vacation, Out sick...), each carrying a
suggested auto-clear so a click sets the message and the timer at once.
- Custom presets: save the current status as a reusable preset, stored
in io.lotus.status_presets account data (synced across devices via the
shared account-data list store), de-duped by normalized label, capped
at 20, deletable inline.
The existing save path is factored into a shared applyStatus() used by
the Save button and by preset apply, so server writes, the status
localStorage keys, and the auto-clear expiry bookkeeping stay identical.
Ordering/de-dupe logic is pure in utils/statusPresets.ts (upsertPreset,
normalizeLabel) with unit tests; no change to the presence wire format,
expiry monitor, or presence-mode selector.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 2 review agents on the bookmark sort/group feature:
- Flash on open: the persisted-sort atom now uses getOnInit so the saved
sort applies on the first render instead of briefly showing Newest and
reordering after mount.
- Stale collapse state: prune collapsed roomIds that no longer have any
bookmark, so a room re-saved later doesn't reappear pre-collapsed and
the Set can't grow unbounded across a session.
- Corrupt persisted value: validate the stored sort with a new
isBookmarkSort type guard, normalizing anything unexpected to Newest so
exactly one sort button is always active.
- a11y: room group headers now expose an explicit aria-label
("<room>, N saved messages") instead of announcing the avatar alt and
the visible name twice with a bare count, plus aria-controls linking the
header to its collapsible content region.
- Layout: move the sort control to its own toolbar row so the three
buttons don't crowd the count text in the narrow (266px) panel.
- Memoize filtered/sortedItems/groups for consistency with renderItem.
Adds unit tests for isBookmarkSort, group-order tie-break, and
groupBookmarksByRoom immutability.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Saved Messages panel showed bookmarks in one fixed order (newest
save first) with no way to reorganize. Add a Newest / Oldest / By-room
segmented sort control to the panel toolbar. In "By room" mode the list
renders collapsible per-room sections, with groups ordered by their most
recently saved message so active rooms float to the top. The chosen sort
persists across panel opens via a localStorage-backed atom.
Ordering and grouping are pure functions in utils/bookmarks.ts
(sortBookmarks, groupBookmarksByRoom) with deterministic eventId
tie-breaks, covered by bookmarks.test.ts (9 tests). No change to the
bookmark data model, account-data schema, useBookmarks, or how bookmarks
are created; search still feeds the sorter/grouper unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 3 review agents on the edit/reschedule feature:
- Race (correctness): handleEdit now marks the old message as cancelling
while its cancel is in flight, so its Edit/Cancel buttons are disabled.
Previously the old row stayed live during the fire-and-forget cancel, so
a fast second edit could orphan a still-scheduled event and send twice.
- Durability (correctness): on a failed cancel-old, re-insert the old
message if auto-prune removed its row while the modal was open, so the
still-live delayed event stays visible and retriable instead of failing
silently. Also clear any stale cancel error when starting an edit.
- a11y: per-row Edit/Cancel buttons now carry distinct aria-labels that
include the message preview and send-time, so screen-reader users can
tell which of several scheduled messages each button targets.
- UX: modal gains a submitLabel prop; the edit flow shows "Reschedule"
instead of "Schedule". Modal now focuses the message body on open.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The scheduled-messages tray was cancel-only. Add an inline edit button
that re-opens ScheduleMessageModal seeded with the existing body and
send-time, letting the user change the text and/or when it sends.
MSC4140 has no in-place edit, so an edit is schedule-new + cancel-old.
Order matters: the modal schedules the new delayed event first, then we
cancel the old one and only prune it from local state once the server
confirms. A failed cancel therefore leaves a visible, retriable copy in
the tray instead of silently letting the stale message fire or losing
the edit. Edits go through the plain-text composer, so rich content
collapses to m.text (acceptable for v1).
ScheduleMessageModal gains optional initialSendAt (seed the pickers) and
title props so it is reusable for both scheduling and editing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>