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

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:
2026-09-15 22:43:31 -04:00
co-authored by Claude Opus 5
parent 3dead4b3e1
commit 61dfdea9e9
3 changed files with 165 additions and 82 deletions
+103 -82
View File
@@ -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<string> => {
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<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;
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<string> => {
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<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.
const unsubLotus = callEmbed.onLotusCallState(syncState);
@@ -120,7 +130,7 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
if (getDoc()?.body) {
bodyWatcher?.disconnect();
bodyWatcher = undefined;
attachObserver();
syncState();
}
});
const doc = getDoc();
@@ -128,7 +138,7 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
}
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<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;
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<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.
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();
};
+31
View File
@@ -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']);
});
+31
View File
@@ -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;
};