diff --git a/src/lotus/LotusFrameScreenshare.module.css b/src/lotus/LotusFrameScreenshare.module.css deleted file mode 100644 index 3af74803..00000000 --- a/src/lotus/LotusFrameScreenshare.module.css +++ /dev/null @@ -1,8 +0,0 @@ -/* [cinny #43] EC's screenshare button, floating in the frame's bottom-right -corner while the host draws the rest of the call bar. */ -.floating { - position: fixed; - right: var(--cpd-space-4x); - bottom: calc(env(safe-area-inset-bottom) + var(--cpd-space-4x)); - z-index: 1; -} diff --git a/src/lotus/LotusFrameScreenshare.test.tsx b/src/lotus/LotusFrameScreenshare.test.tsx deleted file mode 100644 index 2a47f3ab..00000000 --- a/src/lotus/LotusFrameScreenshare.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/* -Copyright 2026 Lotus Guild - -SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE in the repository root for full details. -*/ - -import { describe, expect, it, vi } from "vitest"; -import { act, render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { TooltipProvider } from "@vector-im/compound-web"; -import { BehaviorSubject } from "rxjs"; - -import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; -import { LotusFrameScreenshare } from "./LotusFrameScreenshare"; -import { - lotusFrameScreenshareEnabled, - lotusFrameScreenshareVisible$, - parseFrameScreensharePayload, -} from "./lotusControls"; - -// lotusFlag memoizes the URL params on first read, so set them once here. -window.history.pushState( - {}, - "", - "/?lotusHostControls=1&lotusFrameScreenshare=1", -); - -const mockVm = ( - toggle: (() => void) | null, - sharing = new BehaviorSubject(false), -): CallViewModel => - ({ - sharingScreen$: sharing, - toggleScreenSharing: toggle, - }) as unknown as CallViewModel; - -const renderButton = (vm: CallViewModel): void => { - render( - - - , - ); -}; - -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 deleted file mode 100644 index 91950ab3..00000000 --- a/src/lotus/LotusFrameScreenshare.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* -Copyright 2026 Lotus Guild - -SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE in the repository root for full details. -*/ - -import { type FC } from "react"; - -import { ShareScreenButton } from "../button"; -import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; -import { useBehavior } from "../useBehavior"; -import { - lotusFrameScreenshareEnabled, - lotusFrameScreenshareVisible$, -} from "./lotusControls"; -import styles from "./LotusFrameScreenshare.module.css"; - -/** - * [cinny #43] EC's screenshare button, shown inside the frame when the host - * can't start a share for us (`lotusFrameScreenshare`). The rest of EC's - * footer stays hidden behind the host's call bar. - */ -export const LotusFrameScreenshare: FC<{ vm: CallViewModel }> = ({ vm }) => { - const sharing = useBehavior(vm.sharingScreen$); - const visible = useBehavior(lotusFrameScreenshareVisible$); - if (!lotusFrameScreenshareEnabled() || !vm.toggleScreenSharing) return null; - // Hidden by the host's call policy, but never while a share is live: the - // user must still be able to stop it. - if (!visible && !sharing) return null; - return ( -
- -
- ); -}; diff --git a/src/lotus/LotusScreensharePrompt.module.css b/src/lotus/LotusScreensharePrompt.module.css new file mode 100644 index 00000000..75d57d34 --- /dev/null +++ b/src/lotus/LotusScreensharePrompt.module.css @@ -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; + } +} diff --git a/src/lotus/LotusScreensharePrompt.test.tsx b/src/lotus/LotusScreensharePrompt.test.tsx new file mode 100644 index 00000000..efb45bbf --- /dev/null +++ b/src/lotus/LotusScreensharePrompt.test.tsx @@ -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( + + + , + ); +}; +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); + }); +}); diff --git a/src/lotus/LotusScreensharePrompt.tsx b/src/lotus/LotusScreensharePrompt.tsx new file mode 100644 index 00000000..f12527e2 --- /dev/null +++ b/src/lotus/LotusScreensharePrompt.tsx @@ -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(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 ( +
+ + Share your screen? + + + Your screen will be visible to everyone in this call. + +
+ + +
+
+ ); +}; diff --git a/src/lotus/lotusActions.test.ts b/src/lotus/lotusActions.test.ts index 2941623c..df290e60 100644 --- a/src/lotus/lotusActions.test.ts +++ b/src/lotus/lotusActions.test.ts @@ -33,6 +33,7 @@ describe("LotusWidgetActions", () => { LotusWidgetActions.SetScreenshare, LotusWidgetActions.SetHotkeys, LotusWidgetActions.SetFrameScreenshare, + LotusWidgetActions.PromptScreenshare, ]; expect(new Set(LOTUS_TO_WIDGET_ACTIONS)).toEqual(new Set(expectedToWidget)); diff --git a/src/lotus/lotusActions.ts b/src/lotus/lotusActions.ts index 02f4e6d6..030ba795 100644 --- a/src/lotus/lotusActions.ts +++ b/src/lotus/lotusActions.ts @@ -92,6 +92,12 @@ export enum LotusWidgetActions { * policy forbids screensharing (cinny #43). Defaults to visible. */ SetFrameScreenshare = "io.lotus.set_frame_screenshare", + /** + * toWidget: `{}` — show the "Share your screen?" prompt inside this frame + * (cinny #43). For hosts that can't delegate the user's click: the prompt's + * Share button is clicked here, so getDisplayMedia gets its activation. + */ + PromptScreenshare = "io.lotus.prompt_screenshare", /** * fromWidget: a watched key went down/up here, or this window's focus * changed; see `LotusHotkeyReport` (cinny #43). @@ -99,7 +105,8 @@ export enum LotusWidgetActions { Hotkey = "io.lotus.hotkey", /** * fromWidget: `{ screensharing: boolean, layout: "grid" | "spotlight" | null, - * screenshareAction: true, hotkeys: true, frameScreenshare: boolean }` + * screenshareAction: true, hotkeys: true, frameScreenshare: false, + * screensharePrompt: boolean, screensharePromptOpen: boolean }` * whenever either changes (cinny #43), so the host stops reading EC's DOM for * them. Its arrival also tells the host this fork supports the actions above. */ @@ -125,4 +132,5 @@ export const LOTUS_TO_WIDGET_ACTIONS: LotusWidgetActions[] = [ LotusWidgetActions.SetScreenshare, LotusWidgetActions.SetHotkeys, LotusWidgetActions.SetFrameScreenshare, + LotusWidgetActions.PromptScreenshare, ]; diff --git a/src/lotus/lotusControls.ts b/src/lotus/lotusControls.ts index fb7728d1..4a020c11 100644 --- a/src/lotus/lotusControls.ts +++ b/src/lotus/lotusControls.ts @@ -26,23 +26,39 @@ export interface LotusControlsState { /** This fork handles `io.lotus.set_hotkeys` (always true). */ hotkeys: true; /** - * EC's own screenshare button is shown inside the frame (see - * `lotusFrameScreenshare`), so the host should hide its own. + * EC's own screenshare button is shown inside the frame, so the host should + * hide its own. Always false since the prompt replaced that button; kept so + * a host that still reads it doesn't hide its button. */ - frameScreenshare: boolean; + frameScreenshare: false; + /** + * The host should start a share with `io.lotus.prompt_screenshare` (the + * in-frame prompt) instead of `set_screenshare`: see + * `lotusFrameScreenshareEnabled`. + */ + screensharePrompt: boolean; + /** + * The prompt is showing. The host must let clicks through to this frame + * meanwhile (e.g. its picture-in-picture overlay). + */ + screensharePromptOpen: boolean; } /** * [cinny #43] `lotusFrameScreenshare=1` (with `lotusHostControls`): the host * can't hand the user's click into this frame (no Capability Delegation: * Firefox, Safari, WebKitGTK), and `getDisplayMedia` needs that click here. - * So EC's own screenshare button stays visible in the frame, where the click - * is native, instead of the host clicking it through the DOM. + * The host's call-bar button then asks for `io.lotus.prompt_screenshare`, and + * the "Share your screen?" prompt is drawn inside this frame, where the Share + * click is native. */ export function lotusFrameScreenshareEnabled(): boolean { return lotusFlag("lotusHostControls") && lotusFlag("lotusFrameScreenshare"); } +/** Whether the in-frame "Share your screen?" prompt is showing. */ +export const lotusScreensharePrompt$ = new BehaviorSubject(false); + /** * Host-controlled visibility of the in-frame screenshare button * (`io.lotus.set_frame_screenshare`): the host hides it while the room's @@ -50,6 +66,21 @@ export function lotusFrameScreenshareEnabled(): boolean { */ export const lotusFrameScreenshareVisible$ = new BehaviorSubject(true); +/** + * Whether `io.lotus.prompt_screenshare` should show the prompt: only in + * prompt mode, where the room's policy allows sharing, EC can share, and no + * share is running (stopping goes through `set_screenshare`, which needs no + * click). Exported for tests. + */ +export function shouldPromptScreenshare(s: { + enabled: boolean; + allowed: boolean; + canShare: boolean; + sharing: boolean; +}): boolean { + return s.enabled && s.allowed && s.canShare && !s.sharing; +} + /** `{ visible }` payload → the visibility to apply, or undefined. Exported for tests. */ export function parseFrameScreensharePayload( data: unknown, @@ -138,30 +169,51 @@ export function startLotusControls(vm: CallViewModel): () => void { if (visible !== undefined) lotusFrameScreenshareVisible$.next(visible); }; + const onPromptScreenshare = (ev: CustomEvent): 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(null))), ), + lotusScreensharePrompt$, ]) .pipe( map( - ([screensharing, layout]): LotusControlsState => ({ + ([screensharing, layout, promptOpen]): LotusControlsState => ({ screensharing, layout, screenshareAction: true, hotkeys: true, - frameScreenshare: lotusFrameScreenshareEnabled(), + frameScreenshare: false, + screensharePrompt: lotusFrameScreenshareEnabled(), + screensharePromptOpen: promptOpen, }), ), distinctUntilChanged( - (a, b) => a.screensharing === b.screensharing && a.layout === b.layout, + (a, b) => + a.screensharing === b.screensharing && + a.layout === b.layout && + a.screensharePromptOpen === b.screensharePromptOpen, ), ) .subscribe((state) => { @@ -178,5 +230,10 @@ export function startLotusControls(vm: CallViewModel): () => void { LotusWidgetActions.SetFrameScreenshare, onSetFrameScreenshare, ); + w.lazyActions.off( + LotusWidgetActions.PromptScreenshare, + onPromptScreenshare, + ); + lotusScreensharePrompt$.next(false); }; } diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index d63af00d..dce65202 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -33,7 +33,7 @@ import { startLotusCallState } from "../lotus/lotusCallState"; import { startLotusFocus } from "../lotus/lotusFocus"; import { startLotusControls } from "../lotus/lotusControls"; import { startLotusHotkeys } from "../lotus/lotusHotkeys"; -import { LotusFrameScreenshare } from "../lotus/LotusFrameScreenshare"; +import { LotusScreensharePrompt } from "../lotus/LotusScreensharePrompt"; import { startLotusMicLevel } from "../lotus/lotusMicLevel"; import { startLotusAudioInject } from "../lotus/lotusAudioInject"; import { startLotusQuality } from "../lotus/lotusQuality"; @@ -693,7 +693,7 @@ export const InCallView: FC = ({ {earpieceOverlay} {footer} - + {showModals && ( <>