In-frame "Share your screen?" prompt replaces the corner button (cinny #43) #42

Merged
jared merged 1 commits from lotus-screenshare-prompt into lotus 2026-09-27 13:05:01 -04:00
10 changed files with 347 additions and 148 deletions
@@ -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;
}
-88
View File
@@ -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(
<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
@@ -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 (
<div className={styles.floating}>
<ShareScreenButton
size="md"
enabled={sharing}
onClick={vm.toggleScreenSharing}
data-testid="lotus_frame_screenshare"
/>
</div>
);
};
@@ -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;
}
}
+125
View File
@@ -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(
<TooltipProvider>
<LotusScreensharePrompt vm={vm} />
</TooltipProvider>,
);
};
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);
});
});
+102
View File
@@ -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<HTMLButtonElement>(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 (
<div
className={styles.prompt}
role="alertdialog"
aria-labelledby={titleId}
aria-describedby={descriptionId}
data-testid="lotus_screenshare_prompt"
>
<Text
as="h2"
id={titleId}
size="md"
weight="semibold"
className={styles.title}
>
Share your screen?
</Text>
<Text id={descriptionId} size="md" className={styles.description}>
Your screen will be visible to everyone in this call.
</Text>
<div className={styles.buttons}>
<Button
ref={shareRef}
size="md"
onClick={onShare}
data-testid="lotus_screenshare_prompt_share"
>
Share
</Button>
<Button size="md" kind="secondary" onClick={close}>
Cancel
</Button>
</div>
</div>
);
};
+1
View File
@@ -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));
+9 -1
View File
@@ -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,
];
+65 -8
View File
@@ -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<IWidgetApiRequest>): 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<LayoutMode | null>(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);
};
}
+2 -2
View File
@@ -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<InCallViewProps> = ({
{earpieceOverlay}
<ReactionsOverlay vm={vm} />
{footer}
<LotusFrameScreenshare vm={vm} />
<LotusScreensharePrompt vm={vm} />
{showModals && (
<>
<RageshakeRequestModal {...rageshakeRequestModalProps} />