Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 5f4844cad5 feat(lotus): report remote screenshare audio to the host (cinny #277)
CI / Build embedded bundle (pull_request) Successful in 1m55s
CI / Publish to Gitea npm registry (pull_request) Skipped
New fromWidget action io.lotus.screenshare_audio_state { present }: each
LivekitRoomAudioRenderer reports whether a remote participant's
ScreenShareAudio track is being received; the combined answer is sent to
the host on change, and cleared when a renderer unmounts. cinny uses it to
show "Mute Screenshare Audio" only when there is something to mute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 14:21:52 -04:00
Lotus CIandClaude Opus 5.5 848dcaafa9 chore(lotus): 0.25.0-lotus.26
CI / Build embedded bundle (push) Successful in 1m55s
CI / Publish to Gitea npm registry (push) Successful in 1m0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 12:41:55 -04:00
jared 1ad32e56c7 Merge pull request 'feat(lotus): accept decorations from chat.lotusguild.org (matrix #22)' (#56) from feat/decorations-origin into lotus
CI / Build embedded bundle (push) Canceled after 1m46s
CI / Publish to Gitea npm registry (push) Canceled after 0s
2026-10-19 12:40:07 -04:00
Lotus CIandClaude Opus 5.5 090edc5c2c feat(lotus): accept decorations from chat.lotusguild.org (matrix #22)
CI / Build embedded bundle (pull_request) Successful in 1m42s
CI / Publish to Gitea npm registry (pull_request) Skipped
Decorations moved off Nextcloud to https://chat.lotusguild.org/decorations.
Add that origin to ALLOWED_DECORATION_ORIGINS (first, so tests use it);
keep drive.lotusguild.org until no host build points there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 12:37:53 -04:00
jared dbf77cdc60 Merge pull request 'docs(lotus): LOTUS.md lists io.lotus.call_error and io.lotus.reconnect (#50)' (#55) from docs/lotus-call-error into lotus
CI / Build embedded bundle (push) Successful in 2m8s
CI / Publish to Gitea npm registry (push) Skipped
2026-10-19 01:36:12 -04:00
Lotus CIandClaude Opus 5.5 2ef80cef2c docs(lotus): LOTUS.md lists io.lotus.call_error and io.lotus.reconnect (#50)
CI / Build embedded bundle (pull_request) Successful in 2m1s
CI / Publish to Gitea npm registry (pull_request) Skipped
Regenerate the action table from lotusActions.ts: add the two actions
from #50, and note which cinny PR handles each one still in flight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 00:01:18 -04:00
Lotus CIandClaude Opus 5.5 88c648ba8b chore(lotus): 0.25.0-lotus.25
CI / Build embedded bundle (push) Successful in 2m35s
CI / Publish to Gitea npm registry (push) Successful in 55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 15:27:50 -04:00
jared 0aa543cc7d Merge pull request 'docs(lotus): LOTUS.md: the io.lotus.* widget protocol, URL flags and release flow (#43)' (#53) from docs/lotus-protocol into lotus
CI / Build embedded bundle (push) Successful in 3m6s
CI / Publish to Gitea npm registry (push) Skipped
2026-10-18 07:49:15 -04:00
jared cc57292bf4 Merge pull request 'fix(lotus): soundboard says "not permitted" in audio-only rooms instead of failing silently (#47)' (#52) from lotus-soundboard-not-permitted into lotus
CI / Build embedded bundle (push) Canceled after 0s
CI / Publish to Gitea npm registry (push) Canceled after 0s
2026-10-18 07:42:09 -04:00
jared 22f9083768 Merge pull request 'fix(lotus): restart the shared mic sampler only when the LiveKit rooms change (#45)' (#51) from lotus-mic-sampler-churn into lotus
CI / Build embedded bundle (push) Canceled after 0s
CI / Publish to Gitea npm registry (push) Canceled after 0s
2026-10-18 07:38:41 -04:00
jared d7d75cbba5 Merge pull request 'feat(lotus): tell the host when the call drops, and let it press Reconnect (cinny #318)' (#50) from lotus-call-error into lotus
CI / Build embedded bundle (push) Canceled after 0s
CI / Publish to Gitea npm registry (push) Canceled after 0s
2026-10-18 07:32:17 -04:00
jared 4be3e92eb6 Merge pull request 'fix(lotus): a homeserver stall shorter than the delayed-leave window no longer ends the call (#46)' (#54) from lotus-stall-retry into lotus
CI / Build embedded bundle (push) Canceled after 0s
CI / Publish to Gitea npm registry (push) Canceled after 0s
2026-10-18 04:52:15 -04:00
Lotus CIandClaude Opus 5.5 a1dd0e254c fix(lotus): a homeserver stall shorter than the delayed-leave window no longer ends the call (#46)
CI / Build embedded bundle (pull_request) Successful in 3m33s
CI / Publish to Gitea npm registry (pull_request) Skipped
maximumNetworkErrorRetryCount was sized to min(sync grace period,
leave delay), i.e. 10 s, so the membership manager gave up 20-35 s into
a homeserver stall and showed "Connection lost", even when the server
would keep the membership for 60 s (prod config). Size it to the leave
delay instead: the embedded call has no /sync loop for the grace period
to describe, and the server's delayed leave is what actually ends the
membership.

Fixes #46

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 02:47:36 -04:00
Lotus CIandClaude Opus 5.5 167ffc2440 docs(lotus): LOTUS.md: the io.lotus.* widget protocol, URL flags and release flow (#43)
CI / Build embedded bundle (pull_request) Successful in 4m5s
CI / Publish to Gitea npm registry (pull_request) Skipped
Everything the host relies on was documented only in src/lotus comments.
Add a table of the 22 io.lotus.* actions (generated from lotusActions.ts,
with direction, payload and whether cinny handles each), the URL flags,
fork-only behaviour and the release/publish steps.

Fixes #43

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 01:24:22 -04:00
Lotus CIandClaude Opus 5.5 888ed1a66c fix(lotus): soundboard says "not permitted" in audio-only rooms instead of failing silently (#47)
CI / Build embedded bundle (pull_request) Successful in 3m20s
CI / Publish to Gitea npm registry (pull_request) Skipped
Clips publish as an Unknown-source track. In audio-only rooms the guard
signs a microphone-only token, LiveKit refuses the publish, and since
the fork had already replied {} the host never heard. Check the grants
up front and reply { played: false, reason: "not_permitted" }.

Refs #47

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 01:22:49 -04:00
Lotus CIandClaude Opus 5.5 73bf2133dc fix(lotus): restart the shared mic sampler only when the LiveKit rooms change (#45)
CI / Build embedded bundle (pull_request) Successful in 3m16s
CI / Publish to Gitea npm registry (pull_request) Skipped
observeLocalMicSample$ switchMapped on every allConnections$ emission,
which re-emits far more often than the set of rooms changes. Each one
stopped the clone, closed the AudioContext and emitted null, and the
null reset the muted-speech gate, so the "You're muted" nudge could
never stay true long enough to fire. Map to the room list and only
restart when it actually changes.

Fixes #45

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 01:17:06 -04:00
Lotus CIandClaude Opus 5.5 07e1506dd8 feat(lotus): tell the host when the call drops, and let it press Reconnect (cinny #318)
CI / Build embedded bundle (pull_request) Successful in 3m2s
CI / Publish to Gitea npm registry (pull_request) Skipped
After a homeserver stall the frame shows "Connection lost · Reconnect"
while the host kept showing the call as joined, with controls that did
nothing. While the error page is up, report io.lotus.call_error
{ code, recoverable } (and { code: null } when it clears), and accept
io.lotus.reconnect to run the page's own Reconnect / Try again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 00:54:48 -04:00
Lotus CIandClaude Opus 5.5 58625c86b0 chore(lotus): 0.25.0-lotus.24
CI / Build embedded bundle (push) Successful in 3m14s
CI / Publish to Gitea npm registry (push) Successful in 1m19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:47:48 -04:00
jared e3ac9a96d6 Merge pull request 'feat(lotus): report the frame's audio outputs to the host (cinny #310)' (#49) from fix/audio-output-list into lotus
CI / Build embedded bundle (push) Canceled after 0s
CI / Publish to Gitea npm registry (push) Canceled after 0s
2026-10-17 20:45:10 -04:00
Lotus CIandClaude Opus 5.5 34d541b707 feat(lotus): report the frame's audio outputs to the host (cinny #310)
CI / Build embedded bundle (pull_request) Successful in 3m28s
CI / Publish to Gitea npm registry (pull_request) Skipped
Browsers hash deviceIds per frame/origin, so ids from the host's own
enumerateDevices() are unknown in here and select() fell back to the
default. io.lotus.audio_output_state now also carries
available: [{id,label}] (frame-valid ids), so the host's call-bar menu
can offer real choices.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 19:48:33 -04:00
Lotus CIandClaude Opus 5.5 eb59fd5b3f chore(lotus): 0.25.0-lotus.23
CI / Build embedded bundle (push) Successful in 2m51s
CI / Publish to Gitea npm registry (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 17:53:24 -04:00
jared fea233f5e1 Merge pull request 'feat(lotus): sendNotificationType=none turns the call-start notification off (cinny #295)' (#48) from lotus-notify-none into lotus
CI / Build embedded bundle (push) Successful in 3m18s
CI / Publish to Gitea npm registry (push) Skipped
2026-10-16 23:13:46 -04:00
Lotus CIandClaude Opus 5.5 93905c5bc1 feat(lotus): sendNotificationType=none turns the call-start notification off (cinny #295)
CI / Build embedded bundle (pull_request) Successful in 3m55s
CI / Publish to Gitea npm registry (pull_request) Skipped
The widget intent presets default sendNotificationType to "notification",
and the URL param could only switch it to ring/notification, so the host
had no way to say "don't notify". In persistent voice channels and space
rooms that notification carries an @room mention: every member got a
highlighted ping (and phone push) whenever a moderator joined first, and
other clients showed it as a missed call.

`sendNotificationType=none` now clears it after the presets are applied.
Unit test added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 22:06:12 -04:00
Lotus CIandClaude Opus 5.5 3b0050e8bb chore(lotus): 0.25.0-lotus.22
CI / Build embedded bundle (push) Successful in 4m12s
CI / Publish to Gitea npm registry (push) Successful in 1m11s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 13:05:01 -04:00
jared 41b45bef25 Merge pull request #42: in-frame "Share your screen?" prompt (cinny #43)
CI / Build embedded bundle (push) Canceled after 0s
CI / Publish to Gitea npm registry (push) Canceled after 0s
2026-09-27 13:05:01 -04:00
Lotus CIandClaude Opus 5.5 bb675fa50e feat(lotus): in-frame "Share your screen?" prompt replaces the corner button (cinny #43)
CI / Build embedded bundle (pull_request) Successful in 3m25s
CI / Publish to Gitea npm registry (pull_request) Skipped
Where the host can't delegate the user's click (Firefox, Safari, WebKitGTK),
lotus.20 showed EC's own screenshare button floating in the frame's corner,
apart from the host's call bar. Now the host keeps its bar button and sends
the new toWidget io.lotus.prompt_screenshare; the fork shows a small
"Share your screen?" card inside the frame, just above the bar, and its
Share button starts the share with the frame's own click. Same two clicks as
the host's confirm on Chromium.

- The card focuses Share; Cancel and Escape close it; it closes if a share
  starts some other way or the room's call policy stops allowing sharing,
  and never opens where the policy forbids it (set_frame_screenshare).
- Picture-in-picture (~158 px tall): compact card without the description.
- controls_state: frameScreenshare is now always false (the corner button is
  gone), screensharePrompt says the host should use the prompt, and
  screensharePromptOpen lets the host stop covering the frame meanwhile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 12:48:12 -04:00
Lotus CIandClaude Opus 5.5 9e4ddb3eb6 chore(lotus): 0.25.0-lotus.21
CI / Build embedded bundle (push) Successful in 4m16s
CI / Publish to Gitea npm registry (push) Successful in 1m10s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 01:30:40 -04:00
jared 9d57d104b4 Merge pull request #41: work when served from its own origin (cinny #43)
CI / Build embedded bundle (push) Canceled after 6s
CI / Publish to Gitea npm registry (push) Canceled after 0s
2026-09-27 01:30:31 -04:00
27 changed files with 783 additions and 160 deletions
+82
View File
@@ -0,0 +1,82 @@
# Lotus fork of Element Call
This fork is embedded by [Lotus Chat](https://code.lotusguild.org/LotusGuild/cinny)
(cinny) as the in-app call frame. Everything it adds is opt-in: without the
URL flags and widget actions below it behaves like upstream Element Call.
The source of truth is `src/lotus/` (the `io.lotus.*` actions are declared in
`src/lotus/lotusActions.ts`). Keep this file in step with it: when the host and
the fork drift, features fail silently (element-call#43, cinny#260).
## URL flags
The host sets these on the widget URL (cinny: `CallEmbed.getWidget`).
| Flag | Effect |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `lotusCallState=true` | Stream call state to the host: `io.lotus.call_state` (participants, speaking, mute) and `io.lotus.mic_level`. |
| `lotusTheme=true` | Apply the host theme; the frame's root `color-scheme` follows it. |
| `lotusTransparent=true` | Transparent canvas so the host's wallpaper shows through. Only meaningful with `lotusTheme` (the fork applies it anyway and warns). |
| `lotusHostControls=true` | The host draws the call bar: hide Element Call's footer and enable the host-control actions. |
| `lotusFrameScreenshare=true` | With `lotusHostControls`: show the frame's own screenshare button, for engines without Capability Delegation (Firefox, Safari) where the host can't start a share. |
| `lotusAudioInject=true` | Accept `io.lotus.inject_audio` (soundboard). |
| `lotusDenoiseSource=true` | Run ML noise suppression in-source as a LiveKit audio TrackProcessor. |
| `lotusModel=rnnoise\|speex\|dtln\|deepfilternet` | Denoise model (default `rnnoise`; falls back to `rnnoise` once if the chosen model fails). |
| `lotusGate=true`, `lotusGateThreshold=<dB>` | Noise gate after denoise (default −45 dB). |
| `lotusDenoiseFloor=<0..1>` | Denoise mix floor. |
| `lotusDenoiseBase=<url>` | Where the denoise worklets / wasm / models are served (same-origin by default: `./denoise/`). |
| `sendNotificationType=none` | Upstream param, extended: `none` sends no call-start notification at all (cinny#295; voice channels shouldn't @room-ping). |
## Widget actions (`io.lotus.*`)
"host → call" actions must be allow-listed in `LOTUS_TO_WIDGET_ACTIONS`.
The table is generated from `src/lotus/lotusActions.ts`; the last column was
checked against cinny's `lotus` branch.
| Action | Direction | Payload / meaning | Handled by cinny (`lotus`) |
| ---------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
| `io.lotus.call_state` | call → host | in-call per-participant speaking / mute state. NOTE: matrix-widget-api `transport.send` is request/response — the host MUST register a handler that replies/acks each one (cinny's `listenAction` does, replying `{}`). If the host has no handler, ClientWidgetApi immediately error-replies "unsupported from-widget action", so the data is silently dropped and each throttled send rejects (caught) — functional miss + log churn, not a hang. | yes |
| `io.lotus.focus_participant` | host → call | pin/spotlight (or clear, with userId=null) a participant. | yes |
| `io.lotus.inject_audio` | host → call | play an audio clip into the call as a separate published track. | yes |
| `io.lotus.set_quality` | host → call | set audio/screenshare encoding quality (bitrate/framerate). | yes |
| `io.lotus.decorations` | host → call | per-user avatar-decoration image URLs for in-call tiles. | yes |
| `io.lotus.set_deafen` | host → call | deafen remote audio (and optionally mute screenshare audio). | yes |
| `io.lotus.request_state` | call → host | sent on (re)registration of a lotus toWidget handler (e.g. decorations) so the host can re-push state that would otherwise only be sent on change (decorations roster, the current focus pin) after an EC-side reconnect/remount. cinny's `resendForkState()` should respond to this the same way it responds to a fresh join. | yes |
| `io.lotus.denoise_state` | call → host | real state of the in-source denoise engine — `{ active: boolean, model: string, error?: string }` — sent once the processor attaches (or after the rnnoise fallback also fails), so the host toggle can reflect reality rather than the requested state. | yes |
| `io.lotus.call_summary` | call → host | one-shot end-of-call readout for the local participant — `{ durationMs, reconnects, poorMs, verdict }` (#143) — sent on SFU disconnect or in-call teardown, whichever comes first. | yes |
| `io.lotus.set_audio_output` | host → call | select the audio output device `{ deviceId }` (#119). | yes |
| `io.lotus.audio_output_state` | call → host | the currently selected output `{ deviceId }` (#119). | cinny #417 |
| `io.lotus.screenshare_audio_state` | call → host | `{ present }`: whether any remote screenshare audio is being received, sent on change (cinny #277). | cinny #277 |
| `io.lotus.screenshare_notice` | call → host | local screenshare reminder `{ kind: "ended" \| "no-frames" \| "alone" }` — window closed, no frames for 15 s, or 30 min of sharing with nobody else in the call (#39). Each fires at most once per share. | yes |
| `io.lotus.set_layout` | host → call | switch the call layout `{ layout: "grid" \| "spotlight" }` (cinny #43 — replaces the host clicking EC's hidden layout radio). | yes |
| `io.lotus.open_settings` | host → call | open EC's settings modal, or close it with `{ open: false }`; omit `open` to toggle (cinny #43). | yes |
| `io.lotus.toggle_reactions` | host → call | toggle the reactions / raise-hand menu (cinny #43). | yes |
| `io.lotus.set_screenshare` | host → call | start/stop sharing `{ on?: boolean }` (omit to toggle; cinny #43). Starting calls getDisplayMedia, which needs the user's click: the host sends this with Capability Delegation (`delegate: "display-capture"`), and without it the browser rejects the share. | yes |
| `io.lotus.set_hotkeys` | host → call | `{ codes: string[] }` — KeyboardEvent.code values (PTT, deafen) to report while focus is inside this frame (cinny #43). `[]` stops. | yes |
| `io.lotus.set_frame_screenshare` | host → call | `{ visible: boolean }` — whether the in-frame screenshare button (`lotusFrameScreenshare`) may show, e.g. false while the room's call policy forbids screensharing (cinny #43). Defaults to visible. | yes |
| `io.lotus.prompt_screenshare` | host → call | `{}` — show the "Share your screen?" prompt inside this frame (cinny #43). For hosts that can't delegate the user's click: the prompt's Share button is clicked here, so getDisplayMedia gets its activation. | yes |
| `io.lotus.hotkey` | call → host | a watched key went down/up here, or this window's focus changed; see `LotusHotkeyReport` (cinny #43). | yes |
| `io.lotus.controls_state` | call → host | `{ screensharing: boolean, layout: "grid" \| "spotlight" \| null, screenshareAction: true, hotkeys: true, frameScreenshare: false, screensharePrompt: boolean, screensharePromptOpen: boolean }` whenever either changes (cinny #43), so the host stops reading EC's DOM for them. Its arrival also tells the host this fork supports the actions above. | yes |
| `io.lotus.mic_level` | call → host | local mic level `{ bars: 0 \| 1 \| 2 \| 3 }` (cinny #146), sent only when it changes (≤ 10 Hz); 0 while muted or with no mic. | yes |
| `io.lotus.call_error` | call → host | the call's error page is up (`{ code, recoverable }`) or cleared (`{ code: null }`) (cinny #318), so the host stops showing the call as joined after "Connection lost". | cinny #418 |
| `io.lotus.reconnect` | host → call | press the error page's Reconnect / Try again (cinny #318). | cinny #418 |
Entries naming a cinny PR are handled once that PR merges.
## Fork-only behaviour (no flag needed)
Per-person volume memory, screenshare notices (window closed / no frames /
sharing alone), mono mic capture, an SFU refusal reason instead of "Something
went wrong", "Try again" for failed connects, and support for running on its
own origin (cinny#43).
## Releasing
1. Merge the change into `lotus`.
2. Bump `version` in `embedded/web/package.json` (`0.25.0-lotus.N`), commit
`chore(lotus): 0.25.0-lotus.N` on `lotus`, and push a tag `v0.25.0-lotus.N`.
3. CI builds the embedded bundle and, for `v*` tags, publishes
`@lotusguild/element-call-embedded` to the Gitea npm registry
(`https://code.lotusguild.org/api/packages/LotusGuild/npm/`).
4. In cinny, pin the exact version in `package.json` (`npm install --save-exact
@lotusguild/element-call-embedded@0.25.0-lotus.N`) and open a PR.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lotusguild/element-call-embedded",
"version": "0.25.0-lotus.20",
"version": "0.25.0-lotus.26",
"files": [
"README.md",
"LICENSE-AGPL-3.0",
+13
View File
@@ -305,6 +305,19 @@ describe("UrlParams", () => {
});
});
it("[lotus] sendNotificationType=none turns the preset notification off", () => {
expect(
computeUrlParams(
"?intent=start_call&widgetId=1234&parentUrl=parent.org&sendNotificationType=none",
).sendNotificationType,
).toBeUndefined();
expect(
computeUrlParams(
"?intent=start_call&widgetId=1234&parentUrl=parent.org",
).sendNotificationType,
).toBe("notification");
});
it("accepts join_existing", () => {
expect(
computeUrlParams(
+8
View File
@@ -523,6 +523,14 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
...properties,
...intentPreset,
...pickBy(configuration, (v?: unknown) => v !== undefined),
// [lotus] `sendNotificationType=none` turns the intent preset's
// notification off. The host passes it for persistent voice channels and
// space rooms, where a call start must not @room-ping every member
// (LotusGuild/cinny#295). pickBy above drops undefined, so it can't be
// expressed as a plain override.
...(parser.getParam("sendNotificationType") === "none"
? { sendNotificationType: undefined }
: {}),
};
};
+16
View File
@@ -19,6 +19,10 @@ import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { useEarpieceAudioConfig } from "../MediaDevicesContext";
import { useReactiveState } from "../useReactiveState";
import * as controls from "../controls";
import {
clearScreenshareAudio,
reportScreenshareAudio,
} from "../lotus/lotusScreenshareAudio";
export interface MatrixAudioRendererProps {
/**
@@ -97,6 +101,18 @@ export function LivekitRoomAudioRenderer({
return true;
});
// [lotus / cinny #277] Tell the host whether remote screenshare audio is
// playing, so its "Mute Screenshare Audio" control only shows when useful.
const hasScreenshareAudio = tracks.some(
(ref) =>
!ref.participant.isLocal &&
ref.publication.source === Track.Source.ScreenShareAudio,
);
useEffect(() => {
reportScreenshareAudio(url, hasScreenshareAudio);
}, [url, hasScreenshareAudio]);
useEffect(() => (): void => clearScreenshareAudio(url), [url]);
// This component is also (in addition to the "only play audio for connected members" logic above)
// responsible for mimicking earpiece audio on iPhones.
// The Safari audio devices enumeration does not expose an earpiece audio device.
@@ -1,8 +0,0 @@
/* [cinny #43] EC's screenshare button, floating in the frame's bottom-right
corner while the host draws the rest of the call bar. */
.floating {
position: fixed;
right: var(--cpd-space-4x);
bottom: calc(env(safe-area-inset-bottom) + var(--cpd-space-4x));
z-index: 1;
}
-88
View File
@@ -1,88 +0,0 @@
/*
Copyright 2026 Lotus Guild
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TooltipProvider } from "@vector-im/compound-web";
import { BehaviorSubject } from "rxjs";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { LotusFrameScreenshare } from "./LotusFrameScreenshare";
import {
lotusFrameScreenshareEnabled,
lotusFrameScreenshareVisible$,
parseFrameScreensharePayload,
} from "./lotusControls";
// lotusFlag memoizes the URL params on first read, so set them once here.
window.history.pushState(
{},
"",
"/?lotusHostControls=1&lotusFrameScreenshare=1",
);
const mockVm = (
toggle: (() => void) | null,
sharing = new BehaviorSubject(false),
): CallViewModel =>
({
sharingScreen$: sharing,
toggleScreenSharing: toggle,
}) as unknown as CallViewModel;
const renderButton = (vm: CallViewModel): void => {
render(
<TooltipProvider>
<LotusFrameScreenshare vm={vm} />
</TooltipProvider>,
);
};
describe("LotusFrameScreenshare", () => {
it("is enabled by lotusHostControls + lotusFrameScreenshare", () => {
expect(lotusFrameScreenshareEnabled()).toBe(true);
});
it("toggles the share from a click inside the frame", async () => {
const toggle = vi.fn();
renderButton(mockVm(toggle));
await userEvent.click(screen.getByTestId("lotus_frame_screenshare"));
expect(toggle).toHaveBeenCalledTimes(1);
});
it("follows the sharing state", () => {
const sharing = new BehaviorSubject(false);
renderButton(mockVm(vi.fn(), sharing));
const button = screen.getByTestId("lotus_frame_screenshare");
expect(button).toHaveAttribute("aria-checked", "false");
act(() => sharing.next(true));
expect(button).toHaveAttribute("aria-checked", "true");
});
it("hides while the host's policy forbids sharing, unless a share is live", () => {
const sharing = new BehaviorSubject(false);
act(() => lotusFrameScreenshareVisible$.next(false));
renderButton(mockVm(vi.fn(), sharing));
expect(screen.queryByTestId("lotus_frame_screenshare")).toBeNull();
act(() => sharing.next(true));
expect(screen.getByTestId("lotus_frame_screenshare")).toBeInTheDocument();
act(() => lotusFrameScreenshareVisible$.next(true));
});
it("parses the set_frame_screenshare payload", () => {
expect(parseFrameScreensharePayload({ visible: false })).toBe(false);
expect(parseFrameScreensharePayload({ visible: true })).toBe(true);
expect(parseFrameScreensharePayload({})).toBeUndefined();
expect(parseFrameScreensharePayload(null)).toBeUndefined();
});
it("renders nothing when screensharing is unavailable", () => {
renderButton(mockVm(null));
expect(screen.queryByTestId("lotus_frame_screenshare")).toBeNull();
});
});
-41
View File
@@ -1,41 +0,0 @@
/*
Copyright 2026 Lotus Guild
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC } from "react";
import { ShareScreenButton } from "../button";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { useBehavior } from "../useBehavior";
import {
lotusFrameScreenshareEnabled,
lotusFrameScreenshareVisible$,
} from "./lotusControls";
import styles from "./LotusFrameScreenshare.module.css";
/**
* [cinny #43] EC's screenshare button, shown inside the frame when the host
* can't start a share for us (`lotusFrameScreenshare`). The rest of EC's
* footer stays hidden behind the host's call bar.
*/
export const LotusFrameScreenshare: FC<{ vm: CallViewModel }> = ({ vm }) => {
const sharing = useBehavior(vm.sharingScreen$);
const visible = useBehavior(lotusFrameScreenshareVisible$);
if (!lotusFrameScreenshareEnabled() || !vm.toggleScreenSharing) return null;
// Hidden by the host's call policy, but never while a share is live: the
// user must still be able to stop it.
if (!visible && !sharing) return null;
return (
<div className={styles.floating}>
<ShareScreenButton
size="md"
enabled={sharing}
onClick={vm.toggleScreenSharing}
data-testid="lotus_frame_screenshare"
/>
</div>
);
};
@@ -0,0 +1,43 @@
/* [cinny #43] "Share your screen?" card, drawn inside the frame just above the
host's call bar (the frame's bottom edge). */
.prompt {
position: fixed;
left: 50%;
bottom: calc(env(safe-area-inset-bottom) + var(--cpd-space-4x));
transform: translateX(-50%);
z-index: 2;
box-sizing: border-box;
width: max-content;
max-width: min(360px, calc(100% - 2 * var(--cpd-space-2x)));
padding: var(--cpd-space-4x);
display: flex;
flex-direction: column;
gap: var(--cpd-space-2x);
background: var(--cpd-color-bg-canvas-default);
border: 1px solid var(--cpd-color-border-interactive-secondary);
border-radius: var(--cpd-space-3x);
box-shadow: 0 8px 32px rgb(0 0 0 / 35%);
color: var(--cpd-color-text-primary);
}
.title {
margin: 0;
}
.buttons {
display: flex;
gap: var(--cpd-space-2x);
}
/* The call's picture-in-picture window is only ~158px tall: keep the card to
the question and the two buttons. */
@media (max-height: 240px) {
.prompt {
bottom: var(--cpd-space-2x);
padding: var(--cpd-space-2x) var(--cpd-space-3x);
gap: var(--cpd-space-1-5x);
}
.description {
display: none;
}
}
+125
View File
@@ -0,0 +1,125 @@
/*
Copyright 2026 Lotus Guild
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TooltipProvider } from "@vector-im/compound-web";
import { BehaviorSubject } from "rxjs";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { LotusScreensharePrompt } from "./LotusScreensharePrompt";
import {
lotusFrameScreenshareEnabled,
lotusFrameScreenshareVisible$,
lotusScreensharePrompt$,
shouldPromptScreenshare,
} from "./lotusControls";
// lotusFlag memoizes the URL params on first read, so set them once here.
window.history.pushState(
{},
"",
"/?lotusHostControls=1&lotusFrameScreenshare=1",
);
const mockVm = (
toggle: (() => void) | null,
sharing = new BehaviorSubject(false),
): CallViewModel =>
({
sharingScreen$: sharing,
toggleScreenSharing: toggle,
}) as unknown as CallViewModel;
const renderPrompt = (vm: CallViewModel): void => {
render(
<TooltipProvider>
<LotusScreensharePrompt vm={vm} />
</TooltipProvider>,
);
};
const prompt = (): HTMLElement | null =>
screen.queryByTestId("lotus_screenshare_prompt");
afterEach(() => {
act(() => {
lotusScreensharePrompt$.next(false);
lotusFrameScreenshareVisible$.next(true);
});
});
describe("LotusScreensharePrompt", () => {
it("is enabled by lotusHostControls + lotusFrameScreenshare", () => {
expect(lotusFrameScreenshareEnabled()).toBe(true);
});
it("stays hidden until the host asks", () => {
renderPrompt(mockVm(vi.fn()));
expect(prompt()).toBeNull();
act(() => lotusScreensharePrompt$.next(true));
expect(prompt()).toBeInTheDocument();
expect(
screen.getByRole("alertdialog", { name: "Share your screen?" }),
).toBeInTheDocument();
});
it("focuses Share, and Share starts the share and closes", async () => {
const toggle = vi.fn();
renderPrompt(mockVm(toggle));
act(() => lotusScreensharePrompt$.next(true));
const share = screen.getByTestId("lotus_screenshare_prompt_share");
expect(share).toHaveFocus();
await userEvent.click(share);
expect(toggle).toHaveBeenCalledTimes(1);
expect(prompt()).toBeNull();
});
it("Cancel and Escape close without sharing", async () => {
const toggle = vi.fn();
renderPrompt(mockVm(toggle));
act(() => lotusScreensharePrompt$.next(true));
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(prompt()).toBeNull();
act(() => lotusScreensharePrompt$.next(true));
await userEvent.keyboard("{Escape}");
expect(prompt()).toBeNull();
expect(toggle).not.toHaveBeenCalled();
});
it("closes when a share starts some other way", () => {
const sharing = new BehaviorSubject(false);
renderPrompt(mockVm(vi.fn(), sharing));
act(() => lotusScreensharePrompt$.next(true));
act(() => sharing.next(true));
expect(prompt()).toBeNull();
});
it("closes when the room's policy stops allowing screensharing", () => {
renderPrompt(mockVm(vi.fn()));
act(() => lotusScreensharePrompt$.next(true));
act(() => lotusFrameScreenshareVisible$.next(false));
expect(prompt()).toBeNull();
});
it("renders nothing when screensharing is unavailable", () => {
renderPrompt(mockVm(null));
act(() => lotusScreensharePrompt$.next(true));
expect(prompt()).toBeNull();
});
});
describe("shouldPromptScreenshare", () => {
const ok = { enabled: true, allowed: true, canShare: true, sharing: false };
it("prompts only in prompt mode, when allowed, able and not sharing", () => {
expect(shouldPromptScreenshare(ok)).toBe(true);
expect(shouldPromptScreenshare({ ...ok, enabled: false })).toBe(false);
expect(shouldPromptScreenshare({ ...ok, allowed: false })).toBe(false);
expect(shouldPromptScreenshare({ ...ok, canShare: false })).toBe(false);
expect(shouldPromptScreenshare({ ...ok, sharing: true })).toBe(false);
});
});
+102
View File
@@ -0,0 +1,102 @@
/*
Copyright 2026 Lotus Guild
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, useCallback, useEffect, useId, useRef } from "react";
import { Button, Text } from "@vector-im/compound-web";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { useBehavior } from "../useBehavior";
import {
lotusFrameScreenshareEnabled,
lotusFrameScreenshareVisible$,
lotusScreensharePrompt$,
} from "./lotusControls";
import styles from "./LotusScreensharePrompt.module.css";
/**
* [cinny #43] "Share your screen?" asked inside the call frame, for hosts that
* can't hand the user's click to this frame (Firefox, Safari, WebKitGTK). The
* host's call-bar button sends `io.lotus.prompt_screenshare`; the Share click
* lands here, so getDisplayMedia runs with this frame's own activation. Same
* two clicks as the host's own confirm on Chromium.
*/
export const LotusScreensharePrompt: FC<{ vm: CallViewModel }> = ({ vm }) => {
const open = useBehavior(lotusScreensharePrompt$);
const sharing = useBehavior(vm.sharingScreen$);
const allowed = useBehavior(lotusFrameScreenshareVisible$);
const shareRef = useRef<HTMLButtonElement>(null);
const titleId = useId();
const descriptionId = useId();
const close = useCallback(() => lotusScreensharePrompt$.next(false), []);
// Stale once a share is running (started some other way) or the room's
// policy stops allowing it.
useEffect(() => {
if (open && (sharing || !allowed)) close();
}, [open, sharing, allowed, close]);
useEffect(() => {
if (open) shareRef.current?.focus();
}, [open]);
useEffect(() => {
if (!open) return undefined;
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key !== "Escape") return;
e.preventDefault();
close();
};
window.addEventListener("keydown", onKeyDown, true);
return (): void => window.removeEventListener("keydown", onKeyDown, true);
}, [open, close]);
const toggle = vm.toggleScreenSharing;
if (!open || !lotusFrameScreenshareEnabled() || !toggle) return null;
const onShare = (): void => {
close();
// Synchronously inside this click: getDisplayMedia needs its activation.
toggle();
};
return (
<div
className={styles.prompt}
role="alertdialog"
aria-labelledby={titleId}
aria-describedby={descriptionId}
data-testid="lotus_screenshare_prompt"
>
<Text
as="h2"
id={titleId}
size="md"
weight="semibold"
className={styles.title}
>
Share your screen?
</Text>
<Text id={descriptionId} size="md" className={styles.description}>
Your screen will be visible to everyone in this call.
</Text>
<div className={styles.buttons}>
<Button
ref={shareRef}
size="md"
onClick={onShare}
data-testid="lotus_screenshare_prompt_share"
>
Share
</Button>
<Button size="md" kind="secondary" onClick={close}>
Cancel
</Button>
</div>
</div>
);
};
+3
View File
@@ -33,6 +33,8 @@ describe("LotusWidgetActions", () => {
LotusWidgetActions.SetScreenshare,
LotusWidgetActions.SetHotkeys,
LotusWidgetActions.SetFrameScreenshare,
LotusWidgetActions.PromptScreenshare,
LotusWidgetActions.Reconnect,
];
expect(new Set(LOTUS_TO_WIDGET_ACTIONS)).toEqual(new Set(expectedToWidget));
@@ -55,5 +57,6 @@ describe("LotusWidgetActions", () => {
);
expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain(LotusWidgetActions.MicLevel);
expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain(LotusWidgetActions.Hotkey);
expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain(LotusWidgetActions.CallError);
});
});
+24 -1
View File
@@ -56,6 +56,12 @@ export enum LotusWidgetActions {
SetAudioOutput = "io.lotus.set_audio_output",
/** fromWidget: the currently selected output `{ deviceId }` (#119). */
AudioOutputState = "io.lotus.audio_output_state",
/**
* fromWidget: `{ present: boolean }`, whether any remote screenshare audio
* is being received, so the host's "Mute Screenshare Audio" control only
* shows when there is something to mute (cinny #277). Sent on change.
*/
ScreenshareAudioState = "io.lotus.screenshare_audio_state",
/**
* fromWidget: local screenshare reminder `{ kind: "ended" | "no-frames" | "alone" }`
* — window closed, no frames for 15 s, or 30 min of sharing with nobody
@@ -92,6 +98,12 @@ export enum LotusWidgetActions {
* policy forbids screensharing (cinny #43). Defaults to visible.
*/
SetFrameScreenshare = "io.lotus.set_frame_screenshare",
/**
* toWidget: `{}` — show the "Share your screen?" prompt inside this frame
* (cinny #43). For hosts that can't delegate the user's click: the prompt's
* Share button is clicked here, so getDisplayMedia gets its activation.
*/
PromptScreenshare = "io.lotus.prompt_screenshare",
/**
* fromWidget: a watched key went down/up here, or this window's focus
* changed; see `LotusHotkeyReport` (cinny #43).
@@ -99,7 +111,8 @@ export enum LotusWidgetActions {
Hotkey = "io.lotus.hotkey",
/**
* fromWidget: `{ screensharing: boolean, layout: "grid" | "spotlight" | null,
* screenshareAction: true, hotkeys: true, frameScreenshare: boolean }`
* screenshareAction: true, hotkeys: true, frameScreenshare: false,
* screensharePrompt: boolean, screensharePromptOpen: boolean }`
* whenever either changes (cinny #43), so the host stops reading EC's DOM for
* them. Its arrival also tells the host this fork supports the actions above.
*/
@@ -109,6 +122,14 @@ export enum LotusWidgetActions {
* only when it changes (≤ 10 Hz); 0 while muted or with no mic.
*/
MicLevel = "io.lotus.mic_level",
/**
* fromWidget: the call's error page is up (`{ code, recoverable }`) or
* cleared (`{ code: null }`) (cinny #318), so the host stops showing the call
* as joined after "Connection lost".
*/
CallError = "io.lotus.call_error",
/** toWidget: press the error page's Reconnect / Try again (cinny #318). */
Reconnect = "io.lotus.reconnect",
}
/** toWidget Lotus actions that must be allow-listed in `initializeWidget`. */
@@ -125,4 +146,6 @@ export const LOTUS_TO_WIDGET_ACTIONS: LotusWidgetActions[] = [
LotusWidgetActions.SetScreenshare,
LotusWidgetActions.SetHotkeys,
LotusWidgetActions.SetFrameScreenshare,
LotusWidgetActions.PromptScreenshare,
LotusWidgetActions.Reconnect,
];
+15
View File
@@ -11,6 +11,7 @@ import { of } from "rxjs";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import {
canPublishSoundboard,
MAX_INJECT_BYTES,
parseInjectSource,
startLotusAudioInject,
@@ -258,3 +259,17 @@ test("parseInjectSource rejects oversized or non-buffer audio", () => {
expect(parseInjectSource({ audio: "not bytes" })).toBeNull();
expect(parseInjectSource({ audio: new Uint8Array(4) })).toBeNull();
});
test("canPublishSoundboard: refused by a microphone-only grant (EC #47)", () => {
expect(canPublishSoundboard(undefined)).toBe(true);
expect(
canPublishSoundboard({ canPublish: true, canPublishSources: [] }),
).toBe(true);
expect(
canPublishSoundboard({ canPublish: true, canPublishSources: [2] }),
).toBe(false);
expect(
canPublishSoundboard({ canPublish: true, canPublishSources: [2, 0] }),
).toBe(true);
expect(canPublishSoundboard({ canPublish: false })).toBe(false);
});
+29
View File
@@ -60,6 +60,22 @@ function acquireSharedAudio(): {
* No effect unless the host sends the action. Returns a teardown function that
* also aborts any clip still playing.
*/
/**
* Pure: whether these LiveKit permissions allow publishing the soundboard's
* Unknown-source track (EC #47). No permissions or an empty source list
* means unrestricted; otherwise UNKNOWN (TrackSource 0) must be allowed.
*/
export function canPublishSoundboard(
perm:
| { canPublish?: boolean; canPublishSources?: readonly number[] }
| undefined,
): boolean {
if (!perm) return true;
if (perm.canPublish === false) return false;
const sources = perm.canPublishSources ?? [];
return sources.length === 0 || sources.includes(0);
}
export function startLotusAudioInject(vm: CallViewModel): () => void {
const w = widget;
if (!w) return () => undefined;
@@ -116,6 +132,19 @@ export function startLotusAudioInject(vm: CallViewModel): () => void {
return;
}
// [EC #47] Clips publish as an Unknown-source track. Audio-only rooms get
// a token limited to the microphone source, where LiveKit refuses that
// publish *after* we replied; say so up front instead of failing silently.
if (
rooms.some((r) => !canPublishSoundboard(r.localParticipant.permissions))
) {
w.api.transport.reply(ev.detail, {
played: false,
reason: "not_permitted",
});
return;
}
w.api.transport.reply(ev.detail, {});
void playInjectedClip(clip, volume, rooms, activeClips).catch((e) =>
logger.warn("[lotus] inject_audio failed", e),
+24
View File
@@ -0,0 +1,24 @@
/*
Copyright 2026 Lotus Guild
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test } from "vitest";
import { audioOutputList } from "./lotusAudioOutput";
import { type AudioOutputDeviceLabel } from "../state/MediaDevices";
test("audioOutputList labels every kind of output (cinny #310)", () => {
const m = new Map<string, AudioOutputDeviceLabel>([
["", { type: "default", name: "Speakers" }],
["a1", { type: "name", name: "Headset" }],
["a2", { type: "number", number: 3 }],
]);
expect(audioOutputList(m)).toEqual([
{ id: "", label: "Default (Speakers)" },
{ id: "a1", label: "Headset" },
{ id: "a2", label: "Output 3" },
]);
});
+41 -2
View File
@@ -8,7 +8,12 @@ Please see LICENSE in the repository root for full details.
import { type IWidgetApiRequest } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MediaDevices } from "../state/MediaDevices";
import { combineLatest } from "rxjs";
import {
type AudioOutputDeviceLabel,
type MediaDevices,
} from "../state/MediaDevices";
import { widget } from "../widget";
import { LotusWidgetActions } from "./lotusActions";
import { lotusSendToHost } from "./lotusWidget";
@@ -19,6 +24,10 @@ import { lotusSendToHost } from "./lotusWidget";
* selects the output; the fork answers each change (and the initial state)
* with `io.lotus.audio_output_state { deviceId, available: [{id,label}] }`.
* No effect unless the host sends the action. Returns a teardown function.
*
* [cinny #310] Browsers hash deviceIds per frame/origin, so the host can't
* use ids from its own enumerateDevices(); it builds its menu from
* `available` here, whose ids are valid in this frame.
*/
export function startLotusAudioOutput(mediaDevices: MediaDevices): () => void {
const w = widget;
@@ -33,9 +42,13 @@ export function startLotusAudioOutput(mediaDevices: MediaDevices): () => void {
};
w.lazyActions.on(LotusWidgetActions.SetAudioOutput, handler);
const sub = mediaDevices.audioOutput.selected$.subscribe((selected) => {
const sub = combineLatest([
mediaDevices.audioOutput.available$,
mediaDevices.audioOutput.selected$,
]).subscribe(([available, selected]) => {
lotusSendToHost(LotusWidgetActions.AudioOutputState, {
deviceId: selected?.id ?? null,
available: audioOutputList(available),
});
});
@@ -44,3 +57,29 @@ export function startLotusAudioOutput(mediaDevices: MediaDevices): () => void {
w.lazyActions.off(LotusWidgetActions.SetAudioOutput, handler);
};
}
/** The output list as `{id, label}` for the host's menu (#310). */
export function audioOutputList(
available: Map<string, AudioOutputDeviceLabel>,
): { id: string; label: string }[] {
return [...available].map(([id, l]) => {
let label: string;
switch (l.type) {
case "name":
label = l.name;
break;
case "number":
label = `Output ${l.number}`;
break;
case "default":
label = l.name ? `Default (${l.name})` : "Default";
break;
case "speaker":
label = "Speaker";
break;
default:
label = "Earpiece";
}
return { id, label };
});
}
+65 -8
View File
@@ -26,23 +26,39 @@ export interface LotusControlsState {
/** This fork handles `io.lotus.set_hotkeys` (always true). */
hotkeys: true;
/**
* EC's own screenshare button is shown inside the frame (see
* `lotusFrameScreenshare`), so the host should hide its own.
* EC's own screenshare button is shown inside the frame, so the host should
* hide its own. Always false since the prompt replaced that button; kept so
* a host that still reads it doesn't hide its button.
*/
frameScreenshare: boolean;
frameScreenshare: false;
/**
* The host should start a share with `io.lotus.prompt_screenshare` (the
* in-frame prompt) instead of `set_screenshare`: see
* `lotusFrameScreenshareEnabled`.
*/
screensharePrompt: boolean;
/**
* The prompt is showing. The host must let clicks through to this frame
* meanwhile (e.g. its picture-in-picture overlay).
*/
screensharePromptOpen: boolean;
}
/**
* [cinny #43] `lotusFrameScreenshare=1` (with `lotusHostControls`): the host
* can't hand the user's click into this frame (no Capability Delegation:
* Firefox, Safari, WebKitGTK), and `getDisplayMedia` needs that click here.
* So EC's own screenshare button stays visible in the frame, where the click
* is native, instead of the host clicking it through the DOM.
* The host's call-bar button then asks for `io.lotus.prompt_screenshare`, and
* the "Share your screen?" prompt is drawn inside this frame, where the Share
* click is native.
*/
export function lotusFrameScreenshareEnabled(): boolean {
return lotusFlag("lotusHostControls") && lotusFlag("lotusFrameScreenshare");
}
/** Whether the in-frame "Share your screen?" prompt is showing. */
export const lotusScreensharePrompt$ = new BehaviorSubject(false);
/**
* Host-controlled visibility of the in-frame screenshare button
* (`io.lotus.set_frame_screenshare`): the host hides it while the room's
@@ -50,6 +66,21 @@ export function lotusFrameScreenshareEnabled(): boolean {
*/
export const lotusFrameScreenshareVisible$ = new BehaviorSubject(true);
/**
* Whether `io.lotus.prompt_screenshare` should show the prompt: only in
* prompt mode, where the room's policy allows sharing, EC can share, and no
* share is running (stopping goes through `set_screenshare`, which needs no
* click). Exported for tests.
*/
export function shouldPromptScreenshare(s: {
enabled: boolean;
allowed: boolean;
canShare: boolean;
sharing: boolean;
}): boolean {
return s.enabled && s.allowed && s.canShare && !s.sharing;
}
/** `{ visible }` payload → the visibility to apply, or undefined. Exported for tests. */
export function parseFrameScreensharePayload(
data: unknown,
@@ -138,30 +169,51 @@ export function startLotusControls(vm: CallViewModel): () => void {
if (visible !== undefined) lotusFrameScreenshareVisible$.next(visible);
};
const onPromptScreenshare = (ev: CustomEvent<IWidgetApiRequest>): void => {
w.api.transport.reply(ev.detail, {});
if (
shouldPromptScreenshare({
enabled: lotusFrameScreenshareEnabled(),
allowed: lotusFrameScreenshareVisible$.value,
canShare: !!vm.toggleScreenSharing,
sharing: vm.sharingScreen$.value,
})
) {
lotusScreensharePrompt$.next(true);
}
};
w.lazyActions.on(LotusWidgetActions.SetScreenshare, onSetScreenshare);
w.lazyActions.on(
LotusWidgetActions.SetFrameScreenshare,
onSetFrameScreenshare,
);
w.lazyActions.on(LotusWidgetActions.PromptScreenshare, onPromptScreenshare);
const sub: Subscription = combineLatest([
vm.sharingScreen$,
vm.layoutSwitchVm$.pipe(
switchMap((l) => (l ? l.layout$ : of<LayoutMode | null>(null))),
),
lotusScreensharePrompt$,
])
.pipe(
map(
([screensharing, layout]): LotusControlsState => ({
([screensharing, layout, promptOpen]): LotusControlsState => ({
screensharing,
layout,
screenshareAction: true,
hotkeys: true,
frameScreenshare: lotusFrameScreenshareEnabled(),
frameScreenshare: false,
screensharePrompt: lotusFrameScreenshareEnabled(),
screensharePromptOpen: promptOpen,
}),
),
distinctUntilChanged(
(a, b) => a.screensharing === b.screensharing && a.layout === b.layout,
(a, b) =>
a.screensharing === b.screensharing &&
a.layout === b.layout &&
a.screensharePromptOpen === b.screensharePromptOpen,
),
)
.subscribe((state) => {
@@ -178,5 +230,10 @@ export function startLotusControls(vm: CallViewModel): () => void {
LotusWidgetActions.SetFrameScreenshare,
onSetFrameScreenshare,
);
w.lazyActions.off(
LotusWidgetActions.PromptScreenshare,
onPromptScreenshare,
);
lotusScreensharePrompt$.next(false);
};
}
+30
View File
@@ -83,6 +83,36 @@ test("[lotus #28] safeImageUrl only accepts the pinned decoration CDN origin (or
stop();
});
test("[matrix #22] both the new decoration host and the old Nextcloud one are accepted", () => {
const stop = startLotusDecorations();
const { result } = renderHook(() => useLotusDecoration("@alice:example.org"));
pushDecorations({
"@alice:example.org":
"https://chat.lotusguild.org/decorations/joystick.png",
});
expect(result.current).toBe(
"https://chat.lotusguild.org/decorations/joystick.png",
);
pushDecorations({
"@alice:example.org":
"https://drive.lotusguild.org/public.php/dav/files/x/cinny-decorations/joystick.png",
});
expect(result.current).toBe(
"https://drive.lotusguild.org/public.php/dav/files/x/cinny-decorations/joystick.png",
);
// A look-alike subdomain is a different origin.
pushDecorations({
"@alice:example.org":
"https://chat.lotusguild.org.evil.example/joystick.png",
});
expect(result.current).toBeUndefined();
stop();
});
test("[lotus #17] the roster survives a handler remount within the same page session", () => {
const stop1 = startLotusDecorations();
pushDecorations({
+4
View File
@@ -45,7 +45,11 @@ export function useLotusDecoration(userId: string): string | undefined {
// to the actual decoration CDN origin(s) — cinny's `DECORATION_CDN`
// (`avatarDecorations.ts`) — plus `blob:`, which EC itself may use for local
// previews. Exported so a future CDN move is a one-line edit here.
//
// matrix #22: decorations moved off Nextcloud to chat.lotusguild.org/decorations
// (first entry). drive.lotusguild.org stays until no host build points there.
export const ALLOWED_DECORATION_ORIGINS: readonly string[] = [
"https://chat.lotusguild.org",
"https://drive.lotusguild.org",
];
+12 -1
View File
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { describe, expect, it } from "vitest";
import { MicLevelQuantizer } from "./lotusMicLevel";
import { MicLevelQuantizer, sameRooms } from "./lotusMicLevel";
describe("MicLevelQuantizer", () => {
it("maps RMS to 0–3 bars", () => {
@@ -35,3 +35,14 @@ describe("MicLevelQuantizer", () => {
expect(q.push(0)).toBe(0);
});
});
describe("sameRooms (EC #45)", () => {
it("compares rooms by identity and order", () => {
const a = {};
const b = {};
expect(sameRooms([a, b], [a, b])).toBe(true);
expect(sameRooms([a], [a, b])).toBe(false);
expect(sameRooms([a, b], [b, a])).toBe(false);
expect(sameRooms([{}], [{}])).toBe(false);
});
});
+11 -2
View File
@@ -46,6 +46,10 @@ const micPublication = (
: null;
};
/** Pure: the same LiveKit rooms, in the same order (by identity). */
export const sameRooms = <T>(a: readonly T[], b: readonly T[]): boolean =>
a.length === b.length && a.every((r, i) => r === b[i]);
const samplers = new WeakMap<
CallViewModel,
Observable<LocalMicSample | null>
@@ -62,10 +66,15 @@ export function observeLocalMicSample$(
const cached = samplers.get(vm);
if (cached) return cached;
const sampler$ = vm.allConnections$.pipe(
map((data) => data.getConnections().map((c) => c.livekitRoom)),
// [EC #45] allConnections$ re-emits far more often than the set of
// LiveKit rooms changes; restarting the tap each time closed the
// AudioContext ~1×/s and emitted null, which reset the muted-speech
// gate so the "You're muted" nudge could never fire.
distinctUntilChanged(sameRooms),
switchMap(
(data) =>
(rooms) =>
new Observable<LocalMicSample | null>((subscriber) => {
const rooms = data.getConnections().map((c) => c.livekitRoom);
let ctx: AudioContext | null = null;
let clone: MediaStreamTrack | null = null;
let timer: ReturnType<typeof setInterval> | undefined;
+59
View File
@@ -0,0 +1,59 @@
/*
Copyright 2026 Lotus Guild
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { beforeEach, expect, test, vi } from "vitest";
import { LotusWidgetActions } from "./lotusActions";
import {
clearScreenshareAudio,
reportScreenshareAudio,
resetScreenshareAudioPresence,
} from "./lotusScreenshareAudio";
const send = vi.fn().mockResolvedValue({});
vi.mock("../widget", () => ({
widget: {
api: { transport: { send: (...a: unknown[]): unknown => send(...a) } },
},
}));
const sent = (): unknown[] =>
send.mock.calls
.filter(([action]) => action === LotusWidgetActions.ScreenshareAudioState)
.map(([, data]) => data);
beforeEach(() => {
resetScreenshareAudioPresence();
send.mockClear();
});
test("[cinny #277] reports presence only when the combined answer changes", () => {
reportScreenshareAudio("sfu-a", false);
reportScreenshareAudio("sfu-a", false);
reportScreenshareAudio("sfu-a", true);
reportScreenshareAudio("sfu-a", true);
reportScreenshareAudio("sfu-a", false);
expect(sent()).toEqual([
{ present: false },
{ present: true },
{ present: false },
]);
});
test("[cinny #277] any room with screenshare audio counts as present", () => {
reportScreenshareAudio("sfu-a", true);
reportScreenshareAudio("sfu-b", false);
expect(sent()).toEqual([{ present: true }]);
// A share is still playing in sfu-b after sfu-a's stops.
reportScreenshareAudio("sfu-b", true);
reportScreenshareAudio("sfu-a", false);
expect(sent()).toEqual([{ present: true }]);
// The renderer for sfu-b unmounts (call left): nothing is playing.
clearScreenshareAudio("sfu-b");
expect(sent()).toEqual([{ present: true }, { present: false }]);
});
+39
View File
@@ -8,6 +8,8 @@ Please see LICENSE in the repository root for full details.
import { BehaviorSubject } from "rxjs";
import { globalScope } from "../state/ObservableScope";
import { LotusWidgetActions } from "./lotusActions";
import { lotusSendToHost } from "./lotusWidget";
/**
* Whether the host has muted remote screenshare AUDIO (`io.lotus.set_deafen`
@@ -25,3 +27,40 @@ export const setScreenshareAudioMuted$ = new BehaviorSubject(false);
export const muteScreenshareAudio$ = globalScope.behavior(
setScreenshareAudioMuted$,
);
/**
* [cinny #277] Whether any remote participant's screenshare AUDIO is being
* received, so the host shows its "Mute Screenshare Audio" control only when
* there is something to mute. Each `LivekitRoomAudioRenderer` (one per SFU)
* reports for its own room; the host gets `io.lotus.screenshare_audio_state
* { present }` whenever the combined answer changes.
*/
const presentByRoom = new Map<string, boolean>();
let lastSent: boolean | undefined;
function sendIfChanged(): void {
const present = [...presentByRoom.values()].some(Boolean);
if (present === lastSent) return;
lastSent = present;
lotusSendToHost(LotusWidgetActions.ScreenshareAudioState, { present });
}
export function reportScreenshareAudio(
roomKey: string,
present: boolean,
): void {
presentByRoom.set(roomKey, present);
sendIfChanged();
}
/** The renderer for `roomKey` went away (call left, SFU switched). */
export function clearScreenshareAudio(roomKey: string): void {
presentByRoom.delete(roomKey);
sendIfChanged();
}
/** Test-only: forget all reports. */
export function resetScreenshareAudioPresence(): void {
presentByRoom.clear();
lastSent = undefined;
}
+26
View File
@@ -13,6 +13,7 @@ import {
type ReactNode,
type SVGAttributes,
useCallback,
useEffect,
} from "react";
import { Trans, useTranslation } from "react-i18next";
import {
@@ -24,6 +25,7 @@ import {
import { Button } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/lib/logger";
import { MatrixError } from "matrix-js-sdk";
import { type IWidgetApiRequest } from "matrix-widget-api";
import {
ConnectionLostError,
@@ -38,6 +40,7 @@ import { FullScreenView } from "../FullScreenView.tsx";
import { ErrorView } from "../ErrorView.tsx";
import { type WidgetHelpers } from "../widget.ts";
import styles from "../ErrorView.module.css";
import { LotusWidgetActions, lotusSendToHost } from "../lotus/lotusWidget";
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
@@ -74,6 +77,29 @@ const ErrorPage: FC<ErrorPageProps> = ({
icon = ErrorSolidIcon;
}
// [cinny #318] Tell the host the call dropped (it kept showing the call as
// joined), and let its call bar press Reconnect for us.
const recoverable =
error instanceof ConnectionLostError ||
error instanceof LivekitConnectionError ||
error instanceof PeerConnectionTimeoutError;
useEffect(() => {
lotusSendToHost(LotusWidgetActions.CallError, {
code: error.code,
recoverable,
});
const w = widget;
const onReconnect = (ev: CustomEvent<IWidgetApiRequest>): void => {
w?.api.transport.reply(ev.detail, {});
if (recoverable) void recoveryActionHandler("reconnect");
};
w?.lazyActions?.on(LotusWidgetActions.Reconnect, onReconnect);
return (): void => {
w?.lazyActions?.off(LotusWidgetActions.Reconnect, onReconnect);
lotusSendToHost(LotusWidgetActions.CallError, { code: null });
};
}, [error.code, recoverable, recoveryActionHandler, widget]);
const actions: { label: string; onClick: () => void }[] = [];
if (error instanceof ConnectionLostError) {
actions.push({
+2 -2
View File
@@ -33,7 +33,7 @@ import { startLotusCallState } from "../lotus/lotusCallState";
import { startLotusFocus } from "../lotus/lotusFocus";
import { startLotusControls } from "../lotus/lotusControls";
import { startLotusHotkeys } from "../lotus/lotusHotkeys";
import { LotusFrameScreenshare } from "../lotus/LotusFrameScreenshare";
import { LotusScreensharePrompt } from "../lotus/LotusScreensharePrompt";
import { startLotusMicLevel } from "../lotus/lotusMicLevel";
import { startLotusAudioInject } from "../lotus/lotusAudioInject";
import { startLotusQuality } from "../lotus/lotusQuality";
@@ -693,7 +693,7 @@ export const InCallView: FC<InCallViewProps> = ({
{earpieceOverlay}
<ReactionsOverlay vm={vm} />
{footer}
<LotusFrameScreenshare vm={vm} />
<LotusScreensharePrompt vm={vm} />
{showModals && (
<>
<RageshakeRequestModal {...rageshakeRequestModalProps} />
@@ -868,16 +868,19 @@ export function enterRTCSession(
};
}
// Calculates `maximumNetworkErrorRetryCount`. The connection is failed if EITHER:
// - The /sync loop is unresponsive for > `gracePeriod` ms, or
// - A delayed leave event is emitted (after `leaveDelay` ms period).
// Note: Use leaveDelay >> gracePeriod for delegated leave events.
// Calculates `maximumNetworkErrorRetryCount`: enough retries to outlast the
// server's delayed-leave window (see [lotus #46] below).
const gracePeriod = Config.get().sync_disconnect_grace_period_ms;
const leaveDelay = matrixRtcSessionConfig?.delayed_leave_event_delay_ms;
const retryInterval = matrixRtcSessionConfig?.network_error_retry_ms;
// Math.min is used to account for the respective worst case: /sync not available or leave event emitted.
const maxWaitTime = Math.min(gracePeriod, leaveDelay);
// [lotus #46] Keep retrying for as long as the homeserver itself would keep
// our membership: until its delayed leave fires (`leaveDelay`). Capping at
// the 10 s sync grace period made a ~20–35 s homeserver stall end everyone's
// call ("Connection lost") even with a 60 s server-side leave delay. The
// embedded call has no /sync loop of its own, so the grace period doesn't
// describe anything it can observe.
const maxWaitTime = Math.max(gracePeriod, leaveDelay);
const maximumNetworkErrorRetryCount =
Math.ceil(maxWaitTime / retryInterval) + 1;