ebcd8ec926
Forward: checkbox multi-select room picker + "Send to N rooms" batch send (Promise.allSettled). Full success auto-closes; partial failure keeps the dialog open with a "Forwarded to X/N — failed: …" summary and prunes the selection to only the failures (retry won't duplicate to already-sent rooms). Content builder extracted to a unit-tested forwardContent.ts (edit-forwarding, reply-strip, undecryptable-refused; 4 tests). Bookmarks: BookmarksPanel resolves each saved message's live event (useRoomEvent) so previews reflect edits and show a deleted indicator for redactions; the stored snapshot stays as the fallback while loading, on fetch failure, or after leaving the room. Stored bookmark shape unchanged. Gates: tsc/eslint/prettier clean, build OK, 665 tests. Reviewed (dup-resend on retry + Checkbox readOnly fixed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
139 lines
3.9 KiB
TypeScript
139 lines
3.9 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
|
import { buildForwardContent } from './forwardContent';
|
|
|
|
// Pure content builder buildForwardContent: refuses undecryptable events, forwards
|
|
// the latest edit (`m.new_content`), and strips reply fallbacks + `m.relates_to`.
|
|
// MatrixClient / MatrixEvent are mocked minimally. getEditedEvent reads edits off
|
|
// `timelineSet.relations.getChildEventsForEvent(...).getRelations()`, so the base
|
|
// client returns no child edits and the edit test injects one.
|
|
|
|
const SENDER = '@me:example.org';
|
|
|
|
type EventOptions = {
|
|
content?: Record<string, unknown>;
|
|
type?: string;
|
|
id?: string;
|
|
roomId?: string;
|
|
decryptionFailure?: boolean;
|
|
ts?: number;
|
|
};
|
|
|
|
const makeEvent = (options: EventOptions = {}): MatrixEvent => {
|
|
const {
|
|
content = {},
|
|
type = 'm.room.message',
|
|
id = '$evt:example.org',
|
|
roomId = '!room:example.org',
|
|
decryptionFailure = false,
|
|
ts = 0,
|
|
} = options;
|
|
|
|
return {
|
|
getContent: () => content,
|
|
getType: () => type,
|
|
getId: () => id,
|
|
getRoomId: () => roomId,
|
|
getSender: () => SENDER,
|
|
getTs: () => ts,
|
|
isDecryptionFailure: () => decryptionFailure,
|
|
} as unknown as MatrixEvent;
|
|
};
|
|
|
|
// Base client: the timeline reports no `m.replace` edits, so the original content
|
|
// is forwarded unchanged.
|
|
const makeClient = (): MatrixClient =>
|
|
({
|
|
getRoom: () => ({
|
|
getUnfilteredTimelineSet: () => ({
|
|
relations: {
|
|
getChildEventsForEvent: () => null,
|
|
},
|
|
}),
|
|
}),
|
|
}) as unknown as MatrixClient;
|
|
|
|
test('plain text forwards the body and strips m.relates_to', () => {
|
|
const mx = makeClient();
|
|
const mEvent = makeEvent({
|
|
content: {
|
|
msgtype: 'm.text',
|
|
body: 'hello world',
|
|
'm.relates_to': { rel_type: 'm.thread', event_id: '$root:example.org' },
|
|
},
|
|
});
|
|
|
|
const content = buildForwardContent(mx, mEvent);
|
|
|
|
assert.ok(content);
|
|
assert.equal(content.body, 'hello world');
|
|
assert.equal(content.msgtype, 'm.text');
|
|
assert.equal(content['m.relates_to'], undefined);
|
|
});
|
|
|
|
test('reply quote is stripped from body and formatted_body', () => {
|
|
const mx = makeClient();
|
|
const mEvent = makeEvent({
|
|
content: {
|
|
msgtype: 'm.text',
|
|
body: '> <@alice:example.org> original\n\nmy reply',
|
|
format: 'org.matrix.custom.html',
|
|
formatted_body: '<mx-reply><blockquote>original</blockquote></mx-reply>my reply',
|
|
'm.relates_to': { 'm.in_reply_to': { event_id: '$root:example.org' } },
|
|
},
|
|
});
|
|
|
|
const content = buildForwardContent(mx, mEvent);
|
|
|
|
assert.ok(content);
|
|
assert.equal(content.body, 'my reply');
|
|
assert.equal(content.formatted_body, 'my reply');
|
|
assert.equal(content['m.relates_to'], undefined);
|
|
});
|
|
|
|
test('decryption failure returns undefined', () => {
|
|
const mx = makeClient();
|
|
const mEvent = makeEvent({
|
|
content: { msgtype: 'm.bad.encrypted' },
|
|
decryptionFailure: true,
|
|
});
|
|
|
|
assert.equal(buildForwardContent(mx, mEvent), undefined);
|
|
});
|
|
|
|
test('edited message forwards m.new_content', () => {
|
|
const mEvent = makeEvent({
|
|
content: {
|
|
msgtype: 'm.text',
|
|
body: 'original body',
|
|
'm.relates_to': { rel_type: 'm.thread', event_id: '$root:example.org' },
|
|
},
|
|
});
|
|
|
|
// The latest `m.replace` edit carries the new content under `m.new_content`.
|
|
const editEvent = makeEvent({
|
|
content: { 'm.new_content': { msgtype: 'm.text', body: 'edited body' } },
|
|
ts: 100,
|
|
});
|
|
const mx = {
|
|
getRoom: () => ({
|
|
getUnfilteredTimelineSet: () => ({
|
|
relations: {
|
|
getChildEventsForEvent: () => ({
|
|
getRelations: () => [editEvent],
|
|
}),
|
|
},
|
|
}),
|
|
}),
|
|
} as unknown as MatrixClient;
|
|
|
|
const content = buildForwardContent(mx, mEvent);
|
|
|
|
assert.ok(content);
|
|
assert.equal(content.body, 'edited body');
|
|
assert.equal(content.msgtype, 'm.text');
|
|
assert.equal(content['m.new_content'], undefined);
|
|
assert.equal(content['m.relates_to'], undefined);
|
|
});
|