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", 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; };