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