- The paper lanterns become a garland of small lanterns (gold caps, ribs and
tassels) on a scalloped gold cord, all within the top ~20px. The old
lanterns hung 7-20% down, opaque, over Create Room, the members search box
and messages. One repeating SVG tile; two sets sway and glow alternately.
- The pink lacquer wash is about halved and the dark bottom rim is gone, so
grey secondary text keeps its contrast on light themes.
- The dragon silhouette is removed: screen-blended and blurred, it was not
visible on light and barely on dark (open question on the issue).
- Reduced motion: the garland is static; the settled blossoms are unchanged.
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
The call-ended toast gets a Details button (and stays 10 s) that opens a
card with who was in the call and the peak, connection verdict and
reconnects, how long your / each person's / everyone's connection was
poor (likely the server when everyone), RTT, packet loss and jitter, and
your talk, mute, deafen and screenshare time, in technical wording.
Deafened time is tracked by CallEmbed; the rest comes from the fork's new
io.lotus.call_summary `details` (lotus.29). Settings → Calls gets
"Summary After Calls" (on by default).
Fixes#253
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Each widget in the Widgets panel (and the open widget's header) gets an
"Open in a new window" button, so a pad or board can sit beside the chat.
It opens the same filled-in URL the frame uses (widgetCompleteUrl, now
shared) with noopener,noreferrer, standalone as in Element. Only
cross-origin https URLs get the button: room state can carry anything,
e.g. a script URL from another client.
Refs #205
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Long-press opens the message action sheet (#166), but on iOS the system
takes a long-press for itself: the link/image callout or the text-selection
loupe races the 450 ms press. Message rows on touch screens now carry
data-touch-actions (no -webkit-touch-callout), and the pressed row carries
data-pressing from touch-down until the finger lifts, which turns text
selection off for that moment only. Copy Text stays in the sheet.
Refs #199
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5