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 }; };