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:
2026-09-18 00:03:54 -04:00
co-authored by Claude Opus 5
parent c5082a78ef
commit d929143f7d
11 changed files with 646 additions and 74 deletions
+158
View File
@@ -0,0 +1,158 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
EventTimelineSetHandlerMap,
EventType,
MatrixClient,
MatrixEvent,
MatrixEventEvent,
MsgType,
Room,
RoomEvent,
RoomEventHandlerMap,
} from 'matrix-js-sdk';
import {
collectTimelineEvents,
createDetachedTimelineSet,
createMediaFilter,
DetachedTimelineSet,
} from '../utils/detachedTimeline';
import { decryptAllTimelineEvent } from '../utils/room';
export const MEDIA_MSGTYPES: ReadonlySet<string> = new Set<string>([
MsgType.Image,
MsgType.Video,
MsgType.Audio,
MsgType.File,
]);
export const isMediaMessage = (event: MatrixEvent): boolean =>
event.getType() === EventType.RoomMessage &&
!event.isRedacted() &&
MEDIA_MSGTYPES.has(event.getContent().msgtype as string);
const PAGE_SIZE = 100;
export type RoomMediaTimeline = {
/** Every loaded media message in the room, newest first. */
events: MatrixEvent[];
loadMore: () => Promise<void>;
loading: boolean;
loadError: boolean;
canLoadMore: boolean;
hasLoadedOnce: boolean;
};
/**
* [Gitea #163] Media events for the gallery, paginated on a timeline set of
* their own so the room's live timeline (and the message list rendering it)
* is never mutated. Unencrypted rooms page through a server-side
* `contains_url` filter (100 media per page); encrypted rooms page raw
* history into a private set and filter after decrypting, exactly what the
* gallery used to do on the live timeline.
*/
export const useRoomMediaTimeline = (mx: MatrixClient, room: Room): RoomMediaTimeline => {
const detached = useMemo<DetachedTimelineSet>(
() => createDetachedTimelineSet(mx, room, { filter: createMediaFilter(mx.getSafeUserId()) }),
[mx, room],
);
const readEvents = useCallback(
(): MatrixEvent[] =>
collectTimelineEvents(detached.set.getLiveTimeline()).filter(isMediaMessage).reverse(),
[detached],
);
const [events, setEvents] = useState<MatrixEvent[]>(readEvents);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState(false);
const [canLoadMore, setCanLoadMore] = useState(true);
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
const loadingRef = useRef(false);
useEffect(() => {
setEvents(readEvents());
setCanLoadMore(true);
setHasLoadedOnce(false);
setLoadError(false);
}, [readEvents]);
// Live updates. The server-filtered set is registered with the room and
// receives new events itself; the private set (encrypted rooms) is fed here
// once the event has decrypted. Redactions are applied to both.
useEffect(() => {
const { set, serverFiltered } = detached;
const refresh = () => setEvents(readEvents());
const addIfMedia = (event: MatrixEvent) => {
if (!isMediaMessage(event)) return;
if (set.findEventById(event.getId() ?? '')) return;
set.addLiveEvent(event, { addToState: false });
refresh();
};
const onTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
event,
eventRoom,
_toStart,
_removed,
data,
) => {
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
if (serverFiltered) {
// The room already routed it into our set (client-side filtered).
if (data.timeline.getTimelineSet() === set) refresh();
return;
}
if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) {
event.once(MatrixEventEvent.Decrypted, () => addIfMedia(event));
return;
}
addIfMedia(event);
};
const onRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (event, eventRoom) => {
if (eventRoom?.roomId !== room.roomId) return;
const redactedId = event.event.redacts;
if (!redactedId) return;
if (!serverFiltered) set.removeEvent(redactedId);
refresh();
};
room.on(RoomEvent.Timeline, onTimeline);
room.on(RoomEvent.Redaction, onRedaction);
return () => {
room.removeListener(RoomEvent.Timeline, onTimeline);
room.removeListener(RoomEvent.Redaction, onRedaction);
};
}, [detached, room, readEvents]);
const loadMore = useCallback(async () => {
if (loadingRef.current || !canLoadMore) return;
loadingRef.current = true;
setLoading(true);
setLoadError(false);
try {
const timeline = detached.set.getLiveTimeline();
const hasMore = await mx.paginateEventTimeline(timeline, {
backwards: true,
limit: PAGE_SIZE,
});
if (room.hasEncryptionStateEvent()) {
await decryptAllTimelineEvent(mx, timeline);
}
setEvents(readEvents());
setCanLoadMore(hasMore);
setHasLoadedOnce(true);
} catch {
// Stop auto-retry: the sentinel would keep firing on every render otherwise.
// The user can retry manually via the button shown in the error state.
setLoadError(true);
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [mx, room, detached, canLoadMore, readEvents]);
return { events, loadMore, loading, loadError, canLoadMore, hasLoadedOnce };
};