feat(threads): Thread Panel — full side drawer (P3-8)
Right-side thread drawer (MembersDrawer pattern; mobile fullscreen): - ThreadPanel: header + close/Escape, ThreadTimeline, its own RoomInput (threadRootId prop; drafts/replies/uploads isolated per roomId::threadId; schedule + slash-commands off in threads v1) and threaded mark-as-read. - ThreadTimeline: lean reimplementation over thread.liveTimeline — copied useTimelinePagination pattern (/relations back-pagination + decryption), virtualized, root event emphasized + "N replies" divider, reactions/edits/ redactions, and a pending strip (chronological local echo never enters the thread timelineSet — rendered from LocalEchoUpdated instead). - ThreadSummary chips on root messages (server-aggregated bundle or live Thread; unread badge via getThreadUnreadNotificationCount) keep threads discoverable now that replies leave the main timeline. - Reply-in-Thread menu + thread indicators open the panel; deep links to thread events redirect into it. - State: roomIdToActiveThreadIdAtomFamily + getThreadDraftKey (+18 tests). Gates: tsc clean, eslint 0 errors, build OK, 616/617 tests (1 IDB skip). Awaiting live QA; release note: threaded replies no longer render inline. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
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,
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user