import { useCallback, useEffect, useMemo, useState } from 'react'; import { CallEmbed } from '../plugins/call'; import { useMutationObserver } from './useMutationObserver'; import { isUserId } from '../utils/matrix'; import { useCallMembers, useCallSession } from './useCall'; import { useCallJoined } from './useCallEmbed'; 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); const videoContainers = useMemo(() => { if (callMembers && joined) return callEmbed.document?.querySelectorAll('[data-video-fit]'); return undefined; }, [callEmbed, callMembers, joined]); const mutationObserver = useMutationObserver( useCallback( (mutations) => { const s = new Set(); mutations.forEach((mutation) => { if (mutation.type !== 'attributes') return; const el = mutation.target as HTMLElement; 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); }, [callEmbed], ), ); useEffect(() => { videoContainers?.forEach((element) => { mutationObserver.observe(element, { attributes: true, attributeFilter: ['class', 'style'], }); }); return () => { mutationObserver.disconnect(); }; }, [videoContainers, mutationObserver]); 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; };