From ba48e959930c75bbeb0f797175190ec3847eb76b Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 26 Sep 2026 19:31:10 -0400 Subject: [PATCH] feat(media): Media Gallery includes media posted in threads (#165) 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/hooks/useRoomMediaTimeline.ts | 37 ++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/src/app/hooks/useRoomMediaTimeline.ts b/src/app/hooks/useRoomMediaTimeline.ts index 4907b620a..18074e063 100644 --- a/src/app/hooks/useRoomMediaTimeline.ts +++ b/src/app/hooks/useRoomMediaTimeline.ts @@ -9,6 +9,7 @@ import { Room, RoomEvent, RoomEventHandlerMap, + ThreadEvent, } from 'matrix-js-sdk'; import { @@ -34,7 +35,7 @@ export const isMediaMessage = (event: MatrixEvent): boolean => const PAGE_SIZE = 100; export type RoomMediaTimeline = { - /** Every loaded media message in the room, newest first. */ + /** Every loaded media message in the room and its threads, newest first. */ events: MatrixEvent[]; loadMore: () => Promise; loading: boolean; @@ -57,11 +58,23 @@ export const useRoomMediaTimeline = (mx: MatrixClient, room: Room): RoomMediaTim [mx, room], ); - const readEvents = useCallback( - (): MatrixEvent[] => - collectTimelineEvents(detached.set.getLiveTimeline()).filter(isMediaMessage).reverse(), - [detached], - ); + // [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); @@ -119,11 +132,23 @@ export const useRoomMediaTimeline = (mx: MatrixClient, room: Room): RoomMediaTim 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]);