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 && (
<>