668bdaad7d
Web fixes from the Wave-2 bug-hunt (findings in LOTUS_TODO): - F1 (security): wipe the decrypted-plaintext search index on SERVER-FORCED logout too (token expiry / remote sign-out) — only manual logout did before. F4: the delete no longer reports success while onblocked (waits, 3s cap). - M1/M2 (data-loss): useBookmarks + useUserNotes account-data writes are now serialized at MODULE scope (single queue + latestRef per client, echo-driven), fixing the cross-instance lost-update clobber (useBookmarks mounts per message row, so a per-instance queue was insufficient — caught in review). - M6: room-history export gets a 200-page cap + Cancel + unmount-abort + correct date-range early-break (raw paginated ts). M4: image compression skips PNG (was flattening transparency to black), bakes EXIF orientation via createImageBitmap, .jpg-renames, and falls back to the original on decode failure instead of dropping the file. M5: MediaGallery lightbox opens the right item (shared thumb guard). M8: audio speed survives async decrypt. - Desktop web wiring: D2 badge sums leaf rooms only (space double-count, like the favicon fix); D3 useTauriDnd re-hydrates from get_tray_dnd on mount; D5 updater has a terminal state. Reviewed; M7 reverted (past-time clamp is an intentional, tested contract). tsc/eslint/prettier clean, build OK, 678 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
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);
|
||
// 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 (ts < oldestRawTs) oldestRawTs = ts;
|
||
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, '&')
|
||
.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);
|
||
|
||
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>
|
||
);
|
||
}
|