Reported from the desktop app console on joining a call: "Failed to execute
postMessage on DOMWindow: The target origin provided (http://127.0.0.1:44548)
does not match the recipient window origin (http://localhost:44548)".
useCallThemeSync calls embed.setTheme as soon as the call view mounts. With
waitForIframeLoad: false that send goes out while the iframe is still on its
initial about:blank, which has the app origin, so the browser refuses the
message addressed to the call origin and the request hangs to a timeout. The
theme was right anyway (it is in the frame URL), so this was noise plus a
pending request.
setTheme now only records the theme until the widget API is ready; on ready
it sends the latest theme if it differs from the one the frame loaded with.
Reproduced with the app on localhost and the call frame on 127.0.0.1 (as on
desktop) against the local LiveKit stack: joining logged the error before,
none after.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
New end-to-end coverage for two Lotus features the October audit listed as
untested, checked against the server at each step:
- scheduled-messages.spec: schedule (tray + one delayed event on the
server), Send now (arrives once, delayed event gone), cancel (gone from the
server, never sent).
- offline-outbox.spec: offline, three messages show "Queued. Will send when
you are back online"; back online, the server has each exactly once and in
order; the queued marks clear.
The scheduled-messages test found a real bug: Synapse rate-limits delayed
event management per user, and a quick Send now then Cancel got a 429, so
the tray showed "Could not cancel this message" although nothing was wrong.
All scheduled-message calls (schedule, list, send now, cancel, restart) now
wait out a 429 (Retry-After, bounded) and retry; scheduling reuses its txnId.
Unit test for the retry helper.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Reported: after changing your status it did not appear in the members
drawer until you switched rooms.
The SDK creates our own User object with `new User()` instead of
User.createUser, so its User.* events are never re-emitted on the client.
The shared presence store (PERF-1) listens on the client, so it never heard
our own presence changes; a remount (room switch) re-read the User and
showed it. The store now also refreshes on the ClientEvent.Event the SDK
emits for every sync presence event (after updating the User).
Also read the status from the latest presence event: the SDK only
overwrites presenceStatusMsg with a non-empty status_msg, so a cleared
status lingered (for everyone) until reload.
e2e: set a quick status, close settings, it shows in the drawer; clear it,
it goes. Fails without the fix (status never appears).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Reported by a desktop user: signing out other sessions with SSO looped.
Server logs: the SSO step completed every time, but each delete_devices
retry came without the session (a fresh 401 challenge), 4 attempts, none
succeeded. The app is the Windows desktop build (WebView2) and the SSO
pages opened in the system browser.
- SSOStage only offered "Continue" once window.open returned a window. The
desktop app opens the page in the system browser (no window back), and a
popup can lose its opener, so "authDone" never arrives and the only way
on was starting over. Now "Continue" (plus "Open the sign-in page again")
appears as soon as the page was opened; authDone still auto-continues.
- ActionUIA: when the server offers password and SSO, the first flow was
forced (often password, which an SSO-only account may not know). Offer
"Use SSO instead" / "Use your password instead".
e2e: desktop (window.open returns null) completes with the session; with
password offered first, SSO is one click away.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
The DTLN noise-suppression package was copied whole into every build
(57 MB). Its threaded LiteRT build is only used on a cross-origin-isolated
page, and neither the web nor the desktop call page is (no COOP/COEP), so
its ~9 MB could never load; source maps and .d.ts files aren't needed at
runtime either. Leave those out: 57 -> 48 MB per web deploy and desktop
installer. Verified on the built output, not isolated: the DTLN worklet
reaches ready and runs a 300-frame benchmark, no 404s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Synapse allows one presence write per 10 s per user (shared by every
device). The heartbeat wrote presence at startup and on every tab
switch, so a status save right after usually got a 429 and waited 7-25 s,
with the whole status section (quick statuses, input, Clear) locked and
the Save spinner going.
- One shared presence queue (heartbeat + status save), newest write wins:
changes made while a write waits out a 429 replace it, so only the latest
is sent, as soon as the server allows.
- The status UI no longer waits on the server: the status applies locally,
Save switches off (it compared with the server echo, so it stayed lit
and invited a second, guaranteed-429 write), the controls stay usable,
and a short note shows until the write lands.
- The heartbeat skips writes the server already reflects (e.g. "online"
at startup), so it stops spending the budget for nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
secretStorageKeys was untyped JS, passed to createClient behind `as any`,
which hid that getSecretStorageKey returned undefined where the SDK type
says null. Now TypeScript against the SDK's CryptoCallbacks, returns null,
and storePrivateKey keeps its own copy of the key. Unit test added.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
On a first visit (or right after an update) the service worker only takes
control when it activates. An image requested before then goes out without
the access token, fails, and showed "Retry". Now a failed image waits for the
worker to take control (or a moment) and retries once by itself; "Retry"
appears only if that fails too. Encrypted media is unchanged.
Also the cause of the flaky "#219 gallery lightbox" e2e test (1 in 4 locally,
10/10 after).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
A search box at the top of the settings sidebar (Ctrl/Cmd+F while settings
are open). Results replace the page list while typing, showing the page and
section; picking one (or Enter for the top result) opens that page, scrolls
to the setting and briefly outlines it. Esc clears.
The index is built from the settings source itself (SettingTile titles and
descriptions, section headers), loaded as raw text in a lazy chunk on first
search, so options added later are found without a hand-kept list.
Desktop-only options only show in the desktop app.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
#461 Both link-preview switches move into Privacy. The two overlapping
receipt switches become one choice that maps exactly onto the existing
settings (no migration, every old combination behaves the same):
Everyone / Private read receipts / Hide my activity (also hides typing
and other people's receipts, as "Hide typing and read receipts" did).
#462 Calls & audio is grouped, the same in every mode: Joining,
Microphone and noise, Push to talk and hotkeys, Sounds, Quality,
Soundboard. No setting changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
#459 General held 76 of ~110 settings (11 screens). Its sections now live
on pages named for what people come to do:
Account, Appearance, Messages (incl. editor, date and time),
Notifications, Calls & audio, Privacy & security (privacy options +
the former Devices page), Data & storage (storage, sync, your data),
Desktop (desktop app only), Emojis & stickers, Advanced (the former
Developer Tools + "show hidden events" / "legacy username colour"),
About.
The longest page is now ~3 screens (Appearance). Settings opens on
Account. Deep links keep working: "devices" lands on Privacy & security,
the old "general" on Appearance, the update notice on Desktop. Text that
pointed at the old page names is updated. The push-rule editor gets one
line explaining the Matrix terms. Remaining Title Case labels on Account
and Devices are sentence case.
#460 Chat background, seasonal theme and avatar decoration grids sit
behind one row: the current choice and a Change button (the first two
close after a choice; decorations keep their Save step).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Matches upstream Cinny 4.12.8. npm audit flagged our sanitize-html
2.17.7 with three advisories, two of them XSS bypasses (SMIL animation
namespace-prefix javascript: URIs; meta refresh javascript:/data: URLs;
noscript content rendering after an implied close). sanitize-html cleans
formatted message HTML. After the bump: 0 production advisories.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
- #456 Copy pass: sentence case for every setting title and section,
plainer names ("Hide joins and leaves", "Link previews", "Emoji
style: Twemoji", "Rejoin calls after a restart", "Also use the
browser's noise suppression", "Push-to-talk key"...) and grammar fixes
("when messages arrive", "Play a sound when a new message arrives",
blocked users "Enter their Matrix ID"). Labels only: no setting is
renamed in storage.
- #455 One theme control: Theme: System / Light / Dark. System shows the
light and dark variant pickers, Light or Dark one picker of that kind.
Replaces the System switch plus a Theme dropdown that was greyed out
whenever the switch was on. Uses the existing settings (no migration);
picking a variant also remembers it for System.
- #457 Remove the "Block Messages" stub from Notifications; the GIF
picker row only appears in builds with a GIF key.
- #458 (part 1) "Export/Import Messages Data" -> "Export/Import
encryption keys", with a sentence on when you need it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Settings -> General -> "Your data": Export writes one JSON file of the
account's Lotus data (synced settings, bookmarks, private notes, room names
and sections, reminders, status presets, thread notification settings,
timed mutes, soundboard (clips stay on the server), emoji packs, avatar
decoration, and this device's settings). Not messages or encryption keys:
they have their own exports, and keys are secret.
Import merges: new items are added; things that differ are listed per
category and kept unless the user picks "Use the file's". Only our own
account-data types are read from a file. Files from a newer Lotus Chat
are refused with a plain message; the version stamp leaves room to
migrate older ones.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Sessions of this account not seen for 90+ days (never the current one; a
session with no last-seen time is left alone) get a nudge in the existing
security strip, after the device-security ones and with the same rules
("Not now" snoozes 7 days, gone after 3 dismissals; a snoozed security
nudge no longer hides it): "You have 3 sessions you haven't used in 3
months. Signing them out keeps your account safe if an old device is
lost." [Review]
Review opens Settings -> Devices with those sessions ticked for sign-out
(one confirm, the usual re-auth incl. SSO); they are listed first and
marked "Inactive for 3+ months". OIDC accounts sign out on the
provider's dashboard, so nothing is ticked there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Landscape, at most 500px tall, touch input (a sideways phone is ~840px
wide, the Tablet size class, so the trigger is the screen shape), in this
room's joined call, chat closed: the call takes the whole screen. No room
header, sidebar or call-status bar; safe-area insets respected.
- Taps inside the call frame never reach the app, so while the controls
are hidden a transparent layer covers the call: the first tap shows the
call bar plus Chat / Exit full screen, which hide again after 4 s
without a touch; with them up the call underneath stays usable.
- In an Android browser tab the first tap also enters the browser's own
full screen (hides the address bar); left again on rotate back, hang
up or exit. No effect on iPhone or the installed app.
- Exit full screen lasts until the phone is upright again.
- The frame is transparent (room wallpaper), so in full screen it gets an
opaque base colour under the wallpaper.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
The fork now checks, before joining (lotusDeviceCheck=1), that the mic /
output the user last picked are plugged in. When one is missing it asks
us (io.lotus.device_missing): "Your usual microphone isn't connected.
Arctis 7 isn't connected. Join with Laptop Mic instead?" [Join anyway]
[Cancel]. Join anyway isn't asked again this session for the same
device(s); Cancel closes the call before it starts. A device lost during
a call is a toast ("Arctis 7 disconnected — now using Laptop Mic").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
The login page looked for an OAuth issuer in .well-known
(m.authentication), which matrix.org no longer publishes, so its users
got the password / SSO compatibility forms. Detect native OAuth the way
the SDK finds the auth server for refresh: load /auth_metadata next to
the login flows. When present:
- Login shows "Continue with <server>" only (as Element does); a legacy
SSO loginToken coming back from a redirect still completes.
- Register opens the auth server's sign-up page (prompt=create, where
prompt_values_supported says so) instead of the legacy forms.
Servers without it (Lotus) are unchanged.
Checked against the live servers: matrix.org login and register show
"Continue with matrix.org" and no password form; matrix.lotusguild.org
keeps its password form.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
The wheel ran as soon as the composer appeared; when it landed during the
room's initial jump-to-bottom it was snapped back and "Jump to Latest"
never showed. Seen ~1 in 5 runs since matrix-js-sdk 44 shifted the load
timing (failed the post-merge e2e run of #447 on lotus). Wait for the
newest message, then retry the scroll until the button appears.
10/10 locally.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
The fork synced to upstream v0.28.0 (element-call #61): matrix-js-sdk
develop with the SDK 44 MatrixRTC changes, delayed-leave delegation.
v0.28 renamed the delayed-leave config keys and its embedded build ships
no config.json (defaults: 18 s). Ship the Lotus values with the build
(build/element-call/config.json -> public/element-call/config.json) so
the desktop app keeps the 60 s grace from element-call lotus #46. The web
deploy keeps the server's own config.json (rsync excludes it); that file
already carries both the old and new keys.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Picks up three releases of MatrixRTC / delayed-event fixes and
matrix-sdk-crypto-wasm 18.3.1 -> 18.9.0.
SDK 43 moved OAuth (MSC3861) into the SDK and dropped oidc-client-ts, so
the native-OAuth sign-in is ported:
- login discovers the auth server via /auth_metadata, registers with
OAuth2.registerClient, and keeps the PKCE verifier + device in its own
sessionStorage entry keyed by `state` (single use; CSRF check);
- refresh is done by the SDK (oauthClientId + onTokenRefresh); the #16
cross-tab protection is kept by a narrow guard on refresh-token grants
(the SDK calls the global fetch for OAuth, not fetchFn), which
serialises them with a Web Lock and adopts a sibling tab's tokens;
- logout and forced logout revoke via mx.logout() (the SDK revokes at
the auth server for OAuth sessions);
- the callback page parsed its params on every render, re-running the
single-use code exchange in a loop; parsed once now.
Also: notification_type is deprecated in MSC4075 (ring in DMs, notify
elsewhere when omitted), _onRTCSessionMemberUpdate ->
ensureRecalculateSessionMembers, precache limit 6 -> 10 MB for the
7.8 MB crypto wasm.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
When the server keeps rejecting this device's /keys/upload with 400 "One
time key ... already exists", the device's encryption store has diverged
and the SDK retries forever (matrix-rust-sdk#5200, still open). Synapse
saw 474k of these from May 30 to Jul 8 2026. The fix is a fresh login, so:
- keyUploadWatch observes /keys/upload responses through the client's
fetchFn (the response the SDK gets is untouched; a clone is read).
Three conflicts within 15 min = stuck; any successful upload clears it.
- KeyUploadBanner (critical strip, same slot as the sync/clock banners):
"Encryption on this device is out of sync" with Details and
"Fix: sign out…", which opens the normal logout dialog (it already
warns when the device isn't verified / backed up).
Server side, Grafana now alerts on the same signal (E2EE Key Upload
Conflicts) and on delayed-event 5xx (Call Delayed-Event Errors).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
On the desktop app with a working keychain (Windows Credential Manager),
the tokens leave localStorage: it keeps a token-less shell blob
(tokensIn: keychain) and the full session lives in memory, read from the
keychain at boot before anything reads the session, so getFallbackSession
stays synchronous. Web, Linux and older desktop builds are unchanged.
- A new session (login, token refresh) is written to localStorage in full
first; the mirror verifies the keychain copy and only then strips the
tokens, so one durable store always holds the newest tokens.
- Existing installs migrate on first boot the same way; a failing keychain
write keeps the tokens in localStorage.
- If a shell is there but the keychain entry is missing or unreadable, the
app shows Retry / Sign in again instead of logging out, and the mirror
is not started (it would clear the entry).
Fixes#105
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
vimeo.com/event/{id} (and /event/{id}/{hash} for unlisted events) now get
the same click-to-play card as Vimeo videos, embedding Vimeo's live-event
player (vimeo.com/event/{id}/embed[/{hash}], dnt=1). The issue assumed
this needed vimeo.com in the CSP, but frame-src is already https: on web
and desktop.
Refs #200
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
MSC2931: a widget with m.always_on_screen (auto-granted) can send
set_always_on_screen; the host now answers it, and a widget that asked
keeps running in a small floating window (title, Back to room, close)
when the Widgets panel closes or you switch rooms. Back to room returns
to it with the panel open on that widget, without reloading it.
Moving an iframe reloads it, so widget frames now live in an app-level
layer from the start (widgetSessions.ts) and are positioned over the
panel's placeholder while docked. A widget that did not ask is torn
down as before. One floating widget at a time; it also goes when the
widget withdraws, is removed from the room, or on logout. A permission
request while floating grants nothing extra.
Refs #205
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5