m.location mounted an openstreetmap.org iframe on render, acting as a
read beacon; it's now behind a "Load map" button. CollapsibleBody uses
useReducedMotion() instead of a one-time matchMedia snapshot.
Fixes#66Fixes#85
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
maxSize 10, maxExpand 100, trust false, strict ignore, and a 5000-char
cap (rendered as plain text beyond it), so a remote sender can't blow out
layout or CPU with \rule{99999em} or deep macro expansion.
Fixes#65
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
buildForwardContent copied content.file (AES key/iv/hashes) verbatim, so
forwarding from an E2EE room into an unencrypted one published the key.
For unencrypted destinations the attachment is now downloaded, decrypted
and re-uploaded as plaintext (url instead of file, thumbnail key
stripped); if that fails the forward is refused rather than leaking.
Encrypted destinations unchanged. Needs a manual check on a live
encrypted -> plaintext forward.
Fixes#63
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
RoomMemberEvent.Membership is emitted on RoomMember/MatrixClient, never
on Room, so the (room as any).on() handler was dead. Subscribe on the
client filtered by roomId; drop the cast.
Fixes#61
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Decrypted clip blob: URLs lived in an unbounded module Map for the page
lifetime and survived logout. Add clearSoundboardClipCache() (called from
both logout paths next to clearPlaintextCaches) and a 64-entry LRU that
revokes on evict. Unit-tested.
Fixes#57
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
clearFocusParticipant() had no callers, so a spotlight pin was permanent.
CallControl now tracks focusedUserId, the member menu toggles, and
dispose() clears the pin. Removes _pipMode/setPipMode (never read).
Fixes#56Fixes#59
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
npm audit --omit=dev went from 11 advisories (6 high) to 0:
- pdfjs-dist 5.7.284 -> 6.3.289 (GHSA-hq66-cqwq-w95j, arbitrary JS on
opening a malicious PDF; the client has an inline PDF viewer). Only API
change for us: getDocument() no longer accepts a bare string.
- react-router-dom 7.15.1 -> 7.18.3 (open redirect, DoS, RSC advisories)
- sanitize-html 2.17.4 -> 2.17.7 (javascript: URI bypasses; not
exploitable in our config per the audit, bumped anyway)
- katex 0.16.11 -> 0.16.47 (\htmlData attribute validation)
- uuid under @giphy/js-util overridden to >= 11.1.1 (giphy imports v4,
which is unchanged)
- npm audit fix for transitive brace-expansion / js-yaml / nanoid / postcss
Verified: tsc clean, 1000 tests pass, production build succeeds and
dist/pdf.worker.min.js is still copied from the new package.
Fixes#24
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The guard double-counted staged uploads and read a stale count for every
file in a batch, so a 60-file drop bypassed the cap while a nearly-full
pack refused early. Partition the batch with a running count.
Fixes#31
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
channelFull was computed only in the prescreen; a second click on the
channel in the room nav joined a full channel. Extract
useVoiceChannelFull, use it in both places, and refuse with a
"Channel full (N/N)" toast.
Fixes#30
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The persistent call-status bar exposed Video and ScreenShare with no
io.lotus.room_quality check and no share confirmation, bypassing the
in-room bar's gating. Add useRoomCallPolicy and apply the same hiding
plus a "Share your screen?" confirm.
Fixes#26
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Pronouns, timezone and avatar decoration applied the mount-time fetch
result unconditionally, overwriting a value the user had already edited;
the decoration panel also showed "None" on any fetch failure and let the
user save over a real decoration. Add cancelled/dirty guards (mirroring
ProfileStatus) and an explicit load-error state with Retry.
Fixes#46Fixes#47
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
matrix-widget-api 1.17.0's WidgetParser rejects every URL (it compares
URL.protocol "https:" to "https"), so the widgets panel was always empty.
Build Widget objects from the raw state events with a correct scheme
check plus the existing origin check. Unit-tested against a real state
event.
Fixes#15
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
Nothing ever removed an indexed row: redacted messages stayed searchable
with full plaintext and rendered as normal results. Now: a client-level
RoomEvent.Redaction listener deletes the row, leave/ban clears the room
(clearRoom finally has a caller), m.replace edits upsert the original
row instead of indexing the "* fallback" separately, and cached rows
whose local event is redacted render through the existing
redacted_because placeholder. Unit-tested.
Fixes#14
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
For encrypted rooms, bookmarks persist only {roomId, eventId, savedAt}
and reminders only their non-text fields; the preview, room name and
sender resolve locally at render/fire time from the timeline (with a
"Message unavailable" fallback). A one-time, loop-guarded cleanup strips
text from existing entries in currently-encrypted rooms. Unencrypted
rooms are unchanged. Unit-tested.
Fixes#10
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
They were wired inside CallControls, which only renders while the call
room is selected, so navigating away (PiP) or opening the in-call chat on
mobile silently disabled all three — AFK auto-mute exactly when it
mattered. Move them into useCallHotkeys + useAfkAutoMute mounted from the
embed-lifetime CallUtils, gated on joined; CallControls keeps only the
PTT chip (pttActiveAtom).
Also: window blur/focus release the mic only while a PTT key is actually
held, so a deliberate hands-free unmute survives a click into the iframe
(#27); iframe-side listeners re-bind on the iframe load event so they
survive an EC reload (#60). The #23 modifier/interactive guards are
preserved and unit-tested.
Fixes#9Fixes#27Fixes#60
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
First CI-published fork release: upstream v0.25.0 base plus the audit
fixes (deafen via global output mute, denoise dead-mic fallback, single
denoise processor, strict widget origin check, quality-cap clearing,
call_state standalone guard) — LotusGuild/element-call #1#2#10#11#15#31. Verified locally: the bundle lands in dist/public/element-call/
with all six io.lotus.* actions present.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The MSC4133 io.lotus.avatar_decoration value was interpolated into the
CDN URL verbatim, letting a room member steer the path/query of a request
every viewer's browser makes. Accept only slugs present in the catalog;
anything else is treated as no decoration. Unit-tested.
Fixes#64
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
from_ts/to_ts are not Matrix filter fields; the server dropped them, so
the range only worked for the local encrypted-room search. Stop sending
them and post-filter server results by origin_server_ts with the same
inclusive predicate. Unit-tested; docs corrected.
Fixes#13
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The store-sync effect re-applied the stored note on every notification,
including the echo of the note's own save, reverting text typed after
the debounce fired; and closing the panel inside the 800ms debounce
dropped the pending save. Add a dirty ref that suppresses the resync
while there are unsaved edits, and flush the pending save on unmount.
Fixes#18
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Local room names did a read-modify-write of io.lotus.room_names against
the SDK's local cache, which is stale until the /sync echo, so a second
rename issued before the first echoed overwrote it. Route through
createAccountDataListStore like user notes. Unit-tested with a client
whose setAccountData does not update the local store.
Fixes#17
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- useKeyBind refuses Tab/Enter/arrows/Home/End/Page*/Escape and bare
modifier codes, and refuses a code equal to the other call key, with an
inline message (isBindableCallKey, unit-tested).
- PTT and deafen handlers ignore events with Ctrl/Alt/Meta held (deafen
also Shift), so Cmd+M / Ctrl+M no longer toggle deafen.
- PTT only preventDefault()s when the target is not an interactive
control, so Space still activates focused buttons during a call.
Fixes#23
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
draft-msg-<roomId> was unscoped and deliberately skipped on logout, then
hydrated into whoever logged in next. Wipe drafts in clearPlaintextCaches,
and only hydrate a draft whose stored userId matches the current user.
Drafts written before this change carry no userId and are dropped on
first load (a one-time loss of unsent drafts, accepted for the leak fix).
Fixes#41
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
PollCreator used the legacy 3-arg sendEvent (threadId null). Thread the
composer's threadRootId through and send like the sticker path.
Also (#41, same file): composer drafts are persisted as { userId, nodes }
and the restore path drops any draft with a different or missing userId.
Fixes#35
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
ThreadPanel only honoured the separate Private Read Receipts switch, so
the privacy switch still emitted public m.read for every opened thread.
Match markAsRead: hideActivity || privateReadReceipts.
Fixes#34
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Non-thread relations targeting a thread root live only in the room's main
timeline set (Room.eventShouldLiveIn), so the panel never saw root edits
or reactions and re-reacting duplicated instead of toggling. Use the room
set for the root event, the thread set for replies.
Fixes#12
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Records the toolchain decisions taken with the 2026-09 upstream sync of
LotusGuild/element-call (accept upstream: pnpm 11 + Node >= 22.13,
oxlint/oxfmt replacing eslint/prettier, matrix-js-sdk develop pin), the
tag-driven publish flow, and the pending 0.25.0-lotus.1 bundle.
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
- Image compression in an encrypted room re-encoded the *plaintext* original,
uploaded it unencrypted, and reused the original's encInfo, so the media sat
on the server in the clear AND the attachment was undecryptable. The
compressed bytes are now run through encryptFile and the synthetic upload
item carries the new encInfo (buildCompressedUploadItem, unit-tested; it can
never inherit the stale encInfo).
- Scheduled messages (MSC4140) are PUT as raw m.room.message, bypassing the
SDK encryption pipeline. The Schedule button is now hidden in encrypted
rooms, handleScheduleClick no-ops there, and scheduleMessage() itself
refuses with a clear error so no caller can regress this. README notes the
limitation.
- The GIF picker uploaded the Giphy blob unencrypted into E2EE rooms; it now
mirrors the voice/attachment path (encryptFile -> upload ciphertext ->
content.file).
Fixes#6Fixes#7Fixes#11
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
RuleModeSwitcher rebuilt a rule's actions from scratch, so changing the
mode of e.g. .m.rule.is_user_mention silently dropped its highlight tweak
account-wide. Derive the existing highlight/custom-sound tweaks from the
rule's current actions and pass them through as NotificationModeOptions.
Adds getNotificationModeOptionsFromActions + unit tests.
Fixes#19
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
handleAnswer hard-coded { microphone: true, video, sound: true }, so
pressing Answer on a video call published the camera even with the
"Join with Camera On" setting off (the default) and ignored a persisted
muted/deafened preference. Build the answer prefs the same way every other
join path does; PTT's forceAudioOff still applies downstream.
Fixes#8
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
`enabled` means sound is on; the tooltip said "Deafen" but the aria-label
said "Undeafen". Align the label with the tooltip and add aria-pressed to
match the call-status bar's equivalent button.
Fixes#33
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Arcade:
- perspective grid is masked through the central timeline column so it
frames the chat instead of striping messages; lower opacity, thinner
non-scaling strokes (also fixes the grid vanishing in the 76px swatch)
- neon horizon line is broken through the middle so it never underlines
a message; softer sun glow
- "1UP" score + "INSERT COIN" moved from top-left/bottom-centre (space
bar / composer) to a single bottom-right HUD stack; font-size clamps
to 0 via cqw inside the settings swatch so no more clipped glyphs
- scanlines: 4px pitch at lower opacity (light theme was fully striped);
ambient wash slightly lighter
Deep Space:
- starfield 28 → 62 stars across the two parallax layers
- base violet wash slightly lighter for light themes
SeasonalPreview wrapper is now a size container (container-type) so
overlays can scale/hide fixed-px details with cqw. LOTUS_FEATURES.md
theme table now describes what both overlays actually render.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Replaces the single generic releases-page link with direct downloads
for Windows (.exe), Linux (AppImage/.deb/.pkg.tar.zst), plus a note on
the webkit2gtk/GStreamer WebRTC dependency needed for calls to work.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
RoomNavItem.tsx already statically imported getRoomNotificationModeIcon
and RoomNotificationMode from the same module, so the two
`await import('../../hooks/useRoomsNotificationPreferences')` calls
(in unmuteRoom and handleMuteFor) never achieved real code-splitting —
verified by building and grepping dist/assets: setRoomNotificationPreference
landed in the same eager entry chunk regardless, since Rolldown can't
split a module already reachable via a static import elsewhere. Just
import it statically alongside its siblings instead. No behavior
change — confirmed via 3 independent investigations before starting
and 3 independent reviews of this diff before committing.
ClosesLotusGuild/cinny#5
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Swaps #homelabbing/#proxmox for their parent #homelab:codestorm.net
space, so browsing it surfaces the whole space rather than two
hand-picked children.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Populates the previously-empty featuredCommunities block so the Explore
tab's Featured page shows the Lotus Guild Space and a few community
favorites by default, and adds matrixrooms.info as a browsable server
in the Explore sidebar (it speaks enough of the Matrix federation API
to serve as an aggregated public-room search across the network).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The act_runner cache server is now reachable from job containers: jobs were
landing on isolated per-job docker networks and couldn't reach the runner's
cache server on docker0 (getCacheEntry ETIMEDOUT, ~5 min wasted/build). Fixed
runner-side by putting the runner + all job containers on a shared dedicated
network (`act-cache-net`, runner at 172.30.0.2) and pointing cache.host at it —
verified a container on that network reaches the cache port.
Restores `cache: npm` on Setup Node and the actions/cache node_modules step
(restore + save-on-miss-and-success). Reverts 10270b75 now that the underlying
network issue is resolved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The act_runner's internal cache server (172.17.0.2:46367) can't be reached
from job containers: `setup-node` with `cache: npm` spends ~4m42s on
`getCacheEntry failed: connect ETIMEDOUT` every build, then reports "npm cache
is not found" — ~5 min of pure cost for zero caching. The `actions/cache`
node_modules steps added in 79258668 would hit the same dead server and hang
too, so they're removed here as well.
Removing the cache usage reclaims ~5 min/build with no loss (nothing was being
cached). The fast-gates-before-build reorder is kept. Re-enable caching once
the runner's cache server is reachable from job containers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI (.gitea/workflows/ci.yml):
- Cache node_modules keyed on package-lock + .node-version (actions/cache
restore/save). An unchanged lockfile now skips `npm ci` (extraction +
postinstall folds patch) and just restores the tree. Save runs only on a
cache miss and only when install succeeded (`success()`), so a failed
`npm ci` can't poison the cache. setup-node's existing `cache: npm` still
warms the download cache on the miss path.
- Run prettier/eslint/typecheck/tests BEFORE the ~minutes-long build so a
format/lint/type/test error fails in seconds instead of after the build.
DX (.husky/pre-commit):
- Enable the pre-commit hook (`npx lint-staged`). husky + lint-staged were
already installed with a config (eslint + `prettier --write` on staged
files), just commented out — so formatting kept reaching CI. It's now
auto-applied on commit. (typecheck left out of the hook — too slow per commit.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>