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:
@@ -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.
|
||||
|
||||
@@ -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[] };
|
||||
|
||||
Reference in New Issue
Block a user