import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { EventTimelineSetHandlerMap, EventType, MatrixClient, MatrixEvent, MatrixEventEvent, MsgType, Room, RoomEvent, RoomEventHandlerMap, ThreadEvent, } from 'matrix-js-sdk'; import { collectTimelineEvents, createDetachedTimelineSet, createMediaFilter, DetachedTimelineSet, } from '../utils/detachedTimeline'; import { decryptAllTimelineEvent } from '../utils/room'; export const MEDIA_MSGTYPES: ReadonlySet = new Set([ 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 and its threads, newest first. */ events: MatrixEvent[]; loadMore: () => Promise; 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( () => createDetachedTimelineSet(mx, room, { filter: createMediaFilter(mx.getSafeUserId()) }), [mx, room], ); // [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(); 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]); const [events, setEvents] = useState(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(); }; const onThreadReply = (_thread: unknown, event: MatrixEvent) => { if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) { event.once(MatrixEventEvent.Decrypted, () => { if (isMediaMessage(event)) refresh(); }); return; } if (isMediaMessage(event)) refresh(); }; room.on(RoomEvent.Timeline, onTimeline); room.on(RoomEvent.Redaction, onRedaction); room.on(ThreadEvent.NewReply, onThreadReply); return () => { room.removeListener(RoomEvent.Timeline, onTimeline); room.removeListener(RoomEvent.Redaction, onRedaction); room.removeListener(ThreadEvent.NewReply, onThreadReply); }; }, [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 }; };