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 = { 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('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(); const timeline = room.getLiveTimeline(); let canLoadMore = true; const addEvents = async (events: ReturnType) => { 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, '&') .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 `
[${esc(dateLabel)}] ${esc(msg.sender)}: ${esc(msg.body)}
`; }) .join('\n'); content = ` Export: ${esc(roomName)}

Room: ${esc(roomName)}

Exported ${esc(exportedAt)} — ${collected.length} messages

${msgRows}
`; 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 ( Export {/* Format */} Format {(Object.keys(FORMAT_LABELS) as ExportFormat[]).map((f) => ( ))} {/* Date range */} Date Range (optional) From setFromDate(e.target.value)} /> To setToDate(e.target.value)} /> Leave blank to export all available history. {/* Export */} Download {exporting ? `Exporting… ${exportCount} messages` : 'Export will download automatically.'} ); }