Compare commits

..
26 Commits
Author SHA1 Message Date
jared 8794465034 Merge pull request 'fix(status): read the public Kuma page, with notices scoped by a prefix' (#486) from fix/status-banner-public-page into lotus
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Build & Quality Checks (push) Successful in 3m27s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 10s
CI / Playwright smoke (e2e) (push) Successful in 28m11s
2026-11-04 21:47:13 -05:00
jaredandClaude Opus 5.5 ace084c91c fix(status): read the public Kuma page, with notices scoped by a prefix
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Build & Quality Checks (pull_request) Successful in 3m39s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 17m51s
The banner read Kuma's "matrix" status page, which was merged into "public"
on 2026-10-11, so it has returned 404 since and the banner stayed silent
(by design it fails quiet). Point it at "public", whose Matrix group is now
split into Homeserver / Voice calls / Discord.

That page is shared with other services, so add an optional noticePrefix:
only incidents and maintenance titled "Matrix: ..." reach Lotus users, shown
without the prefix. A media server's maintenance can't say messages may be
interrupted, and the Discord group can't raise a banner at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-11-04 21:25:22 -05:00
jared c73a3f8b31 Merge pull request 'feat(calls): change noise suppression mid-call without rejoining' (#485) from feat/live-denoise-switch into lotus
CI / Secret scan (gitleaks) (push) Successful in 15s
CI / Build & Quality Checks (push) Successful in 1m41s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 22s
CI / Playwright smoke (e2e) (push) Successful in 25m24s
2026-10-30 21:40:26 -04:00
Lotus CIandClaude Opus 5.5 504899aa14 feat(calls): change noise suppression mid-call without rejoining
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 2m5s
CI / Build & Quality Checks (pull_request) Successful in 3m1s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 17m2s
Changing Noise suppression (off / browser-native / ML), the ML model, the
gate or "stack browser NS" in Settings now applies to the call you're in.
useCallDenoise sends the fork's io.lotus.set_denoise whenever those
settings change; CallControl join-gates it like set_quality and re-sends
it after an EC reconnect (the remounted fork handler starts again from
the join URL). The "requires rejoining the call" note is replaced.

Bumps Element Call to 0.28.0-lotus.4, which adds io.lotus.set_denoise and
fixes mic re-captures silently keeping the old noiseSuppression (the mic
level meter's clone held the old track open), the meter following the
old device after a mic switch, and voiceIsolation staying on in off/ML
modes (element-call#66).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-30 21:19:56 -04:00
jared 7d31b2b2f7 Merge pull request 'chore(deps): Element Call 0.28.0-lotus.3 (DeepFilterNet fixes)' (#484) from chore/ec-0.28.0-lotus.3 into lotus
CI / Secret scan (gitleaks) (push) Successful in 1m22s
CI / Build & Quality Checks (push) Successful in 2m12s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 3s
CI / Playwright smoke (e2e) (push) Successful in 18m27s
2026-10-30 20:20:00 -04:00
jared 61d2433721 Merge pull request 'fix(denoise-tester): play models in both ears; match the call's DeepFilterNet strength' (#483) from fix/denoise-tester-audio into lotus
CI / Build & Quality Checks (push) Successful in 1m36s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 5s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 18m59s
2026-10-30 19:30:49 -04:00
Lotus CIandClaude Opus 5.5 78b19f1ced chore(deps): Element Call 0.28.0-lotus.3 (DeepFilterNet strength 25, model-bytes guard)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Build & Quality Checks (pull_request) Successful in 1m52s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 20m55s
Picks up element-call#64 (DeepFilterNet no longer silently passes raw
audio through if its model is served Content-Encoding: gzip) and #65
(attenuation limit 60 -> 25 dB, fixing choppy word endings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-30 19:11:01 -04:00
Lotus CIandClaude Opus 5.5 2f5bcf2b91 fix(denoise-tester): play models in both ears; match the call's DeepFilterNet strength
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 21s
CI / Build & Quality Checks (pull_request) Successful in 7m3s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 27m13s
- Recorded clips often decode as stereo (Chrome Opus/WebM). The models
  only process channel 0, and the default channel mode ignores
  channelCount, so RNNoise/Speex played the cleaned audio in the LEFT EAR
  ONLY. Downmix to mono before the models (as Lotus Call does, #24) and
  pin the RNNoise/Speex/gate nodes to explicit mono. Verified in Chromium
  with a stereo speech clip through RNNoise: before L 16.1 / R 0.0, after
  L 16.0 / R 15.9.
- DeepFilterNet ran at attenuation limit 80 here vs 60 in calls; both
  muted the background outright, which sounds like the mic cutting out
  at word ends. Use 25, matching element-call#65.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-30 18:53:17 -04:00
jared d1a9625b1c Merge pull request 'feat(settings): make DTLN the default ML noise-suppression model' (#482) from feat/denoise-dtln-default into lotus
CI / Secret scan (gitleaks) (push) Successful in 47s
CI / Build & Quality Checks (push) Successful in 3m56s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 12s
CI / Playwright smoke (e2e) (push) Successful in 34m55s
2026-10-30 15:42:48 -04:00
Lotus CIandClaude Opus 5.5 9ab01ff6f6 feat(settings): make DTLN the default ML noise-suppression model
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 17s
CI / Build & Quality Checks (pull_request) Successful in 3m45s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 33m3s
Browser-native stays the overall default; this only changes which model
the ML option starts on.

Measured on real speech mixed with fan, keyboard, street and babble noise
(SNR 5/15 dB), scored against the clean voice:
- DTLN: most natural voice (best PESQ/STOI of the four), strong on fans
  and typing, ~9% of one core.
- DeepFilterNet 3: removes the most noise (typing, traffic) but voices
  can sound slightly muffled, ~30% of one core.
- RNNoise: steady noise only; little help with typing.
- Speex: barely reduces noise.
- No model removes other people talking in the background.

The model list is now ordered by recommendation, with descriptions and
ratings from these results. The "Transients" column is renamed
"Typing & clicks".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-30 15:05:31 -04:00
jared d2c3c56b18 Merge pull request 'fix(settings): measured CPU and download figures for the noise-suppression models' (#481) from fix/denoise-cpu-labels into lotus
CI / Build & Quality Checks (push) Successful in 1m33s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 23m23s
2026-10-29 21:57:14 -04:00
jared e2ce177930 Merge pull request 'fix(call): keep browser AGC on with ML noise suppression (DeepFilterNet "makes people quiet")' (#480) from fix/denoise-keep-agc into lotus
CI / Build & Quality Checks (push) Successful in 1m43s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Canceled after 10m55s
2026-10-29 21:44:22 -04:00
Lotus CIandClaude Opus 5.5 1ebc42863e fix(settings): measured CPU and download figures for the noise-suppression models
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 1m40s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Successful in 19m45s
The CPU column was guessed (DeepFilterNet "25-50%", DTLN "10-20%") with no
unit, so it read like Task Manager's whole-CPU figure and looked wildly off.
Measured with each model running exactly as Lotus Call runs it (real-time
speech, browser process CPU time from /proc minus a no-model baseline,
Ryzen 7 PRO 4750GE): DeepFilterNet ~30%, DTLN ~9%, RNNoise ~3%, Speex ~2%
of one core. Label it "one core" and explain the Task Manager relation.
DTLN's download was listed as ~4 MB but fetches ~20 MB (17 MB worklet with
the runtime inlined, plus 4 MB of models); DFN's ~18 MB is right (17.6 MB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-29 21:35:20 -04:00
Lotus CIandClaude Opus 5.5 58f7b18746 fix(call): keep browser AGC on with ML noise suppression
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Build & Quality Checks (pull_request) Successful in 1m30s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 16m57s
Choosing an ML model (RNNoise / Speex / DTLN / DeepFilterNet) turned the
browser's auto gain control off along with its noise suppressor, so the
call carried the mic's raw level: voices came out 10-20 dB quieter than
in the browser tier and DeepFilterNet "made people quiet". Measured with
speech through each model exactly as the call runs it, the models keep
the voice within ~1.5 dB (DTLN -0.1, DFN3 -1.7, RNNoise -1.3, Speex -1.5)
while cutting background noise 6-29 dB, so the loss was AGC, not the
models. Keep AGC on in every tier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-29 21:25:34 -04:00
jared 43445db5cc Merge pull request 'feat(bridge): Discord badge on bridged users and a header chip on bridged rooms' (#479) from feat/discord-bridge-badge into lotus
CI / Secret scan (gitleaks) (push) Successful in 18s
CI / Build & Quality Checks (push) Successful in 2m40s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 11s
CI / Playwright smoke (e2e) (push) Successful in 23m48s
2026-10-28 22:27:41 -04:00
Lotus CIandClaude Opus 5.5 3c33169297 feat(bridge): Discord badge on bridged users and a header chip on bridged rooms
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Build & Quality Checks (pull_request) Successful in 2m44s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 17m26s
People who come in from Discord through mautrix-discord now carry a
Discord icon next to their name in the timeline, member list and profile,
and a room bridged to a Discord channel shows a "Discord" chip in its
header that names the channel and server and opens it in Discord.

Ghosts are recognised by mautrix-discord's @discord_<snowflake> user IDs;
rooms by their m.bridge / uk.half-shot.bridge state (protocol discordgo).
Only http(s) channel links are made clickable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-28 22:07:03 -04:00
jared 3163829c57 Merge pull request 'fix(call): send theme changes only once the call frame is ready (postMessage origin error)' (#478) from fix/call-theme-before-ready into lotus
CI / Secret scan (gitleaks) (push) Successful in 14s
CI / Build & Quality Checks (push) Successful in 2m43s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 23m49s
2026-10-28 20:52:06 -04:00
jared 2264c79833 Merge pull request 'fix(denoise): ship the DeepFilterNet 3 worklet as a file so it starts in the desktop app' (#477) from fix/dfn-worklet-file into lotus
CI / Build & Quality Checks (push) Canceled after 9m42s
CI / Secret scan (gitleaks) (push) Canceled after 5s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-28 20:42:24 -04:00
Lotus CIandClaude Opus 5.5 2612f71c57 fix(call): send theme changes only once the call frame is ready (postMessage origin error)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m24s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Successful in 18m12s
Reported from the desktop app console on joining a call: "Failed to execute
postMessage on DOMWindow: The target origin provided (http://127.0.0.1:44548)
does not match the recipient window origin (http://localhost:44548)".

useCallThemeSync calls embed.setTheme as soon as the call view mounts. With
waitForIframeLoad: false that send goes out while the iframe is still on its
initial about:blank, which has the app origin, so the browser refuses the
message addressed to the call origin and the request hangs to a timeout. The
theme was right anyway (it is in the frame URL), so this was noise plus a
pending request.

setTheme now only records the theme until the widget API is ready; on ready
it sends the latest theme if it differs from the one the frame loaded with.

Reproduced with the app on localhost and the call frame on 127.0.0.1 (as on
desktop) against the local LiveKit stack: joining logged the error before,
none after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-28 20:29:53 -04:00
Lotus CIandClaude Opus 5.5 e8a04fee20 fix(denoise): ship the DeepFilterNet 3 worklet as a file so it starts in the desktop app
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 32s
CI / Build & Quality Checks (pull_request) Successful in 3m17s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 18m41s
Reported: DeepFilterNet 3 "did not start" in a desktop call. Console:
"Loading the script blob:http://127.0.0.1:44548/… violates … script-src",
then "[lotus] denoise setProcessor failed … Failed to load worklet module".
deepfilternet3-noise-filter inlines its AudioWorklet as a string and loads it
from a blob: URL; audio worklets fall under script-src, and the desktop CSP
has no blob: there (the web call page has no script-src, so web was fine).

Instead of loosening the CSP, the build now writes the inline worklet to
denoise/deepfilternet/dfn-worklet.js and patches the copied module to load
it by URL (script-src self). The build fails if the library changes shape.

Verified in Chromium under the exact desktop CSP with the built assets:
before, DeepFilterNet fails as reported; after, it creates its node and, fed
4 s of white noise, cuts it by 60 dB (its configured level). RNNoise, Speex
and DTLN load as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-28 20:19:02 -04:00
jared 843a48dd10 Merge pull request 'test(e2e): scheduled messages and offline outbox; retry rate-limited scheduled-message calls' (#476) from test/lotus-feature-e2e into lotus
CI / Build & Quality Checks (push) Successful in 3m3s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 2s
CI / Playwright smoke (e2e) (push) Successful in 24m26s
2026-10-28 07:03:36 -04:00
jared 6d86d25b7f Merge pull request 'fix(presence): your own status shows at once (and clears) in the member list' (#475) from fix/own-presence-display into lotus
CI / Build & Quality Checks (push) Successful in 3m20s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Canceled after 9m52s
2026-10-28 06:50:02 -04:00
Lotus CIandClaude Opus 5.5 56af9520bd test(e2e): scheduled messages and offline outbox; retry rate-limited scheduled-message calls
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m16s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 19m2s
New end-to-end coverage for two Lotus features the October audit listed as
untested, checked against the server at each step:
- scheduled-messages.spec: schedule (tray + one delayed event on the
  server), Send now (arrives once, delayed event gone), cancel (gone from the
  server, never sent).
- offline-outbox.spec: offline, three messages show "Queued. Will send when
  you are back online"; back online, the server has each exactly once and in
  order; the queued marks clear.

The scheduled-messages test found a real bug: Synapse rate-limits delayed
event management per user, and a quick Send now then Cancel got a 429, so
the tray showed "Could not cancel this message" although nothing was wrong.
All scheduled-message calls (schedule, list, send now, cancel, restart) now
wait out a 429 (Retry-After, bounded) and retry; scheduling reuses its txnId.
Unit test for the retry helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-28 06:40:51 -04:00
Lotus CIandClaude Opus 5.5 c247024bcd fix(presence): your own status shows at once (and clears) in the member list
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 48s
CI / Build & Quality Checks (pull_request) Successful in 3m31s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 17m34s
Reported: after changing your status it did not appear in the members
drawer until you switched rooms.

The SDK creates our own User object with `new User()` instead of
User.createUser, so its User.* events are never re-emitted on the client.
The shared presence store (PERF-1) listens on the client, so it never heard
our own presence changes; a remount (room switch) re-read the User and
showed it. The store now also refreshes on the ClientEvent.Event the SDK
emits for every sync presence event (after updating the User).

Also read the status from the latest presence event: the SDK only
overwrites presenceStatusMsg with a non-empty status_msg, so a cleared
status lingered (for everyone) until reload.

e2e: set a quick status, close settings, it shows in the drawer; clear it,
it goes. Fails without the fix (status never appears).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-28 06:28:36 -04:00
jared 29ece6a0c8 Merge pull request 'fix(auth): SSO confirmation works in the desktop app; allow switching to SSO' (#474) from fix/sso-uia-desktop into lotus
CI / Secret scan (gitleaks) (push) Successful in 23s
CI / Build & Quality Checks (push) Successful in 1m30s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 13s
CI / Playwright smoke (e2e) (push) Successful in 29m16s
2026-10-26 12:12:02 -04:00
Lotus CIandClaude Opus 5.5 f7f6059b33 fix(auth): SSO confirmation works in the desktop app; allow switching to SSO
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 1m50s
CI / Build & Quality Checks (pull_request) Successful in 3m15s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 18m29s
Reported by a desktop user: signing out other sessions with SSO looped.
Server logs: the SSO step completed every time, but each delete_devices
retry came without the session (a fresh 401 challenge), 4 attempts, none
succeeded. The app is the Windows desktop build (WebView2) and the SSO
pages opened in the system browser.

- SSOStage only offered "Continue" once window.open returned a window. The
  desktop app opens the page in the system browser (no window back), and a
  popup can lose its opener, so "authDone" never arrives and the only way
  on was starting over. Now "Continue" (plus "Open the sign-in page again")
  appears as soon as the page was opened; authDone still auto-continues.
- ActionUIA: when the server offers password and SSO, the first flow was
  forced (often password, which an SSO-only account may not know). Offer
  "Use SSO instead" / "Use your password instead".

e2e: desktop (window.open returns null) completes with the session; with
password offered first, SSO is one click away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-26 11:49:52 -04:00
40 changed files with 1471 additions and 167 deletions
+2 -1
View File
@@ -16,7 +16,8 @@
"statusPages": {
"matrix.lotusguild.org": {
"url": "https://isitup.lotusguild.org",
"slug": "matrix",
"slug": "public",
"noticePrefix": "Matrix:",
"groups": {
"homeserver": "Homeserver",
"calls": "Voice calls",
+31
View File
@@ -473,6 +473,37 @@ test.describe('local homeserver regression', () => {
await expect(page.getByText(/Status set\. Sharing it with others/)).toHaveCount(0);
});
test('own status shows in the members drawer without switching rooms, and clears', async ({
page,
}) => {
// The SDK creates our own User with `new User()` (no event re-emit), so the
// shared presence store missed our own presence until a remount. Reported:
// a new status only appeared after switching rooms.
await page.setViewportSize({ width: 1440, height: 900 });
const me = await ensureUser(uniq('own'));
const room = await createRoom(me, 'Own Status Room');
await sendText(me, room, 'hello');
await loginUI(page, me);
await openRoom(page, room);
await page.getByRole('button', { name: 'User Settings' }).click();
await page.getByRole('dialog').first().getByText('Account', { exact: true }).first().click();
await page
.getByRole('group', { name: 'Quick statuses' })
.getByRole('button', { name: /Gaming/ })
.click();
await page.keyboard.press('Escape');
await expect(page.getByText('🎮 Gaming')).toBeVisible({ timeout: 15_000 });
await page.getByRole('button', { name: 'User Settings' }).click();
await page.getByRole('dialog').first().getByText('Account', { exact: true }).first().click();
await page
.getByRole('button', { name: /^Clear/ })
.first()
.click();
await page.keyboard.press('Escape');
await expect(page.getByText('🎮 Gaming')).toHaveCount(0, { timeout: 15_000 });
});
test('touch: long-press opens the message action sheet (#166)', async ({ browser }) => {
const ctx = await browser.newContext({ ...devices['Pixel 7'] });
const page = await ctx.newPage();
+72
View File
@@ -0,0 +1,72 @@
import { test, expect } from '@playwright/test';
import {
api,
createRoom,
enc,
ensureUser,
hsReachable,
loginUI,
openRoom,
sendText,
uniq,
} from './localHs';
// Offline outbox (#112): messages sent while offline are queued, then
// delivered once each, in order, when the connection returns. Checked on the
// server, so duplicates or reordering would show.
test.describe('offline outbox', () => {
test.beforeAll(async () => {
test.skip(!(await hsReachable()), 'local homeserver not running');
});
test('queued while offline, delivered once each and in order when back', async ({
page,
context,
}) => {
test.setTimeout(120_000);
const me = await ensureUser(uniq('outbox'));
const room = await createRoom(me, 'Outbox Room');
await sendText(me, room, 'before going offline');
await loginUI(page, me);
await openRoom(page, room);
await expect(page.getByText('before going offline')).toBeVisible();
await context.setOffline(true);
const composer = page.getByRole('textbox', { name: /message/i }).last();
const texts = [uniq('one '), uniq('two '), uniq('three ')];
for (const text of texts) {
// eslint-disable-next-line no-await-in-loop
await composer.fill(text);
// eslint-disable-next-line no-await-in-loop
await composer.press('Enter');
}
for (const text of texts) {
const item = page.locator('[data-message-item]', { hasText: text });
// eslint-disable-next-line no-await-in-loop
await expect(item).toHaveCount(1);
// eslint-disable-next-line no-await-in-loop
await expect(item.getByLabel(/^Queued\. Will send when you're back online/)).toBeVisible({
timeout: 20_000,
});
}
await context.setOffline(false);
const bodiesOnServer = async () => {
const res = await api<{ chunk: { type: string; content?: { body?: string } }[] }>(
'GET',
`/_matrix/client/v3/rooms/${enc(room)}/messages?dir=f&limit=100`,
me.token,
);
return res.chunk
.filter((e) => e.type === 'm.room.message' && texts.includes(e.content?.body ?? ''))
.map((e) => e.content?.body);
};
await expect.poll(bodiesOnServer, { timeout: 60_000, intervals: [1000] }).toEqual(texts);
for (const text of texts) {
// eslint-disable-next-line no-await-in-loop
await expect(
page.locator('[data-message-item]', { hasText: text }).getByLabel(/^Queued/),
).toHaveCount(0, { timeout: 20_000 });
}
});
});
+102
View File
@@ -0,0 +1,102 @@
import { test, expect, Page } from '@playwright/test';
import {
api,
createRoom,
enc,
ensureUser,
hsReachable,
loginUI,
openRoom,
sendText,
uniq,
} from './localHs';
// Scheduled messages (MSC4140 delayed events). Each step is checked on the
// server too, so this proves the delayed event really exists, fires and is
// cancelled, not just that the tray looks right.
type Delayed = { delayed_events?: { delay_id: string; room_id: string }[] };
const delayedOnServer = (token: string) =>
api<Delayed>('GET', '/_matrix/client/unstable/org.matrix.msc4140/delayed_events', token).then(
(r) => r.delayed_events ?? [],
);
// The tray remembers whether it is open; open it only if its rows are hidden.
const openTray = async (page: Page) => {
const rowButton = page.getByRole('button', { name: /^Cancel scheduled message: / });
if (await rowButton.count()) return;
await page.getByRole('button', { name: /^\d+ scheduled messages?$/ }).click();
await expect(rowButton.first()).toBeVisible();
};
const schedule = async (page: Page, text: string) => {
await page.getByRole('button', { name: 'Schedule message' }).click();
const dialog = page.getByRole('dialog', { name: 'Schedule message' });
await dialog.locator('#schedule-message-body').fill(text);
// Two hours ahead, in the browser's own time zone.
const { date, time } = await page.evaluate(() => {
const d = new Date(Date.now() + 2 * 60 * 60 * 1000);
const p = (n: number) => String(n).padStart(2, '0');
return {
date: `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`,
time: `${p(d.getHours())}:${p(d.getMinutes())}`,
};
});
await dialog.locator('#schedule-date').fill(date);
await dialog.locator('#schedule-time').fill(time);
await dialog.getByRole('button', { name: 'Schedule', exact: true }).click();
await expect(dialog).toHaveCount(0);
};
test.describe('scheduled messages', () => {
test.beforeAll(async () => {
test.skip(!(await hsReachable()), 'local homeserver not running');
});
test('schedule, send now, and cancel — the server agrees at each step', async ({ page }) => {
test.setTimeout(120_000);
const me = await ensureUser(uniq('sched'));
const room = await createRoom(me, 'Schedule Room');
await sendText(me, room, 'room is ready');
await loginUI(page, me);
await openRoom(page, room);
// 1. Schedule: tray shows it, the server holds one delayed event.
const first = uniq('later ');
await schedule(page, first);
await expect(page.getByRole('button', { name: '1 scheduled message' })).toBeVisible();
await expect
.poll(async () => (await delayedOnServer(me.token)).length, { intervals: [1000] })
.toBe(1);
expect((await delayedOnServer(me.token))[0].room_id).toBe(room);
await expect(page.locator('[data-message-item]', { hasText: first })).toHaveCount(0);
// 2. Send now: it arrives once, the delayed event is gone.
await openTray(page);
await page.getByRole('button', { name: /^Send scheduled message now: / }).click();
await expect(page.locator('[data-message-item]', { hasText: first })).toHaveCount(1, {
timeout: 20_000,
});
await expect
.poll(async () => (await delayedOnServer(me.token)).length, { intervals: [1000] })
.toBe(0);
// 3. Cancel: removed from the server and never sent.
const second = uniq('never ');
await schedule(page, second);
await expect
.poll(async () => (await delayedOnServer(me.token)).length, { intervals: [1000] })
.toBe(1);
await openTray(page);
await page.getByRole('button', { name: /^Cancel scheduled message: / }).click();
await expect
.poll(async () => (await delayedOnServer(me.token)).length, { intervals: [1000] })
.toBe(0);
await expect(page.getByRole('button', { name: /scheduled message/ })).toHaveCount(0);
const msgs = await api<{ chunk: { content?: { body?: string } }[] }>(
'GET',
`/_matrix/client/v3/rooms/${enc(room)}/messages?dir=b&limit=50`,
me.token,
);
expect(msgs.chunk.some((e) => e.content?.body === second)).toBe(false);
});
});
+83
View File
@@ -0,0 +1,83 @@
import { test, expect, Page } from '@playwright/test';
import { api, ensureUser, hsReachable, loginUI, PASSWORD } from './localHs';
// Signing out other devices with SSO confirmation. The desktop app opens the SSO
// page in the system browser (window.open gives no window back), so the dialog
// must still offer "Continue" — before the fix the only way on was starting
// over, in a loop (reported by a desktop SSO user). The server is mocked: the
// retry succeeds only if it carries the challenge's session.
const openDevicesAndSignOutOther = async (page: Page, flows: string[][]) => {
const sent: unknown[] = [];
await page.route('**/delete_devices', async (route) => {
const body = JSON.parse(route.request().postData() ?? '{}');
sent.push(body.auth ?? null);
if (body.auth?.session === 'S1') {
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
}
return route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({
session: 'S1',
flows: flows.map((stages) => ({ stages })),
params: {},
}),
});
});
await page.getByRole('button', { name: 'User Settings' }).click();
const dialog = page.getByRole('dialog').first();
await dialog.getByText('Privacy & security', { exact: true }).first().click();
const card = dialog
.getByText('Old laptop', { exact: true })
.locator('xpath=ancestor::*[.//button][1]');
await card.getByRole('button').nth(1).click(); // the delete chip
const bar = dialog.getByText(/Logout from selected devices/);
await bar
.locator('xpath=ancestor::*[.//button[normalize-space()="Logout"]][1]')
.getByRole('button', { name: 'Logout', exact: true })
.click();
return { sent, bar };
};
test.describe('SSO confirmation for signing out devices', () => {
test.beforeAll(async () => {
test.skip(!(await hsReachable()), 'local homeserver not running');
});
test('desktop: no popup window, Continue still completes it', async ({ page }) => {
test.setTimeout(120_000);
await page.addInitScript(() => {
window.open = () => null; // the desktop app hands the page to the system browser
});
const user = await ensureUser(`ssod${Date.now().toString(36)}`);
await api('POST', '/_matrix/client/v3/login', undefined, {
type: 'm.login.password',
identifier: { type: 'm.id.user', user: user.localpart },
password: PASSWORD,
initial_device_display_name: 'Old laptop',
});
await loginUI(page, user);
const { sent, bar } = await openDevicesAndSignOutOther(page, [['m.login.sso']]);
await page.getByRole('button', { name: 'Continue with SSO' }).click();
await page.getByRole('button', { name: 'Continue', exact: true }).click();
await expect(bar).toHaveCount(0);
expect(sent).toEqual([null, { session: 'S1' }]);
});
test('password offered first: SSO is still one click away', async ({ page }) => {
test.setTimeout(120_000);
const user = await ensureUser(`ssop${Date.now().toString(36)}`);
await api('POST', '/_matrix/client/v3/login', undefined, {
type: 'm.login.password',
identifier: { type: 'm.id.user', user: user.localpart },
password: PASSWORD,
initial_device_display_name: 'Old laptop',
});
await loginUI(page, user);
await openDevicesAndSignOutOther(page, [['m.login.password'], ['m.login.sso']]);
await expect(page.getByText('Account Password').first()).toBeVisible();
await page.getByRole('button', { name: 'Use SSO instead' }).click();
await expect(page.getByRole('button', { name: 'Continue with SSO' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Use your password instead' })).toBeVisible();
});
});
+4 -4
View File
@@ -81,7 +81,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.28.0-lotus.2",
"@lotusguild/element-call-embedded": "0.28.0-lotus.4",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -2695,9 +2695,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@lotusguild/element-call-embedded": {
"version": "0.28.0-lotus.2",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.28.0-lotus.2/element-call-embedded-0.28.0-lotus.2.tgz",
"integrity": "sha512-g3xaZ/i3RQ0mToWmjLi08HjSuKD3BpAllj9aEucPS8wh3Uwmz4HXa0tgF1hEN3dPrZR2BTWLQii5Mtfh4037vg==",
"version": "0.28.0-lotus.4",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.28.0-lotus.4/element-call-embedded-0.28.0-lotus.4.tgz",
"integrity": "sha512-g+1mkdpGqUkE7JJo3b2G0XBiwDFwEedWul20hwD0iBpDyW67OafjbhrzRwfROY8Q9bx6BnulJHiR6Ll98KjguA==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
+1 -1
View File
@@ -108,7 +108,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.28.0-lotus.2",
"@lotusguild/element-call-embedded": "0.28.0-lotus.4",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
+30 -3
View File
@@ -1,4 +1,5 @@
import React, { ReactNode } from 'react';
import React, { ReactNode, useState } from 'react';
import { Box, Button, Text } from 'folds';
import { AuthDict, AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk';
import { getUIAFlowForStages } from '../utils/matrix-uia';
import { useSupportedUIAFlows, useUIACompleted, useUIAFlow } from '../hooks/useUIAFlows';
@@ -23,7 +24,18 @@ type ActionUIAProps = {
export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIAProps) {
const mx = useMatrixClient();
const completed = useUIACompleted(authData);
const { getStageToComplete } = useUIAFlow(authData, ongoingFlow);
// The server may offer several ways to confirm (e.g. password or SSO). Start
// with the given one but let the user switch: an account that signs in with
// SSO may have no password it knows.
const supportedFlows = useSupportedUIAFlows(authData.flows ?? [], SUPPORTED_IN_APP_UIA_STAGES);
const [flow, setFlow] = useState<UIAFlow>(ongoingFlow);
const { getStageToComplete } = useUIAFlow(authData, flow);
const other = supportedFlows.find((f) => f.stages.join() !== flow.stages.join());
const otherLabel = other?.stages.includes(AuthType.Sso)
? 'Use SSO instead'
: other?.stages.includes(AuthType.Password)
? 'Use your password instead'
: undefined;
const stageToComplete = getStageToComplete();
@@ -31,7 +43,7 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
return (
<UIAFlowOverlay
currentStep={completed.length + 1}
stepCount={ongoingFlow.stages.length}
stepCount={flow.stages.length}
onCancel={onCancel}
>
{stageToComplete.type === AuthType.Password && (
@@ -50,6 +62,21 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
submitAuthDict={action}
/>
)}
{other && otherLabel && completed.length === 0 && (
<Box justifyContent="Center" style={{ marginTop: 8 }}>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={() => setFlow(other)}
>
<Text as="span" size="B300">
{otherLabel}
</Text>
</Button>
</Box>
)}
</UIAFlowOverlay>
);
}
+2
View File
@@ -60,6 +60,7 @@ import { SrOnly } from '../features/call-status/styles.css';
import { useCallHotkeys } from '../hooks/useCallHotkeys';
import { useAfkAutoMute } from '../hooks/useAfkAutoMute';
import { useCallQuality } from '../hooks/useCallQuality';
import { useCallDenoise } from '../hooks/useCallDenoise';
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
import { mDirectAtom } from '../state/mDirectList';
@@ -836,6 +837,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
useMutedTalkWarning(embed, joined);
useCallThemeSync(embed);
useCallQuality(embed);
useCallDenoise(embed);
useCallHangupEvent(
embed,
useCallback(() => {
+109
View File
@@ -0,0 +1,109 @@
import React from 'react';
import { Room } from 'matrix-js-sdk';
import { Chip, Icon, IconSrc, Text, Tooltip, TooltipProvider } from 'folds';
import {
BRIDGE_NETWORK_NAME,
BridgeNetwork,
describeRoomBridge,
getBridgedUserNetwork,
} from '../../utils/bridge';
import { useRoomBridges } from '../../hooks/useRoomBridges';
// Discord glyph (simple-icons, 24x24 viewBox like the folds icons).
const DiscordIcon: IconSrc = () => (
<path
fill="currentColor"
d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"
/>
);
const BRIDGE_ICON: Record<BridgeNetwork, IconSrc> = {
discord: DiscordIcon,
};
const BRIDGE_COLOR: Record<BridgeNetwork, string> = {
discord: '#5865F2',
};
type UserBridgeBadgeProps = {
userId: string;
size?: '50' | '100' | '200';
};
/** Network icon for a user who is bridged in from another chat network, else nothing. */
export function UserBridgeBadge({ userId, size = '100' }: UserBridgeBadgeProps) {
const network = getBridgedUserNetwork(userId);
if (!network) return null;
const label = `On ${BRIDGE_NETWORK_NAME[network]} (bridged)`;
return (
<TooltipProvider
position="Top"
tooltip={
<Tooltip>
<Text size="T200">{label}</Text>
</Tooltip>
}
>
{(ref) => (
<span
ref={ref}
role="img"
aria-label={label}
style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}
>
<Icon size={size} src={BRIDGE_ICON[network]} style={{ color: BRIDGE_COLOR[network] }} />
</span>
)}
</TooltipProvider>
);
}
type RoomBridgeChipProps = {
room: Room;
};
/** Header chip for a room bridged to another network; links to the channel when known. */
export function RoomBridgeChip({ room }: RoomBridgeChipProps) {
const bridges = useRoomBridges(room);
const bridge = bridges[0];
if (!bridge) return null;
const description = describeRoomBridge(bridge);
const linkProps = bridge.externalUrl
? { as: 'a' as const, href: bridge.externalUrl, target: '_blank', rel: 'noreferrer noopener' }
: {};
return (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>
{bridge.externalUrl
? `${description}. Open in ${BRIDGE_NETWORK_NAME[bridge.network]}.`
: description}
</Text>
</Tooltip>
}
>
{(triggerRef) => (
<Chip
ref={triggerRef}
size="400"
variant="Secondary"
radii="300"
aria-label={description}
before={
<Icon
size="50"
src={BRIDGE_ICON[bridge.network]}
style={{ color: BRIDGE_COLOR[bridge.network] }}
/>
}
{...linkProps}
>
<Text size="T200">{BRIDGE_NETWORK_NAME[bridge.network]}</Text>
</Chip>
)}
</TooltipProvider>
);
}
+1
View File
@@ -0,0 +1 @@
export * from './BridgeBadge';
@@ -9,14 +9,46 @@ import { keyframes } from '@vanilla-extract/css';
* component; this module is motion only.
*/
/**
* Lantern bob — a hung lantern rises a touch and sinks again on a long, lazy
* cycle, as if buoyed by warm air. translateY + a whisper of scale only; the
* per-lantern duration/delay desynchronise the swarm.
*/
export const animLanternBob = keyframes({
'0%': { transform: 'translate3d(0, 0, 0) scale(1)' },
'50%': { transform: 'translate3d(0, -2.2vh, 0) scale(1.015)' },
'100%': { transform: 'translate3d(0, 0, 0) scale(1)' },
});
/**
* Lantern pendulum — a gentle rotational sway about the top mount, so each
* lantern rocks like it hangs from a string. Pairs with the bob on a different
* period to read as organic drift rather than a metronome.
*/
export const animLanternSway = keyframes({
'0%': { transform: 'rotate(-2.4deg)' },
'50%': { transform: 'rotate(2.4deg)' },
'100%': { transform: 'rotate(-2.4deg)' },
});
/**
* Tassel sway — the silk tassel under a lantern trails its parent's motion with
* a wider, slightly lagging swing. transformOrigin is the top of the tassel.
*/
export const animTasselSway = keyframes({
'0%': { transform: 'rotate(5deg)' },
'50%': { transform: 'rotate(-5deg)' },
'100%': { transform: 'rotate(5deg)' },
});
/**
* Lantern inner glow — the warm light inside each lantern swells and dims, like
* a candle breathing. Opacity + scale only.
*/
export const animGlowBreathe = keyframes({
'0%': { opacity: '0.7' },
'50%': { opacity: '1' },
'100%': { opacity: '0.7' },
'0%': { transform: 'scale(0.94)', opacity: '0.55' },
'50%': { transform: 'scale(1.06)', opacity: '0.9' },
'100%': { transform: 'scale(0.94)', opacity: '0.55' },
});
/**
@@ -45,6 +77,16 @@ export const animPetalSway = keyframes({
'100%': { transform: 'translate3d(0, 0, 0)' },
});
/**
* Dragon drift — the gold dragon silhouette breathes and undulates almost
* imperceptibly across the scene. translate + scale + opacity only, very slow.
*/
export const animDragonDrift = keyframes({
'0%': { transform: 'translate3d(-2%, 0, 0) scale(1)', opacity: '0.42' },
'50%': { transform: 'translate3d(2%, -1%, 0) scale(1.04)', opacity: '0.6' },
'100%': { transform: 'translate3d(-2%, 0, 0) scale(1)', opacity: '0.42' },
});
/**
* Lacquer-tint breathing — a barely-there pulse of the warm red ambient wash so
* the static base feels alive without distracting motion.
@@ -65,13 +107,3 @@ export const animEmberRise = keyframes({
'80%': { opacity: '0.5' },
'100%': { transform: 'translate3d(0.6vw, -26vh, 0) scale(1)', opacity: '0' },
});
/**
* Lantern garland sway — the lantern set shifts a pixel or two side to side, as
* if in a breeze. Transform only.
*/
export const animGarlandSway = keyframes({
'0%': { transform: 'translate3d(-1.5px, 0, 0)' },
'50%': { transform: 'translate3d(1.5px, 0, 0)' },
'100%': { transform: 'translate3d(-1.5px, 0, 0)' },
});
@@ -1,10 +1,13 @@
import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animGarlandSway,
animLanternBob,
animLanternSway,
animTasselSway,
animGlowBreathe,
animPetalFall,
animPetalSway,
animDragonDrift,
animLacquerPulse,
animEmberRise,
} from './LunarNewYear.css';
@@ -28,6 +31,46 @@ const GOLD_HI = 'oklch(0.92 0.10 92)';
// data-URI so it costs a single GPU-composited layer (no DOM weight). The curve
// is intentionally abstract and very subtle — a calligraphic ribbon-body with a
// suggestion of a head, mane and tail arcing across the upper scene.
const dragonUri = ((): string => {
const svg =
`<svg xmlns='http://www.w3.org/2000/svg' width='760' height='320' viewBox='0 0 760 320'>` +
`<defs>` +
`<linearGradient id='g' x1='0' y1='0' x2='1' y2='0'>` +
`<stop offset='0' stop-color='oklch(0.86 0.13 88)' stop-opacity='0.85'/>` +
`<stop offset='0.55' stop-color='oklch(0.82 0.14 85)' stop-opacity='0.7'/>` +
`<stop offset='1' stop-color='oklch(0.78 0.13 80)' stop-opacity='0.45'/>` +
`</linearGradient>` +
`</defs>` +
`<g fill='none' stroke='url(%23g)' stroke-linecap='round' stroke-linejoin='round'>` +
// Sinuous body — a thick tapering serpentine ribbon.
`<path d='M30 180 C120 90 200 250 300 170 S470 60 560 150 S700 240 740 150' ` +
`stroke-width='26' opacity='0.5'/>` +
// Inner highlight running along the body for a calligraphic sheen.
`<path d='M30 180 C120 90 200 250 300 170 S470 60 560 150 S700 240 740 150' ` +
`stroke-width='7' opacity='0.7'/>` +
// Head + horn flourish at the leading end.
`<path d='M30 180 C10 160 8 130 26 120 M26 120 C36 112 50 116 52 130' ` +
`stroke-width='9' opacity='0.6'/>` +
// Mane / whisker strokes flaring back from the head.
`<path d='M44 134 C70 120 96 132 110 152 M40 150 C66 148 92 160 104 180' ` +
`stroke-width='5' opacity='0.45'/>` +
// Tail wisps.
`<path d='M740 150 C754 138 758 160 748 172 M726 158 C742 168 744 186 732 196' ` +
`stroke-width='5' opacity='0.45'/>` +
`</g></svg>`;
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
})();
type Lantern = {
left: number;
top: number;
scale: number;
bobDuration: number;
swayDuration: number;
delay: number;
opacity: number;
};
type Petal = {
left: number;
size: number;
@@ -47,6 +90,8 @@ type Ember = {
delay: number;
};
// A single five-petal plum blossom (gold), inline SVG so each petal sliver is
// one cheap element. Returned as a data-URI background painted on a square.
const blossomUri = ((): string => {
const petals = Array.from({ length: 5 }, (_, i) => {
const a = (i * 72 * Math.PI) / 180;
@@ -62,56 +107,28 @@ const blossomUri = ((): string => {
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
})();
// [Gitea #231] Lantern garland: one 480px SVG tile (four scallops) repeats
// across the width; small lanterns hang at the scallop low points, all within
// the top ~20px.
const TILE_W = 480;
const TILE_H = 20;
const SCALLOP = 120;
const tile = (body: string) =>
`url("data:image/svg+xml,${encodeURIComponent(
`<svg xmlns='http://www.w3.org/2000/svg' width='${TILE_W}' height='${TILE_H}' viewBox='0 0 ${TILE_W} ${TILE_H}'>${body}</svg>`,
)}")`;
const CORD = tile(
`<path d='M0 1 Q${SCALLOP / 2} 9 ${SCALLOP} 1 ${Array.from(
{ length: TILE_W / SCALLOP - 1 },
(_, i) => `T${(i + 2) * SCALLOP} 1`,
).join(' ')}' fill='none' stroke='${GOLD}' stroke-width='1.1' stroke-opacity='0.8'/>`,
);
const lanternSvg = (which: 0 | 1) =>
Array.from({ length: TILE_W / SCALLOP }, (_, k) => {
if (k % 2 !== which) return '';
const x = SCALLOP / 2 + k * SCALLOP;
return (
`<ellipse cx='${x}' cy='11.5' rx='9' ry='7' fill='${VERMILION}' opacity='0.28'/>` +
`<rect x='${x - 3}' y='5' width='6' height='1.6' rx='0.6' fill='${GOLD}'/>` +
`<ellipse cx='${x}' cy='11' rx='5.6' ry='4.4' fill='${CRIMSON}' stroke='${GOLD}' stroke-width='0.6'/>` +
`<path d='M${x - 2.4} 7.2 Q${x - 3.4} 11 ${x - 2.4} 14.8 M${x} 6.6 V15.4 M${x + 2.4} 7.2 Q${x + 3.4} 11 ${x + 2.4} 14.8' fill='none' stroke='${GOLD}' stroke-width='0.4' stroke-opacity='0.7'/>` +
`<ellipse cx='${x - 1.6}' cy='9.6' rx='1.6' ry='1.2' fill='${GOLD_HI}' opacity='0.45'/>` +
`<rect x='${x - 2.4}' y='15.2' width='4.8' height='1.4' rx='0.5' fill='${GOLD}'/>` +
`<path d='M${x} 16.6 V19.4' stroke='${GOLD}' stroke-width='0.9' stroke-linecap='round'/>`
);
}).join('');
const LANTERNS_A = tile(lanternSvg(0));
const LANTERNS_B = tile(lanternSvg(1));
export function LunarNewYearOverlay({ reduced }: SeasonalOverlayProps) {
const garlandLayer = (image: string, animated: boolean, delay = 0): React.CSSProperties => ({
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: `${TILE_H}px`,
backgroundImage: image,
backgroundRepeat: 'repeat-x',
backgroundSize: `${TILE_W}px ${TILE_H}px`,
backgroundPosition: '-20px 0',
animation:
reduced || !animated
? 'none'
: `${animGarlandSway} 5.5s ease-in-out ${delay}s infinite, ${animGlowBreathe} 3.8s ease-in-out ${delay}s infinite`,
willChange: reduced || !animated ? undefined : 'transform, opacity',
});
// Paper lanterns strung across the upper third, gently staggered in depth.
const lanterns = useMemo<Lantern[]>(() => {
const slots = [
{ left: 9, top: 7, scale: 1.0 },
{ left: 27, top: 13, scale: 0.82 },
{ left: 46, top: 6, scale: 1.12 },
{ left: 64, top: 15, scale: 0.78 },
{ left: 82, top: 9, scale: 0.95 },
{ left: 92, top: 20, scale: 0.7 },
];
return slots.map((s, i) => ({
left: s.left,
top: s.top,
scale: s.scale,
bobDuration: 7 + rand(i + 1) * 4,
swayDuration: 5.5 + rand(i + 4) * 3,
delay: -rand(i + 7) * 6,
opacity: 0.78 + rand(i + 2) * 0.18,
}));
}, []);
// Drifting gold plum-blossom petals — two parallax bands (far small/dim/slow,
// near large/bright/fast) for depth.
const petals = useMemo<Petal[]>(() => {
@@ -168,33 +185,216 @@ export function LunarNewYearOverlay({ reduced }: SeasonalOverlayProps) {
inset: 0,
contain: 'layout paint style',
backgroundImage: [
`radial-gradient(120% 70% at 50% -8%, ${CRIMSON.replace(')', ' / 0.09)')} 0%, transparent 52%)`,
`linear-gradient(180deg, oklch(0.55 0.20 28 / 0.035) 0%, transparent 22%)`,
`radial-gradient(120% 80% at 50% -8%, ${CRIMSON.replace(')', ' / 0.16)')} 0%, transparent 56%)`,
`radial-gradient(90% 70% at 50% 112%, oklch(0.42 0.17 28 / 0.1) 0%, transparent 60%)`,
`linear-gradient(180deg, oklch(0.55 0.20 28 / 0.07) 0%, transparent 26%, transparent 82%, oklch(0.40 0.16 28 / 0.08) 100%)`,
].join(','),
animation: reduced ? 'none' : `${animLacquerPulse} 13s ease-in-out infinite`,
willChange: reduced ? undefined : 'opacity',
}}
/>
{/* Warm vignette frame — a faint crimson rim, clear center. */}
{/* Imperial-gold dragon silhouette arcing across the upper scene — a
single composited SVG layer, blurred and screen-blended so it reads as
an ethereal gilt apparition, never a hard graphic. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '8%',
left: '-6%',
right: '-6%',
height: '46%',
contain: 'layout paint style',
backgroundImage: dragonUri,
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
backgroundSize: 'contain',
mixBlendMode: 'screen',
filter: 'blur(1.1px)',
opacity: reduced ? 0.52 : undefined,
animation: reduced ? 'none' : `${animDragonDrift} 30s ease-in-out infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
{/* Warm vignette frame — crimson edges, clear center, with a faint cheap
backdrop-filter for a silken haze around the rim. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
backdropFilter: 'blur(0.4px) saturate(1.05)',
WebkitBackdropFilter: 'blur(0.4px) saturate(1.05)',
backgroundImage:
'radial-gradient(135% 120% at 50% 40%, transparent 60%, oklch(0.55 0.16 28 / 0.05) 100%)',
'radial-gradient(135% 120% at 50% 40%, transparent 54%, oklch(0.55 0.16 28 / 0.06) 76%, oklch(0.40 0.16 28 / 0.16) 100%)',
}}
/>
{/* [Gitea #231] A garland of small paper lanterns on a scalloped cord, all
within the top ~20px so it stays above header icons and text (the old
lanterns hung 7-20% down, over Create Room, search boxes and messages).
Two lantern sets glow and sway a little, alternately. */}
<div aria-hidden="true" style={garlandLayer(CORD, false)} />
<div aria-hidden="true" style={garlandLayer(LANTERNS_A, true, 0)} />
<div aria-hidden="true" style={garlandLayer(LANTERNS_B, true, -2.2)} />
{/* The garland string the lanterns hang from — a faint warm line. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '6%',
contain: 'layout paint style',
backgroundImage: `radial-gradient(140% 80% at 50% -40%, ${GOLD.replace(
')',
' / 0.14)',
)} 0%, transparent 70%)`,
}}
/>
{/* Paper lanterns. Each is a hung group: a sway wrapper rotating about its
mount, an inner bob, then the lantern body (glow + ribs + caps) and a
trailing tassel. */}
{lanterns.map((l, i) => {
const W = 30 * l.scale;
const H = 38 * l.scale;
const cap = Math.max(8, W * 0.5);
return (
<div
key={`lantern-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${l.left}%`,
top: `${l.top}%`,
marginLeft: `${-W / 2}px`,
transformOrigin: 'top center',
opacity: l.opacity,
animation: reduced
? 'none'
: `${animLanternSway} ${l.swayDuration}s ease-in-out ${l.delay}s infinite`,
willChange: reduced ? undefined : 'transform',
}}
>
<div
style={{
animation: reduced
? 'none'
: `${animLanternBob} ${l.bobDuration}s ease-in-out ${l.delay}s infinite`,
willChange: reduced ? undefined : 'transform',
}}
>
{/* short cord from the string to the top cap */}
<div
style={{
width: '2px',
height: `${10 * l.scale}px`,
margin: '0 auto',
background: `linear-gradient(${GOLD}, ${GOLD.replace(')', ' / 0.3)')})`,
}}
/>
{/* top gold cap */}
<div
style={{
width: `${cap}px`,
height: `${4 * l.scale}px`,
margin: '0 auto',
borderRadius: `${2 * l.scale}px`,
background: `linear-gradient(90deg, ${GOLD.replace(
')',
' / 0.6)',
)}, ${GOLD_HI}, ${GOLD.replace(')', ' / 0.6)')})`,
boxShadow: `0 0 ${5 * l.scale}px ${GOLD.replace(')', ' / 0.55)')}`,
}}
/>
{/* lantern body */}
<div
style={{
position: 'relative',
width: `${W}px`,
height: `${H}px`,
margin: `${1 * l.scale}px auto`,
borderRadius: '50% / 42%',
background: `radial-gradient(circle at 38% 32%, ${VERMILION.replace(
')',
' / 0.95)',
)} 0%, ${CRIMSON} 58%, oklch(0.40 0.18 26 / 0.95) 100%)`,
border: `${1.2 * l.scale}px solid ${GOLD.replace(')', ' / 0.8)')}`,
boxShadow: `0 0 ${16 * l.scale}px ${CRIMSON.replace(
')',
' / 0.5)',
)}, inset 0 0 ${10 * l.scale}px oklch(0.78 0.16 60 / 0.35)`,
overflow: 'hidden',
}}
>
{/* breathing inner candle glow */}
<div
style={{
position: 'absolute',
left: '50%',
top: '52%',
width: `${W * 0.6}px`,
height: `${H * 0.55}px`,
marginLeft: `${-W * 0.3}px`,
marginTop: `${-H * 0.275}px`,
borderRadius: '50%',
background: `radial-gradient(circle, ${GOLD_HI.replace(
')',
' / 0.9)',
)} 0%, oklch(0.80 0.16 65 / 0.5) 45%, transparent 75%)`,
filter: 'blur(1px)',
animation: reduced
? 'none'
: `${animGlowBreathe} ${l.bobDuration * 0.7}s ease-in-out ${
l.delay
}s infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
{/* vertical paper ribs */}
<div
style={{
position: 'absolute',
inset: 0,
backgroundImage: `repeating-linear-gradient(90deg, transparent 0, transparent ${
W / 6 - 0.6
}px, ${GOLD.replace(')', ' / 0.18)')} ${W / 6 - 0.6}px, ${GOLD.replace(
')',
' / 0.18)',
)} ${W / 6}px)`,
}}
/>
</div>
{/* bottom gold cap */}
<div
style={{
width: `${cap}px`,
height: `${4 * l.scale}px`,
margin: '0 auto',
borderRadius: `${2 * l.scale}px`,
background: `linear-gradient(90deg, ${GOLD.replace(
')',
' / 0.6)',
)}, ${GOLD_HI}, ${GOLD.replace(')', ' / 0.6)')})`,
}}
/>
{/* swaying silk tassel */}
<div
style={{
width: `${2 * l.scale}px`,
height: `${16 * l.scale}px`,
margin: '0 auto',
transformOrigin: 'top center',
background: `linear-gradient(${CRIMSON}, ${GOLD.replace(')', ' / 0.8)')})`,
borderRadius: '1px',
animation: reduced
? 'none'
: `${animTasselSway} ${l.swayDuration * 0.8}s ease-in-out ${l.delay}s infinite`,
willChange: reduced ? undefined : 'transform',
}}
/>
</div>
</div>
);
})}
{/* Drifting gold plum-blossom petals (motion only). Static settled
blossoms render below for the reduced/preview scene. */}
+23 -7
View File
@@ -12,6 +12,12 @@ export function SSOStage({
}) {
const { errorCode, error, session } = stageData;
const [ssoWindow, setSSOWindow] = useState<Window>();
// Whether the SSO page was opened. Not the same as having a window handle:
// the desktop app opens it in the system browser and gets none back, and a
// popup can lose its link to us (cross-origin opener policy on the identity
// provider). In both cases "authDone" never arrives, so "Continue" must still
// be offered once the page was opened, or the only way on is starting over.
const [started, setStarted] = useState(false);
const handleSubmit = useCallback(() => {
submitAuthDict({
@@ -22,6 +28,7 @@ export function SSOStage({
const handleContinue = () => {
const w = window.open(ssoRedirectURL, '_blank');
setSSOWindow(w ?? undefined);
setStarted(true);
};
useEffect(() => {
@@ -74,7 +81,9 @@ export function SSOStage({
gap="400"
>
<Text size="T200">
To perform this action you need to authenticate yourself by SSO login.
{started
? 'Finish signing in on the page that opened, then come back here and select Continue.'
: 'To do this, confirm it’s you by signing in with SSO.'}
</Text>
{errorCode && (
<Box alignItems="Center" gap="100" style={{ color: color.Critical.Main }}>
@@ -85,12 +94,19 @@ export function SSOStage({
</Box>
)}
{ssoWindow ? (
<Button variant="Primary" onClick={handleSubmit}>
<Text as="span" size="B400">
Continue
</Text>
</Button>
{started ? (
<Box direction="Column" gap="200">
<Button variant="Primary" onClick={handleSubmit}>
<Text as="span" size="B400">
Continue
</Text>
</Button>
<Button variant="Secondary" fill="None" onClick={handleContinue}>
<Text as="span" size="B400">
Open the sign-in page again
</Text>
</Button>
</Box>
) : (
<Button variant="Primary" onClick={handleContinue}>
<Text as="span" size="B400">
@@ -27,6 +27,7 @@ import { stopPropagation } from '../../utils/keyboard';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useLocalTime } from '../../hooks/useLocalTime';
import { UserBridgeBadge } from '../bridge';
type UserHeroProps = {
userId: string;
@@ -143,6 +144,7 @@ export function UserHeroName({
>
{displayName ?? username ?? userId}
</Text>
<UserBridgeBadge userId={userId} size="200" />
</Box>
{pronouns && (
<Box alignItems="Center" gap="100" style={{ marginTop: '1px', overflow: 'hidden' }}>
+2
View File
@@ -71,6 +71,7 @@ import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/use
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useCrossSigningActive } from '../../hooks/useCrossSigning';
import { MemberVerificationBadge } from '../../components/MemberVerificationBadge';
import { UserBridgeBadge } from '../../components/bridge';
import { useUserPresence } from '../../hooks/useUserPresence';
import { PresenceBadge, PresenceRingAvatar } from '../../components/presence';
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
@@ -178,6 +179,7 @@ function MemberItem({
{presence && (
<PresenceBadge presence={presence.presence} status={presence.status} size="300" />
)}
<UserBridgeBadge userId={member.userId} />
{showEncryption && <MemberVerificationBadge userId={member.userId} />}
{typing && (
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
+2
View File
@@ -34,6 +34,7 @@ import { PageHeader } from '../../components/page';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { UseStateProvider } from '../../components/UseStateProvider';
import { RoomTopicViewer } from '../../components/room-topic-viewer';
import { RoomBridgeChip } from '../../components/bridge';
import { StateEvent } from '../../../types/matrix/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
@@ -690,6 +691,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
)}
</TooltipProvider>
)}
<RoomBridgeChip room={room} />
</Box>
{topic && (
<UseStateProvider initial={false}>
@@ -90,6 +90,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
import { PowerIcon } from '../../../components/power';
import { UserBridgeBadge } from '../../../components/bridge';
import colorMXID from '../../../../util/colorMXID';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { ForwardMessageDialog } from './ForwardMessageDialog';
@@ -1084,6 +1085,7 @@ export const Message = React.memo(
</Text>
</Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
<UserBridgeBadge userId={senderId} />
</Box>
<Box shrink="No" gap="100">
{messageLayout === MessageLayout.Modern && hover && (
@@ -6,6 +6,7 @@ import {
DenoiseNode,
buildGateNode,
buildModelNode,
monoDownmix,
readDb,
sampleRateFor,
} from '../../../utils/denoisePipeline';
@@ -139,7 +140,7 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
inAnalyser.fftSize = 1024;
source.connect(inAnalyser);
let head: AudioNode = source;
let head: AudioNode = source.connect(monoDownmix(ctx));
let gate: AudioWorkletNode | null = null;
if (useGate) {
gate = await buildGateNode(ctx, gateThreshold);
@@ -308,10 +309,11 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
const ctx = new AudioContext({ sampleRate: sampleRateFor(playModel ?? 'rnnoise') });
const source = ctx.createBufferSource();
source.buffer = clip;
const mono = source.connect(monoDownmix(ctx));
let playGate: AudioWorkletNode | null = null;
let playModelNode: DenoiseNode | null = null;
if (playModel) {
let head: AudioNode = source;
let head: AudioNode = mono;
if (useGate) {
playGate = await buildGateNode(ctx, gateThreshold);
head.connect(playGate);
@@ -321,7 +323,7 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
head.connect(playModelNode.node);
playModelNode.node.connect(ctx.destination);
} else {
source.connect(ctx.destination);
mono.connect(ctx.destination);
}
// Superseded while the WASM/worklet loaded (another Play, a Stop, or the
// panel unmounted)? Tear down this now-orphaned graph instead of storing
@@ -2037,7 +2037,7 @@ export function Calls() {
<Text size="L400">Model</Text>
<SettingTile
title="ML model"
description="Choose the machine learning model used for noise removal. Heavier models clean more aggressively at a higher CPU cost."
description="Choose the machine learning model used for noise removal. DTLN is the best all-rounder; DeepFilterNet removes the most noise at a higher CPU cost. None of the models remove other people talking in the background. CPU figures are the share of one processor core during a call on a mid-range desktop; Task Manager shows that divided by your number of cores, and slower computers use more."
after={
<SettingsSelect<DenoiseModelId>
value={callDenoiseModel}
@@ -2064,9 +2064,9 @@ export function Calls() {
<Box direction="Row" gap="400" wrap="Wrap">
{(
[
{ label: 'CPU', value: selectedDenoiseModel.cpuUsage },
{ label: 'CPU (one core)', value: selectedDenoiseModel.cpuUsage },
{ label: 'Voice quality', value: selectedDenoiseModel.voiceQuality },
{ label: 'Transients', value: selectedDenoiseModel.transients },
{ label: 'Typing & clicks', value: selectedDenoiseModel.transients },
{ label: 'Download', value: selectedDenoiseModel.binarySize },
] as const
).map((stat) => (
@@ -2113,7 +2113,7 @@ export function Calls() {
</Box>
<Box grow="Yes">
<Text size="T200" priority="300">
Transients
Typing & clicks
</Text>
</Box>
</Box>
@@ -2137,7 +2137,7 @@ export function Calls() {
</details>
<Text size="T200" priority="400">
Note: Applying changes requires rejoining the call.
Changes apply straight away, even during a call.
</Text>
</Box>
+41
View File
@@ -0,0 +1,41 @@
import { useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { CallEmbed } from '../plugins/call';
import { settingsAtom } from '../state/settings';
import { buildDenoisePayload } from '../utils/callDenoise';
/**
* Apply noise-suppression setting changes to a call that's already running
* (the fork's `io.lotus.set_denoise`): switching between off, browser-native
* and an ML model, or between models, takes effect without rejoining. The join
* itself still carries the settings in the call URL, so the first send is a
* no-op fork-side.
*/
export function useCallDenoise(embed: CallEmbed): void {
const {
callNoiseSuppression,
callDenoiseModel,
callDenoiseNativeNS,
callDenoiseGate,
callDenoiseGateThreshold,
} = useAtomValue(settingsAtom);
useEffect(() => {
embed.control.setDenoise(
buildDenoisePayload({
callNoiseSuppression,
callDenoiseModel,
callDenoiseNativeNS,
callDenoiseGate,
callDenoiseGateThreshold,
}),
);
}, [
embed,
callNoiseSuppression,
callDenoiseModel,
callDenoiseNativeNS,
callDenoiseGate,
callDenoiseGateThreshold,
]);
}
+32
View File
@@ -0,0 +1,32 @@
import { Room } from 'matrix-js-sdk';
import { useCallback, useMemo } from 'react';
import { useStateEventCallback } from './useStateEventCallback';
import { useForceUpdate } from './useForceUpdate';
import { getRoomBridges, RoomBridgeInfo } from '../utils/bridge';
import { StateEvent } from '../../types/matrix/room';
export const useRoomBridges = (room: Room): RoomBridgeInfo[] => {
const [updateCount, forceUpdate] = useForceUpdate();
useStateEventCallback(
room.client,
useCallback(
(event) => {
const type = event.getType();
if (
event.getRoomId() === room.roomId &&
(type === StateEvent.Bridge || type === StateEvent.BridgeUnstable)
) {
forceUpdate();
}
},
[room, forceUpdate],
),
);
return useMemo(
() => getRoomBridges(room),
// eslint-disable-next-line react-hooks/exhaustive-deps
[room, updateCount],
);
};
+1 -1
View File
@@ -62,7 +62,7 @@ export const useServerStatus = (
]);
if (stopped) return;
failures = 0;
last = parseKumaStatus(pageJson, heartbeatJson, page!.groups);
last = parseKumaStatus(pageJson, heartbeatJson, page!.groups, page!.noticePrefix);
setStatus(last);
// While this client's connection is lost, keep asking every minute: that
// is how it learns Kuma has checked the server since the drop.
+29 -2
View File
@@ -1,5 +1,12 @@
import { useEffect, useMemo, useState } from 'react';
import { MatrixClient, User, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
import {
ClientEvent,
MatrixClient,
MatrixEvent,
User,
UserEvent,
UserEventHandlerMap,
} from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
export enum Presence {
@@ -19,7 +26,12 @@ export type UserPresence = {
const getUserPresence = (user: User): UserPresence => ({
presence: user.presence as Presence,
status: user.presenceStatusMsg,
// From the latest presence event rather than user.presenceStatusMsg: the SDK
// only overwrites that field with a non-empty status_msg, so a cleared status
// would otherwise linger until reload.
status: user.events.presence
? (user.events.presence.getContent().status_msg as string | undefined) || undefined
: user.presenceStatusMsg,
active: user.currentlyActive,
lastActiveTs: user.getLastActiveTs(),
});
@@ -55,6 +67,19 @@ class PresenceStore {
subs.forEach((cb) => cb());
};
// Our own User object is created by the SDK with `new User()` (not
// User.createUser), so its User.* events are never re-emitted on the client
// and the listeners above miss our own presence changes. The client does
// emit every sync presence event as ClientEvent.Event (after updating the
// User), so refresh from that too.
private handleClientEvent = (event: MatrixEvent): void => {
if (event.getType() !== 'm.presence' || !this.mx) return;
const userId = event.getSender();
if (!userId) return;
const user = this.mx.getUser(userId);
if (user) this.handleEvent(event, user);
};
private start(mx: MatrixClient): void {
if (this.started && this.mx === mx) return;
// A different client (re-login without reload) — drop the old listeners.
@@ -62,12 +87,14 @@ class PresenceStore {
this.mx.removeListener(UserEvent.Presence, this.handleEvent);
this.mx.removeListener(UserEvent.CurrentlyActive, this.handleEvent);
this.mx.removeListener(UserEvent.LastPresenceTs, this.handleEvent);
this.mx.removeListener(ClientEvent.Event, this.handleClientEvent);
this.cache.clear();
}
this.mx = mx;
mx.on(UserEvent.Presence, this.handleEvent);
mx.on(UserEvent.CurrentlyActive, this.handleEvent);
mx.on(UserEvent.LastPresenceTs, this.handleEvent);
mx.on(ClientEvent.Event, this.handleClientEvent);
this.started = true;
}
+23
View File
@@ -4,6 +4,7 @@ import { CallControlState } from './CallControlState';
import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types';
import { canDelegateCapability } from './utils';
import { CallErrorState, parseCallError } from './callError';
import type { LotusDenoisePayload } from '../../utils/callDenoise';
export enum CallControlEvent {
StateUpdate = 'state_update',
@@ -106,6 +107,11 @@ export class CallControl extends EventEmitter implements CallControlState {
// pre-join send pends to a 10s widget timeout, mirroring the deafen gate).
private lastQuality: LotusQualityPayload | null = null;
// Last noise-suppression choice (io.lotus.set_denoise), held like
// lastQuality: sent once joined and re-sent after an EC reconnect, whose
// remounted handler starts again from the settings in the join URL.
private lastDenoise: LotusDenoisePayload | null = null;
// C-M5: set true by CallControls while a push-to-talk key is held. A PTT hold
// unmutes the mic transiently, and onMediaState() must NOT treat that as a
// user-initiated unmute that auto-undeafens the user.
@@ -191,6 +197,7 @@ export class CallControl extends EventEmitter implements CallControlState {
this.joined = true;
this.sendDeafenState();
this.sendQuality();
this.sendDenoise();
this.sendHotkeyCodes();
this.sendFrameScreenshareAllowed();
}
@@ -204,6 +211,7 @@ export class CallControl extends EventEmitter implements CallControlState {
public resendForkState(): void {
this.sendDeafenState();
this.sendQuality();
this.sendDenoise();
if (this._audioOutputId !== undefined) this.sendAudioOutput(this._audioOutputId);
// [Gitea #17] The pin lives fork-side and is dropped on a handler remount.
if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId);
@@ -762,6 +770,21 @@ export class CallControl extends EventEmitter implements CallControlState {
this.call.transport.send('io.lotus.set_quality', this.lastQuality).catch(() => undefined);
}
/**
* Switch noise suppression on the live mic (the fork's `io.lotus.set_denoise`):
* browser-native on/off and/or the ML model, without rejoining. Join-gated
* like setQuality().
*/
public setDenoise(payload: LotusDenoisePayload): void {
this.lastDenoise = payload;
this.sendDenoise();
}
private sendDenoise(): void {
if (!this.joined || !this.lastDenoise) return;
this.call.transport.send('io.lotus.set_denoise', this.lastDenoise).catch(() => undefined);
}
public dispose() {
// [Gitea #56] Don't let a manual focus pin outlive the call.
this.clearFocusParticipant();
+34 -7
View File
@@ -133,6 +133,15 @@ export class CallEmbed {
private themeKind: ElementCallThemeKind = 'dark';
// The theme the frame was loaded with (its URL `theme` param), and whether
// the widget API is ready. Until it is, the iframe may still be on its
// initial about:blank (our own origin), so a send to the call's origin is
// refused ("target origin … does not match the recipient window's origin")
// and the request hangs to a timeout. Theme changes wait for ready.
private loadedThemeKind: ElementCallThemeKind = 'dark';
private widgetReady = false;
// Watchdog: detects an iframe that never reaches a usable state.
private loadWatchdog?: ReturnType<typeof setTimeout>;
@@ -228,10 +237,14 @@ export class CallEmbed {
denoiseMode === 'browser' ||
(denoiseMode === 'ml' && denoiseNativeNS)
).toString(),
// Turn the browser's auto gain control OFF for the ML tier only: its
// dynamic gain fights the in-source ML denoiser (pumping). Browser/off
// tiers keep the browser's normal capture pipeline (AGC on).
autoGainControl: (denoiseMode !== 'ml').toString(),
// Keep the browser's auto gain control ON in every tier. It used to be
// turned off for the ML tier ("pumping"), but AGC is what brings a quiet
// mic up to speaking level: without it ML-tier voices went out 10-20 dB
// quieter than the browser tier, while the models themselves keep speech
// within ~1.5 dB (measured: RNNoise, Speex, DTLN, DeepFilterNet3). Chrome's
// AGC2 only adapts during speech, and the model runs after it, so it still
// removes the noise AGC leaves in the pauses.
autoGainControl: 'true',
audio: initialAudio.toString(),
video: initialVideo.toString(),
header: 'none',
@@ -360,6 +373,7 @@ export class CallEmbed {
this.iframe = iframe;
this.container = container;
this.themeKind = themeKind;
this.loadedThemeKind = themeKind;
const controlState = initialControlState ?? new CallControlState(true, false, true);
this.control = new CallControl(controlState, call, iframe);
@@ -375,6 +389,13 @@ export class CallEmbed {
// happy path (onCallJoined) clears it too; these cover earlier signals so a
// user sitting in the lobby/prescreen isn't flagged as an error.
this.disposables.push(this.onReady(() => this.settleLoad()));
this.disposables.push(
this.onReady(() => {
this.widgetReady = true;
// A theme change made while the frame was loading.
if (this.themeKind !== this.loadedThemeKind) this.sendTheme();
}),
);
this.disposables.push(this.onCapabilitiesNotified(() => this.settleLoad()));
this.disposables.push(this.onPreparingError(() => this.settleLoad('iframe')));
@@ -399,12 +420,18 @@ export class CallEmbed {
return this.room.roomId;
}
public setTheme(theme: ElementCallThemeKind) {
public setTheme(theme: ElementCallThemeKind): Promise<unknown> {
this.themeKind = theme;
// Before ready, the URL theme applies and the change is sent on ready.
if (!this.widgetReady) return Promise.resolve();
return this.sendTheme();
}
private sendTheme(): Promise<unknown> {
return this.call.transport
.send(WidgetApiToWidgetAction.ThemeChange, { name: theme })
.send(WidgetApiToWidgetAction.ThemeChange, { name: this.themeKind })
.catch(() => {
// Widget transport not ready yet; theme URL param is the fallback
// The frame went away mid-send; nothing to do.
});
}
+4 -2
View File
@@ -377,9 +377,11 @@ const defaultSettings: Settings = {
cameraOnJoin: false,
// Tier default stays browser-native (known-good; best-perceived in testing so
// far). If a user opts into the ML tier, default to the highest-quality model.
// far). If a user opts into the ML tier, default to DTLN: in our measurements
// it keeps voices the most natural, removes fans and typing well, and costs a
// third of DeepFilterNet's CPU.
callNoiseSuppression: 'browser',
callDenoiseModel: 'deepfilternet',
callDenoiseModel: 'dtln',
// "Series suppression" (stack the browser's native NS before the ML model) is
// off by default — best practice is a single NS stage; it's an opt-in test aid.
callDenoiseNativeNS: false,
+86
View File
@@ -0,0 +1,86 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MatrixEvent, Room } from 'matrix-js-sdk';
import {
describeRoomBridge,
getBridgedUserNetwork,
getRoomBridges,
parseBridgeEvent,
} from './bridge';
const bridgeEvent = (type: string, stateKey: string, content: Record<string, unknown>) =>
new MatrixEvent({ type, state_key: stateKey, content, room_id: '!r:x', sender: '@discordbot:x' });
// Shape of what mautrix-discord 0.7.7 sends for a guild channel.
const discordContent = {
bridgebot: '@discordbot:matrix.lotusguild.org',
protocol: { id: 'discordgo', displayname: 'Discord' },
channel: {
id: '1565159372122882070',
displayname: '#general',
external_url: 'https://discord.com/channels/1/1565159372122882070',
},
network: { id: '1', displayname: 'Lotus Guild' },
};
const roomWithState = (events: MatrixEvent[]): Room =>
({
getLiveTimeline: () => ({
getState: () => ({
getStateEvents: (type: string) => events.filter((e) => e.getType() === type),
}),
}),
}) as unknown as Room;
test('Discord ghosts are recognised; the bridge bot and lookalikes are not', () => {
assert.equal(
getBridgedUserNetwork('@discord_123456789012345678:matrix.lotusguild.org'),
'discord',
);
assert.equal(getBridgedUserNetwork('@discordbot:matrix.lotusguild.org'), undefined);
assert.equal(getBridgedUserNetwork('@discord_fan:matrix.lotusguild.org'), undefined);
assert.equal(getBridgedUserNetwork('@discord-relay:matrix.lotusguild.org'), undefined);
assert.equal(getBridgedUserNetwork('@jared:matrix.lotusguild.org'), undefined);
});
test('parseBridgeEvent reads a mautrix-discord m.bridge event', () => {
assert.deepEqual(parseBridgeEvent(bridgeEvent('m.bridge', 'k', discordContent)), {
network: 'discord',
channelName: '#general',
networkName: 'Lotus Guild',
externalUrl: 'https://discord.com/channels/1/1565159372122882070',
});
});
test('parseBridgeEvent ignores unknown protocols and empty content', () => {
assert.equal(
parseBridgeEvent(bridgeEvent('m.bridge', 'k', { protocol: { id: 'telegram' } })),
undefined,
);
assert.equal(parseBridgeEvent(bridgeEvent('m.bridge', 'k', {})), undefined);
});
test('only http(s) channel links are kept', () => {
const content = {
...discordContent,
channel: { displayname: '#x', external_url: ['javascript', 'alert(1)'].join(':') },
};
assert.equal(parseBridgeEvent(bridgeEvent('m.bridge', 'k', content))?.externalUrl, undefined);
});
test('getRoomBridges merges m.bridge with its unstable copy, one entry per channel', () => {
const room = roomWithState([
bridgeEvent('m.bridge', 'fi.mau.discord://discord/1/2', discordContent),
bridgeEvent('uk.half-shot.bridge', 'fi.mau.discord://discord/1/2', discordContent),
]);
assert.equal(getRoomBridges(room).length, 1);
assert.equal(getRoomBridges(roomWithState([])).length, 0);
});
test('describeRoomBridge degrades gracefully without names', () => {
assert.equal(
describeRoomBridge({ network: 'discord', channelName: '#general', networkName: 'Lotus Guild' }),
'Bridged to #general in Lotus Guild on Discord',
);
assert.equal(describeRoomBridge({ network: 'discord' }), 'Bridged to Discord');
});
+80
View File
@@ -0,0 +1,80 @@
import { MatrixEvent, Room } from 'matrix-js-sdk';
import { StateEvent } from '../../types/matrix/room';
import { getStateEvents } from './room';
export type BridgeNetwork = 'discord';
export const BRIDGE_NETWORK_NAME: Record<BridgeNetwork, string> = {
discord: 'Discord',
};
// Ghost (puppet) users a bridge creates for people on the other network.
// mautrix-discord's default username_template is discord_{{.}} with a numeric
// Discord snowflake; the bridge bot itself (@discordbot) is not a ghost.
const GHOST_PATTERNS: [RegExp, BridgeNetwork][] = [[/^@discord_\d+:/, 'discord']];
export const getBridgedUserNetwork = (userId: string): BridgeNetwork | undefined =>
GHOST_PATTERNS.find(([pattern]) => pattern.test(userId))?.[1];
// m.bridge protocol.id values that mean each network.
const PROTOCOL_NETWORKS: Record<string, BridgeNetwork> = {
discordgo: 'discord',
discord: 'discord',
};
export type RoomBridgeInfo = {
network: BridgeNetwork;
channelName?: string;
networkName?: string;
externalUrl?: string;
};
const asString = (value: unknown): string | undefined =>
typeof value === 'string' && value.trim() ? value.trim() : undefined;
// Only http(s) links are made clickable; the bridge (or anyone with state
// power in the room) controls this value.
const safeExternalUrl = (value: unknown): string | undefined => {
const raw = asString(value);
if (!raw) return undefined;
try {
const url = new URL(raw);
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : undefined;
} catch {
return undefined;
}
};
export const parseBridgeEvent = (event: MatrixEvent): RoomBridgeInfo | undefined => {
const content = event.getContent();
const protocolId = asString(content?.protocol?.id)?.toLowerCase();
const network = protocolId ? PROTOCOL_NETWORKS[protocolId] : undefined;
if (!network) return undefined;
return {
network,
channelName: asString(content.channel?.displayname),
networkName: asString(content.network?.displayname),
externalUrl: safeExternalUrl(content.channel?.external_url),
};
};
/** Known bridges in a room, one per bridged channel (m.bridge preferred over the unstable copy). */
export const getRoomBridges = (room: Room): RoomBridgeInfo[] => {
const byKey = new Map<string, RoomBridgeInfo>();
[StateEvent.BridgeUnstable, StateEvent.Bridge].forEach((type) => {
getStateEvents(room, type).forEach((event) => {
const info = parseBridgeEvent(event);
if (info) byKey.set(event.getStateKey() ?? '', info);
});
});
return Array.from(byKey.values());
};
export const describeRoomBridge = (info: RoomBridgeInfo): string => {
const network = BRIDGE_NETWORK_NAME[info.network];
if (info.channelName && info.networkName) {
return `Bridged to ${info.channelName} in ${info.networkName} on ${network}`;
}
if (info.channelName) return `Bridged to ${info.channelName} on ${network}`;
return `Bridged to ${network}`;
};
+27
View File
@@ -0,0 +1,27 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildDenoisePayload } from './callDenoise';
test('buildDenoisePayload passes the settings through', () => {
assert.deepEqual(
buildDenoisePayload({
callNoiseSuppression: 'ml',
callDenoiseModel: 'dtln',
callDenoiseNativeNS: false,
callDenoiseGate: true,
callDenoiseGateThreshold: -50,
}),
{ mode: 'ml', model: 'dtln', nativeNS: false, gate: true, gateThreshold: -50 },
);
});
test('buildDenoisePayload uses the same defaults as the join URL', () => {
assert.deepEqual(buildDenoisePayload({}), {
mode: 'browser',
model: 'rnnoise',
nativeNS: true,
gate: false,
gateThreshold: -45,
});
});
+31
View File
@@ -0,0 +1,31 @@
import { DenoiseModelId, NoiseSuppressionMode } from '../state/settings';
/**
* Payload for the fork's `io.lotus.set_denoise` action, which switches noise
* suppression on the live mic mid-call (browser suppressor on/off and/or the
* ML model) instead of needing a rejoin.
*/
export type LotusDenoisePayload = {
mode: NoiseSuppressionMode;
model: DenoiseModelId;
nativeNS: boolean;
gate: boolean;
gateThreshold: number;
};
export type DenoiseSettings = {
callNoiseSuppression?: NoiseSuppressionMode;
callDenoiseModel?: DenoiseModelId;
callDenoiseNativeNS?: boolean;
callDenoiseGate?: boolean;
callDenoiseGateThreshold?: number;
};
/** Same defaults as the call URL built at join (useCallStart/createCallEmbed). */
export const buildDenoisePayload = (s: DenoiseSettings): LotusDenoisePayload => ({
mode: s.callNoiseSuppression ?? 'browser',
model: s.callDenoiseModel ?? 'rnnoise',
nativeNS: s.callDenoiseNativeNS ?? true,
gate: s.callDenoiseGate ?? false,
gateThreshold: s.callDenoiseGateThreshold ?? -45,
});
+23 -1
View File
@@ -21,6 +21,20 @@ const BASE = `${import.meta.env.BASE_URL.replace(/\/+$/, '')}/public/element-cal
*/
export const sampleRateFor = (model: DenoiseModelId): number => (model === 'dtln' ? 16000 : 48000);
/**
* A pass-through node that downmixes whatever feeds it to ONE channel. Every
* model only processes channel 0, and a recorded clip often decodes as stereo
* (Chrome's Opus/WebM), so without this RNNoise/Speex played the cleaned audio
* in the left ear only. Lotus Call does the same (#24). A mono output is then
* upmixed to both ears by the destination.
*/
export const monoDownmix = (ctx: BaseAudioContext): GainNode =>
new GainNode(ctx, {
channelCount: 1,
channelCountMode: 'explicit',
channelInterpretation: 'speakers',
});
export type DenoiseNode = {
node: AudioWorkletNode;
dispose: () => void;
@@ -85,7 +99,10 @@ export async function buildModelNode(
const mod: any = await import(/* @vite-ignore */ `${BASE}deepfilternet/index.esm.js`);
const core = new mod.DeepFilterNet3Core({
sampleRate: sampleRateFor(model),
noiseReductionLevel: 80,
// Same attenuation limit as Lotus Call (dB). Higher values mute the
// background outright, so words drop into dead silence and sound
// clipped at the end.
noiseReductionLevel: 25,
assetConfig: { cdnUrl: dfnBase },
});
await core.initialize();
@@ -105,6 +122,8 @@ export async function buildModelNode(
const [, wasmBinary] = await Promise.all([addModuleOnce(ctx, cfg.script), fetchWasm(cfg.wasm)]);
const node = new AudioWorkletNode(ctx, cfg.proc, {
channelCount: 1,
channelCountMode: 'explicit',
channelInterpretation: 'speakers',
numberOfInputs: 1,
numberOfOutputs: 1,
processorOptions: { maxChannels: 1, wasmBinary },
@@ -127,6 +146,9 @@ export async function buildGateNode(
): Promise<AudioWorkletNode> {
await addModuleOnce(ctx, 'noiseGateWorklet.js');
return new AudioWorkletNode(ctx, '@sapphi-red/web-noise-suppressor/noise-gate', {
channelCount: 1,
channelCountMode: 'explicit',
channelInterpretation: 'speakers',
processorOptions: {
openThreshold: thresholdDb,
closeThreshold: thresholdDb - 5,
+72
View File
@@ -285,3 +285,75 @@ test('plain: strips Markdown syntax only, keeps ordinary #, _, > and * (#308)',
'Read the guide docs',
);
});
test('[noticePrefix] resolveStatusPage keeps it only when set', () => {
const base = { url: 'https://kuma.example', slug: 'public' };
assert.equal(
resolveStatusPage({ hs: { ...base, noticePrefix: ' Matrix: ' } }, 'hs')?.noticePrefix,
'Matrix:',
);
assert.equal(resolveStatusPage({ hs: base }, 'hs')?.noticePrefix, undefined);
});
test('[noticePrefix] on a shared page only prefixed notices reach users, without the prefix', () => {
const page = {
publicGroupList: [],
maintenanceList: [
{ id: 1, title: 'Jellyfin upgrade', status: 'under-maintenance' },
{ id: 2, title: 'matrix: Synapse upgrade', status: 'under-maintenance' },
],
incidents: [
{ id: 3, title: 'Storage redundancy restored', content: 'x', style: 'info' },
{ id: 4, title: 'Matrix: Calls are flaky', content: 'y', style: 'warning' },
{ id: 5, title: 'Matrix:', content: 'nothing left after the prefix', style: 'info' },
{ id: 6, title: 'Matrixish news', content: 'z', style: 'info' },
],
};
const s = parseKumaStatus(page, {}, DEFAULT_GROUPS, 'Matrix:');
assert.deepEqual(
s.maintenance.map((m) => [m.id, m.title]),
[['2', 'Synapse upgrade']],
);
assert.deepEqual(
s.incidents.map((i) => [i.id, i.title]),
[['4', 'Calls are flaky']],
);
});
test('[noticePrefix] without one, every notice shows as before', () => {
const page = {
publicGroupList: [],
maintenanceList: [{ id: 1, title: 'Jellyfin upgrade', status: 'under-maintenance' }],
incidents: [{ id: 3, title: 'Storage redundancy restored', content: 'x', style: 'info' }],
};
const s = parseKumaStatus(page, {}, DEFAULT_GROUPS);
assert.equal(s.maintenance[0].title, 'Jellyfin upgrade');
assert.equal(s.incidents[0].title, 'Storage redundancy restored');
});
test('[split public page] a Discord group going down touches neither homeserver nor calls', () => {
const up = [
{ status: 1, time: '2026-11-05 01:00:00' },
{ status: 1, time: '2026-11-05 01:01:00' },
];
const down = [
{ status: 0, time: '2026-11-05 01:00:00' },
{ status: 0, time: '2026-11-05 01:01:00' },
];
const page = {
publicGroupList: [
{ name: 'Homeserver', monitorList: [{ id: 30 }] },
{ name: 'Voice calls', monitorList: [{ id: 37 }] },
{ name: 'Discord', monitorList: [{ id: 49 }] },
{ name: 'Services', monitorList: [{ id: 27 }] },
],
};
const s = parseKumaStatus(
page,
{ heartbeatList: { 30: up, 37: up, 49: down, 27: down } },
DEFAULT_GROUPS,
);
assert.equal(s.homeserver, 'up');
assert.equal(s.calls, 'up');
assert.equal(s.login, 'unknown');
});
+28 -5
View File
@@ -7,6 +7,11 @@
* "login": "Login" } } }
* Users of any other homeserver never contact Kuma.
*
* Optional "noticePrefix": on a status page shared with other services, only
* incidents and maintenance titled with this prefix (e.g. "Matrix:") reach
* Lotus users, shown without it, so a media server's maintenance doesn't say
* messages may be interrupted. Without it, every notice on the page shows.
*
* Kuma 2.x public JSON:
* - GET /api/status-page/<slug>: publicGroupList[{name, monitorList[{id}]}],
* maintenanceList (only windows UNDER maintenance right now), incidents
@@ -19,7 +24,12 @@
*/
export type StatusGroups = { homeserver?: string; calls?: string; login?: string };
export type StatusPageConfig = { url: string; slug: string; groups: StatusGroups };
export type StatusPageConfig = {
url: string;
slug: string;
groups: StatusGroups;
noticePrefix?: string;
};
export type ServiceState = 'up' | 'down' | 'unknown';
export type KumaMaintenance = { id: string; title: string; description: string; end?: number };
@@ -69,9 +79,11 @@ export const resolveStatusPage = (
const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) return undefined;
const g = obj(entry.groups) ?? {};
const noticePrefix = str(entry.noticePrefix).trim();
return {
url: url.origin,
slug,
...(noticePrefix ? { noticePrefix } : {}),
groups: {
homeserver: str(g.homeserver) || DEFAULT_GROUPS.homeserver,
calls: str(g.calls) || DEFAULT_GROUPS.calls,
@@ -133,10 +145,18 @@ const groupState = (
return known > 0 ? 'up' : 'unknown';
};
/** The title without the prefix, or undefined when it doesn't carry it. */
const noticeTitle = (title: string, prefix: string | undefined): string | undefined => {
if (!prefix) return title;
if (title.slice(0, prefix.length).toLowerCase() !== prefix.toLowerCase()) return undefined;
return title.slice(prefix.length).trim() || undefined;
};
export const parseKumaStatus = (
pageJson: unknown,
heartbeatJson: unknown,
groupNames: StatusGroups,
noticePrefix?: string,
): ServerStatus => {
const page = obj(pageJson) ?? {};
const groups = new Map<string, string[]>();
@@ -155,14 +175,14 @@ export const parseKumaStatus = (
Array.isArray(page.maintenanceList) ? page.maintenanceList : []
)
.map((m) => obj(m))
.filter((m): m is Record<string, unknown> => !!m && str(m.title) !== '')
.filter((m): m is Record<string, unknown> => !!m && !!noticeTitle(str(m.title), noticePrefix))
.filter((m) => !m.status || m.status === 'under-maintenance')
.map((m) => {
const slot = obj((Array.isArray(m.timeslotList) ? m.timeslotList : [])[0]);
const end = Date.parse(str(slot?.endDate));
return {
id: String(m.id ?? str(m.title)),
title: str(m.title),
title: noticeTitle(str(m.title), noticePrefix) ?? '',
description: str(m.description),
...(Number.isFinite(end) ? { end } : {}),
};
@@ -170,10 +190,13 @@ export const parseKumaStatus = (
const incidents: KumaIncident[] = (Array.isArray(page.incidents) ? page.incidents : [])
.map((i) => obj(i))
.filter((i): i is Record<string, unknown> => !!i && str(i.title) !== '' && i.active !== false)
.filter(
(i): i is Record<string, unknown> =>
!!i && !!noticeTitle(str(i.title), noticePrefix) && i.active !== false,
)
.map((i) => ({
id: String(i.id ?? str(i.title)),
title: str(i.title),
title: noticeTitle(str(i.title), noticePrefix) ?? '',
content: str(i.content),
style: str(i.style) || 'info',
updated: str(i.lastUpdatedDate) || str(i.createdDate),
+2 -2
View File
@@ -5,10 +5,10 @@ import { DENOISE_MODELS, ML_DENOISE_REQUIREMENTS, isMLDenoiseSupported } from '.
// ── Model catalog (data integrity) ──────────────────────────────────────────
test('DENOISE_MODELS lists the four models ordered best-quality (highest CPU) first', () => {
test('DENOISE_MODELS lists the four models most-recommended first', () => {
assert.deepEqual(
DENOISE_MODELS.map((m) => m.id),
['deepfilternet', 'dtln', 'rnnoise', 'speex'],
['dtln', 'deepfilternet', 'rnnoise', 'speex'],
);
});
+26 -20
View File
@@ -1,12 +1,18 @@
/**
* Detection utilities + model catalog for Lotus ML noise suppression
* (DeepFilterNet 3 / DTLN / RNNoise / Speex). The catalog is ordered by
* quality (and, correspondingly, CPU cost) — highest first — and drives the
* order of the model dropdown in settings.
* (DTLN / DeepFilterNet 3 / RNNoise / Speex). The catalog is ordered by
* recommendation — most recommended first — and drives the order of the model
* dropdown in settings.
*/
import { DenoiseModelId } from '../state/settings';
// CPU = share of ONE core while in a call, measured with each model running
// exactly as Lotus Call runs it (Ryzen 7 PRO 4750GE, Chromium); download = bytes
// actually fetched on first use. Re-measure when a model or its settings change.
// Ratings come from scoring each model against the clean voice (PESQ/STOI) on
// real speech mixed with fan, keyboard, street and babble noise at SNR 5 and
// 15 dB; `transients` is how well it removes typing and clicks.
export type DenoiseModel = {
id: DenoiseModelId;
name: string;
@@ -17,25 +23,25 @@ export type DenoiseModel = {
voiceQuality: 'Moderate' | 'High' | 'Very High';
};
// Ordered best-quality (highest CPU) first — this is the dropdown order.
// Ordered most-recommended first — this is the dropdown order.
export const DENOISE_MODELS: DenoiseModel[] = [
{
id: 'deepfilternet',
name: 'DeepFilterNet 3 (beta)',
id: 'dtln',
name: 'DTLN (recommended)',
description:
'Studio-grade deep-learning model (48 kHz fullband, ONNX). Best quality; highest CPU and a larger one-time download.',
cpuUsage: '25-50%',
binarySize: '~18 MB',
'Keeps voices the most natural of the four. Removes fans and typing well, but not traffic or people talking in the background.',
cpuUsage: '~9%',
binarySize: '~20 MB',
transients: 'Excellent',
voiceQuality: 'Very High',
},
{
id: 'dtln',
name: 'DTLN (beta)',
id: 'deepfilternet',
name: 'DeepFilterNet 3 (strongest)',
description:
'Dual-signal deep-learning model (16 kHz). Strong on transient noise; moderate CPU.',
cpuUsage: '10-20%',
binarySize: '~4 MB',
'Removes the most noise, including loud typing and traffic, but voices can sound slightly muffled. Uses about three times the CPU of DTLN.',
cpuUsage: '~30%',
binarySize: '~18 MB',
transients: 'Excellent',
voiceQuality: 'High',
},
@@ -43,18 +49,18 @@ export const DENOISE_MODELS: DenoiseModel[] = [
id: 'rnnoise',
name: 'RNNoise',
description:
'Lightweight hybrid model (48 kHz). Very low CPU; good for steady noise like fans, but can sound processed at full strength.',
cpuUsage: '< 5%',
'Very light. Handles steady noise like fans and traffic; does little for typing and can sound slightly processed.',
cpuUsage: '~3%',
binarySize: '< 1 MB',
transients: 'Good',
transients: 'Poor',
voiceQuality: 'Moderate',
},
{
id: 'speex',
name: 'Speex (Legacy)',
name: 'Speex (legacy)',
description:
'Classic DSP noise suppressor. Minimal CPU, gentlest on voice; weakest suppression.',
cpuUsage: '< 2%',
'Classic suppressor kept for very slow computers. Barely reduces noise in our tests; prefer any other option.',
cpuUsage: '~2%',
binarySize: '< 1 MB',
transients: 'Poor',
voiceQuality: 'Moderate',
+40
View File
@@ -191,3 +191,43 @@ test('[#275] the server limit maps to friendly text; other errors keep theirs',
assert.equal(describeScheduleError(new Error('Boom')), 'Boom');
assert.equal(describeScheduleError('nope'), 'Failed to schedule message.');
});
test('withRateLimitRetry: waits out a 429 then succeeds; other errors and over-budget waits throw', async () => {
const { withRateLimitRetry } = await import('./scheduledMessages');
const { MatrixError } = await import('matrix-js-sdk');
const limited = () => new MatrixError({ errcode: 'M_LIMIT_EXCEEDED', retry_after_ms: 1500 }, 429);
const sleeps: number[] = [];
const sleepFn = async (ms: number) => {
sleeps.push(ms);
};
let calls = 0;
const ok = await withRateLimitRetry(
async () => {
calls += 1;
if (calls < 3) throw limited();
return 'done';
},
{ sleepFn },
);
assert.equal(ok, 'done');
assert.deepEqual(sleeps, [1500, 1500]);
await assert.rejects(
withRateLimitRetry(
async () => {
throw new MatrixError({ errcode: 'M_FORBIDDEN' }, 403);
},
{ sleepFn },
),
/M_FORBIDDEN|403/,
);
await assert.rejects(
withRateLimitRetry(
async () => {
throw limited();
},
{ sleepFn, maxWaitMs: 2000 },
),
);
});
+73 -29
View File
@@ -1,4 +1,4 @@
import { IContent, MatrixClient, Method } from 'matrix-js-sdk';
import { IContent, MatrixClient, MatrixError, Method } from 'matrix-js-sdk';
import type { ScheduledMessage } from '../state/scheduledMessages';
/**
@@ -17,6 +17,40 @@ export const describeScheduleError = (err: unknown): string => {
return err instanceof Error ? err.message : 'Failed to schedule message.';
};
/**
* Synapse rate-limits delayed-event management per user (listing, send now,
* cancel, restart all count). A quick "Send now" then "Cancel" could get a 429
* and show "Could not cancel" although nothing was wrong. Wait out the
* Retry-After (bounded) and try again. Retrying is safe: a 429 means the
* request was not processed, and scheduling reuses its txnId.
*/
export async function withRateLimitRetry<T>(
fn: () => Promise<T>,
opts: { maxWaitMs?: number; sleepFn?: (ms: number) => Promise<void> } = {},
): Promise<T> {
const maxWaitMs = opts.maxWaitMs ?? 15_000;
const sleepFn =
opts.sleepFn ??
((ms: number) =>
new Promise<void>((r) => {
setTimeout(r, ms);
}));
let waited = 0;
for (;;) {
try {
// eslint-disable-next-line no-await-in-loop
return await fn();
} catch (e) {
if (!(e instanceof MatrixError) || e.httpStatus !== 429) throw e;
const wait = Math.min(Math.max(e.getRetryAfterMs() ?? 1000, 200), 10_000);
if (waited + wait > maxWaitMs) throw e;
waited += wait;
// eslint-disable-next-line no-await-in-loop
await sleepFn(wait);
}
}
}
/**
* Schedule a message via MSC4140 (delayed messages).
* Synapse implementation: pass org.matrix.msc4140.delay on the standard
@@ -42,13 +76,15 @@ export async function scheduleMessage(
const txnId = `sched_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const path = `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`;
// Use standard v3 prefix with the MSC4140 delay query param.
const res = (await mx.http.authedRequest(
Method.Put,
path,
{
'org.matrix.msc4140.delay': delayMs,
},
content,
const res = (await withRateLimitRetry(() =>
mx.http.authedRequest(
Method.Put,
path,
{
'org.matrix.msc4140.delay': delayMs,
},
content,
),
)) as { delay_id: string };
return res.delay_id;
}
@@ -58,23 +94,27 @@ export async function scheduleMessage(
*/
export async function cancelScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> {
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
await mx.http.authedRequest(
Method.Post,
path,
undefined,
{ action: 'cancel' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
await withRateLimitRetry(() =>
mx.http.authedRequest(
Method.Post,
path,
undefined,
{ action: 'cancel' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
),
);
}
export async function restartScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> {
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
await mx.http.authedRequest(
Method.Post,
path,
undefined,
{ action: 'restart' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
await withRateLimitRetry(() =>
mx.http.authedRequest(
Method.Post,
path,
undefined,
{ action: 'restart' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
),
);
}
@@ -85,12 +125,14 @@ export async function restartScheduledMessage(mx: MatrixClient, delayId: string)
*/
export async function sendScheduledMessageNow(mx: MatrixClient, delayId: string): Promise<void> {
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
await mx.http.authedRequest(
Method.Post,
path,
undefined,
{ action: 'send' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
await withRateLimitRetry(() =>
mx.http.authedRequest(
Method.Post,
path,
undefined,
{ action: 'send' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
),
);
}
@@ -138,9 +180,11 @@ export function parseDelayedEvents(res: unknown): ScheduledMessage[] {
/** The user's pending scheduled messages, as the server has them. */
export async function fetchScheduledMessages(mx: MatrixClient): Promise<ScheduledMessage[]> {
const res = await mx.http.authedRequest(Method.Get, '/delayed_events', undefined, undefined, {
prefix: '/_matrix/client/unstable/org.matrix.msc4140',
});
const res = await withRateLimitRetry(() =>
mx.http.authedRequest(Method.Get, '/delayed_events', undefined, undefined, {
prefix: '/_matrix/client/unstable/org.matrix.msc4140',
}),
);
return parseDelayedEvents(res);
}
+4
View File
@@ -50,6 +50,10 @@ export enum StateEvent {
// [P5-15 v2] Room/Space soundboard pack (mirrors PoniesRoomEmotes). Per
// state-key, aggregated with parent-space packs like custom emoji.
LotusSoundboardRoom = 'io.lotus.soundboard',
// MSC2346 bridge info; state_key identifies the bridged channel. Bridges
// still send the pre-stable uk.half-shot.bridge copy alongside it.
Bridge = 'm.bridge',
BridgeUnstable = 'uk.half-shot.bridge',
}
export enum MessageEvent {
+31
View File
@@ -136,6 +136,36 @@ function denoiseSources() {
};
}
// deepfilternet3-noise-filter inlines its AudioWorklet as a string and loads it
// from a blob: URL. Audio worklets fall under script-src, and the desktop app's
// CSP (rightly) has no blob: there, so DeepFilterNet 3 failed to start ("denoise
// setProcessor failed … Failed to load worklet module script: blob:…"). Write
// the worklet out as a real file next to the module and load it by URL instead,
// which script-src 'self' allows everywhere. Fails the build if the library's
// shape changes, rather than shipping a model that cannot start.
const DFN_WORKLET_FILE = 'dfn-worklet.js';
const DFN_BLOB_LOAD = ` const blob = new Blob([workletCode], { type: 'application/javascript' });
const blobUrl = URL.createObjectURL(blob);
await audioContext.audioWorklet.addModule(blobUrl);`;
function dfnWorkletToFile(esmPath) {
const src = fs.readFileSync(esmPath, 'utf8');
const literal = src.match(/^var workletCode = (".*");\s*$/m)?.[1];
const code = literal ? JSON.parse(literal) : undefined;
if (!code?.includes('registerProcessor(') || !src.includes(DFN_BLOB_LOAD)) {
throw new Error(
`[lotus-denoise] deepfilternet3-noise-filter changed shape (inline worklet / blob loader not found in ${esmPath}) — build aborted; update dfnWorkletToFile in vite.config.js.`,
);
}
fs.writeFileSync(path.join(path.dirname(esmPath), DFN_WORKLET_FILE), code);
fs.writeFileSync(
esmPath,
src.replace(
DFN_BLOB_LOAD,
` // [lotus] Loaded from a file, not a blob: URL (CSP script-src 'self').\n await audioContext.audioWorklet.addModule(new URL('./${DFN_WORKLET_FILE}', import.meta.url).href);`,
),
);
}
// Lotus ML noise suppression: ship the denoise worklets/wasm/models alongside
// the vendored Element Call bundle. Runs after viteStaticCopy has populated
// dist/public/element-call (closeBundle order). [Gitea #293] The dev server
@@ -212,6 +242,7 @@ function lotusDenoise() {
fs.mkdirSync(path.dirname(d), { recursive: true });
fs.copyFileSync(s, d);
});
dfnWorkletToFile(path.join(denoiseDir, 'deepfilternet/index.esm.js'));
// [lotus] DENOISE CUTOVER: the getUserMedia shim is no longer injected.
// Our forked Element Call now runs ML denoise in-source as a real LiveKit