fix(export): resolve edits into the original row; mark media rows

m.replace events no longer appear as their own "* text" line — the
original row gets m.new_content and an (edited) marker. Media rows carry
a [Image]/[Video]/[Audio]/[File] prefix and the JSON export includes
mediaUrl; the UI notes attachments aren't included. Unit-tested.

Fixes #50
Fixes #81

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-12 20:28:42 -04:00
co-authored by Claude Opus 5
parent 7e64ea398b
commit 9f8fae964d
3 changed files with 220 additions and 13 deletions
@@ -7,6 +7,7 @@ import { useRoom } from '../../hooks/useRoom';
import { useRoomName } from '../../hooks/useRoomMeta';
import { SequenceCard } from '../../components/sequence-card';
import { SequenceCardStyle } from '../common-settings/styles.css';
import { RawExportRecord, formatExportBody, resolveMessageEdits } from './exportRoomHistory.utils';
type ExportFormat = 'txt' | 'json' | 'html';
@@ -63,15 +64,11 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
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[] = [];
const rawRecords: RawExportRecord[] = [];
// mxc/media-file URL for message events that carry one, keyed by eventId -
// surfaced in the JSON export since the export never includes the actual
// media (see the UI note below the Export button).
const mediaUrlByEventId = new Map<string, string>();
// 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.
@@ -113,11 +110,39 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
if (fromTs !== null && ts < fromTs) continue;
if (toTs !== null && ts > toTs) continue;
const content = ev.getContent();
// m.replace (edit) events must not become their own row — collect
// them separately and substitute the new content into the original
// event's row below, instead of adding a stale original + a garbled
// "* new text" duplicate line.
const relatesTo = content['m.relates_to'] as
| { rel_type?: string; event_id?: string }
| undefined;
if (relatesTo?.rel_type === 'm.replace' && relatesTo.event_id) {
const newContent = content['m.new_content'] as { body?: string } | undefined;
const newBody = newContent?.body ?? '';
if (!newBody) continue;
rawRecords.push({
eventId: evId,
ts,
sender: ev.getSender() ?? '',
body: '',
msgtype: '',
editsEventId: relatesTo.event_id,
newBody,
});
continue;
}
const body: string = content.body ?? '';
const msgtype: string = content.msgtype ?? '';
if (!body) continue;
if (ts < oldestTs) oldestTs = ts;
collected.push({
const mediaUrl =
(content.url as string | undefined) ??
(content.file as { url?: string } | undefined)?.url;
if (mediaUrl) mediaUrlByEventId.set(evId, mediaUrl);
rawRecords.push({
ts,
sender: ev.getSender() ?? '',
body,
@@ -125,7 +150,9 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
msgtype,
});
}
setExportCount(collected.length);
// Approximate progress — excludes edit rows, which never become their
// own line in the final (edit-resolved) output.
setExportCount(rawRecords.filter((r) => !r.editsEventId).length);
};
await addEvents(timeline.getEvents());
@@ -159,6 +186,10 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
await addEvents(timeline.getEvents());
}
// Resolve m.replace edits against their target row (drops the edit rows,
// substitutes m.new_content into the original) before sorting/rendering.
const collected = resolveMessageEdits(rawRecords);
if (cancelled) {
setNotice(`Export cancelled after ${collected.length} messages.`);
return;
@@ -186,7 +217,7 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
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}`);
lines.push(`[${dateLabel}] ${msg.sender}: ${formatExportBody(msg)}`);
}
content = lines.join('\n');
mimeType = 'text/plain';
@@ -201,6 +232,8 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
body: m.body,
eventId: m.eventId,
type: m.msgtype,
edited: m.edited,
mediaUrl: mediaUrlByEventId.get(m.eventId),
})),
};
content = JSON.stringify(payload, null, 2);
@@ -220,7 +253,7 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
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>`;
return `<div class="msg"><span class="ts">[${esc(dateLabel)}]</span> <span class="sender">${esc(msg.sender)}</span><span class="body">: ${esc(formatExportBody(msg))}</span></div>`;
})
.join('\n');
@@ -388,6 +421,11 @@ ${msgRows}
{notice}
</Text>
)}
<Text size="T200" priority="300">
Attachments (images, videos, audio, files) are not included in the export -
media messages are marked with a type label (e.g. &quot;[Image]&quot;) but only
their filename is exported, not the file itself.
</Text>
</SequenceCard>
</Box>
</Box>