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:
@@ -103,6 +103,8 @@ import * as css from './RoomTimeline.css';
|
||||
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
||||
import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
|
||||
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
||||
import { ThreadSummary } from './thread/ThreadSummary';
|
||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
@@ -245,13 +247,26 @@ const useEventTimelineLoader = (
|
||||
room: Room,
|
||||
onLoad: (eventId: string, linkedTimelines: EventTimeline[], evtAbsIndex: number) => void,
|
||||
onError: (err: Error | null) => void,
|
||||
onThreadRedirect: (threadRootId: string) => void,
|
||||
) => {
|
||||
const loadEventTimeline = useCallback(
|
||||
async (eventId: string) => {
|
||||
const [err, replyEvtTimeline] = await to(
|
||||
mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId),
|
||||
);
|
||||
// Thread events aren't locatable in the main timeline set (getEventTimeline
|
||||
// returns undefined / no abs index). Best-effort: redirect to the thread panel
|
||||
// when the fetched event belongs to a thread instead of surfacing an error.
|
||||
const redirectToThread = () => {
|
||||
const threadRootId = room.findEventById(eventId)?.threadRootId;
|
||||
if (threadRootId) {
|
||||
onThreadRedirect(threadRootId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (!replyEvtTimeline) {
|
||||
if (redirectToThread()) return;
|
||||
onError(err ?? null);
|
||||
return;
|
||||
}
|
||||
@@ -259,13 +274,14 @@ const useEventTimelineLoader = (
|
||||
const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId);
|
||||
|
||||
if (absIndex === undefined) {
|
||||
if (redirectToThread()) return;
|
||||
onError(err ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
onLoad(eventId, linkedTimelines, absIndex);
|
||||
},
|
||||
[mx, room, onLoad, onError],
|
||||
[mx, room, onLoad, onError, onThreadRedirect],
|
||||
);
|
||||
|
||||
return loadEventTimeline;
|
||||
@@ -460,6 +476,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
|
||||
|
||||
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
||||
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId));
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const creators = useRoomCreators(room);
|
||||
|
||||
@@ -622,6 +639,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = false;
|
||||
}, [alive, room]),
|
||||
useCallback(
|
||||
(threadRootId: string) => {
|
||||
if (!alive()) return;
|
||||
setActiveThreadId(threadRootId);
|
||||
},
|
||||
[alive, setActiveThreadId],
|
||||
),
|
||||
);
|
||||
|
||||
useLiveEventArrive(
|
||||
@@ -982,14 +1006,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
console.warn('Button should have "data-event-id" attribute!');
|
||||
return;
|
||||
}
|
||||
if (startThread) {
|
||||
// Open the thread panel instead of arming an m.thread reply in the main composer.
|
||||
setActiveThreadId(replyId);
|
||||
return;
|
||||
}
|
||||
const replyEvt = room.findEventById(replyId);
|
||||
if (!replyEvt) return;
|
||||
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
|
||||
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
||||
const { body, formatted_body: formattedBody } = content;
|
||||
const { 'm.relates_to': relation } = startThread
|
||||
? { 'm.relates_to': { rel_type: 'm.thread', event_id: replyId } }
|
||||
: replyEvt.getWireContent();
|
||||
const { 'm.relates_to': relation } = replyEvt.getWireContent();
|
||||
const senderId = replyEvt.getSender();
|
||||
if (senderId && typeof body === 'string') {
|
||||
setReplyDraft({
|
||||
@@ -1002,7 +1029,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
setTimeout(() => ReactEditor.focus(editor), 100);
|
||||
}
|
||||
},
|
||||
[room, setReplyDraft, editor],
|
||||
[room, setReplyDraft, setActiveThreadId, editor],
|
||||
);
|
||||
|
||||
const handleReactionToggle = useCallback(
|
||||
@@ -1090,6 +1117,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
replyEventId={replyEventId}
|
||||
threadRootId={threadRootId}
|
||||
onClick={handleOpenReply}
|
||||
onThreadClick={setActiveThreadId}
|
||||
getMemberPowerTag={getMemberPowerTag}
|
||||
accessibleTagColors={accessiblePowerTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
@@ -1097,16 +1125,21 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
)
|
||||
}
|
||||
reactions={
|
||||
reactionRelations && (
|
||||
<Reactions
|
||||
style={{ marginTop: config.space.S200 }}
|
||||
room={room}
|
||||
relations={reactionRelations}
|
||||
mEventId={mEventId}
|
||||
canSendReaction={canSendReaction}
|
||||
onReactionToggle={handleReactionToggle}
|
||||
/>
|
||||
)
|
||||
<>
|
||||
{reactionRelations && (
|
||||
<Reactions
|
||||
style={{ marginTop: config.space.S200 }}
|
||||
room={room}
|
||||
relations={reactionRelations}
|
||||
mEventId={mEventId}
|
||||
canSendReaction={canSendReaction}
|
||||
onReactionToggle={handleReactionToggle}
|
||||
/>
|
||||
)}
|
||||
{(!threadRootId || threadRootId === mEventId) && (
|
||||
<ThreadSummary rootEvent={mEvent} room={room} onOpen={setActiveThreadId} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
hideReadReceipts={hideActivity}
|
||||
showDeveloperTools={showDeveloperTools}
|
||||
@@ -1175,6 +1208,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
replyEventId={replyEventId}
|
||||
threadRootId={threadRootId}
|
||||
onClick={handleOpenReply}
|
||||
onThreadClick={setActiveThreadId}
|
||||
getMemberPowerTag={getMemberPowerTag}
|
||||
accessibleTagColors={accessiblePowerTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
@@ -1182,16 +1216,21 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
)
|
||||
}
|
||||
reactions={
|
||||
reactionRelations && (
|
||||
<Reactions
|
||||
style={{ marginTop: config.space.S200 }}
|
||||
room={room}
|
||||
relations={reactionRelations}
|
||||
mEventId={mEventId}
|
||||
canSendReaction={canSendReaction}
|
||||
onReactionToggle={handleReactionToggle}
|
||||
/>
|
||||
)
|
||||
<>
|
||||
{reactionRelations && (
|
||||
<Reactions
|
||||
style={{ marginTop: config.space.S200 }}
|
||||
room={room}
|
||||
relations={reactionRelations}
|
||||
mEventId={mEventId}
|
||||
canSendReaction={canSendReaction}
|
||||
onReactionToggle={handleReactionToggle}
|
||||
/>
|
||||
)}
|
||||
{(!threadRootId || threadRootId === mEventId) && (
|
||||
<ThreadSummary rootEvent={mEvent} room={room} onOpen={setActiveThreadId} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
hideReadReceipts={hideActivity}
|
||||
showDeveloperTools={showDeveloperTools}
|
||||
|
||||
Reference in New Issue
Block a user