Previews are built from the plain body, clicks follow formatted_body's
hrefs, and a sender controls both: text "https://paypal.com" linked to
evil.example showed a real PayPal card. Drop body URLs whose site is
only claimed by the visible text of a mismatched link (linkSafety).
Fixes#286
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Two bugs in the one-row "+N" collapse:
- measure() used the border box, which grows by 4 px while collapsed
(margin -2 / padding 2), so a row within 4 px of fitting flipped
collapsed↔expanded every frame (flicker, layout shifts, ~11 % of a core
per looping message). It now measures the content box.
- The collapsed style used the `margin` shorthand, which overrode the
timeline's marginTop and, when removed, cleared it; the row then stuck
to the message text. Longhand margins keep marginTop (minus 2 px).
Verified headless (12 reactions, viewport sweep 1300–1400 px): before,
90 style flips in 1.5 s at 1344 px and margin-top 0px after a cycle;
now 0 flips at every width and margin-top stays 8px.
Fixes#305
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
createRoom granted org.matrix.msc3401.call.member at user level only for
voice rooms. Every other room fell back to Synapse's state_default (50),
so in a private group room (where Lotus shows the call button and rings)
only admins could start a call; members saw "1 Live · Join" and then
"You don't have permission to join!". All new non-space rooms now get
call.member: 0 (spaces keep their own override).
Verified headless through Create Room → Private: before, no call.member
entry (state_default 50); now 0. Existing rooms are unchanged; an admin
can set it in room permissions.
Fixes#328
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
DeliveryStatus showed whatever mEvent.status was at the last timeline
re-render, and a local echo's status changes (SENDING → SENT → confirmed,
NOT_SENT → retried) only emit event-level events, so the last message in
a quiet room kept "Sending..." (or a stale "Failed to send. Click to
retry" after an outbox retry) indefinitely. It now re-renders on
MatrixEventEvent.Status / LocalEventIdReplaced.
Not browser-verified in this change (checks: tsc, unit tests, eslint).
Fixes#306
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
POST /rooms/{id}/upgrade copies only Synapse's fixed list of state, so an
upgrade silently dropped everything Lotus keeps in room state: an
audio-only voice channel became camera/screenshare-allowed, user limits
and bitrate caps vanished, the soundboard pack was lost, and a room set
to delete messages after 7 days kept new ones forever.
RoomUpgrade now reads the client-owned state first (utils/roomUpgradeState,
unit-tested: io.lotus.*, m.room.retention, role tags, room emoji packs)
and re-creates it in the replacement room; a partial copy is reported in
the dialog instead of closing silently.
Verified headless through the UI (v10 room with voice limit, quality
policy, a soundboard pack and retention → upgrade): before, none
survived; now all four are in the new room.
Follow-up (not here): for spaces, the children's m.space.parent still
point at the old space.
Fixes#327
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
useNotificationsQuiet evaluated the quiet-hours window (and the
"Pause notifications" expiry) only at render time, and the long-lived
message/invite notifiers rarely re-render, so someone leaving Lotus open
overnight with quiet hours 22:00–07:00 got every sound all night (and a
30-minute pause could outlast itself). The hook now re-evaluates every
30 s.
Verified headless (notifications granted, window unfocused): quiet
hours set to start ~75 s after the app opened; a DM before the window
notified; one inside the window, with the app left alone: before it
still notified, now it's silent.
Fixes#325
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
- #319: logout wiped the drafts, then reloaded, and the reload's pagehide
made RoomInput flush the open composer's (decrypted) draft straight back
to localStorage. clearPlaintextCaches now sets a module flag and
persistDraft stops writing once it's set (resets with the new page).
- #296: the device-wide encrypted-search opt-in (searchCacheEnabled)
survived logout, so the next account started indexing decrypted text
without opting in. It's now cleared, along with the SDK's per-user
filter key.
Verified headless with a throwaway account (search opt-in on, "SECRET
half-typed reply" in the composer, Settings → Log out): before, the draft
and searchCacheEnabled=true remained; now neither does. Unit test added.
Fixes#319Fixes#296
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
CallEmbed asked Element Call to send a call-start notification for every
non-DM call. In persistent voice channels and space rooms it carries an
@room mention: every member got a highlighted ping (and phone push)
whenever a moderator or admin joined first, and other clients showed it
as a missed call. Now only rooms that can ring (isRingableRoom: DMs and
invite-only groups outside a space) get ring/notification; everything
else passes sendNotificationType=none (element-call-embedded 0.25.0-lotus.23).
Verified with a local build of the fork: an admin joining the Voice Lounge
sends none, and Bob gets no new notification (before: +1 highlighted).
Fixes#295
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Since the switch to @fontsource-variable/inter 5 (May), the package
registers the family 'Inter Variable', but the app still asked for the
4.x name 'InterVariable', so every user got their platform's default
sans-serif (Arial / Helvetica / DejaVu) and Settings → Appearance →
font "Inter" did nothing.
--font-secondary (index.css) and the App font map now ask for
'Inter Variable' first, keeping 'InterVariable' as a fallback for a
locally installed old version.
Verified headless: before, 0 Inter faces loaded and
document.fonts.check('16px "Inter Variable"') false; now the face loads
and the check is true.
Fixes#320
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Several dialogs are <Box as="form"> with the header ✕ (folds IconButton,
no type attribute, so a submit button) as the form's first button. On
implicit submission, Enter in a text field "clicked" ✕, closing the dialog
and throwing away what was typed (a poll's question and answers, a
schedule, a report reason).
The header close/cancel IconButtons now have type="button": Create Poll,
Schedule Message, Report User, Report Room, Account Data editor, State
Event editor, device verification setup, and the message delete/report
dialogs.
Verified headless: in Create Poll, typing a question and pressing Enter
used to close the dialog; now it stays open with the question kept.
Fixes#315
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
m.room.retention can be set to anything by anyone with state power. A
fat-fingered `max_lifetime: 1000` blanked the timeline for every Lotus
user and, for members with "Enforce Message Retention" on, the sweeper
redacted all their messages within a minute. The RETENTION_MIN_MS floor
existed but was never applied.
- effectiveRetentionMs(): anything under 10 min counts as off; used by
getRoomRetentionMs (sweeper) and the timeline's hiding.
- The sweeper never deletes under a policy less than 24 h old, so a
mistake or a malicious change can be reverted before anything is lost.
(Local hiding of expired messages still applies at once.)
Verified headless (the issue's repro, enforcement on): after
max_lifetime 1000, all 4 messages stay visible and none are redacted
after 45 s (before: timeline blank, all 4 redacted). Unit tests for the
floor and the grace window.
Fixes#298
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
initClient awaited navigator.storage.persist() before creating the client.
Chromium and WebKit answer at once, but Firefox shows a permission prompt
and leaves the promise pending until it's answered, so the app sat on
"Heating up" (no sync, no crypto, no IndexedDB) for every Firefox user who
didn't notice the prompt.
The request is still made (KE-1 only needs that), but startup waits at
most 1.5 s for the answer. The comment claiming persist() never prompts
is corrected.
Verified in Playwright Firefox with the prompt left unanswered
(persist() still pending): before, still "Heating up" after 20 s; now the
room list after 12 s.
Fixes#303
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
After a reload/restart within a ring's lifetime, the catch-up sync replays
the ring together with the caller's hang-up and our own decline, and the
MatrixRTC session can keep the stale join. The client rang again for up to
90 s and showed "1 Live" (call screen instead of chat) for minutes.
- Before ringing, check the room STATE (utils/callRing.ts, unit-tested):
no ring if we already declined that ring or are in the call; if the
caller has no active membership yet, re-check after 4 s (a real call's
membership can land just after its ring) and ring only if they joined.
- The "caller gone" dismissal also consults room state, not only the
possibly stale session.
- When sync becomes live (startup or reconnect), recompute every active
RTC session from the current state, clearing a stale "Live".
Verified headless (Bob's membership + ring in an invite-only room):
live ring rings; hang-up stops it; reload within the window: before
rang again + "1 Live", now no ring and no "Live"; a ring that arrives
just before the membership still rings.
Fixes#312
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Follow-up to #359: the personal room name now also shows in message-search
result groups and the search room filter (whose room search matches it
too), Inbox → Notifications, the threads list and the thread panel header.
Verified headless with general renamed to "Zebra Lounge": a global message
search and Inbox → Notifications both show "Zebra Lounge".
Fixes#336
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Joining a voice channel alone sends a call notification (m.rtc.notification)
to the room, and the timeline rendered every unanswered one as "Missed call
from X", so everyone in the channel saw a missed call from someone who had
just joined it. Ringing already excluded these rooms; the missed-call line
didn't.
utils/callRing.ts (unit-tested) holds the one rule for both: only DMs and
invite-only groups outside any space ring or show "Missed call"; voice
rooms, space channels, restricted and public rooms don't.
Verified headless with the same two notifications from Bob: the Voice
Lounge chat showed 2 "Missed call" lines before, 0 after; a small
invite-only group still shows its missed call.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
matrix.lotusguild.org turned off open registration (2026-10-16; accounts
come from SSO/LLDAP), so the login page's registration probe
(POST /_matrix/client/v3/register) now answers 403 "Registration has been
disabled" instead of 401 + UIA flows. The boot smoke test treated that
console line as unexpected and failed on every branch (seen on #360).
collectConsole now skips a 403 only when its source URL is the register
endpoint; any other 403 still fails the test.
Verified: boot.spec passes locally in chromium, webkit and iphone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
background-position can't run on the compositor, so every frame of the
animated chat backgrounds repainted the whole chat area: 51 to 72% main-thread
busy while idle in a room at 60 fps. The drifts are slow (3 to 22 px/s,
under a pixel per frame), so each animation now uses steps(): about 12
updates/s for fireflies, stars and aurora and about 24/s for rain and
pulse.
Measured headless (20 s idle in a room, CDP TaskDuration):
none 0.7% → 0.9%; rain 50.7 → 5.9; stars 71.2 → 4.5;
pulse 64.9 → 5.8; aurora 58.9 → 4.2; fireflies 71.7 → 3.8.
Aurora and pulse used ease-in-out; with steps() their motion is now
even-paced. Pause animations / reduced motion still strip the animation.
Fixes#337
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Two easy bypasses of the "this link goes somewhere else" check:
- Any whitespace in the link text disabled it, so "Log in at
chat.lotusguild.org" → evil.example wasn't flagged. hostsFromText now
scans each word for host-shaped tokens; a link is flagged if any named
host is a different site. Bare file names (main.py, report.pdf) and
version numbers are skipped inside sentences so ordinary links aren't
flagged.
- Shared-hosting suffixes counted as one site (alice.github.io vs
evil.github.io, *.pages.dev, netlify.app, vercel.app, …).
registrableDomain now treats a curated list of them as public suffixes
(longest match, incl. s3.amazonaws.com). Curated rather than the full
PSL to stay inside the bundle budget.
Verified with unit tests and headless on real messages: the four bypass
cases are flagged; the honest sentence, file/version text and "click
here" are not.
Fixes#335
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
The personal room name (io.lotus.room_names) was only used by the
sidebar, header, intro and call bar; the quick switcher couldn't find a
room by it ("No match found") and showed the server name, as did the
forward/share pickers, toasts, OS notifications and reminders.
useRoomMeta gains useLocalRoomNames() / getLocalRoomName(); the quick
switcher matches both names and shows the personal one, the forward and
share pickers show and filter by it, and message/reminder notifications
use it for the room title.
Verified headless with general renamed to "Zebra Lounge": Ctrl+K "Zebra"
and "general" both list "Zebra Lounge"; the forward picker filtered by
"zebra" lists "Zebra Lounge".
Not covered here (follow-up): message-search result headers, the inbox
notifications page and the threads list still use the server name.
Refs #336
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
- Tab into a link in the hidden part used to scroll the overflow:hidden box
itself (top cut off, and it stayed that way); focus inside now expands
the message, and collapsing resets the scroll.
- "Read more" / "Show less" is announced as a toggle (aria-expanded,
aria-controls); the arrows are aria-hidden.
- The fade is a mask on the content instead of a gradient in a fixed
surface colour, so there's no band in bubble layout, on highlights or
over chat backgrounds.
- The cap is 20rem instead of 320px, so a larger font shows as many lines.
Verified headless on a 41-line message with a link on the last line:
aria-expanded false → focusing the link expands it (aria-expanded true,
no clipping); bubble layout fades into the bubble with no band.
Fixes#343
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Logout left lotus-per-user-volume (Element Call's per-person volume map:
whom the previous user called and turned up/down; same origin on the web)
and settings-sync-meta:<userId> (a key naming the account) in
localStorage. clearPlaintextCaches now removes both; device preferences
(`settings`) are kept as before.
Verified: after joining/leaving a call and logging out via Settings, both
keys are gone (unit test added).
Fixes#347
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Settings sync mirrored enterForNewline to io.lotus.settings, so turning it
on for a phone keyboard switched the desktop to Ctrl+Enter-to-send (and
vice versa). It is now in DEVICE_LOCAL_KEYS.
Verified with one account on a phone and a desktop profile: toggling it
on the phone leaves the desktop at Enter-to-send, and it is no longer in
the synced account data.
Fixes#348
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5