From 49dec686f1acc71767dac8c7c62b830a417f6206 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 26 Sep 2026 22:04:54 -0400 Subject: [PATCH] feat(call): screenshare from inside the frame where delegation is missing; host stops reading the call frame (#43) Firefox, Safari and the WebKitGTK desktop app can't hand the user's click to the call frame (no Capability Delegation), and getDisplayMedia needs it. The host used to click EC's hidden footer button through the DOM instead, which dies with same-origin. Now (pins element-call-embedded 0.25.0-lotus.20): - those engines get `lotusFrameScreenshare`, the fork shows EC's own screenshare button in the frame, and the call bar and status bar hide theirs once controls_state reports `frameScreenshare`; the screenshare-audio mute stays; - the room's call policy is pushed with io.lotus.set_frame_screenshare, so the frame button hides where the server would refuse a share, like ours; - Chromium keeps the delegated io.lotus.set_screenshare from the host bar. Removed the fallbacks for forks older than lotus.14, which read or clicked EC's DOM: the screenshare/layout/settings/reactions/leave button lookups and their MutationObservers, the frame-window hotkey binding, and the speaking/muted tile scrape in useCallSpeakers (io.lotus.call_state is the only source now). getCallDocument is gone; the host's only handle on the frame is postMessage. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- package-lock.json | 8 +- package.json | 2 +- src/app/components/CallEmbedProvider.tsx | 7 + src/app/features/call-status/CallControl.tsx | 11 +- src/app/features/call/CallControls.tsx | 25 +- src/app/hooks/useCallHotkeys.ts | 59 +---- src/app/hooks/useCallSpeakers.ts | 235 ++--------------- src/app/plugins/call/CallControl.ts | 256 +++++-------------- src/app/plugins/call/CallEmbed.ts | 20 +- src/app/plugins/call/hooks.ts | 18 ++ src/app/plugins/call/utils.ts | 19 +- 11 files changed, 156 insertions(+), 504 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8ac1406cb..022b63d55 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.19", + "@lotusguild/element-call-embedded": "0.25.0-lotus.20", "@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.19", - "resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.19/element-call-embedded-0.25.0-lotus.19.tgz", - "integrity": "sha512-xNWgja9PHeDuSgo6fvoI1BJkYHof0xJ9kkLnURqPkrHV0o1UagHgaYcFgbFZMtDsoPnz5UrOGleLsjFo7d5/wQ==", + "version": "0.25.0-lotus.20", + "resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.20/element-call-embedded-0.25.0-lotus.20.tgz", + "integrity": "sha512-USac8cunc+nlkyivxV0dXshzJoSWTyNHc6Aq/xIpphKbkON05NzhEWBX+qNwgwdyVWacdmYAEX97z6jHA5J+Kw==", "dev": true }, "node_modules/@matrix-org/matrix-sdk-crypto-wasm": { diff --git a/package.json b/package.json index 01d817201..854f07df6 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.19", + "@lotusguild/element-call-embedded": "0.25.0-lotus.20", "@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 8f9853771..cd5e4d0ee 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -78,6 +78,7 @@ import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels'; import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators'; import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions'; import { useLivekitSupport } from '../hooks/useLivekitSupport'; +import { useRoomCallPolicy } from '../hooks/useRoomCallPolicy'; import { useNotificationsQuiet } from '../hooks/useNotificationsQuiet'; import { CallAvatarAnimation } from '../styles/Animations.css'; import { webRTCSupported } from '../utils/rtc'; @@ -758,6 +759,12 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) { useCallPolicyRevokedToast(embed, joined); useCallEndedToast(embed); useScreenshareNotices(embed); + // [Gitea #43] The in-frame screenshare button follows the same room policy + // as the host bar's (hidden where the server would refuse the share). + const { allowScreenshare } = useRoomCallPolicy(embed.room); + useEffect(() => { + embed.control.setFrameScreenshareAllowed(allowScreenshare); + }, [embed, allowScreenshare]); usePttHaptics(); useCallAnnouncements(embed, joined); useMutedTalkWarning(embed, joined); diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index 08b2454ec..1b7f885c9 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -3,7 +3,12 @@ import React, { useCallback, useState } from 'react'; import { useSetAtom } from 'jotai'; import { MicLevelBars } from '../call/MicLevelBars'; import { StatusDivider } from './components'; -import { CallEmbed, useCallControlState, useCallMicLevel } from '../../plugins/call'; +import { + CallEmbed, + useCallControlState, + useCallMicLevel, + useFrameScreenshare, +} from '../../plugins/call'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { callEmbedAtom } from '../../state/callEmbed'; import { MobileTouchTarget } from '../../styles/mobile.css'; @@ -192,7 +197,9 @@ 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; - const showScreenshare = allowScreenshare || screenshare; + // [Gitea #43] Hidden where EC shows its own screenshare button in the frame. + const frameScreenshare = useFrameScreenshare(callEmbed.control); + const showScreenshare = !frameScreenshare && (allowScreenshare || screenshare); const [shareConfirm, setShareConfirm] = useState(false); const handleMicrophoneToggle = useCallback( diff --git a/src/app/features/call/CallControls.tsx b/src/app/features/call/CallControls.tsx index c3ae2b1fc..04a84c69b 100644 --- a/src/app/features/call/CallControls.tsx +++ b/src/app/features/call/CallControls.tsx @@ -29,7 +29,12 @@ import { SoundButton, VideoButton, } from './Controls'; -import { CallEmbed, useCallControlState, useCallMicLevel } from '../../plugins/call'; +import { + CallEmbed, + useCallControlState, + useCallMicLevel, + useFrameScreenshare, +} from '../../plugins/call'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { callEmbedAtom } from '../../state/callEmbed'; @@ -106,6 +111,10 @@ export function CallControls({ callEmbed }: CallControlsProps) { // 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] 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); const showScreenshare = allowScreenshare || screenshare; const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled; const handleOpenMenu: MouseEventHandler = (evt) => { @@ -216,12 +225,14 @@ export function CallControls({ callEmbed }: CallControlsProps) { {showCamera && } {showScreenshare && ( <> - - screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true) - } - /> + {!frameScreenshare && ( + + screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true) + } + /> + )} {/* Mute-screenshare-audio sits directly next to the screenshare control since they're the same concern. */} { return false; }; -/** - * Bind listeners to the EC iframe's window and keep them bound across document - * reloads. [Gitea #60] Listeners registered on a contentWindow are discarded - * when that window navigates to a new document (crash recovery, in-widget - * navigation), so re-run `bind` from the iframe's `load` event and detach the - * previous set. Returns a cleanup that detaches everything. - */ -const bindIframeWindow = ( - iframe: HTMLIFrameElement, - bind: (win: Window) => () => void, -): (() => void) => { - let unbind: (() => void) | undefined; - const attach = () => { - unbind?.(); - const win = iframe.contentWindow; - unbind = win ? bind(win) : undefined; - }; - attach(); - iframe.addEventListener('load', attach); - return () => { - iframe.removeEventListener('load', attach); - unbind?.(); - unbind = undefined; - }; -}; - /** * Push-to-talk and deafen hotkeys for the active call. * @@ -150,20 +124,6 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean const { microphone } = useCallControlState(embed?.control); const setPttActive = useSetAtom(pttActiveAtom); - // [Gitea #43] Once the fork reports it handles hotkeys, keys pressed inside - // the call frame arrive as io.lotus.hotkey and we stop adding listeners to - // the frame's window (which needs same-origin access). - const [forkHotkeys, setForkHotkeys] = useState(false); - useEffect(() => { - if (!embed) { - setForkHotkeys(false); - return undefined; - } - const sync = () => setForkHotkeys(embed.control.forkHandlesHotkeys); - sync(); - return embed.control.onForkHotkeysChange(sync); - }, [embed]); - // Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn) const microphoneRef = useRef(microphone); useEffect(() => { @@ -268,25 +228,23 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean }; }; const unbindHost = bind(window); - // BUG-9: PTT also works with focus in the call frame — reported by the - // fork, or (older fork) via listeners on the frame's window. + // BUG-9: PTT also works with focus in the call frame, reported by the + // fork (io.lotus.hotkey; the host no longer listens on the frame's window). embed.control.setHotkeyCodes('ptt', [pttKey]); const unbindFork = embed.control.onHotkey((r) => { if (r.type === 'focus') onFocusChange(); else if (r.type === 'keydown') pttDown(r, r.editable, r.interactive); else if (r.code === pttKey) release(); }); - const unbindIframe = forkHotkeys ? () => undefined : bindIframeWindow(embed.iframe, bind); return () => { unbindHost(); unbindFork(); - unbindIframe(); embed.control.setHotkeyCodes('ptt', []); // BUG-8: if the embed changes while PTT is active, release mic on cleanup if (pttActiveRef.current) release(); }; // microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn - }, [pttMode, pttKey, embed, setPttActive, forkHotkeys]); + }, [pttMode, pttKey, embed, setPttActive]); // [cinny-desktop #2] System-wide PTT/deafen while a game has focus. The // desktop polls the configured keys without consuming them and emits one @@ -366,17 +324,10 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean embed.control.toggleSound(); } }); - const unbindIframe = forkHotkeys - ? () => undefined - : bindIframeWindow(embed.iframe, (win) => { - win.addEventListener('keydown', onKeyDown); - return () => win.removeEventListener('keydown', onKeyDown); - }); return () => { window.removeEventListener('keydown', onKeyDown); unbindFork(); - unbindIframe(); embed.control.setHotkeyCodes('deafen', []); }; - }, [embed, deafenKey, deafenHotkey, forkHotkeys]); + }, [embed, deafenKey, deafenHotkey]); } diff --git a/src/app/hooks/useCallSpeakers.ts b/src/app/hooks/useCallSpeakers.ts index 3c5a3d255..9a9b10cd5 100644 --- a/src/app/hooks/useCallSpeakers.ts +++ b/src/app/hooks/useCallSpeakers.ts @@ -1,25 +1,16 @@ import { useEffect, useState } from 'react'; import { CallEmbed } from '../plugins/call'; -import { getCallDocument } from '../plugins/call/utils'; -import { isUserId } from '../utils/matrix'; import { nextSpeakerSet } from '../utils/speakerSet'; import { useCallMembers, useCallSession } from './useCall'; import { useCallJoined } from './useCallEmbed'; /** - * Returns the set of Matrix user IDs currently speaking in the Element Call - * iframe. + * Returns the set of Matrix user IDs currently speaking in the call, from the + * fork's io.lotus.call_state reports ([lotus #2]). * - * EC renders each participant's video tile with a `[data-video-fit]` wrapper. - * When a participant is speaking, EC draws a speaking indicator via the tile's - * `::before` pseudo-element `background-image` (anything other than `none`). - * The participant's Matrix user ID is exposed on the first descendant carrying - * an `aria-label`. - * - * We watch the whole iframe document so tiles added/removed mid-call are picked - * up automatically, and on every relevant mutation we re-scan ALL `[data-video-fit]` - * tiles and rebuild the set from the full current DOM state (rather than just the - * tiles in the mutation batch). + * [Gitea #43] The fallback that scraped EC's rendered tiles (the speaking + * ring's `::before` background) is gone: the bundled fork always reports call + * state, and the host no longer reads the call frame's DOM. */ export const useCallSpeakers = (callEmbed: CallEmbed): Set => { const [speakers, setSpeakers] = useState(new Set()); @@ -28,9 +19,8 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set => { const joined = useCallJoined(callEmbed); // C-L5 — depend on a STABLE boolean, not the callMembers array (whose identity - // changes on every membership change). The MutationObserver + io.lotus.call_state - // subscription below already track tiles joining/leaving live, so rebuilding - // them on each membership change is pure churn. + // changes on every membership change). The io.lotus.call_state subscription + // below already tracks participants joining/leaving live. const hasCallMembers = callMembers.length > 0; useEffect(() => { @@ -39,123 +29,22 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set => { return undefined; } - const getDoc = (): Document | undefined => getCallDocument(callEmbed.iframe); - - let tileObserver: MutationObserver | undefined; - - const detachTileObserver = (): void => { - tileObserver?.disconnect(); - tileObserver = undefined; - }; - - // #32 — only attach the DOM fallback observer while the fork isn't - // supplying usable speaker data; it stays disconnected for the rest of - // the call once io.lotus.call_state starts reporting participants. - const attachTileObserver = (): void => { - if (tileObserver) return; - const doc = getDoc(); - if (!doc?.body) return; - // Watch the whole document for attribute changes on tiles (which carry - // the speaking indicator) and for new tiles being added/removed. - tileObserver = new MutationObserver((mutations) => { - const relevant = mutations.some( - (m) => - m.type === 'attributes' || - (m.type === 'childList' && - (Array.from(m.addedNodes).some( - (n) => n instanceof Element && n.querySelector('[data-video-fit]'), - ) || - Array.from(m.removedNodes).some( - (n) => n instanceof Element && n.querySelector('[data-video-fit]'), - ))), - ); - if (relevant) syncState(); - }); - tileObserver.observe(doc.body, { - subtree: true, - childList: true, - attributes: true, - attributeFilter: ['class', 'style'], - }); - }; - const syncState = (): void => { - // [lotus #2] Prefer the fork's io.lotus.call_state events over scraping - // EC's rendered DOM. Falls back to the DOM path below when the fork hasn't - // sent yet (null) OR sent a spurious empty list (you're always present in - // your own joined call, so [] means "no usable data", not "nobody"). - const lotus = callEmbed.getLotusParticipants(); - if (lotus !== null && lotus.length > 0) { - detachTileObserver(); - // #32 — bail out of setState (and the re-render it causes) when the - // derived set is unchanged from the previous one. - setSpeakers((prev) => nextSpeakerSet(prev, lotus)); - return; - } - const doc = getDoc(); - if (!doc) { - setSpeakers(new Set()); - return; - } - // Fork gave no usable data (older fork, or hasn't sent yet) — fall back - // to scraping the DOM, and keep watching it for changes. - attachTileObserver(); - const s = new Set(); - // Re-scan every tile on each mutation and build the set from the full - // current DOM state, not just the tiles that mutated this batch. - const tiles = doc.querySelectorAll('[data-video-fit]'); - tiles.forEach((el) => { - const style = callEmbed.iframe.contentWindow?.getComputedStyle(el, '::before'); - if (!style) return; - const tileBackgroundImage = style.getPropertyValue('background-image'); - const speaking = tileBackgroundImage !== 'none'; - if (!speaking) return; - - const speakerId = el.querySelector('[aria-label]')?.getAttribute('aria-label'); - if (speakerId && isUserId(speakerId)) { - s.add(speakerId); - } - }); - setSpeakers(s); + // #32 — bail out of setState (and the re-render it causes) when the + // derived set is unchanged from the previous one. + setSpeakers((prev) => nextSpeakerSet(prev, callEmbed.getLotusParticipants() ?? [])); }; syncState(); - // [lotus #2] Re-derive whenever the fork pushes new call-state. - const unsubLotus = callEmbed.onLotusCallState(syncState); - - // If iframe isn't ready yet, wait for body to be available. - let bodyWatcher: MutationObserver | undefined; - if (!getDoc()?.body) { - bodyWatcher = new MutationObserver(() => { - if (getDoc()?.body) { - bodyWatcher?.disconnect(); - bodyWatcher = undefined; - syncState(); - } - }); - const doc = getDoc(); - if (doc) bodyWatcher.observe(doc, { childList: true }); - } - - return () => { - detachTileObserver(); - bodyWatcher?.disconnect(); - unsubLotus(); - }; + return callEmbed.onLotusCallState(syncState); }, [callEmbed, hasCallMembers, joined]); return speakers; }; /** - * Returns true when any REMOTE participant has their microphone muted in the - * Element Call iframe. - * - * EC renders a mute-icon element per participant tile with a `data-muted` - * attribute ("true" = muted, "false" = unmuted) and an `aria-label` set to - * the participant's Matrix user ID. We watch for attribute changes on all - * `[data-muted]` elements, filter out the local user, and return true if any - * remaining participant is muted. + * Returns true when there is at least one REMOTE participant and every one of + * them has their microphone muted, from the fork's io.lotus.call_state reports. */ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean => { const [muted, setMuted] = useState(false); @@ -163,105 +52,17 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean => useEffect(() => { if (!callEmbed) return undefined; - const getDoc = (): Document | undefined => getCallDocument(callEmbed.iframe); - const localUserId = callEmbed.room.client?.getUserId() ?? ''; - let tileObserver: MutationObserver | undefined; - - const detachTileObserver = (): void => { - tileObserver?.disconnect(); - tileObserver = undefined; - }; - - // #32 — only attach the DOM fallback observer while the fork isn't - // supplying usable participant data; it stays disconnected for the rest - // of the call once io.lotus.call_state starts reporting participants. - const attachTileObserver = (): void => { - if (tileObserver) return; - const doc = getDoc(); - if (!doc?.body) return; - // Watch the whole document for attribute changes on data-muted elements - // and for new tiles being added/removed. - tileObserver = new MutationObserver((mutations) => { - const relevant = mutations.some( - (m) => - m.type === 'attributes' || - (m.type === 'childList' && - (Array.from(m.addedNodes).some( - (n) => n instanceof Element && n.querySelector('[data-muted]'), - ) || - Array.from(m.removedNodes).some( - (n) => n instanceof Element && n.querySelector('[data-muted]'), - ))), - ); - if (relevant) syncState(); - }); - tileObserver.observe(doc.body, { - subtree: true, - childList: true, - attributes: true, - attributeFilter: ['data-muted'], - }); - }; - const syncState = (): void => { - // [lotus #2] Prefer the fork's io.lotus.call_state over DOM scraping; - // ignore a spurious empty list (fall back to DOM). - const lotus = callEmbed.getLotusParticipants(); - if (lotus !== null && lotus.length > 0) { - detachTileObserver(); - const remote = lotus.filter((p) => p.userId !== localUserId); - setMuted(remote.length > 0 && remote.every((p) => !p.audioEnabled)); - return; - } - const doc = getDoc(); - if (!doc) { - setMuted(false); - return; - } - // Fork gave no usable data (older fork, or hasn't sent yet) — fall back - // to scraping the DOM, and keep watching it for changes. - attachTileObserver(); - // Each participant's mute icon has data-muted="true"|"false" and - // aria-label set to their Matrix user ID. - const muteIcons = doc.querySelectorAll('[data-muted]'); - let remoteCount = 0; - let remoteMutedCount = 0; - muteIcons.forEach((el) => { - const userId = el.getAttribute('aria-label') ?? ''; - if (userId === localUserId) return; - remoteCount += 1; - if (el.getAttribute('data-muted') === 'true') remoteMutedCount += 1; - }); - // "All muted" badge: true only when there is at least one remote - // participant and every one of them is muted (not merely any single one). - setMuted(remoteCount > 0 && remoteMutedCount === remoteCount); + const remote = (callEmbed.getLotusParticipants() ?? []).filter( + (p) => p.userId !== localUserId, + ); + setMuted(remote.length > 0 && remote.every((p) => !p.audioEnabled)); }; syncState(); - // [lotus #2] Re-derive whenever the fork pushes new call-state. - const unsubLotus = callEmbed.onLotusCallState(syncState); - - // If iframe isn't ready yet, wait for body to be available. - let bodyWatcher: MutationObserver | undefined; - if (!getDoc()?.body) { - bodyWatcher = new MutationObserver(() => { - if (getDoc()?.body) { - bodyWatcher?.disconnect(); - bodyWatcher = undefined; - syncState(); - } - }); - const doc = getDoc(); - if (doc) bodyWatcher.observe(doc, { childList: true }); - } - - return () => { - detachTileObserver(); - bodyWatcher?.disconnect(); - unsubLotus(); - }; + return callEmbed.onLotusCallState(syncState); }, [callEmbed]); return muted; diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index 10cc8c2e8..82a93d561 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -2,7 +2,7 @@ import { ClientWidgetApi } from 'matrix-widget-api'; import { EventEmitter } from 'events'; import { CallControlState } from './CallControlState'; import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types'; -import { getCallDocument } from './utils'; +import { canDelegateCapability } from './utils'; export enum CallControlEvent { StateUpdate = 'state_update', @@ -54,16 +54,6 @@ export function parseForkHotkeyReport(data: unknown): ForkHotkeyReport | null { }; } -/** - * Capability Delegation (`postMessage(msg, { delegate })`) ships only in - * Chromium; other engines silently ignore the option, so the frame would get - * no activation. `navigator.userAgentData` is likewise Chromium-only, which - * makes it the practical feature test. - */ -function canDelegateCapability(): boolean { - return typeof navigator !== 'undefined' && 'userAgentData' in navigator; -} - export class CallControl extends EventEmitter implements CallControlState { private state: CallControlState; @@ -71,14 +61,6 @@ export class CallControl extends EventEmitter implements CallControlState { private iframe: HTMLIFrameElement; - private bodyMutationObserver: MutationObserver; - - private controlMutationObserver: MutationObserver; - - // C-H3: coalesces bursts of body-subtree mutations into a single debounced - // re-observe pass so a busy EC re-render doesn't thrash the control observer. - private bodyMutationTimer?: ReturnType; - // [Gitea #56] Tracks the participant currently pinned via focusCameraParticipant(), // so callers (MemberGlance) can render a "Focus camera" / "Unfocus camera" toggle // instead of a one-way pin with no way back. null == no manual pin (speaker-follows). @@ -107,68 +89,12 @@ export class CallControl extends EventEmitter implements CallControlState { // timeout — io.lotus toWidget actions must only be sent after call-join). private joined = false; - private get document(): Document | undefined { - return getCallDocument(this.iframe); - } - - private get screenshareButton(): HTMLElement | undefined { - const screenshareBtn = this.document?.querySelector( - '[data-testid="incall_screenshare"]', - ) as HTMLElement | null; - - return screenshareBtn ?? undefined; - } - - private get leaveButton(): Element | undefined { - const leaveBtn = this.document?.querySelector('[data-testid="incall_leave"]'); - - return leaveBtn ?? undefined; - } - - private get settingsButton(): HTMLElement | undefined { - // EC 0.20.1: settings button moved to bottom-left; fall back to bottom-center. - const settingsButtonLeft = this.document?.querySelector( - '[data-testid="settings-bottom-left"]', - ) as HTMLButtonElement | undefined; - const settingsButtonCenter = this.document?.querySelector( - '[data-testid="settings-bottom-center"]', - ) as HTMLButtonElement | undefined; - - return settingsButtonLeft ?? settingsButtonCenter ?? undefined; - } - - private get reactionsButton(): HTMLElement | undefined { - // EC 0.20.1: reactions/raise-hand button sits just before the leave button. - const reactionsButton = this.leaveButton?.previousElementSibling as HTMLElement | null; - - return reactionsButton ?? undefined; - } - - private get spotlightButton(): HTMLInputElement | undefined { - const spotlightButton = this.document?.querySelector( - 'input[value="spotlight"]', - ) as HTMLInputElement | null; - - return spotlightButton ?? undefined; - } - - private get gridButton(): HTMLInputElement | undefined { - const gridButton = this.document?.querySelector( - 'input[value="grid"]', - ) as HTMLInputElement | null; - - return gridButton ?? undefined; - } - constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) { super(); this.state = state; this.call = call; this.iframe = iframe; - - this.bodyMutationObserver = new MutationObserver(this.onBodyMutation.bind(this)); - this.controlMutationObserver = new MutationObserver(this.onControlMutation.bind(this)); } public getState(): CallControlState { @@ -225,6 +151,8 @@ export class CallControl extends EventEmitter implements CallControlState { this.joined = true; this.sendDeafenState(); this.sendQuality(); + this.sendHotkeyCodes(); + this.sendFrameScreenshareAllowed(); } /** @@ -240,55 +168,7 @@ export class CallControl extends EventEmitter implements CallControlState { // [Gitea #17] The pin lives fork-side and is dropped on a handler remount. if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId); this.sendHotkeyCodes(); - } - - public startObserving() { - if (!this.document) return; - - // C-H3: watch the whole body subtree (not just direct children) so we - // re-bind the control observer when EC re-renders its controls deeper in the - // tree. Debounced via onBodyMutation() to avoid thrashing on busy renders. - this.bodyMutationObserver.observe(this.document.body, { - childList: true, - subtree: true, - }); - this.applyBodyMutation(); - } - - private onBodyMutation() { - // C-H3: coalesce a burst of subtree mutations into one debounced pass. - if (this.bodyMutationTimer !== undefined) return; - this.bodyMutationTimer = setTimeout(() => { - this.bodyMutationTimer = undefined; - this.applyBodyMutation(); - }, 100); - } - - private applyBodyMutation() { - if (!this.document) return; - // Hiding EC's footer and the transparent background are the fork's job now - // (lotusHostControls / lotusTransparent URL flags, Gitea #43). - this.observeControls(); - } - - private observeControls() { - this.controlMutationObserver.disconnect(); - - const screenshareBtn = this.screenshareButton; - if (screenshareBtn) { - this.controlMutationObserver.observe(screenshareBtn, { - attributes: true, - attributeFilter: ['data-kind'], - }); - } - const spotlightBtn = this.spotlightButton; - if (spotlightBtn) { - this.controlMutationObserver.observe(spotlightBtn, { - attributes: true, - }); - } - - this.onControlMutation(); + this.sendFrameScreenshareAllowed(); } private async setMediaState(state: ElementMediaStatePayload) { @@ -369,39 +249,52 @@ export class CallControl extends EventEmitter implements CallControlState { } } - // [Gitea #43] Set once the fork reports io.lotus.controls_state: from then on - // layout / settings / reactions go over the widget API and screenshare + - // layout state come from that report, not from EC's DOM. Older forks never - // send it and keep the DOM path below. - private forkControls = false; + // [Gitea #43] The fork has reported io.lotus.controls_state, so its Lotus + // action handlers are mounted. Screenshare and layout state come from that + // report; the host never reads or clicks EC's DOM. + private forkReady = false; - // [Gitea #43] The fork handles io.lotus.set_screenshare (reported in - // controls_state). Used only where Capability Delegation exists. - private forkScreenshare = false; + // [Gitea #43] The fork shows EC's own screenshare button inside the frame + // (lotusFrameScreenshare: no Capability Delegation here), so the host bar + // hides its own. + private _frameScreenshare = false; - // [Gitea #43] The fork reports call hotkeys pressed inside its frame - // (io.lotus.set_hotkeys → io.lotus.hotkey), so the host stops adding key - // listeners to the frame's window. - private forkHotkeys = false; + private frameScreenshareListeners = new Set<() => void>(); + + // [Gitea #43] Whether the room's call policy allows screensharing; the fork + // hides its in-frame button when it doesn't. + private frameScreenshareAllowed = true; private hotkeyCodes = new Map(); private hotkeyListeners = new Set<(report: ForkHotkeyReport) => void>(); - private forkHotkeysListeners = new Set<() => void>(); - - public get forkHandlesHotkeys(): boolean { - return this.forkHotkeys; + /** EC's own screenshare button is shown in the frame instead of ours. */ + public get frameScreenshare(): boolean { + return this._frameScreenshare; } - /** Subscribe to `forkHandlesHotkeys` turning on. Returns an unsubscribe. */ - public onForkHotkeysChange(cb: () => void): () => void { - this.forkHotkeysListeners.add(cb); + /** Subscribe to `frameScreenshare` changes. Returns an unsubscribe. */ + public onFrameScreenshareChange(cb: () => void): () => void { + this.frameScreenshareListeners.add(cb); return () => { - this.forkHotkeysListeners.delete(cb); + this.frameScreenshareListeners.delete(cb); }; } + /** Room call policy for the in-frame screenshare button. */ + public setFrameScreenshareAllowed(allowed: boolean): void { + this.frameScreenshareAllowed = allowed; + this.sendFrameScreenshareAllowed(); + } + + private sendFrameScreenshareAllowed(): void { + if (!this.joined || !this.forkReady || !this._frameScreenshare) return; + this.sendForkAction('io.lotus.set_frame_screenshare', { + visible: this.frameScreenshareAllowed, + }); + } + /** Key codes `source` (e.g. 'ptt', 'deafen') wants reported from the frame. */ public setHotkeyCodes(source: string, codes: string[]): void { this.hotkeyCodes.set(source, codes); @@ -409,7 +302,7 @@ export class CallControl extends EventEmitter implements CallControlState { } private sendHotkeyCodes(): void { - if (!this.joined || !this.forkHotkeys) return; + if (!this.joined || !this.forkReady) return; const codes = [...new Set([...this.hotkeyCodes.values()].flat())]; this.call.transport.send('io.lotus.set_hotkeys', { codes }).catch(() => undefined); } @@ -431,18 +324,21 @@ 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, screenshareAction, hotkeys } = data as { + const { screensharing, layout, frameScreenshare } = data as { screensharing?: unknown; layout?: unknown; - screenshareAction?: unknown; - hotkeys?: unknown; + frameScreenshare?: unknown; }; - this.forkControls = true; - this.forkScreenshare = screenshareAction === true; - if (hotkeys === true && !this.forkHotkeys) { - this.forkHotkeys = true; + const firstReport = !this.forkReady; + this.forkReady = true; + const frame = frameScreenshare === true; + if (frame !== this._frameScreenshare) { + this._frameScreenshare = frame; + this.frameScreenshareListeners.forEach((l) => l()); + } + if (firstReport) { this.sendHotkeyCodes(); - this.forkHotkeysListeners.forEach((l) => l()); + this.sendFrameScreenshareAllowed(); } this.applyControls( typeof screensharing === 'boolean' ? screensharing : this.screenshare, @@ -450,13 +346,6 @@ export class CallControl extends EventEmitter implements CallControlState { ); } - private onControlMutation() { - if (this.forkControls) return; - const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary'; - const spotlight: boolean = this.spotlightButton?.checked ?? false; - this.applyControls(screenshare, spotlight); - } - private applyControls(screenshare: boolean, spotlight: boolean) { const wasScreensharing = this.screenshare; @@ -560,16 +449,17 @@ export class CallControl extends EventEmitter implements CallControlState { * Must be called synchronously inside the user's click: starting a share * calls getDisplayMedia in the frame, which needs that click. Where the * browser can hand the click over (Capability Delegation, Chromium incl. - * WebView2) this goes over the widget API; elsewhere (Firefox, Safari, - * WebKitGTK desktop) it still clicks EC's hidden button, which needs - * same-origin access to the frame. + * WebView2) it is sent with delegation. Elsewhere the host bar hides its + * button and EC's own shows in the frame (`frameScreenshare`); a plain send + * still stops a share, which needs no click. */ public toggleScreenshare() { - if (this.forkScreenshare && canDelegateCapability()) { - this.sendDelegated('io.lotus.set_screenshare', { on: !this.screenshare }, 'display-capture'); + const data = { on: !this.screenshare }; + if (canDelegateCapability()) { + this.sendDelegated('io.lotus.set_screenshare', data, 'display-capture'); return; } - this.screenshareButton?.click(); + this.sendForkAction('io.lotus.set_screenshare', data); } /** @@ -602,34 +492,18 @@ export class CallControl extends EventEmitter implements CallControlState { } public toggleSpotlight() { - if (this.forkControls) { - this.sendForkAction('io.lotus.set_layout', { - layout: this.spotlight ? 'grid' : 'spotlight', - }); - return; - } - if (this.spotlight) { - this.gridButton?.click(); - return; - } - this.spotlightButton?.click(); + this.sendForkAction('io.lotus.set_layout', { + layout: this.spotlight ? 'grid' : 'spotlight', + }); } public toggleReactions() { - if (this.forkControls) { - this.sendForkAction('io.lotus.toggle_reactions', {}); - return; - } - this.reactionsButton?.click(); + this.sendForkAction('io.lotus.toggle_reactions', {}); } public toggleSettings() { - if (this.forkControls) { - // Same as clicking EC's settings button: opens the modal. - this.sendForkAction('io.lotus.open_settings', { open: true }); - return; - } - this.settingsButton?.click(); + // Same as clicking EC's settings button: opens the modal. + this.sendForkAction('io.lotus.open_settings', { open: true }); } private sendForkAction(action: string, data: Record): void { @@ -731,14 +605,8 @@ export class CallControl extends EventEmitter implements CallControlState { } public dispose() { - if (this.bodyMutationTimer !== undefined) { - clearTimeout(this.bodyMutationTimer); - this.bodyMutationTimer = undefined; - } // [Gitea #56] Don't let a manual focus pin outlive the call. this.clearFocusParticipant(); - this.bodyMutationObserver.disconnect(); - this.controlMutationObserver.disconnect(); } private emitStateUpdate() { diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index a13043d8f..ba78559bb 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -28,7 +28,7 @@ import { import { CallControl } from './CallControl'; import { CallControlState } from './CallControlState'; import { verifyDenoiseAssets } from './denoiseSmokeCheck'; -import { getCallDocument } from './utils'; +import { canDelegateCapability } from './utils'; // Maximum time to wait for the embedded Element Call iframe to progress from // initial load to a ready/joined state. If it hasn't by then, we assume the @@ -218,6 +218,11 @@ export class CallEmbed { // [Gitea #43] The fork hides its own footer (we draw the call bar) and // sets its root color-scheme from the theme, instead of us injecting CSS. lotusHostControls: 'true', + // [Gitea #43] Engines without Capability Delegation (Firefox, Safari, + // WebKitGTK) can't start a share from our call bar: getDisplayMedia + // needs the click inside the frame. There the fork shows EC's own + // screenshare button and we hide ours. + ...(canDelegateCapability() ? {} : { lotusFrameScreenshare: 'true' }), // [lotus #3 / P5-15] Arm the fork's audio-inject handler so the in-call // soundboard can publish clips into the call. Dormant until the host // sends io.lotus.inject_audio (only on an explicit user click), so @@ -311,10 +316,6 @@ export class CallEmbed { const controlState = initialControlState ?? new CallControlState(true, false, true); this.control = new CallControl(controlState, call, iframe); this.initialState = controlState; - this.control.startObserving(); - iframe.onload = () => { - this.control.startObserving(); - }; // If the iframe document itself fails to load, fail fast. iframe.onerror = () => { this.settleLoad('iframe'); @@ -348,10 +349,6 @@ export class CallEmbed { return this.room.roomId; } - get document(): Document | undefined { - return getCallDocument(this.iframe); - } - public setTheme(theme: ElementCallThemeKind) { this.themeKind = theme; return this.call.transport @@ -573,13 +570,12 @@ export class CallEmbed { private onCallJoined(): void { this.settleLoad(); - this.control.startObserving(); // C-H1: EC fires JoinCall again on an EC reconnect (this action has no // once-guard). forceState() would reset live mic/video/deafen back to the // join-time snapshot, so only run it on the FIRST join. On a rejoin we just - // re-apply styles/observers (above) and re-push the sticky fork state - // (deafen/quality), leaving the user's live media state untouched. + // re-push the sticky fork state (deafen/quality), leaving the user's live + // media state untouched. if (this.joined) { this.control.resendForkState(); return; diff --git a/src/app/plugins/call/hooks.ts b/src/app/plugins/call/hooks.ts index ac03355b1..ba227d5c6 100644 --- a/src/app/plugins/call/hooks.ts +++ b/src/app/plugins/call/hooks.ts @@ -73,3 +73,21 @@ export const useCallMicLevel = (callEmbed: CallEmbed | undefined): number => { ); return useSyncExternalStore(subscribe, () => callEmbed?.getMicLevel() ?? 0); }; + +/** + * [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. + */ +export const useFrameScreenshare = (control: CallControl | undefined): boolean => { + const [frame, setFrame] = useState(() => control?.frameScreenshare ?? false); + useEffect(() => { + if (!control) { + setFrame(false); + return undefined; + } + const sync = () => setFrame(control.frameScreenshare); + sync(); + return control.onFrameScreenshareChange(sync); + }, [control]); + return frame; +}; diff --git a/src/app/plugins/call/utils.ts b/src/app/plugins/call/utils.ts index 8ab35377a..42375fa28 100644 --- a/src/app/plugins/call/utils.ts +++ b/src/app/plugins/call/utils.ts @@ -118,17 +118,10 @@ export function getCallCapabilities( } /** - * The EC iframe's document, or undefined when it cannot be read. The widget is - * same-origin, but when its navigation fails (offline, blocked) the frame - * becomes a cross-origin error page and `contentWindow.document` THROWS a - * SecurityError — which surfaced as page errors (and a React "Should not - * already be working" cascade) from every DOM-driven call hook the moment the - * load watchdog fired. Treat "can't read" the same as "not loaded yet". + * Capability Delegation (`postMessage(msg, { delegate })`) ships only in + * Chromium; other engines silently ignore the option, so the frame would get + * no activation. `navigator.userAgentData` is likewise Chromium-only, which + * makes it the practical feature test. */ -export const getCallDocument = (iframe: HTMLIFrameElement): Document | undefined => { - try { - return iframe.contentDocument ?? iframe.contentWindow?.document ?? undefined; - } catch { - return undefined; - } -}; +export const canDelegateCapability = (): boolean => + typeof navigator !== 'undefined' && 'userAgentData' in navigator;