From 8520de232d8fdafe3ea6030607c2674728b328b9 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sun, 27 Sep 2026 12:48:12 -0400 Subject: [PATCH 1/2] feat(call): screenshare stays on the call bar in Firefox/Safari via the in-frame prompt (#43) With a fork that reports `screensharePrompt` (element-call lotus-screenshare- prompt), the call bar and status bar keep their screenshare button on every engine. Where the click can't be delegated, starting asks the fork to show "Share your screen?" inside the call frame (io.lotus.prompt_screenshare) instead of our own confirm; its Share click starts the share. Stopping works from the bar as before (no click needed in the frame). Chromium is unchanged. - useScreenshareMode: hidden (older fork's corner button) | prompt | direct. - The room's call policy is also pushed to the fork in prompt mode, so the prompt never opens where sharing is forbidden. - Picture-in-picture: the "Return to call" overlay covers the frame; while the fork reports the prompt open it lets clicks through to it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/components/CallEmbedProvider.tsx | 6 +- src/app/features/call-status/CallControl.tsx | 18 +++--- src/app/features/call/CallControls.tsx | 19 +++--- src/app/plugins/call/CallControl.ts | 65 +++++++++++++++++--- src/app/plugins/call/hooks.ts | 41 +++++++++--- 5 files changed, 118 insertions(+), 31 deletions(-) diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index cd5e4d0ee..c74705a85 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -38,7 +38,7 @@ import { } from '../hooks/useCallEmbed'; import { callChatAtom, callEmbedAtom } from '../state/callEmbed'; import { toastQueueAtom } from '../state/toast'; -import { CallEmbed, useCallControlState } from '../plugins/call'; +import { CallEmbed, useCallControlState, useScreensharePromptOpen } from '../plugins/call'; import { hangupCallAndWait } from '../plugins/call/hangup'; import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; @@ -882,6 +882,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { const callActive = callEmbed && joined; const callVisible = inCallRoom && callActive && !chatOnlyView; const pipMode = callActive && !inCallRoom; + const pipPromptOpen = useScreensharePromptOpen(callEmbed?.control); const { navigateRoom } = useRoomNavigate(); const { screenshare: pipScreenshare } = useCallControlState(callEmbed?.control); @@ -1363,6 +1364,9 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { zIndex: 1, background: 'transparent', cursor: 'grab', + // [Gitea #43] The call's "Share your screen?" prompt is drawn + // inside the frame: let the click reach its Share button. + pointerEvents: pipPromptOpen ? 'none' : undefined, display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-end', diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index 1b7f885c9..6acaf420a 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -7,7 +7,7 @@ import { CallEmbed, useCallControlState, useCallMicLevel, - useFrameScreenshare, + useScreenshareMode, } from '../../plugins/call'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { callEmbedAtom } from '../../state/callEmbed'; @@ -197,10 +197,16 @@ export function CallControl({ // Keep a forbidden control visible while its track is still live (so the user // can stop it); otherwise hide it entirely. const showCamera = allowCamera || video; - // [Gitea #43] Hidden where EC shows its own screenshare button in the frame. - const frameScreenshare = useFrameScreenshare(callEmbed.control); - const showScreenshare = !frameScreenshare && (allowScreenshare || screenshare); + // [Gitea #43] Hidden where EC shows its own screenshare button in the frame; + // where the click can't be delegated, starting asks for the in-frame prompt. + const screenshareMode = useScreenshareMode(callEmbed.control); + const showScreenshare = screenshareMode !== 'hidden' && (allowScreenshare || screenshare); const [shareConfirm, setShareConfirm] = useState(false); + const handleScreenshareToggle = () => { + if (screenshare) callEmbed.control.toggleScreenshare(); + else if (screenshareMode === 'prompt') callEmbed.control.promptScreenshare(); + else setShareConfirm(true); + }; const handleMicrophoneToggle = useCallback( () => callEmbed.control.toggleMicrophone(), @@ -255,9 +261,7 @@ export function CallControl({ {!compact && showScreenshare && ( - screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true) - } + onToggle={() => handleScreenshareToggle()} disabled={!callJoined} /> )} diff --git a/src/app/features/call/CallControls.tsx b/src/app/features/call/CallControls.tsx index 04a84c69b..bbcfcfdc2 100644 --- a/src/app/features/call/CallControls.tsx +++ b/src/app/features/call/CallControls.tsx @@ -33,7 +33,7 @@ import { CallEmbed, useCallControlState, useCallMicLevel, - useFrameScreenshare, + useScreenshareMode, } from '../../plugins/call'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; @@ -112,9 +112,14 @@ export function CallControls({ callEmbed }: CallControlsProps) { // can stop it); otherwise hide it entirely. const showCamera = allowCamera || video; // [Gitea #43] Where EC shows its own screenshare button in the frame, ours - // is hidden (this engine can't start a share from the host); the - // screenshare-audio mute stays. - const frameScreenshare = useFrameScreenshare(callEmbed.control); + // is hidden (the screenshare-audio mute stays); where the click can't be + // delegated, starting asks for the fork's in-frame "Share your screen?". + const screenshareMode = useScreenshareMode(callEmbed.control); + const handleScreenshareToggle = () => { + if (screenshare) callEmbed.control.toggleScreenshare(); + else if (screenshareMode === 'prompt') callEmbed.control.promptScreenshare(); + else setShareConfirm(true); + }; const showScreenshare = allowScreenshare || screenshare; const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled; const handleOpenMenu: MouseEventHandler = (evt) => { @@ -225,12 +230,10 @@ export function CallControls({ callEmbed }: CallControlsProps) { {showCamera && } {showScreenshare && ( <> - {!frameScreenshare && ( + {screenshareMode !== 'hidden' && ( - screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true) - } + onToggle={() => handleScreenshareToggle()} /> )} {/* Mute-screenshare-audio sits directly next to the screenshare diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index ea886f032..da734785c 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -261,6 +261,15 @@ export class CallControl extends EventEmitter implements CallControlState { private frameScreenshareListeners = new Set<() => void>(); + // [Gitea #43] The fork draws the "Share your screen?" prompt inside the frame + // on request (io.lotus.prompt_screenshare), so where the click can't be + // delegated our call-bar button asks for that instead of starting the share. + private _screensharePrompt = false; + + // [Gitea #43] The fork's in-frame prompt is showing: anything the host lays + // over the frame (the picture-in-picture overlay) must let clicks through. + private _screensharePromptOpen = false; + // [Gitea #43] Whether the room's call policy allows screensharing; the fork // hides its in-frame button when it doesn't. private frameScreenshareAllowed = true; @@ -274,7 +283,32 @@ export class CallControl extends EventEmitter implements CallControlState { return this._frameScreenshare; } - /** Subscribe to `frameScreenshare` changes. Returns an unsubscribe. */ + /** + * Starting a share must go through the fork's in-frame prompt: this engine + * can't delegate the click (Firefox, Safari, WebKitGTK) and the fork has + * the prompt. Stopping never needs it. + */ + public get screenshareNeedsPrompt(): boolean { + return this._screensharePrompt && !canDelegateCapability(); + } + + /** The fork's in-frame "Share your screen?" prompt is showing. */ + public get screensharePromptOpen(): boolean { + return this._screensharePromptOpen; + } + + /** + * Ask the fork to show "Share your screen?" inside the call frame; its Share + * button starts the share with the frame's own click. + */ + public promptScreenshare(): void { + this.sendForkAction('io.lotus.prompt_screenshare', {}); + } + + /** + * Subscribe to `frameScreenshare` / `screenshareNeedsPrompt` / + * `screensharePromptOpen` changes. Returns an unsubscribe. + */ public onFrameScreenshareChange(cb: () => void): () => void { this.frameScreenshareListeners.add(cb); return () => { @@ -289,7 +323,8 @@ export class CallControl extends EventEmitter implements CallControlState { } private sendFrameScreenshareAllowed(): void { - if (!this.joined || !this.forkReady || !this._frameScreenshare) return; + if (!this.joined || !this.forkReady) return; + if (!this._frameScreenshare && !this._screensharePrompt) return; this.sendForkAction('io.lotus.set_frame_screenshare', { visible: this.frameScreenshareAllowed, }); @@ -324,17 +359,31 @@ export class CallControl extends EventEmitter implements CallControlState { /** [Gitea #43] The fork's `io.lotus.controls_state` report. */ public onControlsState(data: unknown) { if (typeof data !== 'object' || data === null) return; - const { screensharing, layout, frameScreenshare } = data as { - screensharing?: unknown; - layout?: unknown; - frameScreenshare?: unknown; - }; + const { screensharing, layout, frameScreenshare, screensharePrompt, screensharePromptOpen } = + data as { + screensharing?: unknown; + layout?: unknown; + frameScreenshare?: unknown; + screensharePrompt?: unknown; + screensharePromptOpen?: unknown; + }; const firstReport = !this.forkReady; this.forkReady = true; const frame = frameScreenshare === true; - if (frame !== this._frameScreenshare) { + const prompt = screensharePrompt === true; + const promptOpen = screensharePromptOpen === true; + if (promptOpen !== this._screensharePromptOpen) { + this._screensharePromptOpen = promptOpen; + if (frame === this._frameScreenshare && prompt === this._screensharePrompt) { + this.frameScreenshareListeners.forEach((l) => l()); + } + } + if (frame !== this._frameScreenshare || prompt !== this._screensharePrompt) { + const promptTurnedOn = prompt && !this._screensharePrompt; this._frameScreenshare = frame; + this._screensharePrompt = prompt; this.frameScreenshareListeners.forEach((l) => l()); + if (promptTurnedOn && !firstReport) this.sendFrameScreenshareAllowed(); } if (firstReport) { this.sendHotkeyCodes(); diff --git a/src/app/plugins/call/hooks.ts b/src/app/plugins/call/hooks.ts index ba227d5c6..f7ab9836d 100644 --- a/src/app/plugins/call/hooks.ts +++ b/src/app/plugins/call/hooks.ts @@ -75,19 +75,46 @@ export const useCallMicLevel = (callEmbed: CallEmbed | undefined): number => { }; /** - * [Gitea #43] True when EC's own screenshare button is shown inside the call - * frame (no Capability Delegation in this engine), so the host bar hides its. + * [Gitea #43] How the call bar's screenshare button behaves: + * - `hidden`: EC's own button is inside the frame (fork lotus.20/21), so ours hides; + * - `prompt`: starting asks the fork for its in-frame "Share your screen?" + * (no Capability Delegation in this engine); + * - `direct`: our own confirm, then a (delegated) set_screenshare. */ -export const useFrameScreenshare = (control: CallControl | undefined): boolean => { - const [frame, setFrame] = useState(() => control?.frameScreenshare ?? false); +export type ScreenshareMode = 'hidden' | 'prompt' | 'direct'; + +const screenshareMode = (control: CallControl): ScreenshareMode => { + if (control.frameScreenshare) return 'hidden'; + return control.screenshareNeedsPrompt ? 'prompt' : 'direct'; +}; + +export const useScreenshareMode = (control: CallControl | undefined): ScreenshareMode => { + const [mode, setMode] = useState(() => + control ? screenshareMode(control) : 'direct', + ); useEffect(() => { if (!control) { - setFrame(false); + setMode('direct'); return undefined; } - const sync = () => setFrame(control.frameScreenshare); + const sync = () => setMode(screenshareMode(control)); sync(); return control.onFrameScreenshareChange(sync); }, [control]); - return frame; + return mode; +}; + +/** [Gitea #43] The fork's in-frame "Share your screen?" prompt is showing. */ +export const useScreensharePromptOpen = (control: CallControl | undefined): boolean => { + const [open, setOpen] = useState(() => control?.screensharePromptOpen ?? false); + useEffect(() => { + if (!control) { + setOpen(false); + return undefined; + } + const sync = () => setOpen(control.screensharePromptOpen); + sync(); + return control.onFrameScreenshareChange(sync); + }, [control]); + return open; }; -- 2.47.3 From b929cbf74b944882e9e314d19969f1d018daca92 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sun, 27 Sep 2026 13:32:45 -0400 Subject: [PATCH 2/2] chore(call): pin element-call-embedded 0.25.0-lotus.22 (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lotus.22 draws the "Share your screen?" prompt inside the call frame on request and drops the corner button. Verified against the published package: no-delegation (Firefox path) cross- and same-origin — bar button, in-frame prompt, Cancel, Share → screenshare tracks, bar Stop; Chromium unchanged; picture-in-picture prompt fits and Share works; room policy hides the button and refuses the prompt. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index bb8b83095..41e1b7b77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,7 +81,7 @@ }, "devDependencies": { "@axe-core/playwright": "4.13.0", - "@lotusguild/element-call-embedded": "0.25.0-lotus.21", + "@lotusguild/element-call-embedded": "0.25.0-lotus.22", "@playwright/test": "1.63.0", "@rollup/plugin-inject": "5.0.5", "@rollup/plugin-wasm": "6.2.2", @@ -2695,9 +2695,9 @@ "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==" }, "node_modules/@lotusguild/element-call-embedded": { - "version": "0.25.0-lotus.21", - "resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.21/element-call-embedded-0.25.0-lotus.21.tgz", - "integrity": "sha512-Phtjop2qdKRrCLqAhzipRaJaVnU3MjK9CV4QwHkwU/Xlj8oguZBfjOlV7zTqOTzAr57OteOfLcyxLsmv5+u9tg==", + "version": "0.25.0-lotus.22", + "resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.22/element-call-embedded-0.25.0-lotus.22.tgz", + "integrity": "sha512-AimDKZA4xUKEEyl2XUFUSUDl03tBKFv88T/3igKyH2VqNSPMGnqcQJC5HXIkHLTWrOmg8kLcJ//FFbqlUZiLcA==", "dev": true }, "node_modules/@matrix-org/matrix-sdk-crypto-wasm": { diff --git a/package.json b/package.json index 4665976e8..667d5e3b4 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ }, "devDependencies": { "@axe-core/playwright": "4.13.0", - "@lotusguild/element-call-embedded": "0.25.0-lotus.21", + "@lotusguild/element-call-embedded": "0.25.0-lotus.22", "@playwright/test": "1.63.0", "@rollup/plugin-inject": "5.0.5", "@rollup/plugin-wasm": "6.2.2", -- 2.47.3