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
+43
View File
@@ -0,0 +1,43 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createStore } from 'jotai';
import { getThreadDraftKey, roomIdToActiveThreadIdAtomFamily } from './thread';
// ---------------------------------------------------------------------------
// getThreadDraftKey
// ---------------------------------------------------------------------------
test('getThreadDraftKey joins roomId and threadRootId with "::"', () => {
assert.equal(getThreadDraftKey('!room:server', '$root'), '!room:server::$root');
});
test('getThreadDraftKey keeps the two ids distinguishable', () => {
assert.notEqual(getThreadDraftKey('!a:server', '$b'), getThreadDraftKey('!a:server', '$c'));
});
// ---------------------------------------------------------------------------
// roomIdToActiveThreadIdAtomFamily
// ---------------------------------------------------------------------------
test('returns the same atom instance for the same roomId', () => {
const a = roomIdToActiveThreadIdAtomFamily('!room:server');
const b = roomIdToActiveThreadIdAtomFamily('!room:server');
assert.equal(a, b);
});
test('returns different atoms for different roomIds', () => {
const a = roomIdToActiveThreadIdAtomFamily('!a:server');
const b = roomIdToActiveThreadIdAtomFamily('!b:server');
assert.notEqual(a, b);
});
test('the active-thread atom defaults to null and is writable', () => {
const store = createStore();
const activeThreadIdAtom = roomIdToActiveThreadIdAtomFamily('!store:server');
assert.equal(store.get(activeThreadIdAtom), null);
store.set(activeThreadIdAtom, '$root');
assert.equal(store.get(activeThreadIdAtom), '$root');
store.set(activeThreadIdAtom, null);
assert.equal(store.get(activeThreadIdAtom), null);
});
+22
View File
@@ -0,0 +1,22 @@
import { atom, PrimitiveAtom } from 'jotai';
import { atomFamily } from 'jotai/utils';
const createActiveThreadIdAtom = () => atom<string | null>(null);
export type TActiveThreadIdAtom = PrimitiveAtom<string | null>;
/**
* Per-room "which thread is open in the panel" state. Mirrors
* `roomIdToReplyDraftAtomFamily` in `roomInputDrafts.ts` — the same atom
* instance is returned for the same roomId, so a room's panel state survives
* remounts.
*/
export const roomIdToActiveThreadIdAtomFamily = atomFamily<string, TActiveThreadIdAtom>(() =>
createActiveThreadIdAtom(),
);
/**
* Key used to scope a thread's composer drafts (message/reply/upload) away from
* the main room composer, e.g. `"!room:server::$rootEventId"`.
*/
export const getThreadDraftKey = (roomId: string, threadRootId: string): string =>
`${roomId}::${threadRootId}`;