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
@@ -1,9 +1,14 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Button, Icon, IconButton, Icons, Scroll, Spinner, Text, color, config } from 'folds';
import { MatrixEvent } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoom } from '../../hooks/useRoom';
import {
collectTimelineEvents,
createDetachedTimelineSet,
createTypesFilter,
} from '../../utils/detachedTimeline';
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -343,15 +348,23 @@ export function RoomActivityLog({ requestClose }: RoomActivityLogProps) {
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
const [canLoadMore, setCanLoadMore] = useState(true);
// [Gitea #163] Page through a detached, type-filtered timeline set: the
// live timeline (rendered by index behind this modal) must not be mutated.
const detached = useMemo(
() =>
createDetachedTimelineSet(mx, room, {
filter: createTypesFilter(mx.getSafeUserId(), STATE_EVENT_TYPES, 'io.lotus.activity'),
filterSafeWhenEncrypted: true,
}),
[mx, room],
);
const getStateEvents = useCallback((): MatrixEvent[] => {
const typeSet = new Set<string>(STATE_EVENT_TYPES);
return room
.getLiveTimeline()
.getEvents()
return collectTimelineEvents(detached.set.getLiveTimeline())
.filter((ev) => typeSet.has(ev.getType()) && !ev.isRedacted())
.slice()
.reverse();
}, [room]);
}, [detached]);
const [events, setEvents] = useState<MatrixEvent[]>(() => getStateEvents());
@@ -363,7 +376,7 @@ export function RoomActivityLog({ requestClose }: RoomActivityLogProps) {
if (loading || !canLoadMore) return;
setLoading(true);
try {
const hasMore = await mx.paginateEventTimeline(room.getLiveTimeline(), {
const hasMore = await mx.paginateEventTimeline(detached.set.getLiveTimeline(), {
backwards: true,
limit: 50,
});
@@ -375,7 +388,7 @@ export function RoomActivityLog({ requestClose }: RoomActivityLogProps) {
} finally {
setLoading(false);
}
}, [loading, canLoadMore, mx, room, getStateEvents]);
}, [loading, canLoadMore, mx, detached, getStateEvents]);
// Auto-paginate on mount — state events are rarely in the initial sync
// window, so we immediately fetch backwards to populate the log.