169 lines
5.8 KiB
TypeScript
169 lines
5.8 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|||
|
|
import {
|
||
|
|
EventTimeline,
|
||
|
|
MatrixClient,
|
||
|
|
MatrixEvent,
|
||
|
|
ReceiptType,
|
||
|
|
Room,
|
||
|
|
RoomEvent,
|
||
|
|
RoomEventHandlerMap,
|
||
|
|
Thread,
|
||
|
|
ThreadEvent,
|
||
|
|
} from 'matrix-js-sdk';
|
||
|
|
import { getLinkedTimelines } from '../RoomTimeline';
|
||
|
|
import { isPendingThreadReply } from './threadSummary';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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<Thread | undefined>(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<EventTimeline[]>(() =>
|
||
|
|
getLinkedTimelines(thread.liveTimeline),
|
||
|
|
);
|
||
|
|
const [ready, setReady] = useState<boolean>(() => 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<MatrixEvent[]>([]);
|
||
|
|
|
||
|
|
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;
|
||
|
|
const stillPending = isPendingThreadReply(event, threadRootId) && !alreadyInThread;
|
||
|
|
|
||
|
|
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;
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Send a threaded read receipt up to the latest confirmed event in the thread.
|
||
|
|
*
|
||
|
|
* The receipt is threaded by default (scoped to this thread), which clears the
|
||
|
|
* per-thread unread count. Mirrors the latest-valid-event scan in
|
||
|
|
* `utils/notifications.ts`.
|
||
|
|
*/
|
||
|
|
export const markThreadAsRead = async (
|
||
|
|
mx: MatrixClient,
|
||
|
|
thread: Thread,
|
||
|
|
privateReceipt: boolean,
|
||
|
|
): Promise<void> => {
|
||
|
|
const events = thread.liveTimeline.getEvents();
|
||
|
|
|
||
|
|
let latestEvent: MatrixEvent | undefined;
|
||
|
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||
|
|
const evt = events[i];
|
||
|
|
if (evt && !evt.isSending()) {
|
||
|
|
latestEvent = evt;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (!latestEvent) return;
|
||
|
|
|
||
|
|
await mx.sendReadReceipt(
|
||
|
|
latestEvent,
|
||
|
|
privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read,
|
||
|
|
);
|
||
|
|
};
|