Files
cinny/src/app/features/room-settings/ExportRoomHistory.tsx
T
jaredandClaude Opus 4.8 3ff8fb8e55 fix(export): advance the raw pagination boundary on every event
Exporting a date range from an ENCRYPTED room over-paginated and mislabeled
"truncated": oldestRawTs (the how-far-back-have-we-paged boundary) was updated
only after the RoomMessage + decryption-failure filters, so undecryptable or
non-message old events never advanced it, the fromTs break never fired, and the
loop ran to MAX_EXPORT_PAGES. getTs() is unencrypted envelope metadata, so the
boundary update now runs for every event, above the filters.

Guarded with `ts > 0` so a bogus 0/negative origin_server_ts can't collapse the
boundary and cause the opposite failure — a silent early break / under-paginated
export (per review, silent omission in an export is worse than the loud
over-pagination this fixes). oldestTs (oldest collected in-range message) is
unchanged.

Two review agents (both confirmed getTs is decryption-independent, no
intra-page collection regression, oldestRawTs feeds only the fromTs break, no
plaintext regression); the second surfaced the 0-ts under-pagination edge, hence
the guard. Not unit-testable (embedded component + needs an E2EE room with
undecryptable history). Gate-green (tsc, eslint, prettier, 925 tests, build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:47:40 -04:00

400 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Button, Icon, IconButton, Icons, Input, Scroll, 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',
};
const PAGE_LIMIT = 100;
// Hard cap on back-pagination requests. Without a fromDate, "export all" would
// otherwise decrypt and hold every message in the room, hammering the server and
// risking an OOM/freeze with no way to stop. 200 pages × 100 ≈ 20,000 events.
const MAX_EXPORT_PAGES = 200;
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 [notice, setNotice] = useState('');
const cancelledRef = useRef(false);
const handleCancel = useCallback(() => {
cancelledRef.current = true;
}, []);
// Stop an in-flight export if the panel unmounts (closing settings mid-export
// would otherwise keep paginating + decrypting in the background).
useEffect(
() => () => {
cancelledRef.current = true;
},
[],
);
const handleExport = useCallback(async () => {
if (exporting) return;
cancelledRef.current = false;
setExporting(true);
setExportCount(0);
setNotice('');
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;
// Track the oldest collected timestamp incrementally so the fromTs check
// doesn't rescan the whole `collected` array on every pagination step.
let oldestTs = Number.POSITIVE_INFINITY;
// Oldest RAW message ts paginated (tracked BEFORE the fromTs filter). The
// date-range early-break must use this — oldestTs only ever holds collected
// events (all >= fromTs), so it can never fall below fromTs and the export
// would over-paginate to the page cap and show a misleading "truncated".
let oldestRawTs = Number.POSITIVE_INFINITY;
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);
// Advance the raw pagination boundary for EVERY event (any type,
// decrypted or not) — getTs() is unencrypted metadata. Gating this on
// a decrypted m.room.message let undecryptable/non-message old events
// stall oldestRawTs, so the fromTs break never fired → over-paginate
// and a false "truncated".
const ts = ev.getTs();
// Require a positive ts: an event with a bogus 0/negative
// origin_server_ts must not collapse the boundary and trigger an early
// break (silent under-pagination in the export).
if (ts > 0 && ts < oldestRawTs) oldestRawTs = ts;
// 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;
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;
if (ts < oldestTs) oldestTs = ts;
collected.push({
ts,
sender: ev.getSender() ?? '',
body,
eventId: evId,
msgtype,
});
}
setExportCount(collected.length);
};
await addEvents(timeline.getEvents());
// Paginate backwards until start, date range exceeded, cap hit, or cancel
let pageCount = 0;
let truncated = false;
let cancelled = false;
while (canLoadMore) {
if (cancelledRef.current) {
cancelled = true;
break;
}
// If we've paginated back past the fromTs boundary, there's nothing more
// in range to fetch (use the raw paginated ts, not the collected one).
if (fromTs !== null && oldestRawTs < fromTs) break;
// Hard cap so "export all" can't run away and OOM the tab.
if (pageCount >= MAX_EXPORT_PAGES) {
truncated = true;
break;
}
pageCount += 1;
// eslint-disable-next-line no-await-in-loop
canLoadMore = await mx.paginateEventTimeline(timeline, {
backwards: true,
limit: PAGE_LIMIT,
});
// eslint-disable-next-line no-await-in-loop
await addEvents(timeline.getEvents());
}
if (cancelled) {
setNotice(`Export cancelled after ${collected.length} messages.`);
return;
}
// 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);
if (truncated) {
setNotice(
`Export truncated to ${collected.length} messages (reached the ${MAX_EXPORT_PAGES}-page limit). Narrow the date range to export older history.`,
);
}
} 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>
{exporting ? (
<Button
size="400"
variant="Critical"
fill="Soft"
radii="300"
onClick={handleCancel}
before={<Icon src={Icons.Cross} size="100" />}
>
<Text size="B400">Cancel</Text>
</Button>
) : (
<Button
size="400"
variant="Primary"
fill="Solid"
radii="300"
onClick={handleExport}
before={<Icon src={Icons.Download} size="100" />}
>
<Text size="B400">Export</Text>
</Button>
)}
</Box>
{notice && (
<Text size="T200" priority="400">
{notice}
</Text>
)}
</SequenceCard>
</Box>
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}