One "security" strip, in the same top slot and style as the status banner
(#124), for this device, in priority order:
- "Verify this device" — cross-signing exists but this device isn't
verified (can't unlock backed-up history, untrusted to others). First,
because verifying with the recovery key also connects the backup.
- "Connect this device to your key backup" — a backup exists, this device
isn't using it.
- "Set up key backup" — no backup at all (includes accounts without
cross-signing; the setup flow does both).
The button opens Settings → Devices (existing flows); "Not now" snoozes.
Rules: nothing in a device's first 24 h; "Not now" snoozes that nudge 7
days; 3 dismissals stop it; never on a healthy device; waits until sync
has settled (never under "Connecting…"); outage/maintenance/connection
strips win. Per-device record in localStorage, wiped on logout. Mounted
inside the Matrix client context and an error boundary (an early version
outside the context crashed the app on load — caught before pushing).
Also: the status strip wraps its text on phones instead of truncating it
when there's no Details button (benefits #124's strips too).
Design approved on #123 (real-client screenshots there). Tests: 4 unit
(the #123 state table, loading, timing, stored record); e2e: nothing during
the grace period, "Set up key backup" → Settings → Devices, "Not now"
holds across a reload. Unit 1319, Playwright 29 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Reported on CachyOS: "Check for Updates → The update downloaded but
couldn't be installed … Permission denied (os error 13) at path
/usr/bin/tauri_current_app…". The app was installed from the Arch package;
Tauri's Linux updater can only replace an AppImage.
With cinny-desktop's new update_install_kind command:
- pacman / deb installs: the toast says the update is available and opens
Settings → General → App Updates, which shows the package-manager command
(`sudo pacman -U …pkg.tar.zst`, or the .deb + `sudo apt install`) with
Copy command and Download package — no Install & Restart that can't work.
"Copied" only when the clipboard write actually succeeded.
- other distros: a link to the downloads page.
- Windows, AppImage, and desktop builds without the command: unchanged
in-app update.
- a native "package-managed" refusal shows the same help.
Tests: unit (kinds, commands, refusal detection); in the real client with a
simulated desktop bridge: pacman → toast + Settings command/buttons,
install never attempted; older desktop → in-app flow as before. Unit 1321,
Playwright 26 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
When the homeserver, voice calls or sign-in break, or maintenance is under
way, say so in the app — driven by the Kuma status page
(isitup.lotusguild.org/status/matrix), managed from Kuma's UI. The client
asks Kuma directly (not via our servers) so it still hears "the server is
down" when our servers can't tell it.
- config.json `statusPages`, keyed by homeserver: users of other servers
never contact Kuma.
- utils/kumaStatus.ts (pure, unit-tested): parse Kuma 2.x's public JSON;
a group is down when any monitor fails two checks in a row (down+down or
pending+down); maintenance = windows under way; announcements = incidents.
One strip at a time: server down (connection lost AND Kuma confirms) >
server having problems > maintenance > calls down > announcement;
sign-in problems on the login screen only.
- Wording about the user's own connection: "Connection lost … our status
checks say the server is up, so it may be your internet connection" ONLY
when Kuma checked the server after this client's connection dropped and
it passed; a stale "up" (Kuma needs a minute or two to notice an outage)
keeps the plain "Connection Lost!".
- useServerStatus: polls only while visible; 5 min, 60 s while something is
wrong or the connection is lost, at once when it drops; backoff; any
failure = no banner (Kuma being unreachable never looks like Matrix
being down); GET only, no cookies.
- UI in the existing banner slot and style (ContainerColor/Line like the
sync and clock banners); Details expands; dismiss for calls-down and
announcements (an edited announcement comes back); calls-down note above
Join; phone: one line + Details.
Needs the CSP connect-src to allow https://isitup.lotusguild.org (matrix
repo) before it can fetch in production; until then it fails quiet.
Tests: 15 unit tests (live page layout, two-check rule, any-monitor rule,
unknown/garbage, UTC beat times, stale-vs-fresh "up", priorities, login vs
client, maintenance, announcements + dismiss/edit); e2e (fixtures for Kuma):
calls-down strip + dismiss across reload, other homeservers make no
requests, Kuma 500 → nothing, lost connection + Kuma down → critical strip
instead of "Connection Lost", + fresh "up" → "may be your connection",
+ stale "up" → plain "Connection Lost". Unit 1315, Playwright 26 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The desktop app loads the bundled Element Call page from its own origin
(http://localhost:<port>), so the call frame can read the app's storage
(login token) and DOM — the hole #43 closed on the web by moving the page
to call.chat.lotusguild.org.
The desktop's local server can also answer on http://127.0.0.1:<port>: the
same server and bundle, but a different origin (and still a secure
context). resolveDesktopCallPageUrl loads the bundled page from there when
the desktop config sets `desktopCallOrigin`:
- only a loopback http origin on the SAME port as the app, no path, query
or credentials;
- only when the app itself runs on http://localhost (release builds; debug
builds on tauri:// keep the same-origin page);
- unset (every desktop build until cinny-desktop opts in, together with the
server bind, CSP and permission changes it needs): unchanged.
The web app is unchanged (elementCallUrl as before).
Tested in a simulated desktop app (Tauri bridge stub + the desktop
config.json, served on localhost and 127.0.0.1) against a local Synapse +
LiveKit, two users: call page from http://127.0.0.1:<port>, parentUrl =
the app origin; the frame gets SecurityError on parent.localStorage and
parent.document (same-origin control: readable); join, speaking indicator,
mic off/on, screenshare start/stop, layout switch and hang-up all work, no
page errors — 12/12 in 5 of 6 runs, like the same-origin control (3 of 4;
the misses on both sides were the local LiveKit connection).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Incident 2026-09-29: the homeserver's host ran out of memory and stalled for
~2 minutes. The /sync that finally went out carried events whose `age` was
computed ~30 s before it arrived, so every client showed "Your computer's
clock is 30 seconds ahead of the server" while the real problem was the
server (all host clocks were within 0.25 s the whole evening).
The skew estimate was the median of the last 5 samples, and a sample is
local skew + delivery delay, so one late /sync with a handful of events
tripped it.
- Estimate = the LOWEST sample of the last 5 minutes: delay only ever adds,
so the fastest-delivered event is the truest.
- "Behind" (which a delay can't cause) is reported as soon as there are 3
samples, like before. "Ahead" must hold across samples received at least
a minute apart, so a single late burst never trips it.
- Samples are aged on the monotonic clock, and a change of the local clock
(someone fixing it) resets the measurement, so the warning clears at once.
- Only events stamped by our own homeserver are sampled: a federated event's
origin_server_ts is the other server's clock.
- Wording: "This device's clock is … Voice calls and encrypted messages can
fail until it's corrected." / call bar "Device clock … : calls may fail"
(was "will fail").
Unit tests: the incident (late burst after normal traffic, and a fresh
client whose first samples are all late), mixed slow/fast deliveries,
ahead only after a minute, behind at once, hysteresis, clock fixed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Until now a send that failed (offline, homeserver down, a blip) went
straight to "Failed to send": nothing retried it, and a reload dropped it
without trace (chronological pending ordering keeps local echoes in memory
only).
- Outbox (utils/outbox.ts + features/outbox/OutboxFeature): own message
sends (text, stickers, reactions, polls; not call signalling or
redactions) are mirrored to localStorage from their first local echo until
the server confirms them or the user cancels.
- After a reload they come back as local echoes via room.addPendingEvent,
same shape as the SDK's own. Recent ones (< 1 h) are sent again with the
same txnId; older ones come back as "Failed to send" for the user to
retry or cancel. Ones the server already has (transaction id seen in
/sync) are dropped, so no duplicates.
- Retries: network failures (ConnectionError, 408/429/5xx) are re-sent when
the connection returns (sync recovers or the browser goes back online),
and after a blip while online (5 s, backing off, max 10 per message).
Oldest first, in order per room. 4xx / consent / encryption failures are
left to the user.
- UI: a network failure while offline shows a clock, "Queued. Will send
when you're back online" (thread view too), not the red ✕. The ✕ is now
a button: click to retry.
- Logout wipes the outbox with the other plaintext caches (the content is
decrypted, like drafts).
Tested end to end against a local Synapse (Chromium): offline → queued →
sent once on reconnect; homeserver unreachable → queued → sent once; failed
send → reload → sent once and shown once; server accepted but response lost
→ reload → no duplicate; 2 h old entry → failed, not sent, click ✕ → sent;
cancel → gone after reload; encrypted room → restored message goes out as
m.room.encrypted with no plaintext and decrypts; one-off failure retried by
itself in ~5 s; no page errors. Unit tests for the pure parts; Playwright
20 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Lotus permalinks (#130) were built from window.location.origin. In the
desktop app that's the local tauri-plugin-localhost server (hash-routed), so
"Copy Lotus Link" copied e.g. http://localhost:…/#/home/!room…, which works
for nobody else. And the link recogniser only knew that local base, so a real
https://chat.lotusguild.org/home/… link in a message opened the browser
instead of the room.
- useLotusShareBase: in the desktop app, links for other people use config
`webAppUrl` (https only, set by cinny-desktop #23) in web path-routing
form; otherwise the origin, as before. Used by "Copy Lotus Link" on
messages, the space menu and space tabs.
- The recogniser accepts several bases: the origin, plus `webAppUrl` in the
desktop app.
- The web app is unchanged.
Verified with a simulated desktop (Tauri bridge + webAppUrl) against a local
Synapse. Copied links are https://chat.lotusguild.org/home/<room>/<event> and
https://chat.lotusguild.org/<space>. A public link in a message renders as
the room pill and clicking it opens the room in-app, with nothing sent to the
system browser. The web app still copies origin links. Unit tests for the
base selection; Playwright 20 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Groundwork for serving the call page from its own origin
(call.chat.lotusguild.org). Inert until config.json sets `elementCallUrl`:
without it the bundled same-origin page is used exactly as today.
- callPageUrl: resolves `elementCallUrl` — absolute https only (http only on
localhost for development); anything else, and the desktop app, fall back
to the bundled page so a bad value can't break calls. Set once from the
loaded client config.
- CallEmbed builds the widget URL from it; the widget origin (used by the
message guard and Capability Delegation) follows automatically.
- Soundboard: a host blob: URL can't be fetched from another origin, so
io.lotus.inject_audio now also carries the clip's bytes (`audio`). Forks
that predate it ignore the field and use `url`, so this is safe on the
released fork.
Needs element-call's lotus-call-origin branch (host-origin message check +
inject_audio bytes) released and pinned before `elementCallUrl` is set.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Windows' emoji font has no flag glyphs, so 🇺🇸 (:flag_us:) showed as the
letters "US". Two fixes:
- A TwemojiFlags @font-face over the bundled Twemoji file, limited by
unicode-range to the regional-indicator letters (U+1F1E6–1F1FF), is put in
the font stack on Windows only. Every other emoji stays native, other
platforms keep their own flags, and the file is fetched only when a flag is
on screen.
- The Appearance font setting overwrote --font-secondary with a stack that
had no emoji families at all, so neither this nor the existing "Twitter
emoji" switch reached message text. The font map now keeps
var(--font-flags), var(--font-emoji) before the generic family.
Verified in Chromium via CDP platform fonts: with a Windows user agent 🇺🇸 is
drawn by "Twemoji Mozilla" while 😀 stays on the system emoji font; with a
Linux user agent both stay native.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
A homeserver with a consent requirement (Synapse user_consent) rejects sends
with 403 M_CONSENT_NOT_GIVEN until the user accepts its current terms. The
message just showed "Failed to send" with no reason.
Listen for the SDK's HttpApiEvent.NoConsent and show a dialog naming the
user's own homeserver (the client works with any server, so no Lotus-specific
wording), with "Review and accept" opening the server's consent_uri (http(s)
only; anything else is dropped) and "I've accepted — retry sending" resending
every event that failed for this reason. "Later" snoozes it for 10 s so
background retries don't re-open it immediately.
Verified in Chromium against a local Synapse with the send endpoint answering
M_CONSENT_NOT_GIVEN: dialog shows, link opens, retry delivers the message.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
16 menus (room header, Home/Direct/Space sidebar tabs, create tab, lobby,
room-nav item, history visibility, client root) set
returnFocusOnDeactivate: false, so pressing Escape dropped focus to
<body> and a keyboard user had to start over from the top of the page.
That option was there so focus wouldn't be pulled back to the menu button
when an item opens a dialog; since ce8ed89f dialogs move focus into
themselves (their initial focus runs after the menu's return), so it's no
longer needed. The autocomplete menu (focus must stay in the editor) and
the two menus whose opener disappears are left as they were.
Checked: room header menu → Escape → focus on "More options" (was
<body>); menu → Leave Room → focus inside the Leave Room dialog on
Cancel, same as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Most modals rendered a folds Dialog/Modal with no role and no name, and
their focus traps used `initialFocus: false`, so focus stayed behind the
modal and a screen reader never announced it.
- 32 dialogs with a visible heading: role="dialog", aria-modal,
aria-labelledby → the heading (given an id), tabIndex=-1.
- 4 dialogs that already had a name (Leave Room, room topic viewer,
server ACL, room-nav prompt): role + aria-modal.
- Their focus traps drop `initialFocus: false` for focus-trap's default
(keep an already-focused autoFocus field, else the first tabbable
element) with the dialog itself as fallbackFocus, so a dialog without
a tabbable node can't crash the trap. Traps that live in a parent
(UIA stages, Logout, Forward, Invite) get the semantics only.
- The file drop overlay is deliberately left alone (not a dialog).
Checked at runtime: Join with Address, Delete Message, Report Message,
Leave Room and Logout open as named dialogs with focus inside and close
with Escape (Tab first when a text field has focus — the shared
stopPropagation keeps Escape from discarding typed text, by design).
The axe e2e spec (6 tests) passes; eslint warnings unchanged (46).
17 modals with no heading (image/file viewers, loading screens) remain.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The first time the window is closed, a dialog asks "Keep Lotus Chat
running?" — Keep running in the tray (default, focused) / Quit when I
close the window — with an opt-in "Start Lotus Chat when I sign in"
checkbox in the same moment (per the approved design: one dialog, no
wizard). The choice is saved natively; Settings → General → "When I
close the window" changes it later (tray / quit / ask me).
The dialog is role="dialog" aria-modal, labelled and described, with
focus on the default button. Web-side flow verified with a stubbed
native side: event → dialog → checkbox + Quit sends autostart enable +
resolve_close_request("quit"); the Settings select saves "tray".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- Skip link: following #main-content left focus on <body> (the <main>
target wasn't focusable), so nothing was announced and the URL got a
stray fragment. <main> is now tabIndex=-1 and the link focuses it.
- Keyboard-shortcuts dialog (?): no role, and focus stayed in the
timeline, so it opened silently. Now role="dialog" aria-modal, and focus
moves into it (Escape still returns focus to where you were).
- Reaction viewer (both the reaction context-menu path and "View
Reactions"): same — role="dialog" aria-modal aria-label="Reactions",
focus moves in.
Keyboard-only checks (Playwright): Tab → skip link → Enter focuses
<main>; Tab reaches the room list, Enter opens a room, typing lands in the
composer, Enter sends (verified on the server); focus ring visible. "?"
opens the dialog with focus inside, Escape returns to the same element,
"?" in the composer stays text. Topic viewer, Search dialog and reaction
viewer all return focus to their opener.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Status messages are saved per device and re-sent with every presence
heartbeat (a presence write without status_msg clears it on Synapse). A
device never receives its own user's presence changes made on other
devices, so the DP3 fix in db864326 — mirroring remote changes from the
Profile page — could never fire: device B kept a status that device A had
cleared and re-published it on its next state change.
Heartbeats now reconcile with the server first: GET our own presence,
send the server's current status_msg and bring the local copy in line.
Falls back to the local copy when the read fails, when the server shows
us offline (invisible mode clears the status by design), and for 15 s
after this device saved/cleared its own status (a server read that
hasn't caught up yet can't override a fresh save).
Verified with two sessions of the same user against local Synapse:
B sets "dp3 old status" → A clears it → B goes hidden→visible → server
stays "" and B's local copy is removed (before: back to "dp3 old status").
A sets "dp3 new from A" → B heartbeat keeps it and adopts it locally.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Removed every `as any` in src/, then restored only the ones tsc still
needs: 163 → 36. 112 were in lotus-terminal.css.ts (`'… !important' as
any` etc. — vanilla-extract's style types accept these strings as-is),
so its file-wide eslint-disable goes too. The casts were type-only, so
emitted code and generated CSS are unchanged.
eslint warnings 49 → 46; the ratchet is tightened to match.
tsc clean; 1202 unit tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
While an update is available the web client asks the native side to show
"Restart to update (vX)" in the tray menu and an "update ready" tooltip;
it's cleared once a check reports we're current, and kept while
installing or after a failed attempt. Clicking the tray item runs the
same install flow as the toast, so progress and failures show in-app.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
A friend's update failed ten times in a row ("Update check failed: error
sending request for url (…nsis.zip)") before the 11th went through, and
he didn't know what to do. The label was also wrong: the check had
worked; the download failed.
- Progress: "Downloading update… 28% (14.3 MB of 49.9 MB)", "The update
server didn't respond. Trying again in 3 s (attempt 2 of 4)…", from the
native `lotus-update-progress` events (cinny-desktop retries itself).
- Failures name the step (check / download / install, from the native
error prefix) in plain language, with Try again and a Download
installer button (Windows: the setup .exe; else the release page), and
the raw error under "Details".
- Installing from the update toast now shows a "Downloading update"
toast, and on failure a sticky "Update didn't install" toast that
retries on click and points at Settings → General → App Updates.
Before, the toast vanished and the failure was only visible in Settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Notification sounds called `audio.play()` without handling the promise,
so every message that arrived before the user interacted with the page
(e.g. right after launch) logged an uncaught NotAllowedError — 15 in a
short test run. Same pattern in the video thumbnail loader, the voice
preview (which now also resets its Play button) and useMediaPlay.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
public/res/ carried two DIFFERENT files whose names differ only in case —
Lotus.png (19 897 B, the 256px logo used by the welcome page, auth layout,
OIDC config and About) and lotus.png (2 073 B, the notification icon).
Windows and macOS filesystems are case-insensitive, so a checkout there
collapses them into one path: one of the two references then resolves to
the wrong bytes or to nothing, which is why the desktop client's home
logo rendered as alt text from
http://localhost:44548/public/res/Lotus.png.
Renamed the logo to lotus-logo.png and updated its four references;
verified no case-only filename collisions remain anywhere in the repo,
and that both logos still load (256x256 on the welcome page and About).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The quick-status auto-clear removed its localStorage record BEFORE the
presence write and swallowed any error, so a single failure dropped the
timer permanently and the status stayed set forever. Synapse rate-limits
presence to ~1 write / 10 s per user (#226) and the heartbeat spends that
budget, so 429s here are routine — especially right after startup, when
the monitor's first check runs.
The clear now goes through setPresenceWithRetry (honours retry_after_ms)
and only forgets the status once the server has taken it; a re-entry guard
stops overlapping attempts and the poll is 15 s so a retry lands promptly.
Reproduced and verified with three injected 429s: before, the status
stayed on the server forever with the local record gone; now it clears.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Room context menu → "Add to Section" submenu: every u.<name> tag in use
across your rooms as a checkable item, plus a "New section…" field
(validated: non-empty, ≤ 40 chars, no dots, unique). Toggling writes or
deletes the standard u.<name> room tag (order 0.5), so sections sync
across devices and other clients see the same tags. The menu row reads
"Sections: Raids, Off-topic" once a room is in any.
Home renders each section as a collapsible category between Favorites
and Rooms (alphabetical; members by tag order then name; the same
closed-state store and unread-only-when-collapsed behaviour as the built-in
categories). A sectioned room leaves the plain Rooms list but keeps a
Favorite / Low Priority placement. Empty sections don't exist by
construction; rename is retag (v2). Derivation in utils/roomSections.ts
with unit tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Metadata was only dropped as a side effect of opt-in compression, so a
phone photo carried its GPS fix, camera model and timestamp into the room
and the media store. utils/stripImageMetadata.ts now removes it at the
container level, without touching pixels: JPEG drops APP1/APP13/COM
(writing back a minimal EXIF holding only Orientation when it isn't 1,
so sideways-stored photos still display upright), PNG drops eXIf and the
text chunks XMP lives in, WebP drops EXIF/XMP and clears the VP8X flags.
Other types pass through.
Applied before encryption on every composer path (attach, paste, drop,
share target) and to user/room avatar picks; GIF upload is excluded.
Setting → General → Privacy "Remove Photo Metadata Before Sending",
default on. The upload card says "Photo metadata removed".
Unit tests on generated fixtures with a GPS IFD (JPEG orientation 6,
JPEG + comment, PNG with eXIf + XMP, WebP with EXIF); verified end to
end: the bytes stored by Synapse decode fine and carry only Orientation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Event handlers typed as React.MouseEvent, join errors as Error, the
service-worker session setter as strings, the UIA policy map and the
webkitAudioContext probe as narrow shapes. No behaviour change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
public/manifest.json declares share_target (POST multipart to
/share-target: title/text/url + image/video/audio/pdf/text files). The
service worker answers that POST itself: it stashes the form in a Cache
API bucket and 303s to the in-app /share page, which lists what arrived,
offers a room search, and on pick writes the files into that room's
upload-board atom (encrypting first for E2EE rooms via the composer's
shared filesToUploadItems) and the title/text/url into its draft, then
opens the room — the user still presses Send. The stash is cleared once
placed; reopening /share afterwards says so.
nginx/caddy examples and the prod image config gain a 303 for
/share-target so a POST that reaches the origin before the worker
controls the page lands on /share instead of a 405. iOS has no share
target support and ignores the manifest entry.
Verified headless against the built preview: SW-controlled page → POST
/share-target (two PNGs + title + text) → /share lists both files and the
text → pick the DM → composer shows both files on the upload board and
the text in the draft → /share reports nothing pending.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
A small red dot on a space tab when any room in that space (recursively)
has an active MatrixRTC session, so a live call is visible even when you
are looking at a different space or your DMs. Hidden while the space is
selected — the room list already shows the Live badge there.
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
'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
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
axe-core flagged 18 'button-name' criticals on the room view: the space
tabs, Home/Direct/Inbox/Search/Saved/Explore/Add Space/User Settings/
Unverified sidebar buttons, and each message's avatar button had no text
for screen readers. Labels mirror the existing tooltips; message avatars
read '<name>, open profile'. Room view now has zero button-name findings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
matrix.to cannot target this deployment (Cinny adapter hard-codes
app.cinny.in; web-instance[] is Element-only, allowlisted), so add a
"Copy Lotus Link" next to every Copy Link (message menu, space header
menu, sidebar space tab) producing https://<this origin>/home/<room>/<event>
?viaServers=… via plugins/lotus-permalink.ts. Lotus links in messages are
rewritten to their matrix.to form inside the HTML parser so they render as
room/event mentions and navigate in place.
/home/<room> for a joined room that belongs to a space or Direct now
redirects to its own route (was a preview card with a View button; also
the form matrix.to → Cinny links use). ?via= is accepted as an alias of
?viaServers= (what the matrix.to Cinny adapter emits). A deep link
visited while logged out is now honoured after an OIDC login: the OIDC
callback reloads at the app root, which discarded the stored path — the
index loader consumes it via the shared takeAfterLoginPath().
Verified end-to-end with Playwright on a local Synapse: logged-out cold
link → login → lands on the event under the space route; menu copies the
expected link; a pasted Lotus link renders as a mention and jumps in
place; both space menus copy the space link.
Closes#130
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Same number as the tab title (leaf-room highlights), cleared at zero.
Skipped under Tauri where the native set_badge_count owns the badge, and
silently absent where the Badging API is not available.
Closes#154
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The client never handled beforeinstallprompt and showed no install hint, so
phone users only got the PWA if they knew to dig through Share → Add to
Home Screen (iOS never prompts; Chromium's mini-infobar is easy to miss).
- utils/pwaInstall.ts (pure, 4 tests): show from the second visit, never in
Tauri or an installed PWA (display-mode standalone / navigator.standalone),
30-day snooze after a dismissal; kind = real prompt when the browser
handed us a deferred beforeinstallprompt, Share-sheet instructions on iOS
Safari, nothing elsewhere (Firefox desktop has no install path).
- hooks/usePwaInstallPrompt.ts: captures beforeinstallprompt/appinstalled,
counts one visit per browser session, waits 6s for the prompt event before
deciding, then enqueues a sticky toast; tap → prompt(), X → snooze.
- ToastNotif gains onDismiss (fired by the X button only) so the snooze is
recorded however the toast is closed. Mounted from ClientNonUIFeatures for
signed-in users only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Every Lotus setting was localStorage-only, so a user on web + desktop + phone
configured theme, composer toolbar, quiet hours, call keys… three times.
- utils/settingsSync.ts (pure, 7 tests): DEVICE_LOCAL_KEYS denylist (zoom,
media auto-load, animation pause, glassmorphism, denoise tier/model,
bitrates, volumes, notification permission, developer tools, PTT mode,
camera-on-join, drawer state, and the sync toggle itself), pickSyncable,
mergeRemoteSettings (unknown keys, device-local keys and wrong-shaped
values are skipped), buildSyncedContent, shouldApplyRemote (LWW on
updatedAt; equal stamp = our own echo).
- hooks/useSettingsSync.ts: on start applies a newer remote snapshot or
pushes local if it differs; debounced push on any settingsAtom write,
skipped when the syncable subset equals the last pushed/applied snapshot
so a remote apply never echoes back; AccountData listener for live
updates; stamps forced monotonic per device; per-account lastSyncedAt
marker so another user on the same device can't inherit it; failed pushes
roll the marker back so the next change retries. Remote values are re-read
through getSettings() so enum coercion applies.
- Settings → General → Sync: toggle (device-local), "Push now", "Clear
synced copy". AccountDataEvent.LotusSettings registered.
- ClientNonUIFeatures: the #103 tracking-param subscriber moves out of
PageZoomFeature into its own TrackingParamsFeature next to
SettingsSyncFeature.
- Docs: LOTUS_FEATURES entries for #103/#104; LOTUS_TODO links the new
Features 2026-Q4 milestone and #108.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Shared links routinely carry ad/analytics identifiers (utm_*, fbclid, gclid,
YouTube si=, Amazon ref=/tag=, X s=/t=, TikTok _r/_t, …) that tie every
recipient's click back to the person who shared the link. New
src/app/utils/urlTracking.ts is a pure, local stripper: a global list +
utm_/pk_/matomo_ prefixes, plus host-scoped rules so e.g. `si` is only
removed on youtube/spotify. matrix.to and non-http(s) schemes are never
rewritten; unparseable input is returned unchanged; Amazon's `th`/`psc`
variant selectors are deliberately kept. 13 unit tests.
Wired at three points, all behind a new Settings → Privacy toggle
(`stripTrackingParams`, default on):
- paste: plain-text pastes are cleaned and re-inserted through Slate's own
insertData so multi-line pastes still split into paragraphs;
- send: RoomInput submit + schedule paths and MessageEditor saves clean both
`body` and `formatted_body` (the HTML variant unescapes `&` around each
URL and re-escapes it so the markup is untouched);
- render: linkify `formatHref`/`format` and explicit `<a href>` in
formatted_body are cleaned, so links sent from other clients are safe to
click too. LINKIFY_OPTS is spread into memoised per-timeline objects, so
the toggle is a module flag kept current by ClientNonUIFeatures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The "should we make noise" predicate used for message sounds is extracted
into useNotificationsQuiet() (unit-tested) and applied to the ringtone in
both the full-screen incoming-call overlay and the compact in-call banner.
The overlay/banner still show so the call can be answered; only the audio
is skipped. Join/media paths untouched.
Fixes#28
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Home's categorisation memo keyed only on the room list, and nothing
observed m.tag changes. The SDK emits RoomEvent.Tags on the room and
re-emits it on the client (room.js addTags, sync.js reEmit); a small
client-level hook bumps a version that the memo depends on.
Fixes#20
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The redirect back with ?error=invalid_client is the only place a stale
dynamic client id is ever rejected; the callback now resolves the issuer
from the SDK's stored mx_oidc_<state> entry and invalidates the cache so
the next attempt re-registers. Degrades to a no-op if the state entry is
gone. Unit-tested.
Fixes#102
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
A transient network/discovery failure invalidated the cached dynamic
client and registered a fresh one on every retry. Invalidate only on
invalid_client / unauthorized_client or a 400/401 from the provider.
Unit-tested.
Fixes#67
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
useSessionSync reloaded on any out-of-tab session change, so a routine
refresh in one tab hard-reloaded the others mid-call. Classify the
change: removed → reload, user/device changed → reload, same device with
a new token → swap it into the running client (setAccessToken + the
shared refresh token) in place. The refresher takes a Web Lock and adopts
tokens another tab already rotated instead of racing the issuer.
Unit-tested classifier.
Fixes#16
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
unmuteRoom unconditionally reset the room to Unset when a timed mute expired
(in-session timer and boot-time restore alike), silently reverting a mode the
user had changed by hand during the window. Mute-timer helpers move to
muteTimers.ts; unmuteRoom now reads the live push-rule mode and only resets
when it is still Mute, always dropping the persisted timer. Unit-tested.
Fixes#21
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Swap the logo/favicon URL constants from the inline
`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/res/...` form to the
repo's existing `withOriginBaseUrl(getOriginBaseUrl(), '/public/res/...')`
helper (already used here for the OIDC callback URL). Functionally equivalent —
same /public/res/ target, resolves in dev and the static-copied prod build — and
it keeps the logo URL absolute and consistent with clientUri for the OIDC
logoUri. No build-config change (publicDir stays false).
Co-authored-by: Nathan Vititoe <nathanvititoe@gmail.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Importing images from public/ (`import X from '../../public/res/*.png'`) is
fragile under Vite with `publicDir: false` and can white-screen the dev app —
Vite tries to resolve the public/ path as a module. Switch the five logo/
favicon call sites to the repo's existing BASE_URL URL pattern
(`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/res/...`), matching how
config.json, locales, and the Element Call widget are referenced. Resolves
identically in dev and the static-copied prod build (public/res -> dist/public/res).
Sounds under public/sound/ stay ESM-imported: that folder is not copied to
dist/ by vite-plugin-static-copy, so a URL reference would 404.
Also add a Local Development section to the README (no-backend model, npm ci /
npm start on :8080, which homeserver to log in against, OIDC-on-localhost note).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A completed in-room device-verification request is a plain m.room.message
(msgtype m.key.verification.request) that matches the default DM push rule
with no recency gate, so the server/SDK notification count stays > 0 and the
DM re-lights as unread on every fresh sync until the room is opened twice.
Two-part fix:
- Display suppression: getUnreadInfo/getUnreadInfos return {0,0} for a room
whose ENTIRE unread span (tail -> read receipt) is verification-flow events,
via new pure helpers isVerificationFlowEvent + unreadIsOnlyVerification.
Conservative: never suppresses when the read marker is off-window, the tail
is still encrypted, or a highlight is present.
- Durable auto-read: useAutoMarkVerificationRead sends a read receipt covering
the request (the only SDK-durable lever), once per room per session, gated on
the same verification-only predicate so it can never ack a real message.
unreadIsOnlyVerification also rejects any room with an unread thread, because
markAsRead clears every thread unconditionally — otherwise a verification-only
main timeline with a genuine unread thread reply would be hidden/auto-acked.
Reviewed by 5 agents; the thread-scope guard closes the one bug they found.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Toast queue: a burst of notifications appended unboundedly and could cover the
viewport. Cap at 5 in the atom writer, dropping the OLDEST non-sticky toast
(sticky = action toasts requiring a click, never dropped). The drop scan
excludes the just-appended newest (`length - 1` bound) so a fresh toast is
never the one eaten when the cap is full of stickies — it stretches instead.
Container gains a maxHeight + overflowY safety net and scrolls the newest
(bottom) toast into view if the stack ever overflows. +4 unit tests incl. the
cap-full-of-stickies boundary.
- "Unread First" room sort left the entire read tail (all counts tie at 0) in
arbitrary Map order. factoryRoomIdByUnread now breaks ties by recent activity.
Relocated from Home.tsx (module-private) to utils/sort.ts (exported, pure) and
unit-tested (equal-count and read-tail cases fall back to activity).
Bug-hunt findings from LOTUS_TODO. Three review passes: the second caught that
the cap could silently drop the newest notification when full of stickies (real
bug, untested boundary) — fixed and covered; a third traced the corrected loop.
Gate-green (tsc, eslint, prettier, 920 tests, build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>