Files
cinny/src/app/utils/detachedTimeline.test.ts
T
jaredandClaude Opus 5 d929143f7d fix(gallery): paginate media, activity log and export on detached timeline sets — never the live timeline (#163)
RoomTimeline renders a numeric index window into the live timeline's
event arrays; SDK back-pagination prepends, so any side panel calling
paginateEventTimeline(room.getLiveTimeline()) shifted the visible
messages into the past on the next render and broke at-bottom tracking.

New utils/detachedTimeline.ts builds a timeline set that mirrors the
already-loaded history and paginates independently: a room-registered
filtered set (server-side contains_url / types filter) when the filter
is usable, else a private EventTimelineSet seeded from the live timeline.
useRoomMediaTimeline wraps it for the gallery (live events + redactions
handled); RoomActivityLog uses a type filter (safe in encrypted rooms);
ExportRoomHistory pages a private set so a full export no longer parks
thousands of events in the live timeline.

Verified with Playwright against a local Synapse in a 400-message plain
room and a 200-message encrypted room: timeline stays at the bottom
through gallery pages, activity load-more and a full export; live
messages keep auto-scrolling; all media found in both rooms.

Also adds scripts/dev-homeserver.sh + scripts/dev-seed.py (local
throwaway Synapse for driving the real UI) and documents them.

Closes #163

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 00:03:54 -04:00

142 lines
5.4 KiB
TypeScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Direction, EventTimeline, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import {
collectTimelineEvents,
createDetachedTimelineSet,
createMediaFilter,
createTypesFilter,
getEarliestLinkedTimeline,
} from './detachedTimeline';
const ROOM_ID = '!room:localhost';
const makeClient = (): MatrixClient =>
({
getUserId: () => '@alice:localhost',
getSafeUserId: () => '@alice:localhost',
supportsThreads: () => false,
canSupport: new Map(),
isRoomEncrypted: () => false,
getRooms: () => [],
decryptEventIfNeeded: () => Promise.resolve(),
reEmitter: { reEmit: () => undefined },
getCrypto: () => undefined,
}) as unknown as MatrixClient;
const makeRoom = (mx: MatrixClient, encrypted = false): Room => {
const room = new Room(ROOM_ID, mx, '@alice:localhost', { timelineSupport: true });
if (encrypted) {
room.currentState.setStateEvents([
new MatrixEvent({
type: 'm.room.encryption',
state_key: '',
room_id: ROOM_ID,
sender: '@alice:localhost',
content: { algorithm: 'm.megolm.v1.aes-sha2' },
event_id: '$enc',
origin_server_ts: 1,
}),
]);
}
return room;
};
let counter = 0;
const msg = (body: string, extra: Record<string, unknown> = {}): MatrixEvent => {
counter += 1;
return new MatrixEvent({
type: 'm.room.message',
room_id: ROOM_ID,
sender: '@bob:localhost',
content: { msgtype: 'm.text', body, ...extra },
event_id: `$e${counter}`,
origin_server_ts: counter,
});
};
test('collectTimelineEvents walks backward neighbours oldest-first', () => {
const room = makeRoom(makeClient());
const set = room.getUnfilteredTimelineSet();
const live = set.getLiveTimeline();
const older = new EventTimeline(set);
older.setNeighbouringTimeline(live, Direction.Forward);
live.setNeighbouringTimeline(older, Direction.Backward);
const a = msg('a');
const b = msg('b');
const c = msg('c');
older.addEvent(a, { toStartOfTimeline: false, addToState: false });
live.addEvent(b, { toStartOfTimeline: false, addToState: false });
live.addEvent(c, { toStartOfTimeline: false, addToState: false });
assert.deepEqual(
collectTimelineEvents(live).map((e) => e.getContent().body),
['a', 'b', 'c'],
);
assert.equal(getEarliestLinkedTimeline(live), older);
assert.equal(getEarliestLinkedTimeline(older), older);
});
test('createMediaFilter targets m.room.message with a url; types filter is type-only', () => {
const media = createMediaFilter('@alice:localhost').getDefinition();
assert.deepEqual(media.room?.timeline, { types: ['m.room.message'], contains_url: true });
const types = createTypesFilter('@alice:localhost', ['m.room.member'], 'x').getDefinition();
assert.deepEqual(types.room?.timeline, { types: ['m.room.member'] });
});
test('private detached set: seeded from live history, back token copied, live timeline untouched', () => {
const mx = makeClient();
const room = makeRoom(mx);
const live = room.getLiveTimeline();
live.setPaginationToken('tok-back', Direction.Backward);
const events = [msg('1'), msg('2', { msgtype: 'm.image', url: 'mxc://x/y' }), msg('3')];
events.forEach((e) => live.addEvent(e, { toStartOfTimeline: false, addToState: false }));
const { set, serverFiltered } = createDetachedTimelineSet(mx, room); // no filter → private
assert.equal(serverFiltered, false);
assert.notEqual(set, room.getUnfilteredTimelineSet());
assert.equal(room.getTimelineSets().includes(set), false, 'must not be registered on the room');
assert.deepEqual(
set
.getLiveTimeline()
.getEvents()
.map((e) => e.getContent().body),
['1', '2', '3'],
);
assert.equal(set.getLiveTimeline().getPaginationToken(Direction.Backward), 'tok-back');
// Prepending into the detached set (what /messages pagination does) leaves
// the live timeline's arrays — and therefore RoomTimeline's indices — alone.
set.getLiveTimeline().addEvent(msg('0'), { toStartOfTimeline: true, addToState: false });
assert.equal(live.getEvents().length, 3);
assert.equal(set.getLiveTimeline().getEvents().length, 4);
});
test('filtered detached set is used in plain rooms and skipped in encrypted rooms', () => {
const mx = makeClient();
const filter = createMediaFilter('@alice:localhost');
const plain = makeRoom(mx);
const plainLive = plain.getLiveTimeline();
[msg('t'), msg('i', { msgtype: 'm.image', url: 'mxc://x/y' })].forEach((e) =>
plainLive.addEvent(e, { toStartOfTimeline: false, addToState: false }),
);
const a = createDetachedTimelineSet(mx, plain, { filter });
assert.equal(a.serverFiltered, true);
assert.equal(plain.getTimelineSets().includes(a.set), true, 'registered so it gets live events');
assert.deepEqual(
a.set
.getLiveTimeline()
.getEvents()
.map((e) => e.getContent().body),
['i'],
'prepopulated with the client-side filtered subset',
);
assert.equal(createDetachedTimelineSet(mx, plain, { filter }).set, a.set, 'cached per filter');
const enc = makeRoom(mx, true);
const b = createDetachedTimelineSet(mx, enc, { filter });
assert.equal(b.serverFiltered, false, 'contains_url cannot see ciphertext');
const c = createDetachedTimelineSet(mx, enc, { filter, filterSafeWhenEncrypted: true });
assert.equal(c.serverFiltered, true, 'type-only filters are fine in encrypted rooms');
});