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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
dfb72bd9a5
commit
0f93368a7e
+1
-1
@@ -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
|
/* [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"] {
|
body.lotus-host-controls [data-testid="footer-container"] {
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
visibility: hidden !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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -32,6 +32,7 @@ describe("LotusWidgetActions", () => {
|
|||||||
LotusWidgetActions.ToggleReactions,
|
LotusWidgetActions.ToggleReactions,
|
||||||
LotusWidgetActions.SetScreenshare,
|
LotusWidgetActions.SetScreenshare,
|
||||||
LotusWidgetActions.SetHotkeys,
|
LotusWidgetActions.SetHotkeys,
|
||||||
|
LotusWidgetActions.SetFrameScreenshare,
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(new Set(LOTUS_TO_WIDGET_ACTIONS)).toEqual(new Set(expectedToWidget));
|
expect(new Set(LOTUS_TO_WIDGET_ACTIONS)).toEqual(new Set(expectedToWidget));
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ export enum LotusWidgetActions {
|
|||||||
* to report while focus is inside this frame (cinny #43). `[]` stops.
|
* to report while focus is inside this frame (cinny #43). `[]` stops.
|
||||||
*/
|
*/
|
||||||
SetHotkeys = "io.lotus.set_hotkeys",
|
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
|
* fromWidget: a watched key went down/up here, or this window's focus
|
||||||
* changed; see `LotusHotkeyReport` (cinny #43).
|
* changed; see `LotusHotkeyReport` (cinny #43).
|
||||||
@@ -93,7 +99,7 @@ export enum LotusWidgetActions {
|
|||||||
Hotkey = "io.lotus.hotkey",
|
Hotkey = "io.lotus.hotkey",
|
||||||
/**
|
/**
|
||||||
* fromWidget: `{ screensharing: boolean, layout: "grid" | "spotlight" | null,
|
* 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
|
* 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.
|
* 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.ToggleReactions,
|
||||||
LotusWidgetActions.SetScreenshare,
|
LotusWidgetActions.SetScreenshare,
|
||||||
LotusWidgetActions.SetHotkeys,
|
LotusWidgetActions.SetHotkeys,
|
||||||
|
LotusWidgetActions.SetFrameScreenshare,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { type IWidgetApiRequest } from "matrix-widget-api";
|
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 { distinctUntilChanged, map, switchMap } from "rxjs/operators";
|
||||||
|
|
||||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||||
import { type LayoutMode } from "../state/LayoutSwitchViewModel";
|
import { type LayoutMode } from "../state/LayoutSwitchViewModel";
|
||||||
import { widget } from "../widget";
|
import { widget } from "../widget";
|
||||||
import { LotusWidgetActions, lotusSendToHost } from "./lotusWidget";
|
import { LotusWidgetActions, lotusFlag, lotusSendToHost } from "./lotusWidget";
|
||||||
|
|
||||||
/** Window event the reactions button listens for (see ReactionToggleButton). */
|
/** Window event the reactions button listens for (see ReactionToggleButton). */
|
||||||
export const LOTUS_TOGGLE_REACTIONS_EVENT = "lotus:toggle-reactions";
|
export const LOTUS_TOGGLE_REACTIONS_EVENT = "lotus:toggle-reactions";
|
||||||
@@ -25,6 +25,38 @@ export interface LotusControlsState {
|
|||||||
screenshareAction: true;
|
screenshareAction: true;
|
||||||
/** This fork handles `io.lotus.set_hotkeys` (always true). */
|
/** This fork handles `io.lotus.set_hotkeys` (always true). */
|
||||||
hotkeys: 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. */
|
/** `{ 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.SetLayout, onSetLayout);
|
||||||
w.lazyActions.on(LotusWidgetActions.OpenSettings, onOpenSettings);
|
w.lazyActions.on(LotusWidgetActions.OpenSettings, onOpenSettings);
|
||||||
w.lazyActions.on(LotusWidgetActions.ToggleReactions, onToggleReactions);
|
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.SetScreenshare, onSetScreenshare);
|
||||||
|
w.lazyActions.on(
|
||||||
|
LotusWidgetActions.SetFrameScreenshare,
|
||||||
|
onSetFrameScreenshare,
|
||||||
|
);
|
||||||
|
|
||||||
const sub: Subscription = combineLatest([
|
const sub: Subscription = combineLatest([
|
||||||
vm.sharingScreen$,
|
vm.sharingScreen$,
|
||||||
@@ -115,6 +157,7 @@ export function startLotusControls(vm: CallViewModel): () => void {
|
|||||||
layout,
|
layout,
|
||||||
screenshareAction: true,
|
screenshareAction: true,
|
||||||
hotkeys: true,
|
hotkeys: true,
|
||||||
|
frameScreenshare: lotusFrameScreenshareEnabled(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
distinctUntilChanged(
|
distinctUntilChanged(
|
||||||
@@ -131,5 +174,9 @@ export function startLotusControls(vm: CallViewModel): () => void {
|
|||||||
w.lazyActions.off(LotusWidgetActions.OpenSettings, onOpenSettings);
|
w.lazyActions.off(LotusWidgetActions.OpenSettings, onOpenSettings);
|
||||||
w.lazyActions.off(LotusWidgetActions.ToggleReactions, onToggleReactions);
|
w.lazyActions.off(LotusWidgetActions.ToggleReactions, onToggleReactions);
|
||||||
w.lazyActions.off(LotusWidgetActions.SetScreenshare, onSetScreenshare);
|
w.lazyActions.off(LotusWidgetActions.SetScreenshare, onSetScreenshare);
|
||||||
|
w.lazyActions.off(
|
||||||
|
LotusWidgetActions.SetFrameScreenshare,
|
||||||
|
onSetFrameScreenshare,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { startLotusCallState } from "../lotus/lotusCallState";
|
|||||||
import { startLotusFocus } from "../lotus/lotusFocus";
|
import { startLotusFocus } from "../lotus/lotusFocus";
|
||||||
import { startLotusControls } from "../lotus/lotusControls";
|
import { startLotusControls } from "../lotus/lotusControls";
|
||||||
import { startLotusHotkeys } from "../lotus/lotusHotkeys";
|
import { startLotusHotkeys } from "../lotus/lotusHotkeys";
|
||||||
|
import { LotusFrameScreenshare } from "../lotus/LotusFrameScreenshare";
|
||||||
import { startLotusMicLevel } from "../lotus/lotusMicLevel";
|
import { startLotusMicLevel } from "../lotus/lotusMicLevel";
|
||||||
import { startLotusAudioInject } from "../lotus/lotusAudioInject";
|
import { startLotusAudioInject } from "../lotus/lotusAudioInject";
|
||||||
import { startLotusQuality } from "../lotus/lotusQuality";
|
import { startLotusQuality } from "../lotus/lotusQuality";
|
||||||
@@ -692,6 +693,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
{earpieceOverlay}
|
{earpieceOverlay}
|
||||||
<ReactionsOverlay vm={vm} />
|
<ReactionsOverlay vm={vm} />
|
||||||
{footer}
|
{footer}
|
||||||
|
<LotusFrameScreenshare vm={vm} />
|
||||||
{showModals && (
|
{showModals && (
|
||||||
<>
|
<>
|
||||||
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
||||||
|
|||||||
+3
-3
@@ -83,9 +83,9 @@ export const useTheme = (): void => {
|
|||||||
// (The host used to inject `:root { color-scheme }` for this.)
|
// (The host used to inject `:root { color-scheme }` for this.)
|
||||||
document.documentElement.style.colorScheme = theme;
|
document.documentElement.style.colorScheme = theme;
|
||||||
}
|
}
|
||||||
// [cinny #43] The host draws its own call bar: hide EC's footer, but keep
|
// [cinny #43] The host draws its own call bar: hide EC's footer. Replaces
|
||||||
// it in the DOM (the host still clicks its screenshare button on engines
|
// the host's injected styles. Where the host can't start a share for us,
|
||||||
// without Capability Delegation). Replaces the host's injected styles.
|
// LotusFrameScreenshare shows EC's screenshare button on its own.
|
||||||
if (lotusFlag("lotusHostControls"))
|
if (lotusFlag("lotusHostControls"))
|
||||||
document.body.classList.add("lotus-host-controls");
|
document.body.classList.add("lotus-host-controls");
|
||||||
}, [previousTheme, requestedTheme]);
|
}, [previousTheme, requestedTheme]);
|
||||||
|
|||||||
Reference in New Issue
Block a user