import { useCallback, useEffect, useState } from 'react'; import { EventStatus, EventTimeline, MatrixClient, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap, Thread, ThreadEvent, } from 'matrix-js-sdk'; import { getLinkedTimelines } from '../RoomTimeline'; import { isPendingThreadReply } from './threadSummaryData'; /** * Resolve (or bootstrap) the live {@link Thread} for a root event. * * Uses the existing thread when present, otherwise creates one via * `room.createThread` — the SDK then auto-fetches the thread's events via * `/relations` and inserts the root at the top. If the root event isn't loaded * locally the Thread handles the root fetch itself, so passing `undefined` is * safe. Re-resolves when a matching thread later appears/updates on the room. */ export const useThreadInstance = (room: Room, threadRootId: string): Thread | undefined => { const getInstance = useCallback((): Thread | undefined => { const existing = room.getThread(threadRootId); if (existing) return existing; const rootEvent = room.findEventById(threadRootId); return room.createThread(threadRootId, rootEvent, [], false) ?? undefined; }, [room, threadRootId]); const [thread, setThread] = useState(getInstance); useEffect(() => { setThread(getInstance()); const handleThread: RoomEventHandlerMap[ThreadEvent.New] = (newThread) => { if (newThread.id === threadRootId) setThread(newThread); }; const handleThreadUpdate: RoomEventHandlerMap[ThreadEvent.Update] = (updatedThread) => { if (updatedThread.id === threadRootId) setThread(updatedThread); }; room.on(ThreadEvent.New, handleThread); room.on(ThreadEvent.Update, handleThreadUpdate); return () => { room.removeListener(ThreadEvent.New, handleThread); room.removeListener(ThreadEvent.Update, handleThreadUpdate); }; }, [room, threadRootId, getInstance]); return thread; }; /** * Build the ordered list of linked {@link EventTimeline}s for a thread's live * timeline and track readiness (`thread.initialEventsFetched`). Subscribes to * the Thread's re-emitted timeline events so callers repaginate/re-render as * the thread fills in. */ export const useThreadLinkedTimelines = ( mx: MatrixClient, thread: Thread, ): { timelines: EventTimeline[]; ready: boolean; refresh: () => void } => { const [timelines, setTimelines] = useState(() => getLinkedTimelines(thread.liveTimeline), ); const [ready, setReady] = useState(() => thread.initialEventsFetched); const refresh = useCallback(() => { setTimelines(getLinkedTimelines(thread.liveTimeline)); setReady(thread.initialEventsFetched); }, [thread]); useEffect(() => { refresh(); const handleTimeline = () => refresh(); // Thread re-emits RoomEvent.Timeline / RoomEvent.TimelineReset from its // timelineSet, and fires ThreadEvent.Update as it (re)populates. thread.on(RoomEvent.Timeline, handleTimeline); thread.on(RoomEvent.TimelineReset, handleTimeline); thread.on(ThreadEvent.Update, handleTimeline); return () => { thread.removeListener(RoomEvent.Timeline, handleTimeline); thread.removeListener(RoomEvent.TimelineReset, handleTimeline); thread.removeListener(ThreadEvent.Update, handleTimeline); }; }, [thread, refresh]); return { timelines, ready, refresh }; }; /** * Track in-flight (local echo) replies for a thread. * * Pending thread sends never enter the thread's timelineSet (chronological * pending ordering rejects them; `room.getPendingEvents()` THROWS in this * mode). We instead watch `RoomEvent.LocalEchoUpdated` on the room and keep our * own list of events that are pending replies to this thread and not yet in the * thread timeline. When an event's remote echo arrives (status flips to SENT, * or it lands in the thread) it drops out of the list. */ export const useThreadPendingEvents = ( room: Room, threadRootId: string, thread: Thread | undefined, ): MatrixEvent[] => { const [pending, setPending] = useState([]); useEffect(() => { setPending([]); const handleLocalEcho: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (event) => { const eventId = event.getId(); setPending((prev) => { // Drop any previous entry for this event (same instance across the // temp-id -> real-id transition, or matched by id). const without = prev.filter((e) => e !== event && e.getId() !== eventId); const alreadyInThread = eventId !== undefined && thread?.findEventById(eventId) !== undefined; // Keep a tracked event through the SENT window too: the /send response // flips status to SENT before /sync delivers the event into the thread // timeline — dropping it there would make the message flash out of view. // It falls out on the next LocalEchoUpdated once findEventById sees it. const trackedAndAwaitingSync = event.status === EventStatus.SENT && prev.some((e) => e === event || (eventId !== undefined && e.getId() === eventId)); const stillPending = !alreadyInThread && (isPendingThreadReply(event, threadRootId) || trackedAndAwaitingSync); if (stillPending) return [...without, event]; return without.length === prev.length ? prev : without; }); }; room.on(RoomEvent.LocalEchoUpdated, handleLocalEcho); return () => { room.removeListener(RoomEvent.LocalEchoUpdated, handleLocalEcho); }; }, [room, threadRootId, thread]); return pending; }; // markThreadAsRead moved to ./threadReceipt (pure + unit-tested); re-exported // here for existing import sites. export { markThreadAsRead } from './threadReceipt';