perf(calls): speaker set only updates when it changes; DOM fallback detaches once the fork streams
CI / Build & Quality Checks (push) Successful in 1m31s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 15s
CI / Playwright smoke (e2e) (push) Successful in 2m22s
CI / Build & Quality Checks (push) Successful in 1m31s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 15s
CI / Playwright smoke (e2e) (push) Successful in 2m22s
Every io.lotus.call_state push allocated a new Set, re-rendering the app-wide call bar for the whole call. nextSpeakerSet() returns the previous reference when membership is unchanged (pure helpers in utils/speakerSet.ts, unit-tested), and the DOM MutationObserver fallback in useCallSpeakers/useRemoteAllMuted is attached only while the fork's participant list is unavailable. Fixes #32 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { CallEmbed } from '../plugins/call';
|
import { CallEmbed } from '../plugins/call';
|
||||||
import { isUserId } from '../utils/matrix';
|
import { isUserId } from '../utils/matrix';
|
||||||
|
import { nextSpeakerSet } from '../utils/speakerSet';
|
||||||
import { useCallMembers, useCallSession } from './useCall';
|
import { useCallMembers, useCallSession } from './useCall';
|
||||||
import { useCallJoined } from './useCallEmbed';
|
import { useCallJoined } from './useCallEmbed';
|
||||||
|
|
||||||
@@ -40,50 +41,20 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
|||||||
const getDoc = (): Document | undefined =>
|
const getDoc = (): Document | undefined =>
|
||||||
callEmbed.iframe.contentDocument ?? callEmbed.iframe.contentWindow?.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<string>();
|
|
||||||
lotus.forEach((p) => {
|
|
||||||
if (p.speaking && isUserId(p.userId)) ls.add(p.userId);
|
|
||||||
});
|
|
||||||
setSpeakers(ls);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const doc = getDoc();
|
|
||||||
if (!doc) {
|
|
||||||
setSpeakers(new Set<string>());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
let tileObserver: MutationObserver | undefined;
|
let tileObserver: MutationObserver | undefined;
|
||||||
|
|
||||||
const attachObserver = (): void => {
|
const detachTileObserver = (): void => {
|
||||||
const doc = getDoc();
|
|
||||||
if (!doc) return;
|
|
||||||
tileObserver?.disconnect();
|
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
|
// Watch the whole document for attribute changes on tiles (which carry
|
||||||
// the speaking indicator) and for new tiles being added/removed.
|
// the speaking indicator) and for new tiles being added/removed.
|
||||||
tileObserver = new MutationObserver((mutations) => {
|
tileObserver = new MutationObserver((mutations) => {
|
||||||
@@ -106,10 +77,49 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
|||||||
attributes: true,
|
attributes: true,
|
||||||
attributeFilter: ['class', 'style'],
|
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<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();
|
||||||
// [lotus #2] Re-derive whenever the fork pushes new call-state.
|
// [lotus #2] Re-derive whenever the fork pushes new call-state.
|
||||||
const unsubLotus = callEmbed.onLotusCallState(syncState);
|
const unsubLotus = callEmbed.onLotusCallState(syncState);
|
||||||
|
|
||||||
@@ -120,7 +130,7 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
|||||||
if (getDoc()?.body) {
|
if (getDoc()?.body) {
|
||||||
bodyWatcher?.disconnect();
|
bodyWatcher?.disconnect();
|
||||||
bodyWatcher = undefined;
|
bodyWatcher = undefined;
|
||||||
attachObserver();
|
syncState();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const doc = getDoc();
|
const doc = getDoc();
|
||||||
@@ -128,7 +138,7 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
tileObserver?.disconnect();
|
detachTileObserver();
|
||||||
bodyWatcher?.disconnect();
|
bodyWatcher?.disconnect();
|
||||||
unsubLotus();
|
unsubLotus();
|
||||||
};
|
};
|
||||||
@@ -158,42 +168,20 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean =>
|
|||||||
|
|
||||||
const localUserId = callEmbed.room.client?.getUserId() ?? '';
|
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<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);
|
|
||||||
};
|
|
||||||
|
|
||||||
let tileObserver: MutationObserver | undefined;
|
let tileObserver: MutationObserver | undefined;
|
||||||
|
|
||||||
const attachObserver = (): void => {
|
const detachTileObserver = (): void => {
|
||||||
const doc = getDoc();
|
|
||||||
if (!doc) return;
|
|
||||||
tileObserver?.disconnect();
|
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
|
// Watch the whole document for attribute changes on data-muted elements
|
||||||
// and for new tiles being added/removed.
|
// and for new tiles being added/removed.
|
||||||
tileObserver = new MutationObserver((mutations) => {
|
tileObserver = new MutationObserver((mutations) => {
|
||||||
@@ -216,10 +204,43 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean =>
|
|||||||
attributes: true,
|
attributes: true,
|
||||||
attributeFilter: ['data-muted'],
|
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<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();
|
||||||
// [lotus #2] Re-derive whenever the fork pushes new call-state.
|
// [lotus #2] Re-derive whenever the fork pushes new call-state.
|
||||||
const unsubLotus = callEmbed.onLotusCallState(syncState);
|
const unsubLotus = callEmbed.onLotusCallState(syncState);
|
||||||
|
|
||||||
@@ -230,7 +251,7 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean =>
|
|||||||
if (getDoc()?.body) {
|
if (getDoc()?.body) {
|
||||||
bodyWatcher?.disconnect();
|
bodyWatcher?.disconnect();
|
||||||
bodyWatcher = undefined;
|
bodyWatcher = undefined;
|
||||||
attachObserver();
|
syncState();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const doc = getDoc();
|
const doc = getDoc();
|
||||||
@@ -238,7 +259,7 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
tileObserver?.disconnect();
|
detachTileObserver();
|
||||||
bodyWatcher?.disconnect();
|
bodyWatcher?.disconnect();
|
||||||
unsubLotus();
|
unsubLotus();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
@@ -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 = <T>(a: Set<T>, b: Set<T>): 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<string>,
|
||||||
|
participants: readonly SpeakingParticipant[],
|
||||||
|
): Set<string> => {
|
||||||
|
const next = new Set<string>();
|
||||||
|
participants.forEach((p) => {
|
||||||
|
if (p.speaking && isUserId(p.userId)) next.add(p.userId);
|
||||||
|
});
|
||||||
|
return sameSet(prev, next) ? prev : next;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user