Compare commits

...
Author SHA1 Message Date
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 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
17 changed files with 478 additions and 151 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lotusguild/element-call-embedded",
"version": "0.25.0-lotus.20",
"version": "0.25.0-lotus.24",
"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 }
: {}),
};
};
@@ -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>
);
};
+1
View File
@@ -33,6 +33,7 @@ describe("LotusWidgetActions", () => {
LotusWidgetActions.SetScreenshare,
LotusWidgetActions.SetHotkeys,
LotusWidgetActions.SetFrameScreenshare,
LotusWidgetActions.PromptScreenshare,
];
expect(new Set(LOTUS_TO_WIDGET_ACTIONS)).toEqual(new Set(expectedToWidget));
+9 -1
View File
@@ -92,6 +92,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 +105,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.
*/
@@ -125,4 +132,5 @@ export const LOTUS_TO_WIDGET_ACTIONS: LotusWidgetActions[] = [
LotusWidgetActions.SetScreenshare,
LotusWidgetActions.SetHotkeys,
LotusWidgetActions.SetFrameScreenshare,
LotusWidgetActions.PromptScreenshare,
];
+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);
};
}
+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} />