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:
@@ -0,0 +1,142 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
} from 'folds';
|
||||
import { Room, RoomEvent, ThreadEvent } from 'matrix-js-sdk';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import classNames from 'classnames';
|
||||
import * as css from './ThreadPanel.css';
|
||||
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
||||
import { ThreadTimeline } from './ThreadTimeline';
|
||||
import { markThreadAsRead, useThreadInstance } from './useThread';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useEditor } from '../../../components/editor';
|
||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { RoomInput } from '../RoomInput';
|
||||
|
||||
type ThreadPanelHeaderProps = {
|
||||
room: Room;
|
||||
requestClose: () => void;
|
||||
};
|
||||
function ThreadPanelHeader({ room, requestClose }: ThreadPanelHeaderProps) {
|
||||
return (
|
||||
<Header className={css.ThreadPanelHeader} variant="Background" size="600">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="H5" truncate>
|
||||
Thread
|
||||
</Text>
|
||||
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||
{room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No" alignItems="Center">
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Close</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
variant="Background"
|
||||
aria-label="Close thread"
|
||||
onClick={requestClose}
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</Box>
|
||||
</Box>
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
|
||||
export type ThreadPanelProps = {
|
||||
room: Room;
|
||||
threadId: string;
|
||||
requestClose: () => void;
|
||||
};
|
||||
export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps) {
|
||||
const mx = useMatrixClient();
|
||||
const editor = useEditor();
|
||||
const thread = useThreadInstance(room, threadId);
|
||||
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts');
|
||||
const fileDropContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||
|
||||
useKeyDown(
|
||||
window,
|
||||
useCallback(
|
||||
(evt) => {
|
||||
if (isKeyHotkey('escape', evt)) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
requestClose();
|
||||
}
|
||||
},
|
||||
[requestClose],
|
||||
),
|
||||
);
|
||||
|
||||
// Mark the thread read when the panel is open and on each new thread event.
|
||||
useEffect(() => {
|
||||
if (!thread) return undefined;
|
||||
const markRead = () => {
|
||||
markThreadAsRead(mx, thread, privateReadReceipts);
|
||||
};
|
||||
markRead();
|
||||
thread.on(ThreadEvent.NewReply, markRead);
|
||||
thread.on(RoomEvent.Timeline, markRead);
|
||||
return () => {
|
||||
thread.off(ThreadEvent.NewReply, markRead);
|
||||
thread.off(RoomEvent.Timeline, markRead);
|
||||
};
|
||||
}, [mx, thread, privateReadReceipts]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classNames(css.ThreadPanel, ContainerColor({ variant: 'Background' }))}
|
||||
shrink="No"
|
||||
direction="Column"
|
||||
>
|
||||
<ThreadPanelHeader room={room} requestClose={requestClose} />
|
||||
{!thread ? (
|
||||
<Box grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
||||
<Spinner size="400" variant="Secondary" />
|
||||
<Text size="T300">Loading thread…</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box grow="Yes" className={css.ThreadPanelContent} direction="Column">
|
||||
<ThreadTimeline room={room} thread={thread} editor={editor} />
|
||||
</Box>
|
||||
<Box className={css.ThreadPanelInput} shrink="No" direction="Column">
|
||||
<RoomInput
|
||||
room={room}
|
||||
roomId={room.roomId}
|
||||
threadRootId={threadId}
|
||||
editor={editor}
|
||||
fileDropContainerRef={fileDropContainerRef}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user