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 { 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();
|
||||
};
|
||||
|
||||
@@ -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