Files
cinny/src/app/utils/detachedTimeline.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

144 lines
5.2 KiB
TypeScript

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