Files
cinny/src/app/hooks/useRoomMediaTimeline.ts
T

184 lines
6.0 KiB
TypeScript
Raw Normal View History

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<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 and its threads, 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],
);
// [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]);
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();
};
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 };
};