diff --git a/src/app/hooks/useCallSpeakers.ts b/src/app/hooks/useCallSpeakers.ts index 7412a147d..836621bf6 100644 --- a/src/app/hooks/useCallSpeakers.ts +++ b/src/app/hooks/useCallSpeakers.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { CallEmbed } from '../plugins/call'; import { isUserId } from '../utils/matrix'; +import { nextSpeakerSet } from '../utils/speakerSet'; import { useCallMembers, useCallSession } from './useCall'; import { useCallJoined } from './useCallEmbed'; @@ -40,50 +41,20 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set => { const getDoc = (): Document | undefined => callEmbed.iframe.contentDocument ?? callEmbed.iframe.contentWindow?.document ?? undefined; - 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) { - const ls = new Set(); - lotus.forEach((p) => { - if (p.speaking && isUserId(p.userId)) ls.add(p.userId); - }); - setSpeakers(ls); - return; - } - 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; + 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) => { @@ -106,10 +77,49 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set => { attributes: true, attributeFilter: ['class', 'style'], }); - syncState(); }; - attachObserver(); + 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); + }; + + syncState(); // [lotus #2] Re-derive whenever the fork pushes new call-state. const unsubLotus = callEmbed.onLotusCallState(syncState); @@ -120,7 +130,7 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set => { if (getDoc()?.body) { bodyWatcher?.disconnect(); bodyWatcher = undefined; - attachObserver(); + syncState(); } }); const doc = getDoc(); @@ -128,7 +138,7 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set => { } return () => { - tileObserver?.disconnect(); + detachTileObserver(); bodyWatcher?.disconnect(); unsubLotus(); }; @@ -158,42 +168,20 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean => const localUserId = callEmbed.room.client?.getUserId() ?? ''; - 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) { - 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; - } - // 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); - }; - let tileObserver: MutationObserver | undefined; - const attachObserver = (): void => { - const doc = getDoc(); - if (!doc) return; + 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) => { @@ -216,10 +204,43 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean => attributes: true, attributeFilter: ['data-muted'], }); - syncState(); }; - attachObserver(); + 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); + }; + + syncState(); // [lotus #2] Re-derive whenever the fork pushes new call-state. const unsubLotus = callEmbed.onLotusCallState(syncState); @@ -230,7 +251,7 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean => if (getDoc()?.body) { bodyWatcher?.disconnect(); bodyWatcher = undefined; - attachObserver(); + syncState(); } }); const doc = getDoc(); @@ -238,7 +259,7 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean => } return () => { - tileObserver?.disconnect(); + detachTileObserver(); bodyWatcher?.disconnect(); unsubLotus(); }; diff --git a/src/app/utils/speakerSet.test.ts b/src/app/utils/speakerSet.test.ts new file mode 100644 index 000000000..e7a647bcc --- /dev/null +++ b/src/app/utils/speakerSet.test.ts @@ -0,0 +1,31 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { nextSpeakerSet, sameSet } from './speakerSet'; + +test('sameSet: identity, size and membership', () => { + const a = new Set(['@a:x', '@b:x']); + assert.equal(sameSet(a, a), true); + assert.equal(sameSet(a, new Set(['@b:x', '@a:x'])), true); + assert.equal(sameSet(a, new Set(['@a:x'])), false); + assert.equal(sameSet(a, new Set(['@a:x', '@c:x'])), false); +}); + +test('nextSpeakerSet returns the SAME reference when speakers are unchanged (#32)', () => { + const prev = new Set(['@a:x']); + const next = nextSpeakerSet(prev, [ + { userId: '@a:x', speaking: true }, + { userId: '@b:x', speaking: false }, + ]); + assert.equal(next, prev); +}); + +test('nextSpeakerSet returns a new set when speakers change, ignoring non-user ids', () => { + const prev = new Set(['@a:x']); + const next = nextSpeakerSet(prev, [ + { userId: '@a:x', speaking: false }, + { userId: '@b:x', speaking: true }, + { userId: 'not-a-user-id', speaking: true }, + ]); + assert.notEqual(next, prev); + assert.deepEqual(Array.from(next), ['@b:x']); +}); diff --git a/src/app/utils/speakerSet.ts b/src/app/utils/speakerSet.ts new file mode 100644 index 000000000..57910d2ac --- /dev/null +++ b/src/app/utils/speakerSet.ts @@ -0,0 +1,31 @@ +import { isUserId } from './matrix'; + +/** Minimal shape of a `io.lotus.call_state` participant entry we need here. */ +export type SpeakingParticipant = { userId: string; speaking: boolean }; + +/** True when both sets contain exactly the same members. */ +export const sameSet = (a: Set, b: Set): boolean => { + if (a === b) return true; + if (a.size !== b.size) return false; + for (const v of a) { + if (!b.has(v)) return false; + } + return true; +}; + +/** + * Derives the speaking-users set from a `io.lotus.call_state` participant + * list. Returns `prev` unchanged (same reference) when the derived set is + * equal to it, so callers using the functional `setState` form can bail out + * of a re-render (#32 — the fork pushes this multiple times per second). + */ +export const nextSpeakerSet = ( + prev: Set, + participants: readonly SpeakingParticipant[], +): Set => { + const next = new Set(); + participants.forEach((p) => { + if (p.speaking && isUserId(p.userId)) next.add(p.userId); + }); + return sameSet(prev, next) ? prev : next; +};