diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx
index 8412d0c48..cfef9b71a 100644
--- a/src/app/components/CallEmbedProvider.tsx
+++ b/src/app/components/CallEmbedProvider.tsx
@@ -41,6 +41,7 @@ import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
import { useMatrixClient } from '../hooks/useMatrixClient';
import CallSound from '../../../public/sound/call.ogg';
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
+import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
import { mDirectAtom } from '../state/mDirectList';
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
@@ -402,6 +403,37 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
return null;
}
+/** Shown inside the PiP window when the local microphone is muted. */
+function PipMuteOverlay({ callEmbed }: { callEmbed: CallEmbed }) {
+ const allMuted = useRemoteAllMuted(callEmbed);
+ if (!allMuted) return null;
+ return (
+
+
+
+ );
+}
+
type CallEmbedProviderProps = {
children?: ReactNode;
};
@@ -803,6 +835,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
↗ Return to call
+
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => {
const s = corner.includes('s');
const e2 = corner.includes('e');
diff --git a/src/app/features/room-settings/ExportRoomHistory.tsx b/src/app/features/room-settings/ExportRoomHistory.tsx
new file mode 100644
index 000000000..2fcebda70
--- /dev/null
+++ b/src/app/features/room-settings/ExportRoomHistory.tsx
@@ -0,0 +1,329 @@
+import React, { useCallback, useState } from 'react';
+import { Box, Button, Icon, IconButton, Icons, Scroll, Spinner, Text, config, color } from 'folds';
+import { EventType } from 'matrix-js-sdk';
+import { Page, PageContent, PageHeader } from '../../components/page';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useRoom } from '../../hooks/useRoom';
+import { useRoomName } from '../../hooks/useRoomMeta';
+import { SequenceCard } from '../../components/sequence-card';
+import { SequenceCardStyle } from '../common-settings/styles.css';
+
+type ExportFormat = 'txt' | 'json' | 'html';
+
+const FORMAT_LABELS: Record = {
+ txt: 'Plain Text',
+ json: 'JSON',
+ html: 'HTML',
+};
+
+type ExportRoomHistoryProps = {
+ requestClose: () => void;
+};
+
+export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
+ const mx = useMatrixClient();
+ const room = useRoom();
+ const roomName = useRoomName(room);
+
+ const [format, setFormat] = useState('txt');
+ const [fromDate, setFromDate] = useState('');
+ const [toDate, setToDate] = useState('');
+ const [exporting, setExporting] = useState(false);
+ const [exportCount, setExportCount] = useState(0);
+
+ const handleExport = useCallback(async () => {
+ if (exporting) return;
+ setExporting(true);
+ setExportCount(0);
+
+ try {
+ const fromTs = fromDate ? new Date(`${fromDate}T00:00:00`).getTime() : null;
+ const toTs = toDate ? new Date(`${toDate}T23:59:59`).getTime() : null;
+
+ type MsgRecord = {
+ ts: number;
+ sender: string;
+ body: string;
+ eventId: string;
+ msgtype: string;
+ };
+
+ const collected: MsgRecord[] = [];
+ const timeline = room.getLiveTimeline();
+ let canLoadMore = true;
+
+ // Collect events already in the live timeline
+ const addEvents = (events: ReturnType) => {
+ for (const ev of events) {
+ if (ev.getType() !== EventType.RoomMessage) continue;
+ if (ev.isDecryptionFailure()) continue;
+ const ts = ev.getTs();
+ if (fromTs !== null && ts < fromTs) continue;
+ if (toTs !== null && ts > toTs) continue;
+ const content = ev.getContent();
+ const body: string = content.body ?? '';
+ const msgtype: string = content.msgtype ?? '';
+ if (!body) continue;
+ collected.push({
+ ts,
+ sender: ev.getSender() ?? '',
+ body,
+ eventId: ev.getId() ?? '',
+ msgtype,
+ });
+ }
+ setExportCount(collected.length);
+ };
+
+ addEvents(timeline.getEvents());
+
+ // Paginate backwards until start or date range exceeded
+ while (canLoadMore) {
+ // If we have a fromTs, check whether the oldest collected event is already
+ // before it — if so we don't need to paginate further.
+ if (fromTs !== null && collected.length > 0) {
+ const oldestTs = Math.min(...collected.map((r) => r.ts));
+ if (oldestTs < fromTs) break;
+ }
+
+ // eslint-disable-next-line no-await-in-loop
+ canLoadMore = await mx.paginateEventTimeline(timeline, {
+ backwards: true,
+ limit: 100,
+ });
+
+ addEvents(timeline.getEvents());
+ }
+
+ // Sort chronologically (oldest first)
+ collected.sort((a, b) => a.ts - b.ts);
+
+ const exportedAt = new Date().toISOString();
+ const dateStr = exportedAt.slice(0, 10);
+ const safeRoomName = roomName.replace(/[^a-z0-9_-]/gi, '_').toLowerCase();
+
+ let content = '';
+ let mimeType = 'text/plain';
+ let ext: string = format;
+
+ if (format === 'txt') {
+ const lines: string[] = [
+ `# Export: ${roomName}`,
+ `# Exported: ${exportedAt}`,
+ `# Messages: ${collected.length}`,
+ '',
+ ];
+ for (const msg of collected) {
+ const d = new Date(msg.ts);
+ const pad = (n: number) => String(n).padStart(2, '0');
+ const dateLabel = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+ lines.push(`[${dateLabel}] ${msg.sender}: ${msg.body}`);
+ }
+ content = lines.join('\n');
+ mimeType = 'text/plain';
+ ext = 'txt';
+ } else if (format === 'json') {
+ const payload = {
+ room: roomName,
+ exportedAt,
+ messages: collected.map((m) => ({
+ ts: m.ts,
+ sender: m.sender,
+ body: m.body,
+ eventId: m.eventId,
+ type: m.msgtype,
+ })),
+ };
+ content = JSON.stringify(payload, null, 2);
+ mimeType = 'application/json';
+ ext = 'json';
+ } else {
+ // HTML
+ const esc = (s: string) =>
+ s
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+
+ const msgRows = collected
+ .map((msg) => {
+ const d = new Date(msg.ts);
+ const pad = (n: number) => String(n).padStart(2, '0');
+ const dateLabel = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+ return `[${esc(dateLabel)}] ${esc(msg.sender)}: ${esc(msg.body)}
`;
+ })
+ .join('\n');
+
+ content = `
+Export: ${esc(roomName)}
+
+Room: ${esc(roomName)}
+Exported ${esc(exportedAt)} — ${collected.length} messages
+
+${msgRows}
+
`;
+ mimeType = 'text/html';
+ ext = 'html';
+ }
+
+ const blob = new Blob([content], { type: mimeType });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `export-${safeRoomName}-${dateStr}.${ext}`;
+ a.click();
+ URL.revokeObjectURL(url);
+ } finally {
+ setExporting(false);
+ }
+ }, [exporting, format, fromDate, toDate, mx, room, roomName]);
+
+ return (
+
+
+
+
+
+ Export
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Format */}
+
+ Format
+
+
+ {(Object.keys(FORMAT_LABELS) as ExportFormat[]).map((f) => (
+
+ ))}
+
+
+
+
+ {/* Date range */}
+
+ Date Range (optional)
+
+
+
+ From
+ setFromDate(e.target.value)}
+ style={{
+ background: color.Surface.Container,
+ color: color.Surface.OnContainer,
+ border: `1px solid ${color.Surface.ContainerLine}`,
+ borderRadius: config.radii.R300,
+ padding: `${config.space.S200} ${config.space.S300}`,
+ fontSize: 'inherit',
+ fontFamily: 'inherit',
+ width: '100%',
+ boxSizing: 'border-box',
+ }}
+ />
+
+
+ To
+ setToDate(e.target.value)}
+ style={{
+ background: color.Surface.Container,
+ color: color.Surface.OnContainer,
+ border: `1px solid ${color.Surface.ContainerLine}`,
+ borderRadius: config.radii.R300,
+ padding: `${config.space.S200} ${config.space.S300}`,
+ fontSize: 'inherit',
+ fontFamily: 'inherit',
+ width: '100%',
+ boxSizing: 'border-box',
+ }}
+ />
+
+
+
+ Leave blank to export all available history.
+
+
+
+
+ {/* Export */}
+
+ Download
+
+
+
+ {exporting
+ ? `Exporting… ${exportCount} messages`
+ : 'Export will download automatically.'}
+
+
+ ) : (
+
+ )
+ }
+ >
+ {exporting ? 'Exporting…' : 'Export'}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/room-settings/RoomActivityLog.tsx b/src/app/features/room-settings/RoomActivityLog.tsx
new file mode 100644
index 000000000..70a342777
--- /dev/null
+++ b/src/app/features/room-settings/RoomActivityLog.tsx
@@ -0,0 +1,457 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { Box, Button, Icon, IconButton, Icons, Scroll, Spinner, Text, color, config } from 'folds';
+import { MatrixEvent } from 'matrix-js-sdk';
+import { Page, PageContent, PageHeader } from '../../components/page';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useRoom } from '../../hooks/useRoom';
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+type ActivityFilter = 'all' | 'members' | 'power' | 'room';
+
+const STATE_EVENT_TYPES = [
+ 'm.room.member',
+ 'm.room.power_levels',
+ 'm.room.name',
+ 'm.room.topic',
+ 'm.room.avatar',
+ 'm.room.server_acl',
+] as const;
+
+type StateEventType = (typeof STATE_EVENT_TYPES)[number];
+
+// ── Timestamp formatting ──────────────────────────────────────────────────────
+
+function formatRelativeTs(ts: number): string {
+ const diff = Date.now() - ts;
+ if (diff < 60000) return 'just now';
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
+ if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
+ const d = new Date(ts);
+ const sameYear = d.getFullYear() === new Date().getFullYear();
+ return d.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ ...(sameYear ? {} : { year: 'numeric' }),
+ });
+}
+
+// ── Event description ─────────────────────────────────────────────────────────
+
+function getDisplayName(mx: ReturnType, userId: string): string {
+ return mx.getUser(userId)?.displayName ?? userId;
+}
+
+type EventDesc = {
+ text: React.ReactNode;
+ iconSrc: (typeof Icons)[keyof typeof Icons];
+ filter: ActivityFilter;
+};
+
+function describeEvent(mx: ReturnType, ev: MatrixEvent): EventDesc | null {
+ const type = ev.getType() as StateEventType;
+ const sender = ev.getSender() ?? '';
+ const content = ev.getContent>();
+ const prevContent = ev.getPrevContent() as Record;
+
+ const senderName = getDisplayName(mx, sender);
+ const stateKey = ev.getStateKey() ?? '';
+
+ switch (type) {
+ case 'm.room.member': {
+ const membership = content.membership as string | undefined;
+ const prevMembership = prevContent.membership as string | undefined;
+ const reason = content.reason as string | undefined;
+ const targetName = getDisplayName(mx, stateKey);
+
+ if (membership === 'join') {
+ if (
+ prevMembership === 'invite' ||
+ prevMembership === undefined ||
+ prevMembership === null
+ ) {
+ return {
+ text: (
+ <>
+ {targetName} joined
+ >
+ ),
+ iconSrc: Icons.User,
+ filter: 'members',
+ };
+ }
+ // prevMembership === 'join' → profile update
+ return {
+ text: (
+ <>
+ {targetName} updated their profile
+ >
+ ),
+ iconSrc: Icons.User,
+ filter: 'members',
+ };
+ }
+
+ if (membership === 'leave') {
+ if (prevMembership === 'ban') {
+ return {
+ text: (
+ <>
+ {targetName} was unbanned by {senderName}
+ >
+ ),
+ iconSrc: Icons.User,
+ filter: 'members',
+ };
+ }
+ if (sender === stateKey) {
+ return {
+ text: (
+ <>
+ {targetName} left
+ >
+ ),
+ iconSrc: Icons.User,
+ filter: 'members',
+ };
+ }
+ return {
+ text: (
+ <>
+ {targetName} was kicked by {senderName}
+ {reason ? ` (${reason})` : ''}
+ >
+ ),
+ iconSrc: Icons.User,
+ filter: 'members',
+ };
+ }
+
+ if (membership === 'ban') {
+ return {
+ text: (
+ <>
+ {targetName} was banned by {senderName}
+ {reason ? ` (${reason})` : ''}
+ >
+ ),
+ iconSrc: Icons.User,
+ filter: 'members',
+ };
+ }
+
+ if (membership === 'invite') {
+ return {
+ text: (
+ <>
+ {targetName} was invited by {senderName}
+ >
+ ),
+ iconSrc: Icons.UserPlus,
+ filter: 'members',
+ };
+ }
+
+ return null;
+ }
+
+ case 'm.room.power_levels':
+ return {
+ text: (
+ <>
+ Power levels updated by {senderName}
+ >
+ ),
+ iconSrc: Icons.ShieldUser,
+ filter: 'power',
+ };
+
+ case 'm.room.name': {
+ const newName = content.name as string | undefined;
+ return {
+ text: (
+ <>
+ Room renamed to “{newName ?? ''}” by{' '}
+ {senderName}
+ >
+ ),
+ iconSrc: Icons.Pencil,
+ filter: 'room',
+ };
+ }
+
+ case 'm.room.topic':
+ return {
+ text: (
+ <>
+ Topic updated by {senderName}
+ >
+ ),
+ iconSrc: Icons.Pencil,
+ filter: 'room',
+ };
+
+ case 'm.room.avatar':
+ return {
+ text: (
+ <>
+ Room avatar changed by {senderName}
+ >
+ ),
+ iconSrc: Icons.Photo,
+ filter: 'room',
+ };
+
+ case 'm.room.server_acl':
+ return {
+ text: (
+ <>
+ Server ACL updated by {senderName}
+ >
+ ),
+ iconSrc: Icons.Shield,
+ filter: 'room',
+ };
+
+ default:
+ return null;
+ }
+}
+
+// ── Filter chip ───────────────────────────────────────────────────────────────
+
+type FilterChipProps = {
+ label: string;
+ active: boolean;
+ onClick: () => void;
+};
+
+function FilterChip({ label, active, onClick }: FilterChipProps) {
+ return (
+
+ );
+}
+
+// ── Log entry ─────────────────────────────────────────────────────────────────
+
+type LogEntryProps = {
+ ev: MatrixEvent;
+ desc: EventDesc;
+};
+
+function LogEntry({ ev, desc }: LogEntryProps) {
+ return (
+
+
+
+
+
+
+ {desc.text}
+
+
+ {formatRelativeTs(ev.getTs())}
+
+
+
+ );
+}
+
+// ── Main component ────────────────────────────────────────────────────────────
+
+type RoomActivityLogProps = {
+ requestClose: () => void;
+};
+
+export function RoomActivityLog({ requestClose }: RoomActivityLogProps) {
+ const mx = useMatrixClient();
+ const room = useRoom();
+
+ const [filter, setFilter] = useState('all');
+ const [loading, setLoading] = useState(false);
+ const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
+ const [canLoadMore, setCanLoadMore] = useState(true);
+
+ const getStateEvents = useCallback((): MatrixEvent[] => {
+ const typeSet = new Set(STATE_EVENT_TYPES);
+ return room
+ .getLiveTimeline()
+ .getEvents()
+ .filter((ev) => typeSet.has(ev.getType()) && !ev.isRedacted())
+ .slice()
+ .reverse();
+ }, [room]);
+
+ const [events, setEvents] = useState(() => getStateEvents());
+
+ useEffect(() => {
+ setEvents(getStateEvents());
+ }, [getStateEvents]);
+
+ const handleLoadMore = useCallback(async () => {
+ if (loading || !canLoadMore) return;
+ setLoading(true);
+ try {
+ const hasMore = await mx.paginateEventTimeline(room.getLiveTimeline(), {
+ backwards: true,
+ limit: 50,
+ });
+ setEvents(getStateEvents());
+ setCanLoadMore(hasMore);
+ setHasLoadedOnce(true);
+ } catch {
+ // silently fail — user can retry
+ } finally {
+ setLoading(false);
+ }
+ }, [loading, canLoadMore, mx, room, getStateEvents]);
+
+ // Build described entries
+ const entries: Array<{ ev: MatrixEvent; desc: EventDesc }> = [];
+ for (const ev of events) {
+ const desc = describeEvent(mx, ev);
+ if (!desc) continue;
+ if (filter !== 'all' && desc.filter !== filter) continue;
+ entries.push({ ev, desc });
+ }
+
+ return (
+
+
+
+
+
+ Activity Log
+
+
+
+
+
+
+
+
+
+
+ {/* Filter chips */}
+
+ {(
+ [
+ { key: 'all', label: 'All' },
+ { key: 'members', label: 'Members' },
+ { key: 'power', label: 'Power' },
+ { key: 'room', label: 'Room changes' },
+ ] as { key: ActivityFilter; label: string }[]
+ ).map(({ key, label }) => (
+ setFilter(key)}
+ />
+ ))}
+
+
+
+
+
+
+ {/* Loading skeleton on first load */}
+ {loading && !hasLoadedOnce && (
+
+
+
+ )}
+
+ {/* Empty state */}
+ {!loading && entries.length === 0 && (
+
+
+
+ No room activity found
+
+
+ )}
+
+ {/* Log entries */}
+ {entries.map(({ ev, desc }) => (
+
+ ))}
+
+ {/* Load more */}
+ {canLoadMore && !loading && (
+
+
+
+ )}
+
+ {/* Inline spinner while paginating */}
+ {loading && hasLoadedOnce && (
+
+
+
+ )}
+
+ {/* End of history */}
+ {!canLoadMore && hasLoadedOnce && (
+
+ Beginning of history
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/room-settings/RoomSettings.tsx b/src/app/features/room-settings/RoomSettings.tsx
index 7a0b452f7..5b98bf033 100644
--- a/src/app/features/room-settings/RoomSettings.tsx
+++ b/src/app/features/room-settings/RoomSettings.tsx
@@ -17,6 +17,8 @@ import { Permissions } from './permissions';
import { RoomSettingsPage } from '../../state/roomSettings';
import { useRoom } from '../../hooks/useRoom';
import { DeveloperTools } from '../common-settings/developer-tools';
+import { ExportRoomHistory } from './ExportRoomHistory';
+import { RoomActivityLog } from './RoomActivityLog';
type RoomSettingsMenuItem = {
page: RoomSettingsPage;
@@ -52,6 +54,16 @@ const useRoomSettingsMenuItems = (): RoomSettingsMenuItem[] =>
name: 'Developer Tools',
icon: Icons.Terminal,
},
+ {
+ page: RoomSettingsPage.ExportPage,
+ name: 'Export',
+ icon: Icons.Download,
+ },
+ {
+ page: RoomSettingsPage.ActivityLogPage,
+ name: 'Activity',
+ icon: Icons.RecentClock,
+ },
],
[],
);
@@ -172,6 +184,12 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
{activePage === RoomSettingsPage.DeveloperToolsPage && (
)}
+ {activePage === RoomSettingsPage.ExportPage && (
+
+ )}
+ {activePage === RoomSettingsPage.ActivityLogPage && (
+
+ )}
);
}
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
index 142945555..56a445725 100644
--- a/src/app/features/room/RoomInput.tsx
+++ b/src/app/features/room/RoomInput.tsx
@@ -26,6 +26,7 @@ import {
Scroll,
Spinner,
Text,
+ color,
config,
toRem,
} from 'folds';
@@ -122,6 +123,7 @@ import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useComposingCheck } from '../../hooks/useComposingCheck';
import { VoiceMessageRecorder } from '../../components/VoiceMessageRecorder';
import { PollCreator } from './PollCreator';
+import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus';
const GifPicker = React.lazy(() =>
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
@@ -144,6 +146,15 @@ export const RoomInput = forwardRef(
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
+ const [warnOnUnverifiedDevices] = useSetting(settingsAtom, 'warnOnUnverifiedDevices');
+ const crypto = mx.getCrypto();
+ const roomUnverifiedDeviceCount = useRoomUnverifiedDeviceCount(crypto, room);
+ const isEncrypted = room.hasEncryptionStateEvent();
+ const showUnverifiedWarning =
+ warnOnUnverifiedDevices &&
+ isEncrypted &&
+ roomUnverifiedDeviceCount !== undefined &&
+ roomUnverifiedDeviceCount > 0;
const direct = useIsDirectRoom();
const commands = useCommands(mx, room);
const emojiBtnRef = useRef(null);
@@ -718,6 +729,27 @@ export const RoomInput = forwardRef(
requestClose={handleCloseAutocomplete}
/>
)}
+ {showUnverifiedWarning && (
+
+
+
+ {roomUnverifiedDeviceCount}{' '}
+ {roomUnverifiedDeviceCount === 1 ? 'unverified device' : 'unverified devices'} in this
+ room
+
+
+ )}
@@ -896,6 +900,19 @@ function Privacy() {
after={}
/>
+
+
+ }
+ />
+
);
}
diff --git a/src/app/hooks/useCallSpeakers.ts b/src/app/hooks/useCallSpeakers.ts
index 6360087a0..013e5f9de 100644
--- a/src/app/hooks/useCallSpeakers.ts
+++ b/src/app/hooks/useCallSpeakers.ts
@@ -58,3 +58,80 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set => {
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;
+};
diff --git a/src/app/hooks/useDeviceVerificationStatus.ts b/src/app/hooks/useDeviceVerificationStatus.ts
index 01011b4f6..c17e395fa 100644
--- a/src/app/hooks/useDeviceVerificationStatus.ts
+++ b/src/app/hooks/useDeviceVerificationStatus.ts
@@ -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();
+ 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[] = [];
+
+ 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;
+};
diff --git a/src/app/state/roomSettings.ts b/src/app/state/roomSettings.ts
index 327db596e..0afbf1e2f 100644
--- a/src/app/state/roomSettings.ts
+++ b/src/app/state/roomSettings.ts
@@ -6,6 +6,8 @@ export enum RoomSettingsPage {
PermissionsPage,
EmojisStickersPage,
DeveloperToolsPage,
+ ExportPage,
+ ActivityLogPage,
}
export type RoomSettingsState = {
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index ff8d8ae6e..0017f525b 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -90,6 +90,8 @@ export interface Settings {
nightLightOpacity: number;
deafenKey: string;
+
+ warnOnUnverifiedDevices: boolean;
}
const defaultSettings: Settings = {
@@ -149,6 +151,8 @@ const defaultSettings: Settings = {
nightLightOpacity: 30,
deafenKey: 'KeyM',
+
+ warnOnUnverifiedDevices: false,
};
export const getSettings = (): Settings => {