fix: export deduplication and PiP remote mute detection
CI / Build & Quality Checks (push) Successful in 11m32s
CI / Build & Quality Checks (push) Successful in 11m32s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -49,12 +49,18 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const collected: MsgRecord[] = [];
|
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<string>();
|
||||||
const timeline = room.getLiveTimeline();
|
const timeline = room.getLiveTimeline();
|
||||||
let canLoadMore = true;
|
let canLoadMore = true;
|
||||||
|
|
||||||
// Collect events already in the live timeline
|
|
||||||
const addEvents = (events: ReturnType<typeof timeline.getEvents>) => {
|
const addEvents = (events: ReturnType<typeof timeline.getEvents>) => {
|
||||||
for (const ev of events) {
|
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.getType() !== EventType.RoomMessage) continue;
|
||||||
if (ev.isDecryptionFailure()) continue;
|
if (ev.isDecryptionFailure()) continue;
|
||||||
const ts = ev.getTs();
|
const ts = ev.getTs();
|
||||||
@@ -68,7 +74,7 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
ts,
|
ts,
|
||||||
sender: ev.getSender() ?? '',
|
sender: ev.getSender() ?? '',
|
||||||
body,
|
body,
|
||||||
eventId: ev.getId() ?? '',
|
eventId: evId,
|
||||||
msgtype,
|
msgtype,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,16 +60,14 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true when the local user's microphone is muted in the Element Call
|
* Returns true when any REMOTE participant has their microphone muted in the
|
||||||
* iframe. The state is read directly from the EC mute button DOM using the
|
* Element Call iframe.
|
||||||
* same MutationObserver / `data-kind` pattern that CallControl.ts uses for the
|
|
||||||
* screenshare button:
|
|
||||||
*
|
*
|
||||||
* [data-testid="incall_mute"][data-kind="primary"] → mic is muted
|
* EC renders a mute-icon element per participant tile with a `data-muted`
|
||||||
* [data-testid="incall_mute"][data-kind="secondary"] → mic is active
|
* attribute ("true" = muted, "false" = unmuted) and an `aria-label` set to
|
||||||
*
|
* the participant's Matrix user ID. We watch for attribute changes on all
|
||||||
* This is used by the PiP overlay so the viewer can see at a glance whether
|
* `[data-muted]` elements, filter out the local user, and return true if any
|
||||||
* their microphone is muted while navigated away from the call room.
|
* remaining participant is muted.
|
||||||
*/
|
*/
|
||||||
export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean => {
|
export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean => {
|
||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(false);
|
||||||
@@ -80,56 +78,76 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean =>
|
|||||||
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 getMuteBtn = (): HTMLElement | null =>
|
const localUserId = callEmbed.room.client?.getUserId() ?? '';
|
||||||
getDoc()?.querySelector('[data-testid="incall_mute"]') ?? null;
|
|
||||||
|
|
||||||
/** Read the current button state and update React state. */
|
|
||||||
const syncState = (): void => {
|
const syncState = (): void => {
|
||||||
const btn = getMuteBtn();
|
const doc = getDoc();
|
||||||
// data-kind="primary" means the button is in its "active/on" primary style,
|
if (!doc) {
|
||||||
// which EC uses for muted state (consistent with screenshare active = primary).
|
setMuted(false);
|
||||||
setMuted(btn?.getAttribute('data-kind') === 'primary');
|
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 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 attachObserver = (): void => {
|
||||||
const btn = getMuteBtn();
|
const doc = getDoc();
|
||||||
if (!btn) return;
|
if (!doc) return;
|
||||||
|
tileObserver?.disconnect();
|
||||||
observer?.disconnect();
|
// Watch the whole document for attribute changes on data-muted elements
|
||||||
observer = new MutationObserver(syncState);
|
// and for new tiles being added/removed.
|
||||||
observer.observe(btn, {
|
tileObserver = new MutationObserver((mutations) => {
|
||||||
attributes: true,
|
const relevant = mutations.some(
|
||||||
attributeFilter: ['data-kind'],
|
(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();
|
syncState();
|
||||||
};
|
};
|
||||||
|
|
||||||
// If the button is already present, start observing immediately.
|
|
||||||
attachObserver();
|
attachObserver();
|
||||||
|
|
||||||
// If not yet present (iframe still loading), watch the document body for
|
// If iframe isn't ready yet, wait for body to be available.
|
||||||
// the button to appear — mirrors the styleRetryObserver pattern in CallEmbed.
|
let bodyWatcher: MutationObserver | undefined;
|
||||||
let bodyObserver: MutationObserver | undefined;
|
if (!getDoc()?.body) {
|
||||||
if (!getMuteBtn()) {
|
bodyWatcher = new MutationObserver(() => {
|
||||||
|
if (getDoc()?.body) {
|
||||||
|
bodyWatcher?.disconnect();
|
||||||
|
bodyWatcher = undefined;
|
||||||
|
attachObserver();
|
||||||
|
}
|
||||||
|
});
|
||||||
const doc = getDoc();
|
const doc = getDoc();
|
||||||
if (doc) {
|
if (doc) bodyWatcher.observe(doc, { childList: true });
|
||||||
bodyObserver = new MutationObserver(() => {
|
|
||||||
if (getMuteBtn()) {
|
|
||||||
bodyObserver?.disconnect();
|
|
||||||
bodyObserver = undefined;
|
|
||||||
attachObserver();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
bodyObserver.observe(doc.body, { childList: true, subtree: true });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
observer?.disconnect();
|
tileObserver?.disconnect();
|
||||||
bodyObserver?.disconnect();
|
bodyWatcher?.disconnect();
|
||||||
};
|
};
|
||||||
}, [callEmbed]);
|
}, [callEmbed]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user