Compare commits

..
Author SHA1 Message Date
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
Lotus CIandClaude Opus 5.5 a93bd9d7d8 feat(lotus): work when served from its own origin (cinny #43)
CI / Build embedded bundle (pull_request) Successful in 3m43s
CI / Publish to Gitea npm registry (pull_request) Skipped
Two things tied EC to the host's origin:

- Widget message check: matrix-widget-api's strictOriginCheck compares
  ev.origin with THIS frame's origin, so on call.chat.lotusguild.org every
  message from chat.lotusguild.org would be dropped and calls would not
  start. restrictToHost() instead requires ev.source === window.parent and
  ev.origin === parentUrl's origin. Same-origin deployments keep working
  (the host origin is our own origin there), and it is stricter than
  before: the sender must also be our parent window.
- Soundboard: the host's blob: clip URL is origin-bound. io.lotus.inject_audio
  now accepts the clip's bytes (`audio`, ArrayBuffer, <= 8 MiB) and prefers
  them over `url`; hosts that only send `url` are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 23:24:32 -04:00
Lotus CIandClaude Opus 5.5 43a5e93c37 chore(lotus): 0.25.0-lotus.20
CI / Build embedded bundle (push) Successful in 4m17s
CI / Publish to Gitea npm registry (push) Successful in 52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 21:48:22 -04:00
Lotus CIandClaude Opus 5.5 0f93368a7e feat(lotus): in-frame screenshare button where the host can't delegate (cinny #43)
Firefox, Safari and WebKitGTK have no Capability Delegation, so the host
can't start a share from its call bar: getDisplayMedia needs the click in
this frame. Until now the host clicked EC's hidden footer button through
the DOM, which only works while the frame is same-origin.

With lotusHostControls + lotusFrameScreenshare the fork now shows EC's own
screenshare button (floating bottom-right, footer still hidden), so the
click is native. controls_state reports frameScreenshare so the host hides
its own button. New toWidget io.lotus.set_frame_screenshare { visible }
lets the host hide it while the room's call policy forbids screensharing
(never while a share is live, so it can still be stopped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 21:48:22 -04:00
15 changed files with 415 additions and 44 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lotusguild/element-call-embedded",
"version": "0.25.0-lotus.19",
"version": "0.25.0-lotus.21",
"files": [
"README.md",
"LICENSE-AGPL-3.0",
+1 -1
View File
@@ -91,7 +91,7 @@ body.lotus-transparent[data-background="gradient"]::before {
}
/* [cinny #43] lotusHostControls=1: the host renders its own call bar, so EC's
footer is hidden but stays in the DOM and in layout-independent position. */
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;
@@ -0,0 +1,8 @@
/* [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
@@ -0,0 +1,88 @@
/*
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
@@ -0,0 +1,41 @@
/*
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>
);
};
+1
View File
@@ -32,6 +32,7 @@ describe("LotusWidgetActions", () => {
LotusWidgetActions.ToggleReactions,
LotusWidgetActions.SetScreenshare,
LotusWidgetActions.SetHotkeys,
LotusWidgetActions.SetFrameScreenshare,
];
expect(new Set(LOTUS_TO_WIDGET_ACTIONS)).toEqual(new Set(expectedToWidget));
+8 -1
View File
@@ -86,6 +86,12 @@ export enum LotusWidgetActions {
* 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",
/**
* fromWidget: a watched key went down/up here, or this window's focus
* changed; see `LotusHotkeyReport` (cinny #43).
@@ -93,7 +99,7 @@ export enum LotusWidgetActions {
Hotkey = "io.lotus.hotkey",
/**
* fromWidget: `{ screensharing: boolean, layout: "grid" | "spotlight" | null,
* screenshareAction: true, hotkeys: true }`
* screenshareAction: true, hotkeys: true, frameScreenshare: 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.
*/
@@ -118,4 +124,5 @@ export const LOTUS_TO_WIDGET_ACTIONS: LotusWidgetActions[] = [
LotusWidgetActions.ToggleReactions,
LotusWidgetActions.SetScreenshare,
LotusWidgetActions.SetHotkeys,
LotusWidgetActions.SetFrameScreenshare,
];
+36 -1
View File
@@ -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();
});
+53 -25
View File
@@ -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.
+49 -2
View File
@@ -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";
@@ -25,6 +25,38 @@ export interface LotusControlsState {
screenshareAction: true;
/** 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.
*/
frameScreenshare: 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.
*/
export function lotusFrameScreenshareEnabled(): boolean {
return lotusFlag("lotusHostControls") && lotusFlag("lotusFrameScreenshare");
}
/**
* 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);
/** `{ 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. */
@@ -100,7 +132,17 @@ export function startLotusControls(vm: CallViewModel): () => void {
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);
};
w.lazyActions.on(LotusWidgetActions.SetScreenshare, onSetScreenshare);
w.lazyActions.on(
LotusWidgetActions.SetFrameScreenshare,
onSetFrameScreenshare,
);
const sub: Subscription = combineLatest([
vm.sharingScreen$,
@@ -115,6 +157,7 @@ export function startLotusControls(vm: CallViewModel): () => void {
layout,
screenshareAction: true,
hotkeys: true,
frameScreenshare: lotusFrameScreenshareEnabled(),
}),
),
distinctUntilChanged(
@@ -131,5 +174,9 @@ export function startLotusControls(vm: CallViewModel): () => void {
w.lazyActions.off(LotusWidgetActions.OpenSettings, onOpenSettings);
w.lazyActions.off(LotusWidgetActions.ToggleReactions, onToggleReactions);
w.lazyActions.off(LotusWidgetActions.SetScreenshare, onSetScreenshare);
w.lazyActions.off(
LotusWidgetActions.SetFrameScreenshare,
onSetFrameScreenshare,
);
};
}
+65
View File
@@ -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"]);
});
});
+53
View File
@@ -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);
}
+2
View File
@@ -33,6 +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 { startLotusMicLevel } from "../lotus/lotusMicLevel";
import { startLotusAudioInject } from "../lotus/lotusAudioInject";
import { startLotusQuality } from "../lotus/lotusQuality";
@@ -692,6 +693,7 @@ export const InCallView: FC<InCallViewProps> = ({
{earpieceOverlay}
<ReactionsOverlay vm={vm} />
{footer}
<LotusFrameScreenshare vm={vm} />
{showModals && (
<>
<RageshakeRequestModal {...rageshakeRequestModalProps} />
+3 -3
View File
@@ -83,9 +83,9 @@ export const useTheme = (): void => {
// (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, but keep
// it in the DOM (the host still clicks its screenshare button on engines
// without Capability Delegation). Replaces the host's injected styles.
// [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
View File
@@ -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);