From 0f93368a7ecd88dc5ea54ff885924555aaf6d9ab Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 26 Sep 2026 21:48:22 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/index.css | 2 +- src/lotus/LotusFrameScreenshare.module.css | 8 ++ src/lotus/LotusFrameScreenshare.test.tsx | 88 ++++++++++++++++++++++ src/lotus/LotusFrameScreenshare.tsx | 41 ++++++++++ src/lotus/lotusActions.test.ts | 1 + src/lotus/lotusActions.ts | 9 ++- src/lotus/lotusControls.ts | 51 ++++++++++++- src/room/InCallView.tsx | 2 + src/useTheme.ts | 6 +- 9 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 src/lotus/LotusFrameScreenshare.module.css create mode 100644 src/lotus/LotusFrameScreenshare.test.tsx create mode 100644 src/lotus/LotusFrameScreenshare.tsx diff --git a/src/index.css b/src/index.css index e9539472..ef59b48e 100644 --- a/src/index.css +++ b/src/index.css @@ -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; diff --git a/src/lotus/LotusFrameScreenshare.module.css b/src/lotus/LotusFrameScreenshare.module.css new file mode 100644 index 00000000..3af74803 --- /dev/null +++ b/src/lotus/LotusFrameScreenshare.module.css @@ -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; +} diff --git a/src/lotus/LotusFrameScreenshare.test.tsx b/src/lotus/LotusFrameScreenshare.test.tsx new file mode 100644 index 00000000..2a47f3ab --- /dev/null +++ b/src/lotus/LotusFrameScreenshare.test.tsx @@ -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( + + + , + ); +}; + +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(); + }); +}); diff --git a/src/lotus/LotusFrameScreenshare.tsx b/src/lotus/LotusFrameScreenshare.tsx new file mode 100644 index 00000000..91950ab3 --- /dev/null +++ b/src/lotus/LotusFrameScreenshare.tsx @@ -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 ( +
+ +
+ ); +}; diff --git a/src/lotus/lotusActions.test.ts b/src/lotus/lotusActions.test.ts index ed994d3e..2941623c 100644 --- a/src/lotus/lotusActions.test.ts +++ b/src/lotus/lotusActions.test.ts @@ -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)); diff --git a/src/lotus/lotusActions.ts b/src/lotus/lotusActions.ts index 7d2feabb..02f4e6d6 100644 --- a/src/lotus/lotusActions.ts +++ b/src/lotus/lotusActions.ts @@ -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, ]; diff --git a/src/lotus/lotusControls.ts b/src/lotus/lotusControls.ts index 803427ed..fb7728d1 100644 --- a/src/lotus/lotusControls.ts +++ b/src/lotus/lotusControls.ts @@ -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): 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, + ); }; } diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index e2c2791a..d63af00d 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -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 = ({ {earpieceOverlay} {footer} + {showModals && ( <> diff --git a/src/useTheme.ts b/src/useTheme.ts index eee8699a..e0c1c213 100644 --- a/src/useTheme.ts +++ b/src/useTheme.ts @@ -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]);