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
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { atom, useSetAtom } from 'jotai';
|
||||
import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
@@ -105,32 +105,6 @@ const isInteractive = (el: HTMLElement): boolean => {
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -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<string> => {
|
||||
const [speakers, setSpeakers] = useState(new Set<string>());
|
||||
@@ -28,9 +19,8 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
||||
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<string> => {
|
||||
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<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);
|
||||
// #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<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);
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user