From 160db1eaefef69926ea994cf90b11c3261789b3f Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 3 Jun 2026 22:44:11 -0400 Subject: [PATCH] fix: export deduplication and PiP remote mute detection Export: timeline.getEvents() returns the entire growing window on every pagination step, causing the same events to be added multiple times. Fixed by tracking seen eventIds in a Set and skipping duplicates. PiP mute: replace silence-inference with real remote participant mute state. EC renders a [data-muted] attribute per participant tile with aria-label=userId. Watch attribute changes via MutationObserver, filter out local user, show overlay when any remote is muted. Co-Authored-By: Claude Sonnet 4.6 --- .../room-settings/ExportRoomHistory.tsx | 10 +- src/app/hooks/useCallSpeakers.ts | 104 ++++++++++-------- 2 files changed, 69 insertions(+), 45 deletions(-) diff --git a/src/app/features/room-settings/ExportRoomHistory.tsx b/src/app/features/room-settings/ExportRoomHistory.tsx index 2fcebda70..5023835d9 100644 --- a/src/app/features/room-settings/ExportRoomHistory.tsx +++ b/src/app/features/room-settings/ExportRoomHistory.tsx @@ -49,12 +49,18 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) { }; const collected: MsgRecord[] = []; + // timeline.getEvents() returns the entire growing window on every call, + // so we must deduplicate by eventId to avoid re-adding the same events + // on each pagination step. + const seen = new Set(); const timeline = room.getLiveTimeline(); let canLoadMore = true; - // Collect events already in the live timeline const addEvents = (events: ReturnType) => { for (const ev of events) { + const evId = ev.getId(); + if (!evId || seen.has(evId)) continue; + seen.add(evId); if (ev.getType() !== EventType.RoomMessage) continue; if (ev.isDecryptionFailure()) continue; const ts = ev.getTs(); @@ -68,7 +74,7 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) { ts, sender: ev.getSender() ?? '', body, - eventId: ev.getId() ?? '', + eventId: evId, msgtype, }); } diff --git a/src/app/hooks/useCallSpeakers.ts b/src/app/hooks/useCallSpeakers.ts index 013e5f9de..64c97a1c6 100644 --- a/src/app/hooks/useCallSpeakers.ts +++ b/src/app/hooks/useCallSpeakers.ts @@ -60,16 +60,14 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set => { }; /** - * Returns true when the local user's microphone is muted in the Element Call - * iframe. The state is read directly from the EC mute button DOM using the - * same MutationObserver / `data-kind` pattern that CallControl.ts uses for the - * screenshare button: + * Returns true when any REMOTE participant has their microphone muted in the + * Element Call iframe. * - * [data-testid="incall_mute"][data-kind="primary"] → mic is muted - * [data-testid="incall_mute"][data-kind="secondary"] → mic is active - * - * This is used by the PiP overlay so the viewer can see at a glance whether - * their microphone is muted while navigated away from the call room. + * 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); @@ -80,56 +78,76 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean => const getDoc = (): Document | undefined => callEmbed.iframe.contentDocument ?? callEmbed.iframe.contentWindow?.document ?? undefined; - const getMuteBtn = (): HTMLElement | null => - getDoc()?.querySelector('[data-testid="incall_mute"]') ?? null; + const localUserId = callEmbed.room.client?.getUserId() ?? ''; - /** Read the current button state and update React state. */ const syncState = (): void => { - const btn = getMuteBtn(); - // data-kind="primary" means the button is in its "active/on" primary style, - // which EC uses for muted state (consistent with screenshare active = primary). - setMuted(btn?.getAttribute('data-kind') === 'primary'); + 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 observer: MutationObserver | undefined; + let tileObserver: MutationObserver | undefined; const attachObserver = (): void => { - const btn = getMuteBtn(); - if (!btn) return; - - observer?.disconnect(); - observer = new MutationObserver(syncState); - observer.observe(btn, { - attributes: true, - attributeFilter: ['data-kind'], + 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'], }); - // Read the current state immediately once we have a button to observe. syncState(); }; - // If the button is already present, start observing immediately. attachObserver(); - // If not yet present (iframe still loading), watch the document body for - // the button to appear — mirrors the styleRetryObserver pattern in CallEmbed. - let bodyObserver: MutationObserver | undefined; - if (!getMuteBtn()) { + // 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) { - bodyObserver = new MutationObserver(() => { - if (getMuteBtn()) { - bodyObserver?.disconnect(); - bodyObserver = undefined; - attachObserver(); - } - }); - bodyObserver.observe(doc.body, { childList: true, subtree: true }); - } + if (doc) bodyWatcher.observe(doc, { childList: true }); } return () => { - observer?.disconnect(); - bodyObserver?.disconnect(); + tileObserver?.disconnect(); + bodyWatcher?.disconnect(); }; }, [callEmbed]);