Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb675fa50e | ||
|
|
9e4ddb3eb6 | ||
|
|
9d57d104b4 | ||
|
|
a93bd9d7d8 | ||
|
|
43a5e93c37 | ||
|
|
0f93368a7e | ||
|
|
dfb72bd9a5 | ||
|
|
e0eecc5271 | ||
|
|
c4f6193888 | ||
|
|
7855a0c22f | ||
|
|
aeb72c22b9 | ||
|
|
6fa34911ef |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lotusguild/element-call-embedded",
|
||||
"version": "0.25.0-lotus.16",
|
||||
"version": "0.25.0-lotus.21",
|
||||
"files": [
|
||||
"README.md",
|
||||
"LICENSE-AGPL-3.0",
|
||||
|
||||
+14
-1
@@ -90,16 +90,29 @@ body.lotus-transparent[data-background="gradient"]::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* [cinny #43] lotusHostControls=1: the host renders its own call bar, so EC's
|
||||
footer is hidden (kept in the DOM but out of the layout flow). */
|
||||
body.lotus-host-controls [data-testid="footer-container"] {
|
||||
position: absolute !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
/* [lotus] Native Lotus/TDS theme, applied when lotusTheme=1, instead of the
|
||||
host injecting CSS into the iframe after load. Overrides Compound design tokens
|
||||
with Lotus values at the source so theming is complete and flash-free. Extend
|
||||
this block with the full Lotus token map from the design system; the canvas
|
||||
override below is a safe starting point that matches the Lotus dark surface. */
|
||||
body.lotus-theme {
|
||||
--cpd-color-bg-canvas-default: #0c0d10;
|
||||
--video-tile-background: var(--cpd-color-bg-subtle-secondary);
|
||||
}
|
||||
|
||||
/* Dark themes only: in the light theme this near-black canvas sat behind
|
||||
light-theme (dark) text, e.g. the tile name tags were unreadable. */
|
||||
body.lotus-theme.cpd-theme-dark,
|
||||
body.lotus-theme.cpd-theme-dark-hc {
|
||||
--cpd-color-bg-canvas-default: #0c0d10;
|
||||
}
|
||||
|
||||
/* [lotus #21] Subtle contrast guard for elements that sit directly on the
|
||||
transparent canvas (no opaque tile background behind them) when
|
||||
lotusTransparent is set: the host's real wallpaper is unknown to us, so a
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -30,6 +30,10 @@ describe("LotusWidgetActions", () => {
|
||||
LotusWidgetActions.SetLayout,
|
||||
LotusWidgetActions.OpenSettings,
|
||||
LotusWidgetActions.ToggleReactions,
|
||||
LotusWidgetActions.SetScreenshare,
|
||||
LotusWidgetActions.SetHotkeys,
|
||||
LotusWidgetActions.SetFrameScreenshare,
|
||||
LotusWidgetActions.PromptScreenshare,
|
||||
];
|
||||
|
||||
expect(new Set(LOTUS_TO_WIDGET_ACTIONS)).toEqual(new Set(expectedToWidget));
|
||||
@@ -51,5 +55,6 @@ describe("LotusWidgetActions", () => {
|
||||
LotusWidgetActions.ControlsState,
|
||||
);
|
||||
expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain(LotusWidgetActions.MicLevel);
|
||||
expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain(LotusWidgetActions.Hotkey);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,7 +75,38 @@ export enum LotusWidgetActions {
|
||||
/** toWidget: toggle the reactions / raise-hand menu (cinny #43). */
|
||||
ToggleReactions = "io.lotus.toggle_reactions",
|
||||
/**
|
||||
* fromWidget: `{ screensharing: boolean, layout: "grid" | "spotlight" | null }`
|
||||
* toWidget: 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.
|
||||
*/
|
||||
SetScreenshare = "io.lotus.set_screenshare",
|
||||
/**
|
||||
* toWidget: `{ codes: string[] }` — KeyboardEvent.code values (PTT, deafen)
|
||||
* to report while focus is inside this frame (cinny #43). `[]` stops.
|
||||
*/
|
||||
SetHotkeys = "io.lotus.set_hotkeys",
|
||||
/**
|
||||
* toWidget: `{ 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.
|
||||
*/
|
||||
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).
|
||||
*/
|
||||
Hotkey = "io.lotus.hotkey",
|
||||
/**
|
||||
* fromWidget: `{ 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.
|
||||
*/
|
||||
@@ -98,4 +129,8 @@ export const LOTUS_TO_WIDGET_ACTIONS: LotusWidgetActions[] = [
|
||||
LotusWidgetActions.SetLayout,
|
||||
LotusWidgetActions.OpenSettings,
|
||||
LotusWidgetActions.ToggleReactions,
|
||||
LotusWidgetActions.SetScreenshare,
|
||||
LotusWidgetActions.SetHotkeys,
|
||||
LotusWidgetActions.SetFrameScreenshare,
|
||||
LotusWidgetActions.PromptScreenshare,
|
||||
];
|
||||
|
||||
@@ -10,7 +10,11 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { startLotusAudioInject } from "./lotusAudioInject";
|
||||
import {
|
||||
MAX_INJECT_BYTES,
|
||||
parseInjectSource,
|
||||
startLotusAudioInject,
|
||||
} from "./lotusAudioInject";
|
||||
import { LotusWidgetActions } from "./lotusActions";
|
||||
|
||||
const lazyActions = new EventEmitter();
|
||||
@@ -223,3 +227,34 @@ test("#14: the shared context stays open while another instance is still active"
|
||||
stopB();
|
||||
expect(ctx.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// [cinny #43] Cross-origin hosts send the clip bytes instead of a blob: URL.
|
||||
test("parseInjectSource prefers the clip bytes over the url", () => {
|
||||
const audio = new ArrayBuffer(16);
|
||||
expect(parseInjectSource({ audio, url: "https://x.example/a.ogg" })).toBe(
|
||||
audio,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseInjectSource falls back to a safe url", () => {
|
||||
expect(parseInjectSource({ url: "https://x.example/a.ogg" })).toBe(
|
||||
"https://x.example/a.ogg",
|
||||
);
|
||||
expect(
|
||||
parseInjectSource({
|
||||
audio: new ArrayBuffer(0),
|
||||
url: "https://x.example/a.ogg",
|
||||
}),
|
||||
).toBe("https://x.example/a.ogg");
|
||||
expect(parseInjectSource({ url: "javascript" + ":alert(1)" })).toBeNull();
|
||||
expect(parseInjectSource({})).toBeNull();
|
||||
expect(parseInjectSource(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
test("parseInjectSource rejects oversized or non-buffer audio", () => {
|
||||
expect(
|
||||
parseInjectSource({ audio: new ArrayBuffer(MAX_INJECT_BYTES + 1) }),
|
||||
).toBeNull();
|
||||
expect(parseInjectSource({ audio: "not bytes" })).toBeNull();
|
||||
expect(parseInjectSource({ audio: new Uint8Array(4) })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -52,8 +52,10 @@ function acquireSharedAudio(): {
|
||||
* that was impossible against the prebuilt EC bundle (LiveKit's
|
||||
* LocalParticipant lived in EC's module scope).
|
||||
*
|
||||
* Action data: `{ url: string, volume?: number }`. `url` must be an https/blob
|
||||
* URL (the host resolves mxc → media URL).
|
||||
* Action data: `{ url?: string, audio?: ArrayBuffer, volume?: number }`.
|
||||
* `audio` is the clip's bytes (cinny #43: a host `blob:` URL can't be fetched
|
||||
* once EC is served from its own origin, so the host sends the bytes and they
|
||||
* win); otherwise `url` must be an https/blob URL (older hosts).
|
||||
*
|
||||
* No effect unless the host sends the action. Returns a teardown function that
|
||||
* also aborts any clip still playing.
|
||||
@@ -88,12 +90,12 @@ export function startLotusAudioInject(vm: CallViewModel): () => void {
|
||||
return;
|
||||
}
|
||||
const data = ev.detail.data as
|
||||
| { url?: unknown; volume?: unknown }
|
||||
| { url?: unknown; audio?: unknown; volume?: unknown }
|
||||
| undefined;
|
||||
const url = typeof data?.url === "string" ? safeMediaUrl(data.url) : null;
|
||||
if (!url) {
|
||||
const clip = parseInjectSource(data);
|
||||
if (!clip) {
|
||||
w.api.transport.reply(ev.detail, {});
|
||||
logger.warn("[lotus] inject_audio: missing/invalid url");
|
||||
logger.warn("[lotus] inject_audio: missing/invalid url or audio");
|
||||
return;
|
||||
}
|
||||
const volume =
|
||||
@@ -115,7 +117,7 @@ export function startLotusAudioInject(vm: CallViewModel): () => void {
|
||||
}
|
||||
|
||||
w.api.transport.reply(ev.detail, {});
|
||||
void playInjectedClip(url, volume, rooms, activeClips).catch((e) =>
|
||||
void playInjectedClip(clip, volume, rooms, activeClips).catch((e) =>
|
||||
logger.warn("[lotus] inject_audio failed", e),
|
||||
);
|
||||
};
|
||||
@@ -144,6 +146,27 @@ export function startLotusAudioInject(vm: CallViewModel): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
/** Largest clip accepted as bytes (the host's soundboard clips are small). */
|
||||
export const MAX_INJECT_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The clip to play: the bytes the host sent (`audio`), or else a fetchable
|
||||
* `url`. Exported for tests.
|
||||
*/
|
||||
export function parseInjectSource(
|
||||
data: { url?: unknown; audio?: unknown } | undefined,
|
||||
): string | ArrayBuffer | null {
|
||||
const audio = data?.audio;
|
||||
if (
|
||||
audio instanceof ArrayBuffer &&
|
||||
audio.byteLength > 0 &&
|
||||
audio.byteLength <= MAX_INJECT_BYTES
|
||||
) {
|
||||
return audio;
|
||||
}
|
||||
return typeof data?.url === "string" ? safeMediaUrl(data.url) : null;
|
||||
}
|
||||
|
||||
/** Only allow fetchable media URLs; never same-origin credentialed GETs etc. */
|
||||
function safeMediaUrl(raw: string): string | null {
|
||||
try {
|
||||
@@ -155,7 +178,7 @@ function safeMediaUrl(raw: string): string | null {
|
||||
}
|
||||
|
||||
async function playInjectedClip(
|
||||
url: string,
|
||||
clip: string | ArrayBuffer,
|
||||
volume: number,
|
||||
rooms: LivekitRoom[],
|
||||
activeClips: Set<() => void>,
|
||||
@@ -188,25 +211,30 @@ async function playInjectedClip(
|
||||
};
|
||||
activeClips.add(placeholder);
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
credentials: "omit",
|
||||
mode: "cors",
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (e) {
|
||||
// Superseded by a newer clip mid-fetch — expected, not a failure.
|
||||
let arrayBuffer: ArrayBuffer;
|
||||
if (typeof clip !== "string") {
|
||||
arrayBuffer = clip;
|
||||
} else {
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(clip, {
|
||||
credentials: "omit",
|
||||
mode: "cors",
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (e) {
|
||||
// Superseded by a newer clip mid-fetch — expected, not a failure.
|
||||
if (aborted) return;
|
||||
throw e;
|
||||
}
|
||||
if (aborted) return;
|
||||
if (!resp.ok) {
|
||||
activeClips.delete(placeholder);
|
||||
throw new Error(`fetch ${clip} -> ${resp.status}`);
|
||||
}
|
||||
arrayBuffer = await resp.arrayBuffer();
|
||||
if (aborted) return;
|
||||
throw e;
|
||||
}
|
||||
if (aborted) return;
|
||||
if (!resp.ok) {
|
||||
activeClips.delete(placeholder);
|
||||
throw new Error(`fetch ${url} -> ${resp.status}`);
|
||||
}
|
||||
const arrayBuffer = await resp.arrayBuffer();
|
||||
if (aborted) return;
|
||||
|
||||
// [lotus] Reuse the shared module-level context/destination (#14) rather
|
||||
// than `new AudioContext()` per clip — see the declaration above.
|
||||
|
||||
@@ -7,7 +7,11 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseLayoutPayload, parseSettingsPayload } from "./lotusControls";
|
||||
import {
|
||||
parseLayoutPayload,
|
||||
parseSettingsPayload,
|
||||
shouldToggleScreenshare,
|
||||
} from "./lotusControls";
|
||||
|
||||
describe("parseLayoutPayload", () => {
|
||||
it("accepts grid and spotlight", () => {
|
||||
@@ -33,3 +37,17 @@ describe("parseSettingsPayload", () => {
|
||||
expect(parseSettingsPayload({ open: "yes" }, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldToggleScreenshare", () => {
|
||||
it("toggles only when an explicit on differs from the current state", () => {
|
||||
expect(shouldToggleScreenshare({ on: true }, false)).toBe(true);
|
||||
expect(shouldToggleScreenshare({ on: true }, true)).toBe(false);
|
||||
expect(shouldToggleScreenshare({ on: false }, true)).toBe(true);
|
||||
expect(shouldToggleScreenshare({ on: false }, false)).toBe(false);
|
||||
});
|
||||
it("toggles when on is missing or not a boolean", () => {
|
||||
expect(shouldToggleScreenshare({}, false)).toBe(true);
|
||||
expect(shouldToggleScreenshare({ on: "yes" }, true)).toBe(true);
|
||||
expect(shouldToggleScreenshare(null, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+142
-7
@@ -6,13 +6,13 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type IWidgetApiRequest } from "matrix-widget-api";
|
||||
import { combineLatest, of, type Subscription } from "rxjs";
|
||||
import { BehaviorSubject, combineLatest, of, type Subscription } from "rxjs";
|
||||
import { distinctUntilChanged, map, switchMap } from "rxjs/operators";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { type LayoutMode } from "../state/LayoutSwitchViewModel";
|
||||
import { widget } from "../widget";
|
||||
import { LotusWidgetActions, lotusSendToHost } from "./lotusWidget";
|
||||
import { LotusWidgetActions, lotusFlag, lotusSendToHost } from "./lotusWidget";
|
||||
|
||||
/** Window event the reactions button listens for (see ReactionToggleButton). */
|
||||
export const LOTUS_TOGGLE_REACTIONS_EVENT = "lotus:toggle-reactions";
|
||||
@@ -21,6 +21,73 @@ export interface LotusControlsState {
|
||||
screensharing: boolean;
|
||||
/** Null while EC offers no layout switch (e.g. PiP or a 1:1 layout). */
|
||||
layout: LayoutMode | null;
|
||||
/** This fork handles `io.lotus.set_screenshare` (always true). */
|
||||
screenshareAction: true;
|
||||
/** This fork handles `io.lotus.set_hotkeys` (always true). */
|
||||
hotkeys: true;
|
||||
/**
|
||||
* 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: 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.
|
||||
* 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
|
||||
* call policy forbids screensharing, as it does its own call-bar button.
|
||||
*/
|
||||
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,
|
||||
): boolean | undefined {
|
||||
if (typeof data !== "object" || data === null) return undefined;
|
||||
const { visible } = data as { visible?: unknown };
|
||||
return typeof visible === "boolean" ? visible : undefined;
|
||||
}
|
||||
|
||||
/** `{ layout }` payload → a layout mode, or undefined if invalid. Exported for tests. */
|
||||
@@ -39,13 +106,28 @@ export function parseSettingsPayload(data: unknown, current: boolean): boolean {
|
||||
return !current;
|
||||
}
|
||||
|
||||
/**
|
||||
* `{ on? }` payload + current sharing state → whether to call the toggle.
|
||||
* Exported for tests.
|
||||
*/
|
||||
export function shouldToggleScreenshare(
|
||||
data: unknown,
|
||||
sharing: boolean,
|
||||
): boolean {
|
||||
if (typeof data === "object" && data !== null && "on" in data) {
|
||||
const { on } = data as { on?: unknown };
|
||||
if (typeof on === "boolean") return on !== sharing;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* [cinny #43] Widget-API replacements for the host's DOM access to EC's
|
||||
* controls: layout switch, settings modal and reactions menu, plus a
|
||||
* `controls_state` report (screensharing + layout) so the host no longer reads
|
||||
* EC's DOM for them. Screensharing itself stays host-DOM driven for now:
|
||||
* `getDisplayMedia` needs the user's click to reach this frame (Capability
|
||||
* Delegation), which a plain widget message doesn't carry.
|
||||
* EC's DOM for them. Screenshare start/stop too: `getDisplayMedia` needs the
|
||||
* user's click in this frame, so the host sends `set_screenshare` with
|
||||
* Capability Delegation (Chromium). Elsewhere it keeps clicking EC's button.
|
||||
*
|
||||
* No effect unless the host sends the actions; registering is safe whenever
|
||||
* we're a widget. Returns a teardown function.
|
||||
@@ -69,26 +151,69 @@ export function startLotusControls(vm: CallViewModel): () => void {
|
||||
w.api.transport.reply(ev.detail, {});
|
||||
window.dispatchEvent(new Event(LOTUS_TOGGLE_REACTIONS_EVENT));
|
||||
};
|
||||
const onSetScreenshare = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
w.api.transport.reply(ev.detail, {});
|
||||
// Call synchronously: the delegated activation is only good for a few
|
||||
// seconds after the message arrived.
|
||||
if (shouldToggleScreenshare(ev.detail.data, vm.sharingScreen$.value)) {
|
||||
vm.toggleScreenSharing?.();
|
||||
}
|
||||
};
|
||||
|
||||
w.lazyActions.on(LotusWidgetActions.SetLayout, onSetLayout);
|
||||
w.lazyActions.on(LotusWidgetActions.OpenSettings, onOpenSettings);
|
||||
w.lazyActions.on(LotusWidgetActions.ToggleReactions, onToggleReactions);
|
||||
const onSetFrameScreenshare = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
w.api.transport.reply(ev.detail, {});
|
||||
const visible = parseFrameScreensharePayload(ev.detail.data);
|
||||
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: 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) => {
|
||||
@@ -100,5 +225,15 @@ export function startLotusControls(vm: CallViewModel): () => void {
|
||||
w.lazyActions.off(LotusWidgetActions.SetLayout, onSetLayout);
|
||||
w.lazyActions.off(LotusWidgetActions.OpenSettings, onOpenSettings);
|
||||
w.lazyActions.off(LotusWidgetActions.ToggleReactions, onToggleReactions);
|
||||
w.lazyActions.off(LotusWidgetActions.SetScreenshare, onSetScreenshare);
|
||||
w.lazyActions.off(
|
||||
LotusWidgetActions.SetFrameScreenshare,
|
||||
onSetFrameScreenshare,
|
||||
);
|
||||
w.lazyActions.off(
|
||||
LotusWidgetActions.PromptScreenshare,
|
||||
onPromptScreenshare,
|
||||
);
|
||||
lotusScreensharePrompt$.next(false);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
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 } from "vitest";
|
||||
|
||||
import {
|
||||
isEditableTarget,
|
||||
isInteractiveTarget,
|
||||
parseHotkeyCodes,
|
||||
} from "./lotusHotkeys";
|
||||
|
||||
describe("parseHotkeyCodes", () => {
|
||||
it("keeps KeyboardEvent.code-like strings", () => {
|
||||
expect(parseHotkeyCodes({ codes: ["Space", "KeyM", "F13"] })).toEqual([
|
||||
"Space",
|
||||
"KeyM",
|
||||
"F13",
|
||||
]);
|
||||
});
|
||||
it("drops junk and caps the list", () => {
|
||||
expect(parseHotkeyCodes({ codes: ["Space", 3, "", "a b", "<x>"] })).toEqual(
|
||||
["Space"],
|
||||
);
|
||||
expect(parseHotkeyCodes({ codes: Array(20).fill("KeyA") })).toHaveLength(8);
|
||||
expect(parseHotkeyCodes({})).toEqual([]);
|
||||
expect(parseHotkeyCodes(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("target checks", () => {
|
||||
it("treats inputs and contenteditable as editable", () => {
|
||||
document.body.innerHTML =
|
||||
'<input id="i"><div contenteditable="true"><span id="s">x</span></div><div id="d"></div>';
|
||||
expect(isEditableTarget(document.getElementById("i"))).toBe(true);
|
||||
expect(isEditableTarget(document.getElementById("s"))).toBe(true);
|
||||
expect(isEditableTarget(document.getElementById("d"))).toBe(false);
|
||||
expect(isEditableTarget(null)).toBe(false);
|
||||
});
|
||||
it("treats buttons, links and button roles as interactive", () => {
|
||||
document.body.innerHTML =
|
||||
'<button id="b">b</button><div role="menuitem"><span id="m">m</span></div><p id="p"></p>';
|
||||
expect(isInteractiveTarget(document.getElementById("b"))).toBe(true);
|
||||
expect(isInteractiveTarget(document.getElementById("m"))).toBe(true);
|
||||
expect(isInteractiveTarget(document.getElementById("p"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
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 IWidgetApiRequest } from "matrix-widget-api";
|
||||
|
||||
import { widget } from "../widget";
|
||||
import { LotusWidgetActions, lotusSendToHost } from "./lotusWidget";
|
||||
|
||||
/** fromWidget `io.lotus.hotkey` payload. */
|
||||
export type LotusHotkeyReport =
|
||||
| {
|
||||
type: "keydown" | "keyup";
|
||||
code: string;
|
||||
repeat: boolean;
|
||||
ctrlKey: boolean;
|
||||
altKey: boolean;
|
||||
metaKey: boolean;
|
||||
shiftKey: boolean;
|
||||
/** The key went to a text field (typing, not a hotkey). */
|
||||
editable: boolean;
|
||||
/** The key went to a button/link/etc. (Space activates it). */
|
||||
interactive: boolean;
|
||||
}
|
||||
/** This window gained or lost focus: the host releases a held PTT. */
|
||||
| { type: "focus" };
|
||||
|
||||
const MAX_CODES = 8;
|
||||
|
||||
// Codes the host currently owns. Module-level so EC's own shortcuts
|
||||
// (useCallViewKeyboardShortcuts) can stand down for them.
|
||||
let hostCodes = new Set<string>();
|
||||
|
||||
/**
|
||||
* Whether `code` is one of the host's call hotkeys (its PTT or deafen key).
|
||||
* EC's built-in shortcuts ignore such keys: with the defaults, M would
|
||||
* otherwise both toggle EC's mic and the host's deafen, and Space would open
|
||||
* EC's own push-to-talk alongside the host's.
|
||||
*/
|
||||
export function isLotusHostHotkey(code: string): boolean {
|
||||
return hostCodes.has(code);
|
||||
}
|
||||
|
||||
/** `{ codes }` payload → the KeyboardEvent.code values to watch. Exported for tests. */
|
||||
export function parseHotkeyCodes(data: unknown): string[] {
|
||||
if (typeof data !== "object" || data === null) return [];
|
||||
const { codes } = data as { codes?: unknown };
|
||||
if (!Array.isArray(codes)) return [];
|
||||
return codes
|
||||
.filter(
|
||||
(c): c is string =>
|
||||
typeof c === "string" && /^[A-Za-z0-9]{1,32}$/.test(c),
|
||||
)
|
||||
.slice(0, MAX_CODES);
|
||||
}
|
||||
|
||||
/** Same rules as the host's useCallHotkeys. Exported for tests. */
|
||||
export function isEditableTarget(el: Element | null): boolean {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const tag = el.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
||||
let node: HTMLElement | null = el;
|
||||
while (node && node !== el.ownerDocument.body) {
|
||||
const ce = node.getAttribute("contenteditable");
|
||||
if (ce === "" || ce === "true" || ce === "plaintext-only") return true;
|
||||
if (ce === "false") return false;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Exported for tests. */
|
||||
export function isInteractiveTarget(el: Element | null): boolean {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const tag = el.tagName;
|
||||
if (tag === "BUTTON" || tag === "A" || tag === "SELECT") return true;
|
||||
let node: HTMLElement | null = el;
|
||||
while (node && node !== el.ownerDocument.body) {
|
||||
const role = node.getAttribute("role");
|
||||
if (
|
||||
role === "button" ||
|
||||
role === "link" ||
|
||||
role === "menuitem" ||
|
||||
role === "tab"
|
||||
)
|
||||
return true;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* [cinny #43] Call hotkeys (push-to-talk, deafen) while focus is inside this
|
||||
* frame. The host used to add key listeners to this window directly, which
|
||||
* needs same-origin access. Now it sends `io.lotus.set_hotkeys { codes }` and
|
||||
* we report those keys back as `io.lotus.hotkey`. The default action is
|
||||
* cancelled here, synchronously (it can't be across frames), with the host's
|
||||
* rules: never while typing in a field, and not on a focused button/link so
|
||||
* keyboard users can still activate it. Returns a teardown function.
|
||||
*/
|
||||
export function startLotusHotkeys(): () => void {
|
||||
const w = widget;
|
||||
if (!w) return (): void => undefined;
|
||||
hostCodes = new Set<string>();
|
||||
|
||||
const onSetHotkeys = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
w.api.transport.reply(ev.detail, {});
|
||||
hostCodes = new Set(parseHotkeyCodes(ev.detail.data));
|
||||
};
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (!hostCodes.has(e.code)) return;
|
||||
const target = e.target instanceof Element ? e.target : null;
|
||||
const editable = isEditableTarget(target);
|
||||
const interactive = isInteractiveTarget(target);
|
||||
if (
|
||||
e.type === "keydown" &&
|
||||
!editable &&
|
||||
!interactive &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
const report: LotusHotkeyReport = {
|
||||
type: e.type === "keyup" ? "keyup" : "keydown",
|
||||
code: e.code,
|
||||
repeat: e.repeat,
|
||||
ctrlKey: e.ctrlKey,
|
||||
altKey: e.altKey,
|
||||
metaKey: e.metaKey,
|
||||
shiftKey: e.shiftKey,
|
||||
editable,
|
||||
interactive,
|
||||
};
|
||||
lotusSendToHost(LotusWidgetActions.Hotkey, report);
|
||||
};
|
||||
const onFocusChange = (): void => {
|
||||
if (hostCodes.size > 0)
|
||||
lotusSendToHost(LotusWidgetActions.Hotkey, { type: "focus" });
|
||||
};
|
||||
|
||||
w.lazyActions.on(LotusWidgetActions.SetHotkeys, onSetHotkeys);
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
window.addEventListener("keyup", onKey, true);
|
||||
window.addEventListener("blur", onFocusChange);
|
||||
window.addEventListener("focus", onFocusChange);
|
||||
return (): void => {
|
||||
w.lazyActions.off(LotusWidgetActions.SetHotkeys, onSetHotkeys);
|
||||
window.removeEventListener("keydown", onKey, true);
|
||||
window.removeEventListener("keyup", onKey, true);
|
||||
window.removeEventListener("blur", onFocusChange);
|
||||
window.removeEventListener("focus", onFocusChange);
|
||||
hostCodes = new Set<string>();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
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 } from "vitest";
|
||||
|
||||
import { isFromHost, restrictToHost } from "./lotusWidgetOrigin";
|
||||
|
||||
const parent = {} as Window;
|
||||
const other = {} as Window;
|
||||
const HOST = "https://chat.example.org";
|
||||
|
||||
describe("isFromHost", () => {
|
||||
it("accepts only the parent window at the host origin", () => {
|
||||
expect(isFromHost({ source: parent, origin: HOST }, parent, HOST)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isFromHost({ source: other, origin: HOST }, parent, HOST)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isFromHost(
|
||||
{ source: parent, origin: "https://evil.example" },
|
||||
parent,
|
||||
HOST,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isFromHost({ source: parent, origin: "null" }, parent, HOST)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restrictToHost", () => {
|
||||
it("replaces the listener and drops foreign messages", () => {
|
||||
const seen: unknown[] = [];
|
||||
const original = (ev: MessageEvent): void => {
|
||||
seen.push(ev.data);
|
||||
};
|
||||
const transport = { handleMessage: original };
|
||||
const listeners = new Set<EventListener>([original as EventListener]);
|
||||
const inbound = {
|
||||
addEventListener: (_t: string, l: EventListener): void => {
|
||||
listeners.add(l);
|
||||
},
|
||||
removeEventListener: (_t: string, l: EventListener): void => {
|
||||
listeners.delete(l);
|
||||
},
|
||||
} as unknown as Window;
|
||||
|
||||
restrictToHost(transport, HOST, parent, inbound);
|
||||
expect(listeners.has(original as EventListener)).toBe(false);
|
||||
expect(listeners.size).toBe(1);
|
||||
|
||||
const dispatch = (ev: Partial<MessageEvent>): void =>
|
||||
listeners.forEach((l) => l(ev as unknown as Event));
|
||||
dispatch({ source: other, origin: HOST, data: "spoof" });
|
||||
dispatch({ source: parent, origin: "https://evil.example", data: "bad" });
|
||||
dispatch({ source: parent, origin: HOST, data: "real" });
|
||||
expect(seen).toEqual(["real"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
type MessageLike = Pick<MessageEvent, "source" | "origin">;
|
||||
|
||||
/** True when a message came from our parent window, at the host's origin. */
|
||||
export function isFromHost(
|
||||
ev: MessageLike,
|
||||
parentWindow: Window,
|
||||
parentOrigin: string,
|
||||
): boolean {
|
||||
return ev.source === parentWindow && ev.origin === parentOrigin;
|
||||
}
|
||||
|
||||
interface InboundTransport {
|
||||
handleMessage: (ev: MessageEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* [cinny #43] Only handle widget messages sent by the host (our parent window)
|
||||
* from the origin it was loaded at (`parentUrl`).
|
||||
*
|
||||
* matrix-widget-api's `strictOriginCheck` compares `ev.origin` with this
|
||||
* frame's OWN origin, which only works while EC is served from the host's
|
||||
* origin; served from its own origin (e.g. call.chat.lotusguild.org) every
|
||||
* host message would be dropped. Turning the check off instead would let any
|
||||
* window post toWidget actions (including io.lotus.*). This compares against
|
||||
* the host's origin and also requires the sender to be our parent, so it is
|
||||
* correct same-origin and cross-origin alike.
|
||||
*
|
||||
* Swaps the transport's `message` listener; safe whether or not the transport
|
||||
* has started (`stop()` removes `handleMessage`, the guarded one after this).
|
||||
*/
|
||||
export function restrictToHost(
|
||||
transport: unknown,
|
||||
parentOrigin: string,
|
||||
parentWindow: Window = window.parent,
|
||||
inbound: Pick<Window, "addEventListener" | "removeEventListener"> = window,
|
||||
): void {
|
||||
const t = transport as InboundTransport;
|
||||
const original = t.handleMessage;
|
||||
const guarded = (ev: MessageEvent): void => {
|
||||
if (!isFromHost(ev, parentWindow, parentOrigin)) return;
|
||||
original(ev);
|
||||
};
|
||||
inbound.removeEventListener("message", original as EventListener);
|
||||
t.handleMessage = guarded;
|
||||
inbound.addEventListener("message", guarded as EventListener);
|
||||
}
|
||||
@@ -32,6 +32,8 @@ import { widget } from "../widget";
|
||||
import { startLotusCallState } from "../lotus/lotusCallState";
|
||||
import { startLotusFocus } from "../lotus/lotusFocus";
|
||||
import { startLotusControls } from "../lotus/lotusControls";
|
||||
import { startLotusHotkeys } from "../lotus/lotusHotkeys";
|
||||
import { LotusScreensharePrompt } from "../lotus/LotusScreensharePrompt";
|
||||
import { startLotusMicLevel } from "../lotus/lotusMicLevel";
|
||||
import { startLotusAudioInject } from "../lotus/lotusAudioInject";
|
||||
import { startLotusQuality } from "../lotus/lotusQuality";
|
||||
@@ -305,6 +307,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
// [cinny #43] layout / settings / reactions over the widget API, plus a
|
||||
// screensharing + layout report, replacing the host's DOM access.
|
||||
useEffect(() => startLotusControls(vm), [vm]);
|
||||
useEffect(() => startLotusHotkeys(), []);
|
||||
// [cinny #146] Local mic level for the host's mute-button meter.
|
||||
useEffect(() => startLotusMicLevel(vm), [vm]);
|
||||
// [lotus] Handle the host's io.lotus.inject_audio action to mix a soundboard
|
||||
@@ -690,6 +693,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
{earpieceOverlay}
|
||||
<ReactionsOverlay vm={vm} />
|
||||
{footer}
|
||||
<LotusScreensharePrompt vm={vm} />
|
||||
{showModals && (
|
||||
<>
|
||||
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ReactionSet,
|
||||
ReactionsRowSize,
|
||||
} from "./reactions";
|
||||
import { isLotusHostHotkey } from "./lotus/lotusHotkeys";
|
||||
|
||||
/**
|
||||
* Determines whether focus is in the same part of the tree as the given
|
||||
@@ -86,6 +87,8 @@ export function useCallViewKeyboardShortcuts(
|
||||
useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
logger.info("Keydown event", event);
|
||||
// [lotus] The Lotus host owns its PTT / deafen keys (cinny #43).
|
||||
if (isLotusHostHotkey(event.code)) return;
|
||||
if (!mayReceiveKeyEvents()) return;
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
|
||||
return;
|
||||
@@ -132,6 +135,7 @@ export function useCallViewKeyboardShortcuts(
|
||||
"keyup",
|
||||
useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (isLotusHostHotkey(event.code)) return;
|
||||
if (!mayReceiveKeyEvents() || !mayReceiveSpaceKeyEvents()) return;
|
||||
if (event.key === " ") {
|
||||
spacebarHeld.current = false;
|
||||
|
||||
+11
-1
@@ -76,7 +76,17 @@ export const useTheme = (): void => {
|
||||
"lotusTheme anyway since the two flags are only meaningful together.",
|
||||
);
|
||||
}
|
||||
if (lotusTheme || lotusTransparent)
|
||||
if (lotusTheme || lotusTransparent) {
|
||||
document.body.classList.add("lotus-theme");
|
||||
// [cinny #43] The frame's root colour scheme must match the host's, or
|
||||
// the browser paints an opaque backdrop behind the transparent frame.
|
||||
// (The host used to inject `:root { color-scheme }` for this.)
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}
|
||||
// [cinny #43] The host draws its own call bar: hide EC's footer. Replaces
|
||||
// the host's injected styles. Where the host can't start a share for us,
|
||||
// LotusFrameScreenshare shows EC's screenshare button on its own.
|
||||
if (lotusFlag("lotusHostControls"))
|
||||
document.body.classList.add("lotus-host-controls");
|
||||
}, [previousTheme, requestedTheme]);
|
||||
};
|
||||
|
||||
+6
-10
@@ -24,6 +24,7 @@ import { Config } from "./config/Config";
|
||||
import { seedSettingsFromConfig } from "./settings/settings";
|
||||
import { ElementCallReactionEventType } from "./reactions";
|
||||
import { LOTUS_TO_WIDGET_ACTIONS } from "./lotus/lotusActions";
|
||||
import { restrictToHost } from "./lotus/lotusWidgetOrigin";
|
||||
|
||||
// Subset of the actions in element-web
|
||||
export enum ElementWidgetActions {
|
||||
@@ -94,16 +95,11 @@ export const initializeWidget = (
|
||||
const parentOrigin = new URL(parentUrl).origin;
|
||||
logger.info("Widget API is available");
|
||||
const api = new WidgetApi(widgetId, parentOrigin);
|
||||
// [lotus] matrix-widget-api's PostmessageTransport defaults
|
||||
// strictOriginCheck to false, which would let any frame holding a
|
||||
// handle to our window post toWidget actions (including the
|
||||
// io.lotus.* actions below). The Lotus deployment serves EC
|
||||
// same-origin with the host (cinny loads /public/element-call/index.html),
|
||||
// so globalThis.origin === parentOrigin and this check passes safely.
|
||||
// A cross-origin deployment would need to compare ev.origin to
|
||||
// parentOrigin instead, since strictOriginCheck compares against
|
||||
// globalThis.origin.
|
||||
api.transport.strictOriginCheck = true;
|
||||
// [lotus] Only messages from the host (our parent, at parentUrl's
|
||||
// origin) reach the widget API. matrix-widget-api's own
|
||||
// strictOriginCheck compares against THIS frame's origin, so it only
|
||||
// worked while EC was served from the host's origin (cinny #43).
|
||||
restrictToHost(api.transport, parentOrigin);
|
||||
api.requestCapability(MatrixCapabilities.AlwaysOnScreen);
|
||||
api.requestCapability(MatrixCapabilities.MSC4039DownloadFile);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user