import { useEffect, useState } from 'react'; import { CallEmbed } from '../plugins/call'; import { isUserId } from '../utils/matrix'; import { useCallMembers, useCallSession } from './useCall'; import { useCallJoined } from './useCallEmbed'; /** * Returns the set of Matrix user IDs currently speaking in the Element Call * iframe. * * 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). */ export const useCallSpeakers = (callEmbed: CallEmbed): Set => { const [speakers, setSpeakers] = useState(new Set()); const callSession = useCallSession(callEmbed.room); const callMembers = useCallMembers(callSession); const joined = useCallJoined(callEmbed); useEffect(() => { if (!callMembers || !joined) { setSpeakers(new Set()); return undefined; } const getDoc = (): Document | undefined => callEmbed.iframe.contentDocument ?? callEmbed.iframe.contentWindow?.document ?? undefined; const syncState = (): void => { const doc = getDoc(); if (!doc) { setSpeakers(new Set()); return; } 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); }; let tileObserver: MutationObserver | undefined; const attachObserver = (): void => { const doc = getDoc(); if (!doc) return; tileObserver?.disconnect(); // 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'], }); syncState(); }; attachObserver(); // 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; attachObserver(); } }); const doc = getDoc(); if (doc) bodyWatcher.observe(doc, { childList: true }); } return () => { tileObserver?.disconnect(); bodyWatcher?.disconnect(); }; }, [callEmbed, callMembers, 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. */ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean => { const [muted, setMuted] = useState(false); useEffect(() => { if (!callEmbed) return undefined; const getDoc = (): Document | undefined => callEmbed.iframe.contentDocument ?? callEmbed.iframe.contentWindow?.document ?? undefined; const localUserId = callEmbed.room.client?.getUserId() ?? ''; const syncState = (): void => { const doc = getDoc(); if (!doc) { setMuted(false); return; } // 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 anyRemoteMuted = false; muteIcons.forEach((el) => { const userId = el.getAttribute('aria-label') ?? ''; if (userId === localUserId) return; if (el.getAttribute('data-muted') === 'true') anyRemoteMuted = true; }); setMuted(anyRemoteMuted); }; let tileObserver: MutationObserver | undefined; const attachObserver = (): void => { const doc = getDoc(); if (!doc) return; tileObserver?.disconnect(); // 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'], }); syncState(); }; attachObserver(); // 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; attachObserver(); } }); const doc = getDoc(); if (doc) bodyWatcher.observe(doc, { childList: true }); } return () => { tileObserver?.disconnect(); bodyWatcher?.disconnect(); }; }, [callEmbed]); return muted; };