2026-09-18 00:03:54 -04:00
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
|
import {
|
|
|
|
|
EventTimelineSetHandlerMap,
|
|
|
|
|
EventType,
|
|
|
|
|
MatrixClient,
|
|
|
|
|
MatrixEvent,
|
|
|
|
|
MatrixEventEvent,
|
|
|
|
|
MsgType,
|
|
|
|
|
Room,
|
|
|
|
|
RoomEvent,
|
|
|
|
|
RoomEventHandlerMap,
|
2026-09-26 19:31:10 -04:00
|
|
|
ThreadEvent,
|
2026-09-18 00:03:54 -04:00
|
|
|
} 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 = {
|
2026-09-26 19:31:10 -04:00
|
|
|
/** Every loaded media message in the room and its threads, newest first. */
|
2026-09-18 00:03:54 -04:00
|
|
|
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],
|
|
|
|
|
);
|
|
|
|
|
|
2026-09-26 19:31:10 -04:00
|
|
|
// [Gitea #165] The SDK routes thread replies out of every room timeline set
|
|
|
|
|
// (ours included) into the room's Thread objects, so media posted in a
|
|
|
|
|
// thread only shows up if we read the loaded threads too.
|
|
|
|
|
const readEvents = useCallback((): MatrixEvent[] => {
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
return [
|
|
|
|
|
...collectTimelineEvents(detached.set.getLiveTimeline()),
|
|
|
|
|
...room.getThreads().flatMap((thread) => thread.timeline),
|
|
|
|
|
]
|
|
|
|
|
.filter((event) => {
|
|
|
|
|
const id = event.getId();
|
|
|
|
|
if (!id || seen.has(id) || !isMediaMessage(event)) return false;
|
|
|
|
|
seen.add(id);
|
|
|
|
|
return true;
|
|
|
|
|
})
|
|
|
|
|
.sort((a, b) => b.getTs() - a.getTs());
|
|
|
|
|
}, [detached, room]);
|
2026-09-18 00:03:54 -04:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-26 19:31:10 -04:00
|
|
|
const onThreadReply = (_thread: unknown, event: MatrixEvent) => {
|
|
|
|
|
if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) {
|
|
|
|
|
event.once(MatrixEventEvent.Decrypted, () => {
|
|
|
|
|
if (isMediaMessage(event)) refresh();
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (isMediaMessage(event)) refresh();
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-18 00:03:54 -04:00
|
|
|
room.on(RoomEvent.Timeline, onTimeline);
|
|
|
|
|
room.on(RoomEvent.Redaction, onRedaction);
|
2026-09-26 19:31:10 -04:00
|
|
|
room.on(ThreadEvent.NewReply, onThreadReply);
|
2026-09-18 00:03:54 -04:00
|
|
|
return () => {
|
|
|
|
|
room.removeListener(RoomEvent.Timeline, onTimeline);
|
|
|
|
|
room.removeListener(RoomEvent.Redaction, onRedaction);
|
2026-09-26 19:31:10 -04:00
|
|
|
room.removeListener(ThreadEvent.NewReply, onThreadReply);
|
2026-09-18 00:03:54 -04:00
|
|
|
};
|
|
|
|
|
}, [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 };
|
|
|
|
|
};
|