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:
@@ -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, '&')
|
||||
.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 `<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)} — ${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 “<strong>{newName ?? ''}</strong>” 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ export enum RoomSettingsPage {
|
||||
PermissionsPage,
|
||||
EmojisStickersPage,
|
||||
DeveloperToolsPage,
|
||||
ExportPage,
|
||||
ActivityLogPage,
|
||||
}
|
||||
|
||||
export type RoomSettingsState = {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user