2026-06-03 22:13:22 -04:00
|
|
|
import React, { useCallback, useState } from 'react';
|
2026-06-19 18:12:25 -04:00
|
|
|
import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Spinner, Text } from 'folds';
|
2026-06-03 22:13:22 -04:00
|
|
|
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[] = [];
|
2026-06-03 22:44:11 -04:00
|
|
|
// 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>();
|
2026-06-03 22:13:22 -04:00
|
|
|
const timeline = room.getLiveTimeline();
|
|
|
|
|
let canLoadMore = true;
|
|
|
|
|
|
2026-06-15 00:09:54 -04:00
|
|
|
const addEvents = async (events: ReturnType<typeof timeline.getEvents>) => {
|
2026-06-03 22:13:22 -04:00
|
|
|
for (const ev of events) {
|
2026-06-03 22:44:11 -04:00
|
|
|
const evId = ev.getId();
|
|
|
|
|
if (!evId || seen.has(evId)) continue;
|
|
|
|
|
seen.add(evId);
|
2026-06-15 00:09:54 -04:00
|
|
|
// 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);
|
|
|
|
|
}
|
2026-06-03 22:13:22 -04:00
|
|
|
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,
|
2026-06-03 22:44:11 -04:00
|
|
|
eventId: evId,
|
2026-06-03 22:13:22 -04:00
|
|
|
msgtype,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
setExportCount(collected.length);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-15 00:09:54 -04:00
|
|
|
await addEvents(timeline.getEvents());
|
2026-06-03 22:13:22 -04:00
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-15 00:09:54 -04:00
|
|
|
// eslint-disable-next-line no-await-in-loop
|
|
|
|
|
await addEvents(timeline.getEvents());
|
2026-06-03 22:13:22 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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>
|
2026-06-19 18:12:25 -04:00
|
|
|
<Input
|
2026-06-03 22:13:22 -04:00
|
|
|
type="date"
|
2026-06-19 18:12:25 -04:00
|
|
|
variant="Secondary"
|
|
|
|
|
size="400"
|
|
|
|
|
radii="300"
|
2026-06-03 22:13:22 -04:00
|
|
|
value={fromDate}
|
|
|
|
|
onChange={(e) => setFromDate(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
</Box>
|
|
|
|
|
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
|
|
|
|
|
<Text size="T300">To</Text>
|
2026-06-19 18:12:25 -04:00
|
|
|
<Input
|
2026-06-03 22:13:22 -04:00
|
|
|
type="date"
|
2026-06-19 18:12:25 -04:00
|
|
|
variant="Secondary"
|
|
|
|
|
size="400"
|
|
|
|
|
radii="300"
|
2026-06-03 22:13:22 -04:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|