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;