One line under the handle in the user profile popover, from the presence the
SDK already receives (currently_active + last_active_ago): 'Active now',
'Active just now', 'Last active 12 min ago / 3 hours ago / yesterday / 5 days
ago / over a month ago'. Nothing is shown without presence data or for an
offline user with no timestamp (Hide Online Status users), so nothing new is
exposed; re-renders on the existing presence events, no polling. Wording
unit-tested; verified headless ('Active now' for an online member).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Clicking a reply quote (or arriving via a thread-panel / search / permalink
jump, i.e. an eventId route change) remembers the message that was in the
middle of the viewport and shows a Primary chip next to Jump to Latest —
'Back to where you were' — which scrolls/navigates back to it. Nothing is
rendered normally; the chip expires after 20 s or once used. Verified headless:
reply quote → original 120 messages up is shown + chip; chip → back at the
reply, chip gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Verified upstream first: the Copy chip (with a 'Copied' state) already exists
on every code block. Added the missing half: a Wrap chip that switches long
lines to pre-wrap (default stays no-wrap + horizontal scroll); the choice is
remembered for the session, not persisted. Markdown/HTML output, layout widths
and the syntax theme are untouched. Verified headless: scrollWidth 1016 →
612 px with wrapping on, 'Copied' state on copy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
useSensitiveCopy: the recovery key's Copy button becomes 'Copied · clears in
60 s' and counts down; at zero the clipboard is cleared only if it still holds
the key (readText() where permitted — if the browser refuses to read, nothing
is wiped rather than risk eating something else). Any other copy made in the
app cancels the timer. No setting. Verified headless with a fake clock:
countdown ticks, clipboard emptied at 0; copying something else mid-countdown
cancels and leaves that content untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
utils/linkSafety.ts compares the registrable domain the visible text claims
(when it looks like a URL/host) with the href's; a mismatch, or a punycode
(IDN) destination, renders the anchor as SuspiciousLink, whose click opens a
confirm — "It shows matrix.lotusguild.org but goes to evil.example." with the
full URL, Cancel / Open anyway (opens in a new tab with noopener). Honest links
are untouched: same registrable domain (youtube.com text over www.youtube.com,
bbc.co.uk over news.bbc.co.uk), plain-word text, mailto:, matrix.to and Lotus
permalinks, anchors with non-text children. Comparator unit-tested (incl. a
Cyrillic-а paypal homograph); verified headless that the phish and IDN
messages are flagged, the honest ones are not, the click shows the confirm and
does not navigate, Cancel keeps you put, Open anyway opens the real target.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Host half of the talking-while-muted feature. The fork (0.25.0-lotus.9)
reports speakingWhileMuted for the local participant in io.lotus.call_state —
a level tap on the muted track, so cinny captures no audio. useMutedTalkWarning
polls that flag while muted; after 1.5 s of it being true it shows one sticky
toast, "You're muted — click to unmute", whose click unmutes; it fires at most
once per mute session (re-arms after unmute → mute), is retired the moment the
mic comes back on by any means, and never runs in Push-to-Talk mode. Setting
'Warn me when I talk while muted' (default on) sits next to AFK Auto-Mute.
Verified headless with a tone mic: no toast at +1 s, toast at +4 s, click
unmutes and clears it, second mute re-arms, unmute via the button clears it,
PTT mode → nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The e2e job now runs scripts/dev-homeserver.sh start + dev-seed.py before
Playwright (installing python3-venv if the runner lacks it) and stops it
afterwards. e2e/local-homeserver.spec.ts registers its own users and rooms
over the CS API and drives the built client — no prod secrets — covering the
fixes that were reproduced with scratch scripts this week: login/send/receive,
own-message scroll (#212), /kick toast (#216), upload 413 wording (#213),
forward provenance, thread panel at 1400px (#218), timeline lightbox (#219),
clock-skew banner via page.clock (#158), status save under the presence limit
(#226), long-press action sheet on a Pixel 7 emulation (#166). Skips itself
when no homeserver answers, so
> lotus-chat@4.12.7-lotus test:e2e
> playwright test
Running 16 tests using 1 worker
✓ 1 [chromium] › e2e/boot.spec.ts:8:3 › boot › client boots to the login screen without errors (1.5s)
✓ 2 [chromium] › e2e/boot.spec.ts:27:3 › boot › service worker script is served and registers (1.4s)
✓ 3 [chromium] › e2e/boot.spec.ts:54:3 › boot › bundled Element Call loads in a frame (3.8s)
- 4 [chromium] › e2e/e2ee-composer.spec.ts:67:3 › E2EE composer › logs in with a password and reaches the client
- 5 [chromium] › e2e/e2ee-composer.spec.ts:84:3 › E2EE composer › creates a private encrypted room and sends a text message
- 6 [chromium] › e2e/e2ee-composer.spec.ts:146:3 › E2EE composer › attaches a compressed image and it is sent encrypted
✓ 7 [chromium] › e2e/local-homeserver.spec.ts:31:3 › local homeserver regression › logs in, opens a room, sends and receives (4.5s)
✓ 8 [chromium] › e2e/local-homeserver.spec.ts:58:3 › local homeserver regression › your own message scrolls into view even after scrolling up (#212) (11.4s)
✓ 9 [chromium] › e2e/local-homeserver.spec.ts:76:3 › local homeserver regression › /kick failure is reported, not swallowed (#216) (5.4s)
✓ 10 [chromium] › e2e/local-homeserver.spec.ts:90:3 › local homeserver regression › upload failure shows a plain sentence, never the raw MatrixError (#213) (3.7s)
✓ 11 [chromium] › e2e/local-homeserver.spec.ts:122:3 › local homeserver regression › forwarded message carries its provenance header (6.7s)
✓ 12 [chromium] › e2e/local-homeserver.spec.ts:143:3 › local homeserver regression › thread panel: opens from the chip and yields the member drawer at 1400px (#218) (4.7s)
✓ 13 [chromium] › e2e/local-homeserver.spec.ts:172:3 › local homeserver regression › timeline image opens the gallery lightbox (#219) (3.9s)
✓ 14 [chromium] › e2e/local-homeserver.spec.ts:204:3 › local homeserver regression › warns when the local clock is far off the server (#158) (6.5s)
✓ 15 [chromium] › e2e/local-homeserver.spec.ts:221:3 › local homeserver regression › status save survives the presence rate limit (#226) (12.6s)
✓ 16 [chromium] › e2e/local-homeserver.spec.ts:265:3 › local homeserver regression › touch: long-press opens the message action sheet (#166) (5.0s)
3 skipped
13 passed (1.2m) still works cold. 13 pass
locally against dist + the dev homeserver in 1.8 min.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
One small Surface chip next to the header controls, only when a rule exists:
'0/10' (participants / io.lotus.voice_limit) and/or a muted-camera glyph when
the room's io.lotus.room_quality forbids camera and screenshare; the tooltip
and aria-label carry the words ('Voice limit 10 (0 in call) · Audio-only
room'). Desktop only — the mobile header is already tight. Verified headless:
appears with the rules set, disappears when they are cleared.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Reported: 'Failed to save status — server may be rate limiting. Try again.'
on the first status change in a month. Root cause: Synapse rate-limits
PUT /presence/{user}/status to ONE request per 10 s per user by default
(rc_presence.per_user: per_second 0.1, burst_count 1), shared across all of
the user's devices, and our presence heartbeat (online/away on visibility and
activity changes, from every open tab/device) spends that budget — so a manual
save that lands within 10 s of a heartbeat gets a 429, which the form showed
as a dead end.
Two fixes: (1) the status save waits out Retry-After (bounded to ~25 s) via
setPresenceWithRetry instead of failing, and the error text now says what
actually happened (rate limit / server text / offline); (2) the heartbeat
dedupes — it only sends when presence or status actually changes (/sync
already keeps us online), so it stops burning the budget in the first place.
Unit-tested; reproduced headless with a routed 10 s limiter: heartbeat ok →
save 429 → retried 8 s later → saved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
CollapsibleBody kept its collapsed flag in component state, which the
timeline's windowed rendering throws away when a row unmounts. A module-level
Set<eventId> (never persisted, cleared on reload) now seeds the state, so
scrolling away and back, or a re-render, keeps an expanded message open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Verified headless that pasting an image copied from a web page (bitmap +
text/html <img src alt> + text/plain URL) already produced only the upload
card and an empty composer; make that explicit with a preventDefault so a
future editor change can't regress it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The only join path that skips the prescreen and can start with the camera on
is answering a video call with 'Join with Camera On' enabled. The incoming
dialog and the in-call banner now say 'Answering turns your camera on', label
the primary button 'Answer with camera' and add 'Answer without camera'.
Nothing changes for voice calls or when the setting is off. The other
no-prescreen paths already can't start on camera: the sidebar join uses a
session video pref that is only ever true after the user turned it on
themselves, and it is guarded by the same setting.
Verified headless: video call + setting on → notice + both buttons, 'Answer
without camera' joins with videoEnabled=false; voice call → plain Answer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
On deafen the participant set is snapshotted; on undeafen it is diffed and,
only if it changed and you were deafened for at least 10 s, one auto-dismissing
toast says 'While you were deafened: Alice, Bob joined · Cole left' (names
capped at 3 + N more). Rides the membership stream that already drives the
join/leave sounds — no new subscriptions; PTT holds don't touch deafen so they
can't trigger it. Verified headless: short deafen → nothing; bob leaves during
an 11 s deafen → 'bob left'; long deafen with no change → nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Elbow-on-the-keyboard guard: a PTT hold longer than 5 min (fixed, not a
setting) is released exactly like a keyup — pttActive off, mic muted — with a
toast 'Push to talk released after 5 minutes — press the key again to keep
talking.' A fresh press re-engages normally. One watchdog shared by the DOM
path and the desktop-global hotkey path; keydown auto-repeat no longer
restarts anything; non-PTT mode, deafen and the mic button are untouched.
Helper unit-tested with fake timers; verified headless with Playwright's clock:
held → still open at +4 min → released + toast at +5 min → re-press works.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Picks up the fork change that shows the SFU token service's refusal reason
("Can't join this call — This voice channel is full.") instead of the generic
OPEN_ID_ERROR page. Verified headless with a routed 403 on /sfu/get.
(lotus.6/7 were tagged but failed CI lint; lotus.8 is the published one.)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Room Settings → General → Privacy: 'Your messages in this room' with a
Delete all… flow. The confirm dialog first counts your events with a
server-side sender-filtered /messages walk (live count), then asks to confirm
with the number — typing the room name above 50 — and offers 'Leave the room
afterwards'. Files are called out as not purged by a redaction.
The job runs outside React (closing settings is fine): sequential redactEvent
with 429 back-off, 404/already-redacted skipped, progress on the tile with
Cancel, pending ids persisted per room so a reload shows Resume/Discard, a
toast when done. State events are never touched; reactions, edits and thread
replies you sent are included; encrypted rooms work the same (nothing is
decrypted). Own events need no power level, so it is purely self-service.
Unit tests cover candidate filtering, the server filter, pagination, 429/404
handling and cancel. Verified headless: 62 of bob's events (60 messages, a
reaction, a thread reply) redacted in ~34 s while alice's 10 stayed; cancel at
17/40 → reload → Resume → 'Deleted 40 messages.'
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Message notifications shown through the service worker now carry a text-input
'Reply' action (Chrome desktop/Android). On notificationclick with
action==='reply' the SW sends the typed text itself — it already holds the
newest session's access token for authenticated media — as m.room.message
(threaded when the notification was for a thread), so it works with the tab in
the background or closed; a failed send shows a 'Reply not sent' notification
that opens the room. Not offered for encrypted rooms (the SW cannot encrypt).
The sender lives in swReply.ts so it is unit-tested; verified headless that
the SW notification carries actions + {roomId, threadId}.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
On coarse-pointer devices the hover action bar is unreachable and iOS Safari
has no contextmenu on long-press. useLongPress (450 ms, < 10 px movement,
cancelled by scroll/lift) now opens an ActionSheet — slides up from the
bottom, drag handle, swipe-down or backdrop tap to dismiss — with the quick
reactions row + 'Add reaction' on top and the same folds Menu the desktop
popout uses (extracted to a shared menuJSX) beneath. Android's contextmenu
echo of the same press is dropped; the press-induced text/image selection is
cleared; the finger-lift's synthetic click is swallowed so it can't hit a menu
item; the sheet ignores 'outside' clicks for its first 600 ms. Desktop is
unchanged (right-click → anchored popout).
Verified with Playwright touch emulation (Pixel 7, CDP touch events):
long-press → sheet with 👍 / Add Reaction / Reply / Forward / Bookmark /
Remind Me / Reply in Thread / Copy Link / Pin / Delete; swipe-down dismisses;
a scroll gesture does not open it; tapping Reply closes it and arms the
composer. Still wants a real iPhone + Android pass before it's called done.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Of the two plaintext-localStorage items in #204, composer drafts were already
swept on logout (#41); the presence status message + expiry were deliberately
kept. They are PII with an authoritative copy in server presence, so sweep
them too. The test's localStorage mock now enumerates keys like the real
Storage object, so the prefix sweeps (drafts, status) are actually exercised —
the old 'draft preserved' assertion only passed because Object.keys() saw
nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The ring event (m.rtc.notification) now renders — only once the ring is over
(its lifetime lapsed or the caller hung up) and only if we did not join between
the ring and the hang-up — as a red 'Missed call from bob' line with the ring's
time, in the same style as the call-membership summaries. Derived entirely from
events already in the timeline; nothing is sent; a call we answered shows
nothing extra, and a later separate call cannot retro-actively 'answer' an
earlier missed one.
Verified headless in a DM: ring ignored → line appears once bob hangs up; a
second call that alice answers adds no line and leaves the first one in place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
'Unable to decrypt message' now carries one sentence per matrix-js-sdk
DecryptionFailureCode (describeDecryptionFailure, unit-tested against every
code so no raw code can leak into the copy) and, where something fixes it,
one button: no key backup → 'Set up key backup'; backup exists but this
session can't open it / key withheld for an unverified session → 'Unlock key
backup' / 'Verify this session' (both open Settings → Devices via a new
settingsRequestAtom that SettingsTab consumes); backup working or unknown
session (rust-crypto re-requests keys itself) → 'Retry', which re-runs
decryptEventIfNeeded. Sender-side problems are plain text. The raw code sits
in the placeholder's tooltip for support.
Verified headless on a fresh session in the encrypted seed room: each event
shows 'Sent before you signed in here, and no key backup exists…' with
tooltip HISTORICAL_MESSAGE_NO_KEY_BACKUP; the button opens Settings → Devices.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Every page load contacted fonts.googleapis.com / fonts.gstatic.com (IP +
referrer to Google, GDPR exposure) for the Lotus Terminal font. VT323 (OFL)
now ships in public/fonts as latin + latin-ext woff2 subsets with @font-face
declarations in the existing custom-fonts.css; the three Google tags are gone
from index.html. Also deletes three unreferenced JetBrainsMono-*.woff2 files
that were actually 1.6 kB Google 404 HTML pages.
Verified headless: no third-party host on a plain load; document.fonts.load
('16px VT323') resolves from /fonts/VT323-latin.woff2. CSP tightening
(nginx + tauri) follows in their repos.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Clicking an image in the room or thread timeline now opens the same viewer
the media gallery uses — dark backdrop, sender + date, 1/N counter, ←/→ across
the room's media, +/-/0 and wheel/double-click zoom, download, jump to
message — positioned at the clicked event. RoomMediaLightbox feeds it from the
detached media timeline (#163); when the event isn't in the loaded window it
pages back (bounded, 6 pages) and shows the clicked image alone meanwhile, so
the viewer opens instantly. ImageContent gains onOpenViewer (RenderMessageContent
passes onOpenImageViewer); its built-in viewer remains for stickers, search
results, pins, notifications and avatars.
Verified headless: click → 'Media viewer' dialog focused, counter 1/2, '+' →
120 %, Jump to message present, Esc closes; an older image 10 pages up →
found at 10/45 after paging.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Two causes. (1) Three right-hand columns don't fit under ~1500 px: the member
drawer now yields to an open content panel (thread / gallery / widgets /
threads list) on desktops ≤ 1500 px and returns when it closes, like Element.
(2) The thread composer rendered the full ten-button toolbar inside a 360 px
panel, leaving 24 px for the input ('S…') at every width — RoomInput gains a
compactLayout prop (the phone one-row '+ | input | emoji | send' layout) and
ThreadPanel uses it.
Measured headless: 1400 px thread open → drawer hidden, composers 328/168 px
(was 422 → 'Send a mes…' + 24 px); 1600 px → drawer stays, thread composer 168 px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
All 11 seasonal overlays (particles + vanilla-extract keyframes) were in the
main JS and CSS for everyone year-round. Each is now a React.lazy chunk fetched
only when that season is active (auto date or override) or previewed in
Settings → Appearance; the schedule/override decision stays in the main bundle.
check-bundle-size: largest chunk 332.4 kB → 320.1 kB gzip; main CSS 68.4 kB →
56.5 kB; total gzip 1551.6 kB → 1561.3 kB (per-chunk overhead, only paid when
a season is on). Verified headless: no theme chunk requested at startup with
'auto' out of season; the Settings grid fetches the 11 previews; picking
Halloween renders the full-screen overlay (20 particle nodes).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
A visually hidden aria-live=polite region (rendered by CallEmbedProvider so it
outlives the embed) announces joins/leaves — batched over 1.5 s: 'alice
joined', 'alice and bob joined', '3 people joined' — your own mute/unmute,
deafen/undeafen and screenshare start/stop, and 'Call ended'. Nothing visible,
nothing audible for anyone else, no setting. Verified headless by observing
the region: bob joined → You are muted → … → bob left → Call ended.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Incident 2026-09-17: a wrong Windows clock broke calls and media keys while
the server answered 200 to everything, with no hint in the UI.
Measurement needs no extra requests and no CORS-exposed headers: every live
event carries origin_server_ts and unsigned.age (our server's now − ts at
response time), so localTimestamp − origin_server_ts is the skew. Only
RoomEvent.Timeline live events count (cache replays have stale age and are
already flagged liveEvent=false by the SDK); the initial network sync
qualifies, so a wrong clock is flagged within seconds of startup. Median of
the last 5 samples, ≥3 needed; warn at |skew| > 30 s, clear below 15 s.
UI: a banner in the sync-status slot — "Your computer's clock is 14 minutes
ahead of the server. Encrypted messages and voice calls will fail until it is
fixed." with a per-OS How-to-fix hint and Dismiss for 24 h — plus the same
line in the call status bar while in a call. Never auto-corrects anything.
Unit-tested (median, hysteresis, stale-age rejection, wording); verified
headless with Playwright's clock skewed +14 min and −3 h (banner, hint,
in-call line, dismiss) and in sync (nothing shown).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Auto-scroll on a live event only ran while the at-bottom flag was true, so a
stale flag (in-place growth like a poll gaining a row briefly pushes the anchor
out of view; or simply having scrolled up) left your own just-sent message
below the fold behind 'Jump to Latest'. An own non-relation, non-state,
non-reaction event now always brings the live end into view: advance the
range + smooth scroll when the window is at the live end, otherwise reset to
the live timeline like the Jump to Latest button. Others' messages keep the
current behaviour.
Verified headless: scrolled up 600 px → Send → message visible, no Jump pill
(before: Jump pill, message off-screen).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The upload card printed the SDK's toString — 'MatrixError: [413] nope
(http://<hs>/_matrix/media/v3/upload?filename=…)'. describeUploadError() maps
the common cases to one sentence: 413/M_TOO_LARGE → 'This file is larger than
the server allows (limit N)' using m.upload.size when known, 429 → 'Slow down —
try again in a moment.', 401/403 → 'The server refused this upload: <server
text>', 5xx/transport after the retry loop → 'Couldn't reach the server. Check
your connection and retry.', other 4xx → the server's own sentence, URL
stripped. Both card renderers use it; the raw error is still console.warn-ed
by uploadContent for debugging. Unit-tested; verified headless with routed
413/403/503 responses.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
rateLimitedActions now collects non-429 failures (and a 429 that exhausted its
retries) and returns them instead of swallowing them; existing callers ignore
the return. /kick and /ban turn the list into a CommandError whose message
names who and why, using the server's own sentence (MatrixError.data.error),
never the URL-bearing toString(); RoomInput's toast shows it verbatim.
Verified headless as a non-moderator: '/kick @alice' → "Could not kick
@alice:localhost: You cannot kick user @alice:localhost."; '/ban @nobody
@alice' → "Could not ban @nobody:localhost, @alice:localhost: You don't have
permission to ban".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Tooltip (folds TooltipProvider, 300 ms) on the room-list Live badge listing the
call members' display names — deduped per user, capped at 6 + 'and N more' —
plus an aria-label with the same text so keyboard/screen-reader users get it
too (the badge is focusable). Names come from the same MatrixRTC memberships
that produce the count; no new data.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Re-measured the revoke on the current stack (guard fix + fork lotus.4): the
SFU drops the sharer's tracks and EC's own button, the captured tracks and our
control bar all follow within ~2-4 s — the 30 s lag is gone. What remained was
that the button simply vanished with no explanation, so a call-lifetime hook
now toasts 'Screen sharing was turned off by a room admin.' (or the camera
variant) when a track ends while the room policy forbids it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Answering from the in-call banner is the only call-to-call switch path; it
started the new embed and let the atom dispose the old one, which just removed
the iframe — our m.call.member in the old room lingered ~17 s (until the
delayed leave expired), so everyone there still saw us in the call.
hangupAndWait moves out of LogoutDialog into plugins/call/hangup.ts and
handleAnswer now hangs up, waits for our membership to clear (bounded 4 s),
disposes the old embed itself (its HangupCall echo would otherwise land after
startCall and clear the NEW embed from the atom — seen in testing), then joins
the new call. Measured headless: old membership gone in 1 s, new call live
with both participants.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
CallControl.setSound()/applyScreenshareAudioMuted() no longer poke <audio>.muted
inside the EC frame, and the per-membership re-apply (useCallMemberSoundSync)
is gone: with the pin at 0.25.0-lotus.4 the fork applies deafen and the
screenshare-audio mute through its own renderer, including for late joiners.
Verified headless: deafen set before a second participant joins mutes their
track on arrival; deafen/undeafen leaves the screenshare-audio mute in place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The fork renders EC transparent so the room wallpaper shows through the
full-size embed; in PiP the element behind the frame is whatever room you
navigated to, so the timeline bled through the participant tiles. The PiP
container now gets the surface colour (the full embed keeps the wallpaper).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Picks up the fork fix where "Mute Screenshare Audio" was undone whenever the
sharer stopped and re-shared (EC's own volume controls wrote volume 1 through
the same setter). The mute now rides the renderer's `muted` prop like deafen
does. Docs: fork feature table gains the deafen/screenshare-audio row; the
soundboard + quality rows are marked verified end-to-end.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
When the Element Call frame's navigation fails (offline, blocked) it becomes a
cross-origin error page and `iframe.contentWindow.document` throws a
SecurityError. Every DOM-driven call hook (CallControl selectors, deafen DOM
fallback, useCallSpeakers/useRemoteAllMuted observers, CallEmbed theme
injection) read it unguarded, so the moment the 25 s load watchdog fired the
page logged a burst of uncaught SecurityErrors. Reproduced headless by aborting
the EC index request. One `getCallDocument()` helper now treats "can't read" as
"not loaded yet"; the watchdog overlay itself was already correct.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Verified first on the local calls stack: a participant who left and rejoined
~3 s later played "leave" then "join" every time. Cues now go through
createCallSoundDebouncer (per USER, not per device):
- a leave cue waits 5 s; if the same user is back before it fires, the leave is
cancelled and no join cue is played either;
- a join cue is suppressed for a user who left < 60 s ago;
- same-kind cues within 250 ms collapse, so a batch of leaves still sounds once;
- a second device of someone already present is not a new arrival, and a
device switch is quiet.
Only the sound is debounced; membership UI is unchanged. Sound style, volume
and PTT interplay untouched (the style is read at play time). Timers are
injected — unit-tested with a manual clock. Re-ran the flap scenario headless:
join → flap → (silence) → real leave → one "leave" 5 s later → rejoin within
60 s → silence.
Also enables msc4133 (custom profile fields → in-call avatar decorations) on
the dev Synapse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Root cause of the 'went deaf while typing' reports: the deafen key (default
M) and Cinny's type-anywhere-to-focus-the-composer both listen on window,
so the first letter of a message typed after clicking the timeline toggled
deafen and was swallowed (reproduced: typing 'mom' → deafened, composer
shows 'om'). Now: (1) Settings → Calls → Push to Deafen has an on/off
switch (deafenHotkey); (2) a letter/digit/Space deafen key only toggles
where no composer is on screen — typing wins; (3) such keys are never
bound system-wide on desktop — only F-keys, numpad and the lock/navigation
cluster qualify (isSafeGlobalToggleKey) — so 'm' typed in another app
can't deafen you. Verified in live calls: M still toggles in the call
view, is ignored on a chat screen, switch off disables it entirely.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The incoming-call dialog only went away on Ignore/Answer/Reject on THAT
device or when the notification lifetime expired, so a DM call answered
on the desktop kept the phone ringing for up to two minutes, and a caller
who gave up left everyone ringing. While a ring is showing we now watch
the room's MatrixRTC session and timeline: our own membership from any
device (answered elsewhere), our own RTCDecline for this ring (declined
elsewhere), or an empty session after it has settled (caller hung up)
all dismiss it. Verified with two alice devices + bob on the local
LiveKit stack: answer elsewhere → dismissed; decline elsewhere →
dismissed; caller End → both dialogs gone in 0.5 s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Logout stopped the client with the call still joined; the m.call.member
state (expires 4 h) stayed and everyone saw the user 'in call'. The
logout dialog now sends HangupCall and waits (≤4 s) until our own
membership is gone from the room's RTC session before stopping the
client. Verified on the local LiveKit stack: membership count 1 → 0,
logout completes in ~2 s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Deafen muted the mic (correct) but undeafen left you muted, so every
deafen cycle silently turned into a mute. Remember whether the mic was on
when deafening and turn it back on when undeafening (Discord semantics).
Verified in a real two-party LiveKit call on the local stack.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Gallery lightbox: FocusTrap had initialFocus:false, so focus stayed on the
tile behind the overlay and ←/→/Esc/+/- were dead until the user clicked
inside. Timeline ImageViewer: no keyboard zoom, no role/aria-modal/label,
focus landed on the <img>; now mirrors the lightbox (+ = / - / 0,
double-click toggles 1×↔2×, role=dialog aria-modal labelled by the file
name, focuses on open).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Inside the thread panel each reply rendered a 'Thread ↩ <root author> <root
text…>' quote because the spec's fallback reply relation (is_falling_back
+ m.in_reply_to root) was treated like a real reply — noise on every row
when the root is already pinned at the top. Genuine reply-to-a-reply
quotes are kept. The Threads list and Widgets panel had desktop-only
header buttons and no way to open them on a phone; both are now in the
mobile ⋮ menu next to Members / Media Gallery.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Forwarded events carried no origin at all; they read as the forwarder's own
words. buildForwardContent now stamps io.lotus.forwarded (sender, ts,
room_id, event_id; re-forwards keep the original stamp) and the main and
thread timelines render a reply-style header above the message that jumps
to the original when the viewer is in the source room (sender + time only
otherwise — the source room's name is not leaked). Unit-tested; verified
end to end with Playwright (header text, event content, jump, re-forward).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
vite-plugin-pwa's dev-sw.js imports workbox as a module; registering it
as a classic script failed with 'script evaluation failed', leaving the
dev client with no SW — authenticated media 401'd (broken images in every
dev screenshot) and SW notification routing was untestable. Production
sw.js is unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
All targets were sent concurrently through matrix-js-sdk's message queue;
when the send to a room you cannot post to failed with 403 the scheduler
clearQueue()'d every send still waiting, so 'Send to 3 rooms' with one
read-only room reported 'Failed to forward' for all three and left a
half-sent comment in the first. Rooms are now sent one at a time.
Verified: 'Forwarded to 2/3. Failed: Read Only Room.' and both good rooms
receive comment + forwarded message.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
RoomViewTyping's live region fell into the >3-names branch when nobody
was typing, so screen readers heard that string on every room load and
whenever typing stopped. Empty list now announces nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA