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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user