CI / Build & Quality Checks (push) Successful in 4m26s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 16s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 12m0s
The SDK routes thread replies out of every room timeline set, the gallery's detached one included, into the room's Thread objects, so photos posted in a thread never reached the gallery. The gallery now merges media from the loaded threads (deduped, newest first) and refreshes on ThreadEvent.NewReply, waiting for decryption in encrypted rooms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
184 lines
6.0 KiB
TypeScript
184 lines
6.0 KiB
TypeScript
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 };
|
|
};
|