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
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
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');
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
Direction,
|
||||
EventTimeline,
|
||||
EventTimelineSet,
|
||||
Filter,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
Room,
|
||||
} from 'matrix-js-sdk';
|
||||
|
||||
/**
|
||||
* [Gitea #163] Timelines that paginate history WITHOUT touching the room's
|
||||
* live timeline.
|
||||
*
|
||||
* `RoomTimeline` renders a numeric window (`range`) of absolute indices into
|
||||
* the live timeline's event arrays. Backwards pagination in the SDK PREPENDS
|
||||
* (`events.splice(0, 0, …)`), so anything else that calls
|
||||
* `paginateEventTimeline(room.getLiveTimeline())` — the media gallery, the
|
||||
* activity log, history export — silently shifts what those indices point at
|
||||
* and the visible timeline "jumps into the past" on its next render. The fix
|
||||
* is for side panels to page through their own `EventTimelineSet`; this file
|
||||
* is the shared plumbing for that.
|
||||
*/
|
||||
|
||||
/** All events of a timeline and its backward neighbours, oldest first. */
|
||||
export const collectTimelineEvents = (timeline: EventTimeline): MatrixEvent[] => {
|
||||
const timelines: EventTimeline[] = [];
|
||||
let current: EventTimeline | null = timeline;
|
||||
while (current) {
|
||||
timelines.unshift(current);
|
||||
current = current.getNeighbouringTimeline(Direction.Backward);
|
||||
}
|
||||
return timelines.flatMap((t) => t.getEvents());
|
||||
};
|
||||
|
||||
/** Earliest timeline linked backwards from `timeline` (where the back token lives). */
|
||||
export const getEarliestLinkedTimeline = (timeline: EventTimeline): EventTimeline => {
|
||||
let current = timeline;
|
||||
let prev = current.getNeighbouringTimeline(Direction.Backward);
|
||||
while (prev) {
|
||||
current = prev;
|
||||
prev = current.getNeighbouringTimeline(Direction.Backward);
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
/**
|
||||
* Server-side filter for the media gallery in unencrypted rooms: only
|
||||
* `m.room.message` events whose content carries a `url`, i.e. image / video /
|
||||
* audio / file. `/messages` then returns 100 media events per page instead of
|
||||
* 100 events of which a handful are media. Useless in encrypted rooms (the
|
||||
* server only sees ciphertext), so `createDetachedTimelineSet` skips it there.
|
||||
*/
|
||||
export const createMediaFilter = (userId: string): Filter => {
|
||||
const filter = new Filter(userId);
|
||||
filter.setDefinition({
|
||||
room: {
|
||||
timeline: {
|
||||
types: ['m.room.message'],
|
||||
contains_url: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Key for `room.filteredTimelineSets` (the SDK caches per filterId) — only
|
||||
// ever sent to the server inline on `/messages`, never registered via
|
||||
// `/filter`, so any stable string will do.
|
||||
filter.filterId = 'io.lotus.media';
|
||||
return filter;
|
||||
};
|
||||
|
||||
/**
|
||||
* Server-side filter on event `type` only — safe in encrypted rooms because
|
||||
* state events are never encrypted.
|
||||
*/
|
||||
export const createTypesFilter = (userId: string, types: readonly string[], id: string): Filter => {
|
||||
const filter = new Filter(userId);
|
||||
filter.setDefinition({ room: { timeline: { types: [...types] } } });
|
||||
filter.filterId = id;
|
||||
return filter;
|
||||
};
|
||||
|
||||
export type DetachedTimelineSet = {
|
||||
set: EventTimelineSet;
|
||||
/** True when the server filters pages for us (the filter was usable). */
|
||||
serverFiltered: boolean;
|
||||
};
|
||||
|
||||
export type DetachedTimelineOptions = {
|
||||
/** Server-side filter for `/messages` (and client-side for live events). */
|
||||
filter?: Filter;
|
||||
/**
|
||||
* Set when the filter only looks at unencrypted fields (event `type`,
|
||||
* `sender`, `state_key`), so it is still correct in an encrypted room. A
|
||||
* content filter such as `contains_url` must leave this false.
|
||||
*/
|
||||
filterSafeWhenEncrypted?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a timeline set for `room` that mirrors the live timeline's already
|
||||
* loaded history (no refetch) and can be paginated backwards without ever
|
||||
* mutating `room.getLiveTimeline()`.
|
||||
*
|
||||
* - usable `filter` → `room.getOrCreateFilteredTimelineSet`: registered with
|
||||
* the room, so it also receives live events (client-side filtered) and
|
||||
* redactions for free, and it is cached on the room across open/close.
|
||||
* - otherwise → a private `EventTimelineSet` seeded from the live timeline;
|
||||
* the caller feeds it live events via `addLiveEvent` (see
|
||||
* `useRoomMediaTimeline`). Not registered with the room, so a
|
||||
* `TimelineReset` after a sync gap does not clear it — acceptable for a
|
||||
* read-only side panel that is rebuilt on every open.
|
||||
*/
|
||||
export const createDetachedTimelineSet = (
|
||||
mx: MatrixClient,
|
||||
room: Room,
|
||||
{ filter, filterSafeWhenEncrypted = false }: DetachedTimelineOptions = {},
|
||||
): DetachedTimelineSet => {
|
||||
const filterUsable = !!filter && (filterSafeWhenEncrypted || !room.hasEncryptionStateEvent());
|
||||
|
||||
if (filter && filterUsable) {
|
||||
const set = room.getOrCreateFilteredTimelineSet(filter, {
|
||||
prepopulateTimeline: true,
|
||||
useSyncEvents: true,
|
||||
pendingEvents: false,
|
||||
});
|
||||
return { set, serverFiltered: true };
|
||||
}
|
||||
|
||||
const set = new EventTimelineSet(room, { timelineSupport: true, pendingEvents: false }, mx);
|
||||
const live = room.getLiveTimeline();
|
||||
// Seed with everything already loaded (already decrypted where applicable)
|
||||
// so opening the panel never refetches what the timeline has.
|
||||
collectTimelineEvents(live).forEach((event) => {
|
||||
set.addLiveEvent(event, { addToState: false });
|
||||
});
|
||||
set
|
||||
.getLiveTimeline()
|
||||
.setPaginationToken(
|
||||
getEarliestLinkedTimeline(live).getPaginationToken(Direction.Backward),
|
||||
Direction.Backward,
|
||||
);
|
||||
return { set, serverFiltered: false };
|
||||
};
|
||||
Reference in New Issue
Block a user