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 = {}): 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'); });