feat: PiP mute indicator, export history, activity log, unverified device warning

- 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 6d0b778755
commit ee717e8361
10 changed files with 1030 additions and 1 deletions
+33
View File
@@ -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 (
<div
aria-label="Microphone muted"
style={{
position: 'absolute',
bottom: '8px',
left: '8px',
zIndex: 3,
background: 'rgba(0,0,0,0.60)',
backdropFilter: 'blur(4px)',
borderRadius: '6px',
padding: '3px 7px',
display: 'flex',
alignItems: 'center',
gap: '4px',
pointerEvents: 'none',
color: color.Critical.Main,
fontSize: '13px',
lineHeight: 1,
userSelect: 'none',
}}
>
<Icon size="100" src={Icons.MicMute} filled />
</div>
);
}
type CallEmbedProviderProps = {
children?: ReactNode;
};
@@ -803,6 +835,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
Return to call
</div>
</div>
<PipMuteOverlay callEmbed={callEmbed} />
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => {
const s = corner.includes('s');
const e2 = corner.includes('e');
@@ -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<ExportFormat, string> = {
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<ExportFormat>('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<typeof timeline.getEvents>) => {
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
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 `<div class="msg"><span class="ts">[${esc(dateLabel)}]</span> <span class="sender">${esc(msg.sender)}</span><span class="body">: ${esc(msg.body)}</span></div>`;
})
.join('\n');
content = `<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><title>Export: ${esc(roomName)}</title>
<style>body{background:#0d0d0d;color:#c4d9ee;font-family:monospace;padding:20px}
.msg{margin:4px 0;line-height:1.5}
.ts{color:#555;font-size:0.85em}
.sender{color:#FF6B00;font-weight:bold}
.body{color:#c4d9ee}</style></head>
<body><h2 style="color:#00D4FF">Room: ${esc(roomName)}</h2>
<p style="color:#555">Exported ${esc(exportedAt)} &mdash; ${collected.length} messages</p>
<div class="messages">
${msgRows}
</div></body></html>`;
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 (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text as="h2" size="H3" truncate>
Export
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
{/* Format */}
<Box direction="Column" gap="100">
<Text size="L400">Format</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<Box gap="200" wrap="Wrap">
{(Object.keys(FORMAT_LABELS) as ExportFormat[]).map((f) => (
<Button
key={f}
size="300"
variant={format === f ? 'Primary' : 'Secondary'}
fill={format === f ? 'Soft' : 'None'}
radii="300"
onClick={() => setFormat(f)}
aria-pressed={format === f}
>
<Text size="B300">{FORMAT_LABELS[f]}</Text>
</Button>
))}
</Box>
</SequenceCard>
</Box>
{/* Date range */}
<Box direction="Column" gap="100">
<Text size="L400">Date Range (optional)</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<Box gap="400" wrap="Wrap">
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
<Text size="T300">From</Text>
<input
type="date"
value={fromDate}
onChange={(e) => 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',
}}
/>
</Box>
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
<Text size="T300">To</Text>
<input
type="date"
value={toDate}
onChange={(e) => 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',
}}
/>
</Box>
</Box>
<Text size="T200" priority="300">
Leave blank to export all available history.
</Text>
</SequenceCard>
</Box>
{/* Export */}
<Box direction="Column" gap="100">
<Text size="L400">Download</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<Box alignItems="Center" gap="400" justifyContent="SpaceBetween">
<Text size="T300" priority="300">
{exporting
? `Exporting… ${exportCount} messages`
: 'Export will download automatically.'}
</Text>
<Button
size="400"
variant="Primary"
fill="Solid"
radii="300"
disabled={exporting}
onClick={handleExport}
before={
exporting ? (
<Spinner size="200" />
) : (
<Icon src={Icons.Download} size="100" />
)
}
>
<Text size="B400">{exporting ? 'Exporting…' : 'Export'}</Text>
</Button>
</Box>
</SequenceCard>
</Box>
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}
@@ -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<typeof useMatrixClient>, 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<typeof useMatrixClient>, ev: MatrixEvent): EventDesc | null {
const type = ev.getType() as StateEventType;
const sender = ev.getSender() ?? '';
const content = ev.getContent<Record<string, unknown>>();
const prevContent = ev.getPrevContent() as Record<string, unknown>;
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: (
<>
<strong>{targetName}</strong> joined
</>
),
iconSrc: Icons.User,
filter: 'members',
};
}
// prevMembership === 'join' → profile update
return {
text: (
<>
<strong>{targetName}</strong> updated their profile
</>
),
iconSrc: Icons.User,
filter: 'members',
};
}
if (membership === 'leave') {
if (prevMembership === 'ban') {
return {
text: (
<>
<strong>{targetName}</strong> was unbanned by <strong>{senderName}</strong>
</>
),
iconSrc: Icons.User,
filter: 'members',
};
}
if (sender === stateKey) {
return {
text: (
<>
<strong>{targetName}</strong> left
</>
),
iconSrc: Icons.User,
filter: 'members',
};
}
return {
text: (
<>
<strong>{targetName}</strong> was kicked by <strong>{senderName}</strong>
{reason ? ` (${reason})` : ''}
</>
),
iconSrc: Icons.User,
filter: 'members',
};
}
if (membership === 'ban') {
return {
text: (
<>
<strong>{targetName}</strong> was banned by <strong>{senderName}</strong>
{reason ? ` (${reason})` : ''}
</>
),
iconSrc: Icons.User,
filter: 'members',
};
}
if (membership === 'invite') {
return {
text: (
<>
<strong>{targetName}</strong> was invited by <strong>{senderName}</strong>
</>
),
iconSrc: Icons.UserPlus,
filter: 'members',
};
}
return null;
}
case 'm.room.power_levels':
return {
text: (
<>
Power levels updated by <strong>{senderName}</strong>
</>
),
iconSrc: Icons.ShieldUser,
filter: 'power',
};
case 'm.room.name': {
const newName = content.name as string | undefined;
return {
text: (
<>
Room renamed to &ldquo;<strong>{newName ?? ''}</strong>&rdquo; by{' '}
<strong>{senderName}</strong>
</>
),
iconSrc: Icons.Pencil,
filter: 'room',
};
}
case 'm.room.topic':
return {
text: (
<>
Topic updated by <strong>{senderName}</strong>
</>
),
iconSrc: Icons.Pencil,
filter: 'room',
};
case 'm.room.avatar':
return {
text: (
<>
Room avatar changed by <strong>{senderName}</strong>
</>
),
iconSrc: Icons.Photo,
filter: 'room',
};
case 'm.room.server_acl':
return {
text: (
<>
Server ACL updated by <strong>{senderName}</strong>
</>
),
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 (
<Button
size="300"
variant={active ? 'Primary' : 'Secondary'}
fill={active ? 'Soft' : 'None'}
radii="300"
onClick={onClick}
>
<Text size="B300">{label}</Text>
</Button>
);
}
// ── Log entry ─────────────────────────────────────────────────────────────────
type LogEntryProps = {
ev: MatrixEvent;
desc: EventDesc;
};
function LogEntry({ ev, desc }: LogEntryProps) {
return (
<Box
alignItems="Center"
gap="300"
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
}}
>
<Box
shrink="No"
alignItems="Center"
justifyContent="Center"
style={{
width: 32,
height: 32,
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
flexShrink: 0,
}}
>
<Icon src={desc.iconSrc} size="200" />
</Box>
<Box grow="Yes" direction="Column" style={{ overflow: 'hidden', gap: 2 }}>
<Text size="T300" style={{ lineHeight: 1.4 }}>
{desc.text}
</Text>
<Text size="T200" priority="300">
{formatRelativeTs(ev.getTs())}
</Text>
</Box>
</Box>
);
}
// ── Main component ────────────────────────────────────────────────────────────
type RoomActivityLogProps = {
requestClose: () => void;
};
export function RoomActivityLog({ requestClose }: RoomActivityLogProps) {
const mx = useMatrixClient();
const room = useRoom();
const [filter, setFilter] = useState<ActivityFilter>('all');
const [loading, setLoading] = useState(false);
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
const [canLoadMore, setCanLoadMore] = useState(true);
const getStateEvents = useCallback((): MatrixEvent[] => {
const typeSet = new Set<string>(STATE_EVENT_TYPES);
return room
.getLiveTimeline()
.getEvents()
.filter((ev) => typeSet.has(ev.getType()) && !ev.isRedacted())
.slice()
.reverse();
}, [room]);
const [events, setEvents] = useState<MatrixEvent[]>(() => 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 (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text as="h2" size="H3" truncate>
Activity Log
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
{/* Filter chips */}
<Box
shrink="No"
gap="100"
wrap="Wrap"
style={{ padding: `${config.space.S200} ${config.space.S300}` }}
>
{(
[
{ 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 }) => (
<FilterChip
key={key}
label={label}
active={filter === key}
onClick={() => setFilter(key)}
/>
))}
</Box>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="200">
{/* Loading skeleton on first load */}
{loading && !hasLoadedOnce && (
<Box justifyContent="Center" style={{ padding: config.space.S400 }}>
<Spinner />
</Box>
)}
{/* Empty state */}
{!loading && entries.length === 0 && (
<Box
direction="Column"
alignItems="Center"
gap="200"
style={{ padding: config.space.S600 }}
>
<Icon src={Icons.RecentClock} size="600" />
<Text size="T300" priority="300" align="Center">
No room activity found
</Text>
</Box>
)}
{/* Log entries */}
{entries.map(({ ev, desc }) => (
<LogEntry key={ev.getId()} ev={ev} desc={desc} />
))}
{/* Load more */}
{canLoadMore && !loading && (
<Box justifyContent="Center" style={{ paddingTop: config.space.S200 }}>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={handleLoadMore}
>
<Text size="B300">Load more</Text>
</Button>
</Box>
)}
{/* Inline spinner while paginating */}
{loading && hasLoadedOnce && (
<Box justifyContent="Center" style={{ padding: config.space.S300 }}>
<Spinner />
</Box>
)}
{/* End of history */}
{!canLoadMore && hasLoadedOnce && (
<Text
size="T200"
priority="300"
align="Center"
style={{ padding: `${config.space.S200} 0` }}
>
Beginning of history
</Text>
)}
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}
@@ -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 && (
<DeveloperTools requestClose={handlePageRequestClose} />
)}
{activePage === RoomSettingsPage.ExportPage && (
<ExportRoomHistory requestClose={handlePageRequestClose} />
)}
{activePage === RoomSettingsPage.ActivityLogPage && (
<RoomActivityLog requestClose={handlePageRequestClose} />
)}
</PageRoot>
);
}
+32
View File
@@ -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<HTMLDivElement, RoomInputProps>(
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<HTMLButtonElement>(null);
@@ -718,6 +729,27 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
requestClose={handleCloseAutocomplete}
/>
)}
{showUnverifiedWarning && (
<Box
alignItems="Center"
gap="200"
style={{
padding: `${config.space.S100} ${config.space.S300}`,
background: color.Warning.Container,
}}
>
<Icon
size="100"
src={Icons.Shield}
style={{ color: color.Warning.OnContainer, flexShrink: 0 }}
/>
<Text size="T200" style={{ color: color.Warning.OnContainer }}>
{roomUnverifiedDeviceCount}{' '}
{roomUnverifiedDeviceCount === 1 ? 'unverified device' : 'unverified devices'} in this
room
</Text>
</Box>
)}
<CustomEditor
editableName="RoomInput"
editor={editor}
@@ -865,6 +865,10 @@ function Privacy() {
settingsAtom,
'privateReadReceipts',
);
const [warnOnUnverifiedDevices, setWarnOnUnverifiedDevices] = useSetting(
settingsAtom,
'warnOnUnverifiedDevices',
);
return (
<Box direction="Column" gap="100">
@@ -896,6 +900,19 @@ function Privacy() {
after={<Switch variant="Primary" value={hidePresence} onChange={setHidePresence} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Warn before sending to unverified devices"
description="Show a warning in the composer when sending to a room with unverified devices."
after={
<Switch
variant="Primary"
value={warnOnUnverifiedDevices}
onChange={setWarnOnUnverifiedDevices}
/>
}
/>
</SequenceCard>
</Box>
);
}
+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;
};
+2
View File
@@ -6,6 +6,8 @@ export enum RoomSettingsPage {
PermissionsPage,
EmojisStickersPage,
DeveloperToolsPage,
ExportPage,
ActivityLogPage,
}
export type RoomSettingsState = {
+4
View File
@@ -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 => {