feat: PiP mute indicator, export history, activity log, unverified device warning
CI / Build & Quality Checks (push) Successful in 10m29s
CI / Build & Quality Checks (push) Successful in 10m29s
- PiP call window: mute overlay using MutationObserver on EC iframe's [data-testid="incall_mute"] button (data-kind="primary" = muted), same pattern as screenshare detection in CallControl.ts - P2-4 Export Room History: new tab in room settings — Plain Text / JSON / HTML formats, optional date range, progress counter, paginated via paginateEventTimeline, blob download; E2EE-aware (skips failed decryptions) - P2-6 Room Activity Log: new tab in room settings — filterable log of m.room.member, m.room.power_levels, m.room.name/topic/avatar/server_acl events with human-readable descriptions, relative timestamps, Load More pagination - P2-10 Unverified Device Warning: warnOnUnverifiedDevices setting (default off); Warning.Container banner above composer in encrypted rooms with unverified devices; toggle in Settings → General → Privacy Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -58,3 +58,80 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
||||
|
||||
return speakers;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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:
|
||||
*
|
||||
* [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.
|
||||
*/
|
||||
export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean => {
|
||||
const [muted, setMuted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!callEmbed) return undefined;
|
||||
|
||||
const getDoc = (): Document | undefined =>
|
||||
callEmbed.iframe.contentDocument ?? callEmbed.iframe.contentWindow?.document ?? undefined;
|
||||
|
||||
const getMuteBtn = (): HTMLElement | null =>
|
||||
getDoc()?.querySelector('[data-testid="incall_mute"]') ?? null;
|
||||
|
||||
/** 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');
|
||||
};
|
||||
|
||||
let observer: 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'],
|
||||
});
|
||||
// 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()) {
|
||||
const doc = getDoc();
|
||||
if (doc) {
|
||||
bodyObserver = new MutationObserver(() => {
|
||||
if (getMuteBtn()) {
|
||||
bodyObserver?.disconnect();
|
||||
bodyObserver = undefined;
|
||||
attachObserver();
|
||||
}
|
||||
});
|
||||
bodyObserver.observe(doc.body, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
bodyObserver?.disconnect();
|
||||
};
|
||||
}, [callEmbed]);
|
||||
|
||||
return muted;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
|
||||
import { verifiedDevice } from '../utils/matrix-crypto';
|
||||
import { useAlive } from './useAlive';
|
||||
@@ -104,3 +105,62 @@ export const useUnverifiedDeviceCount = (
|
||||
|
||||
return unverifiedCount;
|
||||
};
|
||||
|
||||
export const useRoomUnverifiedDeviceCount = (
|
||||
crypto: CryptoApi | undefined,
|
||||
room: Room,
|
||||
): number | undefined => {
|
||||
const [unverifiedCount, setUnverifiedCount] = useState<number>();
|
||||
const alive = useAlive();
|
||||
|
||||
const memberIds = useMemo(
|
||||
() => room.getJoinedMembers().map((m) => m.userId),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[room.roomId],
|
||||
);
|
||||
|
||||
const updateCount = useCallback(async () => {
|
||||
if (!crypto) return;
|
||||
|
||||
const deviceMap = await crypto.getUserDeviceInfo(memberIds);
|
||||
let count = 0;
|
||||
|
||||
const allChecks: Promise<boolean | null>[] = [];
|
||||
|
||||
deviceMap.forEach((devices, userId) => {
|
||||
devices.forEach((_device, deviceId) => {
|
||||
allChecks.push(verifiedDevice(crypto, userId, deviceId));
|
||||
});
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(allChecks);
|
||||
const settled = fulfilledPromiseSettledResult(results);
|
||||
settled.forEach((status) => {
|
||||
if (status === false) {
|
||||
count += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (alive()) {
|
||||
setUnverifiedCount(count);
|
||||
}
|
||||
}, [crypto, memberIds, alive]);
|
||||
|
||||
useDeviceListChange(
|
||||
useCallback(
|
||||
(userIds) => {
|
||||
const affected = userIds.some((uid) => memberIds.includes(uid));
|
||||
if (affected) {
|
||||
updateCount();
|
||||
}
|
||||
},
|
||||
[memberIds, updateCount],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
updateCount();
|
||||
}, [updateCount]);
|
||||
|
||||
return unverifiedCount;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user