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:
2026-07-01 21:45:20 -04:00
co-authored by Claude Opus 4.8
parent 15ac538a4b
commit aa62df9c75
16 changed files with 1811 additions and 54 deletions
+168
View File
@@ -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,
);
};