feat(call): screenshare from inside the frame where delegation is missing; host stops reading the call frame (#43)
CI / Build & Quality Checks (push) Successful in 4m32s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 16m32s
CI / Build & Quality Checks (push) Successful in 4m32s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 16m32s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ba48e95993
commit
49dec686f1
Generated
+4
-4
@@ -81,7 +81,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@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",
|
"@playwright/test": "1.63.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
@@ -2695,9 +2695,9 @@
|
|||||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||||
},
|
},
|
||||||
"node_modules/@lotusguild/element-call-embedded": {
|
"node_modules/@lotusguild/element-call-embedded": {
|
||||||
"version": "0.25.0-lotus.19",
|
"version": "0.25.0-lotus.20",
|
||||||
"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",
|
"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-xNWgja9PHeDuSgo6fvoI1BJkYHof0xJ9kkLnURqPkrHV0o1UagHgaYcFgbFZMtDsoPnz5UrOGleLsjFo7d5/wQ==",
|
"integrity": "sha512-USac8cunc+nlkyivxV0dXshzJoSWTyNHc6Aq/xIpphKbkON05NzhEWBX+qNwgwdyVWacdmYAEX97z6jHA5J+Kw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||||
|
|||||||
+1
-1
@@ -108,7 +108,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@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",
|
"@playwright/test": "1.63.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
|||||||
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
||||||
import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
|
import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
|
||||||
import { useLivekitSupport } from '../hooks/useLivekitSupport';
|
import { useLivekitSupport } from '../hooks/useLivekitSupport';
|
||||||
|
import { useRoomCallPolicy } from '../hooks/useRoomCallPolicy';
|
||||||
import { useNotificationsQuiet } from '../hooks/useNotificationsQuiet';
|
import { useNotificationsQuiet } from '../hooks/useNotificationsQuiet';
|
||||||
import { CallAvatarAnimation } from '../styles/Animations.css';
|
import { CallAvatarAnimation } from '../styles/Animations.css';
|
||||||
import { webRTCSupported } from '../utils/rtc';
|
import { webRTCSupported } from '../utils/rtc';
|
||||||
@@ -758,6 +759,12 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
|||||||
useCallPolicyRevokedToast(embed, joined);
|
useCallPolicyRevokedToast(embed, joined);
|
||||||
useCallEndedToast(embed);
|
useCallEndedToast(embed);
|
||||||
useScreenshareNotices(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();
|
usePttHaptics();
|
||||||
useCallAnnouncements(embed, joined);
|
useCallAnnouncements(embed, joined);
|
||||||
useMutedTalkWarning(embed, joined);
|
useMutedTalkWarning(embed, joined);
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import React, { useCallback, useState } from 'react';
|
|||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import { MicLevelBars } from '../call/MicLevelBars';
|
import { MicLevelBars } from '../call/MicLevelBars';
|
||||||
import { StatusDivider } from './components';
|
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 { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { callEmbedAtom } from '../../state/callEmbed';
|
import { callEmbedAtom } from '../../state/callEmbed';
|
||||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
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
|
// Keep a forbidden control visible while its track is still live (so the user
|
||||||
// can stop it); otherwise hide it entirely.
|
// can stop it); otherwise hide it entirely.
|
||||||
const showCamera = allowCamera || video;
|
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 [shareConfirm, setShareConfirm] = useState(false);
|
||||||
|
|
||||||
const handleMicrophoneToggle = useCallback(
|
const handleMicrophoneToggle = useCallback(
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ import {
|
|||||||
SoundButton,
|
SoundButton,
|
||||||
VideoButton,
|
VideoButton,
|
||||||
} from './Controls';
|
} from './Controls';
|
||||||
import { CallEmbed, useCallControlState, useCallMicLevel } from '../../plugins/call';
|
import {
|
||||||
|
CallEmbed,
|
||||||
|
useCallControlState,
|
||||||
|
useCallMicLevel,
|
||||||
|
useFrameScreenshare,
|
||||||
|
} from '../../plugins/call';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { callEmbedAtom } from '../../state/callEmbed';
|
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
|
// Keep a forbidden control visible while its track is still live (so the user
|
||||||
// can stop it); otherwise hide it entirely.
|
// can stop it); otherwise hide it entirely.
|
||||||
const showCamera = allowCamera || video;
|
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 showScreenshare = allowScreenshare || screenshare;
|
||||||
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
||||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
@@ -216,12 +225,14 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||||||
{showScreenshare && (
|
{showScreenshare && (
|
||||||
<>
|
<>
|
||||||
<ScreenShareButton
|
{!frameScreenshare && (
|
||||||
enabled={screenshare}
|
<ScreenShareButton
|
||||||
onToggle={() =>
|
enabled={screenshare}
|
||||||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
onToggle={() =>
|
||||||
}
|
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/* Mute-screenshare-audio sits directly next to the screenshare
|
{/* Mute-screenshare-audio sits directly next to the screenshare
|
||||||
control since they're the same concern. */}
|
control since they're the same concern. */}
|
||||||
<ScreenshareAudioButton
|
<ScreenshareAudioButton
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { atom, useSetAtom } from 'jotai';
|
import { atom, useSetAtom } from 'jotai';
|
||||||
import { CallEmbed, useCallControlState } from '../plugins/call';
|
import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
@@ -105,32 +105,6 @@ const isInteractive = (el: HTMLElement): boolean => {
|
|||||||
return false;
|
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.
|
* 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 { microphone } = useCallControlState(embed?.control);
|
||||||
const setPttActive = useSetAtom(pttActiveAtom);
|
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)
|
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
|
||||||
const microphoneRef = useRef(microphone);
|
const microphoneRef = useRef(microphone);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -268,25 +228,23 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
const unbindHost = bind(window);
|
const unbindHost = bind(window);
|
||||||
// BUG-9: PTT also works with focus in the call frame — reported by the
|
// 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.
|
// fork (io.lotus.hotkey; the host no longer listens on the frame's window).
|
||||||
embed.control.setHotkeyCodes('ptt', [pttKey]);
|
embed.control.setHotkeyCodes('ptt', [pttKey]);
|
||||||
const unbindFork = embed.control.onHotkey((r) => {
|
const unbindFork = embed.control.onHotkey((r) => {
|
||||||
if (r.type === 'focus') onFocusChange();
|
if (r.type === 'focus') onFocusChange();
|
||||||
else if (r.type === 'keydown') pttDown(r, r.editable, r.interactive);
|
else if (r.type === 'keydown') pttDown(r, r.editable, r.interactive);
|
||||||
else if (r.code === pttKey) release();
|
else if (r.code === pttKey) release();
|
||||||
});
|
});
|
||||||
const unbindIframe = forkHotkeys ? () => undefined : bindIframeWindow(embed.iframe, bind);
|
|
||||||
return () => {
|
return () => {
|
||||||
unbindHost();
|
unbindHost();
|
||||||
unbindFork();
|
unbindFork();
|
||||||
unbindIframe();
|
|
||||||
embed.control.setHotkeyCodes('ptt', []);
|
embed.control.setHotkeyCodes('ptt', []);
|
||||||
// BUG-8: if the embed changes while PTT is active, release mic on cleanup
|
// BUG-8: if the embed changes while PTT is active, release mic on cleanup
|
||||||
if (pttActiveRef.current) release();
|
if (pttActiveRef.current) release();
|
||||||
};
|
};
|
||||||
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
|
// 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
|
// [cinny-desktop #2] System-wide PTT/deafen while a game has focus. The
|
||||||
// desktop polls the configured keys without consuming them and emits one
|
// 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();
|
embed.control.toggleSound();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const unbindIframe = forkHotkeys
|
|
||||||
? () => undefined
|
|
||||||
: bindIframeWindow(embed.iframe, (win) => {
|
|
||||||
win.addEventListener('keydown', onKeyDown);
|
|
||||||
return () => win.removeEventListener('keydown', onKeyDown);
|
|
||||||
});
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', onKeyDown);
|
window.removeEventListener('keydown', onKeyDown);
|
||||||
unbindFork();
|
unbindFork();
|
||||||
unbindIframe();
|
|
||||||
embed.control.setHotkeyCodes('deafen', []);
|
embed.control.setHotkeyCodes('deafen', []);
|
||||||
};
|
};
|
||||||
}, [embed, deafenKey, deafenHotkey, forkHotkeys]);
|
}, [embed, deafenKey, deafenHotkey]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,16 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { CallEmbed } from '../plugins/call';
|
import { CallEmbed } from '../plugins/call';
|
||||||
import { getCallDocument } from '../plugins/call/utils';
|
|
||||||
import { isUserId } from '../utils/matrix';
|
|
||||||
import { nextSpeakerSet } from '../utils/speakerSet';
|
import { nextSpeakerSet } from '../utils/speakerSet';
|
||||||
import { useCallMembers, useCallSession } from './useCall';
|
import { useCallMembers, useCallSession } from './useCall';
|
||||||
import { useCallJoined } from './useCallEmbed';
|
import { useCallJoined } from './useCallEmbed';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the set of Matrix user IDs currently speaking in the Element Call
|
* Returns the set of Matrix user IDs currently speaking in the call, from the
|
||||||
* iframe.
|
* fork's io.lotus.call_state reports ([lotus #2]).
|
||||||
*
|
*
|
||||||
* EC renders each participant's video tile with a `[data-video-fit]` wrapper.
|
* [Gitea #43] The fallback that scraped EC's rendered tiles (the speaking
|
||||||
* When a participant is speaking, EC draws a speaking indicator via the tile's
|
* ring's `::before` background) is gone: the bundled fork always reports call
|
||||||
* `::before` pseudo-element `background-image` (anything other than `none`).
|
* state, and the host no longer reads the call frame's DOM.
|
||||||
* 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).
|
|
||||||
*/
|
*/
|
||||||
export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
||||||
const [speakers, setSpeakers] = useState(new Set<string>());
|
const [speakers, setSpeakers] = useState(new Set<string>());
|
||||||
@@ -28,9 +19,8 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
|||||||
const joined = useCallJoined(callEmbed);
|
const joined = useCallJoined(callEmbed);
|
||||||
|
|
||||||
// C-L5 — depend on a STABLE boolean, not the callMembers array (whose identity
|
// C-L5 — depend on a STABLE boolean, not the callMembers array (whose identity
|
||||||
// changes on every membership change). The MutationObserver + io.lotus.call_state
|
// changes on every membership change). The io.lotus.call_state subscription
|
||||||
// subscription below already track tiles joining/leaving live, so rebuilding
|
// below already tracks participants joining/leaving live.
|
||||||
// them on each membership change is pure churn.
|
|
||||||
const hasCallMembers = callMembers.length > 0;
|
const hasCallMembers = callMembers.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,123 +29,22 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
|||||||
return undefined;
|
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 => {
|
const syncState = (): void => {
|
||||||
// [lotus #2] Prefer the fork's io.lotus.call_state events over scraping
|
// #32 — bail out of setState (and the re-render it causes) when the
|
||||||
// EC's rendered DOM. Falls back to the DOM path below when the fork hasn't
|
// derived set is unchanged from the previous one.
|
||||||
// sent yet (null) OR sent a spurious empty list (you're always present in
|
setSpeakers((prev) => nextSpeakerSet(prev, callEmbed.getLotusParticipants() ?? []));
|
||||||
// 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<string>());
|
|
||||||
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<string>();
|
|
||||||
// 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<HTMLElement>('[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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
syncState();
|
syncState();
|
||||||
// [lotus #2] Re-derive whenever the fork pushes new call-state.
|
return callEmbed.onLotusCallState(syncState);
|
||||||
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();
|
|
||||||
};
|
|
||||||
}, [callEmbed, hasCallMembers, joined]);
|
}, [callEmbed, hasCallMembers, joined]);
|
||||||
|
|
||||||
return speakers;
|
return speakers;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true when any REMOTE participant has their microphone muted in the
|
* Returns true when there is at least one REMOTE participant and every one of
|
||||||
* Element Call iframe.
|
* them has their microphone muted, from the fork's io.lotus.call_state reports.
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean => {
|
export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean => {
|
||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(false);
|
||||||
@@ -163,105 +52,17 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean =>
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!callEmbed) return undefined;
|
if (!callEmbed) return undefined;
|
||||||
|
|
||||||
const getDoc = (): Document | undefined => getCallDocument(callEmbed.iframe);
|
|
||||||
|
|
||||||
const localUserId = callEmbed.room.client?.getUserId() ?? '';
|
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 => {
|
const syncState = (): void => {
|
||||||
// [lotus #2] Prefer the fork's io.lotus.call_state over DOM scraping;
|
const remote = (callEmbed.getLotusParticipants() ?? []).filter(
|
||||||
// ignore a spurious empty list (fall back to DOM).
|
(p) => p.userId !== localUserId,
|
||||||
const lotus = callEmbed.getLotusParticipants();
|
);
|
||||||
if (lotus !== null && lotus.length > 0) {
|
setMuted(remote.length > 0 && remote.every((p) => !p.audioEnabled));
|
||||||
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<HTMLElement>('[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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
syncState();
|
syncState();
|
||||||
// [lotus #2] Re-derive whenever the fork pushes new call-state.
|
return callEmbed.onLotusCallState(syncState);
|
||||||
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();
|
|
||||||
};
|
|
||||||
}, [callEmbed]);
|
}, [callEmbed]);
|
||||||
|
|
||||||
return muted;
|
return muted;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ClientWidgetApi } from 'matrix-widget-api';
|
|||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { CallControlState } from './CallControlState';
|
import { CallControlState } from './CallControlState';
|
||||||
import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types';
|
import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types';
|
||||||
import { getCallDocument } from './utils';
|
import { canDelegateCapability } from './utils';
|
||||||
|
|
||||||
export enum CallControlEvent {
|
export enum CallControlEvent {
|
||||||
StateUpdate = 'state_update',
|
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 {
|
export class CallControl extends EventEmitter implements CallControlState {
|
||||||
private state: CallControlState;
|
private state: CallControlState;
|
||||||
|
|
||||||
@@ -71,14 +61,6 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
|
|
||||||
private iframe: HTMLIFrameElement;
|
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<typeof setTimeout>;
|
|
||||||
|
|
||||||
// [Gitea #56] Tracks the participant currently pinned via focusCameraParticipant(),
|
// [Gitea #56] Tracks the participant currently pinned via focusCameraParticipant(),
|
||||||
// so callers (MemberGlance) can render a "Focus camera" / "Unfocus camera" toggle
|
// 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).
|
// 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).
|
// timeout — io.lotus toWidget actions must only be sent after call-join).
|
||||||
private joined = false;
|
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) {
|
constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.state = state;
|
this.state = state;
|
||||||
this.call = call;
|
this.call = call;
|
||||||
this.iframe = iframe;
|
this.iframe = iframe;
|
||||||
|
|
||||||
this.bodyMutationObserver = new MutationObserver(this.onBodyMutation.bind(this));
|
|
||||||
this.controlMutationObserver = new MutationObserver(this.onControlMutation.bind(this));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getState(): CallControlState {
|
public getState(): CallControlState {
|
||||||
@@ -225,6 +151,8 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
this.joined = true;
|
this.joined = true;
|
||||||
this.sendDeafenState();
|
this.sendDeafenState();
|
||||||
this.sendQuality();
|
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.
|
// [Gitea #17] The pin lives fork-side and is dropped on a handler remount.
|
||||||
if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId);
|
if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId);
|
||||||
this.sendHotkeyCodes();
|
this.sendHotkeyCodes();
|
||||||
}
|
this.sendFrameScreenshareAllowed();
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setMediaState(state: ElementMediaStatePayload) {
|
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
|
// [Gitea #43] The fork has reported io.lotus.controls_state, so its Lotus
|
||||||
// layout / settings / reactions go over the widget API and screenshare +
|
// action handlers are mounted. Screenshare and layout state come from that
|
||||||
// layout state come from that report, not from EC's DOM. Older forks never
|
// report; the host never reads or clicks EC's DOM.
|
||||||
// send it and keep the DOM path below.
|
private forkReady = false;
|
||||||
private forkControls = false;
|
|
||||||
|
|
||||||
// [Gitea #43] The fork handles io.lotus.set_screenshare (reported in
|
// [Gitea #43] The fork shows EC's own screenshare button inside the frame
|
||||||
// controls_state). Used only where Capability Delegation exists.
|
// (lotusFrameScreenshare: no Capability Delegation here), so the host bar
|
||||||
private forkScreenshare = false;
|
// hides its own.
|
||||||
|
private _frameScreenshare = false;
|
||||||
|
|
||||||
// [Gitea #43] The fork reports call hotkeys pressed inside its frame
|
private frameScreenshareListeners = new Set<() => void>();
|
||||||
// (io.lotus.set_hotkeys → io.lotus.hotkey), so the host stops adding key
|
|
||||||
// listeners to the frame's window.
|
// [Gitea #43] Whether the room's call policy allows screensharing; the fork
|
||||||
private forkHotkeys = false;
|
// hides its in-frame button when it doesn't.
|
||||||
|
private frameScreenshareAllowed = true;
|
||||||
|
|
||||||
private hotkeyCodes = new Map<string, string[]>();
|
private hotkeyCodes = new Map<string, string[]>();
|
||||||
|
|
||||||
private hotkeyListeners = new Set<(report: ForkHotkeyReport) => void>();
|
private hotkeyListeners = new Set<(report: ForkHotkeyReport) => void>();
|
||||||
|
|
||||||
private forkHotkeysListeners = new Set<() => void>();
|
/** EC's own screenshare button is shown in the frame instead of ours. */
|
||||||
|
public get frameScreenshare(): boolean {
|
||||||
public get forkHandlesHotkeys(): boolean {
|
return this._frameScreenshare;
|
||||||
return this.forkHotkeys;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Subscribe to `forkHandlesHotkeys` turning on. Returns an unsubscribe. */
|
/** Subscribe to `frameScreenshare` changes. Returns an unsubscribe. */
|
||||||
public onForkHotkeysChange(cb: () => void): () => void {
|
public onFrameScreenshareChange(cb: () => void): () => void {
|
||||||
this.forkHotkeysListeners.add(cb);
|
this.frameScreenshareListeners.add(cb);
|
||||||
return () => {
|
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. */
|
/** Key codes `source` (e.g. 'ptt', 'deafen') wants reported from the frame. */
|
||||||
public setHotkeyCodes(source: string, codes: string[]): void {
|
public setHotkeyCodes(source: string, codes: string[]): void {
|
||||||
this.hotkeyCodes.set(source, codes);
|
this.hotkeyCodes.set(source, codes);
|
||||||
@@ -409,7 +302,7 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private sendHotkeyCodes(): void {
|
private sendHotkeyCodes(): void {
|
||||||
if (!this.joined || !this.forkHotkeys) return;
|
if (!this.joined || !this.forkReady) return;
|
||||||
const codes = [...new Set([...this.hotkeyCodes.values()].flat())];
|
const codes = [...new Set([...this.hotkeyCodes.values()].flat())];
|
||||||
this.call.transport.send('io.lotus.set_hotkeys', { codes }).catch(() => undefined);
|
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. */
|
/** [Gitea #43] The fork's `io.lotus.controls_state` report. */
|
||||||
public onControlsState(data: unknown) {
|
public onControlsState(data: unknown) {
|
||||||
if (typeof data !== 'object' || data === null) return;
|
if (typeof data !== 'object' || data === null) return;
|
||||||
const { screensharing, layout, screenshareAction, hotkeys } = data as {
|
const { screensharing, layout, frameScreenshare } = data as {
|
||||||
screensharing?: unknown;
|
screensharing?: unknown;
|
||||||
layout?: unknown;
|
layout?: unknown;
|
||||||
screenshareAction?: unknown;
|
frameScreenshare?: unknown;
|
||||||
hotkeys?: unknown;
|
|
||||||
};
|
};
|
||||||
this.forkControls = true;
|
const firstReport = !this.forkReady;
|
||||||
this.forkScreenshare = screenshareAction === true;
|
this.forkReady = true;
|
||||||
if (hotkeys === true && !this.forkHotkeys) {
|
const frame = frameScreenshare === true;
|
||||||
this.forkHotkeys = true;
|
if (frame !== this._frameScreenshare) {
|
||||||
|
this._frameScreenshare = frame;
|
||||||
|
this.frameScreenshareListeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
if (firstReport) {
|
||||||
this.sendHotkeyCodes();
|
this.sendHotkeyCodes();
|
||||||
this.forkHotkeysListeners.forEach((l) => l());
|
this.sendFrameScreenshareAllowed();
|
||||||
}
|
}
|
||||||
this.applyControls(
|
this.applyControls(
|
||||||
typeof screensharing === 'boolean' ? screensharing : this.screenshare,
|
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) {
|
private applyControls(screenshare: boolean, spotlight: boolean) {
|
||||||
const wasScreensharing = this.screenshare;
|
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
|
* Must be called synchronously inside the user's click: starting a share
|
||||||
* calls getDisplayMedia in the frame, which needs that click. Where the
|
* calls getDisplayMedia in the frame, which needs that click. Where the
|
||||||
* browser can hand the click over (Capability Delegation, Chromium incl.
|
* browser can hand the click over (Capability Delegation, Chromium incl.
|
||||||
* WebView2) this goes over the widget API; elsewhere (Firefox, Safari,
|
* WebView2) it is sent with delegation. Elsewhere the host bar hides its
|
||||||
* WebKitGTK desktop) it still clicks EC's hidden button, which needs
|
* button and EC's own shows in the frame (`frameScreenshare`); a plain send
|
||||||
* same-origin access to the frame.
|
* still stops a share, which needs no click.
|
||||||
*/
|
*/
|
||||||
public toggleScreenshare() {
|
public toggleScreenshare() {
|
||||||
if (this.forkScreenshare && canDelegateCapability()) {
|
const data = { on: !this.screenshare };
|
||||||
this.sendDelegated('io.lotus.set_screenshare', { on: !this.screenshare }, 'display-capture');
|
if (canDelegateCapability()) {
|
||||||
|
this.sendDelegated('io.lotus.set_screenshare', data, 'display-capture');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.screenshareButton?.click();
|
this.sendForkAction('io.lotus.set_screenshare', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -602,34 +492,18 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public toggleSpotlight() {
|
public toggleSpotlight() {
|
||||||
if (this.forkControls) {
|
this.sendForkAction('io.lotus.set_layout', {
|
||||||
this.sendForkAction('io.lotus.set_layout', {
|
layout: this.spotlight ? 'grid' : 'spotlight',
|
||||||
layout: this.spotlight ? 'grid' : 'spotlight',
|
});
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.spotlight) {
|
|
||||||
this.gridButton?.click();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.spotlightButton?.click();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public toggleReactions() {
|
public toggleReactions() {
|
||||||
if (this.forkControls) {
|
this.sendForkAction('io.lotus.toggle_reactions', {});
|
||||||
this.sendForkAction('io.lotus.toggle_reactions', {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.reactionsButton?.click();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public toggleSettings() {
|
public toggleSettings() {
|
||||||
if (this.forkControls) {
|
// Same as clicking EC's settings button: opens the modal.
|
||||||
// Same as clicking EC's settings button: opens the modal.
|
this.sendForkAction('io.lotus.open_settings', { open: true });
|
||||||
this.sendForkAction('io.lotus.open_settings', { open: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.settingsButton?.click();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendForkAction(action: string, data: Record<string, unknown>): void {
|
private sendForkAction(action: string, data: Record<string, unknown>): void {
|
||||||
@@ -731,14 +605,8 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public dispose() {
|
public dispose() {
|
||||||
if (this.bodyMutationTimer !== undefined) {
|
|
||||||
clearTimeout(this.bodyMutationTimer);
|
|
||||||
this.bodyMutationTimer = undefined;
|
|
||||||
}
|
|
||||||
// [Gitea #56] Don't let a manual focus pin outlive the call.
|
// [Gitea #56] Don't let a manual focus pin outlive the call.
|
||||||
this.clearFocusParticipant();
|
this.clearFocusParticipant();
|
||||||
this.bodyMutationObserver.disconnect();
|
|
||||||
this.controlMutationObserver.disconnect();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitStateUpdate() {
|
private emitStateUpdate() {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
import { CallControl } from './CallControl';
|
import { CallControl } from './CallControl';
|
||||||
import { CallControlState } from './CallControlState';
|
import { CallControlState } from './CallControlState';
|
||||||
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
|
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
|
// 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
|
// 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
|
// [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.
|
// sets its root color-scheme from the theme, instead of us injecting CSS.
|
||||||
lotusHostControls: 'true',
|
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
|
// [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
|
// soundboard can publish clips into the call. Dormant until the host
|
||||||
// sends io.lotus.inject_audio (only on an explicit user click), so
|
// 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);
|
const controlState = initialControlState ?? new CallControlState(true, false, true);
|
||||||
this.control = new CallControl(controlState, call, iframe);
|
this.control = new CallControl(controlState, call, iframe);
|
||||||
this.initialState = controlState;
|
this.initialState = controlState;
|
||||||
this.control.startObserving();
|
|
||||||
iframe.onload = () => {
|
|
||||||
this.control.startObserving();
|
|
||||||
};
|
|
||||||
// If the iframe document itself fails to load, fail fast.
|
// If the iframe document itself fails to load, fail fast.
|
||||||
iframe.onerror = () => {
|
iframe.onerror = () => {
|
||||||
this.settleLoad('iframe');
|
this.settleLoad('iframe');
|
||||||
@@ -348,10 +349,6 @@ export class CallEmbed {
|
|||||||
return this.room.roomId;
|
return this.room.roomId;
|
||||||
}
|
}
|
||||||
|
|
||||||
get document(): Document | undefined {
|
|
||||||
return getCallDocument(this.iframe);
|
|
||||||
}
|
|
||||||
|
|
||||||
public setTheme(theme: ElementCallThemeKind) {
|
public setTheme(theme: ElementCallThemeKind) {
|
||||||
this.themeKind = theme;
|
this.themeKind = theme;
|
||||||
return this.call.transport
|
return this.call.transport
|
||||||
@@ -573,13 +570,12 @@ export class CallEmbed {
|
|||||||
|
|
||||||
private onCallJoined(): void {
|
private onCallJoined(): void {
|
||||||
this.settleLoad();
|
this.settleLoad();
|
||||||
this.control.startObserving();
|
|
||||||
|
|
||||||
// C-H1: EC fires JoinCall again on an EC reconnect (this action has no
|
// 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
|
// 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
|
// 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
|
// re-push the sticky fork state (deafen/quality), leaving the user's live
|
||||||
// (deafen/quality), leaving the user's live media state untouched.
|
// media state untouched.
|
||||||
if (this.joined) {
|
if (this.joined) {
|
||||||
this.control.resendForkState();
|
this.control.resendForkState();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -73,3 +73,21 @@ export const useCallMicLevel = (callEmbed: CallEmbed | undefined): number => {
|
|||||||
);
|
);
|
||||||
return useSyncExternalStore(subscribe, () => callEmbed?.getMicLevel() ?? 0);
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -118,17 +118,10 @@ export function getCallCapabilities(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The EC iframe's document, or undefined when it cannot be read. The widget is
|
* Capability Delegation (`postMessage(msg, { delegate })`) ships only in
|
||||||
* same-origin, but when its navigation fails (offline, blocked) the frame
|
* Chromium; other engines silently ignore the option, so the frame would get
|
||||||
* becomes a cross-origin error page and `contentWindow.document` THROWS a
|
* no activation. `navigator.userAgentData` is likewise Chromium-only, which
|
||||||
* SecurityError — which surfaced as page errors (and a React "Should not
|
* makes it the practical feature test.
|
||||||
* 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".
|
|
||||||
*/
|
*/
|
||||||
export const getCallDocument = (iframe: HTMLIFrameElement): Document | undefined => {
|
export const canDelegateCapability = (): boolean =>
|
||||||
try {
|
typeof navigator !== 'undefined' && 'userAgentData' in navigator;
|
||||||
return iframe.contentDocument ?? iframe.contentWindow?.document ?? undefined;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user