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
parent 15ac538a4b
commit aa62df9c75
16 changed files with 1811 additions and 54 deletions
+57
View File
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import {
MatrixEvent,
NotificationCountType,
Room,
RoomEvent,
RoomEventHandlerMap,
ThreadEvent,
} from 'matrix-js-sdk';
import { getThreadSummary, ThreadSummaryData } from '../features/room/thread/threadSummary';
/**
* Reactive thread summary + unread count for a root event's "N replies" chip.
*
* Re-computes the summary on `ThreadEvent.Update` (the SDK re-emits this on the
* root MatrixEvent) and the unread count on `RoomEvent.UnreadNotifications`.
*/
export const useThreadSummary = (
rootEvent: MatrixEvent,
room: Room,
): { summary: ThreadSummaryData | undefined; unread: number } => {
const threadId = rootEvent.getId();
const [summary, setSummary] = useState<ThreadSummaryData | undefined>(() =>
getThreadSummary(rootEvent),
);
const [unread, setUnread] = useState<number>(() =>
threadId
? (room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Total) ?? 0)
: 0,
);
useEffect(() => {
const refreshSummary = () => setSummary(getThreadSummary(rootEvent));
const refreshUnread = () => {
if (!threadId) return;
setUnread(room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Total) ?? 0);
};
refreshSummary();
refreshUnread();
const handleUnread: RoomEventHandlerMap[RoomEvent.UnreadNotifications] = (_counts, tId) => {
if (tId && tId !== threadId) return;
refreshUnread();
};
rootEvent.on(ThreadEvent.Update, refreshSummary);
room.on(RoomEvent.UnreadNotifications, handleUnread);
return () => {
rootEvent.removeListener(ThreadEvent.Update, refreshSummary);
room.removeListener(RoomEvent.UnreadNotifications, handleUnread);
};
}, [rootEvent, room, threadId]);
return { summary, unread };
};