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
@@ -4,6 +4,7 @@ import { EventType } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoom } from '../../hooks/useRoom';
import { createDetachedTimelineSet } from '../../utils/detachedTimeline';
import { useRoomName } from '../../hooks/useRoomMeta';
import { SequenceCard } from '../../components/sequence-card';
import { SequenceCardStyle } from '../common-settings/styles.css';
@@ -73,7 +74,10 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
// so we must deduplicate by eventId to avoid re-adding the same events
// on each pagination step.
const seen = new Set<string>();
const timeline = room.getLiveTimeline();
// [Gitea #163] Paginate a private timeline set, not the live one: the
// export can pull thousands of events and the room behind this modal
// renders the live timeline by index.
const timeline = createDetachedTimelineSet(mx, room).set.getLiveTimeline();
let canLoadMore = true;
// Track the oldest collected timestamp incrementally so the fromTs check
// doesn't rescan the whole `collected` array on every pagination step.
@@ -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.
+25 -64
View File
@@ -17,7 +17,7 @@ import {
color,
config,
} from 'folds';
import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import { MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import classNames from 'classnames';
import { useNearViewport } from '../../hooks/useNearViewport';
@@ -31,6 +31,7 @@ import { AudioContent, FileDownloadButton } from '../../components/message';
import { MediaControl } from '../../components/media';
import { getBlobSafeMimeType, mimeTypeToExt } from '../../utils/mimeTypes';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useRoomMediaTimeline } from '../../hooks/useRoomMediaTimeline';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { stopPropagation } from '../../utils/keyboard';
import * as css from './MediaGallery.css';
@@ -702,10 +703,17 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
);
const [tab, setTab] = useState<GalleryTab>('image');
const [loading, setLoading] = useState(false);
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
const [canLoadMore, setCanLoadMore] = useState(true);
const [loadError, setLoadError] = useState(false);
// [Gitea #163] Media is paginated on its own timeline set — never on
// `room.getLiveTimeline()`, which the message list behind this drawer is
// rendering by index.
const {
events: mediaEvents,
loadMore: handleLoadMore,
loading,
loadError,
canLoadMore,
hasLoadedOnce,
} = useRoomMediaTimeline(mx, room);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
@@ -730,51 +738,11 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const msgtype = TAB_MSGTYPES[tab];
const getFilteredEvents = useCallback(
(): MatrixEvent[] =>
room
.getLiveTimeline()
.getEvents()
.filter((ev) => {
if (ev.isRedacted()) return false;
const c = ev.getContent();
return ev.getType() === EventType.RoomMessage && c.msgtype === msgtype;
})
.slice()
.reverse(),
[room, msgtype],
const events = useMemo(
() => mediaEvents.filter((ev) => ev.getContent().msgtype === msgtype),
[mediaEvents, msgtype],
);
const [events, setEvents] = useState<MatrixEvent[]>(() => getFilteredEvents());
useEffect(() => {
setEvents(getFilteredEvents());
setCanLoadMore(true);
setHasLoadedOnce(false);
setLoadError(false);
}, [getFilteredEvents]);
const handleLoadMore = useCallback(async () => {
if (loading || !canLoadMore) return;
setLoading(true);
setLoadError(false);
try {
const hasMore = await mx.paginateEventTimeline(room.getLiveTimeline(), {
backwards: true,
limit: 100,
});
setEvents(getFilteredEvents());
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 {
setLoading(false);
}
}, [loading, canLoadMore, mx, room, getFilteredEvents]);
// Auto-load when sentinel scrolls into view
useEffect(() => {
const sentinel = sentinelRef.current;
@@ -814,25 +782,18 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
};
});
// Per-tab counts for the tab labels (single pass over loaded timeline)
// Per-tab counts for the tab labels (single pass over the loaded media)
const tabCounts = useMemo(() => {
const counts: Record<GalleryTab, number> = { image: 0, video: 0, audio: 0, file: 0 };
room
.getLiveTimeline()
.getEvents()
.forEach((ev) => {
if (ev.isRedacted() || ev.getType() !== EventType.RoomMessage) return;
const mt = ev.getContent().msgtype;
if (mt === MsgType.Image) counts.image += 1;
else if (mt === MsgType.Video) counts.video += 1;
else if (mt === MsgType.Audio) counts.audio += 1;
else if (mt === MsgType.File) counts.file += 1;
});
mediaEvents.forEach((ev) => {
const mt = ev.getContent().msgtype;
if (mt === MsgType.Image) counts.image += 1;
else if (mt === MsgType.Video) counts.video += 1;
else if (mt === MsgType.Audio) counts.audio += 1;
else if (mt === MsgType.File) counts.file += 1;
});
return counts;
// `events` is intentional: it changes when more history is paginated in, so
// the counts stay in sync with the loaded window (it isn't read directly).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, events]);
}, [mediaEvents]);
// Group image/video events by month for the grid
type MonthGroup = { label: string; events: MatrixEvent[] };