feat: PiP mute indicator, export history, activity log, unverified device warning
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:
2026-06-03 22:13:22 -04:00
parent 2aca90b57d
commit 158fcd1309
10 changed files with 1030 additions and 1 deletions
+77
View File
@@ -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;
};
+61 -1
View File
@@ -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;
};