Compare commits

..
40 Commits
Author SHA1 Message Date
jaredandClaude Opus 5 a29be7953b fix(soundboard): upload cap counts staged clips once and per batch
CI / Build & Quality Checks (push) Successful in 1m26s
CI / Trigger Desktop Build (push) Successful in 28s
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
2026-09-12 19:46:06 -04:00
jaredandClaude Opus 5 26c70f5a1d fix(calls): sidebar voice-channel join respects the voice limit
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
2026-09-12 19:46:06 -04:00
jaredandClaude Opus 5 fc68e0a769 fix(calls): app-wide call bar honours room camera/screenshare policy
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
2026-09-12 19:46:06 -04:00
jaredandClaude Opus 5 fb14db6d50 fix(notifications): Work/Gaming presets clear an active snooze
Both presets claim notifications end up on, but left a "Pause
Notifications" snooze in place. Sleep is unchanged (its description
holds regardless).

Fixes #48

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 19:46:06 -04:00
jaredandClaude Opus 5 6b9c8de393 fix(profile): don't clobber edits with a slow fetch; show decoration load errors
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 #46
Fixes #47

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 19:46:06 -04:00
jaredandClaude Opus 5 32e5fec6f3 fix(widgets): room widgets render again (bypass broken isValidUrl)
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
2026-09-12 19:46:06 -04:00
jaredandClaude Opus 5 8210ee7046 fix(auth): only drop the cached OIDC client id when the client is rejected
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
2026-09-12 19:46:05 -04:00
jaredandClaude Opus 5 d0dccdeb67 fix(auth): OIDC token rotation no longer reloads every other tab
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
2026-09-12 19:46:05 -04:00
jaredandClaude Opus 5 dddaa4183e chore(client): mount search-cache invalidation; reminder toast resolves text locally
Wires useSearchCacheInvalidation (#14) and makes ReminderMonitor derive
the toast body from the local event when the stored reminder carries no
text (#10).

Refs #10
Refs #14

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 19:46:05 -04:00
jaredandClaude Opus 5 c8e49d3855 fix(security): persistent search index forgets redacted and left-room text
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
2026-09-12 19:46:05 -04:00
jaredandClaude Opus 5 9bdf4ff1fd fix(security): bookmarks/reminders stop storing decrypted text server-side
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
2026-09-12 19:46:05 -04:00
jaredandClaude Opus 5 23ee156f2f fix(calls): PTT, deafen hotkey and AFK auto-mute live for the whole call
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 #9
Fixes #27
Fixes #60

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 19:46:05 -04:00
jaredandClaude Opus 5 9a85a48704 chore(call): bump @lotusguild/element-call-embedded to 0.25.0-lotus.1
CI / Build & Quality Checks (push) Successful in 1m25s
CI / Trigger Desktop Build (push) Successful in 6s
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
2026-09-12 19:18:21 -04:00
jaredandClaude Opus 5 f3119e3dc2 fix(security): validate avatar-decoration slugs from remote profiles
CI / Build & Quality Checks (push) Successful in 1m33s
CI / Trigger Desktop Build (push) Successful in 21s
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
2026-09-12 14:48:35 -04:00
jaredandClaude Opus 5 cecf65a3a1 fix(moderation): policy list viewer reads legacy Draupnir/Mjolnir rule types
Only the stable m.policy.rule.* types were queried, so lists still writing
org.matrix.mjolnir.rule.* showed as empty. Query both and de-duplicate by
entity+recommendation.

Fixes #22

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 14:48:35 -04:00
jaredandClaude Opus 5 02592ed43c fix(search): apply the date range to server results client-side
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
2026-09-12 14:48:35 -04:00
jaredandClaude Opus 5 6bd2903de1 fix(profile): private notes no longer lose typed text
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
2026-09-12 14:48:35 -04:00
jaredandClaude Opus 5 7c52027afb fix(rooms): serialize per-room rename writes so back-to-back renames survive
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
2026-09-12 14:48:35 -04:00
jaredandClaude Opus 5 2344c8273e fix(calls): hotkey rebind denylist; ignore modifiers; keep Space on buttons
- 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
2026-09-12 14:48:34 -04:00
jaredandClaude Opus 5 6e4c4bc795 fix(security): composer drafts no longer survive logout or cross accounts
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
2026-09-12 14:48:34 -04:00
jaredandClaude Opus 5 e447fdc0f3 fix(threads): polls created from a thread composer land in the thread
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
2026-09-12 14:48:34 -04:00
jaredandClaude Opus 5 4dd0e6637d fix(threads): "Hide Typing & Read Receipts" now governs thread receipts
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
2026-09-12 14:48:34 -04:00
jaredandClaude Opus 5 dfccaec9dc fix(threads): resolve root edits/reactions against the room timeline set
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
2026-09-12 14:48:34 -04:00
jaredandClaude Opus 5 9019d7c21e docs: EC fork toolchain — install pnpm directly, not via corepack
CI / Build & Quality Checks (push) Successful in 4m20s
CI / Trigger Desktop Build (push) Successful in 9s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 12:09:56 -04:00
jaredandClaude Opus 5 eef1d14492 docs: EC fork now on upstream v0.25.0 — Node 24 / pnpm 11 / oxlint toolchain
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
2026-09-12 11:55:22 -04:00
jaredandClaude Fable 5.1 2b66dcc08c fix(room-nav): expiring timed mute no longer clobbers a manual mode change
CI / Build & Quality Checks (push) Successful in 1m58s
CI / Trigger Desktop Build (push) Successful in 10s
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
2026-09-12 02:08:03 -04:00
jaredandClaude Fable 5.1 dac74f098e fix(composer): stop three E2EE plaintext leaks (compress, schedule, GIF)
- 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 #6
Fixes #7
Fixes #11

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 02:08:03 -04:00
jaredandClaude Fable 5.1 d4d1b4957f fix(notifications): Advanced Push Rules mode switch keeps highlight/sound tweaks
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
2026-09-12 02:05:34 -04:00
jaredandClaude Fable 5.1 c4aa1567d7 fix(call): answering an incoming call honours cameraOnJoin and mic/sound prefs
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
2026-09-12 02:05:34 -04:00
jaredandClaude Fable 5.1 a6ddafb446 fix(seasonal): Halloween fog layers drop willChange under reduced motion
The three fog bands pinned compositor layers even when their animation was
'none' (always the case in the settings swatch). Match the sibling layers.

Fixes #73

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 02:04:55 -04:00
jaredandClaude Fable 5.1 6c1a9942b0 fix(call): deafen button aria-label was inverted relative to its tooltip
`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
2026-09-12 02:04:52 -04:00
jaredandClaude Fable 5.1 4bea48959e fix(seasonal): keep Arcade + Deep Space overlays out of the chat text
CI / Build & Quality Checks (push) Successful in 1m39s
CI / Trigger Desktop Build (push) Successful in 7s
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
2026-09-12 01:29:33 -04:00
jaredandClaude Sonnet 5 34a3352e21 docs(readme): add per-OS desktop download table
CI / Build & Quality Checks (push) Successful in 2m48s
CI / Trigger Desktop Build (push) Successful in 6s
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>
2026-08-31 21:32:18 -04:00
jaredandClaude Sonnet 5 3cc5f0cc6a fix(room-nav): drop pointless dynamic import of setRoomNotificationPreference
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.

Closes LotusGuild/cinny#5

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 21:29:29 -04:00
jaredandClaude Sonnet 5 fd93339ad4 feat(explore): feature the Homelab space instead of its individual rooms
CI / Build & Quality Checks (push) Successful in 2m21s
CI / Trigger Desktop Build (push) Successful in 12s
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>
2026-08-28 22:14:24 -04:00
jaredandClaude Sonnet 5 b1ecb0c46b feat(explore): feature Lotus Guild Space + favorite rooms, add matrixrooms.info directory
CI / Build & Quality Checks (push) Successful in 3m46s
CI / Trigger Desktop Build (push) Successful in 15s
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>
2026-08-28 22:07:41 -04:00
jared 4656f08802 Revert "ci: re-enable npm/node_modules cache — runner cache network fixed"
CI / Build & Quality Checks (push) Successful in 1m42s
CI / Trigger Desktop Build (push) Successful in 12s
This reverts commit a631e90ea2.
2026-08-02 23:23:52 -04:00
jaredandClaude Opus 4.8 a631e90ea2 ci: re-enable npm/node_modules cache — runner cache network fixed
CI / Build & Quality Checks (push) Canceled after 4m44s
CI / Trigger Desktop Build (push) Canceled after 0s
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>
2026-08-02 23:18:00 -04:00
jaredandClaude Opus 4.8 10270b75ca ci: drop npm/node_modules cache — runner cache server is unreachable
CI / Build & Quality Checks (push) Successful in 1m40s
CI / Trigger Desktop Build (push) Successful in 13s
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>
2026-08-02 21:26:26 -04:00
jaredandClaude Opus 4.8 7925866868 ci: cache node_modules + run fast gates first; enable lint-staged hook
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>
2026-08-02 21:05:50 -04:00
96 changed files with 3039 additions and 1103 deletions
+30 -28
View File
@@ -30,8 +30,13 @@ jobs:
uses: actions/setup-node@v4
with:
node-version-file: '.node-version'
cache: npm
# No npm / node_modules cache: the act_runner's internal cache server is
# unreachable from job containers (`getCacheEntry failed: connect ETIMEDOUT
# 172.17.0.2`), so every cache restore hangs ~5 min and then fails — pure
# cost, zero benefit. `cache: npm` was removed from Setup Node above for the
# same reason. Re-enable both (setup-node `cache: npm` + an actions/cache
# node_modules step) once the runner's cache server is reachable from jobs.
- name: Install dependencies
# Harden against transient registry network failures (ECONNRESET etc.):
# raise npm's built-in fetch retries/timeouts and retry `npm ci` up to
@@ -52,39 +57,36 @@ jobs:
sleep $((attempt * 15))
done
# ── Critical gate — if this fails, nothing deploys ──────────────────
# ── Quality gates run BEFORE the slow build so a format/lint/type/test
# error fails in seconds instead of after the ~minutes-long build. All are
# hard gates — any failure fails the job and blocks the deploy. The tree is
# held clean (prettier formatted, eslint 0 errors, typecheck 0), so these
# gate real regressions. NOTE: the lotus-build.sh upstream-merge path can
# deploy without CI; a later normal push surfaces any introduced issue here
# — fix forward (or briefly re-soften a gate) rather than deploy broken.
# eslint gates on errors only (existing no-explicit-any warnings stay
# informational — check:eslint has no --max-warnings).
- name: Prettier
run: npm run check:prettier
- name: ESLint
run: npm run check:eslint
- name: TypeScript
run: npm run typecheck
# Deterministic pure-logic tests on Node's built-in runner via tsx (no
# vitest — Vite 8 is ahead of vitest's range). A failure blocks the deploy.
- name: Unit tests
run: npm test
# ── Critical gate — if this fails, nothing deploys. Produces dist/. ──
- name: Build
run: npm run build
env:
NODE_OPTIONS: '--max_old_space_size=4096'
VITE_APP_VERSION: ${{ github.sha }}
# Unit tests are a hard gate too — deterministic pure-logic tests on Node's
# built-in runner via tsx (no vitest — Vite 8 is ahead of vitest's range).
# A failure blocks the deploy.
- name: Unit tests
run: npm test
# ── Quality gates (hard — a failure fails the job and blocks deploy) ──
# The tree is held clean (typecheck 0, eslint 0 errors, prettier
# formatted), so these gate real regressions instead of relying on local
# runs. NOTE: an upstream-stable merge (the lotus-build.sh path) could
# introduce upstream type/lint/format issues; that path deploys without
# CI, but a subsequent normal push would surface the failure here — fix
# forward (or briefly re-soften a gate) rather than let it deploy broken.
# eslint gates on errors only (existing `no-explicit-any` warnings stay
# informational — `check:eslint` has no --max-warnings).
- name: TypeScript
run: npm run typecheck
- name: ESLint
run: npm run check:eslint
- name: Prettier Check and Fix
run: |
npx prettier --write .
npm run check:prettier
# ── Security (informational — findings shouldn't block a deploy) ─────
- name: Audit (high/critical)
run: npm audit --audit-level=high --omit=dev
+1 -3
View File
@@ -1,3 +1 @@
# These are commented until we enable lint and typecheck
# npx tsc -p tsconfig.json --noEmit
# npx lint-staged
npx lint-staged
+14 -14
View File
@@ -175,19 +175,19 @@ Decorative CSS-only overlays that activate automatically on holidays and events.
### Themes
| Theme | Window | Effect |
| -------------------- | ------------- | -------------------------------------------------------------------------------------------------- |
| 🎆 New Year | Dec 31–Jan 2 | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep |
| 🏮 Lunar New Year | Jan 22–Feb 5 | Floating paper lanterns bobbing; silk texture; gold shimmer accent |
| 💖 Valentine's Day | Feb 10–15 | ♥ hearts floating upward; soft pink ambient glow |
| 🍀 St. Patrick's Day | Mar 15–18 | ☘ clovers drifting down; gold metallic shimmer top border |
| 🃏 April Fool's | Apr 1 | Glitch overlay: RGB channel separation, hue-rotate spikes, scanline sweep, "SIGNAL LOST" watermark |
| 🌱 Earth Day | Apr 20–23 | 🌿🍃 leaf emoji drift; sage green ambient tint; vine accent on left edge |
| 🍂 Autumn | Sep 21–Oct 31 | Warm orange/amber leaf shapes rotating and falling |
| 👾 Arcade Day | Sep 12 | CRT scanlines; blinking pixel corner decorations; "INSERT COIN" prompt |
| 🚀 Deep Space Week | Oct 4–10 | Warp-speed star streaks radiating from screen centre; nebula purple/blue ambient |
| 🎃 Halloween | Oct 15–Nov 1 | Purple and orange glowing particles; SVG spider web in top-left corner; dark purple tint |
| ❄️ Christmas | Dec 10–Jan 2 | White dot snowfall in multiple layers at varied speeds |
| Theme | Window | Effect |
| -------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🎆 New Year | Dec 31–Jan 2 | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep |
| 🏮 Lunar New Year | Jan 22–Feb 5 | Floating paper lanterns bobbing; silk texture; gold shimmer accent |
| 💖 Valentine's Day | Feb 10–15 | ♥ hearts floating upward; soft pink ambient glow |
| 🍀 St. Patrick's Day | Mar 15–18 | ☘ clovers drifting down; gold metallic shimmer top border |
| 🃏 April Fool's | Apr 1 | Glitch overlay: RGB channel separation, hue-rotate spikes, scanline sweep, "SIGNAL LOST" watermark |
| 🌱 Earth Day | Apr 20–23 | 🌿🍃 leaf emoji drift; sage green ambient tint; vine accent on left edge |
| 🍂 Autumn | Sep 21–Oct 31 | Warm orange/amber leaf shapes rotating and falling |
| 👾 Arcade Day | Sep 12 | Synthwave CRT: neon perspective grid framing the timeline (faded through the chat column), broken horizon line, rolling scanlines, pixel sparkles, bottom-right "1UP / INSERT COIN" HUD |
| 🚀 Deep Space Week | Oct 4–10 | Violet void with drifting magenta/cyan nebula clouds, two-depth parallax starfield (~60 twinkling stars + 6 hero gleams), slow galaxy spiral, occasional comet streaks |
| 🎃 Halloween | Oct 15–Nov 1 | Purple and orange glowing particles; SVG spider web in top-left corner; dark purple tint |
| ❄️ Christmas | Dec 10–Jan 2 | White dot snowfall in multiple layers at varied speeds |
### Implementation
@@ -742,7 +742,7 @@ never leaves it.
### Message Search Date Range
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds) passed to the search API
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds); server results are filtered client-side by `origin_server_ts` (they are not Matrix filter fields), matching the local encrypted-room search
- A chip shows the active date range with an **×** button to clear it
### Encrypted Search Cache (P4-8, opt-in)
+4 -2
View File
@@ -372,9 +372,11 @@ Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgra
### Element Call fork — operational reference
Fork = `LotusGuild/element-call` (branch `lotus`, from upstream tag `v0.20.1`); cinny consumes the npm package `@lotusguild/element-call-embedded` (built bundle copied into `public/element-call/`).
Fork = `LotusGuild/element-call` (branch `lotus`, upstream base **v0.25.0** since the 2026-09 sync — was v0.20.1); cinny consumes the npm package `@lotusguild/element-call-embedded` (built bundle copied into `public/element-call/`).
**Publish a new version (manual; needs the Gitea npm token):** bump `embedded/web/package.json` (current unpublished `0.20.1-lotus.2`) → `pnpm run build:embedded` (Node 24, pnpm 10.33) → `cd embedded/web && npm version <tag> --no-git-tag-version && npm publish` (Gitea registry) → in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.20.1-lotus.1`) → `npm install` → build.
**Toolchain (upstream-driven, accepted 2026-09):** Node ≥ 22.13 (`.node-version` = 24) and **pnpm 11**, installed directly (`npm i -g pnpm@<packageManager version>`, currently 11.21.0) — **not** via `corepack enable`: `matrix-js-sdk` is a git dependency pnpm builds from source, and its own devEngines pins pnpm 11.9.0; a corepack-shimmed pnpm refuses to switch for that nested install and `pnpm install` fails (fork CI run #1854). pnpm 10 rejects the lockfile and Node 20 cannot build. Lint is **oxlint + oxfmt** (upstream dropped eslint/prettier in v0.25.0): `pnpm lint` (tsc + oxlint + knip) and `pnpm format:check` / `pnpm format`. `matrix-js-sdk` is pinned to a `matrix-org/matrix-js-sdk#develop` commit in the lockfile, as upstream ships it. Fork CI (`.gitea/workflows/ci.yml`) hard-gates lint + format + `pnpm test:unit` before build, with `concurrency: cancel-in-progress`.
**Publish a new version (CI on tag push; needs the `GITEA_NPM_TOKEN` secret):** the published version is derived from the git tag — bump `embedded/web/package.json` (currently `0.25.0-lotus.1`, published by CI 2026-09-12 — the first CI publish; the secret is `NPM_PUBLISH_TOKEN`, names starting `GITEA_` are reserved), push `lotus`, then `git push lotus v0.25.0-lotus.1`; the `publish` job builds and publishes to the Gitea registry. Always push (never delete) the annotated `vX.Y.Z-lotus.N` tag for every published version. Then in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.25.0-lotus.1`) → `npm install` → build. Manual fallback: `pnpm run build:embedded && cd embedded/web && npm version <ver> --no-git-tag-version && npm publish`.
**`io.lotus.*` widget actions** (add new toWidget actions to the enum + `LOTUS_TO_WIDGET_ACTIONS` in `src/lotus/lotusActions.ts`; only send AFTER call-join or a 10s timeout fires):
+13 -35
View File
@@ -13,38 +13,7 @@ The source code is licensed under [AGPLv3](LICENSE), the same license as the ups
The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0.
---
## Development Environment Setup
#### Getting correct Node version
- Ensure you have the correct version of node installed, specified in `.node-version`
- Use this command from the terminal to install nvm
```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
```
- Reload your terminal shell using (for Ubuntu):
```bash
source ~/.bashrc
```
- Install the specified Node version
```bash
NODE_VERSION="$(tr -d '[:space:]' < .node-version)"
nvm install "$NODE_VERSION"
nvm use "$NODE_VERSION"
```
- verify the correct version was installed by running
```bash
node --version
```
and comparing the output to what is listed in `.node-version`
#### Install npm packages
- To install the npm packages listed in `package.json` run:
```bash
npm i
```
### Start Development Server
```bash
npm run start
```
You should now have an active development server at `localhost:8080`, where you can make changes to the code and see the UI update in real time
## Features
### Messaging
@@ -53,7 +22,7 @@ You should now have an active development server at `localhost:8080`, where you
- Slack-style thread notifications: by default you're only pinged for threads you're in or where you're @mentioned; set any thread to All / Mentions-only / Mute from the panel's bell menu (muted threads stop bumping badges; syncs across devices)
- See who has read each message, and track delivery status (sending / sent / failed)
- Bookmark any message and revisit saved messages from the sidebar
- Schedule messages to send at a specific time
- Schedule messages to send at a specific time (unencrypted rooms only — MSC4140 delayed events cannot be end-to-end encrypted, so the option is hidden in E2EE rooms)
- Click "edited" on any message to see the full edit history
- Drafts are saved automatically and survive page reloads
- Long messages collapse automatically — click "Read more" to expand
@@ -160,7 +129,16 @@ Lotus Chat has a desktop app for Windows, macOS, and Linux. It wraps the same we
### Download
Download the latest release from the [Releases page on code.lotusguild.org](https://code.lotusguild.org).
Operating System | Download
---|---
Windows | [Get the installer (.exe)](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64-setup.exe)
Linux (AppImage, any distro) | [Get the AppImage](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.AppImage)
Linux (Debian/Ubuntu) | [Get the .deb](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.deb)
Linux (Arch/CachyOS/EndeavourOS) | [Get the .pkg.tar.zst](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.pkg.tar.zst) — install with `pacman -U LotusChat-x86_64.pkg.tar.zst`
All Linux builds need `webkit2gtk-4.1` and, for calls to work, GStreamer's `good`/`bad`/`ugly`/`libav` plugin sets (the pacman package pulls these in automatically; on the AppImage/.deb, install them via your package manager if joining a call shows "browser does not support WebRTC").
See the full [Releases page on code.lotusguild.org](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases) for signatures and older builds.
### SmartScreen Warning (Windows)
@@ -218,7 +196,7 @@ The dev server defaults to **port 8080** (`vite.config.js`); if 8080 is already
### 🔱 Element Call fork ("Lotus Call") — LIVE
Voice/video channels embed **Element Call**, which is now our **self-built fork**
(`@lotusguild/element-call-embedded` `0.20.1-lotus.1`, source at
(`@lotusguild/element-call-embedded` `0.25.0-lotus.1`, upstream base v0.25.0, source at
`LotusGuild/element-call`), published to our private Gitea npm registry and served
same-origin. We no longer depend on the upstream prebuilt bundle, so in-call
behavior is editable source instead of fragile DOM/widget hacks.
+3 -3
View File
@@ -4,9 +4,9 @@
"allowCustomHomeservers": true,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [],
"rooms": [],
"servers": []
"spaces": ["!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc", "#homelab:codestorm.net"],
"rooms": ["#jellyfin:matrix.org"],
"servers": ["matrixrooms.info"]
},
"hashRouter": {
"enabled": false,
+1 -1
View File
@@ -109,7 +109,7 @@ export default [
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
],
'@typescript-eslint/no-shadow': 'error',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
// jsx-a11y — media captions not required for this app
'jsx-a11y/media-has-caption': 'off',
+369 -457
View File
File diff suppressed because it is too large Load Diff
+10 -8
View File
@@ -12,12 +12,13 @@
"build": "vite build",
"preview": "vite preview",
"lint": "npm run check:eslint && npm run check:prettier",
"check:eslint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"",
"check:eslint": "eslint src/*",
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit",
"test": "node --import tsx --test $(find src -name '*.test.ts')",
"prepare": "husky",
"commit": "git-cz",
"postinstall": "node scripts/patch-folds.mjs",
"sync:decorations": "node scripts/syncDecorations.mjs"
},
@@ -25,6 +26,11 @@
"*.{ts,tsx,js,jsx}": "eslint",
"*": "prettier --ignore-unknown --write"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
},
"keywords": [],
"author": "Ajay Bura",
"license": "AGPL-3.0-only",
@@ -37,7 +43,7 @@
"@fontsource-variable/inter": "5.2.8",
"@giphy/js-fetch-api": "5.8.0",
"@giphy/js-types": "5.1.0",
"@giphy/js-util": "2.0.0",
"@giphy/js-util": "5.2.0",
"@giphy/react-components": "10.1.2",
"@sapphi-red/web-noise-suppressor": "0.3.5",
"@tanstack/react-query": "5.100.13",
@@ -89,7 +95,7 @@
"react-i18next": "17.0.8",
"react-range": "1.10.0",
"react-router-dom": "7.15.1",
"sanitize-html": "2.17.6",
"sanitize-html": "2.17.4",
"slate": "0.124.1",
"slate-dom": "0.124.1",
"slate-history": "0.113.1",
@@ -99,7 +105,7 @@
"workbox-precaching": "7.4.1"
},
"devDependencies": {
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
"@lotusguild/element-call-embedded": "0.25.0-lotus.1",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
"@types/chroma-js": "3.1.2",
@@ -124,7 +130,6 @@
"cz-conventional-changelog": "3.3.0",
"eslint": "9.39.4",
"eslint-config-airbnb": "19.0.4",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-jsx-a11y": "6.10.2",
@@ -144,8 +149,5 @@
"dompurify": ">=3.3.4"
},
"js-cookie": ">=3.0.6"
},
"allowScripts": {
"esbuild@0.28.1": true
}
}
+19 -4
View File
@@ -45,6 +45,8 @@ import { useMatrixClient } from '../hooks/useMatrixClient';
import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ringtones';
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
import { useCallHotkeys } from '../hooks/useCallHotkeys';
import { useAfkAutoMute } from '../hooks/useAfkAutoMute';
import { useCallQuality } from '../hooks/useCallQuality';
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
@@ -58,6 +60,7 @@ import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls';
import { useTheme, ThemeKind } from '../hooks/useTheme';
import { useReducedMotion } from '../hooks/useReducedMotion';
import { useSetting } from '../state/hooks/settings';
import { useCallPreferences } from '../state/hooks/callPreferences';
import { settingsAtom } from '../state/settings';
import { getStateEvent, getStateEvents, getMemberName } from '../utils/room';
import { StateEvent } from '../../types/matrix/room';
@@ -410,6 +413,8 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
const startCall = useCallStart(dm);
const { microphone, sound } = useCallPreferences();
const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
// C-L6: handleTimelineEvent awaits decryption before calling setState; guard
// against the component unmounting during that await.
@@ -566,11 +571,15 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const handleAnswer = useCallback(
(room: Room, video: boolean) => {
startCall(room, { microphone: true, video, sound: true });
// Honour cameraOnJoin and the persisted mic/sound preferences instead of
// forcing camera+mic+sound on — every other join path does this, and
// Answer was skipping it, publishing the camera with no prescreen.
// (PTT's forceAudioOff is applied downstream inside useCallStart.)
startCall(room, { microphone, video: cameraOnJoin && video, sound });
setCallInfo(undefined);
navigateRoom(room.roomId);
},
[startCall, navigateRoom],
[startCall, navigateRoom, microphone, sound, cameraOnJoin],
);
if (!callInfo) return null;
@@ -602,9 +611,15 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
);
}
function CallUtils({ embed }: { embed: CallEmbed }) {
function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
const setCallEmbed = useSetAtom(callEmbedAtom);
// [Gitea #9] PTT/deafen hotkeys and AFK auto-mute are bound here, for the
// embed's whole lifetime, rather than in CallControls (which only renders
// while the call room is selected) — so they keep working in PiP and behind
// the mobile in-call chat. Both are gated on `joined`.
useCallHotkeys(embed, joined);
useAfkAutoMute(joined ? embed : undefined);
useCallMemberSoundSync(embed);
useCallJoinLeaveSounds(embed);
useCallThemeSync(embed);
@@ -1139,7 +1154,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
return (
<CallEmbedContextProvider value={callEmbed}>
{callEmbed && <CallUtils embed={callEmbed} />}
{callEmbed && <CallUtils embed={callEmbed} joined={joined} />}
<CallEmbedRefContextProvider value={callEmbedRef}>
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
{children}
+1
View File
@@ -34,6 +34,7 @@ export default function KaTeX({ latex, displayMode = false }: KaTeXProps) {
return (
<Wrapper
// KaTeX output is generated by our own render call (trusted-safe).
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: html }}
/>
);
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
@@ -84,7 +84,15 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
return (
<div
aria-hidden="true"
style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none' }}
style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
pointerEvents: 'none',
// Size container so overlays can scale/hide fixed-size details (e.g.
// Arcade's HUD text) with `cqw` instead of rendering clipped in a swatch.
containerType: 'inline-size',
}}
>
{buildOverlayContent(theme, true)}
</div>
@@ -102,12 +102,12 @@ export const animSparkleTwinkle = keyframes({
* Opacity + a hair of scale for a CRT bloom feel.
*/
export const animCoinBlink = keyframes({
'0%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
'6%': { opacity: '1', transform: 'translateX(-50%) scale(1.015)' },
'12%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
'49%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
'50%': { opacity: '0', transform: 'translateX(-50%) scale(1)' },
'100%': { opacity: '0', transform: 'translateX(-50%) scale(1)' },
'0%': { opacity: '0.85', transform: 'scale(1)' },
'6%': { opacity: '1', transform: 'scale(1.015)' },
'12%': { opacity: '0.85', transform: 'scale(1)' },
'49%': { opacity: '0.85', transform: 'scale(1)' },
'50%': { opacity: '0', transform: 'scale(1)' },
'100%': { opacity: '0', transform: 'scale(1)' },
});
/**
+103 -69
View File
@@ -46,6 +46,10 @@ const NEON_CYAN = 'oklch(0.80 0.15 200)';
const GRID_PURPLE = 'oklch(0.45 0.18 300)';
// The receding grid as an inline SVG data-URI (CSP-safe, no external assets).
// Strokes use vector-effect=non-scaling-stroke so a line is ~1px whether the
// tile is stretched across a 2000px plane (preserveAspectRatio=none would
// otherwise fatten the verticals ~4x) or squeezed into the 76px settings
// swatch (where scaled strokes disappeared entirely).
// It is a 1x2 vertical tile of horizontal rule lines + a single set of vertical
// lines fanning toward a top-center vanishing point. The plane is then placed
// under a CSS `perspective` rotateX so the lines genuinely recede. Scrolling the
@@ -58,7 +62,7 @@ function gridDataUri(): string {
rows.forEach((y) => {
lines.push(
`<line x1='0' y1='${y}' x2='600' y2='${y}' stroke='${GRID_PURPLE}' ` +
`stroke-width='1.4' stroke-opacity='0.9'/>`,
`stroke-width='1.2' stroke-opacity='0.9' vector-effect='non-scaling-stroke'/>`,
);
});
// Vertical lines fanning out from the top-center vanishing point.
@@ -67,7 +71,7 @@ function gridDataUri(): string {
const botX = 300 + i * 95; // wide at the foreground
lines.push(
`<line x1='${topX}' y1='0' x2='${botX}' y2='600' stroke='${GRID_PURPLE}' ` +
`stroke-width='1.4' stroke-opacity='0.8'/>`,
`stroke-width='1.2' stroke-opacity='0.8' vector-effect='non-scaling-stroke'/>`,
);
}
const svg =
@@ -105,6 +109,13 @@ const RESTING_SPARKLES: ReadonlyArray<{
const GRID_URI = gridDataUri();
// HUD text size: 11px on any real viewport, 0px (invisible) inside anything
// narrower than ~330px. `cqw` resolves against the nearest size container —
// the settings swatch (`SeasonalPreview` sets container-type) — and falls back
// to the viewport width when there is no container, i.e. the full-screen
// overlay. clamp(0, 100cqw - 320px, 11px) → 76px swatch: 0px; 1440px app: 11px.
const HUD_FONT_SIZE = 'clamp(0px, calc(100cqw - 320px), 11px)';
export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
// Deterministic sparkle field, computed ONCE. No per-frame state.
const sparkles = useMemo<Sparkle[]>(() => {
@@ -134,9 +145,9 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
position: 'absolute',
inset: 0,
backgroundImage: [
'radial-gradient(140% 80% at 50% -8%, oklch(0.65 0.25 350 / 0.16) 0%, transparent 55%)',
'radial-gradient(120% 70% at 50% 112%, oklch(0.45 0.18 300 / 0.20) 0%, transparent 60%)',
'linear-gradient(180deg, oklch(0.12 0.05 300 / 0.10) 0%, transparent 38%, oklch(0.10 0.06 310 / 0.16) 100%)',
'radial-gradient(140% 80% at 50% -8%, oklch(0.65 0.25 350 / 0.12) 0%, transparent 55%)',
'radial-gradient(120% 70% at 50% 112%, oklch(0.45 0.18 300 / 0.16) 0%, transparent 60%)',
'linear-gradient(180deg, oklch(0.12 0.05 300 / 0.08) 0%, transparent 38%, oklch(0.10 0.06 310 / 0.12) 100%)',
].join(','),
contain: 'layout paint style',
}}
@@ -147,41 +158,59 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
a vanishing point at the top (the horizon). It lives in the lower
half of the screen — the "floor". The inner plane scrolls upward by
one tile via transform translateY, which reads as the grid flowing
toward the viewer. Pure transform; never background-position. */}
toward the viewer. Pure transform; never background-position.
Two masks are nested (multiple mask-images on one element union by
default, and `mask-composite: intersect` isn't universal yet): the
outer wrapper fades the lattice through the central column where the
message timeline lives, so it frames the chat instead of striping
the text; the inner box fades it in from the horizon. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
left: '-25%',
right: '-25%',
bottom: 0,
height: '62%',
overflow: 'hidden',
perspective: '280px',
perspectiveOrigin: '50% 0%',
maskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
opacity: reduced ? 0.5 : 0.62,
inset: 0,
maskImage:
'linear-gradient(90deg, #000 0%, #000 12%, rgba(0,0,0,0.3) 34%, rgba(0,0,0,0.3) 66%, #000 88%, #000 100%)',
WebkitMaskImage:
'linear-gradient(90deg, #000 0%, #000 12%, rgba(0,0,0,0.3) 34%, rgba(0,0,0,0.3) 66%, #000 88%, #000 100%)',
opacity: reduced ? 0.4 : 0.46,
contain: 'layout paint style',
}}
>
<div
style={{
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: '200%',
transformOrigin: 'top center',
transform: 'rotateX(74deg)',
backgroundImage: GRID_URI,
backgroundRepeat: 'repeat-y',
backgroundSize: '100% 50%',
filter: 'drop-shadow(0 0 3px oklch(0.55 0.22 320 / 0.6))',
willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`,
left: '-25%',
right: '-25%',
bottom: 0,
height: '62%',
overflow: 'hidden',
perspective: '280px',
perspectiveOrigin: '50% 0%',
maskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
contain: 'layout paint style',
}}
/>
>
<div
style={{
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: '200%',
transformOrigin: 'top center',
transform: 'rotateX(74deg)',
backgroundImage: GRID_URI,
backgroundRepeat: 'repeat-y',
backgroundSize: '100% 50%',
filter: 'drop-shadow(0 0 2px oklch(0.55 0.22 320 / 0.55))',
willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`,
}}
/>
</div>
</div>
{/* 3. Horizon glow + neon horizon line. A soft synthwave sun-bloom sits
@@ -197,7 +226,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
height: '34%',
transform: 'translate(-50%, -50%)',
backgroundImage:
'radial-gradient(60% 100% at 50% 100%, oklch(0.70 0.22 350 / 0.22) 0%, oklch(0.65 0.18 330 / 0.10) 40%, transparent 72%)',
'radial-gradient(60% 100% at 50% 100%, oklch(0.70 0.22 350 / 0.16) 0%, oklch(0.65 0.18 330 / 0.08) 40%, transparent 72%)',
contain: 'layout paint style',
}}
/>
@@ -209,8 +238,10 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
right: '12%',
top: '38%',
height: '1.5px',
background: `linear-gradient(90deg, transparent 0%, ${NEON_CYAN} 25%, oklch(0.92 0.10 320 / 0.95) 50%, ${NEON_CYAN} 75%, transparent 100%)`,
opacity: 0.55,
// Bright at the flanks, dropped out through the centre column so the
// rule frames the timeline rather than underlining a message.
background: `linear-gradient(90deg, transparent 0%, ${NEON_CYAN} 14%, oklch(0.92 0.10 320 / 0.95) 22%, transparent 34%, transparent 66%, oklch(0.92 0.10 320 / 0.95) 78%, ${NEON_CYAN} 86%, transparent 100%)`,
opacity: 0.4,
filter: 'blur(0.4px) drop-shadow(0 0 4px oklch(0.78 0.16 200 / 0.7))',
}}
/>
@@ -273,7 +304,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
inset: 0,
overflow: 'hidden',
mixBlendMode: 'multiply',
opacity: 0.5,
opacity: 0.32,
contain: 'layout paint style',
}}
>
@@ -285,7 +316,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
top: '-8px',
bottom: '-8px',
backgroundImage:
'repeating-linear-gradient(0deg, oklch(0.10 0.04 300 / 0.55) 0px, oklch(0.10 0.04 300 / 0.55) 1px, transparent 1px, transparent 3px)',
'repeating-linear-gradient(0deg, oklch(0.10 0.04 300 / 0.45) 0px, oklch(0.10 0.04 300 / 0.45) 1px, transparent 1px, transparent 4px)',
willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animScanRoll} 6s linear infinite`,
}}
@@ -309,51 +340,54 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
}}
/>
{/* 7a. Glowing "INSERT COIN" attract-mode blip, low-opacity, bottom-center.
Static scene shows it steady (no blink). */}
{/* 7. Attract-mode HUD: a tiny SCORE readout over a glowing "INSERT COIN"
blip, stacked bottom-right. That corner is the one spot that is
clear in every layout (below the members list, or the empty right
end of the read-receipt strip) — top-left collided with the space
bar and bottom-centre sat on the composer. Static scene shows both
steady (no blink). The font-size clamp collapses the text to nothing
when the host is narrower than ~330px, so the 76px settings swatch
never shows clipped glyphs. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
bottom: '5%',
left: '50%',
transform: 'translateX(-50%)',
right: '14px',
bottom: '8px',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: '3px',
fontFamily: '"Courier New", monospace',
fontSize: '12px',
fontSize: HUD_FONT_SIZE,
fontWeight: 700,
letterSpacing: '0.32em',
color: NEON_CYAN,
textShadow: '0 0 6px oklch(0.80 0.15 200 / 0.9), 0 0 14px oklch(0.65 0.25 350 / 0.5)',
lineHeight: 1,
userSelect: 'none',
whiteSpace: 'nowrap',
opacity: reduced ? 0.6 : undefined,
animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
}}
>
INSERT COIN
</div>
{/* 7b. Corner SCORE HUD glyph — a tiny pixel score that blips, top-left,
very low opacity so it reads as ambient chrome, not UI. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '2.5%',
left: '2%',
fontFamily: '"Courier New", monospace',
fontSize: '10px',
fontWeight: 700,
letterSpacing: '0.18em',
color: NEON_MAGENTA,
textShadow: '0 0 6px oklch(0.65 0.25 350 / 0.8)',
userSelect: 'none',
whiteSpace: 'nowrap',
opacity: reduced ? 0.5 : undefined,
animation: reduced ? 'none' : `${animScoreBlip} 2.4s ease-in-out infinite`,
}}
>
1UP 00<span style={{ color: NEON_CYAN }}>0000</span>
<div
style={{
letterSpacing: '0.18em',
color: NEON_MAGENTA,
textShadow: '0 0 6px oklch(0.65 0.25 350 / 0.8)',
opacity: reduced ? 0.5 : undefined,
animation: reduced ? 'none' : `${animScoreBlip} 2.4s ease-in-out infinite`,
}}
>
1UP 00<span style={{ color: NEON_CYAN }}>0000</span>
</div>
<div
style={{
letterSpacing: '0.32em',
color: NEON_CYAN,
textShadow: '0 0 6px oklch(0.80 0.15 200 / 0.9), 0 0 14px oklch(0.65 0.25 350 / 0.5)',
opacity: reduced ? 0.6 : undefined,
animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
}}
>
INSERT COIN
</div>
</div>
{/* 8. CRT vignette + screen-glow. A radial darkening frames the corners,
@@ -97,8 +97,8 @@ function makeStars(count: number, seedBase: number): Star[] {
export function DeepSpaceOverlay({ reduced }: SeasonalOverlayProps) {
// Two parallax depths. Far = dense + faint, Near = sparser + slightly larger.
const farStars = useMemo<Star[]>(() => makeStars(16, 1000), []);
const nearStars = useMemo<Star[]>(() => makeStars(12, 2000), []);
const farStars = useMemo<Star[]>(() => makeStars(40, 1000), []);
const nearStars = useMemo<Star[]>(() => makeStars(22, 2000), []);
const heroStars = useMemo<HeroStar[]>(
() =>
@@ -144,7 +144,7 @@ export function DeepSpaceOverlay({ reduced }: SeasonalOverlayProps) {
position: 'absolute',
inset: '-6%',
contain: 'layout paint style',
backgroundColor: 'oklch(0.2 0.12 300 / 0.16)',
backgroundColor: 'oklch(0.2 0.12 300 / 0.12)',
backgroundImage: [
'radial-gradient(120% 90% at 50% -8%, oklch(0.28 0.13 295 / 0.2) 0%, transparent 60%)',
'radial-gradient(100% 80% at 12% 18%, oklch(0.55 0.2 330 / 0.1) 0%, transparent 55%)',
@@ -164,7 +164,7 @@ export function HalloweenOverlay({ reduced }: SeasonalOverlayProps) {
height: `${f.height}px`,
backgroundImage: `radial-gradient(60% 100% at 50% 100%, ${FOG_TINT} 0%, transparent 75%)`,
filter: 'blur(14px)',
willChange: 'transform, opacity',
willChange: reduced ? undefined : 'transform, opacity',
opacity: reduced ? 0.5 : undefined,
transform: reduced ? 'translate3d(2%, 0, 0) scale(1.18)' : undefined,
animation: reduced
@@ -73,6 +73,32 @@ const formatClipSeconds = (seconds: number): string => {
return `${m}:${s.toString().padStart(2, '0')}`;
};
/**
* [Gitea #31] Pure running-count cap check for `handleFiles`: given how many
* clips already exist (staged uploads included) before this batch starts,
* decide which of the batch's files fit under `max`. Kept pure/exported so the
* "running count, not a stale double-counted closure value" logic can be unit
* tested without a DOM/MatrixClient.
*/
export function acceptClips<T>(
currentCount: number,
files: readonly T[],
max: number,
): { accepted: T[]; rejected: T[] } {
const accepted: T[] = [];
const rejected: T[] = [];
let count = currentCount;
files.forEach((file) => {
if (count >= max) {
rejected.push(file);
} else {
accepted.push(file);
count += 1;
}
});
return { accepted, rejected };
}
type ClipDraft = {
url: string;
body: string;
@@ -186,11 +212,19 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
...existing.map((c) => c.shortcode),
...uploads.map((u) => u.shortcode),
]);
for (let i = 0; i < files.length; i += 1) {
const file = files[i];
if (clipCount + uploads.length >= SOUNDBOARD_MAX_CLIPS) {
throw new Error(`Soundboard is full (max ${SOUNDBOARD_MAX_CLIPS} clips).`);
}
// [Gitea #31] `clipCount` already includes staged `uploads`, so don't
// add `uploads.length` again here (double-counting). And since
// `setUploads` inside the loop doesn't update this closure's
// `clipCount`, track the running total in a local variable that starts
// from the real current total instead of re-reading a stale value for
// every file in the batch.
const { accepted, rejected } = acceptClips(
clipCount,
Array.from(files),
SOUNDBOARD_MAX_CLIPS,
);
for (let i = 0; i < accepted.length; i += 1) {
const file = accepted[i];
if (file.size > SOUNDBOARD_MAX_CLIP_BYTES) {
throw new Error(`"${file.name}" is too large (max 1 MB).`);
}
@@ -215,6 +249,9 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
},
]);
}
if (rejected.length > 0) {
throw new Error(`Soundboard is full (max ${SOUNDBOARD_MAX_CLIPS} clips).`);
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Upload failed.');
} finally {
@@ -214,24 +214,56 @@ function UserPrivateNotes({ userId }: { userId: string }) {
const [draft, setDraft] = useState(() => getNote(userId));
const [saving, setSaving] = useState(false);
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// True while the user has unsaved local edits — prevents the store-sync
// effect below from reacting to the echo of our own save and reverting text
// typed after the debounce fired but before that save's account-data echo
// landed (mirrors statusDirtyRef in Profile.tsx's ProfileStatus).
const dirtyRef = useRef(false);
// Latest draft/userId, kept current on every render so the unmount cleanup
// can flush a pending save without capturing a stale closure.
const draftRef = useRef(draft);
draftRef.current = draft;
const userIdRef = useRef(userId);
userIdRef.current = userId;
const setNoteRef = useRef(setNote);
setNoteRef.current = setNote;
const prevUserIdRef = useRef(userId);
// Sync if account data arrives after mount
// Sync if account data arrives after mount, but never while there are
// unsaved local edits (including our own save's in-flight echo).
useEffect(() => {
if (prevUserIdRef.current !== userId) {
prevUserIdRef.current = userId;
dirtyRef.current = false;
}
if (dirtyRef.current) return;
setDraft(getNote(userId));
}, [getNote, userId]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value;
dirtyRef.current = true;
setDraft(val);
clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(async () => {
dirtyRef.current = false;
setSaving(true);
await setNote(userId, val);
setSaving(false);
}, 800);
};
useEffect(() => () => clearTimeout(saveTimer.current), []);
useEffect(
() => () => {
clearTimeout(saveTimer.current);
// Flush a still-pending debounced save instead of dropping it (e.g. the
// profile panel closes within the 800ms debounce window).
if (dirtyRef.current) {
setNoteRef.current(userIdRef.current, draftRef.current);
}
},
[],
);
const charsLeft = USER_NOTE_MAX_LENGTH - draft.length;
+22 -11
View File
@@ -103,7 +103,8 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview, senderName }: Bookm
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = mx.getRoom(bookmark.roomId) ?? undefined;
const displayRoomName = room?.name ?? bookmark.roomName;
// E2EE-room bookmarks store no roomName; fall back past the '' placeholder.
const displayRoomName = room?.name || bookmark.roomName || 'Unknown room';
const avatarUrl = room
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
: undefined;
@@ -162,7 +163,7 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview, senderName }: Bookm
style={{ justifyContent: 'flex-start', height: 'unset', padding: config.space.S200 }}
>
<Text className={css.BookmarkPreview} size="T200" priority="400">
{preview ?? (bookmark.previewText || '(no preview)')}
{preview ?? (bookmark.previewText || 'Message unavailable')}
</Text>
</Button>
</Box>
@@ -173,13 +174,16 @@ type LiveBookmarkItemProps = BookmarkItemProps & { room: Room };
// Renders the same layout as BookmarkItem, but resolves the message body live so
// edits (m.replace, applied by useRoomEvent) and redactions are reflected. The
// stored snapshot (previewText) remains the fallback for loading/failed/empty states.
// stored snapshot (previewText) remains the fallback for loading/failed/empty
// states; bookmarks from E2EE rooms have no snapshot at all (account data is
// server-readable), so the live event is their only source of text.
function LiveBookmarkItem({ room, bookmark, onJump, onRemove }: LiveBookmarkItemProps) {
const liveEvent = useRoomEvent(room, bookmark.eventId, () =>
room.findEventById(bookmark.eventId),
);
const snapshot = bookmark.previewText || '(no preview)';
const snapshot =
bookmark.previewText || (liveEvent === undefined ? 'Loading…' : 'Message unavailable');
let preview: ReactNode = snapshot;
// undefined (loading) and null (fetch failed / not found) both keep the snapshot.
@@ -234,7 +238,7 @@ function RoomGroupHeader({
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = mx.getRoom(roomId) ?? undefined;
const displayRoomName = room?.name ?? roomName;
const displayRoomName = room?.name || roomName || 'Unknown room';
const avatarUrl = room
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
: undefined;
@@ -327,13 +331,20 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
() =>
query.length === 0
? bookmarks
: bookmarks.filter(
(bk) =>
bk.previewText.toLowerCase().includes(query) ||
: bookmarks.filter((bk) => {
// E2EE-room bookmarks have no stored text: match against the locally
// cached event body / live room name instead (nothing is fetched here).
const room = mx.getRoom(bk.roomId);
const localBody = room?.findEventById(bk.eventId)?.getContent()?.body;
return (
(bk.previewText?.toLowerCase().includes(query) ?? false) ||
(typeof localBody === 'string' && localBody.toLowerCase().includes(query)) ||
bk.roomName.toLowerCase().includes(query) ||
(bk.senderName?.toLowerCase().includes(query) ?? false),
),
[bookmarks, query],
(room?.name.toLowerCase().includes(query) ?? false) ||
(bk.senderName?.toLowerCase().includes(query) ?? false)
);
}),
[mx, bookmarks, query],
);
// Prune collapsed roomIds that no longer have any bookmark, so a room re-saved
+100 -7
View File
@@ -1,11 +1,25 @@
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
import React, { useCallback } from 'react';
import {
Box,
Button,
Chip,
color,
config,
Icon,
IconButton,
Icons,
Spinner,
Text,
Tooltip,
TooltipProvider,
} from 'folds';
import React, { useCallback, useEffect, useState } from 'react';
import { useSetAtom } from 'jotai';
import { StatusDivider } from './components';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { callEmbedAtom } from '../../state/callEmbed';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
type MicrophoneButtonProps = {
enabled: boolean;
@@ -177,6 +191,23 @@ export function CallControl({
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
const setCallEmbed = useSetAtom(callEmbedAtom);
// [Gitea #26] Apply the same room-level camera/screenshare policy as the
// in-room CallControls bar, so the status bar can't be used to bypass it.
const { allowCamera, allowScreenshare } = useRoomCallPolicy(callEmbed.room);
// Keep a forbidden control visible while its track is still live (so the user
// can stop it); otherwise hide it entirely.
const showCamera = allowCamera || video;
const showScreenshare = allowScreenshare || screenshare;
const [shareConfirm, setShareConfirm] = useState(false);
useEffect(() => {
if (!shareConfirm) return undefined;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setShareConfirm(false);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [shareConfirm]);
const handleMicrophoneToggle = useCallback(
() => callEmbed.control.toggleMicrophone(),
[callEmbed],
@@ -198,7 +229,65 @@ export function CallControl({
};
return (
<Box shrink="No" alignItems="Center" gap="300">
<Box shrink="No" alignItems="Center" gap="300" style={{ position: 'relative' }}>
{shareConfirm && (
<>
<div
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
onClick={() => setShareConfirm(false)}
aria-hidden="true"
/>
<Box
style={{
position: 'absolute',
bottom: '110%',
left: 0,
background: color.Surface.Container,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
borderRadius: '0.75rem',
padding: '1rem 1.25rem',
zIndex: 100,
minWidth: '260px',
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
}}
>
<Text size="T300" style={{ fontWeight: 600 }}>
Share your screen?
</Text>
<Text size="T200" style={{ opacity: 0.75 }}>
Your screen will be visible to all participants in this call.
</Text>
<Box gap="200">
<Button
size="300"
variant="Success"
fill="Solid"
radii="300"
onClick={() => {
callEmbed.control.toggleScreenshare();
setShareConfirm(false);
}}
>
<Text size="B300">Share</Text>
</Button>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
outlined
onClick={() => setShareConfirm(false)}
>
<Text size="B300">Cancel</Text>
</Button>
</Box>
</Box>
</>
)}
<Box alignItems="Inherit" gap="200">
<MicrophoneButton
enabled={microphone}
@@ -210,12 +299,16 @@ export function CallControl({
onToggle={() => callEmbed.control.toggleSound()}
disabled={!callJoined}
/>
{!compact && <StatusDivider />}
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
{!compact && (
{!compact && (showCamera || showScreenshare) && <StatusDivider />}
{showCamera && (
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
)}
{!compact && showScreenshare && (
<ScreenShareButton
enabled={screenshare}
onToggle={() => callEmbed.control.toggleScreenshare()}
onToggle={() =>
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
disabled={!callJoined}
/>
)}
+7 -135
View File
@@ -1,5 +1,5 @@
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
import { useSetAtom } from 'jotai';
import { useAtomValue, useSetAtom } from 'jotai';
import {
Box,
Button,
@@ -39,7 +39,7 @@ import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
import { stopPropagation } from '../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
import { useAfkAutoMute } from '../../hooks/useAfkAutoMute';
import { pttActiveAtom } from '../../hooks/useCallHotkeys';
import { CallSoundboard } from './CallSoundboard';
import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
@@ -88,8 +88,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
useCallControlState(callEmbed.control);
useAfkAutoMute(callEmbed);
const [cords, setCords] = useState<RectCords>();
const [shareConfirm, setShareConfirm] = useState(false);
useEffect(() => {
@@ -102,8 +100,12 @@ export function CallControls({ callEmbed }: CallControlsProps) {
}, [shareConfirm]);
const [pttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey] = useSetting(settingsAtom, 'pttKey');
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
const [soundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
// [Gitea #9] PTT/deafen key handling and AFK auto-mute live in useCallHotkeys
// / useAfkAutoMute, mounted from CallEmbedProvider for the embed's lifetime
// (this component only renders while the call room is selected). Only the
// visual PTT chip remains here.
const pttActive = useAtomValue(pttActiveAtom);
// [P5-31] Hard room publish policy — hide controls the server will refuse so
// users don't click dead buttons. Absent/true = allowed.
@@ -116,28 +118,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const showCamera = cameraAllowed || video;
const showScreenshare = screenshareAllowed || screenshare;
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
const [pttActive, setPttActive] = useState(false);
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
const microphoneRef = useRef(microphone);
useEffect(() => {
microphoneRef.current = microphone;
}, [microphone]);
// Handle PTT mode toggle mid-call — save/restore mic state (I-4)
const pttModeRef = useRef(pttMode);
const micBeforePTTRef = useRef<boolean | null>(null);
useEffect(() => {
if (pttMode && !pttModeRef.current) {
micBeforePTTRef.current = microphoneRef.current;
callEmbed.control.setMicrophone(false);
} else if (!pttMode && pttModeRef.current) {
callEmbed.control.setMicrophone(micBeforePTTRef.current ?? true);
micBeforePTTRef.current = null;
}
pttModeRef.current = pttMode;
}, [pttMode, callEmbed]);
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
@@ -163,114 +143,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
);
const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]);
const pttActiveRef = useRef(false);
useEffect(() => {
if (!pttMode) return;
const iframeWindow = callEmbed.iframe.contentWindow;
const onKeyDown = (e: KeyboardEvent) => {
if (e.code !== pttKey || e.repeat) return;
const target = e.target as HTMLElement;
// BUG-7: use ownerDocument.body so isEditable works inside the EC iframe
const isEditable = (el: HTMLElement): boolean => {
const tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
let node: HTMLElement | null = el;
while (node && node !== el.ownerDocument.body) {
if (node.contentEditable === 'true') return true;
if (node.contentEditable === 'false') return false;
node = node.parentElement;
}
return false;
};
if (isEditable(target)) return;
e.preventDefault();
// C-M5: mark PTT active BEFORE unmuting so the mic echo (onMediaState)
// doesn't treat this transient unmute as a user-initiated undeafen.
callEmbed.control.pttActive = true;
if (!microphoneRef.current) callEmbed.control.setMicrophone(true);
pttActiveRef.current = true;
setPttActive(true);
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code !== pttKey) return;
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false);
pttActiveRef.current = false;
setPttActive(false);
};
const onBlur = () => {
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false);
pttActiveRef.current = false;
setPttActive(false);
};
const onFocus = () => {
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false);
pttActiveRef.current = false;
setPttActive(false);
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
window.addEventListener('focus', onFocus);
// BUG-9: also wire iframe blur/focus so stuck-mic release works when focus moves to iframe
iframeWindow?.addEventListener('keydown', onKeyDown);
iframeWindow?.addEventListener('keyup', onKeyUp);
iframeWindow?.addEventListener('blur', onBlur);
iframeWindow?.addEventListener('focus', onFocus);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
window.removeEventListener('focus', onFocus);
iframeWindow?.removeEventListener('keydown', onKeyDown);
iframeWindow?.removeEventListener('keyup', onKeyUp);
iframeWindow?.removeEventListener('blur', onBlur);
iframeWindow?.removeEventListener('focus', onFocus);
// BUG-8: if callEmbed changes while PTT is active, release mic on cleanup
if (pttActiveRef.current) {
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false);
pttActiveRef.current = false;
setPttActive(false);
}
};
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
}, [pttMode, pttKey, callEmbed]);
useEffect(() => {
const isEditable = (el: HTMLElement): boolean => {
const tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
let node: HTMLElement | null = el;
while (node && node !== el.ownerDocument.body) {
if (node.contentEditable === 'true') return true;
if (node.contentEditable === 'false') return false;
node = node.parentElement;
}
return false;
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.code !== deafenKey) return;
if (e.repeat) return;
if (isEditable(e.target as HTMLElement)) return;
e.preventDefault();
callEmbed.control.toggleSound();
};
// C-L4: also bind the EC iframe window so the deafen key works when focus is
// inside the iframe (mirrors the PTT binding above).
const iframeWindow = callEmbed.iframe.contentWindow;
window.addEventListener('keydown', onKeyDown);
iframeWindow?.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('keydown', onKeyDown);
iframeWindow?.removeEventListener('keydown', onKeyDown);
};
}, [callEmbed, deafenKey]);
const [hangupState, hangup] = useAsyncCallback(
useCallback(() => callEmbed.hangup(), [callEmbed]),
);
+5 -9
View File
@@ -18,8 +18,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { StateEvent } from '../../../types/matrix/room';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { LotusDecorationPusher } from '../lotus/LotusDecorationPusher';
import { useStateEvent } from '../../hooks/useStateEvent';
import { VoiceLimitContent } from '../common-settings/general/RoomVoiceLimit';
import { useVoiceChannelFull } from '../../hooks/useVoiceChannelFull';
import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css';
import { CallControls } from './CallControls';
@@ -114,12 +113,9 @@ function CallPrescreen() {
const callEmbed = useCallEmbed();
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
// Voice channel user limit (io.lotus.voice_limit). 0 / absent means no limit.
const limitEvent = useStateEvent(room, StateEvent.LotusVoiceLimit);
const maxUsers = limitEvent?.getContent<VoiceLimitContent>().max_users ?? 0;
// A user already counted in the session is rejoining and should not be blocked.
const alreadyMember = callMembers.some((m) => m.sender === mx.getSafeUserId());
const channelFull = maxUsers > 0 && !alreadyMember && callMembers.length >= maxUsers;
// [Gitea #30] Voice channel user limit (io.lotus.voice_limit), shared with the
// room-nav join path via useVoiceChannelFull so both agree on "full".
const { channelFull, current: callMembersCount, max: maxUsers } = useVoiceChannelFull(room);
const canJoin = hasPermission && livekitSupported && rtcSupported && !channelFull;
@@ -144,7 +140,7 @@ function CallPrescreen() {
<Box className={css.PrescreenMessage} alignItems="Center">
{!inOtherCall && !hasPermission && <NoPermissionMessage />}
{!inOtherCall && hasPermission && channelFull && (
<ChannelFullMessage current={callMembers.length} max={maxUsers} />
<ChannelFullMessage current={callMembersCount} max={maxUsers} />
)}
{!inOtherCall && hasPermission && !channelFull && (
<JoinMessage
+2 -1
View File
@@ -74,7 +74,8 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
size="400"
className={MobileTouchTarget}
onClick={() => onToggle()}
aria-label={enabled ? 'Undeafen' : 'Deafen'}
aria-label={enabled ? 'Deafen' : 'Undeafen'}
aria-pressed={enabled}
outlined
>
<Icon
@@ -58,6 +58,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
const submitAccountData: AccountDataSubmitCallback = useCallback(
async (type, content) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await mx.setRoomAccountData(room.roomId, type as any, content);
},
[mx, room.roomId],
@@ -55,6 +55,7 @@ export function RoomQuality({ permissions }: RoomQualityProps) {
const [submitState, submit] = useAsyncCallback(
useCallback(
async (next: RoomQualityContent) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await sendStateEvent(mx, room.roomId, StateEvent.LotusRoomQuality, next);
},
[mx, room.roomId],
@@ -31,6 +31,7 @@ export function RoomRetention({ permissions }: RoomRetentionProps) {
const content: RetentionContent = ms > 0 ? { max_lifetime: ms } : {};
// Lotus custom-state convention: cast the type key (RoomRetention isn't a
// typed key in the SDK's StateEvents map).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await sendStateEvent(mx, room.roomId, StateEvent.RoomRetention, content);
},
[mx, room.roomId],
@@ -5,6 +5,7 @@ import {
DECORATION_CATEGORIES,
ALL_DECORATIONS,
decorationUrl,
isValidDecorationSlug,
} from './avatarDecorations';
test('decorationUrl builds a CDN png url from the slug', () => {
@@ -66,3 +67,20 @@ test('slugs use the snake_case charset (lowercase, digits, underscore)', () => {
assert.match(decoration.slug, /^[a-z0-9_]+$/, `bad slug: ${decoration.slug}`);
});
});
test('isValidDecorationSlug: accepts a real catalog slug', () => {
assert.equal(isValidDecorationSlug('joystick'), true);
assert.equal(isValidDecorationSlug('lotus_flower'), true);
});
test('isValidDecorationSlug: rejects a path-traversal string', () => {
assert.equal(isValidDecorationSlug('../../anything'), false);
});
test('isValidDecorationSlug: rejects a slug carrying a query string', () => {
assert.equal(isValidDecorationSlug('joystick?u=probe'), false);
});
test('isValidDecorationSlug: rejects an empty string', () => {
assert.equal(isValidDecorationSlug(''), false);
});
@@ -188,6 +188,19 @@ export const ALL_DECORATIONS: AvatarDecoration[] = DECORATION_CATEGORIES.flatMap
(c) => c.decorations,
);
const DECORATION_SLUGS = new Set(ALL_DECORATIONS.map((d) => d.slug));
/**
* Whether `slug` is a known catalog decoration. `io.lotus.avatar_decoration`
* is a free-form MSC4133 profile field set by a remote user (and their
* homeserver), and its value is interpolated verbatim into `decorationUrl`
* — so anything not in the catalog (path traversal, a query string, an
* oversized value) must be rejected before it reaches a URL.
*/
export function isValidDecorationSlug(slug: string): boolean {
return DECORATION_SLUGS.has(slug);
}
export function decorationUrl(slug: string): string {
return `${RESOLVED_DECORATION_CDN}/${slug}.png`;
}
@@ -36,6 +36,7 @@ import { mDirectAtom } from '../../state/mDirectList';
import { getStateEvent } from '../../utils/room';
import { StateEvent } from '../../../types/matrix/room';
import {
filterGroupsByDateRange,
filterGroupsByMsgType,
filterGroupsByPinned,
MessageSearchParams,
@@ -316,12 +317,21 @@ export function MessageSearch({
getNextPageParam: (lastPage) => lastPage.nextToken,
});
// Shared client-side post-filter (msgtype + pinned) applied to BOTH the
// server results and the local/encrypted-cache results, so the filter chips
// narrow the whole UI consistently rather than only the server section.
// Shared client-side post-filter (date range + msgtype + pinned) applied to
// BOTH the server results and the local/encrypted-cache results, so the
// filter chips narrow the whole UI consistently rather than only the
// server section. The date range must be enforced here because the Matrix
// search API has no timestamp filter fields (see useMessageSearch.ts); the
// local/encrypted path already filters in-range before this runs, so this
// is a no-op there and only actually trims the server section.
const applyResultFilters = useCallback(
(allGroups: ResultGroup[]): ResultGroup[] => {
const byMsgType = filterGroupsByMsgType(allGroups, msgTypeFilters);
const inDateRange = filterGroupsByDateRange(
allGroups,
msgSearchParams.fromTs,
msgSearchParams.toTs,
);
const byMsgType = filterGroupsByMsgType(inDateRange, msgTypeFilters);
if (!pinnedOnly) return byMsgType;
// Build a per-room pinned-event lookup. Heavy Matrix reads stay here
// (where `mx` is available); the pure helper only consumes the predicate.
@@ -343,7 +353,7 @@ export function MessageSearch({
};
return filterGroupsByPinned(byMsgType, pinnedOnly, isPinned);
},
[msgTypeFilters, pinnedOnly, mx],
[msgSearchParams.fromTs, msgSearchParams.toTs, msgTypeFilters, pinnedOnly, mx],
);
const groups = useMemo(() => {
@@ -0,0 +1,56 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { SearchCacheRow } from '../../utils/searchCache';
// useLocalMessageSearch.ts imports searchCacheEnabledAtom, which touches
// localStorage at module-load time (atomWithLocalStorage reads the initial
// value eagerly). Stub it before a dynamic import — a static import would
// hoist above the stub. Same pattern as state/plaintextCaches.test.ts.
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
};
const { rowToResultItem } = await import('./useLocalMessageSearch');
const row = (overrides: Partial<SearchCacheRow> = {}): SearchCacheRow => ({
roomId: '!r1',
eventId: '$1',
ts: 100,
sender: '@a',
body: 'hello world',
...overrides,
});
// Gitea #14 — cached rows for a locally-known-redacted event must carry a
// `redacted_because` marker so SearchResultGroup's guard renders the
// "message deleted" placeholder instead of the stale plaintext.
test('rowToResultItem: plain row has no redacted_because marker', () => {
const item = rowToResultItem(row());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assert.equal((item.event as any).unsigned?.redacted_because, undefined);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assert.equal((item.event as any).content.body, 'hello world');
});
test('rowToResultItem: redacted=true sets the redacted_because marker', () => {
const item = rowToResultItem(row(), true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assert.ok((item.event as any).unsigned?.redacted_because);
});
test('rowToResultItem: falls back to pollText when body is empty', () => {
const item = rowToResultItem(row({ body: '', pollText: 'question answer' }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assert.equal((item.event as any).content.body, 'question answer');
});
test('rowToResultItem: carries formattedBody as HTML when present', () => {
const item = rowToResultItem(row({ formattedBody: '<b>hi</b>' }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const content = (item.event as any).content;
assert.equal(content.format, 'org.matrix.custom.html');
assert.equal(content.formatted_body, '<b>hi</b>');
});
@@ -1,4 +1,4 @@
import { EventType, MatrixEvent } from 'matrix-js-sdk';
import { EventType, MatrixEvent, RelationType } from 'matrix-js-sdk';
import { useCallback } from 'react';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -38,14 +38,17 @@ type ExtractedText = {
const POLL_START_TYPES = ['m.poll.start', 'org.matrix.msc3381.poll.start'];
/**
* Pull the text we index/search from a decrypted event's content. Returns
* `null` for events that carry no searchable text (e.g. stickers).
* Pull the text we index/search from an event type + content pair. Returns
* `null` when there's no searchable text (e.g. stickers). Split out from
* `extractText` so an edit's `m.new_content` can be run through the same
* logic as a normal event's content.
*/
const extractText = (event: MatrixEvent): ExtractedText | null => {
const evType = event.getType();
const content = event.getContent();
const extractTextFromContent = (
evType: string,
content: Record<string, unknown>,
): ExtractedText | null => {
if (POLL_START_TYPES.includes(evType)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const poll = (content['m.poll'] ?? content['org.matrix.msc3381.poll.start']) as any;
if (!poll) return null;
const qBody =
@@ -56,6 +59,7 @@ const extractText = (event: MatrixEvent): ExtractedText | null => {
.map(
(a) =>
((a['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(a['org.matrix.msc3381.poll.answer'] as any)?.body ??
'') as string,
)
@@ -72,6 +76,13 @@ const extractText = (event: MatrixEvent): ExtractedText | null => {
return { body, formattedBody, pollText: '' };
};
/**
* Pull the text we index/search from a decrypted event's content. Returns
* `null` for events that carry no searchable text (e.g. stickers).
*/
const extractText = (event: MatrixEvent): ExtractedText | null =>
extractTextFromContent(event.getType(), event.getContent());
/** Does the extracted text contain the (already-lowercased) term? */
const matchesTerm = (text: ExtractedText, termLower: string): boolean =>
text.body.toLowerCase().includes(termLower) ||
@@ -83,8 +94,17 @@ const rowMatchesTerm = (row: SearchCacheRow, termLower: string): boolean =>
(row.formattedBody ?? '').toLowerCase().includes(termLower) ||
(row.pollText ?? '').toLowerCase().includes(termLower);
/** Build the synthetic result item a cached row renders as (text message). */
const rowToResultItem = (row: SearchCacheRow): ResultItem => {
/**
* Build the synthetic result item a cached row renders as (text message).
*
* `redacted` marks a row whose event we can tell, from the local timeline,
* has since been redacted (the async cache-delete listener in
* `searchCacheInvalidation.ts` may not have caught up yet). It carries a
* `redacted_because` marker on `unsigned` so `SearchResultGroup`'s existing
* guard renders the "message deleted" placeholder instead of the stale
* plaintext (Gitea #14).
*/
export const rowToResultItem = (row: SearchCacheRow, redacted = false): ResultItem => {
const bodyText = row.body || row.pollText || '';
const content: Record<string, unknown> = { msgtype: 'm.text', body: bodyText };
if (row.formattedBody) {
@@ -98,10 +118,11 @@ const rowToResultItem = (row: SearchCacheRow): ResultItem => {
sender: row.sender,
origin_server_ts: row.ts,
content,
unsigned: {},
unsigned: redacted ? { redacted_because: { content: {} } } : {},
};
return {
rank: 0,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event: syntheticEvent as any,
context: { events_before: [], events_after: [], profile_info: {} },
};
@@ -193,16 +214,43 @@ export const useLocalMessageSearch = () => {
// Persist every indexable (text-bearing) event we scanned, regardless
// of whether it matches the current term — future searches benefit.
if (cacheEnabled && text && event.getId()) {
rowsToPersist.push({
roomId,
eventId: event.getId() as string,
ts,
sender,
body: text.body,
...(text.formattedBody ? { formattedBody: text.formattedBody } : {}),
...(text.pollText ? { pollText: text.pollText } : {}),
});
if (cacheEnabled && event.getId()) {
// An edit (`m.replace`) event's own body is just a "* new text"
// fallback. Indexing it under its own event id would leave two
// separate matching rows (the stale pre-edit text and the edit
// fallback) searchable forever. Instead, upsert the *original*
// event's row with the edit's `m.new_content` (Gitea #14).
const editTargetId =
event.getRelation()?.rel_type === RelationType.Replace
? event.getRelation()?.event_id
: undefined;
if (editTargetId) {
const newContent = (event.getContent()['m.new_content'] ?? {}) as Record<
string,
unknown
>;
const editedText = extractTextFromContent(EventType.RoomMessage, newContent);
if (editedText) {
rowsToPersist.push({
roomId,
eventId: editTargetId,
ts: room.findEventById(editTargetId)?.getTs() ?? ts,
sender,
body: editedText.body,
...(editedText.formattedBody ? { formattedBody: editedText.formattedBody } : {}),
});
}
} else if (text) {
rowsToPersist.push({
roomId,
eventId: event.getId() as string,
ts,
sender,
body: text.body,
...(text.formattedBody ? { formattedBody: text.formattedBody } : {}),
...(text.pollText ? { pollText: text.pollText } : {}),
});
}
}
if (senderSet && !senderSet.has(sender)) continue;
@@ -224,6 +272,7 @@ export const useLocalMessageSearch = () => {
};
memoryItems.push({
rank: 0,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event: syntheticEvent as any,
context: { events_before: [], events_after: [], profile_info: {} },
});
@@ -235,7 +284,12 @@ export const useLocalMessageSearch = () => {
if (senderSet && !senderSet.has(row.sender)) return;
if (!inRange(row.ts)) return;
if (!senderOnlyMode && !rowMatchesTerm(row, termLower)) return;
cachedItems.push(rowToResultItem(row));
// The cache-delete listener (searchCacheInvalidation.ts) removes a
// row on redaction asynchronously; if the event is still around
// locally we can check for certain and must not surface stale
// plaintext in the meantime (Gitea #14).
const localEvent = room.findEventById(row.eventId);
cachedItems.push(rowToResultItem(row, localEvent?.isRedacted()));
});
const items = mergeSearchResults(memoryItems, cachedItems);
@@ -1,6 +1,11 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { filterGroupsByMsgType, filterGroupsByPinned, ResultGroup } from './useMessageSearch';
import {
filterGroupsByDateRange,
filterGroupsByMsgType,
filterGroupsByPinned,
ResultGroup,
} from './useMessageSearch';
// Minimal ResultGroup/ResultItem fixtures — only the fields the filters read
// (event.content.msgtype, event.event_id, group.roomId).
@@ -9,6 +14,11 @@ const item = (msgtype: string | undefined, eventId: string) => ({
event: { event_id: eventId, content: msgtype === undefined ? {} : { msgtype } },
context: {},
});
const tsItem = (eventId: string, ts: number) => ({
rank: 1,
event: { event_id: eventId, origin_server_ts: ts, content: {} },
context: {},
});
const mkGroups = (
...groups: { roomId: string; items: ReturnType<typeof item>[] }[]
): ResultGroup[] => groups as unknown as ResultGroup[];
@@ -48,6 +58,33 @@ test('filterGroupsByMsgType: ignores items with a non-string msgtype', () => {
assert.equal(out[0].items[0].event.event_id, '$2');
});
test('filterGroupsByDateRange: no bounds returns groups unchanged', () => {
const groups = mkGroups({ roomId: '!r1', items: [tsItem('$1', 100)] });
assert.equal(filterGroupsByDateRange(groups, undefined, undefined), groups);
});
test('filterGroupsByDateRange: keeps only items within an inclusive range', () => {
const groups = mkGroups({
roomId: '!r1',
items: [tsItem('$1', 50), tsItem('$2', 100), tsItem('$3', 150), tsItem('$4', 200)],
});
const out = filterGroupsByDateRange(groups, 100, 150);
assert.deepEqual(
out[0].items.map((i) => i.event.event_id),
['$2', '$3'],
);
});
test('filterGroupsByDateRange: drops groups left empty and supports one-sided bounds', () => {
const groups = mkGroups(
{ roomId: '!r1', items: [tsItem('$1', 50)] },
{ roomId: '!r2', items: [tsItem('$2', 500)] },
);
const out = filterGroupsByDateRange(groups, 100, undefined);
assert.equal(out.length, 1);
assert.equal(out[0].roomId, '!r2');
});
test('filterGroupsByPinned: disabled returns groups unchanged', () => {
const groups = mkGroups({ roomId: '!r1', items: [item('m.text', '$1')] });
assert.equal(
@@ -71,6 +71,31 @@ export const filterGroupsByPinned = (
.filter((group) => group.items.length > 0);
};
/** Inclusive-range predicate, mirrored from `inRange` in useLocalMessageSearch.ts. */
export const inTsRange = (ts: number, fromTs?: number, toTs?: number): boolean =>
(fromTs === undefined || ts >= fromTs) && (toTs === undefined || ts <= toTs);
/**
* Filter result groups to items whose `origin_server_ts` falls within
* [fromTs, toTs] (inclusive, either bound optional). The Matrix search API
* has no timestamp filter fields, so server results must be post-filtered
* here — the same predicate the local/encrypted search already applies.
* Now-empty groups are dropped.
*/
export const filterGroupsByDateRange = (
groups: ResultGroup[],
fromTs?: number,
toTs?: number,
): ResultGroup[] => {
if (fromTs === undefined && toTs === undefined) return groups;
return groups
.map((group) => ({
...group,
items: group.items.filter((item) => inTsRange(item.event.origin_server_ts, fromTs, toTs)),
}))
.filter((group) => group.items.length > 0);
};
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
const groups: ResultGroup[] = [];
@@ -119,7 +144,9 @@ export type MessageSearchParams = {
};
export const useMessageSearch = (params: MessageSearchParams) => {
const mx = useMatrixClient();
const { term, order, rooms, senders, fromTs, toTs, containsUrl } = params;
// fromTs/toTs are intentionally not sent to the server (see comment below) —
// callers post-filter results with filterGroupsByDateRange instead.
const { term, order, rooms, senders, containsUrl } = params;
const searchMessages = useCallback(
async (nextBatch?: string) => {
@@ -142,10 +169,12 @@ export const useMessageSearch = (params: MessageSearchParams) => {
limit,
rooms,
senders,
// from_ts / to_ts and contains_url are valid Matrix spec fields not yet in SDK types
...(fromTs !== undefined && { from_ts: fromTs }),
...(toTs !== undefined && { to_ts: toTs }),
// `RoomEventFilter` has no timestamp bounds — from_ts/to_ts are not
// Matrix filter fields and the homeserver silently drops them, so the
// date range is instead enforced client-side (see filterGroupsByDateRange).
// contains_url is a valid spec field not yet in SDK types.
...(containsUrl !== undefined && { contains_url: containsUrl }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
include_state: false,
order_by: order as SearchOrderBy.Recent,
@@ -160,7 +189,7 @@ export const useMessageSearch = (params: MessageSearchParams) => {
});
return parseSearchResult(r);
},
[mx, term, order, rooms, senders, fromTs, toTs, containsUrl],
[mx, term, order, rooms, senders, containsUrl],
);
return searchMessages;
+31 -60
View File
@@ -6,7 +6,7 @@ import React, {
useRef,
useState,
} from 'react';
import { MatrixClient, Room } from 'matrix-js-sdk';
import { Room } from 'matrix-js-sdk';
import {
Avatar,
Box,
@@ -42,7 +42,6 @@ import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../componen
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, getStateEvent } from '../../utils/room';
import { setAccountData } from '../../utils/accountData';
import { nameInitials } from '../../utils/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomUnread } from '../../state/hooks/unread';
@@ -66,19 +65,22 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
import {
getRoomNotificationModeIcon,
RoomNotificationMode,
setRoomNotificationPreference,
} from '../../hooks/useRoomsNotificationPreferences';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { scheduleMuteTimer, unmuteRoom } from './muteTimers';
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import {
LOCAL_ROOM_NAMES_KEY,
getLocalRoomNamesContent,
setLocalRoomName,
useHasLocalRoomName,
useLocalRoomName,
} from '../../hooks/useRoomMeta';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useVoiceChannelFull } from '../../hooks/useVoiceChannelFull';
import { callChatAtom } from '../../state/callEmbed';
import { createErrorToast, toastQueueAtom } from '../../state/toast';
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
@@ -136,22 +138,16 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
const handleSave = useCallback(() => {
const newName = inputRef.current?.value.trim() ?? '';
if (newName.length > 255) return;
const existing = getLocalRoomNamesContent(mx);
if (newName === '') {
const { [room.roomId]: _removed, ...rest } = existing.rooms;
setAccountData(mx, LOCAL_ROOM_NAMES_KEY, { rooms: rest });
} else {
setAccountData(mx, LOCAL_ROOM_NAMES_KEY, {
rooms: { ...existing.rooms, [room.roomId]: newName },
});
}
// Routed through the shared write queue (setLocalRoomName) instead of a
// read-modify-write against the SDK's local cache, which stays stale
// until the /sync echo lands and would otherwise let a second rename
// clobber a still-in-flight first rename.
setLocalRoomName(mx, room.roomId, newName);
onClose();
}, [mx, room.roomId, onClose]);
const handleClear = useCallback(() => {
const existing = getLocalRoomNamesContent(mx);
const { [room.roomId]: _removed, ...rest } = existing.rooms;
setAccountData(mx, LOCAL_ROOM_NAMES_KEY, { rooms: rest });
setLocalRoomName(mx, room.roomId, '');
onClose();
}, [mx, room.roomId, onClose]);
@@ -273,49 +269,6 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
);
}
// localStorage key for timed mute timers
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
// immediately. Clamp long delays to this max (~24.8 days).
export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
export function loadMuteTimers(): MuteTimerEntry[] {
try {
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
}
// Reverse a timed mute: restore the room's notification mode to Unset and drop
// its persisted timer. Shared by the in-session timer and the boot-time restore.
export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void> {
const { setRoomNotificationPreference } =
await import('../../hooks/useRoomsNotificationPreferences');
await setRoomNotificationPreference(
mx,
roomId,
RoomNotificationMode.Unset,
RoomNotificationMode.Mute,
).catch(() => {});
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
}
function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
const unmuteAt = Date.now() + durationMs;
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
saveMuteTimers([...existing, { roomId, unmuteAt }]);
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
}
type RoomNavItemMenuProps = {
room: Room;
requestClose: () => void;
@@ -392,8 +345,6 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const handleMuteFor = useCallback(
async (durationMs: number | null) => {
const { setRoomNotificationPreference } =
await import('../../hooks/useRoomsNotificationPreferences');
const prevMode = notificationMode ?? RoomNotificationMode.Unset;
await setRoomNotificationPreference(
mx,
@@ -741,8 +692,12 @@ function RoomNavItem_({
const callMembers = useCallMembers(callSession);
const startCall = useCallStart(direct);
const callEmbed = useCallEmbed();
// [Gitea #30] Same voice-limit check the call prescreen uses, so the sidebar
// second-click join path can't bypass a full channel.
const { channelFull, current: voiceCurrent, max: voiceMax } = useVoiceChannelFull(room);
const callPref = useAtomValue(useCallPreferencesAtom());
const autoDiscoveryInfo = useAutoDiscoveryInfo();
const setToast = useSetAtom(toastQueueAtom);
const handleStartCall: MouseEventHandler<HTMLAnchorElement> = (evt) => {
const powerLevelsEvent = getStateEvent(room, StateEvent.RoomPowerLevels);
@@ -764,6 +719,22 @@ function RoomNavItem_({
if (callEmbed) {
return;
}
// [Gitea #30] Refuse to start a call into a full voice channel — the
// prescreen already blocks this, but the sidebar second-click join path
// skipped the check entirely.
if (channelFull) {
evt.preventDefault();
setToast(
createErrorToast(
`Channel full (${voiceCurrent}/${voiceMax})`,
Icons.Warning,
'Cannot join',
),
);
return;
}
// Start call in second click
if (selected) {
evt.preventDefault();
@@ -0,0 +1,20 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreferences';
import { shouldResetMuteOnUnmute } from './muteTimers';
test('resets to Unset when the room is still Mute at expiry', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Mute), true);
});
test('does not reset when the user switched to All messages during the mute window', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.AllMessages), false);
});
test('does not reset when the user switched to Special messages during the mute window', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.SpecialMessages), false);
});
test('does not reset when the mode is already Unset', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Unset), false);
});
+88
View File
@@ -0,0 +1,88 @@
import { IPushRule, IPushRules, MatrixClient } from 'matrix-js-sdk';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { getAccountData } from '../../utils/room';
import { getNotificationMode, NotificationMode } from '../../hooks/useNotificationMode';
import {
RoomNotificationMode,
setRoomNotificationPreference,
} from '../../hooks/useRoomsNotificationPreferences';
// localStorage key for timed mute timers
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
// immediately. Clamp long delays to this max (~24.8 days).
export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
export function loadMuteTimers(): MuteTimerEntry[] {
try {
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
}
// Pure decision for the unmute guard: a timed mute should only be reset back to
// Unset if the room's notification mode is still Mute at expiry time. If the user
// manually changed it (e.g. to All messages) while the timer was pending, leave
// their choice alone — just let the stale timer entry get dropped.
export function shouldResetMuteOnUnmute(currentMode: RoomNotificationMode): boolean {
return currentMode === RoomNotificationMode.Mute;
}
// Reads the room's live notification mode straight from account data push rules,
// mirroring useRoomsNotificationPreferences' per-room derivation, without needing
// the React hook (this runs from plain timers/effects, not components).
export function getLiveRoomNotificationMode(
mx: MatrixClient,
roomId: string,
): RoomNotificationMode {
const pushRules = getAccountData(mx, AccountDataEvent.PushRules)?.getContent<IPushRules>();
const global = pushRules?.global;
const overrideRule = global?.override?.find((rule: IPushRule) => rule.rule_id === roomId);
if (overrideRule && getNotificationMode(overrideRule.actions) === NotificationMode.OFF) {
return RoomNotificationMode.Mute;
}
const roomRule = global?.room?.find((rule: IPushRule) => rule.rule_id === roomId);
if (roomRule) {
return getNotificationMode(roomRule.actions) === NotificationMode.OFF
? RoomNotificationMode.SpecialMessages
: RoomNotificationMode.AllMessages;
}
return RoomNotificationMode.Unset;
}
// Reverse a timed mute: restore the room's notification mode to Unset and drop
// its persisted timer. Shared by the in-session timer and the boot-time restore.
// Only resets the mode if it is still Mute — otherwise a manual change made
// during the mute window (e.g. switching to "All messages") would silently get
// reverted when the stale timer fires.
export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void> {
const currentMode = getLiveRoomNotificationMode(mx, roomId);
if (shouldResetMuteOnUnmute(currentMode)) {
await setRoomNotificationPreference(
mx,
roomId,
RoomNotificationMode.Unset,
RoomNotificationMode.Mute,
).catch(() => {});
}
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
}
export function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
const unmuteAt = Date.now() + durationMs;
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
saveMuteTimers([...existing, { roomId, unmuteAt }]);
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
}
@@ -24,6 +24,14 @@ const POLICY_USER_EVENT = 'm.policy.rule.user';
const POLICY_ROOM_EVENT = 'm.policy.rule.room';
const POLICY_SERVER_EVENT = 'm.policy.rule.server';
// Legacy, unstable-prefixed event types still emitted by Draupnir/Mjolnir
// policy lists that predate MSC stabilization (or haven't migrated). Queried
// alongside the stable types and merged/de-duped so those lists don't show
// as falsely empty.
const LEGACY_POLICY_USER_EVENT = 'org.matrix.mjolnir.rule.user';
const LEGACY_POLICY_ROOM_EVENT = 'org.matrix.mjolnir.rule.room';
const LEGACY_POLICY_SERVER_EVENT = 'org.matrix.mjolnir.rule.server';
type PolicyRuleContent = {
entity?: string;
reason?: string;
@@ -76,6 +84,23 @@ function extractPolicyEntries(events: MatrixEvent[]): PolicyEntry[] {
.filter((entry) => entry.entity !== '');
}
/**
* Merge policy entries from the stable and legacy event types for a rule
* kind, de-duplicating by entity+recommendation so a room that emits both a
* stable and a legacy rule for the same target isn't double-listed.
*/
export function dedupePolicyEntries(entries: PolicyEntry[]): PolicyEntry[] {
const seen = new Set<string>();
const result: PolicyEntry[] = [];
entries.forEach((entry) => {
const key = `${entry.entity} ${entry.recommendation}`;
if (seen.has(key)) return;
seen.add(key);
result.push(entry);
});
return result;
}
// ── Entry row ─────────────────────────────────────────────────────────────────
function PolicyEntryRow({ entry }: { entry: PolicyEntry }) {
@@ -201,9 +226,24 @@ export function PolicyListViewer({ requestClose }: PolicyListViewerProps) {
return;
}
setUserEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_USER_EVENT)));
setRoomEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_ROOM_EVENT)));
setServerEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_SERVER_EVENT)));
setUserEntries(
dedupePolicyEntries([
...extractPolicyEntries(getRoomPolicyEvents(room, POLICY_USER_EVENT)),
...extractPolicyEntries(getRoomPolicyEvents(room, LEGACY_POLICY_USER_EVENT)),
]),
);
setRoomEntries(
dedupePolicyEntries([
...extractPolicyEntries(getRoomPolicyEvents(room, POLICY_ROOM_EVENT)),
...extractPolicyEntries(getRoomPolicyEvents(room, LEGACY_POLICY_ROOM_EVENT)),
]),
);
setServerEntries(
dedupePolicyEntries([
...extractPolicyEntries(getRoomPolicyEvents(room, POLICY_SERVER_EVENT)),
...extractPolicyEntries(getRoomPolicyEvents(room, LEGACY_POLICY_SERVER_EVENT)),
]),
);
setLoadedRoomId(roomId);
setError(undefined);
}, [mx, roomIdInput]);
@@ -341,6 +341,7 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
variant="Primary"
/>
<Box direction="Column" gap="0">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label
htmlFor="allow-ip-literals"
style={{ cursor: canEdit ? 'pointer' : 'default' }}
+6 -2
View File
@@ -25,10 +25,12 @@ import { useModalStyle } from '../../hooks/useModalStyle';
interface PollCreatorProps {
roomId: string;
room: Room;
/** Set when the composer is inside a thread so the poll lands in that thread. */
threadRootId?: string;
onClose: () => void;
}
export function PollCreator({ roomId, onClose }: PollCreatorProps) {
export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps) {
const mx = useMatrixClient();
const modalStyle = useModalStyle(440);
const [question, setQuestion] = useState('');
@@ -85,7 +87,9 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
const fallbackBody = [trimmedQuestion, ...filledOptions.map((o, i) => `${i + 1}. ${o}`)].join(
'\n',
);
await mx.sendEvent(roomId, 'm.poll.start' as any, {
// Pass the thread id explicitly (like the sticker path in RoomInput); the
// legacy 3-arg form always resolves to the main timeline.
await mx.sendEvent(roomId, threadRootId ?? null, 'm.poll.start' as any, {
'm.poll': {
question: { 'm.text': trimmedQuestion },
answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })),
+96 -35
View File
@@ -105,6 +105,7 @@ import {
settingsAtom,
} from '../../state/settings';
import {
buildCompressedUploadItem,
getAudioMsgContent,
getFileMsgContent,
getImageMsgContent,
@@ -244,8 +245,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const showLocation = composerToolbarButtons?.showLocation ?? true;
const showPoll = composerToolbarButtons?.showPoll ?? true;
const showVoice = composerToolbarButtons?.showVoice ?? true;
// Schedule-send is hidden in thread mode (v1 reduction).
const showSchedule = (composerToolbarButtons?.showSchedule ?? true) && !threadRootId;
// Schedule-send is hidden in thread mode (v1 reduction) and in encrypted rooms:
// MSC4140 delayed events are PUT as plaintext m.room.message, bypassing the
// SDK's encryption pipeline, so scheduling in an E2EE room would leak the body.
const showSchedule =
(composerToolbarButtons?.showSchedule ?? true) && !threadRootId && !isEncrypted;
const composerButtonOrder = useMemo(
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
[composerToolbarButtons?.order],
@@ -394,27 +398,47 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
try {
const stored = localStorage.getItem(`draft-msg-${draftKey}`);
if (stored) {
const nodes = JSON.parse(stored);
if (Array.isArray(nodes) && nodes.length > 0) {
Transforms.insertFragment(editor, nodes);
// Mirror the restored draft into the atom so the draft indicator
// (reads roomIdToMsgDraftAtomFamily) reflects a persisted draft
// after a page reload — not only on same-session room re-entry.
setMsgDraft(nodes);
const parsed = JSON.parse(stored);
// [Gitea #41] Only restore a draft this same account wrote. A legacy
// draft (stored as a bare array, pre-dating user-scoping) or one
// written by a different userId is foreign — drop it rather than
// risk pre-filling another account's unsent text into the composer.
const foreign =
!parsed ||
typeof parsed !== 'object' ||
Array.isArray(parsed) ||
parsed.userId !== mx.getUserId();
if (foreign) {
localStorage.removeItem(`draft-msg-${draftKey}`);
} else {
const nodes = parsed.nodes;
if (Array.isArray(nodes) && nodes.length > 0) {
Transforms.insertFragment(editor, nodes);
// Mirror the restored draft into the atom so the draft indicator
// (reads roomIdToMsgDraftAtomFamily) reflects a persisted draft
// after a page reload — not only on same-session room re-entry.
setMsgDraft(nodes);
}
}
}
} catch {
// Ignore malformed stored draft
}
}
}, [editor, msgDraft, draftKey, setMsgDraft]);
}, [editor, msgDraft, draftKey, setMsgDraft, mx]);
useEffect(
() => () => {
if (!isEmptyEditor(editor)) {
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
setMsgDraft(parsedDraft);
localStorage.setItem(`draft-msg-${draftKey}`, JSON.stringify(parsedDraft));
// [Gitea #41] Tag the persisted draft with the writing user's id so a
// different account logging into this browser can't have it hydrated
// into their composer (see useHydrateMsgDrafts / clearPlaintextCaches).
localStorage.setItem(
`draft-msg-${draftKey}`,
JSON.stringify({ userId: mx.getUserId(), nodes: parsedDraft }),
);
} else {
setMsgDraft([]);
localStorage.removeItem(`draft-msg-${draftKey}`);
@@ -422,7 +446,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
resetEditor(editor);
resetEditorHistory(editor);
},
[draftKey, editor, setMsgDraft],
[draftKey, editor, setMsgDraft, mx],
);
const handleFileMetadata = useCallback(
@@ -485,22 +509,29 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const compressedFile = new File([compressionResult.blob], compressedName, {
type: compressedType,
});
const uploadRes = await mx.uploadContent(compressedFile, {
name: compressedName,
type: compressedType,
});
// Compression re-encodes the image, so in an encrypted room the new
// bytes must be encrypted before upload (and the event must carry the
// *new* encInfo) — reusing the original's encInfo would publish the
// image in the clear and yield an undecryptable attachment.
const encrypted = fileItem.encInfo ? await encryptFile(compressedFile) : undefined;
const uploadRes = encrypted
? await mx.uploadContent(encrypted.file)
: await mx.uploadContent(compressedFile, {
name: compressedName,
type: compressedType,
});
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
if (compressedMxc) {
// Delete the pre-uploaded original so only one copy lives on the server.
tryDeleteMxcContent(mx, upload.mxc);
mxc = compressedMxc;
// Build a synthetic fileItem that refers to the compressed file so
// getImageMsgContent picks up the correct dimensions and type.
const compressedItem = {
...fileItem,
file: compressedFile,
originalFile: compressedFile,
};
// Synthetic fileItem referring to the compressed file so
// getImageMsgContent picks up the correct dimensions, type and encInfo.
const compressedItem = buildCompressedUploadItem(
fileItem,
compressedFile,
encrypted,
);
return getImageMsgContent(mx, compressedItem, mxc);
}
}
@@ -697,11 +728,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}, [editor, isMarkdown, mx, roomId, replyDraft]);
const handleScheduleClick = useCallback(() => {
// Defense in depth: scheduling sends an unencrypted m.room.message, so never
// open the modal for an encrypted room even if the button somehow renders.
if (isEncrypted) return;
// Pre-fill from editor if there's content; open blank if editor is empty.
const content = buildCurrentTextContent();
setScheduleContent(content);
setScheduleOpen(true);
}, [buildCurrentTextContent]);
}, [buildCurrentTextContent, isEncrypted]);
const handleScheduled = useCallback(
(delayId: string, sendAt: number, content: IContent) => {
@@ -823,18 +857,38 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
return;
}
const uploadRes = await mx.uploadContent(
new File([blob], 'image.gif', { type: 'image/gif' }),
{ type: 'image/gif', name: 'image.gif', includeFilename: false },
);
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
if (!mxcUrl) return;
mx.sendMessage(roomId, threadRootId ?? null, {
const gifFile = new File([blob], 'image.gif', { type: 'image/gif' });
const baseContent = {
msgtype: MsgType.Image,
body: 'image.gif',
url: mxcUrl,
info: { mimetype: 'image/gif', w, h, size: blob.size },
});
};
// Mirror the attachment/voice paths: in an encrypted room the media
// itself must be encrypted, otherwise the homeserver (and anyone with
// the mxc URI) can see the GIF even though the event body is encrypted.
if (room.hasEncryptionStateEvent()) {
const { encInfo, file: encBlob } = await encryptFile(gifFile);
const uploadRes = await mx.uploadContent(encBlob);
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
if (!mxcUrl) return;
mx.sendMessage(roomId, threadRootId ?? null, {
...baseContent,
file: { ...encInfo, url: mxcUrl },
} as any);
} else {
const uploadRes = await mx.uploadContent(gifFile, {
type: 'image/gif',
name: 'image.gif',
includeFilename: false,
});
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
if (!mxcUrl) return;
mx.sendMessage(roomId, threadRootId ?? null, {
...baseContent,
url: mxcUrl,
} as any);
}
} catch (e) {
console.error('GIF send failed:', e instanceof Error ? e.message : 'unknown error');
if (!alive()) return;
@@ -844,7 +898,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (alive()) setGifUploading(false);
}
},
[mx, roomId, threadRootId, alive],
[mx, room, roomId, threadRootId, alive],
);
const handleStickerSelect = useCallback(
@@ -1446,7 +1500,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</>
}
/>
{pollOpen && <PollCreator room={room} roomId={roomId} onClose={() => setPollOpen(false)} />}
{pollOpen && (
<PollCreator
room={room}
roomId={roomId}
threadRootId={threadRootId}
onClose={() => setPollOpen(false)}
/>
)}
{scheduleOpen && (
<ScheduleMessageModal
roomId={roomId}
@@ -318,6 +318,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
const results = await Promise.allSettled(
ids.map((id) => {
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sendForward = () => mx.sendEvent(id, null, mEvent.getType() as any, fwdContent);
// Send the optional comment first so it reads as a note above the
// forwarded content. The room counts as failed if either send rejects.
@@ -326,6 +327,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
const needsComment = commentBody && !commentSentRef.current.has(id);
const step = needsComment
? mx
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.sendMessage(id, null, { msgtype: MsgType.Text, body: commentBody } as any)
.then(() => {
commentSentRef.current.add(id);
@@ -1273,6 +1273,9 @@ export const Message = React.memo(
const content = mEvent.getContent();
const body: string =
(content?.body as string | undefined) ?? '';
// For E2EE rooms useBookmarks strips the text
// fields before persisting (account data is
// server-readable); the panel resolves them live.
addBookmark({
roomId: room.roomId,
eventId,
@@ -1390,6 +1393,7 @@ export const Message = React.memo(
after={<Icon size="100" src={Icons.Send} />}
radii="300"
onClick={() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mx as any).resendEvent(mEvent, room);
closeMenu();
}}
@@ -1408,6 +1412,7 @@ export const Message = React.memo(
after={<Icon size="100" src={Icons.Cross} />}
radii="300"
onClick={() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mx as any).cancelPendingEvent(mEvent);
closeMenu();
}}
@@ -187,6 +187,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
rel_type: RelationType.Replace,
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return mx.sendMessage(roomId, content as any);
}
@@ -235,6 +236,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return mx.sendMessage(roomId, content as any);
}, [
mx,
@@ -0,0 +1,73 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { buildCompressedUploadItem } from './msgContent';
import { TUploadItem } from '../../state/room/roomInputDrafts';
// buildCompressedUploadItem decides which bytes are uploaded and which encInfo
// (if any) the resulting m.image event carries. Getting this wrong either leaks
// a plaintext image into an E2EE room or produces an undecryptable attachment.
const enc = (tag: string): EncryptedAttachmentInfo =>
({
v: 'v2',
key: { alg: 'A256CTR', k: tag },
iv: `iv-${tag}`,
hashes: { sha256: `sha-${tag}` },
}) as unknown as EncryptedAttachmentInfo;
const fakeFile = (name: string, size: number): File =>
new File([new Uint8Array(size)], name, { type: 'image/jpeg' });
const makeItem = (encInfo?: EncryptedAttachmentInfo): TUploadItem =>
({
file: fakeFile('photo.png', 900),
originalFile: fakeFile('photo.png', 900),
encInfo,
metadata: { markedAsSpoiler: false, compressImage: true },
}) as unknown as TUploadItem;
test('unencrypted room: compressed item uploads the plain file and carries no encInfo', () => {
const compressed = fakeFile('photo.jpg', 300);
const item = buildCompressedUploadItem(makeItem(), compressed);
assert.equal(item.file, compressed);
assert.equal(item.originalFile, compressed);
assert.equal(item.encInfo, undefined);
});
test('encrypted room: compressed item carries the NEW encInfo, never the original one', () => {
const compressed = fakeFile('photo.jpg', 300);
const encryptedBlob = fakeFile('photo.jpg', 320);
const item = buildCompressedUploadItem(makeItem(enc('original')), compressed, {
file: encryptedBlob,
encInfo: enc('compressed'),
});
// The ciphertext is what gets uploaded; the plaintext stays available for
// dimensions/blurhash only.
assert.equal(item.file, encryptedBlob);
assert.equal(item.originalFile, compressed);
assert.deepEqual(item.encInfo, enc('compressed'));
assert.notDeepEqual(item.encInfo, enc('original'));
});
test('encrypted room: an encInfo-less compressed item never inherits the original encInfo', () => {
// Defensive: even if the caller forgets to re-encrypt, we must not emit the
// stale encInfo (that is the bug this helper exists to prevent).
const item = buildCompressedUploadItem(makeItem(enc('original')), fakeFile('photo.jpg', 300));
assert.equal(item.encInfo, undefined);
});
test('metadata (caption, spoiler) is preserved on the compressed item', () => {
const base = makeItem();
base.metadata.caption = 'a caption';
base.metadata.markedAsSpoiler = true;
const item = buildCompressedUploadItem(base, fakeFile('photo.jpg', 300));
assert.equal(item.metadata.caption, 'a caption');
assert.equal(item.metadata.markedAsSpoiler, true);
});
// getImageMsgContent itself is not covered here: it needs a DOM (loadImageElement).
// Its encInfo branch (content.file vs content.url) is exercised by the sibling
// msgContent.test.ts builders, which share the same shape.
+23
View File
@@ -1,5 +1,6 @@
import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk';
import to from 'await-to-js';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import {
IThumbnailContent,
MATRIX_BLUR_HASH_PROPERTY_NAME,
@@ -43,6 +44,28 @@ const generateThumbnailContent = async (
return thumbnailContent;
};
/**
* Build the synthetic upload item for a *re-encoded* (compressed) image.
*
* The compressed bytes are a brand new payload, so the item must never inherit
* the original's `encInfo` — that key/iv/sha256 describes the pre-compression
* ciphertext and would make receivers fail to decrypt. In an encrypted room the
* caller re-runs `encryptFile` and passes the new ciphertext + encInfo here; in
* an unencrypted room both are omitted and the item carries no `encInfo` at all.
*/
export const buildCompressedUploadItem = (
item: TUploadItem,
compressedFile: File,
encrypted?: { file: File; encInfo: EncryptedAttachmentInfo },
): TUploadItem => ({
...item,
// `file` is what gets uploaded/described, `originalFile` is the plaintext used
// for dimensions + blurhash.
file: encrypted?.file ?? compressedFile,
originalFile: compressedFile,
encInfo: encrypted?.encInfo,
});
export const getImageMsgContent = async (
mx: MatrixClient,
item: TUploadItem,
+4 -2
View File
@@ -116,6 +116,8 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
const editor = useEditor();
const thread = useThreadInstance(room, threadId);
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts');
// "Hide Typing & Read Receipts" must also make thread receipts private (matches markAsRead).
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const fileDropContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
useKeyDown(
@@ -157,7 +159,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
}
if (!latestId || latestId === lastReadEventIdRef.current) return;
lastReadEventIdRef.current = latestId;
markThreadAsRead(mx, thread, privateReadReceipts).catch(() => {
markThreadAsRead(mx, thread, hideActivity || privateReadReceipts).catch(() => {
// Allow a retry on the next event if the receipt POST failed.
if (lastReadEventIdRef.current === latestId) {
lastReadEventIdRef.current = undefined;
@@ -171,7 +173,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
thread.off(ThreadEvent.NewReply, markRead);
thread.off(RoomEvent.Timeline, markRead);
};
}, [mx, thread, privateReadReceipts]);
}, [mx, thread, privateReadReceipts, hideActivity]);
return (
<Box
@@ -545,9 +545,19 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
[room, thread, setReplyDraft, editor],
);
// Non-thread relations (reactions, edits) that target the thread root live only in
// the room's main timeline set (matrix-js-sdk Room.eventShouldLiveIn), so lookups
// for the root must use the room set instead of the thread set.
const getRelationTimelineSet = useCallback(
(eventId: string) =>
eventId === thread.id ? room.getUnfilteredTimelineSet() : thread.getUnfilteredTimelineSet(),
[room, thread],
);
const handleReactionToggle = useCallback(
(targetEventId: string, key: string, shortcode?: string) => {
const timelineSet = thread.getUnfilteredTimelineSet();
const isRoot = targetEventId === thread.id;
const timelineSet = getRelationTimelineSet(targetEventId);
const relations = getEventReactions(timelineSet, targetEventId);
const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
@@ -563,12 +573,14 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
mx.sendEvent(
room.roomId,
thread.id,
// A reaction on the root is a main-timeline event, not a thread reply.
isRoot ? null : thread.id,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
MessageEvent.Reaction as any,
getReactionContent(targetEventId, key, rShortcode),
);
},
[mx, room, thread],
[mx, room, thread, getRelationTimelineSet],
);
const handleEdit = useCallback(
@@ -714,7 +726,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
): ReactNode => {
const mEventId = mEvent.getId();
if (!mEventId) return null;
const timelineSet = thread.getUnfilteredTimelineSet();
const timelineSet = getRelationTimelineSet(mEventId);
const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations?.getSortedAnnotationsByKey();
const hasReactions = !!reactions && reactions.length > 0;
@@ -782,7 +794,6 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
);
},
[
thread,
room,
messageSpacing,
messageLayout,
@@ -809,6 +820,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
lotusTerminal,
mx,
renderMessageContent,
getRelationTimelineSet,
],
);
@@ -60,6 +60,7 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
clientApi.stop();
iframe.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mx, room.roomId, widget.id, widget.templateUrl]);
if (blocked) {
@@ -84,6 +84,7 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
data: {},
};
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await sendStateEvent(mx, room.roomId, StateEvent.Widget, content, id);
setAdding(false);
} catch (e) {
@@ -95,6 +96,7 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
const handleRemove = (id: string) => {
if (viewingId === id) setViewingId(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sendStateEvent(mx, room.roomId, StateEvent.Widget, {}, id).catch(() => undefined);
};
@@ -0,0 +1,72 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { MatrixEvent } from 'matrix-js-sdk';
import { widgetsFromStateEvents } from './useRoomWidgets';
const APP = 'https://chat.lotusguild.org';
// Minimal fake MatrixEvent, just enough of the surface widgetsFromStateEvents reads.
const fakeEvent = (stateKey: string, sender: string, content: Record<string, unknown>) =>
({
getStateKey: () => stateKey,
getSender: () => sender,
getContent: () => content,
}) as unknown as MatrixEvent;
test('returns a Widget for a valid im.vector.modular.widgets state event', () => {
// Regression test for matrix-widget-api 1.17.0's broken isValidUrl, which
// compares URL.protocol ("https:") to "https" and rejects every URL,
// making WidgetParser.parseWidgetsFromRoomState always return [].
const events = new Map([
[
'w1',
fakeEvent('w1', '@a:example.org', {
id: 'w1',
type: 'custom',
url: 'https://example.com/widget',
name: 'My Widget',
creatorUserId: '@a:example.org',
}),
],
]);
const widgets = widgetsFromStateEvents(events, APP);
assert.equal(widgets.length, 1);
assert.equal(widgets[0].id, 'w1');
assert.equal(widgets[0].templateUrl, 'https://example.com/widget');
assert.equal(widgets[0].creatorUserId, '@a:example.org');
assert.equal(widgets[0].name, 'My Widget');
});
test('skips removed widgets (empty content)', () => {
const events = new Map([['w1', fakeEvent('w1', '@a:example.org', {})]]);
assert.deepEqual(widgetsFromStateEvents(events, APP), []);
});
test('skips non-https and same-origin urls', () => {
const events = new Map([
[
'w1',
fakeEvent('w1', '@a:example.org', {
id: 'w1',
type: 'custom',
url: 'http://example.com/widget',
creatorUserId: '@a:example.org',
}),
],
[
'w2',
fakeEvent('w2', '@a:example.org', {
id: 'w2',
type: 'custom',
url: `${APP}/evil`,
creatorUserId: '@a:example.org',
}),
],
]);
assert.deepEqual(widgetsFromStateEvents(events, APP), []);
});
test('undefined state map yields no widgets', () => {
assert.deepEqual(widgetsFromStateEvents(undefined, APP), []);
});
+58 -12
View File
@@ -1,21 +1,67 @@
import { Room } from 'matrix-js-sdk';
import { Room, MatrixEvent } from 'matrix-js-sdk';
import { useMemo } from 'react';
import { Widget, WidgetParser, IStateEvent } from 'matrix-widget-api';
import { Widget } from 'matrix-widget-api';
import { StateEvent } from '../../../../types/matrix/room';
import { useRoomState } from '../../../hooks/useRoomState';
import { StateKeyToEvents, useRoomState } from '../../../hooks/useRoomState';
import { isWidgetUrlSafe } from './widgetUtils';
/**
* Builds the `Widget` list from raw `im.vector.modular.widgets` state events.
*
* NOTE: we do NOT use `WidgetParser.parseWidgetsFromRoomState` here. In
* matrix-widget-api 1.17.0 its `isValidUrl` compares `URL.protocol` (which is
* always colon-suffixed, e.g. "https:") against the bare strings "http"/
* "https", so it rejects every URL and the parser always returns []. We build
* the `Widget`s ourselves with a correct scheme check plus the existing
* `isWidgetUrlSafe` origin check.
*/
export const widgetsFromStateEvents = (
widgetEvents: StateKeyToEvents | undefined,
appOrigin: string,
): Widget[] => {
if (!widgetEvents) return [];
const widgets: Widget[] = [];
Array.from(widgetEvents.values()).forEach((event: MatrixEvent) => {
const content = event.getContent();
// Removed widgets are represented as an empty content state event.
if (!content || Object.keys(content).length === 0) return;
const id = event.getStateKey();
const { type, url, name, data, waitForIframeLoad } = content;
const creatorUserId = content.creatorUserId || event.getSender();
if (!id || !type || !url || !creatorUserId) return;
let scheme: string;
try {
scheme = new URL(url).protocol;
} catch {
return;
}
if (scheme !== 'https:') return;
if (!isWidgetUrlSafe(url, appOrigin)) return;
widgets.push(
new Widget({
id,
creatorUserId,
type,
url,
name,
data,
waitForIframeLoad,
}),
);
});
return widgets;
};
/**
* All valid `im.vector.modular.widgets` room widgets, reactive on room state.
* `WidgetParser` drops empty/removed (`{}`) and malformed entries.
*/
export const useRoomWidgets = (room: Room): Widget[] => {
const state = useRoomState(room);
return useMemo(() => {
const widgetEvents = state.get(StateEvent.Widget);
if (!widgetEvents) return [];
const stateEvents = Array.from(widgetEvents.values()).map(
(event) => event.getEffectiveEvent() as unknown as IStateEvent,
);
return WidgetParser.parseWidgetsFromRoomState(stateEvents);
}, [state]);
return useMemo(
() => widgetsFromStateEvents(state.get(StateEvent.Widget), window.location.origin),
[state],
);
};
+29 -2
View File
@@ -751,22 +751,32 @@ function ProfilePronouns() {
const [pronouns, setPronouns] = useState<string>('');
const [savedPronouns, setSavedPronouns] = useState<string>('');
// True once the user has edited the field — guards against the mount-time
// fetch below clobbering a fresh edit if it resolves late (mirrors
// ProfileStatus's statusDirtyRef in this file).
const pronounsDirtyRef = useRef(false);
useEffect(() => {
let cancelled = false;
mx.http
.authedRequest<{ 'm.pronouns': string }>(
Method.Get,
`/profile/${encodeURIComponent(userId)}/m.pronouns`,
)
.then((res) => {
if (cancelled || pronounsDirtyRef.current) return;
const val = res['m.pronouns'] ?? '';
setPronouns(val);
setSavedPronouns(val);
})
.catch(() => {
if (cancelled || pronounsDirtyRef.current) return;
setPronouns('');
setSavedPronouns('');
});
return () => {
cancelled = true;
};
}, [mx, userId]);
const [saveState, savePronouns] = useAsyncCallback(
@@ -788,10 +798,12 @@ function ProfilePronouns() {
const saving = saveState.status === AsyncStatus.Loading;
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
pronounsDirtyRef.current = true;
setPronouns(evt.currentTarget.value);
};
const handleReset = () => {
pronounsDirtyRef.current = true;
setPronouns(savedPronouns);
};
@@ -875,10 +887,15 @@ function ProfileTimezone() {
const [timezone, setTimezone] = useState<string>('');
const [savedTimezone, setSavedTimezone] = useState<string>('');
// True once the user has edited the field — guards against the mount-time
// fetch below clobbering a fresh edit if it resolves late (mirrors
// ProfileStatus's statusDirtyRef in this file).
const timezoneDirtyRef = useRef(false);
useEffect(() => {
let cancelled = false;
const cached = getAccountData<{ timezone: string }>(mx, 'im.lotus.timezone');
if (cached?.timezone) {
if (cached?.timezone && !timezoneDirtyRef.current) {
setTimezone(cached.timezone);
setSavedTimezone(cached.timezone);
}
@@ -889,6 +906,7 @@ function ProfileTimezone() {
`/user/${encodeURIComponent(userId)}/account_data/im.lotus.timezone`,
)
.then((res) => {
if (cancelled || timezoneDirtyRef.current) return;
const val = res.timezone ?? '';
setTimezone(val);
setSavedTimezone(val);
@@ -896,6 +914,9 @@ function ProfileTimezone() {
.catch(() => {
/* no stored timezone yet */
});
return () => {
cancelled = true;
};
}, [mx, userId]);
const [saveState, saveTimezone] = useAsyncCallback(
@@ -921,7 +942,13 @@ function ProfileTimezone() {
);
const saving = saveState.status === AsyncStatus.Loading;
const handleChange = (value: string) => {
timezoneDirtyRef.current = true;
setTimezone(value);
};
const handleReset = () => {
timezoneDirtyRef.current = true;
setTimezone(savedTimezone);
};
@@ -955,7 +982,7 @@ function ProfileTimezone() {
{ value: '', label: '— select timezone —' },
...COMMON_TIMEZONES.map((tz) => ({ value: tz, label: tz })),
]}
onChange={setTimezone}
onChange={handleChange}
disabled={saving}
aria-label="Timezone"
/>
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Button, Text, Spinner, color } from 'folds';
import { Method } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -67,24 +67,47 @@ export function ProfileDecoration() {
const [current, setCurrent] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
// Distinguish "confirmed no decoration" from "failed to load": a fetch
// failure must not be shown as (and saved over) "None".
const [loadError, setLoadError] = useState(false);
const [loading, setLoading] = useState(true);
// True once the user has picked/cleared a decoration — guards against the
// mount-time fetch below clobbering a fresh selection if it resolves late
// (mirrors ProfileStatus's statusDirtyRef in Profile.tsx).
const dirtyRef = useRef(false);
useEffect(() => {
const fetchDecoration = useCallback(() => {
let cancelled = false;
setLoading(true);
// Fetch the whole profile, not the `/{field}` sub-resource: an unset field
// 404s (a console error for anyone without a decoration). The full profile
// returns 200 with all fields incl. custom MSC4133 ones — read it out.
mx.http
.authedRequest<Record<string, string>>(Method.Get, `/profile/${encodeURIComponent(userId)}`)
.then((res) => {
if (cancelled) return;
setLoadError(false);
setLoading(false);
if (dirtyRef.current) return;
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
setCurrent(val);
setSelected(val);
})
.catch(() => {
setCurrent(null);
setSelected(null);
if (cancelled) return;
setLoading(false);
// Do NOT touch current/selected here — a network failure is not proof
// there's no decoration, and defaulting to null risks the user saving
// "None" over a real, still-set decoration (see #46).
setLoadError(true);
});
return () => {
cancelled = true;
};
}, [mx, userId]);
useEffect(() => fetchDecoration(), [fetchDecoration]);
const [saveState, save] = useAsyncCallback(
useCallback(
async (slug: string | null) => {
@@ -105,16 +128,27 @@ export function ProfileDecoration() {
const hasChanges = selected !== current;
const handleSelect = (slug: string) => {
dirtyRef.current = true;
setSelected((prev) => (prev === slug ? null : slug));
};
const handleClear = () => setSelected(null);
const handleClear = () => {
dirtyRef.current = true;
setSelected(null);
};
const handleSave = () => {
if (!hasChanges || saving) return;
// Refuse to save while the initial load failed: `current`/`selected` are
// not known-good, so saving could silently overwrite a real decoration.
if (!hasChanges || saving || loadError) return;
save(selected);
};
const handleRetry = () => {
dirtyRef.current = false;
fetchDecoration();
};
return (
<SettingTile
title={
@@ -159,13 +193,15 @@ export function ProfileDecoration() {
</div>
<Box grow="Yes" direction="Column" gap="100">
<Text size="T300">
{selected
? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find(
(d) => d.slug === selected,
)?.name ?? selected)
: 'None'}
{loadError
? 'Failed to load'
: selected
? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find(
(d) => d.slug === selected,
)?.name ?? selected)
: 'None'}
</Text>
{selected && (
{selected && !loadError && (
<Button
type="button"
size="300"
@@ -178,7 +214,7 @@ export function ProfileDecoration() {
</Button>
)}
</Box>
{hasChanges && (
{hasChanges && !loadError && (
<Button
type="button"
size="400"
@@ -194,6 +230,26 @@ export function ProfileDecoration() {
)}
</Box>
{loadError && (
<Box alignItems="Center" gap="200">
<Text size="T200" style={{ color: color.Critical.Main }}>
Could not load your current decoration. Saving is disabled until this succeeds, so you
don’t overwrite it based on a wrong display.
</Text>
<Button
type="button"
size="300"
radii="300"
variant="Secondary"
fill="Soft"
onClick={handleRetry}
disabled={loading}
>
<Text size="B300">{loading ? 'Retrying…' : 'Retry'}</Text>
</Button>
</Box>
)}
{saveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Failed to save. Try again.
+24 -8
View File
@@ -117,6 +117,7 @@ import { playCallJoinSound } from '../../../utils/callSounds';
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
import { DenoiseTester } from './DenoiseTester';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
import { isBindableCallKey } from '../../../utils/callKeybind';
/**
* P5-47 — opt-in TDS window chrome toggle (desktop only). Renders nothing in the
@@ -1474,8 +1475,12 @@ function Privacy() {
);
}
function useKeyBind(setter: (code: string) => void) {
// [Gitea #23] Denylist navigation-critical/modifier codes and reject a code that
// collides with the other call key (`otherKey`), so a rebind can never trap
// keyboard focus in-call or silently double-bind PTT and deafen to the same key.
function useKeyBind(setter: (code: string) => void, otherKey?: string) {
const [listening, setListening] = useState(false);
const [error, setError] = useState<string | null>(null);
const listenerRef = useRef<((e: KeyboardEvent) => void) | null>(null);
useEffect(
@@ -1487,19 +1492,28 @@ function useKeyBind(setter: (code: string) => void) {
const startListening = useCallback(() => {
if (listening) return;
setError(null);
setListening(true);
const onKey = (e: KeyboardEvent) => {
e.preventDefault();
if (e.code !== 'Escape') setter(e.code);
if (e.code === 'Escape') {
// Escape always cancels the rebind without changing the key.
} else if (!isBindableCallKey(e.code)) {
setError('That key can’t be bound — it’s needed for keyboard navigation.');
} else if (otherKey && e.code === otherKey) {
setError('That key is already bound to the other call shortcut.');
} else {
setter(e.code);
}
setListening(false);
window.removeEventListener('keydown', onKey, true);
listenerRef.current = null;
};
listenerRef.current = onKey;
window.addEventListener('keydown', onKey, true);
}, [listening, setter]);
}, [listening, setter, otherKey]);
return { listening, startListening };
return { listening, startListening, error };
}
const keyLabel = (code: string) =>
@@ -1556,8 +1570,8 @@ function Calls() {
previewRingtone(value, Math.max(0, Math.min(1, ringtoneVolume / 100)));
};
const pttBind = useKeyBind(setPttKey);
const deafenBind = useKeyBind(setDeafenKey);
const pttBind = useKeyBind(setPttKey, deafenKey);
const deafenBind = useKeyBind(setDeafenKey, pttKey);
const mlSupported = isMLDenoiseSupported();
const selectedDenoiseModel = DENOISE_MODELS.find((m) => m.id === callDenoiseModel);
@@ -1823,7 +1837,7 @@ function Calls() {
{pttMode && (
<SettingTile
title="PTT Key"
description="Press a key to bind it as your push-to-talk key."
description={pttBind.error ?? 'Press a key to bind it as your push-to-talk key.'}
after={
<Button
size="300"
@@ -1841,7 +1855,9 @@ function Calls() {
)}
<SettingTile
title="Push to Deafen"
description="Toggle speaker mute during a call. Press Escape to cancel rebind."
description={
deafenBind.error ?? 'Toggle speaker mute during a call. Press Escape to cancel rebind.'
}
after={
<Button
size="300"
@@ -11,12 +11,19 @@ import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { settingsAtom, Settings } from '../../../state/settings';
import { notificationSnoozeUntilAtom } from '../../../state/notificationSnooze';
const PRESETS: Array<{
label: string;
icon: IconSrc;
description: string;
patch: Partial<Settings>;
// Whether applying this preset should also clear an active "Pause
// Notifications" snooze. Work/Gaming both claim notifications end up on,
// so a leftover snooze would silently contradict them (#48). Sleep's
// description ("All notifications off") is still true with a snooze left
// active, so it does not need to touch it.
clearSnooze: boolean;
}> = [
{
label: 'Gaming',
@@ -29,6 +36,7 @@ const PRESETS: Array<{
inviteSoundId: 'none',
quietHoursEnabled: false,
},
clearSnooze: true,
},
{
label: 'Work',
@@ -41,6 +49,7 @@ const PRESETS: Array<{
inviteSoundId: 'invite',
quietHoursEnabled: false,
},
clearSnooze: true,
},
{
label: 'Sleep',
@@ -51,15 +60,23 @@ const PRESETS: Array<{
isNotificationSounds: false,
quietHoursEnabled: false,
},
clearSnooze: false,
},
];
function NotificationPresets() {
const settings = useAtomValue(settingsAtom);
const setSettings = useSetAtom(settingsAtom);
const setSnoozeUntil = useSetAtom(notificationSnoozeUntilAtom);
const applyPreset = (patch: Partial<Settings>) => {
const applyPreset = (patch: Partial<Settings>, clearSnooze: boolean) => {
setSettings({ ...settings, ...patch });
// Work/Gaming promise notifications are on; an active snooze from an
// earlier "Pause Notifications" would otherwise keep them silently
// suppressed despite the preset applying successfully (#48).
if (clearSnooze) {
setSnoozeUntil(0);
}
};
return (
@@ -71,7 +88,7 @@ function NotificationPresets() {
<Button
key={preset.label}
type="button"
onClick={() => applyPreset(preset.patch)}
onClick={() => applyPreset(preset.patch, preset.clearSnooze)}
title={preset.description}
variant="Secondary"
fill="Soft"
@@ -18,6 +18,7 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import {
getNotificationModeActions,
getNotificationModeOptionsFromActions,
NotificationMode,
useNotificationModeActions,
} from '../../../hooks/useNotificationMode';
@@ -131,7 +132,13 @@ type RuleModeSwitcherProps = {
function RuleModeSwitcher({ kind, pushRule }: RuleModeSwitcherProps) {
const mx = useMatrixClient();
const getModeActions = useNotificationModeActions();
// Preserve any `highlight`/custom sound tweak already on the rule — otherwise
// switching mode here rebuilds actions from scratch and silently drops them.
const options = useMemo(
() => getNotificationModeOptionsFromActions(pushRule.actions),
[pushRule.actions],
);
const getModeActions = useNotificationModeActions(options);
const handleChange = useCallback(
async (mode: NotificationMode) => {
+5 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { MatrixError, Method } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { isValidDecorationSlug } from '../features/lotus/avatarDecorations';
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
@@ -51,7 +52,10 @@ function fetchDecoration(
// all fields (incl. custom MSC4133 ones); read the decoration out of it.
return authedRequest(Method.Get, `/profile/${encodeURIComponent(userId)}`)
.then((res) => {
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
const rawVal = (res[PROFILE_FIELD] as string | undefined) ?? null;
// The remote profile field is free-form and attacker-controlled; only
// accept it when it names a real catalog decoration (see decorationUrl).
const val = rawVal && isValidDecorationSlug(rawVal) ? rawVal : null;
cache.set(userId, val);
return val;
})
+81
View File
@@ -0,0 +1,81 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
Bookmark,
cleanupEncryptedBookmarks,
hasBookmarkText,
stripBookmarkText,
toStorableBookmark,
} from './useBookmarks';
import { cleanupEncryptedReminders, Reminder, toStorableReminder } from './useReminders';
// E2EE policy for bookmarks/reminders (Gitea #10): account data is stored
// unencrypted on the homeserver, so entries for encrypted rooms must carry no
// message text or names. `isEncryptedRoom` is injected to keep these pure.
const isEncrypted = (roomId: string) => roomId === '!enc';
const full = (roomId: string): Bookmark => ({
roomId,
eventId: `$ev-${roomId}`,
savedAt: 100,
previewText: 'secret text',
roomName: 'Room',
senderName: 'Alice',
});
test('stripBookmarkText keeps only roomId/eventId/savedAt', () => {
const stripped = stripBookmarkText(full('!enc'));
assert.deepEqual(stripped, { roomId: '!enc', eventId: '$ev-!enc', savedAt: 100, roomName: '' });
assert.equal(hasBookmarkText(stripped), false);
assert.equal(hasBookmarkText(full('!enc')), true);
});
test('toStorableBookmark strips text for encrypted rooms only', () => {
const plain = full('!plain');
assert.equal(toStorableBookmark(plain, isEncrypted), plain);
const enc = toStorableBookmark(full('!enc'), isEncrypted);
assert.equal(enc.previewText, undefined);
assert.equal(enc.senderName, undefined);
assert.equal(enc.roomName, '');
});
test('cleanupEncryptedBookmarks returns undefined when nothing to strip', () => {
const already = stripBookmarkText(full('!enc'));
assert.equal(cleanupEncryptedBookmarks([full('!plain'), already], isEncrypted), undefined);
assert.equal(cleanupEncryptedBookmarks([], isEncrypted), undefined);
});
test('cleanupEncryptedBookmarks strips legacy encrypted entries and keeps the rest', () => {
const plain = full('!plain');
const out = cleanupEncryptedBookmarks([plain, full('!enc')], isEncrypted);
assert.ok(out);
assert.equal(out[0], plain);
assert.deepEqual(out[1], stripBookmarkText(full('!enc')));
// Running it again on the result is a no-op (no write loop).
assert.equal(cleanupEncryptedBookmarks(out, isEncrypted), undefined);
});
const reminder = (roomId: string): Reminder => ({
roomId,
eventId: `$ev-${roomId}`,
timestamp: 200,
message: 'secret text',
});
test('toStorableReminder drops message for encrypted rooms only', () => {
const plain = reminder('!plain');
assert.equal(toStorableReminder(plain, isEncrypted), plain);
assert.deepEqual(toStorableReminder(reminder('!enc'), isEncrypted), {
roomId: '!enc',
eventId: '$ev-!enc',
timestamp: 200,
});
});
test('cleanupEncryptedReminders strips legacy entries once', () => {
const out = cleanupEncryptedReminders([reminder('!plain'), reminder('!enc')], isEncrypted);
assert.ok(out);
assert.equal(out[0].message, 'secret text');
assert.equal(out[1].message, undefined);
assert.equal(cleanupEncryptedReminders(out, isEncrypted), undefined);
});
+97 -7
View File
@@ -1,4 +1,5 @@
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { createAccountDataListStore } from './createAccountDataListStore';
@@ -6,7 +7,14 @@ export type Bookmark = {
roomId: string;
eventId: string;
savedAt: number;
previewText: string;
// Snapshot fields. Account data is stored UNENCRYPTED on the homeserver, so
// for E2EE rooms none of these are persisted (see stripBookmarkText) — the
// panel resolves the preview / room / author live from the local timeline
// instead. They remain for unencrypted rooms (already server-visible) and for
// entries saved before this policy, so older stored bookmarks still render.
previewText?: string;
// Kept as a (possibly empty) string in memory because the grouping helpers in
// utils/bookmarks read it directly; the store omits it from the wire when empty.
roomName: string;
// Author display name, snapshotted at save time. Optional for backward
// compatibility with bookmarks stored before attribution was added; the panel
@@ -16,22 +24,83 @@ export type Bookmark = {
senderName?: string;
};
// Wire shape: identical to Bookmark except that `roomName` may be absent.
type StoredBookmark = Omit<Bookmark, 'roomName'> & { roomName?: string };
const BOOKMARKS_KEY = 'io.lotus.bookmarks';
const MAX_BOOKMARKS = 500;
type BookmarksContent = {
bookmarks: Bookmark[];
bookmarks: StoredBookmark[];
};
/** True when the entry carries any of the text fields we refuse to upload for E2EE rooms. */
export const hasBookmarkText = (b: Bookmark): boolean =>
b.previewText !== undefined || b.roomName !== '' || b.senderName !== undefined;
/**
* Reduce a bookmark to its non-text fields ({roomId, eventId, savedAt}). Used for
* messages in encrypted rooms so no decrypted plaintext (or who said it) ever
* reaches the server's account-data store.
*/
export const stripBookmarkText = (b: Bookmark): Bookmark => ({
roomId: b.roomId,
eventId: b.eventId,
savedAt: b.savedAt,
roomName: '',
});
/**
* Apply the E2EE storage policy to a bookmark about to be persisted.
* `isEncryptedRoom` is injected so the policy is testable without a client.
*/
export const toStorableBookmark = (
b: Bookmark,
isEncryptedRoom: (roomId: string) => boolean,
): Bookmark => (isEncryptedRoom(b.roomId) ? stripBookmarkText(b) : b);
/**
* One-time cleanup for entries persisted before the E2EE policy: returns the list
* with text fields stripped for every bookmark whose room is currently encrypted,
* or `undefined` when nothing needs to change (so callers can skip the write).
*/
export const cleanupEncryptedBookmarks = (
bookmarks: Bookmark[],
isEncryptedRoom: (roomId: string) => boolean,
): Bookmark[] | undefined => {
let changed = false;
const next = bookmarks.map((b) => {
if (hasBookmarkText(b) && isEncryptedRoom(b.roomId)) {
changed = true;
return stripBookmarkText(b);
}
return b;
});
return changed ? next : undefined;
};
const isEncryptedRoomFor =
(mx: MatrixClient) =>
(roomId: string): boolean =>
mx.getRoom(roomId)?.hasEncryptionStateEvent() ?? false;
// Shared, concurrency-safe store. See createAccountDataListStore for why the
// snapshot + write queue must be module-scoped (writes are serialized to avoid
// lost updates, since setAccountData replaces the whole content with no merge).
const bookmarksStore = createAccountDataListStore<Bookmark[], BookmarksContent>({
eventType: BOOKMARKS_KEY,
read: (content) => content?.bookmarks ?? [],
write: (bookmarks) => ({ bookmarks }),
read: (content) => (content?.bookmarks ?? []).map((b) => ({ ...b, roomName: b.roomName ?? '' })),
write: (bookmarks) => ({
// Drop the in-memory '' placeholder so stripped entries stay {roomId, eventId, savedAt}.
bookmarks: bookmarks.map(({ roomName, ...rest }) => (roomName ? { ...rest, roomName } : rest)),
}),
});
// Guard so the load-time cleanup below runs once per client, not on every mount
// (useBookmarks is mounted by every message row) and not again after its own
// write echoes back.
let cleanedUpFor: MatrixClient | null = null;
export function useBookmarks(): {
bookmarks: Bookmark[];
addBookmark: (b: Bookmark) => Promise<void>;
@@ -41,12 +110,33 @@ export function useBookmarks(): {
const mx = useMatrixClient();
const bookmarks = bookmarksStore.useValue(mx);
// Strip text from entries saved (by older builds) for rooms that are encrypted,
// writing the store back at most once per client.
useEffect(() => {
if (cleanedUpFor === mx) return;
cleanedUpFor = mx;
const cleaned = cleanupEncryptedBookmarks(bookmarksStore.getLatest(mx), isEncryptedRoomFor(mx));
if (cleaned) {
bookmarksStore
.enqueueWrite(
mx,
(current) => cleanupEncryptedBookmarks(current, isEncryptedRoomFor(mx)) ?? current,
)
.catch(() => {
// Retry on the next client (re)mount.
if (cleanedUpFor === mx) cleanedUpFor = null;
});
}
}, [mx]);
const addBookmark = useCallback(
(b: Bookmark) =>
bookmarksStore.enqueueWrite(mx, (current) => {
// Never upload plaintext for E2EE rooms — account data is server-readable.
const stored = toStorableBookmark(b, isEncryptedRoomFor(mx));
// Avoid duplicates
const filtered = current.filter((bk) => bk.eventId !== b.eventId);
let next = [b, ...filtered];
const filtered = current.filter((bk) => bk.eventId !== stored.eventId);
let next = [stored, ...filtered];
if (next.length > MAX_BOOKMARKS) {
next = next.slice(0, MAX_BOOKMARKS);
}
+35
View File
@@ -0,0 +1,35 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isDeafenKeyDown, isPttKeyDown } from './useCallHotkeys';
const key = (
code: string,
mods: Partial<Record<'repeat' | 'ctrlKey' | 'altKey' | 'metaKey' | 'shiftKey', boolean>> = {},
) => ({
code,
repeat: false,
ctrlKey: false,
altKey: false,
metaKey: false,
shiftKey: false,
...mods,
});
test('isPttKeyDown: matches the bare key and Shift, ignores repeats and Ctrl/Alt/Meta chords (#23)', () => {
assert.equal(isPttKeyDown(key('Space'), 'Space'), true);
assert.equal(isPttKeyDown(key('Space', { shiftKey: true }), 'Space'), true);
assert.equal(isPttKeyDown(key('KeyV'), 'Space'), false);
assert.equal(isPttKeyDown(key('Space', { repeat: true }), 'Space'), false);
assert.equal(isPttKeyDown(key('Space', { ctrlKey: true }), 'Space'), false);
assert.equal(isPttKeyDown(key('Space', { altKey: true }), 'Space'), false);
assert.equal(isPttKeyDown(key('Space', { metaKey: true }), 'Space'), false);
});
test('isDeafenKeyDown: matches only the bare key — any modifier (incl. Shift) is ignored (#23)', () => {
assert.equal(isDeafenKeyDown(key('KeyM'), 'KeyM'), true);
assert.equal(isDeafenKeyDown(key('KeyN'), 'KeyM'), false);
assert.equal(isDeafenKeyDown(key('KeyM', { repeat: true }), 'KeyM'), false);
assert.equal(isDeafenKeyDown(key('KeyM', { shiftKey: true }), 'KeyM'), false);
assert.equal(isDeafenKeyDown(key('KeyM', { ctrlKey: true }), 'KeyM'), false);
assert.equal(isDeafenKeyDown(key('KeyM', { metaKey: true }), 'KeyM'), false);
});
+214
View File
@@ -0,0 +1,214 @@
import { useEffect, useRef } from 'react';
import { atom, useSetAtom } from 'jotai';
import { CallEmbed, useCallControlState } from '../plugins/call';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
/**
* True while a push-to-talk key is held. Written by useCallHotkeys (mounted for
* the lifetime of the embed) and read by the PTT chip in CallControls, which
* only renders while the call room is selected.
*/
export const pttActiveAtom = atom(false);
type KeyLike = {
code: string;
repeat: boolean;
ctrlKey: boolean;
altKey: boolean;
metaKey: boolean;
shiftKey: boolean;
};
/**
* Whether a keydown should engage push-to-talk for `pttKey`.
* [Gitea #23] Ignore the PTT key with Ctrl/Alt/Meta held so it doesn't hijack
* OS/app chords (e.g. Cmd+Space) that happen to share the code. Shift is
* allowed through — Shift+Space is a harmless combo for PTT.
*/
export const isPttKeyDown = (e: KeyLike, pttKey: string): boolean =>
e.code === pttKey && !e.repeat && !e.ctrlKey && !e.altKey && !e.metaKey;
/**
* Whether a keydown should toggle deafen for `deafenKey`.
* [Gitea #23] Ignore the deafen key with any modifier held — with the default
* 'KeyM', Ctrl+M / Alt+M / Cmd+M are common OS/app chords that shouldn't also
* toggle deafen (and previously got preventDefault()ed).
*/
export const isDeafenKeyDown = (e: KeyLike, deafenKey: string): boolean =>
e.code === deafenKey && !e.repeat && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey;
// BUG-7: use ownerDocument.body so isEditable works inside the EC iframe
const isEditable = (el: HTMLElement): boolean => {
const tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
let node: HTMLElement | null = el;
while (node && node !== el.ownerDocument.body) {
if (node.contentEditable === 'true') return true;
if (node.contentEditable === 'false') return false;
node = node.parentElement;
}
return false;
};
// [Gitea #23] Don't swallow Space on a focused button/link/etc — PTT still
// engages the mic, but the key's default action (activating the control)
// is left alone so keyboard users can still Tab+Space the call buttons.
const isInteractive = (el: HTMLElement): boolean => {
const tag = el.tagName;
if (tag === 'BUTTON' || tag === 'A' || tag === 'SELECT') return true;
let node: HTMLElement | null = el;
while (node && node !== el.ownerDocument.body) {
const role = node.getAttribute('role');
if (role === 'button' || role === 'link' || role === 'menuitem' || role === 'tab') {
return true;
}
node = node.parentElement;
}
return false;
};
/**
* Bind listeners to the EC iframe's window and keep them bound across document
* reloads. [Gitea #60] Listeners registered on a contentWindow are discarded
* when that window navigates to a new document (crash recovery, in-widget
* navigation), so re-run `bind` from the iframe's `load` event and detach the
* previous set. Returns a cleanup that detaches everything.
*/
const bindIframeWindow = (
iframe: HTMLIFrameElement,
bind: (win: Window) => () => void,
): (() => void) => {
let unbind: (() => void) | undefined;
const attach = () => {
unbind?.();
const win = iframe.contentWindow;
unbind = win ? bind(win) : undefined;
};
attach();
iframe.addEventListener('load', attach);
return () => {
iframe.removeEventListener('load', attach);
unbind?.();
unbind = undefined;
};
};
/**
* Push-to-talk and deafen hotkeys for the active call.
*
* [Gitea #9] Mounted from CallUtils (CallEmbedProvider) so the bindings live as
* long as the embed — not only while the call room is the selected room. Before
* this lived in CallControls, so navigating away (PiP) or opening the in-call
* chat on mobile silently dropped PTT and the deafen key. Gated on `joined` so
* nothing is sent over the widget transport before EC's handler mounts.
*/
export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean): void {
const embed = joined ? callEmbed : undefined;
const [pttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey] = useSetting(settingsAtom, 'pttKey');
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
const { microphone } = useCallControlState(embed?.control);
const setPttActive = useSetAtom(pttActiveAtom);
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
const microphoneRef = useRef(microphone);
useEffect(() => {
microphoneRef.current = microphone;
}, [microphone]);
// Handle PTT mode toggle mid-call — save/restore mic state (I-4)
const pttModeRef = useRef(pttMode);
const micBeforePTTRef = useRef<boolean | null>(null);
useEffect(() => {
if (embed) {
if (pttMode && !pttModeRef.current) {
micBeforePTTRef.current = microphoneRef.current;
embed.control.setMicrophone(false);
} else if (!pttMode && pttModeRef.current) {
embed.control.setMicrophone(micBeforePTTRef.current ?? true);
micBeforePTTRef.current = null;
}
}
pttModeRef.current = pttMode;
}, [pttMode, embed]);
const pttActiveRef = useRef(false);
useEffect(() => {
if (!embed || !pttMode) return undefined;
const release = () => {
embed.control.pttActive = false;
embed.control.setMicrophone(false);
pttActiveRef.current = false;
setPttActive(false);
};
const onKeyDown = (e: KeyboardEvent) => {
if (!isPttKeyDown(e, pttKey)) return;
const target = e.target as HTMLElement;
if (isEditable(target)) return;
if (!isInteractive(target)) e.preventDefault();
// C-M5: mark PTT active BEFORE unmuting so the mic echo (onMediaState)
// doesn't treat this transient unmute as a user-initiated undeafen.
embed.control.pttActive = true;
if (!microphoneRef.current) embed.control.setMicrophone(true);
pttActiveRef.current = true;
setPttActive(true);
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code !== pttKey) return;
release();
};
// BUG-9: release a held key when focus leaves/enters so the mic can't stick
// open after the keyup lands elsewhere. [Gitea #27] Only when a PTT hold is
// actually in progress — the mic button is still usable in PTT mode, and a
// deliberate hands-free unmute must survive a click into the iframe/alt-tab.
const onFocusChange = () => {
if (pttActiveRef.current) release();
};
const bind = (win: Window) => {
win.addEventListener('keydown', onKeyDown);
win.addEventListener('keyup', onKeyUp);
win.addEventListener('blur', onFocusChange);
win.addEventListener('focus', onFocusChange);
return () => {
win.removeEventListener('keydown', onKeyDown);
win.removeEventListener('keyup', onKeyUp);
win.removeEventListener('blur', onFocusChange);
win.removeEventListener('focus', onFocusChange);
};
};
const unbindHost = bind(window);
// BUG-9: also wire iframe key/blur/focus so PTT works with focus in the iframe
const unbindIframe = bindIframeWindow(embed.iframe, bind);
return () => {
unbindHost();
unbindIframe();
// BUG-8: if the embed changes while PTT is active, release mic on cleanup
if (pttActiveRef.current) release();
};
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
}, [pttMode, pttKey, embed, setPttActive]);
useEffect(() => {
if (!embed) return undefined;
const onKeyDown = (e: KeyboardEvent) => {
if (!isDeafenKeyDown(e, deafenKey)) return;
if (isEditable(e.target as HTMLElement)) return;
e.preventDefault();
embed.control.toggleSound();
};
window.addEventListener('keydown', onKeyDown);
// C-L4: also bind the EC iframe window so the deafen key works when focus is
// inside the iframe (mirrors the PTT binding above).
const unbindIframe = bindIframeWindow(embed.iframe, (win) => {
win.addEventListener('keydown', onKeyDown);
return () => win.removeEventListener('keydown', onKeyDown);
});
return () => {
window.removeEventListener('keydown', onKeyDown);
unbindIframe();
};
}, [embed, deafenKey]);
}
+23 -3
View File
@@ -1,8 +1,8 @@
import { useEffect } from 'react';
import { useStore } from 'jotai';
import { Descendant } from 'slate';
import { roomIdToMsgDraftAtomFamily } from '../state/room/roomInputDrafts';
import { DRAFT_MSG_KEY_PREFIX, hasMsgDraft } from '../utils/draft';
import { useMatrixClient } from './useMatrixClient';
/**
* On startup, pre-fill the per-room message-draft atoms from their localStorage
@@ -13,11 +13,19 @@ import { DRAFT_MSG_KEY_PREFIX, hasMsgDraft } from '../utils/draft';
* (identical content), so composer restore is unaffected.
*
* Thread drafts (key contains `::`) are skipped — the nav indicator is room-level.
*
* [Gitea #41] Drafts are stored as `{ userId, nodes }` (RoomInput's persist
* path) so a draft written by a different account never gets hydrated into the
* currently logged-in user's session. A legacy draft (stored as a bare array,
* pre-dating user-scoping) has no userId to check, so it's treated as foreign
* and dropped rather than trusted.
*/
export function useHydrateMsgDrafts(): void {
const store = useStore();
const mx = useMatrixClient();
useEffect(() => {
const userId = mx.getUserId();
let keys: string[];
try {
keys = Object.keys(localStorage);
@@ -34,7 +42,19 @@ export function useHydrateMsgDrafts(): void {
try {
const stored = localStorage.getItem(key);
if (!stored) return;
const nodes = JSON.parse(stored) as Descendant[];
const parsed = JSON.parse(stored);
const foreign =
!parsed ||
typeof parsed !== 'object' ||
Array.isArray(parsed) ||
parsed.userId !== userId;
if (foreign) {
// Another account's (or a pre-scoping legacy) draft — never hydrate it,
// and drop it so it can't resurface for the next login either.
localStorage.removeItem(key);
return;
}
const nodes = parsed.nodes;
if (Array.isArray(nodes) && hasMsgDraft(nodes)) {
store.set(roomIdToMsgDraftAtomFamily(draftKey), nodes);
}
@@ -42,5 +62,5 @@ export function useHydrateMsgDrafts(): void {
// Ignore a malformed stored draft.
}
});
}, [store]);
}, [store, mx]);
}
+42
View File
@@ -0,0 +1,42 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PushRuleActionName, TweakName } from 'matrix-js-sdk';
import { getNotificationModeOptionsFromActions } from './useNotificationMode';
test('getNotificationModeOptionsFromActions: no tweaks -> no highlight, no sound value', () => {
const options = getNotificationModeOptionsFromActions([PushRuleActionName.Notify]);
assert.deepEqual(options, { soundValue: undefined, highlight: false });
});
test('getNotificationModeOptionsFromActions: picks up highlight: true', () => {
const options = getNotificationModeOptionsFromActions([
PushRuleActionName.Notify,
{ set_tweak: TweakName.Highlight, value: true },
]);
assert.equal(options.highlight, true);
});
test('getNotificationModeOptionsFromActions: highlight: false is not treated as set', () => {
const options = getNotificationModeOptionsFromActions([
PushRuleActionName.Notify,
{ set_tweak: TweakName.Highlight, value: false },
]);
assert.equal(options.highlight, false);
});
test('getNotificationModeOptionsFromActions: picks up a custom sound value', () => {
const options = getNotificationModeOptionsFromActions([
PushRuleActionName.Notify,
{ set_tweak: TweakName.Sound, value: 'ping.ogg' },
]);
assert.equal(options.soundValue, 'ping.ogg');
});
test('getNotificationModeOptionsFromActions: picks up both tweaks together', () => {
const options = getNotificationModeOptionsFromActions([
PushRuleActionName.Notify,
{ set_tweak: TweakName.Sound, value: 'ping.ogg' },
{ set_tweak: TweakName.Highlight, value: true },
]);
assert.deepEqual(options, { soundValue: 'ping.ogg', highlight: true });
});
+23
View File
@@ -49,6 +49,29 @@ export const getNotificationModeActions = (
return actions;
};
// Derive the options that would reproduce an existing rule's tweaks, so a mode
// switch rebuilds actions on top of them instead of silently dropping a
// `highlight` tweak (or a custom sound) that isn't part of the mode itself.
export const getNotificationModeOptionsFromActions = (
actions: PushRuleAction[],
): NotificationModeOptions => {
const soundTweak = actions.find(
(action) => typeof action === 'object' && action.set_tweak === TweakName.Sound,
);
const highlightTweak = actions.find(
(action) => typeof action === 'object' && action.set_tweak === TweakName.Highlight,
);
return {
soundValue:
soundTweak && typeof soundTweak === 'object' && typeof soundTweak.value === 'string'
? soundTweak.value
: undefined,
highlight:
!!highlightTweak && typeof highlightTweak === 'object' && highlightTweak.value !== false,
};
};
export type GetNotificationModeCallback = (mode: NotificationMode) => PushRuleAction[];
export const useNotificationModeActions = (
options?: NotificationModeOptions,
+69 -3
View File
@@ -1,4 +1,5 @@
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { createAccountDataListStore } from './createAccountDataListStore';
@@ -6,7 +7,10 @@ export type Reminder = {
roomId: string;
eventId: string;
timestamp: number;
message: string;
// Message preview. Account data is stored UNENCRYPTED on the homeserver, so
// this is omitted for E2EE rooms (see stripReminderText) and the toast resolves
// the text locally at fire time. Optional for entries stored before that policy.
message?: string;
};
const REMINDERS_KEY = 'io.lotus.reminders';
@@ -15,6 +19,43 @@ type RemindersContent = {
reminders: Reminder[];
};
/** Reduce a reminder to its non-text fields ({roomId, eventId, timestamp}). */
export const stripReminderText = (r: Reminder): Reminder => ({
roomId: r.roomId,
eventId: r.eventId,
timestamp: r.timestamp,
});
/** Apply the E2EE storage policy to a reminder about to be persisted. */
export const toStorableReminder = (
r: Reminder,
isEncryptedRoom: (roomId: string) => boolean,
): Reminder => (isEncryptedRoom(r.roomId) ? stripReminderText(r) : r);
/**
* One-time cleanup for entries persisted before the E2EE policy; `undefined`
* when nothing needs to change (so callers can skip the write).
*/
export const cleanupEncryptedReminders = (
reminders: Reminder[],
isEncryptedRoom: (roomId: string) => boolean,
): Reminder[] | undefined => {
let changed = false;
const next = reminders.map((r) => {
if (r.message !== undefined && isEncryptedRoom(r.roomId)) {
changed = true;
return stripReminderText(r);
}
return r;
});
return changed ? next : undefined;
};
const isEncryptedRoomFor =
(mx: MatrixClient) =>
(roomId: string): boolean =>
mx.getRoom(roomId)?.hasEncryptionStateEvent() ?? false;
// Shared, concurrency-safe store. See createAccountDataListStore for why the
// snapshot + write queue must be module-scoped (writes are serialized to avoid
// lost updates, since setAccountData replaces the whole content with no merge).
@@ -24,6 +65,9 @@ const remindersStore = createAccountDataListStore<Reminder[], RemindersContent>(
write: (reminders) => ({ reminders }),
});
// Once-per-client guard for the load-time cleanup (mirrors useBookmarks).
let cleanedUpFor: MatrixClient | null = null;
export function useReminders(): {
reminders: Reminder[];
addReminder: (r: Reminder) => Promise<void>;
@@ -33,8 +77,30 @@ export function useReminders(): {
const mx = useMatrixClient();
const reminders = remindersStore.useValue(mx);
// Strip text from entries saved (by older builds) for rooms that are encrypted,
// writing the store back at most once per client.
useEffect(() => {
if (cleanedUpFor === mx) return;
cleanedUpFor = mx;
if (cleanupEncryptedReminders(remindersStore.getLatest(mx), isEncryptedRoomFor(mx))) {
remindersStore
.enqueueWrite(
mx,
(current) => cleanupEncryptedReminders(current, isEncryptedRoomFor(mx)) ?? current,
)
.catch(() => {
if (cleanedUpFor === mx) cleanedUpFor = null;
});
}
}, [mx]);
const addReminder = useCallback(
(r: Reminder) => remindersStore.enqueueWrite(mx, (current) => [...current, r]),
(r: Reminder) =>
remindersStore.enqueueWrite(mx, (current) => [
...current,
// Never upload plaintext for E2EE rooms — account data is server-readable.
toStorableReminder(r, isEncryptedRoomFor(mx)),
]),
[mx],
);
+28
View File
@@ -0,0 +1,28 @@
import { Room } from 'matrix-js-sdk';
import { useMemo } from 'react';
import { useStateEvent } from './useStateEvent';
import { StateEvent } from '../../types/matrix/room';
import { RoomQualityContent } from '../utils/callQuality';
export type RoomCallPolicy = {
allowCamera: boolean;
allowScreenshare: boolean;
};
/**
* [Gitea #26] Shared room-level camera/screenshare policy, read from the
* `io.lotus.room_quality` state event. Absent/true = allowed; only an explicit
* `false` forbids. Hoisted out of `CallControls` so other call surfaces (e.g.
* the app-wide `CallStatus` bar) can apply the same gating.
*/
export const useRoomCallPolicy = (room: Room): RoomCallPolicy => {
const roomQualityEvent = useStateEvent(room, StateEvent.LotusRoomQuality);
return useMemo(() => {
const roomQuality = roomQualityEvent?.getContent<RoomQualityContent>();
return {
allowCamera: roomQuality?.allow_camera !== false,
allowScreenshare: roomQuality?.allow_screenshare !== false,
};
}, [roomQualityEvent]);
};
+93
View File
@@ -0,0 +1,93 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { MatrixClient } from 'matrix-js-sdk';
import { getLocalRoomNamesContent, setLocalRoomName } from './useRoomMeta';
// Minimal fake client. Mirrors the real SDK behavior that matters here:
// setAccountData resolves WITHOUT updating what getAccountData returns — the
// local cache only updates once the /sync echo is delivered via the
// AccountData listener. This is exactly the staleness that let two
// back-to-back renames clobber each other before the fix (issue #17).
const makeFakeMx = () => {
const accountData: Record<string, unknown> = {};
const listeners: Array<(e: { getType: () => string; getContent: () => unknown }) => void> = [];
const setAccountDataCalls: Array<{ type: string; content: unknown }> = [];
const mx = {
getAccountData: (type: string) => {
const content = accountData[type];
return content ? { getContent: () => content } : undefined;
},
setAccountData: (type: string, content: unknown) => {
setAccountDataCalls.push({ type, content });
// Deliberately do NOT update `accountData` here — the real SDK doesn't
// either. It only updates on the emitted echo below.
return Promise.resolve();
},
on: (_event: unknown, h: (e: { getType: () => string; getContent: () => unknown }) => void) => {
listeners.push(h);
},
removeListener: (
_event: unknown,
h: (e: { getType: () => string; getContent: () => unknown }) => void,
) => {
const i = listeners.indexOf(h);
if (i >= 0) listeners.splice(i, 1);
},
};
const emitEcho = (type: string, content: unknown) => {
accountData[type] = content;
listeners.forEach((h) => h({ getType: () => type, getContent: () => content }));
};
return {
mx: mx as unknown as MatrixClient,
emitEcho,
setAccountDataCalls,
};
};
test('back-to-back renames of different rooms both survive with no echo in between', async () => {
const { mx } = makeFakeMx();
// Rename room A, then room B, before either write's /sync echo has landed —
// the exact scenario from issue #17.
const writeA = setLocalRoomName(mx, '!a:example.org', 'Room A renamed');
const writeB = setLocalRoomName(mx, '!b:example.org', 'Room B renamed');
await Promise.all([writeA, writeB]);
const content = getLocalRoomNamesContent(mx);
assert.deepEqual(content.rooms, {
'!a:example.org': 'Room A renamed',
'!b:example.org': 'Room B renamed',
});
});
test("writes are serialized: the second write computes from the first write's result", async () => {
const { mx, setAccountDataCalls } = makeFakeMx();
await Promise.all([
setLocalRoomName(mx, '!a:example.org', 'A'),
setLocalRoomName(mx, '!b:example.org', 'B'),
]);
// The last PUT to the server must carry both renames — proof the second
// write's compute() saw the first write's in-memory result rather than a
// stale snapshot from before it landed.
const lastCall = setAccountDataCalls[setAccountDataCalls.length - 1];
assert.deepEqual(lastCall.content, {
rooms: { '!a:example.org': 'A', '!b:example.org': 'B' },
});
});
test('clearing a local name removes only that room', async () => {
const { mx } = makeFakeMx();
await setLocalRoomName(mx, '!a:example.org', 'A');
await setLocalRoomName(mx, '!b:example.org', 'B');
await setLocalRoomName(mx, '!a:example.org', '');
const content = getLocalRoomNamesContent(mx);
assert.deepEqual(content.rooms, { '!b:example.org': 'B' });
});
+48 -49
View File
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { ClientEvent, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { StateEvent } from '../../types/matrix/room';
import { useStateEvent } from './useStateEvent';
import { useMatrixClient } from './useMatrixClient';
import { getAccountData } from '../utils/accountData';
import { createAccountDataListStore } from './createAccountDataListStore';
export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => {
const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
@@ -40,73 +40,72 @@ export const LOCAL_ROOM_NAMES_KEY = 'io.lotus.room_names';
export type LocalRoomNamesContent = { rooms: Record<string, string> };
type LocalRoomNamesMap = Record<string, string>;
// Shared, concurrency-safe store. See createAccountDataListStore for why the
// snapshot + write queue must be module-scoped: setAccountData does not update
// the SDK's local cache (it only resolves once the /sync echo lands), so a
// plain read-modify-write against getAccountData can lose a rename that is
// still in flight when a second rename is issued (fixed: back-to-back renames
// of different rooms no longer clobber each other).
const roomNamesStore = createAccountDataListStore<LocalRoomNamesMap, LocalRoomNamesContent>({
eventType: LOCAL_ROOM_NAMES_KEY,
read: (content) =>
content && typeof content === 'object' && typeof content.rooms === 'object'
? content.rooms
: {},
write: (rooms) => ({ rooms }),
});
export function getLocalRoomNamesContent(
mx: ReturnType<typeof useMatrixClient>,
): LocalRoomNamesContent {
const raw: unknown = getAccountData<unknown>(mx, LOCAL_ROOM_NAMES_KEY);
if (raw && typeof raw === 'object' && 'rooms' in raw && typeof (raw as any).rooms === 'object') {
return raw as LocalRoomNamesContent;
}
return { rooms: {} };
return { rooms: roomNamesStore.getLatest(mx) };
}
/**
* Set (or clear, when `name` is empty) the local display name for a room.
* Routed through the shared write queue so back-to-back renames of different
* rooms are always computed from the latest snapshot instead of a stale one.
*/
export function setLocalRoomName(
mx: ReturnType<typeof useMatrixClient>,
roomId: string,
name: string,
): Promise<void> {
return roomNamesStore.enqueueWrite(mx, (current) => {
if (!name) {
const { [roomId]: _removed, ...rest } = current;
return rest;
}
return { ...current, [roomId]: name };
});
}
export const useLocalRoomName = (room: Room): string => {
const mx = useMatrixClient();
const getLocalName = useCallback((): string => {
const content = getLocalRoomNamesContent(mx);
return content.rooms[room.roomId] ?? room.name;
}, [mx, room]);
const [name, setName] = useState(getLocalName);
const localNames = roomNamesStore.useValue(mx);
const [name, setName] = useState(room.name);
useEffect(() => {
setName(getLocalName());
const handleAccountData = (event: MatrixEvent) => {
if (event.getType() !== LOCAL_ROOM_NAMES_KEY) return;
setName(getLocalName());
};
mx.on(ClientEvent.AccountData, handleAccountData);
setName(room.name);
const handleRoomNameChange: RoomEventHandlerMap[RoomEvent.Name] = () => {
setName(getLocalName());
setName(room.name);
};
room.on(RoomEvent.Name, handleRoomNameChange);
return () => {
mx.removeListener(ClientEvent.AccountData, handleAccountData);
room.removeListener(RoomEvent.Name, handleRoomNameChange);
};
}, [mx, room, getLocalName]);
}, [room]);
return name;
return localNames[room.roomId] ?? name;
};
export const useHasLocalRoomName = (roomId: string): boolean => {
const mx = useMatrixClient();
const check = useCallback((): boolean => {
const content = getLocalRoomNamesContent(mx);
return !!content.rooms[roomId];
}, [mx, roomId]);
const [hasLocal, setHasLocal] = useState(check);
useEffect(() => {
setHasLocal(check());
const handleAccountData = (event: MatrixEvent) => {
if (event.getType() !== LOCAL_ROOM_NAMES_KEY) return;
setHasLocal(check());
};
mx.on(ClientEvent.AccountData, handleAccountData);
return () => {
mx.removeListener(ClientEvent.AccountData, handleAccountData);
};
}, [mx, check]);
return hasLocal;
const localNames = roomNamesStore.useValue(mx);
return !!localNames[roomId];
};
export type RoomTopicContent = {
+35
View File
@@ -0,0 +1,35 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classifySessionChange, SessionIdentity } from './useSessionSync';
const alice: SessionIdentity = { userId: '@alice:hs', deviceId: 'DEV1', accessToken: 'tok-1' };
test('classifySessionChange: session removed elsewhere → removed', () => {
assert.equal(classifySessionChange(alice, null), 'removed');
});
test('classifySessionChange: nothing before or after → none', () => {
assert.equal(classifySessionChange(null, null), 'none');
});
test('classifySessionChange: session appeared → relogin', () => {
assert.equal(classifySessionChange(null, alice), 'relogin');
});
test('classifySessionChange: different user or device → relogin', () => {
assert.equal(classifySessionChange(alice, { ...alice, userId: '@bob:hs' }), 'relogin');
assert.equal(classifySessionChange(alice, { ...alice, deviceId: 'DEV2' }), 'relogin');
// Even when the access token also changed, the identity change wins.
assert.equal(
classifySessionChange(alice, { ...alice, deviceId: 'DEV2', accessToken: 'tok-2' }),
'relogin',
);
});
test('classifySessionChange: same user+device, new access token → rotated (no reload)', () => {
assert.equal(classifySessionChange(alice, { ...alice, accessToken: 'tok-2' }), 'rotated');
});
test('classifySessionChange: identical credentials (metadata-only rewrite) → none', () => {
assert.equal(classifySessionChange(alice, { ...alice }), 'none');
});
+53 -13
View File
@@ -1,5 +1,32 @@
import { useEffect } from 'react';
import { getFallbackSession, subscribeSessionChanges } from '../state/sessions';
import type { MatrixClient } from 'matrix-js-sdk';
import { getFallbackSession, Session, subscribeSessionChanges } from '../state/sessions';
/** The credential identity this tab is currently running on. */
export type SessionIdentity = Pick<Session, 'userId' | 'deviceId' | 'accessToken'>;
/**
* What an out-of-tab session change means for this tab:
* - `none` — nothing credential-relevant changed (metadata-only rewrite,
* or a duplicate storage event from the dual-write).
* - `removed` — the session disappeared (logout / localStorage.clear()).
* - `relogin` — a different user or device signed in.
* - `rotated` — same user + device, only the access token changed (an OIDC
* refresh performed by another tab).
*/
export type SessionChange = 'none' | 'removed' | 'relogin' | 'rotated';
/** Pure: classify a freshly-read session against the one this tab runs on. */
export const classifySessionChange = (
current: SessionIdentity | null,
next: SessionIdentity | null,
): SessionChange => {
if (!next) return current ? 'removed' : 'none';
if (!current) return 'relogin';
if (next.userId !== current.userId || next.deviceId !== current.deviceId) return 'relogin';
if (next.accessToken !== current.accessToken) return 'rotated';
return 'none';
};
/**
* Keep this tab in sync with session changes performed in other tabs/windows.
@@ -11,26 +38,39 @@ import { getFallbackSession, subscribeSessionChanges } from '../state/sessions';
* Default action is the safest one for auth-critical state — a full reload:
* - session REMOVED elsewhere (logout / localStorage.clear()) → the access
* token disappears, so we reload; the router bounces to auth on next boot.
* - session APPEARED or its access token CHANGED elsewhere (a fresh login or
* a token rotation) → we reload so the client re-initialises with the new
* credentials rather than running on a stale/revoked token.
* - session APPEARED or its user/device CHANGED elsewhere (a fresh login) →
* we reload so the client re-initialises with the new credentials rather
* than running on a stale/revoked token.
*
* A change that does not alter the access token (e.g. an OIDC metadata-only
* #16 — a same-user, same-device access-token ROTATION (a routine OIDC refresh
* in another tab) must NOT reload: that would drop in-progress calls/uploads
* every few minutes. Instead the new tokens are swapped into the running
* client in place. When no client is available yet we fall back to the reload.
*
* A change that does not alter the credentials (e.g. an OIDC metadata-only
* rewrite) is ignored, which also collapses the several storage events emitted
* by a single dual-write into at most one reload.
* by a single dual-write into at most one reaction.
*/
export const useSessionSync = (): void => {
export const useSessionSync = (mx?: MatrixClient): void => {
useEffect(() => {
// Snapshot the credential this tab booted with; compare against it so we
// only reload on a genuine credential change.
const initialAccessToken = getFallbackSession()?.accessToken ?? null;
// Snapshot the credential this tab runs on; compare against it so we only
// react to a genuine credential change. Updated on an in-place rotation.
let current: SessionIdentity | null = getFallbackSession() ?? null;
const unsubscribe = subscribeSessionChanges((session) => {
const nextAccessToken = session?.accessToken ?? null;
if (nextAccessToken === initialAccessToken) return;
const change = classifySessionChange(current, session);
if (change === 'none') return;
if (change === 'rotated' && mx && session) {
// Same opts object backs the SDK's TokenRefresher, so updating the
// refresh token here keeps its next refresh on the rotated token.
mx.setAccessToken(session.accessToken);
if (session.refreshToken) mx.http.opts.refreshToken = session.refreshToken;
current = session;
return;
}
window.location.reload();
});
return unsubscribe;
}, []);
}, [mx]);
};
+34
View File
@@ -0,0 +1,34 @@
import { Room } from 'matrix-js-sdk';
import { useMemo } from 'react';
import { useStateEvent } from './useStateEvent';
import { StateEvent } from '../../types/matrix/room';
import { VoiceLimitContent } from '../features/common-settings/general/RoomVoiceLimit';
import { useCallMembers, useCallSession } from './useCall';
import { useMatrixClient } from './useMatrixClient';
export type VoiceChannelFull = {
channelFull: boolean;
current: number;
max: number;
};
/**
* [Gitea #30] Voice channel user limit (`io.lotus.voice_limit`), shared between
* `CallPrescreen` and any other join path (e.g. the room-nav second-click join)
* so they agree on when a channel is full. 0/absent `max_users` means no limit.
*/
export const useVoiceChannelFull = (room: Room): VoiceChannelFull => {
const mx = useMatrixClient();
const callSession = useCallSession(room);
const callMembers = useCallMembers(callSession);
const limitEvent = useStateEvent(room, StateEvent.LotusVoiceLimit);
return useMemo(() => {
const maxUsers = limitEvent?.getContent<VoiceLimitContent>().max_users ?? 0;
// A user already counted in the session is rejoining and should not be blocked.
const alreadyMember = callMembers.some((m) => m.sender === mx.getSafeUserId());
const channelFull = maxUsers > 0 && !alreadyMember && callMembers.length >= maxUsers;
return { channelFull, current: callMembers.length, max: maxUsers };
}, [limitEvent, callMembers, mx]);
};
+11 -3
View File
@@ -6,7 +6,12 @@ import {
registerOidcClient,
} from 'matrix-js-sdk';
import { getOidcCallbackUrl, getOidcClientMetadata } from './oidcConfig';
import { cacheClientId, getCachedClientId, invalidateCachedClient } from './oidcState';
import {
cacheClientId,
getCachedClientId,
invalidateCachedClient,
isStaleClientError,
} from './oidcState';
export { completeAuthorizationCodeGrant };
@@ -42,8 +47,11 @@ export const startOidcLogin = async (issuer: string, homeserverBaseUrl: string):
});
window.location.assign(url);
} catch (e) {
// Drop a possibly-stale cached client so the next attempt re-registers.
invalidateCachedClient(issuer);
// #67 — drop the cached client so the next attempt re-registers, but ONLY
// when the failure says the client id itself was rejected. Network and
// discovery failures keep the cache, otherwise each retry would register
// yet another throwaway dynamic client at the provider.
if (isStaleClientError(e)) invalidateCachedClient(issuer);
throw e;
}
};
+22
View File
@@ -5,6 +5,7 @@ import {
cacheClientId,
invalidateCachedClient,
parseOidcCallbackParams,
isStaleClientError,
} from './oidcState';
const installStorage = (): Map<string, string> => {
@@ -60,3 +61,24 @@ test('parseOidcCallbackParams classifies success / error / invalid', () => {
assert.deepEqual(parseOidcCallbackParams('?code=only'), { kind: 'invalid' });
assert.deepEqual(parseOidcCallbackParams(''), { kind: 'invalid' });
});
test('isStaleClientError: only client-rejection shapes invalidate the cache', () => {
// OAuth error responses naming the client (oidc-client-ts ErrorResponse shape).
assert.equal(isStaleClientError({ error: 'invalid_client' }), true);
assert.equal(isStaleClientError({ error: 'unauthorized_client' }), true);
// HTTP 400/401 from the registration/authorize step (MatrixError-style).
assert.equal(isStaleClientError({ httpStatus: 400 }), true);
assert.equal(isStaleClientError({ httpStatus: 401 }), true);
assert.equal(isStaleClientError({ status: 401 }), true);
assert.equal(isStaleClientError({ httpStatus: 500 }), false);
assert.equal(isStaleClientError({ httpStatus: 400, status: 200 }), true); // httpStatus wins
});
test('isStaleClientError: transient / discovery / local failures keep the cache', () => {
assert.equal(isStaleClientError(new TypeError('Failed to fetch')), false);
assert.equal(isStaleClientError(new Error('Something went wrong with OIDC discovery')), false);
assert.equal(isStaleClientError({ error: 'server_error' }), false);
assert.equal(isStaleClientError(new Error('crypto.randomUUID is not a function')), false);
assert.equal(isStaleClientError(undefined), false);
assert.equal(isStaleClientError('invalid_client'), false); // bare strings are not error objects
});
+26
View File
@@ -40,6 +40,32 @@ export const invalidateCachedClient = (issuer: string): void => {
}
};
// OAuth error codes that mean the provider no longer recognises our client id
// (deleted/expired dynamic registration, or one issued by a different deployment).
const STALE_CLIENT_ERROR_CODES = new Set(['invalid_client', 'unauthorized_client']);
/**
* #67 — pure: does a `startOidcLogin` failure indicate the CACHED client id is
* bad? Only then is dropping the registration cache justified; a transient
* network error, an offline discovery fetch, or a local (`crypto.randomUUID`)
* failure must leave it alone, otherwise every retry performs a fresh dynamic
* registration and piles throwaway clients onto the provider. Recognised
* shapes: an OAuth error response (`{ error: 'invalid_client' }`, as thrown by
* oidc-client-ts `ErrorResponse`), or an HTTP 400/401 carried as
* `httpStatus`/`status` (MatrixError-style) from the registration/authorize step.
*/
export const isStaleClientError = (e: unknown): boolean => {
if (!e || typeof e !== 'object') return false;
const { error, httpStatus, status } = e as {
error?: unknown;
httpStatus?: unknown;
status?: unknown;
};
if (typeof error === 'string' && STALE_CLIENT_ERROR_CODES.has(error)) return true;
const code = typeof httpStatus === 'number' ? httpStatus : status;
return code === 400 || code === 401;
};
/** Parsed shape of the provider's redirect back to our callback URL. */
export type OidcCallbackParams =
| { kind: 'success'; code: string; state: string }
+23 -5
View File
@@ -26,6 +26,7 @@ import { settingsAtom } from '../../state/settings';
import { allInvitesAtom } from '../../state/room-list/inviteList';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
import { useSearchCacheInvalidation } from '../../utils/searchCacheInvalidation';
import {
getDirectRoomPath,
getHomeRoomPath,
@@ -51,7 +52,7 @@ import {
MuteTimerEntry,
loadMuteTimers,
unmuteRoom,
} from '../../features/room-nav/RoomNavItem';
} from '../../features/room-nav/muteTimers';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { toastQueueAtom } from '../../state/toast';
@@ -70,12 +71,12 @@ import {
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
} from '../../utils/threadNotifications';
// Grace period after the initial sync settles before invite notifications arm, so
// the async invite-atom population lands first and isn't mistaken for new invites.
const LogoSVG = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus.png');
const LogoUnreadSVG = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-unread.png');
const LogoHighlightSVG = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-highlight.png');
// Grace period after the initial sync settles before invite notifications arm, so
// the async invite-atom population lands first and isn't mistaken for new invites.
const INVITE_NOTIFY_ARM_DELAY_MS = 3000;
function SystemEmojiFeature() {
@@ -722,14 +723,23 @@ function ReminderMonitor() {
if (!firedRef.current.has(key)) {
firedRef.current.add(key);
const room = mx.getRoom(r.roomId);
const roomName = room?.name ?? 'Unknown Room';
// Reminders for E2EE rooms carry no text (account data is server-
// readable), so resolve the body from the local timeline at fire time.
const localBody = room?.findEventById(r.eventId)?.getContent()?.body;
const body =
r.message ??
(typeof localBody === 'string' && localBody
? localBody.slice(0, 120)
: `Reminder for a message in ${roomName}`);
const hashPath = mDirectsRef.current.has(r.roomId)
? getDirectRoomPath(r.roomId, r.eventId)
: getHomeRoomPath(r.roomId, r.eventId);
setToast({
id: `reminder-${key}`,
displayName: 'Reminder',
body: r.message,
roomName: room?.name ?? 'Unknown Room',
body,
roomName,
roomId: r.roomId,
hashPath,
});
@@ -896,10 +906,18 @@ function MsgDraftHydrator(): null {
return null;
}
// Gitea #14 — keeps the opt-in persistent search cache from outliving the
// plaintext it indexed (redactions + leaving/being banned from a room).
function SearchCacheInvalidationFeature(): null {
useSearchCacheInvalidation();
return null;
}
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
return (
<>
<MsgDraftHydrator />
<SearchCacheInvalidationFeature />
<SystemEmojiFeature />
<PageZoomFeature />
<FaviconUpdater />
+5 -3
View File
@@ -208,9 +208,11 @@ export function ClientRoot({ children }: ClientRootProps) {
);
useLogoutListener(mx);
// Cross-tab session sync: another tab logging out / in (access token changed
// in localStorage) reloads this tab so it never runs with stale credentials.
useSessionSync();
// Cross-tab session sync: another tab logging out / in reloads this tab so it
// never runs with stale credentials. A same-device token *rotation* (OIDC
// refresh in another tab) is swapped into the running client instead of
// reloading, which would drop an in-progress call/upload (#16).
useSessionSync(mx);
useEffect(() => {
if (loadState.status === AsyncStatus.Idle) {
+32 -3
View File
@@ -5,6 +5,31 @@ import { clearRecentForwardTargets } from './recentForwardTargets';
import { clearRecentGifs } from './recentGifs';
import { clearRecentStickers } from './recentStickers';
import { clearNavToActivePathStore } from './navToActivePath';
import { DRAFT_MSG_KEY_PREFIX } from '../utils/draft';
/**
* [Gitea #41] Wipe every persisted composer draft (`draft-msg-<roomId>`). Drafts
* hold decrypted, unsent message text with no user scoping, so leaving them in
* place across logout lets the next account on this device see (and send) the
* previous user's draft the moment they open the same room.
*/
const clearMsgDrafts = (): void => {
let keys: string[];
try {
keys = Object.keys(localStorage);
} catch {
return;
}
keys.forEach((key) => {
if (key.startsWith(DRAFT_MSG_KEY_PREFIX)) {
try {
localStorage.removeItem(key);
} catch {
// Best-effort — a single unreadable/blocked key must not abort the sweep.
}
}
});
};
/**
* Single auditable place that wipes the `localStorage` caches holding decrypted
@@ -18,6 +43,10 @@ import { clearNavToActivePathStore } from './navToActivePath';
* - `cinny_recent_forward_targets_v1` — recent forward contact/room graph (PII)
* - `cinny_recent_gifs_v1` / `cinny_recent_stickers_v1` — media the user sent
* - `navToActivePath<userId>` — per-space last-visited room paths (needs userId)
* - `draft-msg-*` — unsent composer drafts (decrypted message text, unscoped by
* user — see [Gitea #41]; previously deliberately preserved across logout
* (N98), which let the next account on this device see/send a prior user's
* draft, so this is no longer a "by design" exemption)
*
* NOT swept here (by design):
* - session credential keys → `removeFallbackSession()`
@@ -25,9 +54,8 @@ import { clearNavToActivePathStore } from './navToActivePath';
* bookmarks, user notes, status presets — themselves plaintext) → wiped by
* `mx.clearStores()` on both logout paths
* - the opt-in encrypted-search index (IndexedDB) → `deleteSearchCacheDatabase()`
* - unsent composer drafts (`draft-msg-*`) and the presence status message
* (`lotus-status-msg-*`) are deliberately preserved across a normal logout
* (N98); clearing them is a separate product decision
* - the presence status message (`lotus-status-msg-*`) is deliberately
* preserved across a normal logout; clearing it is a separate product decision
* - low-sensitivity UI/metadata residue (`io.lotus.mute_timers`, collapsed
* nav/space categories, `cinny_oidc_dynamic_clients`) is treated as
* preferences, not swept here
@@ -39,5 +67,6 @@ export const clearPlaintextCaches = (userId?: string): void => {
clearRecentForwardTargets();
clearRecentGifs();
clearRecentStickers();
clearMsgDrafts();
if (userId) clearNavToActivePathStore(userId);
};
+2
View File
@@ -25,8 +25,10 @@ export const setMarkedUnread = (
unread: boolean,
): Promise<unknown> =>
Promise.all([
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mx.setRoomAccountData(roomId, AccountDataEvent.MarkedUnread as any, { unread }),
// Best-effort mirror for older servers; never fail the primary write on it.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mx.setRoomAccountData(roomId, UNSTABLE_MARKED_UNREAD as any, { unread }).catch(() => undefined),
]);
+2
View File
@@ -13,6 +13,7 @@ export function getAccountData<T>(
mx: MatrixClient,
eventType: AccountDataEvent | string,
): T | undefined {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const event = (mx as any).getAccountData(eventType) as MatrixEvent | undefined;
return event?.getContent() as T | undefined;
}
@@ -22,5 +23,6 @@ export function setAccountData<T>(
eventType: AccountDataEvent | string,
content: T,
): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (mx as any).setAccountData(eventType, content);
}
+47
View File
@@ -0,0 +1,47 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isBindableCallKey } from './callKeybind';
test('isBindableCallKey rejects navigation-critical codes', () => {
[
'Escape',
'Tab',
'Enter',
'NumpadEnter',
'ArrowUp',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'Home',
'End',
'PageUp',
'PageDown',
].forEach((code) => {
assert.equal(isBindableCallKey(code), false, `${code} should be unbindable`);
});
});
test('isBindableCallKey rejects bare modifier codes', () => {
[
'ShiftLeft',
'ShiftRight',
'ControlLeft',
'ControlRight',
'AltLeft',
'AltRight',
'MetaLeft',
'MetaRight',
].forEach((code) => {
assert.equal(isBindableCallKey(code), false, `${code} should be unbindable`);
});
});
test('isBindableCallKey accepts ordinary keys', () => {
['Space', 'KeyM', 'KeyQ', 'Digit1', 'F13'].forEach((code) => {
assert.equal(isBindableCallKey(code), true, `${code} should be bindable`);
});
});
test('isBindableCallKey rejects the empty string', () => {
assert.equal(isBindableCallKey(''), false);
});
+33
View File
@@ -0,0 +1,33 @@
/**
* `KeyboardEvent.code` values the push-to-talk / push-to-deafen rebind must
* never accept. Binding one of these turns it into a keyboard trap (the call
* hotkey listener swallows the key everywhere outside an editable field for
* the rest of the call) or collides with a bare modifier chord.
*/
const UNBINDABLE_CALL_KEY_CODES = new Set<string>([
'Escape',
'Tab',
'Enter',
'NumpadEnter',
'ArrowUp',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'Home',
'End',
'PageUp',
'PageDown',
'ShiftLeft',
'ShiftRight',
'ControlLeft',
'ControlRight',
'AltLeft',
'AltRight',
'MetaLeft',
'MetaRight',
]);
/** Whether `code` is safe to bind as a call hotkey (PTT / push-to-deafen). */
export function isBindableCallKey(code: string): boolean {
return code.length > 0 && !UNBINDABLE_CALL_KEY_CODES.has(code);
}
+2
View File
@@ -70,6 +70,7 @@ export async function buildModelNode(
model: DenoiseModelId,
): Promise<DenoiseNode> {
if (model === 'dtln') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mod: any = await import(/* @vite-ignore */ `${BASE}workadventure/audio-worklet.js`);
const handle = await mod.createNoiseSuppressionAudioWorklet(ctx, { bypassUntilReady: true });
return { node: handle.node, dispose: () => handle.dispose() };
@@ -80,6 +81,7 @@ export async function buildModelNode(
// deepfilternet/v2/... Override its cdnUrl to our absolute base so nothing
// hits the upstream CDN. DeepFilterNet3Core builds the worklet node directly.
const dfnBase = new URL(`${BASE}deepfilternet`, window.location.href).href;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mod: any = await import(/* @vite-ignore */ `${BASE}deepfilternet/index.esm.js`);
const core = new mod.DeepFilterNet3Core({
sampleRate: sampleRateFor(model),
+5
View File
@@ -44,10 +44,12 @@ test('onTabPress fires only on Tab', () => {
test('preventScrollWithArrowKey prevents default only on arrows', () => {
const up = evt('ArrowUp', 38);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
preventScrollWithArrowKey(up as any);
assert.equal(up.prevented, true);
const a = evt('a', 65);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
preventScrollWithArrowKey(a as any);
assert.equal(a.prevented, false);
});
@@ -93,12 +95,14 @@ test('stopPropagation: stops unless an editable element is focused', () => {
// nothing focused → stops, returns true
withActive(null);
let k = makeKeyEvt();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assert.equal(stopPropagation(k.ev as any), true);
assert.equal(k.wasStopped(), true);
// input focused → does not stop, returns false
withActive({ nodeName: 'INPUT', getAttribute: () => null });
k = makeKeyEvt();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assert.equal(stopPropagation(k.ev as any), false);
assert.equal(k.wasStopped(), false);
@@ -108,5 +112,6 @@ test('stopPropagation: stops unless an editable element is focused', () => {
getAttribute: (a: string) => (a === 'contenteditable' ? 'true' : null),
});
k = makeKeyEvt();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assert.equal(stopPropagation(k.ev as any), false);
});
@@ -22,6 +22,7 @@ const makeMx = (
if (opts.forgetRejects) throw new Error('forget failed');
return {};
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
return { mx, calls };
};
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { M_POLL_KIND_DISCLOSED } from 'matrix-js-sdk';
// Pure helpers for poll display. matrix-js-sdk 41.7.0's PollStartEvent /
+1
View File
@@ -58,6 +58,7 @@ export function sendStateEvent<T extends object>(
content: T,
stateKey = '',
): Promise<ISendEventResponse> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return mx.sendStateEvent(roomId, eventType as any, content, stateKey);
}
@@ -0,0 +1,49 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { scheduleMessage } from './scheduledMessages';
// MSC4140 delayed events are PUT as a plaintext m.room.message, so scheduling
// must be refused outright for encrypted rooms — the composer hides the button,
// this guard stops any other caller from regressing it.
const makeMx = (encrypted: boolean | undefined) => {
const calls: unknown[][] = [];
const mx = {
getRoom: (_roomId: string) =>
encrypted === undefined ? null : { hasEncryptionStateEvent: () => encrypted },
http: {
authedRequest: (...args: unknown[]) => {
calls.push(args);
return Promise.resolve({ delay_id: 'delay-1' });
},
},
};
return { mx: mx as never, calls };
};
test('scheduleMessage throws and sends nothing for an encrypted room', async () => {
const { mx, calls } = makeMx(true);
await assert.rejects(
() => scheduleMessage(mx, '!enc:lotusguild.org', { body: 'secret' }, Date.now() + 60_000),
/encrypted rooms/,
);
assert.equal(calls.length, 0);
});
test('scheduleMessage still sends for an unencrypted room', async () => {
const { mx, calls } = makeMx(false);
const delayId = await scheduleMessage(
mx,
'!plain:lotusguild.org',
{ body: 'hi' },
Date.now() + 60_000,
);
assert.equal(delayId, 'delay-1');
assert.equal(calls.length, 1);
});
test('scheduleMessage sends when the room is unknown to the client', async () => {
const { mx, calls } = makeMx(undefined);
await scheduleMessage(mx, '!unknown:lotusguild.org', { body: 'hi' }, Date.now() + 60_000);
assert.equal(calls.length, 1);
});
+7
View File
@@ -12,6 +12,13 @@ export async function scheduleMessage(
content: IContent,
sendAtMs: number,
): Promise<string> {
// MSC4140 delayed events are PUT straight to /send/m.room.message, bypassing
// the SDK's encryptEventIfNeeded pipeline — the body would land on the server
// (and later in the timeline) in the clear. Refuse rather than leak; the
// composer also hides the Schedule button in encrypted rooms.
if (mx.getRoom?.(roomId)?.hasEncryptionStateEvent()) {
throw new Error('Scheduled messages are not supported in encrypted rooms.');
}
// A past/near target floors at 1000ms (send ~immediately) — an intentional,
// tested contract; the ScheduleMessageModal already guards ≥60s in the future.
const delayMs = Math.max(1000, Math.round(sendAtMs - Date.now()));
+27
View File
@@ -10,6 +10,7 @@ import {
saveRoomIndex,
clearRoom,
clearAll,
deleteRow,
deleteSearchCacheDatabase,
SearchCacheRow,
} from './searchCache';
@@ -128,6 +129,31 @@ test('searchCache IDB round-trip', { skip: !hasIdb }, async () => {
await deleteSearchCacheDatabase();
});
test('deleteRow: removes only the targeted [roomId, eventId] row', { skip: !hasIdb }, async () => {
await clearAll();
const rows: SearchCacheRow[] = [
{ roomId: '!r1', eventId: '$1', ts: 100, sender: '@a', body: 'hello' },
{ roomId: '!r1', eventId: '$2', ts: 200, sender: '@b', body: 'world' },
{ roomId: '!r2', eventId: '$1', ts: 300, sender: '@a', body: 'other room, same id' },
];
await putRows(rows);
await deleteRow('!r1', '$1');
const r1 = await queryRoom('!r1');
assert.deepEqual(
r1.map((x) => x.eventId),
['$2'],
);
// A same-eventId row in a different room is untouched (composite key).
assert.equal((await queryRoom('!r2')).length, 1);
// Deleting a row that doesn't exist is a silent no-op.
await assert.doesNotReject(deleteRow('!r1', '$does-not-exist'));
await deleteSearchCacheDatabase();
});
test('resilient helpers never throw when IDB is unavailable', { skip: hasIdb }, async () => {
// In this environment IndexedDB is absent; every call must degrade to a
// cache-miss rather than throwing.
@@ -138,6 +164,7 @@ test('resilient helpers never throw when IDB is unavailable', { skip: hasIdb },
assert.equal(await getCoverage('!r'), null);
await assert.doesNotReject(saveRoomIndex('!r', []));
await assert.doesNotReject(clearRoom('!r'));
await assert.doesNotReject(deleteRow('!r', '$1'));
await assert.doesNotReject(clearAll());
await assert.doesNotReject(deleteSearchCacheDatabase());
});
+17
View File
@@ -288,6 +288,23 @@ export const mergeSearchResults = <
);
};
/**
* Delete a single cached row, e.g. because its event was redacted. Gitea #14:
* without this, a redaction only ever removed the in-memory hit — the
* decrypted plaintext stayed in IndexedDB forever.
*/
export const deleteRow = async (roomId: string, eventId: string): Promise<void> => {
const db = await openDb();
if (!db) return;
try {
const tx = db.transaction(MESSAGES_STORE, 'readwrite');
tx.objectStore(MESSAGES_STORE).delete([roomId, eventId]);
await awaitTx(tx);
} catch {
// ignore
}
};
export const clearRoom = async (roomId: string): Promise<void> => {
const db = await openDb();
if (!db) return;
+43
View File
@@ -0,0 +1,43 @@
import { useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { searchCacheEnabledAtom } from '../state/searchCacheEnabled';
import { clearRoom, deleteRow } from './searchCache';
/**
* Gitea #14 — the persistent search cache (`searchCache.ts`) had no
* invalidation path other than logout or the manual "Clear cached index"
* button, so redacted messages and rooms the user left kept their decrypted
* plaintext searchable on disk indefinitely.
*
* While the cache is enabled, listen client-wide for redactions (delete the
* redacted row) and for leaving/being banned from a room (wipe the room's
* cached rows via the existing `clearRoom`).
*/
export const useSearchCacheInvalidation = (): void => {
const mx = useMatrixClient();
const cacheEnabled = useAtomValue(searchCacheEnabledAtom);
useEffect(() => {
if (!cacheEnabled) return undefined;
const onRedaction = (event: MatrixEvent, room: Room) => {
const redactedEventId = event.getAssociatedId();
if (redactedEventId) deleteRow(room.roomId, redactedEventId);
};
const onMyMembership = (room: Room, membership: string) => {
if (membership === 'leave' || membership === 'ban') {
clearRoom(room.roomId);
}
};
mx.on(RoomEvent.Redaction, onRedaction);
mx.on(RoomEvent.MyMembership, onMyMembership);
return () => {
mx.off(RoomEvent.Redaction, onRedaction);
mx.off(RoomEvent.MyMembership, onMyMembership);
};
}, [mx, cacheEnabled]);
};
+50 -2
View File
@@ -1,6 +1,23 @@
import { OidcTokenRefresher } from 'matrix-js-sdk';
import { AccessTokens, OidcTokenRefresher } from 'matrix-js-sdk';
import type { IdTokenClaims } from 'oidc-client-ts';
import { OidcSessionMeta, setFallbackSession } from '../app/state/sessions';
import { getFallbackSession, OidcSessionMeta, setFallbackSession } from '../app/state/sessions';
// Web Lock name serialising OIDC refreshes across tabs (Gitea #16). Every tab
// runs its own refresher against the SAME stored refresh token; with rotating
// refresh tokens the second tab to hit the issuer gets `invalid_grant` and is
// signed out. Holding the lock while refreshing (and re-reading storage once
// inside it) makes the loser adopt the winner's tokens instead.
const REFRESH_LOCK_NAME = 'lotus-oidc-refresh';
/**
* Run `fn` under the cross-tab refresh lock. Falls back to running it directly
* when the Web Locks API is unavailable (older browsers, non-secure contexts).
*/
export const withRefreshLock = <T>(fn: () => Promise<T>): Promise<T> => {
const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined;
if (!locks) return fn();
return locks.request(REFRESH_LOCK_NAME, fn);
};
/**
* OidcTokenRefresher that persists rotated tokens back to the fallback session,
@@ -30,6 +47,37 @@ export class LotusOidcTokenRefresher extends OidcTokenRefresher {
this.oidcRef = oidc;
}
// #16 — before touching the issuer, check whether another tab already rotated
// the tokens. `refreshToken` is exactly what the SDK currently holds, so a
// DIFFERENT stored refresh token (same user + device) means a sibling tab won
// the race: adopt its tokens instead of burning a possibly-consumed refresh
// token. Comparing refresh tokens (not access tokens) can never adopt the very
// token that just 401'd, so this cannot loop. The whole thing runs under a
// cross-tab Web Lock so concurrent refreshes serialise and the waiter sees
// the winner's write.
public doRefreshAccessToken(refreshToken: string): Promise<AccessTokens> {
return withRefreshLock(async () => {
const stored = getFallbackSession();
if (
stored &&
stored.userId === this.userIdRef &&
stored.deviceId === this.deviceIdRef &&
stored.refreshToken &&
stored.refreshToken !== refreshToken
) {
return {
accessToken: stored.accessToken,
refreshToken: stored.refreshToken,
expiry:
typeof stored.expiresInMs === 'number'
? new Date(Date.now() + stored.expiresInMs)
: undefined,
};
}
return super.doRefreshAccessToken(refreshToken);
});
}
// F5 — persist the new expiry so the stored `expiresAt` stays fresh across
// reloads instead of going stale. The SDK invokes persistTokens synchronously
// inside the refresh and passes the freshly-refreshed `expiry` (a Date) on the
+6 -28
View File
@@ -22,40 +22,18 @@ document.body.classList.add(configClass, varsClass);
// Register Service Worker
if ('serviceWorker' in navigator) {
const isProduction = import.meta.env.PROD;
const swUrl = isProduction
? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
: `/dev-sw.js?dev-sw`;
const swUrl =
import.meta.env.MODE === 'production'
? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
: `/dev-sw.js?dev-sw`;
const sendSessionToSW = () => {
const session = getFallbackSession();
pushSessionToSW(session?.baseUrl, session?.accessToken);
};
const registerServiceWorker = async () => {
try {
const registration = await navigator.serviceWorker.register(
swUrl,
isProduction
? undefined
: {
type: 'module',
scope: '/',
},
);
sendSessionToSW();
await navigator.serviceWorker.ready;
sendSessionToSW();
console.info('Service worker registered:', registration.scope);
} catch (error) {
console.error('Service worker registration failed:', error);
}
};
registerServiceWorker();
navigator.serviceWorker.register(swUrl).then(sendSessionToSW);
navigator.serviceWorker.ready.then(sendSessionToSW);
navigator.serviceWorker.addEventListener('message', (ev) => {
const { type } = ev.data ?? {};
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { globalStyle, keyframes, style } from '@vanilla-extract/css';
const glitch1 = keyframes({
+1 -1
View File
@@ -246,7 +246,7 @@ const vendorChunks = (id) => {
export default defineConfig({
appType: 'spa',
publicDir: './public/res',
publicDir: false,
base: buildConfig.base,
server: {
port: 8080,