feat(ux): forward to multiple rooms + live bookmark previews (P6-3)
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>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user