Files
cinny/src/app/features/room-settings/ExportRoomHistory.tsx
T
jared b361d43088 fix(ui): native inputs/checkboxes, QR fallback, focus + report modal cleanup
- N23 RoomServerACL: raw text input -> folds Input; raw checkbox -> folds Checkbox
- N24 PolicyListViewer: raw room-id input -> folds Input (Critical variant on error)
- N25 ExportRoomHistory: raw <input type="date"> x2 -> folds Input
- N26 RoomShareInvite: QR <img> gets loading="lazy" + onError fallback card
  ("QR code unavailable") instead of a broken-image icon
- N27 GifPicker: FocusTrap returnFocusOnDeactivate:false (matches EmojiBoard)
- N76 Report modals: drop redundant Cancel button (dismiss via header x /
  click-outside, like MessageReportItem)
- N5 ReadReceiptAvatars: hover/focus moved to co-located css :hover/:focus-visible
  (removed JS onMouseEnter/Leave .style mutation)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 18:12:25 -04:00

327 lines
12 KiB
TypeScript

import React, { useCallback, useState } from 'react';
import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Spinner, Text } 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[] = [];
// timeline.getEvents() returns the entire growing window on every call,
// so we must deduplicate by eventId to avoid re-adding the same events
// on each pagination step.
const seen = new Set<string>();
const timeline = room.getLiveTimeline();
let canLoadMore = true;
const addEvents = async (events: ReturnType<typeof timeline.getEvents>) => {
for (const ev of events) {
const evId = ev.getId();
if (!evId || seen.has(evId)) continue;
seen.add(evId);
// Attempt decryption for events that haven't been decrypted yet
// (paginateEventTimeline may fetch events before the SDK decrypts them)
if (ev.isEncrypted() && !ev.getClearContent()) {
// eslint-disable-next-line no-await-in-loop
await mx.decryptEventIfNeeded(ev).catch(() => undefined);
}
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: evId,
msgtype,
});
}
setExportCount(collected.length);
};
await 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,
});
// eslint-disable-next-line no-await-in-loop
await 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"
variant="Secondary"
size="400"
radii="300"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
/>
</Box>
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
<Text size="T300">To</Text>
<Input
type="date"
variant="Secondary"
size="400"
radii="300"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
/>
</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>
);
}