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>
@@ -0,0 +1,93 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
resolveMessageEdits,
formatExportBody,
RawExportRecord,
ResolvedExportRecord,
} from './exportRoomHistory.utils';
const msg = (over: Partial<RawExportRecord>): RawExportRecord => ({
eventId: '$1',
ts: 1000,
sender: '@alice:example.org',
body: 'hello',
msgtype: 'm.text',
...over,
});
test('resolveMessageEdits leaves unedited messages untouched', () => {
const out = resolveMessageEdits([msg({ eventId: '$1', body: 'hi' })]);
assert.deepEqual(out, [
{
eventId: '$1',
ts: 1000,
sender: '@alice:example.org',
body: 'hi',
msgtype: 'm.text',
edited: false,
},
]);
});
test('resolveMessageEdits substitutes the new content into the original row and drops the edit row', () => {
const out = resolveMessageEdits([
msg({ eventId: '$1', body: 'origianl typo' }),
msg({ eventId: '$2', editsEventId: '$1', newBody: 'original fixed' }),
]);
assert.equal(out.length, 1);
assert.equal(out[0].eventId, '$1');
assert.equal(out[0].body, 'original fixed');
assert.equal(out[0].edited, true);
});
test('resolveMessageEdits works regardless of whether the edit appears before its target', () => {
const out = resolveMessageEdits([
msg({ eventId: '$2', editsEventId: '$1', newBody: 'fixed' }),
msg({ eventId: '$1', body: 'orig' }),
]);
assert.equal(out.length, 1);
assert.equal(out[0].body, 'fixed');
assert.equal(out[0].edited, true);
});
test('resolveMessageEdits ignores an edit with no matching target (target never collected)', () => {
const out = resolveMessageEdits([msg({ eventId: '$2', editsEventId: '$1', newBody: 'fixed' })]);
assert.deepEqual(out, []);
});
test('formatExportBody prefixes media messages with a type marker', () => {
const record: ResolvedExportRecord = {
eventId: '$1',
ts: 1000,
sender: '@alice:example.org',
body: 'photo.jpg',
msgtype: 'm.image',
edited: false,
};
assert.equal(formatExportBody(record), '[Image] photo.jpg');
});
test('formatExportBody appends an (edited) suffix', () => {
const record: ResolvedExportRecord = {
eventId: '$1',
ts: 1000,
sender: '@alice:example.org',
body: 'fixed text',
msgtype: 'm.text',
edited: true,
};
assert.equal(formatExportBody(record), 'fixed text (edited)');
});
test('formatExportBody combines media prefix and edited suffix', () => {
const record: ResolvedExportRecord = {
eventId: '$1',
ts: 1000,
sender: '@alice:example.org',
body: 'photo.jpg',
msgtype: 'm.file',
edited: true,
};
assert.equal(formatExportBody(record), '[File] photo.jpg (edited)');
});
@@ -0,0 +1,76 @@
// Pure helpers for ExportRoomHistory.tsx, kept SDK/DOM-free so they're easy to
// unit test in isolation.
export type RawExportRecord = {
eventId: string;
ts: number;
sender: string;
body: string;
msgtype: string;
// Set when this record IS an m.replace edit event, to the event id it targets.
editsEventId?: string;
// The m.new_content.body carried by an m.replace edit event.
newBody?: string;
};
export type ResolvedExportRecord = {
eventId: string;
ts: number;
sender: string;
body: string;
msgtype: string;
edited: boolean;
};
// Human-readable label for media msgtypes, used to prefix txt/html export rows
// so a media message isn't indistinguishable from a plain-text one.
export const MEDIA_TYPE_LABELS: Partial<Record<string, string>> = {
'm.image': 'Image',
'm.video': 'Video',
'm.audio': 'Audio',
'm.file': 'File',
};
/**
* Resolves m.replace (edit) events against the original message they target:
* the edit event is dropped from the output (it should not appear as its own
* row), and the original message's body is replaced with the edit's
* `m.new_content.body`, marked `edited: true`.
*
* Order-independent: edits may appear before or after their target in the
* input (e.g. across separate back-pagination batches).
*/
export function resolveMessageEdits(records: RawExportRecord[]): ResolvedExportRecord[] {
const edits = new Map<string, string>();
for (const record of records) {
if (record.editsEventId && record.newBody) {
edits.set(record.editsEventId, record.newBody);
}
}
const resolved: ResolvedExportRecord[] = [];
for (const record of records) {
// Edit events never become their own row.
if (record.editsEventId) continue;
const editedBody = edits.get(record.eventId);
resolved.push({
eventId: record.eventId,
ts: record.ts,
sender: record.sender,
body: editedBody ?? record.body,
msgtype: record.msgtype,
edited: editedBody !== undefined,
});
}
return resolved;
}
// Prefixes a media message's body with a "[Image]"-style marker for txt/html
// export output, and appends an "(edited)" suffix when applicable. Plain-text
// messages are returned unchanged (aside from the edited suffix).
export function formatExportBody(record: ResolvedExportRecord): string {
const label = MEDIA_TYPE_LABELS[record.msgtype];
const mediaPrefix = label ? `[${label}] ` : '';
const editedSuffix = record.edited ? ' (edited)' : '';
return `${mediaPrefix}${record.body}${editedSuffix}`;
}