From aa62df9c75fe420d4e95168ed3639ed67d6ab2d5 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 1 Jul 2026 21:45:20 -0400 Subject: [PATCH] =?UTF-8?q?feat(threads):=20Thread=20Panel=20=E2=80=94=20f?= =?UTF-8?q?ull=20side=20drawer=20(P3-8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/components/message/Reply.tsx | 4 +- src/app/features/room/Room.tsx | 17 + src/app/features/room/RoomInput.tsx | 75 +- src/app/features/room/RoomTimeline.tsx | 89 +- .../features/room/thread/ThreadPanel.css.ts | 30 + src/app/features/room/thread/ThreadPanel.tsx | 142 +++ .../features/room/thread/ThreadSummary.tsx | 49 + .../room/thread/ThreadTimeline.css.ts | 39 + .../features/room/thread/ThreadTimeline.tsx | 940 ++++++++++++++++++ src/app/features/room/thread/index.ts | 2 + .../room/thread/threadSummary.test.ts | 133 +++ src/app/features/room/thread/threadSummary.ts | 55 + src/app/features/room/thread/useThread.ts | 168 ++++ src/app/hooks/useThreadSummary.ts | 57 ++ src/app/state/room/thread.test.ts | 43 + src/app/state/room/thread.ts | 22 + 16 files changed, 1811 insertions(+), 54 deletions(-) create mode 100644 src/app/features/room/thread/ThreadPanel.css.ts create mode 100644 src/app/features/room/thread/ThreadPanel.tsx create mode 100644 src/app/features/room/thread/ThreadSummary.tsx create mode 100644 src/app/features/room/thread/ThreadTimeline.css.ts create mode 100644 src/app/features/room/thread/ThreadTimeline.tsx create mode 100644 src/app/features/room/thread/index.ts create mode 100644 src/app/features/room/thread/threadSummary.test.ts create mode 100644 src/app/features/room/thread/threadSummary.ts create mode 100644 src/app/features/room/thread/useThread.ts create mode 100644 src/app/hooks/useThreadSummary.ts create mode 100644 src/app/state/room/thread.test.ts create mode 100644 src/app/state/room/thread.ts diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index 5d2a65899..ba93fde20 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -61,6 +61,7 @@ type ReplyProps = { replyEventId: string; threadRootId?: string | undefined; onClick?: MouseEventHandler | undefined; + onThreadClick?: ((threadRootId: string) => void) | undefined; getMemberPowerTag?: GetMemberPowerTag; accessibleTagColors?: Map; legacyUsernameColor?: boolean; @@ -74,6 +75,7 @@ export const Reply = as<'div', ReplyProps>( replyEventId, threadRootId, onClick, + onThreadClick, getMemberPowerTag, accessibleTagColors, legacyUsernameColor, @@ -110,7 +112,7 @@ export const Reply = as<'div', ReplyProps>( onThreadClick(threadRootId) : onClick} aria-label="View thread" /> )} diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index c9f4f96de..a46e01113 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -22,6 +22,8 @@ import { callChatAtom } from '../../state/callEmbed'; import { CallChatView } from './CallChatView'; import { useCallEmbed } from '../../hooks/useCallEmbed'; import { useCallMembers, useCallSession } from '../../hooks/useCall'; +import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread'; +import { ThreadPanel } from './thread'; export function Room() { const { eventId } = useParams(); @@ -33,6 +35,8 @@ export function Room() { const callEmbed = useCallEmbed(); const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); + const activeThreadId = useAtomValue(roomIdToActiveThreadIdAtomFamily(room.roomId)); + const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId)); const galleryOpen = useAtomValue(mediaGalleryAtom); const setGalleryOpen = useSetAtom(mediaGalleryAtom); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); @@ -90,6 +94,19 @@ export function Room() { setGalleryOpen(false)} /> )} + {!callView && activeThreadId && ( + <> + {screenSize === ScreenSize.Desktop && ( + + )} + setActiveThreadId(null)} + /> + + )} {!callView && isDrawer && ( <> {screenSize === ScreenSize.Desktop && ( diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index d5007b233..08f27cf76 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -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; roomId: string; room: Room; + threadRootId?: string; } export const RoomInput = forwardRef( - ({ 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( 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( 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( 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( 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( }, { 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( 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( 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( } 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( // 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( }); 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( 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( }); 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( ); 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( if (alive()) setGifUploading(false); } }, - [mx, roomId, alive], + [mx, roomId, threadRootId, alive], ); const handleStickerSelect = useCallback( @@ -770,13 +789,13 @@ export const RoomInput = forwardRef( 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( {locationError} )} - + {charCount > 0 && ( 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 && ( - - ) + <> + {reactionRelations && ( + + )} + {(!threadRootId || threadRootId === mEventId) && ( + + )} + } 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 && ( - - ) + <> + {reactionRelations && ( + + )} + {(!threadRootId || threadRootId === mEventId) && ( + + )} + } hideReadReceipts={hideActivity} showDeveloperTools={showDeveloperTools} diff --git a/src/app/features/room/thread/ThreadPanel.css.ts b/src/app/features/room/thread/ThreadPanel.css.ts new file mode 100644 index 000000000..56000e360 --- /dev/null +++ b/src/app/features/room/thread/ThreadPanel.css.ts @@ -0,0 +1,30 @@ +import { style } from '@vanilla-extract/css'; +import { config, toRem } from 'folds'; + +export const ThreadPanel = style({ + width: toRem(360), + '@media': { + '(max-width: 750px)': { + position: 'fixed', + inset: 0, + width: '100%', + zIndex: 500, + }, + }, +}); + +export const ThreadPanelHeader = style({ + flexShrink: 0, + padding: `0 ${config.space.S200} 0 ${config.space.S300}`, + borderBottomWidth: config.borderWidth.B300, +}); + +export const ThreadPanelContent = style({ + position: 'relative', + overflow: 'hidden', +}); + +export const ThreadPanelInput = style({ + padding: config.space.S200, + borderTopWidth: config.borderWidth.B300, +}); diff --git a/src/app/features/room/thread/ThreadPanel.tsx b/src/app/features/room/thread/ThreadPanel.tsx new file mode 100644 index 000000000..7d528e7e1 --- /dev/null +++ b/src/app/features/room/thread/ThreadPanel.tsx @@ -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 ( +
+ + + + Thread + + + {room.name} + + + + + Close + + } + > + {(triggerRef) => ( + + + + )} + + + +
+ ); +} + +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(null) as React.RefObject; + + 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 ( + + + {!thread ? ( + + + Loading thread… + + ) : ( + <> + + + + + + + + )} + + ); +} diff --git a/src/app/features/room/thread/ThreadSummary.tsx b/src/app/features/room/thread/ThreadSummary.tsx new file mode 100644 index 000000000..43e17ea27 --- /dev/null +++ b/src/app/features/room/thread/ThreadSummary.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Badge, Box, Chip, Icon, Icons, Text, config } from 'folds'; +import { MatrixEvent, Room } from 'matrix-js-sdk'; +import { useThreadSummary } from '../../../hooks/useThreadSummary'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; +import { timeDayMonthYear, timeHourMinute, today } from '../../../utils/time'; + +type ThreadSummaryProps = { + rootEvent: MatrixEvent; + room: Room; + onOpen: (threadId: string) => void; +}; +export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) { + const { summary, unread } = useThreadSummary(rootEvent, room); + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + + if (!summary || summary.count === 0) return null; + + const { count, latestTs } = summary; + const latestStr = + latestTs !== undefined + ? today(latestTs) + ? timeHourMinute(latestTs, hour24Clock) + : timeDayMonthYear(latestTs) + : undefined; + + return ( + + } + after={ + unread > 0 ? : undefined + } + onClick={() => { + const threadId = rootEvent.getId(); + if (threadId) onOpen(threadId); + }} + > + + {count === 1 ? '1 reply' : `${count} replies`} + {latestStr ? ` · ${latestStr}` : ''} + + + + ); +} diff --git a/src/app/features/room/thread/ThreadTimeline.css.ts b/src/app/features/room/thread/ThreadTimeline.css.ts new file mode 100644 index 000000000..303afb3a2 --- /dev/null +++ b/src/app/features/room/thread/ThreadTimeline.css.ts @@ -0,0 +1,39 @@ +import { style } from '@vanilla-extract/css'; +import { color, config } from 'folds'; + +export const ThreadTimeline = style({ + height: '100%', + position: 'relative', +}); + +export const ThreadTimelineContent = style({ + minHeight: '100%', + padding: `${config.space.S400} 0`, +}); + +export const ThreadCentered = style({ + height: '100%', + padding: config.space.S700, +}); + +export const RootMessage = style({ + backgroundColor: color.SurfaceVariant.Container, + borderRadius: config.radii.R400, + marginBottom: config.space.S100, +}); + +export const RepliesDivider = style({ + padding: `${config.space.S200} ${config.space.S400}`, +}); + +export const NoReplies = style({ + padding: config.space.S400, +}); + +export const PendingMessage = style({ + opacity: 0.6, +}); + +export const PendingFailed = style({ + opacity: 1, +}); diff --git a/src/app/features/room/thread/ThreadTimeline.tsx b/src/app/features/room/thread/ThreadTimeline.tsx new file mode 100644 index 000000000..82057e74c --- /dev/null +++ b/src/app/features/room/thread/ThreadTimeline.tsx @@ -0,0 +1,940 @@ +import React, { + Dispatch, + MouseEventHandler, + ReactNode, + SetStateAction, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + Direction, + EventStatus, + EventTimeline, + EventTimelineSet, + EventTimelineSetHandlerMap, + MatrixClient, + MatrixEvent, + RelationType, + Room, + RoomEvent, + Thread, + ThreadEvent, +} from 'matrix-js-sdk'; +import { HTMLReactParserOptions } from 'html-react-parser'; +import { Editor } from 'slate'; +import { ReactEditor } from 'slate-react'; +import to from 'await-to-js'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { Badge, Box, Line, Scroll, Spinner, Text, color, config } from 'folds'; +import classNames from 'classnames'; +import { Opts as LinkifyOpts } from 'linkifyjs'; +import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../../utils/matrix'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator'; +import { useAlive } from '../../../hooks/useAlive'; +import { scrollToBottom } from '../../../utils/dom'; +import { + DefaultPlaceholder, + MessageBase, + Reply, + RedactedContent, + MSticker, + MessageUnsupportedContent, + MessageNotDecryptedContent, + ImageContent, +} from '../../../components/message'; +import { + factoryRenderLinkifyWithMention, + getReactCustomHtmlParser, + LINKIFY_OPTS, + makeMentionCustomProps, + renderMatrixMention, +} from '../../../plugins/react-custom-html-parser'; +import { + decryptAllTimelineEvent, + getEditedEvent, + getEventReactions, + getMemberDisplayName, + getReactionContent, + reactionOrEditEvent, +} from '../../../utils/room'; +import { useSetting } from '../../../state/hooks/settings'; +import { MessageLayout, settingsAtom } from '../../../state/settings'; +import { Message, Reactions } from '../message'; +import { RenderMessageContent } from '../../../components/RenderMessageContent'; +import { Image } from '../../../components/media'; +import { ImageViewer } from '../../../components/image-viewer'; +import * as css from './ThreadTimeline.css'; +import { + inSameDay, + minuteDifference, + timeDayMonthYear, + today, + yesterday, +} from '../../../utils/time'; +import { createMentionElement, moveCursor } from '../../../components/editor'; +import { roomIdToReplyDraftAtomFamily } from '../../../state/room/roomInputDrafts'; +import { usePowerLevelsContext } from '../../../hooks/usePowerLevels'; +import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room'; +import { + getIntersectionObserverEntry, + useIntersectionObserver, +} from '../../../hooks/useIntersectionObserver'; +import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler'; +import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler'; +import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers'; +import { useImagePackRooms } from '../../../hooks/useImagePackRooms'; +import { useIsDirectRoom } from '../../../hooks/useRoom'; +import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile'; +import { useSpaceOptionally } from '../../../hooks/useSpace'; +import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; +import { + useAccessiblePowerTagColors, + useGetMemberPowerTag, +} from '../../../hooks/useMemberPowerTag'; +import { useTheme } from '../../../hooks/useTheme'; +import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag'; +import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags'; +import { roomToParentsAtom } from '../../../state/room/roomToParents'; +import { EditHistoryModal } from '../message/EditHistoryModal'; +import { + getLinkedTimelines, + getTimelineAndBaseIndex, + getTimelineEvent, + getTimelineRelativeIndex, + getTimelinesEventsCount, + timelineToEventsCount, +} from '../RoomTimeline'; +import { getThreadDraftKey } from '../../../state/room/thread'; +import { useThreadLinkedTimelines, useThreadPendingEvents } from './useThread'; + +// Virtual window size (how many items render around the viewport). +const PAGINATION_LIMIT = 50; +// Network page size for backward /relations pagination of the thread timeline. +const THREAD_PAGE_LIMIT = 30; + +type Timeline = { + linkedTimelines: EventTimeline[]; + range: ItemRange; +}; + +const getEmptyTimeline = (): Timeline => ({ + linkedTimelines: [], + range: { start: 0, end: 0 }, +}); + +const getInitialThreadTimeline = (thread: Thread, timelines?: EventTimeline[]): Timeline => { + const linkedTimelines = + timelines && timelines.length > 0 ? timelines : getLinkedTimelines(thread.liveTimeline); + const evLength = getTimelinesEventsCount(linkedTimelines); + return { + linkedTimelines, + range: { + start: Math.max(evLength - PAGINATION_LIMIT, 0), + end: evLength, + }, + }; +}; + +/** + * Copy of RoomTimeline's `useTimelinePagination` pattern (not exported from RoomTimeline + * as its ~35 hooks are hardwired to the room live timeline). Works transparently against + * the thread timeline's /relations pagination. + */ +const useThreadTimelinePagination = ( + mx: MatrixClient, + timeline: Timeline, + setTimeline: Dispatch>, + limit: number, +) => { + const timelineRef = useRef(timeline); + timelineRef.current = timeline; + const alive = useAlive(); + + const handleTimelinePagination = useMemo(() => { + let fetching = false; + + const recalibratePagination = ( + linkedTimelines: EventTimeline[], + timelinesEventsCount: number[], + backwards: boolean, + ) => { + const topTimeline = linkedTimelines[0]; + const timelineMatch = (mt: EventTimeline) => (t: EventTimeline) => t === mt; + + const newLTimelines = getLinkedTimelines(topTimeline); + const topTmIndex = newLTimelines.findIndex(timelineMatch(topTimeline)); + const topAddedTm = topTmIndex === -1 ? [] : newLTimelines.slice(0, topTmIndex); + + const topTmAddedEvt = + timelineToEventsCount(newLTimelines[topTmIndex]) - timelinesEventsCount[0]; + const offsetRange = getTimelinesEventsCount(topAddedTm) + (backwards ? topTmAddedEvt : 0); + + setTimeline((currentTimeline) => ({ + linkedTimelines: newLTimelines, + range: + offsetRange > 0 + ? { + start: currentTimeline.range.start + offsetRange, + end: currentTimeline.range.end + offsetRange, + } + : { ...currentTimeline.range }, + })); + }; + + return async (backwards: boolean) => { + if (fetching) return; + const { linkedTimelines: lTimelines } = timelineRef.current; + const timelinesEventsCount = lTimelines.map(timelineToEventsCount); + + const timelineToPaginate = backwards ? lTimelines[0] : lTimelines[lTimelines.length - 1]; + if (!timelineToPaginate) return; + + const paginationToken = timelineToPaginate.getPaginationToken( + backwards ? Direction.Backward : Direction.Forward, + ); + if ( + !paginationToken && + getTimelinesEventsCount(lTimelines) !== + getTimelinesEventsCount(getLinkedTimelines(timelineToPaginate)) + ) { + recalibratePagination(lTimelines, timelinesEventsCount, backwards); + return; + } + + fetching = true; + const [err] = await to( + mx.paginateEventTimeline(timelineToPaginate, { + backwards, + limit, + }), + ); + if (err) { + fetching = false; + return; + } + const fetchedTimeline = + timelineToPaginate.getNeighbouringTimeline( + backwards ? Direction.Backward : Direction.Forward, + ) ?? timelineToPaginate; + // Decrypt all event ahead of render cycle + const roomId = fetchedTimeline.getRoomId(); + const room = roomId ? mx.getRoom(roomId) : null; + + if (room?.hasEncryptionStateEvent()) { + await to(decryptAllTimelineEvent(mx, fetchedTimeline)); + } + + fetching = false; + if (alive()) { + recalibratePagination(lTimelines, timelinesEventsCount, backwards); + } + }; + }, [mx, alive, setTimeline, limit]); + return handleTimelinePagination; +}; + +export type ThreadTimelineProps = { + room: Room; + thread: Thread; + editor: Editor; +}; + +export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) { + const mx = useMatrixClient(); + const alive = useAlive(); + const useAuthentication = useMediaAuthentication(); + + const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); + const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); + const [perMessageProfiles] = useSetting(settingsAtom, 'perMessageProfiles'); + const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); + const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal'); + const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); + const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); + const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); + const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + + const direct = useIsDirectRoom(); + const ignoredUsersList = useIgnoredUsers(); + const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]); + + const setReplyDraft = useSetAtom( + roomIdToReplyDraftAtomFamily(getThreadDraftKey(room.roomId, thread.id)), + ); + + const powerLevels = usePowerLevelsContext(); + const creators = useRoomCreators(room); + const creatorsTag = useRoomCreatorsTag(); + const powerLevelTags = usePowerLevelTags(room, powerLevels); + const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels); + + const theme = useTheme(); + const accessiblePowerTagColors = useAccessiblePowerTagColors( + theme.kind, + creatorsTag, + powerLevelTags, + ); + + const permissions = useRoomPermissions(creators, powerLevels); + const canRedact = permissions.action('redact', mx.getSafeUserId()); + const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId()); + const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId()); + const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId()); + + const mentionClickHandler = useMentionClickHandler(room.roomId); + const spoilerClickHandler = useSpoilerClickHandler(); + const openUserRoomProfile = useOpenUserRoomProfile(); + const space = useSpaceOptionally(); + const roomToParents = useAtomValue(roomToParentsAtom); + const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents); + + const [editId, setEditId] = useState(); + const [editHistoryEvent, setEditHistoryEvent] = useState(); + + const linkifyOpts = useMemo( + () => ({ + ...LINKIFY_OPTS, + render: factoryRenderLinkifyWithMention((href) => + renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)), + ), + }), + [mx, room, mentionClickHandler], + ); + const htmlReactParserOptions = useMemo( + () => + getReactCustomHtmlParser(mx, room.roomId, { + linkifyOpts, + useAuthentication, + handleSpoilerClick: spoilerClickHandler, + handleMentionClick: mentionClickHandler, + }), + [mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication], + ); + + const { timelines, ready } = useThreadLinkedTimelines(mx, thread); + const pendingEvents = useThreadPendingEvents(room, thread.id, thread); + + const [timeline, setTimeline] = useState(() => + ready ? getInitialThreadTimeline(thread, timelines) : getEmptyTimeline(), + ); + const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines); + + const canPaginateBack = + typeof timeline.linkedTimelines[0]?.getPaginationToken(Direction.Backward) === 'string'; + const rangeAtStart = timeline.range.start === 0; + + const scrollRef = useRef(null); + const atBottomAnchorRef = useRef(null); + const [atBottom, setAtBottom] = useState(true); + const atBottomRef = useRef(atBottom); + atBottomRef.current = atBottom; + const scrollToBottomRef = useRef({ count: 0, smooth: true }); + + const handleTimelinePagination = useThreadTimelinePagination( + mx, + timeline, + setTimeline, + THREAD_PAGE_LIMIT, + ); + + const getScrollElement = useCallback(() => scrollRef.current, []); + + const { getItems, scrollToItem, observeBackAnchor } = useVirtualPaginator({ + count: eventsLength, + limit: PAGINATION_LIMIT, + range: timeline.range, + onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []), + getScrollElement, + getItemElement: useCallback( + (index: number) => + (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ?? + undefined, + [], + ), + onEnd: handleTimelinePagination, + }); + + // Seed local timeline once the thread has fetched its initial events. + const seededRef = useRef(false); + useEffect(() => { + if (!ready || seededRef.current) return; + seededRef.current = true; + setTimeline(getInitialThreadTimeline(thread, timelines)); + scrollToBottomRef.current.count += 1; + scrollToBottomRef.current.smooth = false; + if (room.hasEncryptionStateEvent()) { + to(decryptAllTimelineEvent(mx, thread.liveTimeline)).then(() => { + if (alive()) setTimeline((ct) => ({ ...ct })); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- seed once when ready flips + }, [ready, thread]); + + // Re-render / stick-to-bottom on live thread activity. + useEffect(() => { + const handleTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = ( + mEvent, + eventRoom, + toStartOfTimeline, + removed, + data, + ) => { + if (!data?.liveEvent) return; + if (atBottomRef.current) { + scrollToBottomRef.current.count += 1; + scrollToBottomRef.current.smooth = true; + setTimeline((ct) => ({ + ...ct, + range: { + start: ct.range.start + 1, + end: ct.range.end + 1, + }, + })); + return; + } + setTimeline((ct) => ({ ...ct })); + }; + const handleUpdate = () => setTimeline((ct) => ({ ...ct })); + + thread.on(RoomEvent.Timeline, handleTimeline); + thread.on(ThreadEvent.Update, handleUpdate); + return () => { + thread.removeListener(RoomEvent.Timeline, handleTimeline); + thread.removeListener(ThreadEvent.Update, handleUpdate); + }; + }, [thread]); + + // atBottom detection + useIntersectionObserver( + useCallback((entries) => { + const target = atBottomAnchorRef.current; + if (!target) return; + const entry = getIntersectionObserverEntry(target, entries); + if (entry) setAtBottom(entry.isIntersecting); + }, []), + useCallback( + () => ({ + root: getScrollElement(), + rootMargin: '100px', + }), + [getScrollElement], + ), + useCallback(() => atBottomAnchorRef.current, []), + ); + + // Initial scroll to bottom on mount. + useLayoutEffect(() => { + const scrollEl = scrollRef.current; + if (scrollEl) scrollToBottom(scrollEl); + }, []); + + // Scroll to bottom when requested. + const scrollToBottomCount = scrollToBottomRef.current.count; + useLayoutEffect(() => { + if (scrollToBottomCount > 0) { + const scrollEl = scrollRef.current; + if (scrollEl) + scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant'); + } + }, [scrollToBottomCount]); + + // Scroll in-place editor into view. + useEffect(() => { + if (editId) { + const editMsgElement = + (scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ?? + undefined; + editMsgElement?.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + }, [editId]); + + const handleUserClick: MouseEventHandler = useCallback( + (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + const userId = evt.currentTarget.getAttribute('data-user-id'); + if (!userId) return; + openUserRoomProfile( + room.roomId, + space?.roomId, + userId, + evt.currentTarget.getBoundingClientRect(), + ); + }, + [room, space, openUserRoomProfile], + ); + + const handleUsernameClick: MouseEventHandler = useCallback( + (evt) => { + evt.preventDefault(); + const userId = evt.currentTarget.getAttribute('data-user-id'); + if (!userId) return; + const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; + editor.insertNode( + createMentionElement( + userId, + name.startsWith('@') ? name : `@${name}`, + userId === mx.getUserId(), + ), + ); + ReactEditor.focus(editor); + moveCursor(editor); + }, + [mx, room, editor], + ); + + const handleReplyClick: MouseEventHandler = useCallback( + (evt) => { + const replyId = evt.currentTarget.getAttribute('data-event-id'); + if (!replyId) return; + const replyEvt = thread.findEventById(replyId) ?? room.findEventById(replyId); + if (!replyEvt) return; + const editedReply = getEditedEvent(replyId, replyEvt, thread.getUnfilteredTimelineSet()); + const content = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent(); + const { body, formatted_body: formattedBody } = content; + const senderId = replyEvt.getSender(); + if (senderId && typeof body === 'string') { + setReplyDraft({ + userId: senderId, + eventId: replyId, + body, + formattedBody, + relation: { rel_type: RelationType.Thread, event_id: thread.id }, + }); + setTimeout(() => ReactEditor.focus(editor), 100); + } + }, + [room, thread, setReplyDraft, editor], + ); + + const handleReactionToggle = useCallback( + (targetEventId: string, key: string, shortcode?: string) => { + const timelineSet = thread.getUnfilteredTimelineSet(); + const relations = getEventReactions(timelineSet, targetEventId); + const allReactions = relations?.getSortedAnnotationsByKey() ?? []; + const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? []; + const reactions = reactionsSet ? Array.from(reactionsSet) : []; + const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!)); + + if (myReaction && !!myReaction.isRelation()) { + mx.redactEvent(room.roomId, myReaction.getId()!); + return; + } + const rShortcode = + shortcode || + (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined); + mx.sendEvent( + room.roomId, + thread.id, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + MessageEvent.Reaction as any, + getReactionContent(targetEventId, key, rShortcode), + ); + }, + [mx, room, thread], + ); + + const handleEdit = useCallback( + (editEvtId?: string) => { + if (editEvtId) { + setEditId(editEvtId); + return; + } + setEditId(undefined); + ReactEditor.focus(editor); + }, + [editor], + ); + + const handleOpenReply: MouseEventHandler = useCallback( + (evt) => { + const targetId = evt.currentTarget.getAttribute('data-event-id'); + if (!targetId) return; + // best-effort: scroll to referenced event if it is inside the loaded thread window + let absIndex = -1; + let acc = 0; + timeline.linkedTimelines.some((tl) => { + const idx = tl.getEvents().findIndex((e) => e.getId() === targetId); + if (idx !== -1) { + absIndex = acc + idx; + return true; + } + acc += tl.getEvents().length; + return false; + }); + if (absIndex >= 0) { + scrollToItem(absIndex, { + behavior: 'smooth', + align: 'center', + stopInView: true, + }); + } + }, + [timeline.linkedTimelines, scrollToItem], + ); + + const renderMessageContent = useCallback( + (mEvent: MatrixEvent, mEventId: string, timelineSet: EventTimelineSet): ReactNode => { + if (mEvent.isRedacted()) { + return ; + } + const type = mEvent.getType(); + if (type === MessageEvent.Sticker) { + return ( + ( + } + renderViewer={(p) => } + /> + )} + /> + ); + } + if (type === MessageEvent.RoomMessageEncrypted) { + return ( + + + + ); + } + if (type !== MessageEvent.RoomMessage) { + return ( + + + + ); + } + const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); + const getContent = (() => + editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback; + const senderId = mEvent.getSender() ?? ''; + const senderDisplayName = + getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; + return ( + setEditHistoryEvent(mEvent) : undefined} + getContent={getContent} + mediaAutoLoad={mediaAutoLoad} + urlPreview={showUrlPreview} + htmlReactParserOptions={htmlReactParserOptions} + linkifyOpts={linkifyOpts} + outlineAttachment={messageLayout === MessageLayout.Bubble} + eventId={mEventId} + /> + ); + }, + [room, mediaAutoLoad, showUrlPreview, htmlReactParserOptions, linkifyOpts, messageLayout], + ); + + const renderMessage = useCallback( + ( + mEvent: MatrixEvent, + opts: { item?: number; collapse: boolean; highlight: boolean; editable: boolean }, + ): ReactNode => { + const mEventId = mEvent.getId(); + if (!mEventId) return null; + const timelineSet = thread.getUnfilteredTimelineSet(); + const reactionRelations = getEventReactions(timelineSet, mEventId); + const reactions = reactionRelations?.getSortedAnnotationsByKey(); + const hasReactions = !!reactions && reactions.length > 0; + const { replyEventId, threadRootId } = mEvent; + + return ( + + ) + } + reactions={ + reactionRelations && ( + + ) + } + hideReadReceipts={hideActivity} + showDeveloperTools={showDeveloperTools} + memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')} + accessibleTagColors={accessiblePowerTagColors} + legacyUsernameColor={legacyUsernameColor || direct} + hour24Clock={hour24Clock} + dateFormatString={dateFormatString} + lotusTerminal={!!lotusTerminal} + > + {renderMessageContent(mEvent, mEventId, timelineSet)} + + ); + }, + [ + thread, + room, + messageSpacing, + messageLayout, + editId, + canRedact, + canDeleteOwn, + canSendReaction, + canPinEvent, + imagePackRooms, + handleUserClick, + handleUsernameClick, + handleReplyClick, + handleReactionToggle, + handleEdit, + handleOpenReply, + getMemberPowerTag, + accessiblePowerTagColors, + legacyUsernameColor, + direct, + hideActivity, + showDeveloperTools, + hour24Clock, + dateFormatString, + lotusTerminal, + mx, + renderMessageContent, + ], + ); + + let prevEvent: MatrixEvent | undefined; + let isPrevRendered = false; + let dayDivider = false; + const eventRenderer = (item: number) => { + const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item); + if (!eventTimeline) return null; + const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex)); + const mEventId = mEvent?.getId(); + if (!mEvent || !mEventId) return null; + + // Skip annotations, edits, and any state/membership events (they can't be threaded). + if (reactionOrEditEvent(mEvent) || typeof mEvent.getStateKey() === 'string') { + prevEvent = mEvent; + return null; + } + const eventSender = mEvent.getSender(); + if (eventSender && ignoredUsersSet.has(eventSender)) { + prevEvent = mEvent; + return null; + } + + const isRoot = mEventId === thread.id; + + if (!dayDivider) { + dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false; + } + + const collapsed = + !isRoot && + !perMessageProfiles && + isPrevRendered && + !dayDivider && + prevEvent !== undefined && + prevEvent.getId() !== thread.id && + prevEvent.getSender() === eventSender && + prevEvent.getType() === mEvent.getType() && + minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 5; + + const eventJSX = renderMessage(mEvent, { + item, + collapse: collapsed, + highlight: false, + editable: true, + }); + + const dayDividerJSX = + dayDivider && eventJSX && !isRoot ? ( + + + + + + {(() => { + if (today(mEvent.getTs())) return 'Today'; + if (yesterday(mEvent.getTs())) return 'Yesterday'; + return timeDayMonthYear(mEvent.getTs()); + })()} + + + + + + ) : null; + + prevEvent = mEvent; + isPrevRendered = !!eventJSX; + if (dayDividerJSX) dayDivider = false; + + // Root gets an emphasized container + a "N replies" divider under it. + if (isRoot && eventJSX) { + const replyCount = thread.length; + return ( + +
{eventJSX}
+ {replyCount > 0 && ( + + + + {replyCount === 1 ? '1 reply' : `${replyCount} replies`} + + + + )} +
+ ); + } + + if (eventJSX && dayDividerJSX) { + return ( + + {dayDividerJSX} + {eventJSX} + + ); + } + + return eventJSX; + }; + + const items = getItems(); + const showEmptyReplies = ready && thread.length === 0; + + const renderPendingEvent = (mEvent: MatrixEvent) => { + const failed = + mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED; + return ( +
+ {renderMessage(mEvent, { collapse: false, highlight: false, editable: false })} + {failed && ( + + + Failed to send + + + )} +
+ ); + }; + + if (!ready) { + return ( + + + + ); + } + + return ( + + + + {(canPaginateBack || !rangeAtStart) && ( + <> + + + + + + + + )} + + {items.map(eventRenderer)} + + {showEmptyReplies && ( + + + No replies yet — say something + + + )} + + {pendingEvents.map(renderPendingEvent)} + + + + + {editHistoryEvent && ( + setEditHistoryEvent(undefined)} + /> + )} + + ); +} diff --git a/src/app/features/room/thread/index.ts b/src/app/features/room/thread/index.ts new file mode 100644 index 000000000..26a60587d --- /dev/null +++ b/src/app/features/room/thread/index.ts @@ -0,0 +1,2 @@ +export * from './ThreadPanel'; +export * from './ThreadSummary'; diff --git a/src/app/features/room/thread/threadSummary.test.ts b/src/app/features/room/thread/threadSummary.test.ts new file mode 100644 index 000000000..795a69df9 --- /dev/null +++ b/src/app/features/room/thread/threadSummary.test.ts @@ -0,0 +1,133 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventStatus, MatrixEvent, RelationType } from 'matrix-js-sdk'; +import { getThreadSummary, isPendingThreadReply } from './threadSummary'; + +// getThreadSummary reads either the live Thread (preferred) or the +// server-aggregated `m.thread` bundle. We stub only the members it touches and +// cast through `unknown` to MatrixEvent, mirroring the light mocking used in +// the state tests. + +type ThreadStub = { length: number; lastReplyTs?: number }; +type BundleStub = { count: number; latestTs?: number }; + +const makeRootEvent = (opts: { thread?: ThreadStub; bundle?: BundleStub }): MatrixEvent => { + const thread = opts.thread + ? { + length: opts.thread.length, + lastReply: () => + opts.thread?.lastReplyTs === undefined + ? null + : ({ getTs: () => opts.thread?.lastReplyTs } as unknown as MatrixEvent), + } + : undefined; + + return { + getThread: () => thread, + getServerAggregatedRelation: (relType: string) => { + if (relType !== RelationType.Thread || !opts.bundle) return undefined; + return { + count: opts.bundle.count, + latest_event: + opts.bundle.latestTs === undefined + ? undefined + : { origin_server_ts: opts.bundle.latestTs }, + }; + }, + } as unknown as MatrixEvent; +}; + +// --------------------------------------------------------------------------- +// getThreadSummary +// --------------------------------------------------------------------------- + +test('prefers the live thread: count from length, latestTs from lastReply', () => { + const rootEvent = makeRootEvent({ + thread: { length: 3, lastReplyTs: 1700 }, + bundle: { count: 99, latestTs: 1 }, + }); + assert.deepEqual(getThreadSummary(rootEvent), { count: 3, latestTs: 1700 }); +}); + +test('live thread with no replies yields undefined latestTs', () => { + const rootEvent = makeRootEvent({ thread: { length: 0 } }); + assert.deepEqual(getThreadSummary(rootEvent), { count: 0, latestTs: undefined }); +}); + +test('falls back to the server bundle when no live thread', () => { + const rootEvent = makeRootEvent({ bundle: { count: 5, latestTs: 1234 } }); + assert.deepEqual(getThreadSummary(rootEvent), { count: 5, latestTs: 1234 }); +}); + +test('bundle without latest_event yields undefined latestTs', () => { + const rootEvent = makeRootEvent({ bundle: { count: 2 } }); + assert.deepEqual(getThreadSummary(rootEvent), { count: 2, latestTs: undefined }); +}); + +test('returns undefined when there is neither a thread nor a bundle', () => { + const rootEvent = makeRootEvent({}); + assert.equal(getThreadSummary(rootEvent), undefined); +}); + +// --------------------------------------------------------------------------- +// isPendingThreadReply +// --------------------------------------------------------------------------- + +const ROOT = '$root:server'; + +const makeReply = (opts: { + status: EventStatus | null; + threadRootId?: string; + relation?: { rel_type?: string; event_id?: string } | null; +}): MatrixEvent => + ({ + status: opts.status, + threadRootId: opts.threadRootId, + getRelation: () => opts.relation ?? null, + }) as unknown as MatrixEvent; + +test('SENDING with matching threadRootId is pending', () => { + const event = makeReply({ status: EventStatus.SENDING, threadRootId: ROOT }); + assert.equal(isPendingThreadReply(event, ROOT), true); +}); + +test('NOT_SENT with matching threadRootId is pending', () => { + const event = makeReply({ status: EventStatus.NOT_SENT, threadRootId: ROOT }); + assert.equal(isPendingThreadReply(event, ROOT), true); +}); + +test('SENDING resolved via the m.thread relation content is pending', () => { + const event = makeReply({ + status: EventStatus.SENDING, + relation: { rel_type: RelationType.Thread, event_id: ROOT }, + }); + assert.equal(isPendingThreadReply(event, ROOT), true); +}); + +test('SENT (confirmed) event is not pending', () => { + const event = makeReply({ status: EventStatus.SENT, threadRootId: ROOT }); + assert.equal(isPendingThreadReply(event, ROOT), false); +}); + +test('null status is not pending', () => { + const event = makeReply({ status: null, threadRootId: ROOT }); + assert.equal(isPendingThreadReply(event, ROOT), false); +}); + +test('SENDING but for a different thread is not pending', () => { + const event = makeReply({ status: EventStatus.SENDING, threadRootId: '$other:server' }); + assert.equal(isPendingThreadReply(event, ROOT), false); +}); + +test('SENDING with a non-thread relation is not pending', () => { + const event = makeReply({ + status: EventStatus.SENDING, + relation: { rel_type: RelationType.Reference, event_id: ROOT }, + }); + assert.equal(isPendingThreadReply(event, ROOT), false); +}); + +test('SENDING with no relation and no threadRootId is not pending', () => { + const event = makeReply({ status: EventStatus.SENDING }); + assert.equal(isPendingThreadReply(event, ROOT), false); +}); diff --git a/src/app/features/room/thread/threadSummary.ts b/src/app/features/room/thread/threadSummary.ts new file mode 100644 index 000000000..6896332db --- /dev/null +++ b/src/app/features/room/thread/threadSummary.ts @@ -0,0 +1,55 @@ +import { EventStatus, IThreadBundledRelationship, MatrixEvent, RelationType } from 'matrix-js-sdk'; + +export type ThreadSummaryData = { + count: number; + latestTs: number | undefined; +}; + +/** + * Summary data for a thread root's "N replies" chip. + * + * Prefers the live {@link Thread} object when it exists (it reflects local + * echo + pagination), otherwise falls back to the server-aggregated bundle + * (`unsigned['m.relations']['m.thread']`) so the chip renders before any + * Thread object has been created. Returns `undefined` when the root has no + * thread at all. + */ +export const getThreadSummary = (rootEvent: MatrixEvent): ThreadSummaryData | undefined => { + const thread = rootEvent.getThread(); + if (thread) { + const lastReply = thread.lastReply(); + return { + count: thread.length, + latestTs: lastReply?.getTs(), + }; + } + + const bundle = rootEvent.getServerAggregatedRelation( + RelationType.Thread, + ); + if (bundle) { + return { + count: bundle.count, + latestTs: bundle.latest_event?.origin_server_ts, + }; + } + + return undefined; +}; + +/** + * True when `event` is a still-in-flight (local echo) reply belonging to the + * given thread root. Used to render the pending strip, since pending thread + * sends never enter the thread's timelineSet. + */ +export const isPendingThreadReply = (event: MatrixEvent, threadRootId: string): boolean => { + const { status } = event; + if (status !== EventStatus.SENDING && status !== EventStatus.NOT_SENT) return false; + + // Prefer the SDK's resolved thread root id; fall back to the raw relation + // content for events the SDK hasn't associated with a thread yet. + if (event.threadRootId === threadRootId) return true; + + const relation = event.getRelation(); + return relation?.rel_type === RelationType.Thread && relation.event_id === threadRootId; +}; diff --git a/src/app/features/room/thread/useThread.ts b/src/app/features/room/thread/useThread.ts new file mode 100644 index 000000000..645b18574 --- /dev/null +++ b/src/app/features/room/thread/useThread.ts @@ -0,0 +1,168 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + EventTimeline, + MatrixClient, + MatrixEvent, + ReceiptType, + Room, + RoomEvent, + RoomEventHandlerMap, + Thread, + ThreadEvent, +} from 'matrix-js-sdk'; +import { getLinkedTimelines } from '../RoomTimeline'; +import { isPendingThreadReply } from './threadSummary'; + +/** + * Resolve (or bootstrap) the live {@link Thread} for a root event. + * + * Uses the existing thread when present, otherwise creates one via + * `room.createThread` — the SDK then auto-fetches the thread's events via + * `/relations` and inserts the root at the top. If the root event isn't loaded + * locally the Thread handles the root fetch itself, so passing `undefined` is + * safe. Re-resolves when a matching thread later appears/updates on the room. + */ +export const useThreadInstance = (room: Room, threadRootId: string): Thread | undefined => { + const getInstance = useCallback((): Thread | undefined => { + const existing = room.getThread(threadRootId); + if (existing) return existing; + const rootEvent = room.findEventById(threadRootId); + return room.createThread(threadRootId, rootEvent, [], false) ?? undefined; + }, [room, threadRootId]); + + const [thread, setThread] = useState(getInstance); + + useEffect(() => { + setThread(getInstance()); + + const handleThread: RoomEventHandlerMap[ThreadEvent.New] = (newThread) => { + if (newThread.id === threadRootId) setThread(newThread); + }; + const handleThreadUpdate: RoomEventHandlerMap[ThreadEvent.Update] = (updatedThread) => { + if (updatedThread.id === threadRootId) setThread(updatedThread); + }; + + room.on(ThreadEvent.New, handleThread); + room.on(ThreadEvent.Update, handleThreadUpdate); + return () => { + room.removeListener(ThreadEvent.New, handleThread); + room.removeListener(ThreadEvent.Update, handleThreadUpdate); + }; + }, [room, threadRootId, getInstance]); + + return thread; +}; + +/** + * Build the ordered list of linked {@link EventTimeline}s for a thread's live + * timeline and track readiness (`thread.initialEventsFetched`). Subscribes to + * the Thread's re-emitted timeline events so callers repaginate/re-render as + * the thread fills in. + */ +export const useThreadLinkedTimelines = ( + mx: MatrixClient, + thread: Thread, +): { timelines: EventTimeline[]; ready: boolean; refresh: () => void } => { + const [timelines, setTimelines] = useState(() => + getLinkedTimelines(thread.liveTimeline), + ); + const [ready, setReady] = useState(() => thread.initialEventsFetched); + + const refresh = useCallback(() => { + setTimelines(getLinkedTimelines(thread.liveTimeline)); + setReady(thread.initialEventsFetched); + }, [thread]); + + useEffect(() => { + refresh(); + + const handleTimeline = () => refresh(); + // Thread re-emits RoomEvent.Timeline / RoomEvent.TimelineReset from its + // timelineSet, and fires ThreadEvent.Update as it (re)populates. + thread.on(RoomEvent.Timeline, handleTimeline); + thread.on(RoomEvent.TimelineReset, handleTimeline); + thread.on(ThreadEvent.Update, handleTimeline); + return () => { + thread.removeListener(RoomEvent.Timeline, handleTimeline); + thread.removeListener(RoomEvent.TimelineReset, handleTimeline); + thread.removeListener(ThreadEvent.Update, handleTimeline); + }; + }, [thread, refresh]); + + return { timelines, ready, refresh }; +}; + +/** + * Track in-flight (local echo) replies for a thread. + * + * Pending thread sends never enter the thread's timelineSet (chronological + * pending ordering rejects them; `room.getPendingEvents()` THROWS in this + * mode). We instead watch `RoomEvent.LocalEchoUpdated` on the room and keep our + * own list of events that are pending replies to this thread and not yet in the + * thread timeline. When an event's remote echo arrives (status flips to SENT, + * or it lands in the thread) it drops out of the list. + */ +export const useThreadPendingEvents = ( + room: Room, + threadRootId: string, + thread: Thread | undefined, +): MatrixEvent[] => { + const [pending, setPending] = useState([]); + + useEffect(() => { + setPending([]); + + const handleLocalEcho: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (event) => { + const eventId = event.getId(); + setPending((prev) => { + // Drop any previous entry for this event (same instance across the + // temp-id -> real-id transition, or matched by id). + const without = prev.filter((e) => e !== event && e.getId() !== eventId); + + const alreadyInThread = + eventId !== undefined && thread?.findEventById(eventId) !== undefined; + const stillPending = isPendingThreadReply(event, threadRootId) && !alreadyInThread; + + if (stillPending) return [...without, event]; + return without.length === prev.length ? prev : without; + }); + }; + + room.on(RoomEvent.LocalEchoUpdated, handleLocalEcho); + return () => { + room.removeListener(RoomEvent.LocalEchoUpdated, handleLocalEcho); + }; + }, [room, threadRootId, thread]); + + return pending; +}; + +/** + * Send a threaded read receipt up to the latest confirmed event in the thread. + * + * The receipt is threaded by default (scoped to this thread), which clears the + * per-thread unread count. Mirrors the latest-valid-event scan in + * `utils/notifications.ts`. + */ +export const markThreadAsRead = async ( + mx: MatrixClient, + thread: Thread, + privateReceipt: boolean, +): Promise => { + const events = thread.liveTimeline.getEvents(); + + let latestEvent: MatrixEvent | undefined; + for (let i = events.length - 1; i >= 0; i -= 1) { + const evt = events[i]; + if (evt && !evt.isSending()) { + latestEvent = evt; + break; + } + } + if (!latestEvent) return; + + await mx.sendReadReceipt( + latestEvent, + privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read, + ); +}; diff --git a/src/app/hooks/useThreadSummary.ts b/src/app/hooks/useThreadSummary.ts new file mode 100644 index 000000000..931e2255a --- /dev/null +++ b/src/app/hooks/useThreadSummary.ts @@ -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(() => + getThreadSummary(rootEvent), + ); + const [unread, setUnread] = useState(() => + 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 }; +}; diff --git a/src/app/state/room/thread.test.ts b/src/app/state/room/thread.test.ts new file mode 100644 index 000000000..9a1ac7bb3 --- /dev/null +++ b/src/app/state/room/thread.test.ts @@ -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); +}); diff --git a/src/app/state/room/thread.ts b/src/app/state/room/thread.ts new file mode 100644 index 000000000..418bc8355 --- /dev/null +++ b/src/app/state/room/thread.ts @@ -0,0 +1,22 @@ +import { atom, PrimitiveAtom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; + +const createActiveThreadIdAtom = () => atom(null); +export type TActiveThreadIdAtom = PrimitiveAtom; + +/** + * 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(() => + 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}`;