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
+47 -28
View File
@@ -136,6 +136,7 @@ import { ScheduleMessageModal } from './ScheduleMessageModal';
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
import { DraftIndicator } from './DraftIndicator';
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
import { getThreadDraftKey } from '../../state/room/thread';
const GifPicker = React.lazy(() =>
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
@@ -149,9 +150,10 @@ interface RoomInputProps {
fileDropContainerRef: RefObject<HTMLElement>;
roomId: string;
room: Room;
threadRootId?: string;
}
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
({ editor, fileDropContainerRef, roomId, room }, ref) => {
({ editor, fileDropContainerRef, roomId, room, threadRootId }, ref) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
@@ -184,8 +186,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const setScheduledMessages = useSetAtom(scheduledMessagesAtom);
const alive = useAlive();
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
// Scope drafts/replies/uploads by thread so a thread composer stays fully
// isolated from the main room composer (and from other threads).
const draftKey = threadRootId ? getThreadDraftKey(roomId, threadRootId) : roomId;
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(draftKey));
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(draftKey));
const replyUserID = replyDraft?.userId;
const powerLevelTags = usePowerLevelTags(room, powerLevels);
@@ -206,7 +211,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor;
const [uploadBoard, setUploadBoard] = useState(true);
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(draftKey));
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
roomUploadAtomFamily,
selectedFiles.map((f) => f.file),
@@ -225,7 +230,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const showLocation = composerToolbarButtons?.showLocation ?? true;
const showPoll = composerToolbarButtons?.showPoll ?? true;
const showVoice = composerToolbarButtons?.showVoice ?? true;
const showSchedule = composerToolbarButtons?.showSchedule ?? true;
// Schedule-send is hidden in thread mode (v1 reduction).
const showSchedule = (composerToolbarButtons?.showSchedule ?? true) && !threadRootId;
const composerButtonOrder = useMemo(
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
[composerToolbarButtons?.order],
@@ -244,7 +250,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
setLocating(false);
const { latitude, longitude } = pos.coords;
const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
mx.sendMessage(roomId, {
mx.sendMessage(roomId, threadRootId ?? null, {
msgtype: 'm.location',
body: `Location: ${geoUri}`,
geo_uri: geoUri,
@@ -263,7 +269,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
},
{ timeout: 10000 },
);
}, [mx, roomId]);
}, [mx, roomId, threadRootId]);
const handleVoiceSend = useCallback(
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
@@ -279,7 +285,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (room.hasEncryptionStateEvent()) {
const { encInfo, file: encBlob } = await encryptFile(blob);
const uploadResult = await mx.uploadContent(encBlob);
mx.sendMessage(roomId, {
mx.sendMessage(roomId, threadRootId ?? null, {
...baseContent,
file: { ...encInfo, url: uploadResult.content_uri },
} as any);
@@ -288,13 +294,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
name: 'voice-message.ogg',
type: mimeType,
});
mx.sendMessage(roomId, {
mx.sendMessage(roomId, threadRootId ?? null, {
...baseContent,
url: uploadResult.content_uri,
} as any);
}
},
[mx, room, roomId],
[mx, room, roomId, threadRootId],
);
const [autocompleteQuery, setAutocompleteQuery] =
@@ -364,7 +370,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
} else {
// Jotai draft is empty (page reload) — try localStorage fallback
try {
const stored = localStorage.getItem(`draft-msg-${roomId}`);
const stored = localStorage.getItem(`draft-msg-${draftKey}`);
if (stored) {
const nodes = JSON.parse(stored);
if (Array.isArray(nodes) && nodes.length > 0) {
@@ -379,22 +385,22 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
// Ignore malformed stored draft
}
}
}, [editor, msgDraft, roomId, setMsgDraft]);
}, [editor, msgDraft, draftKey, setMsgDraft]);
useEffect(
() => () => {
if (!isEmptyEditor(editor)) {
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
setMsgDraft(parsedDraft);
localStorage.setItem(`draft-msg-${roomId}`, JSON.stringify(parsedDraft));
localStorage.setItem(`draft-msg-${draftKey}`, JSON.stringify(parsedDraft));
} else {
setMsgDraft([]);
localStorage.removeItem(`draft-msg-${roomId}`);
localStorage.removeItem(`draft-msg-${draftKey}`);
}
resetEditor(editor);
resetEditorHistory(editor);
},
[roomId, editor, setMsgDraft],
[draftKey, editor, setMsgDraft],
);
const handleFileMetadata = useCallback(
@@ -487,15 +493,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
});
handleCancelUpload(uploads);
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
contents.forEach((content) => mx.sendMessage(roomId, content as any));
contents.forEach((content) => mx.sendMessage(roomId, threadRootId ?? null, content as any));
},
[mx, roomId, selectedFiles, handleCancelUpload],
[mx, roomId, threadRootId, selectedFiles, handleCancelUpload],
);
const submit = useCallback(() => {
uploadBoardHandlers.current?.handleSend();
const commandName = getBeginCommand(editor);
// Slash-command interpretation is disabled in thread mode (v1): "/foo"
// sends literally rather than being parsed as a command.
const commandName = threadRootId ? undefined : getBeginCommand(editor);
let plainText = toPlainText(editor.children, isMarkdown).trim();
let customHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, {
@@ -568,13 +576,24 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
content['m.relates_to'].is_falling_back = false;
}
}
mx.sendMessage(roomId, content as any);
mx.sendMessage(roomId, threadRootId ?? null, content as any);
resetEditor(editor);
resetEditorHistory(editor);
localStorage.removeItem(`draft-msg-${roomId}`);
localStorage.removeItem(`draft-msg-${draftKey}`);
setReplyDraft(undefined);
sendTypingStatus(false);
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
}, [
mx,
roomId,
threadRootId,
draftKey,
editor,
replyDraft,
sendTypingStatus,
setReplyDraft,
isMarkdown,
commands,
]);
/**
* Build a text message content object from the current editor state.
@@ -643,11 +662,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
});
resetEditor(editor);
resetEditorHistory(editor);
localStorage.removeItem(`draft-msg-${roomId}`);
localStorage.removeItem(`draft-msg-${draftKey}`);
setReplyDraft(undefined);
sendTypingStatus(false);
},
[setScheduledMessages, roomId, editor, setReplyDraft, sendTypingStatus],
[setScheduledMessages, roomId, draftKey, editor, setReplyDraft, sendTypingStatus],
);
const handleKeyDown: KeyboardEventHandler = useCallback(
@@ -742,7 +761,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
);
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
if (!mxcUrl) return;
mx.sendMessage(roomId, {
mx.sendMessage(roomId, threadRootId ?? null, {
msgtype: MsgType.Image,
body: 'image.gif',
url: mxcUrl,
@@ -757,7 +776,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (alive()) setGifUploading(false);
}
},
[mx, roomId, alive],
[mx, roomId, threadRootId, alive],
);
const handleStickerSelect = useCallback(
@@ -770,13 +789,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
await getImageUrlBlob(stickerUrl),
);
mx.sendEvent(roomId, EventType.Sticker, {
mx.sendEvent(roomId, threadRootId ?? null, EventType.Sticker, {
body: label,
url: mxc,
info,
});
},
[mx, roomId, useAuthentication],
[mx, roomId, threadRootId, useAuthentication],
);
if (room.getType() === 'm.server_notice') {
@@ -1258,7 +1277,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
{locationError}
</Text>
)}
<DraftIndicator roomId={roomId} />
<DraftIndicator roomId={draftKey} />
{charCount > 0 && (
<Text
size="T200"