This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

superconfig / configs / services / matrix / cinny-threads-ui.patch
98 kB 2628 lines
1diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx 2index bd848dd..1acac04 100644 3--- a/src/app/components/editor/Editor.tsx 4+++ b/src/app/components/editor/Editor.tsx 5@@ -24,13 +24,6 @@ import { CustomElement } from './slate'; 6 import * as css from './Editor.css'; 7 import { toggleKeyboardShortcut } from './keyboard'; 8 9-const initialValue: CustomElement[] = [ 10- { 11- type: BlockType.Paragraph, 12- children: [{ text: '' }], 13- }, 14-]; 15- 16 const withInline = (editor: Editor): Editor => { 17 const { isInline } = editor; 18 19@@ -90,6 +83,10 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>( 20 }, 21 ref 22 ) => { 23+ const [slateInitialValue] = useState<CustomElement[]>(() => [ 24+ { type: BlockType.Paragraph, children: [{ text: '' }] }, 25+ ]); 26+ 27 const renderElement = useCallback( 28 (props: RenderElementProps) => <RenderElement {...props} />, 29 [] 30@@ -120,7 +117,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>( 31 32 return ( 33 <div className={css.Editor} ref={ref}> 34- <Slate editor={editor} initialValue={initialValue} onChange={onChange}> 35+ <Slate editor={editor} initialValue={slateInitialValue} onChange={onChange}> 36 {top} 37 <Box alignItems="Start"> 38 {before && ( 39diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx 40index ae42da8..a09b7dd 100644 41--- a/src/app/features/room/Room.tsx 42+++ b/src/app/features/room/Room.tsx 43@@ -1,10 +1,12 @@ 44-import React, { useCallback } from 'react'; 45+import React, { useCallback, useEffect } from 'react'; 46 import { Box, Line } from 'folds'; 47 import { useParams } from 'react-router-dom'; 48 import { isKeyHotkey } from 'is-hotkey'; 49-import { useAtomValue } from 'jotai'; 50+import { useAtom, useAtomValue } from 'jotai'; 51 import { RoomView } from './RoomView'; 52 import { MembersDrawer } from './MembersDrawer'; 53+import { ThreadBrowser } from './ThreadBrowser'; 54+import { ThreadDrawer } from './ThreadDrawer'; 55 import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; 56 import { useSetting } from '../../state/hooks/settings'; 57 import { settingsAtom } from '../../state/settings'; 58@@ -17,6 +19,8 @@ import { useRoomMembers } from '../../hooks/useRoomMembers'; 59 import { CallView } from '../call/CallView'; 60 import { RoomViewHeader } from './RoomViewHeader'; 61 import { callChatAtom } from '../../state/callEmbed'; 62+import { roomIdToOpenThreadAtomFamily } from '../../state/room/roomToOpenThread'; 63+import { roomIdToThreadBrowserAtomFamily } from '../../state/room/roomToThreadBrowser'; 64 import { CallChatView } from './CallChatView'; 65 import { useCallEmbed } from '../../hooks/useCallEmbed'; 66 import { useCallMembers, useCallSession } from '../../hooks/useCall'; 67@@ -36,6 +40,27 @@ export function Room() { 68 const powerLevels = usePowerLevels(room); 69 const members = useRoomMembers(mx, room.roomId); 70 const chat = useAtomValue(callChatAtom); 71+ const [openThreadId, setOpenThread] = useAtom(roomIdToOpenThreadAtomFamily(room.roomId)); 72+ const [threadBrowserOpen, setThreadBrowserOpen] = useAtom( 73+ roomIdToThreadBrowserAtomFamily(room.roomId) 74+ ); 75+ 76+ useEffect(() => { 77+ if (!eventId) return; 78+ 79+ const event = room.findEventById(eventId); 80+ const threadRootId = event?.threadRootId; 81+ if (!threadRootId) return; 82+ 83+ if (!room.getThread(threadRootId)) { 84+ const rootEvent = room.findEventById(threadRootId); 85+ if (rootEvent) { 86+ room.createThread(threadRootId, rootEvent, [], false); 87+ } 88+ } 89+ 90+ setOpenThread(threadRootId); 91+ }, [eventId, room, setOpenThread]); 92 93 useKeyDown( 94 window, 95@@ -53,7 +78,7 @@ export function Room() { 96 97 return ( 98 <PowerLevelsContextProvider value={powerLevels}> 99- <Box grow="Yes"> 100+ <Box grow="Yes" style={{ position: 'relative' }}> 101 {callView && (screenSize === ScreenSize.Desktop || !chat) && ( 102 <Box grow="Yes" direction="Column"> 103 <RoomViewHeader callView /> 104@@ -79,12 +104,62 @@ export function Room() { 105 <CallChatView /> 106 </> 107 )} 108- {!callView && screenSize === ScreenSize.Desktop && isDrawer && ( 109+ {screenSize === ScreenSize.Desktop && openThreadId && ( 110+ <> 111+ <Line variant="Background" direction="Vertical" size="300" /> 112+ <ThreadDrawer 113+ key={`thread-${room.roomId}-${openThreadId}`} 114+ room={room} 115+ threadRootId={openThreadId} 116+ onClose={() => setOpenThread(undefined)} 117+ /> 118+ </> 119+ )} 120+ {screenSize === ScreenSize.Desktop && !openThreadId && threadBrowserOpen && ( 121 <> 122 <Line variant="Background" direction="Vertical" size="300" /> 123- <MembersDrawer key={room.roomId} room={room} members={members} /> 124+ <ThreadBrowser 125+ key={`thread-browser-${room.roomId}`} 126+ room={room} 127+ onOpenThread={(threadId) => { 128+ setOpenThread(threadId); 129+ setThreadBrowserOpen(false); 130+ }} 131+ onClose={() => setThreadBrowserOpen(false)} 132+ /> 133 </> 134 )} 135+ {screenSize === ScreenSize.Desktop && 136+ !openThreadId && 137+ !threadBrowserOpen && 138+ !callView && 139+ isDrawer && ( 140+ <> 141+ <Line variant="Background" direction="Vertical" size="300" /> 142+ <MembersDrawer key={room.roomId} room={room} members={members} /> 143+ </> 144+ )} 145+ {screenSize !== ScreenSize.Desktop && openThreadId && ( 146+ <ThreadDrawer 147+ key={`thread-${room.roomId}-${openThreadId}`} 148+ room={room} 149+ threadRootId={openThreadId} 150+ onClose={() => setOpenThread(undefined)} 151+ overlay 152+ /> 153+ )} 154+ {screenSize !== ScreenSize.Desktop && threadBrowserOpen && !openThreadId && ( 155+ <ThreadBrowser 156+ key={`thread-browser-${room.roomId}`} 157+ room={room} 158+ onOpenThread={(threadId) => { 159+ setOpenThread(threadId); 160+ setThreadBrowserOpen(false); 161+ }} 162+ onClose={() => setThreadBrowserOpen(false)} 163+ overlay 164+ /> 165+ )} 166 </Box> 167 </PowerLevelsContextProvider> 168 ); 169diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx 170index f88ccf9..75417c4 100644 171--- a/src/app/features/room/RoomInput.tsx 172+++ b/src/app/features/room/RoomInput.tsx 173@@ -68,6 +68,7 @@ import { useFilePicker } from '../../hooks/useFilePicker'; 174 import { useFilePasteHandler } from '../../hooks/useFilePasteHandler'; 175 import { useFileDropZone } from '../../hooks/useFileDrop'; 176 import { 177+ IReplyDraft, 178 TUploadItem, 179 TUploadMetadata, 180 roomIdToMsgDraftAtomFamily, 181@@ -118,14 +119,40 @@ import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; 182 import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; 183 import { useComposingCheck } from '../../hooks/useComposingCheck'; 184 185+const getReplyContent = (replyDraft: IReplyDraft): IContent['m.relates_to'] => { 186+ const relation: Record<string, unknown> = {}; 187+ 188+ if (replyDraft.relation?.rel_type === RelationType.Thread) { 189+ relation.event_id = replyDraft.relation.event_id; 190+ relation.rel_type = RelationType.Thread; 191+ 192+ if (replyDraft.eventId !== replyDraft.relation.event_id) { 193+ relation['m.in_reply_to'] = { 194+ event_id: replyDraft.eventId, 195+ }; 196+ relation.is_falling_back = false; 197+ } else { 198+ relation.is_falling_back = true; 199+ } 200+ } else { 201+ relation['m.in_reply_to'] = { 202+ event_id: replyDraft.eventId, 203+ }; 204+ } 205+ 206+ return relation; 207+}; 208+ 209 interface RoomInputProps { 210 editor: Editor; 211 fileDropContainerRef: RefObject<HTMLElement>; 212 roomId: string; 213 room: Room; 214+ threadRootId?: string; 215 } 216 export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>( 217- ({ editor, fileDropContainerRef, roomId, room }, ref) => { 218+ ({ editor, fileDropContainerRef, roomId, room, threadRootId }, ref) => { 219+ const draftKey = threadRootId ?? roomId; 220 const mx = useMatrixClient(); 221 const useAuthentication = useMediaAuthentication(); 222 const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); 223@@ -139,8 +166,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>( 224 const powerLevels = usePowerLevelsContext(); 225 const creators = useRoomCreators(room); 226 227- const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId)); 228- const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId)); 229+ const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(draftKey)); 230+ const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(draftKey)); 231 const replyUserID = replyDraft?.userId; 232 233 const powerLevelTags = usePowerLevelTags(room, powerLevels); 234@@ -161,7 +188,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>( 235 legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor; 236 237 const [uploadBoard, setUploadBoard] = useState(true); 238- const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId)); 239+ const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(draftKey)); 240 const uploadFamilyObserverAtom = createUploadFamilyObserverAtom( 241 roomUploadAtomFamily, 242 selectedFiles.map((f) => f.file) 243@@ -225,22 +252,42 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>( 244 useCallback((width) => setHideStickerBtn(width < 500), []) 245 ); 246 247+ useEffect(() => { 248+ if (!threadRootId) return; 249+ 250+ setReplyDraft((prev) => { 251+ if ( 252+ prev?.relation?.rel_type === RelationType.Thread && 253+ prev.relation.event_id === threadRootId 254+ ) { 255+ return prev; 256+ } 257+ 258+ return { 259+ userId: mx.getUserId() ?? '', 260+ eventId: threadRootId, 261+ body: '', 262+ relation: { rel_type: RelationType.Thread, event_id: threadRootId }, 263+ }; 264+ }); 265+ }, [mx, setReplyDraft, threadRootId]); 266+ 267 useEffect(() => { 268 Transforms.insertFragment(editor, msgDraft); 269 }, [editor, msgDraft]); 270 271 useEffect( 272 () => () => { 273- if (!isEmptyEditor(editor)) { 274+ if (isEmptyEditor(editor)) { 275+ setMsgDraft([]); 276+ } else { 277 const parsedDraft = JSON.parse(JSON.stringify(editor.children)); 278 setMsgDraft(parsedDraft); 279- } else { 280- setMsgDraft([]); 281 } 282 resetEditor(editor); 283 resetEditorHistory(editor); 284 }, 285- [roomId, editor, setMsgDraft] 286+ [draftKey, editor, setMsgDraft] 287 ); 288 289 const handleFileMetadata = useCallback( 290@@ -276,6 +323,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>( 291 }; 292 293 const handleSendUpload = async (uploads: UploadSuccess[]) => { 294+ const plainText = toPlainText(editor.children, isMarkdown).trim(); 295 const contentsPromises = uploads.map(async (upload) => { 296 const fileItem = selectedFiles.find((f) => f.file === upload.file); 297 if (!fileItem) throw new Error('Broken upload'); 298@@ -293,7 +341,28 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>( 299 }); 300 handleCancelUpload(uploads); 301 const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); 302- contents.forEach((content) => mx.sendMessage(roomId, content as any)); 303+ 304+ const relateTo = 305+ contents.length > 0 && plainText.length === 0 && replyDraft 306+ ? getReplyContent(replyDraft) 307+ : undefined; 308+ 309+ contents 310+ .map((content) => (relateTo ? { ...content, 'm.relates_to': relateTo } : content)) 311+ .forEach((content) => mx.sendMessage(roomId, threadRootId ?? null, content as any)); 312+ 313+ if (replyDraft) { 314+ if (threadRootId) { 315+ setReplyDraft({ 316+ userId: mx.getUserId() ?? '', 317+ eventId: threadRootId, 318+ body: '', 319+ relation: { rel_type: RelationType.Thread, event_id: threadRootId }, 320+ }); 321+ } else { 322+ setReplyDraft(undefined); 323+ } 324+ } 325 }; 326 327 const submit = useCallback(() => { 328@@ -361,23 +430,33 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>( 329 content.formatted_body = formattedBody; 330 } 331 if (replyDraft) { 332- content['m.relates_to'] = { 333- 'm.in_reply_to': { 334- event_id: replyDraft.eventId, 335- }, 336- }; 337- if (replyDraft.relation?.rel_type === RelationType.Thread) { 338- content['m.relates_to'].event_id = replyDraft.relation.event_id; 339- content['m.relates_to'].rel_type = RelationType.Thread; 340- content['m.relates_to'].is_falling_back = false; 341- } 342+ content['m.relates_to'] = getReplyContent(replyDraft); 343 } 344- mx.sendMessage(roomId, content as any); 345+ mx.sendMessage(roomId, threadRootId ?? null, content as any); 346 resetEditor(editor); 347 resetEditorHistory(editor); 348- setReplyDraft(undefined); 349+ if (threadRootId) { 350+ setReplyDraft({ 351+ userId: mx.getUserId() ?? '', 352+ eventId: threadRootId, 353+ body: '', 354+ relation: { rel_type: RelationType.Thread, event_id: threadRootId }, 355+ }); 356+ } else { 357+ setReplyDraft(undefined); 358+ } 359 sendTypingStatus(false); 360- }, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]); 361+ }, [ 362+ mx, 363+ roomId, 364+ threadRootId, 365+ editor, 366+ replyDraft, 367+ sendTypingStatus, 368+ setReplyDraft, 369+ isMarkdown, 370+ commands, 371+ ]); 372 373 const handleKeyDown: KeyboardEventHandler = useCallback( 374 (evt) => { 375@@ -443,7 +522,21 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>( 376 body: label, 377 url: mxc, 378 info, 379+ ...(replyDraft ? { 'm.relates_to': getReplyContent(replyDraft) } : {}), 380 }); 381+ 382+ if (replyDraft) { 383+ if (threadRootId) { 384+ setReplyDraft({ 385+ userId: mx.getUserId() ?? '', 386+ eventId: threadRootId, 387+ body: '', 388+ relation: { rel_type: RelationType.Thread, event_id: threadRootId }, 389+ }); 390+ } else { 391+ setReplyDraft(undefined); 392+ } 393+ } 394 }; 395 396 return ( 397@@ -544,7 +637,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>( 398 onKeyUp={handleKeyUp} 399 onPaste={handlePaste} 400 top={ 401- replyDraft && ( 402+ replyDraft && 403+ (!threadRootId || replyDraft.eventId !== threadRootId) && ( 404 <div> 405 <Box 406 alignItems="Center" 407@@ -552,7 +646,19 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>( 408 style={{ padding: `${config.space.S200} ${config.space.S300} 0` }} 409 > 410 <IconButton 411- onClick={() => setReplyDraft(undefined)} 412+ onClick={() => { 413+ if (threadRootId) { 414+ setReplyDraft({ 415+ userId: mx.getUserId() ?? '', 416+ eventId: threadRootId, 417+ body: '', 418+ relation: { rel_type: RelationType.Thread, event_id: threadRootId }, 419+ }); 420+ return; 421+ } 422+ 423+ setReplyDraft(undefined); 424+ }} 425 variant="SurfaceVariant" 426 size="300" 427 radii="300" 428diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx 429index c533528..6975088 100644 430--- a/src/app/features/room/RoomTimeline.tsx 431+++ b/src/app/features/room/RoomTimeline.tsx 432@@ -19,10 +19,12 @@ import { 433 IContent, 434 MatrixClient, 435 MatrixEvent, 436+ RelationType, 437 Room, 438 RoomEvent, 439 RoomEventHandlerMap, 440 } from 'matrix-js-sdk'; 441+import { ThreadEvent } from 'matrix-js-sdk/lib/models/thread'; 442 import { HTMLReactParserOptions } from 'html-react-parser'; 443 import classNames from 'classnames'; 444 import { ReactEditor } from 'slate-react'; 445@@ -31,6 +33,7 @@ import to from 'await-to-js'; 446 import { useAtomValue, useSetAtom } from 'jotai'; 447 import { 448 Badge, 449+ Avatar, 450 Box, 451 Chip, 452 ContainerColor, 453@@ -47,7 +50,7 @@ import { 454 import { isKeyHotkey } from 'is-hotkey'; 455 import { Opts as LinkifyOpts } from 'linkifyjs'; 456 import { useTranslation } from 'react-i18next'; 457-import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix'; 458+import { getMxIdLocalPart, mxcUrlToHttp, toggleReaction } from '../../utils/matrix'; 459 import { useMatrixClient } from '../../hooks/useMatrixClient'; 460 import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator'; 461 import { useAlive } from '../../hooks/useAlive'; 462@@ -78,8 +81,8 @@ import { 463 getEditedEvent, 464 getEventReactions, 465 getLatestEditableEvt, 466+ getMemberAvatarMxc, 467 getMemberDisplayName, 468- getReactionContent, 469 isMembershipChanged, 470 reactionOrEditEvent, 471 } from '../../utils/room'; 472@@ -101,6 +104,7 @@ import * as css from './RoomTimeline.css'; 473 import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time'; 474 import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor'; 475 import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts'; 476+import { roomIdToOpenThreadAtomFamily } from '../../state/room/roomToOpenThread'; 477 import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; 478 import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room'; 479 import { useKeyDown } from '../../hooks/useKeyDown'; 480@@ -126,6 +130,7 @@ import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/u 481 import { useTheme } from '../../hooks/useTheme'; 482 import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; 483 import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; 484+import { UserAvatar } from '../../components/user-avatar'; 485 486 const TimelineFloat = as<'div', css.TimelineFloatVariants>( 487 ({ position, className, ...props }, ref) => ( 488@@ -387,6 +392,149 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) 489 }, [room, onArrive]); 490 }; 491 492+const useThreadUpdate = (room: Room, onUpdate: () => void) => { 493+ useEffect(() => { 494+ room.on(ThreadEvent.New, onUpdate as any); 495+ room.on(ThreadEvent.Update, onUpdate as any); 496+ room.on(ThreadEvent.NewReply, onUpdate as any); 497+ 498+ return () => { 499+ room.removeListener(ThreadEvent.New, onUpdate as any); 500+ room.removeListener(ThreadEvent.Update, onUpdate as any); 501+ room.removeListener(ThreadEvent.NewReply, onUpdate as any); 502+ }; 503+ }, [room, onUpdate]); 504+}; 505+ 506+const buildThreadReplyCountMap = (room: Room): Map<string, number> => { 507+ const counts = new Map<string, number>(); 508+ room 509+ .getUnfilteredTimelineSet() 510+ .getLiveTimeline() 511+ .getEvents() 512+ .forEach((ev) => { 513+ const rootId = ev.threadRootId; 514+ if (rootId && ev.getId() !== rootId && !reactionOrEditEvent(ev)) { 515+ counts.set(rootId, (counts.get(rootId) || 0) + 1); 516+ } 517+ }); 518+ return counts; 519+}; 520+ 521+const getThreadReplyCount = ( 522+ room: Room, 523+ eventId: string, 524+ fallbackCounts?: Map<string, number> 525+): number => { 526+ const thread = room.getThread(eventId); 527+ if (thread) return thread.length; 528+ if (fallbackCounts) return fallbackCounts.get(eventId) ?? 0; 529+ return buildThreadReplyCountMap(room).get(eventId) ?? 0; 530+}; 531+ 532+function ThreadReplyChip({ 533+ room, 534+ mEventId, 535+ openThreadId, 536+ onToggle, 537+}: { 538+ room: Room; 539+ mEventId: string; 540+ openThreadId: string | undefined; 541+ onToggle: () => void; 542+}) { 543+ const mx = useMatrixClient(); 544+ const useAuthentication = useMediaAuthentication(); 545+ 546+ const thread = room.getThread(mEventId); 547+ const replyEvents = thread 548+ ? thread.events.filter((ev) => ev.getId() !== mEventId && !reactionOrEditEvent(ev)) 549+ : room 550+ .getUnfilteredTimelineSet() 551+ .getLiveTimeline() 552+ .getEvents() 553+ .filter( 554+ (ev) => 555+ ev.threadRootId === mEventId && ev.getId() !== mEventId && !reactionOrEditEvent(ev) 556+ ); 557+ 558+ const replyCount = thread ? thread.length : replyEvents.length; 559+ if (replyCount === 0) return null; 560+ 561+ const uniqueSenders: string[] = []; 562+ const seen = new Set<string>(); 563+ replyEvents.forEach((ev) => { 564+ const senderId = ev.getSender(); 565+ if (senderId && !seen.has(senderId)) { 566+ seen.add(senderId); 567+ uniqueSenders.push(senderId); 568+ } 569+ }); 570+ 571+ const latestReply = replyEvents[replyEvents.length - 1]; 572+ const latestSenderId = latestReply?.getSender() ?? ''; 573+ const latestSenderName = 574+ getMemberDisplayName(room, latestSenderId) ?? 575+ getMxIdLocalPart(latestSenderId) ?? 576+ latestSenderId; 577+ const latestBody = (latestReply?.getContent()?.body as string | undefined) ?? ''; 578+ const isOpen = openThreadId === mEventId; 579+ 580+ return ( 581+ <Chip 582+ style={{ marginTop: config.space.S200 }} 583+ size="400" 584+ variant={isOpen ? 'Primary' : 'SurfaceVariant'} 585+ radii="300" 586+ before={ 587+ <Box alignItems="Center" style={{ gap: 0 }}> 588+ {uniqueSenders.slice(0, 3).map((senderId, index) => { 589+ const avatarMxc = getMemberAvatarMxc(room, senderId); 590+ const avatarUrl = avatarMxc 591+ ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 20, 20, 'crop') ?? undefined 592+ : undefined; 593+ const displayName = 594+ getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; 595+ 596+ return ( 597+ <Avatar key={senderId} size="200" style={{ marginLeft: index > 0 ? '-4px' : 0 }}> 598+ <UserAvatar 599+ userId={senderId} 600+ src={avatarUrl} 601+ alt={displayName} 602+ renderFallback={() => ( 603+ <span style={{ fontSize: '10px', fontWeight: 'bold', lineHeight: 1 }}> 604+ {displayName[0]?.toUpperCase() ?? '?'} 605+ </span> 606+ )} 607+ /> 608+ </Avatar> 609+ ); 610+ })} 611+ </Box> 612+ } 613+ onClick={onToggle} 614+ > 615+ <Text size="T300" style={{ whiteSpace: 'nowrap' }}> 616+ {replyCount}&nbsp;{replyCount === 1 ? 'reply' : 'replies'} 617+ </Text> 618+ {latestBody && ( 619+ <Text 620+ size="T300" 621+ style={{ 622+ opacity: 0.7, 623+ overflow: 'hidden', 624+ textOverflow: 'ellipsis', 625+ whiteSpace: 'nowrap', 626+ }} 627+ > 628+ &nbsp;·&nbsp;{latestSenderName}:&nbsp;{latestBody.slice(0, 60)} 629+ </Text> 630+ )} 631+ </Chip> 632+ ); 633+} 634+ 635 const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => { 636 useEffect(() => { 637 const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => { 638@@ -454,6 +602,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli 639 const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]); 640 641 const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId)); 642+ const openThreadId = useAtomValue(roomIdToOpenThreadAtomFamily(room.roomId)); 643+ const setOpenThread = useSetAtom(roomIdToOpenThreadAtomFamily(room.roomId)); 644 const powerLevels = usePowerLevelsContext(); 645 const creators = useRoomCreators(room); 646 647@@ -607,6 +757,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli 648 room, 649 useCallback( 650 (mEvt: MatrixEvent) => { 651+ // Thread reply events are re-emitted from the Thread to the Room and 652+ // must not increment the main timeline range or scroll it. 653+ // useThreadUpdate handles the chip re-render for these events. 654+ // Only skip actual thread replies (rel_type === m.thread), 655+ // not thread roots or plain replies to thread roots. 656+ if (mEvt.isRelation(RelationType.Thread)) return; 657+ 658 // if user is at bottom of timeline 659 // keep paginating timeline and conditionally mark as read 660 // otherwise we update timeline without paginating 661@@ -644,6 +801,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli 662 ) 663 ); 664 665+ useThreadUpdate( 666+ room, 667+ useCallback(() => { 668+ setTimeline((currentTimeline) => ({ ...currentTimeline })); 669+ }, []) 670+ ); 671+ 672 const handleOpenEvent = useCallback( 673 async ( 674 evtId: string, 675@@ -958,6 +1122,16 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli 676 console.warn('Button should have "data-event-id" attribute!'); 677 return; 678 } 679+ 680+ if (startThread) { 681+ const rootEvent = room.findEventById(replyId); 682+ if (rootEvent && !room.getThread(replyId)) { 683+ room.createThread(replyId, rootEvent, [], false); 684+ } 685+ setOpenThread(openThreadId === replyId ? undefined : replyId); 686+ return; 687+ } 688+ 689 const replyEvt = room.findEventById(replyId); 690 if (!replyEvt) return; 691 const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet()); 692@@ -978,30 +1152,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli 693 setTimeout(() => ReactEditor.focus(editor), 100); 694 } 695 }, 696- [room, setReplyDraft, editor] 697+ [room, setReplyDraft, editor, setOpenThread, openThreadId] 698 ); 699 700 const handleReactionToggle = useCallback( 701- (targetEventId: string, key: string, shortcode?: string) => { 702- const relations = getEventReactions(room.getUnfilteredTimelineSet(), targetEventId); 703- const allReactions = relations?.getSortedAnnotationsByKey() ?? []; 704- const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? []; 705- const reactions = reactionsSet ? Array.from(reactionsSet) : []; 706- const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!)); 707- 708- if (myReaction && !!myReaction?.isRelation()) { 709- mx.redactEvent(room.roomId, myReaction.getId()!); 710- return; 711- } 712- const rShortcode = 713- shortcode || 714- (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined); 715- mx.sendEvent( 716- room.roomId, 717- MessageEvent.Reaction as any, 718- getReactionContent(targetEventId, key, rShortcode) 719- ); 720- }, 721+ (targetEventId: string, key: string, shortcode?: string) => 722+ toggleReaction(mx, room, targetEventId, key, shortcode), 723 [mx, room] 724 ); 725 const handleEdit = useCallback( 726@@ -1017,6 +1173,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli 727 ); 728 const { t } = useTranslation(); 729 730+ // Build a map of thread reply counts once per render to avoid O(n²) timeline scans. 731+ const threadReplyCountMap = buildThreadReplyCountMap(room); 732+ 733 const renderMatrixEvent = useMatrixEventRenderer< 734 [string, MatrixEvent, number, EventTimelineSet, boolean] 735 >( 736@@ -1027,6 +1186,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli 737 const hasReactions = reactions && reactions.length > 0; 738 const { replyEventId, threadRootId } = mEvent; 739 const highlighted = focusItem?.index === item && focusItem.highlight; 740+ const threadReplyCount = getThreadReplyCount(room, mEventId, threadReplyCountMap); 741 742 const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); 743 const getContent = (() => 744@@ -1072,19 +1232,35 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli 745 /> 746 ) 747 } 748- reactions={ 749- reactionRelations && ( 750- <Reactions 751- style={{ marginTop: config.space.S200 }} 752- room={room} 753- relations={reactionRelations} 754- mEventId={mEventId} 755- canSendReaction={canSendReaction} 756- onReactionToggle={handleReactionToggle} 757- /> 758- ) 759- } 760+ reactions={(() => { 761+ const threadChip = 762+ threadReplyCount > 0 ? ( 763+ <ThreadReplyChip 764+ room={room} 765+ mEventId={mEventId} 766+ openThreadId={openThreadId} 767+ onToggle={() => setOpenThread(openThreadId === mEventId ? undefined : mEventId)} 768+ /> 769+ ) : null; 770+ if (!reactionRelations && !threadChip) return undefined; 771+ return ( 772+ <> 773+ {reactionRelations && ( 774+ <Reactions 775+ style={{ marginTop: config.space.S200 }} 776+ room={room} 777+ relations={reactionRelations} 778+ mEventId={mEventId} 779+ canSendReaction={canSendReaction} 780+ onReactionToggle={handleReactionToggle} 781+ /> 782+ )} 783+ {threadChip} 784+ </> 785+ ); 786+ })()} 787 hideReadReceipts={hideActivity} 788+ hideThreadButton={threadReplyCount > 0} 789 showDeveloperTools={showDeveloperTools} 790 memberPowerTag={getMemberPowerTag(senderId)} 791 accessibleTagColors={accessiblePowerTagColors} 792@@ -1117,6 +1293,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli 793 const hasReactions = reactions && reactions.length > 0; 794 const { replyEventId, threadRootId } = mEvent; 795 const highlighted = focusItem?.index === item && focusItem.highlight; 796+ const threadReplyCount = getThreadReplyCount(room, mEventId, threadReplyCountMap); 797 798 return ( 799 <Message 800@@ -1154,19 +1331,35 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli 801 /> 802 ) 803 } 804- reactions={ 805- reactionRelations && ( 806- <Reactions 807- style={{ marginTop: config.space.S200 }} 808- room={room} 809- relations={reactionRelations} 810- mEventId={mEventId} 811- canSendReaction={canSendReaction} 812- onReactionToggle={handleReactionToggle} 813- /> 814- ) 815- } 816+ reactions={(() => { 817+ const threadChip = 818+ threadReplyCount > 0 ? ( 819+ <ThreadReplyChip 820+ room={room} 821+ mEventId={mEventId} 822+ openThreadId={openThreadId} 823+ onToggle={() => setOpenThread(openThreadId === mEventId ? undefined : mEventId)} 824+ /> 825+ ) : null; 826+ if (!reactionRelations && !threadChip) return undefined; 827+ return ( 828+ <> 829+ {reactionRelations && ( 830+ <Reactions 831+ style={{ marginTop: config.space.S200 }} 832+ room={room} 833+ relations={reactionRelations} 834+ mEventId={mEventId} 835+ canSendReaction={canSendReaction} 836+ onReactionToggle={handleReactionToggle} 837+ /> 838+ )} 839+ {threadChip} 840+ </> 841+ ); 842+ })()} 843 hideReadReceipts={hideActivity} 844+ hideThreadButton={threadReplyCount > 0} 845 showDeveloperTools={showDeveloperTools} 846 memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')} 847 accessibleTagColors={accessiblePowerTagColors} 848@@ -1236,6 +1429,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli 849 const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); 850 const hasReactions = reactions && reactions.length > 0; 851 const highlighted = focusItem?.index === item && focusItem.highlight; 852+ const threadReplyCount = getThreadReplyCount(room, mEventId, threadReplyCountMap); 853 854 return ( 855 <Message 856@@ -1257,19 +1451,35 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli 857 onUsernameClick={handleUsernameClick} 858 onReplyClick={handleReplyClick} 859 onReactionToggle={handleReactionToggle} 860- reactions={ 861- reactionRelations && ( 862- <Reactions 863- style={{ marginTop: config.space.S200 }} 864- room={room} 865- relations={reactionRelations} 866- mEventId={mEventId} 867- canSendReaction={canSendReaction} 868- onReactionToggle={handleReactionToggle} 869- /> 870- ) 871- } 872+ reactions={(() => { 873+ const threadChip = 874+ threadReplyCount > 0 ? ( 875+ <ThreadReplyChip 876+ room={room} 877+ mEventId={mEventId} 878+ openThreadId={openThreadId} 879+ onToggle={() => setOpenThread(openThreadId === mEventId ? undefined : mEventId)} 880+ /> 881+ ) : null; 882+ if (!reactionRelations && !threadChip) return undefined; 883+ return ( 884+ <> 885+ {reactionRelations && ( 886+ <Reactions 887+ style={{ marginTop: config.space.S200 }} 888+ room={room} 889+ relations={reactionRelations} 890+ mEventId={mEventId} 891+ canSendReaction={canSendReaction} 892+ onReactionToggle={handleReactionToggle} 893+ /> 894+ )} 895+ {threadChip} 896+ </> 897+ ); 898+ })()} 899 hideReadReceipts={hideActivity} 900+ hideThreadButton={threadReplyCount > 0} 901 showDeveloperTools={showDeveloperTools} 902 memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')} 903 accessibleTagColors={accessiblePowerTagColors} 904@@ -1639,6 +1849,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli 905 if (mEvent.isRedacted() && !showHiddenEvents) { 906 return null; 907 } 908+ // Only hide actual thread replies (rel_type === m.thread) from the main timeline. 909+ // Plain replies (m.in_reply_to) to thread roots must remain visible. 910+ if (mEvent.isRelation(RelationType.Thread) && mEvent.threadRootId !== mEventId) { 911+ return null; 912+ } 913 914 if (!newDivider && readUptoEventIdRef.current) { 915 newDivider = prevEvent?.getId() === readUptoEventIdRef.current; 916diff --git a/src/app/features/room/RoomViewFollowing.tsx b/src/app/features/room/RoomViewFollowing.tsx 917index 5a96e6a..d36dbf1 100644 918--- a/src/app/features/room/RoomViewFollowing.tsx 919+++ b/src/app/features/room/RoomViewFollowing.tsx 920@@ -30,21 +30,23 @@ export function RoomViewFollowingPlaceholder() { 921 922 export type RoomViewFollowingProps = { 923 room: Room; 924+ threadEventId?: string; 925+ participantIds?: Set<string>; 926 }; 927 export const RoomViewFollowing = as<'div', RoomViewFollowingProps>( 928- ({ className, room, ...props }, ref) => { 929+ ({ className, room, threadEventId, participantIds, ...props }, ref) => { 930 const mx = useMatrixClient(); 931 const [open, setOpen] = useState(false); 932 const latestEvent = useRoomLatestRenderedEvent(room); 933- const latestEventReaders = useRoomEventReaders(room, latestEvent?.getId()); 934+ const eventId = threadEventId ?? latestEvent?.getId(); 935+ const latestEventReaders = useRoomEventReaders(room, eventId); 936 const names = latestEventReaders 937 .filter((readerId) => readerId !== mx.getUserId()) 938+ .filter((readerId) => !participantIds || participantIds.has(readerId)) 939 .map( 940 (readerId) => getMemberDisplayName(room, readerId) ?? getMxIdLocalPart(readerId) ?? readerId 941 ); 942 943- const eventId = latestEvent?.getId(); 944- 945 return ( 946 <> 947 {eventId && ( 948diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx 949index 8089b5e..754d44a 100644 950--- a/src/app/features/room/RoomViewHeader.tsx 951+++ b/src/app/features/room/RoomViewHeader.tsx 952@@ -1,5 +1,6 @@ 953-import React, { MouseEventHandler, forwardRef, useState } from 'react'; 954+import React, { MouseEventHandler, forwardRef, useEffect, useState } from 'react'; 955 import FocusTrap from 'focus-trap-react'; 956+import { useAtom } from 'jotai'; 957 import { 958 Box, 959 Avatar, 960@@ -24,7 +25,8 @@ import { 961 Button, 962 } from 'folds'; 963 import { useNavigate } from 'react-router-dom'; 964-import { Room } from 'matrix-js-sdk'; 965+import { Direction, MatrixEvent, NotificationCountType, Room, RoomEvent } from 'matrix-js-sdk'; 966+import { ThreadEvent } from 'matrix-js-sdk/lib/models/thread'; 967 import { useStateEvent } from '../../hooks/useStateEvent'; 968 import { PageHeader } from '../../components/page'; 969 import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; 970@@ -72,6 +74,8 @@ import { RoomSettingsPage } from '../../state/roomSettings'; 971 import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed'; 972 import { useLivekitSupport } from '../../hooks/useLivekitSupport'; 973 import { webRTCSupported } from '../../utils/rtc'; 974+import { roomIdToOpenThreadAtomFamily } from '../../state/room/roomToOpenThread'; 975+import { roomIdToThreadBrowserAtomFamily } from '../../state/room/roomToThreadBrowser'; 976 977 type RoomMenuProps = { 978 room: Room; 979@@ -404,6 +408,10 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { 980 const [menuAnchor, setMenuAnchor] = useState<RectCords>(); 981 const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>(); 982 const direct = useIsDirectRoom(); 983+ const [threadBrowserOpen, setThreadBrowserOpen] = useAtom( 984+ roomIdToThreadBrowserAtomFamily(room.roomId) 985+ ); 986+ const [openThreadId, setOpenThread] = useAtom(roomIdToOpenThreadAtomFamily(room.roomId)); 987 988 const pinnedEvents = useRoomPinnedEvents(room); 989 const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption); 990@@ -416,6 +424,114 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { 991 : undefined; 992 993 const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); 994+ const [unreadThreadsCount, setUnreadThreadsCount] = useState(0); 995+ const [hasThreadHighlights, setHasThreadHighlights] = useState(false); 996+ 997+ useEffect(() => { 998+ const scanTimelineForThreads = (timeline: any) => { 999+ const events = timeline.getEvents(); 1000+ const threadRoots = new Set<string>(); 1001+ 1002+ events.forEach((event: MatrixEvent) => { 1003+ if (event.isThreadRoot) { 1004+ const rootId = event.getId(); 1005+ if (rootId && !room.getThread(rootId)) { 1006+ threadRoots.add(rootId); 1007+ } 1008+ } 1009+ 1010+ const { threadRootId } = event; 1011+ if (threadRootId && !room.getThread(threadRootId)) { 1012+ threadRoots.add(threadRootId); 1013+ } 1014+ }); 1015+ 1016+ threadRoots.forEach((rootId) => { 1017+ const rootEvent = room.findEventById(rootId); 1018+ if (rootEvent) { 1019+ room.createThread(rootId, rootEvent, [], false); 1020+ } 1021+ }); 1022+ }; 1023+ 1024+ const liveTimeline = room.getLiveTimeline(); 1025+ scanTimelineForThreads(liveTimeline); 1026+ 1027+ let backwardTimeline = liveTimeline.getNeighbouringTimeline(Direction.Backward); 1028+ while (backwardTimeline) { 1029+ scanTimelineForThreads(backwardTimeline); 1030+ backwardTimeline = backwardTimeline.getNeighbouringTimeline(Direction.Backward); 1031+ } 1032+ 1033+ // Initialize thread timeline sets then fetch threads from server 1034+ room 1035+ .createThreadsTimelineSets() 1036+ .then(() => room.fetchRoomThreads()) 1037+ .catch(() => { 1038+ // Silently ignore — server may not support threads 1039+ }); 1040+ 1041+ const handleTimeline = (event: MatrixEvent, eventRoom?: Room) => { 1042+ if (eventRoom?.roomId !== room.roomId) return; 1043+ 1044+ if (event.isThreadRoot) { 1045+ const rootId = event.getId(); 1046+ if (rootId && !room.getThread(rootId)) { 1047+ const rootEvent = room.findEventById(rootId); 1048+ if (rootEvent) { 1049+ room.createThread(rootId, rootEvent, [], false); 1050+ } 1051+ } 1052+ } 1053+ 1054+ const { threadRootId } = event; 1055+ if (threadRootId && !room.getThread(threadRootId)) { 1056+ const rootEvent = room.findEventById(threadRootId); 1057+ if (rootEvent) { 1058+ room.createThread(threadRootId, rootEvent, [], false); 1059+ } 1060+ } 1061+ }; 1062+ 1063+ mx.on(RoomEvent.Timeline, handleTimeline as any); 1064+ return () => { 1065+ mx.off(RoomEvent.Timeline, handleTimeline as any); 1066+ }; 1067+ }, [room, mx]); 1068+ 1069+ useEffect(() => { 1070+ const updateThreadCounts = () => { 1071+ let total = 0; 1072+ 1073+ room.getThreads().forEach((thread) => { 1074+ total += room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total); 1075+ }); 1076+ 1077+ setUnreadThreadsCount(total); 1078+ setHasThreadHighlights( 1079+ room.threadsAggregateNotificationType === NotificationCountType.Highlight 1080+ ); 1081+ }; 1082+ 1083+ updateThreadCounts(); 1084+ room.on(ThreadEvent.New, updateThreadCounts as any); 1085+ room.on(ThreadEvent.Update, updateThreadCounts as any); 1086+ room.on(ThreadEvent.NewReply, updateThreadCounts as any); 1087+ // Read receipts (including our own local echo when a thread is marked read) 1088+ // and notification-count recomputes do not emit ThreadEvent.*, so subscribe 1089+ // to them too — otherwise the badge keeps a stale count until unrelated 1090+ // thread activity happens to fire. 1091+ room.on(RoomEvent.Receipt, updateThreadCounts as any); 1092+ room.on(RoomEvent.UnreadNotifications, updateThreadCounts as any); 1093+ 1094+ return () => { 1095+ room.removeListener(ThreadEvent.New, updateThreadCounts as any); 1096+ room.removeListener(ThreadEvent.Update, updateThreadCounts as any); 1097+ room.removeListener(ThreadEvent.NewReply, updateThreadCounts as any); 1098+ room.removeListener(RoomEvent.Receipt, updateThreadCounts as any); 1099+ room.removeListener(RoomEvent.UnreadNotifications, updateThreadCounts as any); 1100+ }; 1101+ }, [room]); 1102 1103 const handleSearchClick = () => { 1104 const searchParams: _SearchPathSearchParams = { 1105@@ -442,6 +558,10 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { 1106 openSettings(room.roomId, parentSpace?.roomId, RoomSettingsPage.MembersPage); 1107 return; 1108 } 1109+ if (!peopleDrawer) { 1110+ setOpenThread(undefined); 1111+ setThreadBrowserOpen(false); 1112+ } 1113 setPeopleDrawer(!peopleDrawer); 1114 }; 1115 1116@@ -597,6 +717,56 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { 1117 {!room.isCallRoom() && livekitSupported && rtcSupported && hasCallPermission && ( 1118 <CallButton /> 1119 )} 1120+ <TooltipProvider 1121+ position="Bottom" 1122+ offset={4} 1123+ tooltip={ 1124+ <Tooltip> 1125+ <Text>Threads</Text> 1126+ </Tooltip> 1127+ } 1128+ > 1129+ {(triggerRef) => ( 1130+ <IconButton 1131+ fill="None" 1132+ ref={triggerRef} 1133+ onClick={() => { 1134+ if (openThreadId) { 1135+ setOpenThread(undefined); 1136+ setThreadBrowserOpen(true); 1137+ return; 1138+ } 1139+ 1140+ if (!threadBrowserOpen) { 1141+ setPeopleDrawer(false); 1142+ } 1143+ setThreadBrowserOpen(!threadBrowserOpen); 1144+ }} 1145+ aria-pressed={threadBrowserOpen || !!openThreadId} 1146+ style={{ position: 'relative' }} 1147+ > 1148+ {unreadThreadsCount > 0 && ( 1149+ <Badge 1150+ style={{ 1151+ position: 'absolute', 1152+ left: toRem(3), 1153+ top: toRem(3), 1154+ }} 1155+ variant={hasThreadHighlights ? 'Critical' : 'Secondary'} 1156+ size="400" 1157+ fill="Solid" 1158+ radii="Pill" 1159+ > 1160+ <Text as="span" size="L400"> 1161+ {unreadThreadsCount} 1162+ </Text> 1163+ </Badge> 1164+ )} 1165+ <Icon size="400" src={Icons.Thread} filled={threadBrowserOpen || !!openThreadId} /> 1166+ </IconButton> 1167+ )} 1168+ </TooltipProvider> 1169+ 1170 {screenSize === ScreenSize.Desktop && ( 1171 <TooltipProvider 1172 position="Bottom" 1173@@ -612,8 +782,17 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { 1174 } 1175 > 1176 {(triggerRef) => ( 1177- <IconButton fill="None" ref={triggerRef} onClick={handleMemberToggle}> 1178- <Icon size="400" src={Icons.User} /> 1179+ <IconButton 1180+ fill="None" 1181+ ref={triggerRef} 1182+ onClick={handleMemberToggle} 1183+ aria-pressed={peopleDrawer && !openThreadId && !threadBrowserOpen} 1184+ > 1185+ <Icon 1186+ size="400" 1187+ src={Icons.User} 1188+ filled={peopleDrawer && !openThreadId && !threadBrowserOpen} 1189+ /> 1190 </IconButton> 1191 )} 1192 </TooltipProvider> 1193diff --git a/src/app/features/room/ThreadBrowser.tsx b/src/app/features/room/ThreadBrowser.tsx 1194new file mode 100644 1195index 0000000..8deb54a 1196--- /dev/null 1197+++ b/src/app/features/room/ThreadBrowser.tsx 1198@@ -0,0 +1,372 @@ 1199+import React, { 1200+ ChangeEventHandler, 1201+ MouseEventHandler, 1202+ useCallback, 1203+ useEffect, 1204+ useMemo, 1205+ useRef, 1206+ useState, 1207+} from 'react'; 1208+import { 1209+ Box, 1210+ Header, 1211+ Icon, 1212+ IconButton, 1213+ Icons, 1214+ Input, 1215+ Scroll, 1216+ Text, 1217+ Avatar, 1218+ config, 1219+ Chip, 1220+} from 'folds'; 1221+import { MatrixEvent, Room } from 'matrix-js-sdk'; 1222+import { Thread, ThreadEvent } from 'matrix-js-sdk/lib/models/thread'; 1223+import { HTMLReactParserOptions } from 'html-react-parser'; 1224+import { Opts as LinkifyOpts } from 'linkifyjs'; 1225+import { useMatrixClient } from '../../hooks/useMatrixClient'; 1226+import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; 1227+import { useRoomNavigate } from '../../hooks/useRoomNavigate'; 1228+import { getMemberAvatarMxc, getMemberDisplayName, reactionOrEditEvent } from '../../utils/room'; 1229+import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; 1230+import { UserAvatar } from '../../components/user-avatar'; 1231+import { 1232+ AvatarBase, 1233+ ModernLayout, 1234+ RedactedContent, 1235+ Time, 1236+ Username, 1237+ UsernameBold, 1238+ Reply, 1239+} from '../../components/message'; 1240+import { RenderMessageContent } from '../../components/RenderMessageContent'; 1241+import { settingsAtom } from '../../state/settings'; 1242+import { useSetting } from '../../state/hooks/settings'; 1243+import { GetContentCallback } from '../../../types/matrix/room'; 1244+import { useMentionClickHandler } from '../../hooks/useMentionClickHandler'; 1245+import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; 1246+import { 1247+ factoryRenderLinkifyWithMention, 1248+ getReactCustomHtmlParser, 1249+ LINKIFY_OPTS, 1250+ makeMentionCustomProps, 1251+ renderMatrixMention, 1252+} from '../../plugins/react-custom-html-parser'; 1253+import { EncryptedContent } from './message'; 1254+import * as css from './ThreadDrawer.css'; 1255+ 1256+type ThreadPreviewProps = { 1257+ room: Room; 1258+ thread: Thread; 1259+ onClick: (threadId: string) => void; 1260+}; 1261+ 1262+function ThreadPreview({ room, thread, onClick }: ThreadPreviewProps) { 1263+ const mx = useMatrixClient(); 1264+ const useAuthentication = useMediaAuthentication(); 1265+ const { navigateRoom } = useRoomNavigate(); 1266+ const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); 1267+ const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); 1268+ const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); 1269+ const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); 1270+ const mentionClickHandler = useMentionClickHandler(room.roomId); 1271+ const spoilerClickHandler = useSpoilerClickHandler(); 1272+ 1273+ const linkifyOpts = useMemo<LinkifyOpts>( 1274+ () => ({ 1275+ ...LINKIFY_OPTS, 1276+ render: factoryRenderLinkifyWithMention((href: string) => 1277+ renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)) 1278+ ), 1279+ }), 1280+ [mx, room.roomId, mentionClickHandler] 1281+ ); 1282+ 1283+ const htmlReactParserOptions = useMemo<HTMLReactParserOptions>( 1284+ () => 1285+ getReactCustomHtmlParser(mx, room.roomId, { 1286+ linkifyOpts, 1287+ handleSpoilerClick: spoilerClickHandler, 1288+ handleMentionClick: mentionClickHandler, 1289+ useAuthentication, 1290+ }), 1291+ [mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication] 1292+ ); 1293+ 1294+ const handleJumpClick: MouseEventHandler = useCallback( 1295+ (evt) => { 1296+ evt.stopPropagation(); 1297+ onClick(thread.id); 1298+ navigateRoom(room.roomId, thread.id); 1299+ }, 1300+ [onClick, navigateRoom, room.roomId, thread.id] 1301+ ); 1302+ 1303+ const { rootEvent } = thread; 1304+ if (!rootEvent) return null; 1305+ 1306+ const senderId = rootEvent.getSender() ?? ''; 1307+ const displayName = 1308+ getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; 1309+ const senderAvatarMxc = getMemberAvatarMxc(room, senderId); 1310+ const getContent = (() => rootEvent.getContent()) as GetContentCallback; 1311+ 1312+ const replyCount = thread.events.filter( 1313+ (ev: MatrixEvent) => ev.getId() !== thread.id && !reactionOrEditEvent(ev) 1314+ ).length; 1315+ 1316+ const lastReply = thread.events 1317+ .filter((ev: MatrixEvent) => ev.getId() !== thread.id && !reactionOrEditEvent(ev)) 1318+ .at(-1); 1319+ const lastSenderId = lastReply?.getSender() ?? ''; 1320+ const lastDisplayName = 1321+ getMemberDisplayName(room, lastSenderId) ?? getMxIdLocalPart(lastSenderId) ?? lastSenderId; 1322+ const lastContent = lastReply?.getContent(); 1323+ const lastBody: string = typeof lastContent?.body === 'string' ? lastContent.body : ''; 1324+ 1325+ return ( 1326+ <Box 1327+ as="button" 1328+ direction="Column" 1329+ gap="100" 1330+ className={css.ThreadBrowserItem} 1331+ onClick={() => onClick(thread.id)} 1332+ > 1333+ <ModernLayout 1334+ before={ 1335+ <AvatarBase> 1336+ <Avatar size="300"> 1337+ <UserAvatar 1338+ userId={senderId} 1339+ src={ 1340+ senderAvatarMxc 1341+ ? mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? 1342+ undefined 1343+ : undefined 1344+ } 1345+ alt={displayName} 1346+ renderFallback={() => <Icon size="200" src={Icons.User} filled />} 1347+ /> 1348+ </Avatar> 1349+ </AvatarBase> 1350+ } 1351+ > 1352+ <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes"> 1353+ <Box gap="200" alignItems="Baseline"> 1354+ <Username> 1355+ <Text as="span" truncate> 1356+ <UsernameBold>{displayName}</UsernameBold> 1357+ </Text> 1358+ </Username> 1359+ <Time 1360+ ts={rootEvent.getTs()} 1361+ hour24Clock={hour24Clock} 1362+ dateFormatString={dateFormatString} 1363+ /> 1364+ </Box> 1365+ <Box shrink="No"> 1366+ <Chip data-event-id={thread.id} onClick={handleJumpClick} radii="Pill"> 1367+ <Text size="T200">Jump</Text> 1368+ </Chip> 1369+ </Box> 1370+ </Box> 1371+ {rootEvent.replyEventId && ( 1372+ <Reply 1373+ room={room} 1374+ replyEventId={rootEvent.replyEventId} 1375+ threadRootId={rootEvent.threadRootId} 1376+ onClick={handleJumpClick} 1377+ /> 1378+ )} 1379+ <Box 1380+ direction="Column" 1381+ style={{ 1382+ maxHeight: '200px', 1383+ overflow: 'auto', 1384+ flexShrink: 0, 1385+ }} 1386+ > 1387+ <EncryptedContent mEvent={rootEvent}> 1388+ {() => { 1389+ if (rootEvent.isRedacted()) { 1390+ return <RedactedContent />; 1391+ } 1392+ 1393+ return ( 1394+ <RenderMessageContent 1395+ displayName={displayName} 1396+ msgType={rootEvent.getContent().msgtype ?? ''} 1397+ ts={rootEvent.getTs()} 1398+ getContent={getContent} 1399+ edited={!!rootEvent.replacingEvent()} 1400+ mediaAutoLoad={mediaAutoLoad} 1401+ urlPreview={urlPreview} 1402+ htmlReactParserOptions={htmlReactParserOptions} 1403+ linkifyOpts={linkifyOpts} 1404+ outlineAttachment 1405+ /> 1406+ ); 1407+ }} 1408+ </EncryptedContent> 1409+ </Box> 1410+ {replyCount > 0 && ( 1411+ <Box gap="100" alignItems="Center" style={{ marginTop: config.space.S200 }}> 1412+ <Text size="T200" priority="300" style={{ flexShrink: 0 }}> 1413+ {replyCount} {replyCount === 1 ? 'reply' : 'replies'} 1414+ </Text> 1415+ {lastReply && lastBody && ( 1416+ <Text 1417+ size="T200" 1418+ priority="300" 1419+ style={{ 1420+ overflow: 'hidden', 1421+ textOverflow: 'ellipsis', 1422+ whiteSpace: 'nowrap', 1423+ minWidth: 0, 1424+ }} 1425+ > 1426+ · {lastDisplayName}: {lastBody.slice(0, 60)} 1427+ </Text> 1428+ )} 1429+ </Box> 1430+ )} 1431+ </ModernLayout> 1432+ </Box> 1433+ ); 1434+} 1435+ 1436+type ThreadBrowserProps = { 1437+ room: Room; 1438+ onOpenThread: (threadId: string) => void; 1439+ onClose: () => void; 1440+ overlay?: boolean; 1441+}; 1442+ 1443+export function ThreadBrowser({ room, onOpenThread, onClose, overlay }: ThreadBrowserProps) { 1444+ const [, forceUpdate] = useState(0); 1445+ const [query, setQuery] = useState(''); 1446+ const searchRef = useRef<HTMLInputElement>(null); 1447+ 1448+ // Re-render when threads change. 1449+ useEffect(() => { 1450+ const onUpdate = () => forceUpdate((n) => n + 1); 1451+ room.on(ThreadEvent.New as any, onUpdate); 1452+ room.on(ThreadEvent.Update as any, onUpdate); 1453+ room.on(ThreadEvent.NewReply as any, onUpdate); 1454+ return () => { 1455+ room.off(ThreadEvent.New as any, onUpdate); 1456+ room.off(ThreadEvent.Update as any, onUpdate); 1457+ room.off(ThreadEvent.NewReply as any, onUpdate); 1458+ }; 1459+ }, [room]); 1460+ 1461+ const allThreads = room.getThreads().sort((a: Thread, b: Thread) => { 1462+ const aTs = a.events.at(-1)?.getTs() ?? a.rootEvent?.getTs() ?? 0; 1463+ const bTs = b.events.at(-1)?.getTs() ?? b.rootEvent?.getTs() ?? 0; 1464+ return bTs - aTs; 1465+ }); 1466+ 1467+ const lowerQuery = query.trim().toLowerCase(); 1468+ const threads = lowerQuery 1469+ ? allThreads.filter((t: Thread) => { 1470+ const body = t.rootEvent?.getContent()?.body ?? ''; 1471+ return typeof body === 'string' && body.toLowerCase().includes(lowerQuery); 1472+ }) 1473+ : allThreads; 1474+ 1475+ const handleSearchChange: ChangeEventHandler<HTMLInputElement> = (e) => { 1476+ setQuery(e.target.value); 1477+ }; 1478+ 1479+ return ( 1480+ <Box 1481+ className={overlay ? css.ThreadDrawerOverlay : css.ThreadDrawer} 1482+ direction="Column" 1483+ shrink="No" 1484+ > 1485+ <Header className={css.ThreadDrawerHeader} variant="Background" size="600"> 1486+ <Box grow="Yes" alignItems="Center" gap="200"> 1487+ <Icon size="200" src={Icons.Thread} /> 1488+ <Text size="H5" truncate> 1489+ Threads 1490+ </Text> 1491+ </Box> 1492+ <Box alignItems="Center" gap="200" shrink="No"> 1493+ <Text size="T300" priority="300" truncate> 1494+ # {room.name} 1495+ </Text> 1496+ <IconButton onClick={onClose} variant="Background" aria-label="Close threads"> 1497+ <Icon src={Icons.Cross} /> 1498+ </IconButton> 1499+ </Box> 1500+ </Header> 1501+ 1502+ <Box 1503+ direction="Column" 1504+ gap="100" 1505+ style={{ padding: `${config.space.S200} ${config.space.S300}` }} 1506+ shrink="No" 1507+ > 1508+ <Input 1509+ ref={searchRef} 1510+ value={query} 1511+ onChange={handleSearchChange} 1512+ placeholder="Search threads..." 1513+ variant="Surface" 1514+ size="400" 1515+ radii="400" 1516+ before={<Icon size="50" src={Icons.Search} />} 1517+ after={ 1518+ query ? ( 1519+ <IconButton 1520+ size="300" 1521+ radii="300" 1522+ variant="SurfaceVariant" 1523+ onClick={() => { 1524+ setQuery(''); 1525+ searchRef.current?.focus(); 1526+ }} 1527+ aria-label="Clear search" 1528+ > 1529+ <Icon size="50" src={Icons.Cross} /> 1530+ </IconButton> 1531+ ) : undefined 1532+ } 1533+ /> 1534+ </Box> 1535+ 1536+ <Box className={css.ThreadDrawerContent} grow="Yes" direction="Column"> 1537+ <Scroll 1538+ variant="Background" 1539+ visibility="Hover" 1540+ direction="Vertical" 1541+ hideTrack 1542+ style={{ flexGrow: 1 }} 1543+ > 1544+ {threads.length === 0 ? ( 1545+ <Box 1546+ direction="Column" 1547+ alignItems="Center" 1548+ justifyContent="Center" 1549+ style={{ padding: config.space.S400, gap: config.space.S200 }} 1550+ > 1551+ <Icon size="400" src={Icons.Thread} /> 1552+ <Text size="T300" align="Center"> 1553+ {lowerQuery ? 'No threads match your search.' : 'No threads yet.'} 1554+ </Text> 1555+ </Box> 1556+ ) : ( 1557+ <Box 1558+ direction="Column" 1559+ style={{ padding: `${config.space.S100} ${config.space.S200}` }} 1560+ > 1561+ {threads.map((thread: Thread) => ( 1562+ <ThreadPreview key={thread.id} room={room} thread={thread} onClick={onOpenThread} /> 1563+ ))} 1564+ </Box> 1565+ )} 1566+ </Scroll> 1567+ </Box> 1568+ </Box> 1569+ ); 1570+} 1571diff --git a/src/app/features/room/ThreadDrawer.css.ts b/src/app/features/room/ThreadDrawer.css.ts 1572new file mode 100644 1573index 0000000..b579e74 1574--- /dev/null 1575+++ b/src/app/features/room/ThreadDrawer.css.ts 1576@@ -0,0 +1,67 @@ 1577+import { style, globalStyle } from '@vanilla-extract/css'; 1578+import { config, color, toRem } from 'folds'; 1579+ 1580+export const ThreadDrawer = style({ 1581+ width: toRem(490), 1582+ height: '100%', 1583+ display: 'flex', 1584+ flexDirection: 'column', 1585+ overflow: 'hidden', 1586+}); 1587+ 1588+export const messageList = style({ 1589+ display: 'flex', 1590+ flexDirection: 'column', 1591+ width: '100%', 1592+}); 1593+ 1594+globalStyle(`body ${messageList} [data-message-id]`, { 1595+ transition: 'background-color 0.1s ease-in-out !important', 1596+}); 1597+ 1598+globalStyle(`body ${messageList} [data-message-id]:hover`, { 1599+ backgroundColor: `${color.Background.ContainerHover} !important`, 1600+}); 1601+ 1602+export const ThreadDrawerHeader = style({ 1603+ flexShrink: 0, 1604+ padding: `0 ${config.space.S200} 0 ${config.space.S300}`, 1605+ borderBottomWidth: config.borderWidth.B300, 1606+}); 1607+ 1608+export const ThreadDrawerContent = style({ 1609+ position: 'relative', 1610+ overflow: 'hidden', 1611+ flexGrow: 1, 1612+ minHeight: 0, // Ensure flex child can shrink below content size 1613+}); 1614+ 1615+export const ThreadDrawerInput = style({ 1616+ flexShrink: 0, 1617+}); 1618+ 1619+export const ThreadDrawerOverlay = style({ 1620+ position: 'absolute', 1621+ inset: 0, 1622+ zIndex: 10, 1623+ width: '100%', 1624+ display: 'flex', 1625+ flexDirection: 'column', 1626+ overflow: 'hidden', 1627+ backgroundColor: color.Background.Container, 1628+}); 1629+ 1630+export const ThreadBrowserItem = style({ 1631+ width: '100%', 1632+ padding: `${config.space.S200} ${config.space.S100}`, 1633+ borderRadius: config.radii.R300, 1634+ textAlign: 'left', 1635+ cursor: 'pointer', 1636+ background: 'none', 1637+ border: 'none', 1638+ color: 'inherit', 1639+ transition: 'background-color 0.1s ease-in-out', 1640+ ':hover': { 1641+ backgroundColor: color.Background.ContainerHover, 1642+ }, 1643+}); 1644diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx 1645new file mode 100644 1646index 0000000..d3c98ba 1647--- /dev/null 1648+++ b/src/app/features/room/ThreadDrawer.tsx 1649@@ -0,0 +1,751 @@ 1650+import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; 1651+import { Box, Header, Icon, IconButton, Icons, Scroll, Text, config } from 'folds'; 1652+import { RelationType } from 'matrix-js-sdk/lib/@types/event'; 1653+import { ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts'; 1654+import { MatrixEvent } from 'matrix-js-sdk/lib/models/event'; 1655+import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk/lib/models/room'; 1656+import { ThreadEvent } from 'matrix-js-sdk/lib/models/thread'; 1657+import { useAtomValue, useSetAtom } from 'jotai'; 1658+import { ReactEditor } from 'slate-react'; 1659+import { HTMLReactParserOptions } from 'html-react-parser'; 1660+import { Opts as LinkifyOpts } from 'linkifyjs'; 1661+import { ImageContent, MSticker, RedactedContent, Reply } from '../../components/message'; 1662+import { RenderMessageContent } from '../../components/RenderMessageContent'; 1663+import { Image } from '../../components/media'; 1664+import { ImageViewer } from '../../components/image-viewer'; 1665+import { 1666+ factoryRenderLinkifyWithMention, 1667+ getReactCustomHtmlParser, 1668+ LINKIFY_OPTS, 1669+ makeMentionCustomProps, 1670+ renderMatrixMention, 1671+} from '../../plugins/react-custom-html-parser'; 1672+import { 1673+ getEditedEvent, 1674+ getEventReactions, 1675+ getMemberDisplayName, 1676+ reactionOrEditEvent, 1677+} from '../../utils/room'; 1678+import { getMxIdLocalPart, toggleReaction } from '../../utils/matrix'; 1679+import { minuteDifference } from '../../utils/time'; 1680+import { useMatrixClient } from '../../hooks/useMatrixClient'; 1681+import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; 1682+import { MessageLayout, MessageSpacing, settingsAtom } from '../../state/settings'; 1683+import { useSetting } from '../../state/hooks/settings'; 1684+import { createMentionElement, moveCursor, useEditor } from '../../components/editor'; 1685+import { useMentionClickHandler } from '../../hooks/useMentionClickHandler'; 1686+import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; 1687+import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room'; 1688+import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; 1689+import { useRoomPermissions } from '../../hooks/useRoomPermissions'; 1690+import { useRoomCreators } from '../../hooks/useRoomCreators'; 1691+import { useImagePackRooms } from '../../hooks/useImagePackRooms'; 1692+import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; 1693+import { IReplyDraft, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts'; 1694+import { roomToParentsAtom } from '../../state/room/roomToParents'; 1695+import { EncryptedContent, Message, Reactions } from './message'; 1696+import { RoomInput } from './RoomInput'; 1697+import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; 1698+import * as css from './ThreadDrawer.css'; 1699+ 1700+type ThreadMessageProps = { 1701+ room: Room; 1702+ mEvent: MatrixEvent; 1703+ threadRootId: string; 1704+ editId: string | undefined; 1705+ onEditId: (id?: string) => void; 1706+ messageLayout: MessageLayout; 1707+ messageSpacing: MessageSpacing; 1708+ canDelete: boolean; 1709+ canSendReaction: boolean; 1710+ canPinEvent: boolean; 1711+ imagePackRooms: Room[]; 1712+ hour24Clock: boolean; 1713+ dateFormatString: string; 1714+ onUserClick: MouseEventHandler<HTMLButtonElement>; 1715+ onUsernameClick: MouseEventHandler<HTMLButtonElement>; 1716+ onReplyClick: MouseEventHandler<HTMLButtonElement>; 1717+ onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; 1718+ linkifyOpts: LinkifyOpts; 1719+ htmlReactParserOptions: HTMLReactParserOptions; 1720+ showHideReads: boolean; 1721+ showDeveloperTools: boolean; 1722+ onReferenceClick: MouseEventHandler<HTMLButtonElement>; 1723+ jumpToEventId?: string; 1724+ collapse?: boolean; 1725+}; 1726+ 1727+function ThreadMessage({ 1728+ room, 1729+ threadRootId: threadRootIdProp, 1730+ mEvent, 1731+ editId, 1732+ onEditId, 1733+ messageLayout, 1734+ messageSpacing, 1735+ canDelete, 1736+ canSendReaction, 1737+ collapse = false, 1738+ canPinEvent, 1739+ imagePackRooms, 1740+ hour24Clock, 1741+ dateFormatString, 1742+ onUserClick, 1743+ onUsernameClick, 1744+ onReplyClick, 1745+ onReactionToggle, 1746+ linkifyOpts, 1747+ htmlReactParserOptions, 1748+ showHideReads, 1749+ showDeveloperTools, 1750+ onReferenceClick, 1751+ jumpToEventId, 1752+}: ThreadMessageProps) { 1753+ // Use the thread's own timeline set so reactions/edits on thread events are found correctly 1754+ const threadTimelineSet = room.getThread(threadRootIdProp)?.timelineSet; 1755+ const timelineSet = threadTimelineSet ?? room.getUnfilteredTimelineSet(); 1756+ const mEventId = mEvent.getId()!; 1757+ const senderId = mEvent.getSender() ?? ''; 1758+ const senderDisplayName = 1759+ getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; 1760+ 1761+ const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); 1762+ const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); 1763+ const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); 1764+ const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; 1765+ 1766+ const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); 1767+ const editedNewContent = editedEvent?.getContent()['m.new_content']; 1768+ const baseContent = mEvent.getContent(); 1769+ const safeContent = 1770+ Object.keys(baseContent).length > 0 ? baseContent : mEvent.getOriginalContent(); 1771+ const getContent = (() => editedNewContent ?? safeContent) as GetContentCallback; 1772+ 1773+ const reactionRelations = getEventReactions(timelineSet, mEventId); 1774+ const reactions = reactionRelations?.getSortedAnnotationsByKey(); 1775+ const hasReactions = reactions && reactions.length > 0; 1776+ 1777+ const { replyEventId } = mEvent; 1778+ 1779+ return ( 1780+ <Message 1781+ key={mEvent.getId()} 1782+ data-message-id={mEventId} 1783+ room={room} 1784+ mEvent={mEvent} 1785+ messageSpacing={messageSpacing} 1786+ messageLayout={messageLayout} 1787+ collapse={collapse} 1788+ highlight={jumpToEventId === mEventId} 1789+ edit={editId === mEventId} 1790+ canDelete={canDelete} 1791+ canSendReaction={canSendReaction} 1792+ canPinEvent={canPinEvent} 1793+ imagePackRooms={imagePackRooms} 1794+ relations={hasReactions ? reactionRelations : undefined} 1795+ onUserClick={onUserClick} 1796+ onUsernameClick={onUsernameClick} 1797+ onReplyClick={onReplyClick} 1798+ onReactionToggle={onReactionToggle} 1799+ onEditId={onEditId} 1800+ hour24Clock={hour24Clock} 1801+ dateFormatString={dateFormatString} 1802+ hideReadReceipts={showHideReads} 1803+ hideThreadButton 1804+ showDeveloperTools={showDeveloperTools} 1805+ reply={ 1806+ replyEventId && 1807+ replyEventId !== threadRootIdProp && ( 1808+ <Reply 1809+ room={room} 1810+ timelineSet={timelineSet} 1811+ replyEventId={replyEventId} 1812+ onClick={onReferenceClick} 1813+ /> 1814+ ) 1815+ } 1816+ reactions={ 1817+ hasReactions && reactionRelations ? ( 1818+ <Reactions 1819+ style={{ marginTop: config.space.S200 }} 1820+ room={room} 1821+ relations={reactionRelations} 1822+ mEventId={mEventId} 1823+ canSendReaction={canSendReaction} 1824+ onReactionToggle={onReactionToggle} 1825+ /> 1826+ ) : undefined 1827+ } 1828+ > 1829+ {mEvent.isRedacted() ? ( 1830+ <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} /> 1831+ ) : ( 1832+ <EncryptedContent mEvent={mEvent}> 1833+ {() => { 1834+ if (mEvent.isRedacted()) 1835+ return ( 1836+ <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} /> 1837+ ); 1838+ 1839+ if (mEvent.getType() === MessageEvent.Sticker) 1840+ return ( 1841+ <MSticker 1842+ content={mEvent.getContent()} 1843+ renderImageContent={(props) => ( 1844+ <ImageContent 1845+ {...props} 1846+ autoPlay={mediaAutoLoad} 1847+ renderImage={(p) => <Image {...p} loading="lazy" />} 1848+ renderViewer={(p) => <ImageViewer {...p} />} 1849+ /> 1850+ )} 1851+ /> 1852+ ); 1853+ 1854+ if (mEvent.getType() === MessageEvent.RoomMessage) { 1855+ return ( 1856+ <RenderMessageContent 1857+ displayName={senderDisplayName} 1858+ msgType={(editedNewContent ?? safeContent).msgtype ?? ''} 1859+ ts={mEvent.getTs()} 1860+ edited={!!editedEvent} 1861+ getContent={getContent} 1862+ mediaAutoLoad={mediaAutoLoad} 1863+ urlPreview={showUrlPreview} 1864+ htmlReactParserOptions={htmlReactParserOptions} 1865+ linkifyOpts={linkifyOpts} 1866+ outlineAttachment={messageLayout === MessageLayout.Bubble} 1867+ /> 1868+ ); 1869+ } 1870+ 1871+ return ( 1872+ <RenderMessageContent 1873+ displayName={senderDisplayName} 1874+ msgType={(editedNewContent ?? safeContent).msgtype ?? ''} 1875+ ts={mEvent.getTs()} 1876+ edited={!!editedEvent} 1877+ getContent={getContent} 1878+ mediaAutoLoad={mediaAutoLoad} 1879+ urlPreview={showUrlPreview} 1880+ htmlReactParserOptions={htmlReactParserOptions} 1881+ linkifyOpts={linkifyOpts} 1882+ outlineAttachment={messageLayout === MessageLayout.Bubble} 1883+ /> 1884+ ); 1885+ }} 1886+ </EncryptedContent> 1887+ )} 1888+ </Message> 1889+ ); 1890+} 1891+ 1892+type ThreadDrawerProps = { 1893+ room: Room; 1894+ threadRootId: string; 1895+ onClose: () => void; 1896+ overlay?: boolean; 1897+}; 1898+ 1899+export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDrawerProps) { 1900+ const mx = useMatrixClient(); 1901+ const drawerRef = useRef<HTMLDivElement>(null); 1902+ const editor = useEditor(); 1903+ const [, forceUpdate] = useState(0); 1904+ const [editId, setEditId] = useState<string | undefined>(undefined); 1905+ const [jumpToEventId, setJumpToEventId] = useState<string | undefined>(undefined); 1906+ const scrollRef = useRef<HTMLDivElement>(null); 1907+ const prevReplyCountRef = useRef(0); 1908+ const replyEventsRef = useRef<MatrixEvent[]>([]); 1909+ const useAuthentication = useMediaAuthentication(); 1910+ const mentionClickHandler = useMentionClickHandler(room.roomId); 1911+ const spoilerClickHandler = useSpoilerClickHandler(); 1912+ 1913+ // Settings 1914+ const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); 1915+ const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); 1916+ const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); 1917+ const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); 1918+ const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); 1919+ const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); 1920+ 1921+ // Memoized parsing options 1922+ const linkifyOpts = useMemo<LinkifyOpts>( 1923+ () => ({ 1924+ ...LINKIFY_OPTS, 1925+ render: factoryRenderLinkifyWithMention((href) => 1926+ renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)) 1927+ ), 1928+ }), 1929+ [mx, room, mentionClickHandler] 1930+ ); 1931+ 1932+ const htmlReactParserOptions = useMemo<HTMLReactParserOptions>( 1933+ () => 1934+ getReactCustomHtmlParser(mx, room.roomId, { 1935+ linkifyOpts, 1936+ useAuthentication, 1937+ handleSpoilerClick: spoilerClickHandler, 1938+ handleMentionClick: mentionClickHandler, 1939+ }), 1940+ [mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication] 1941+ ); 1942+ 1943+ // Power levels & permissions 1944+ const powerLevels = usePowerLevelsContext(); 1945+ const creators = useRoomCreators(room); 1946+ const permissions = useRoomPermissions(creators, powerLevels); 1947+ const canRedact = permissions.action('redact', mx.getSafeUserId()); 1948+ const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId()); 1949+ const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId()); 1950+ const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId()); 1951+ 1952+ // Image packs 1953+ const roomToParents = useAtomValue(roomToParentsAtom); 1954+ const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents); 1955+ 1956+ // Reply draft (keyed by threadRootId to match RoomInput's draftKey logic) 1957+ const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(threadRootId)); 1958+ const replyDraft = useAtomValue(roomIdToReplyDraftAtomFamily(threadRootId)); 1959+ const activeReplyId = replyDraft?.eventId; 1960+ 1961+ // User profile popup 1962+ const openUserRoomProfile = useOpenUserRoomProfile(); 1963+ 1964+ const rootEvent = room.findEventById(threadRootId); 1965+ 1966+ // Re-render when new thread events arrive (including reactions via ThreadEvent.Update). 1967+ useEffect(() => { 1968+ const isEventInThread = (mEvent: MatrixEvent): boolean => { 1969+ // Direct thread message or the root itself 1970+ if (mEvent.threadRootId === threadRootId || mEvent.getId() === threadRootId) { 1971+ return true; 1972+ } 1973+ 1974+ // Check if this is a reaction/edit targeting an event in this thread 1975+ if (reactionOrEditEvent(mEvent)) { 1976+ const relation = mEvent.getRelation(); 1977+ const targetEventId = relation?.event_id; 1978+ if (targetEventId) { 1979+ const targetEvent = room.findEventById(targetEventId); 1980+ if ( 1981+ targetEvent && 1982+ (targetEvent.threadRootId === threadRootId || targetEvent.getId() === threadRootId) 1983+ ) { 1984+ return true; 1985+ } 1986+ } 1987+ } 1988+ 1989+ return false; 1990+ }; 1991+ 1992+ const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (mEvent) => { 1993+ if (isEventInThread(mEvent)) { 1994+ forceUpdate((n) => n + 1); 1995+ } 1996+ }; 1997+ const onRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent) => { 1998+ if (isEventInThread(mEvent)) { 1999+ forceUpdate((n) => n + 1); 2000+ } 2001+ }; 2002+ const onThreadUpdate = () => forceUpdate((n) => n + 1); 2003+ room.on(RoomEvent.Timeline, onTimeline); 2004+ room.on(RoomEvent.Redaction, onRedaction); 2005+ (room as any).on(ThreadEvent.Update, onThreadUpdate); 2006+ (room as any).on(ThreadEvent.NewReply, onThreadUpdate); 2007+ return () => { 2008+ room.removeListener(RoomEvent.Timeline, onTimeline); 2009+ room.removeListener(RoomEvent.Redaction, onRedaction); 2010+ (room as any).removeListener(ThreadEvent.Update, onThreadUpdate); 2011+ (room as any).removeListener(ThreadEvent.NewReply, onThreadUpdate); 2012+ }; 2013+ }, [mx, room, threadRootId]); 2014+ 2015+ // Use the Thread object if available (authoritative source with full history). 2016+ // Fall back to scanning the live room timeline for local echoes and the 2017+ // window before the Thread object is registered by the SDK. 2018+ const replyEvents: MatrixEvent[] = (() => { 2019+ const thread = room.getThread(threadRootId); 2020+ const fromThread = thread?.events ?? []; 2021+ if (fromThread.length > 0) { 2022+ return fromThread.filter( 2023+ (ev: MatrixEvent) => ev.getId() !== threadRootId && !reactionOrEditEvent(ev) 2024+ ); 2025+ } 2026+ return room 2027+ .getUnfilteredTimelineSet() 2028+ .getLiveTimeline() 2029+ .getEvents() 2030+ .filter( 2031+ (ev: MatrixEvent) => 2032+ ev.threadRootId === threadRootId && 2033+ ev.getId() !== threadRootId && 2034+ !reactionOrEditEvent(ev) 2035+ ); 2036+ })(); 2037+ 2038+ replyEventsRef.current = replyEvents; 2039+ 2040+ // Mark thread as read when viewing it and when new messages arrive 2041+ useEffect(() => { 2042+ const markThreadAsRead = async () => { 2043+ const thread = room.getThread(threadRootId); 2044+ if (!thread) return; 2045+ 2046+ // The receipt must target the latest genuine *in-thread reply*. The SDK's 2047+ // threadIdForReceipt() routes a receipt for the thread root — or for a 2048+ // relation to it, e.g. a reaction to the root — to the "main" timeline, 2049+ // which clears the room's main unread but never the thread's unread 2050+ // bucket. And it must be the thread's *true* latest reply: receipting an 2051+ // older event leaves a residual server-side count, so the notification 2052+ // reappears on the next sync/reload even for threads we opened. 2053+ // 2054+ // thread.lastReply() is the SDK's own canonical latest m.thread reply, 2055+ // taken from the authoritative thread timeline (which includes the 2056+ // server-bundled latest_event from /threads) and is the same event the 2057+ // SDK uses for its read-up-to comparison. Prefer it; fall back to a 2058+ // newest-first scan of the live timeline / rendered replies only when the 2059+ // Thread object is not yet hydrated. 2060+ const pickLastReply = (evs: MatrixEvent[]): MatrixEvent | undefined => { 2061+ for (let i = evs.length - 1; i >= 0; i -= 1) { 2062+ const ev = evs[i]; 2063+ if ( 2064+ ev.getId() !== threadRootId && 2065+ ev.threadRootId === threadRootId && 2066+ !ev.isSending() 2067+ ) { 2068+ return ev; 2069+ } 2070+ } 2071+ return undefined; 2072+ }; 2073+ const lastEvent = 2074+ thread.lastReply() ?? 2075+ pickLastReply(thread.events || []) ?? 2076+ pickLastReply(replyEventsRef.current); 2077+ if (!lastEvent || lastEvent.isSending()) return; 2078+ 2079+ const userId = mx.getUserId(); 2080+ if (!userId) return; 2081+ 2082+ const readUpToId = thread.getEventReadUpTo(userId, false); 2083+ const lastEventId = lastEvent.getId(); 2084+ 2085+ // Only send receipt if we haven't already read up to the last event 2086+ if (readUpToId !== lastEventId) { 2087+ try { 2088+ await mx.sendReadReceipt( 2089+ lastEvent, 2090+ hideActivity ? ReceiptType.ReadPrivate : ReceiptType.Read 2091+ ); 2092+ } catch (err) { 2093+ // eslint-disable-next-line no-console 2094+ console.warn('Failed to send thread read receipt:', err); 2095+ } 2096+ } 2097+ }; 2098+ 2099+ markThreadAsRead(); 2100+ }, [mx, room, threadRootId, replyEvents.length, hideActivity]); 2101+ 2102+ // Auto-scroll to bottom when event count grows (if the user is near the bottom). 2103+ useEffect(() => { 2104+ const el = scrollRef.current; 2105+ if (!el) return; 2106+ const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150; 2107+ if (prevReplyCountRef.current === 0 || isAtBottom) { 2108+ el.scrollTop = el.scrollHeight; 2109+ } 2110+ prevReplyCountRef.current = replyEvents.length; 2111+ }, [replyEvents.length]); 2112+ 2113+ const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback( 2114+ (evt) => { 2115+ evt.preventDefault(); 2116+ evt.stopPropagation(); 2117+ const userId = evt.currentTarget.getAttribute('data-user-id'); 2118+ if (!userId) return; 2119+ openUserRoomProfile( 2120+ room.roomId, 2121+ undefined, 2122+ userId, 2123+ evt.currentTarget.getBoundingClientRect() 2124+ ); 2125+ }, 2126+ [room, openUserRoomProfile] 2127+ ); 2128+ 2129+ const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback( 2130+ (evt) => { 2131+ evt.preventDefault(); 2132+ const userId = evt.currentTarget.getAttribute('data-user-id'); 2133+ if (!userId) return; 2134+ const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; 2135+ editor.insertNode( 2136+ createMentionElement( 2137+ userId, 2138+ name.startsWith('@') ? name : `@${name}`, 2139+ userId === mx.getUserId() 2140+ ) 2141+ ); 2142+ ReactEditor.focus(editor); 2143+ moveCursor(editor); 2144+ }, 2145+ [mx, room, editor] 2146+ ); 2147+ 2148+ const handleReplyClick = useCallback( 2149+ (evt: Parameters<MouseEventHandler<HTMLButtonElement>>[0]) => { 2150+ const replyId = evt.currentTarget.getAttribute('data-event-id'); 2151+ if (!replyId) { 2152+ // In thread mode, resetting means going back to base thread draft 2153+ setReplyDraft({ 2154+ userId: mx.getUserId() ?? '', 2155+ eventId: threadRootId, 2156+ body: '', 2157+ relation: { rel_type: RelationType.Thread, event_id: threadRootId }, 2158+ }); 2159+ return; 2160+ } 2161+ const replyEvt = room.findEventById(replyId); 2162+ if (!replyEvt) return; 2163+ const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet()); 2164+ const content = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent(); 2165+ const { body, formatted_body: formattedBody } = content; 2166+ const senderId = replyEvt.getSender(); 2167+ if (senderId) { 2168+ const draft: IReplyDraft = { 2169+ userId: senderId, 2170+ eventId: replyId, 2171+ body: typeof body === 'string' ? body : '', 2172+ formattedBody, 2173+ relation: { rel_type: RelationType.Thread, event_id: threadRootId }, 2174+ }; 2175+ // Only toggle off if we're actively replying to this event (non-empty body distinguishes 2176+ // a real reply draft from the seeded base-thread draft, which has body: ''). 2177+ if (activeReplyId === replyId && replyDraft?.body) { 2178+ // Toggle off — reset to base thread draft 2179+ setReplyDraft({ 2180+ userId: mx.getUserId() ?? '', 2181+ eventId: threadRootId, 2182+ body: '', 2183+ relation: { rel_type: RelationType.Thread, event_id: threadRootId }, 2184+ }); 2185+ } else { 2186+ setReplyDraft(draft); 2187+ } 2188+ } 2189+ }, 2190+ [mx, room, setReplyDraft, activeReplyId, threadRootId, replyDraft] 2191+ ); 2192+ 2193+ const handleReactionToggle = useCallback( 2194+ (targetEventId: string, key: string, shortcode?: string) => { 2195+ const threadTimelineSet = room.getThread(threadRootId)?.timelineSet; 2196+ toggleReaction(mx, room, targetEventId, key, shortcode, threadTimelineSet); 2197+ }, 2198+ [mx, room, threadRootId] 2199+ ); 2200+ 2201+ const handleEdit = useCallback( 2202+ (evtId?: string) => { 2203+ setEditId(evtId); 2204+ if (!evtId) { 2205+ ReactEditor.focus(editor); 2206+ moveCursor(editor); 2207+ } 2208+ }, 2209+ [editor] 2210+ ); 2211+ 2212+ const handleOpenReply: MouseEventHandler<HTMLButtonElement> = useCallback( 2213+ (evt) => { 2214+ const targetId = evt.currentTarget.getAttribute('data-event-id'); 2215+ if (!targetId) return; 2216+ const isRoot = targetId === threadRootId; 2217+ const isInReplies = replyEventsRef.current.some((e) => e.getId() === targetId); 2218+ if (!isRoot && !isInReplies) return; 2219+ setJumpToEventId(targetId); 2220+ setTimeout(() => setJumpToEventId(undefined), 2500); 2221+ const el = drawerRef.current; 2222+ if (el) { 2223+ const target = el.querySelector(`[data-message-id="${targetId}"]`); 2224+ target?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); 2225+ } 2226+ }, 2227+ [threadRootId] 2228+ ); 2229+ 2230+ const sharedMessageProps = { 2231+ room, 2232+ threadRootId, 2233+ editId, 2234+ onEditId: handleEdit, 2235+ messageLayout, 2236+ messageSpacing, 2237+ canDelete: canRedact || canDeleteOwn, 2238+ canSendReaction, 2239+ canPinEvent, 2240+ imagePackRooms, 2241+ hour24Clock, 2242+ dateFormatString, 2243+ onUserClick: handleUserClick, 2244+ onUsernameClick: handleUsernameClick, 2245+ onReplyClick: handleReplyClick, 2246+ onReactionToggle: handleReactionToggle, 2247+ linkifyOpts, 2248+ htmlReactParserOptions, 2249+ showHideReads: hideActivity, 2250+ showDeveloperTools, 2251+ onReferenceClick: handleOpenReply, 2252+ jumpToEventId, 2253+ }; 2254+ 2255+ // Latest thread event for the following indicator (latest reply, or root if no replies) 2256+ const threadParticipantIds = new Set( 2257+ [rootEvent, ...replyEvents].map((ev) => ev?.getSender()).filter(Boolean) as string[] 2258+ ); 2259+ const latestThreadEventId = ( 2260+ replyEvents.length > 0 ? replyEvents[replyEvents.length - 1] : rootEvent 2261+ )?.getId(); 2262+ 2263+ return ( 2264+ <Box 2265+ ref={drawerRef} 2266+ className={overlay ? css.ThreadDrawerOverlay : css.ThreadDrawer} 2267+ direction="Column" 2268+ shrink="No" 2269+ > 2270+ {/* Header */} 2271+ <Header className={css.ThreadDrawerHeader} variant="Background" size="600"> 2272+ <Box grow="Yes" alignItems="Center" gap="200"> 2273+ <Icon size="200" src={Icons.Thread} /> 2274+ <Text size="H5" truncate> 2275+ Thread 2276+ </Text> 2277+ </Box> 2278+ <Box alignItems="Center" gap="200" shrink="No"> 2279+ <Text size="T300" priority="300" truncate> 2280+ # {room.name} 2281+ </Text> 2282+ <IconButton onClick={onClose} variant="Background" aria-label="Close thread"> 2283+ <Icon src={Icons.Cross} /> 2284+ </IconButton> 2285+ </Box> 2286+ </Header> 2287+ 2288+ {/* Thread root message */} 2289+ {rootEvent && ( 2290+ <Scroll 2291+ variant="Background" 2292+ visibility="Hover" 2293+ direction="Vertical" 2294+ hideTrack={false} 2295+ style={{ 2296+ maxHeight: '200px', 2297+ flexShrink: 0, 2298+ height: 'auto', 2299+ }} 2300+ > 2301+ <Box 2302+ className={css.messageList} 2303+ direction="Column" 2304+ style={{ 2305+ padding: `${config.space.S200} 0`, 2306+ }} 2307+ > 2308+ <ThreadMessage {...sharedMessageProps} mEvent={rootEvent} /> 2309+ </Box> 2310+ </Scroll> 2311+ )} 2312+ 2313+ {/* Replies */} 2314+ <Box className={css.ThreadDrawerContent} grow="Yes" direction="Column"> 2315+ <Scroll 2316+ ref={scrollRef} 2317+ variant="Background" 2318+ visibility="Hover" 2319+ direction="Vertical" 2320+ hideTrack 2321+ style={{ flexGrow: 1 }} 2322+ > 2323+ {replyEvents.length === 0 ? ( 2324+ <Box 2325+ direction="Column" 2326+ alignItems="Center" 2327+ justifyContent="Center" 2328+ style={{ padding: config.space.S400, gap: config.space.S200 }} 2329+ > 2330+ <Icon size="400" src={Icons.Thread} /> 2331+ <Text size="T300" align="Center"> 2332+ No replies yet. Start the thread below! 2333+ </Text> 2334+ </Box> 2335+ ) : ( 2336+ <> 2337+ {/* Reply count label inside scroll area */} 2338+ <Box 2339+ style={{ 2340+ padding: `${config.space.S200} ${config.space.S400}`, 2341+ flexShrink: 0, 2342+ }} 2343+ > 2344+ <Text size="T300" priority="300"> 2345+ {replyEvents.length} {replyEvents.length === 1 ? 'reply' : 'replies'} 2346+ </Text> 2347+ </Box> 2348+ <Box 2349+ className={css.messageList} 2350+ direction="Column" 2351+ style={{ padding: `${config.space.S600} 0` }} 2352+ > 2353+ {replyEvents.map((mEvent, i) => { 2354+ const prevEvent = i > 0 ? replyEvents[i - 1] : undefined; 2355+ const collapse = 2356+ prevEvent !== undefined && 2357+ prevEvent.getSender() === mEvent.getSender() && 2358+ prevEvent.getType() === mEvent.getType() && 2359+ minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2; 2360+ return ( 2361+ <ThreadMessage 2362+ key={mEvent.getId()} 2363+ {...sharedMessageProps} 2364+ mEvent={mEvent} 2365+ collapse={collapse} 2366+ /> 2367+ ); 2368+ })} 2369+ </Box> 2370+ </> 2371+ )} 2372+ </Scroll> 2373+ </Box> 2374+ 2375+ {/* Thread input */} 2376+ <Box className={css.ThreadDrawerInput} direction="Column" shrink="No"> 2377+ <div style={{ padding: `0 ${config.space.S400}` }}> 2378+ <RoomInput 2379+ key={threadRootId} 2380+ room={room} 2381+ roomId={room.roomId} 2382+ threadRootId={threadRootId} 2383+ editor={editor} 2384+ fileDropContainerRef={drawerRef} 2385+ /> 2386+ </div> 2387+ {hideActivity ? ( 2388+ <RoomViewFollowingPlaceholder /> 2389+ ) : ( 2390+ <RoomViewFollowing 2391+ style={{ backgroundColor: 'transparent' }} 2392+ room={room} 2393+ threadEventId={latestThreadEventId} 2394+ participantIds={threadParticipantIds} 2395+ /> 2396+ )} 2397+ </Box> 2398+ </Box> 2399+ ); 2400+} 2401diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx 2402index 2d87e9b..3e7fd5b 100644 2403--- a/src/app/features/room/message/Message.tsx 2404+++ b/src/app/features/room/message/Message.tsx 2405@@ -676,6 +676,7 @@ export type MessageProps = { 2406 reply?: ReactNode; 2407 reactions?: ReactNode; 2408 hideReadReceipts?: boolean; 2409+ hideThreadButton?: boolean; 2410 showDeveloperTools?: boolean; 2411 memberPowerTag?: MemberPowerTag; 2412 accessibleTagColors?: Map<string, string>; 2413@@ -707,6 +708,7 @@ export const Message = as<'div', MessageProps>( 2414 reply, 2415 reactions, 2416 hideReadReceipts, 2417+ hideThreadButton, 2418 showDeveloperTools, 2419 memberPowerTag, 2420 accessibleTagColors, 2421@@ -814,7 +816,7 @@ export const Message = as<'div', MessageProps>( 2422 ); 2423 2424 const msgContentJSX = ( 2425- <Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}> 2426+ <Box direction="Column" alignSelf="Start" style={{ width: '100%' }}> 2427 {reply} 2428 {edit && onEditId ? ( 2429 <MessageEditor 2430@@ -872,8 +874,6 @@ export const Message = as<'div', MessageProps>( 2431 }, 100); 2432 }; 2433 2434- const isThreadedMessage = mEvent.threadRootId !== undefined; 2435- 2436 return ( 2437 <MessageBase 2438 className={classNames(css.MessageBase, className, { 2439@@ -938,7 +938,7 @@ export const Message = as<'div', MessageProps>( 2440 > 2441 <Icon src={Icons.ReplyArrow} size="100" /> 2442 </IconButton> 2443- {!isThreadedMessage && ( 2444+ {!hideThreadButton && ( 2445 <IconButton 2446 onClick={(ev) => onReplyClick(ev, true)} 2447 data-event-id={mEvent.getId()} 2448@@ -1028,7 +1028,7 @@ export const Message = as<'div', MessageProps>( 2449 Reply 2450 </Text> 2451 </MenuItem> 2452- {!isThreadedMessage && ( 2453+ {!hideThreadButton && ( 2454 <MenuItem 2455 size="300" 2456 after={<Icon src={Icons.ThreadPlus} size="100" />} 2457diff --git a/src/app/state/room/roomToOpenThread.ts b/src/app/state/room/roomToOpenThread.ts 2458new file mode 100644 2459index 0000000..0a60fa4 2460--- /dev/null 2461+++ b/src/app/state/room/roomToOpenThread.ts 2462@@ -0,0 +1,14 @@ 2463+import { atom } from 'jotai'; 2464+import { atomFamily } from 'jotai/utils'; 2465+ 2466+const createOpenThreadAtom = () => atom<string | undefined>(undefined); 2467+export type TOpenThreadAtom = ReturnType<typeof createOpenThreadAtom>; 2468+ 2469+/** 2470+ * Tracks the currently-open thread root event ID per room. 2471+ * Key: roomId 2472+ * Value: eventId of the thread root, or undefined if no thread is open. 2473+ */ 2474+export const roomIdToOpenThreadAtomFamily = atomFamily<string, TOpenThreadAtom>(() => 2475+ createOpenThreadAtom() 2476+); 2477diff --git a/src/app/state/room/roomToThreadBrowser.ts b/src/app/state/room/roomToThreadBrowser.ts 2478new file mode 100644 2479index 0000000..3d89631 2480--- /dev/null 2481+++ b/src/app/state/room/roomToThreadBrowser.ts 2482@@ -0,0 +1,13 @@ 2483+import { atom } from 'jotai'; 2484+import { atomFamily } from 'jotai/utils'; 2485+ 2486+const createThreadBrowserAtom = () => atom<boolean>(false); 2487+export type TThreadBrowserAtom = ReturnType<typeof createThreadBrowserAtom>; 2488+ 2489+/** 2490+ * Tracks whether the thread browser panel is open per room. 2491+ * Key: roomId 2492+ */ 2493+export const roomIdToThreadBrowserAtomFamily = atomFamily<string, TThreadBrowserAtom>(() => 2494+ createThreadBrowserAtom() 2495+); 2496diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts 2497index 4c86c4e..56d4851 100644 2498--- a/src/app/utils/matrix.ts 2499+++ b/src/app/utils/matrix.ts 2500@@ -5,6 +5,7 @@ import { 2501 } from 'browser-encrypt-attachment'; 2502 import { 2503 EventTimeline, 2504+ EventTimelineSet, 2505 MatrixClient, 2506 MatrixError, 2507 MatrixEvent, 2508@@ -16,8 +17,8 @@ import { 2509 import to from 'await-to-js'; 2510 import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common'; 2511 import { AccountDataEvent } from '../../types/matrix/accountData'; 2512-import { getStateEvent } from './room'; 2513-import { Membership, StateEvent } from '../../types/matrix/room'; 2514+import { getEventReactions, getReactionContent, getStateEvent } from './room'; 2515+import { Membership, MessageEvent, StateEvent } from '../../types/matrix/room'; 2516 2517 const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; 2518 2519@@ -372,3 +373,40 @@ export const creatorsSupported = (version: string): boolean => { 2520 const unsupportedVersion = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11']; 2521 return !unsupportedVersion.includes(version); 2522 }; 2523+ 2524+export const toggleReaction = ( 2525+ mx: MatrixClient, 2526+ room: Room, 2527+ targetEventId: string, 2528+ key: string, 2529+ shortcode?: string, 2530+ timelineSet?: EventTimelineSet 2531+) => { 2532+ const relations = getEventReactions( 2533+ timelineSet ?? room.getUnfilteredTimelineSet(), 2534+ targetEventId 2535+ ); 2536+ const allReactions = relations?.getSortedAnnotationsByKey() ?? []; 2537+ const [, reactionsSet] = allReactions.find(([reactionKey]) => reactionKey === key) ?? []; 2538+ const reactions: MatrixEvent[] = reactionsSet ? Array.from(reactionsSet) : []; 2539+ const userId = mx.getUserId(); 2540+ if (!userId) { 2541+ return; 2542+ } 2543+ const myReaction = reactions.find(factoryEventSentBy(userId)); 2544+ 2545+ const myReactionId = myReaction?.getId(); 2546+ if (myReaction && myReactionId && myReaction.isRelation()) { 2547+ mx.redactEvent(room.roomId, myReactionId); 2548+ return; 2549+ } 2550+ 2551+ const resolvedShortcode = 2552+ shortcode || (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined); 2553+ 2554+ mx.sendEvent( 2555+ room.roomId, 2556+ MessageEvent.Reaction as any, 2557+ getReactionContent(targetEventId, key, resolvedShortcode) 2558+ ); 2559+}; 2560diff --git a/src/app/utils/notifications.ts b/src/app/utils/notifications.ts 2561index a23bd1a..4ba2d2b 100644 2562--- a/src/app/utils/notifications.ts 2563+++ b/src/app/utils/notifications.ts 2564@@ -5,22 +5,28 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip 2565 if (!room) return; 2566 2567 const timeline = room.getLiveTimeline().getEvents(); 2568- const readEventId = room.getEventReadUpTo(mx.getUserId()!); 2569+ const userId = mx.getUserId(); 2570+ if (!userId) return; 2571 2572- const getLatestValidEvent = () => { 2573- for (let i = timeline.length - 1; i >= 0; i -= 1) { 2574- const latestEvent = timeline[i]; 2575- if (latestEvent.getId() === readEventId) return null; 2576- if (!latestEvent.isSending()) return latestEvent; 2577+ // Latest non-pending event in the room, thread replies included. 2578+ let latestEvent = null; 2579+ for (let i = timeline.length - 1; i >= 0; i -= 1) { 2580+ if (!timeline[i].isSending()) { 2581+ latestEvent = timeline[i]; 2582+ break; 2583 } 2584- return null; 2585- }; 2586- if (timeline.length === 0) return; 2587- const latestEvent = getLatestValidEvent(); 2588- if (latestEvent === null) return; 2589+ } 2590+ if (!latestEvent) return; 2591 2592+ // Send an UNTHREADED receipt. A room's unread in cinny is the main-timeline 2593+ // count plus the sum of every thread's count (Room.getUnreadNotificationCount), 2594+ // so a plain main/threaded receipt leaves the room marked unread whenever any 2595+ // thread still has unread notifications - the room stays bold even after 2596+ // "Mark as Read". An unthreaded receipt clears the main timeline and every 2597+ // thread's unread bucket in one shot. 2598 await mx.sendReadReceipt( 2599 latestEvent, 2600- privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read 2601+ privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read, 2602+ true 2603 ); 2604 } 2605diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts 2606index 05dfeb2..a29d52b 100644 2607--- a/src/app/utils/room.ts 2608+++ b/src/app/utils/room.ts 2609@@ -203,6 +203,7 @@ export const isNotificationEvent = (mEvent: MatrixEvent) => { 2610 2611 if (mEvent.isRedacted()) return false; 2612 if (mEvent.getRelation()?.rel_type === 'm.replace') return false; 2613+ if (mEvent.isRelation(RelationType.Thread)) return false; 2614 2615 return true; 2616 }; 2617diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts 2618index 498d4f7..f391945 100644 2619--- a/src/client/initMatrix.ts 2620+++ b/src/client/initMatrix.ts 2621@@ -43,6 +43,7 @@ export const initClient = async (session: Session): Promise<MatrixClient> => { 2622 export const startClient = async (mx: MatrixClient) => { 2623 await mx.startClient({ 2624 lazyLoadMembers: true, 2625+ threadSupport: true, 2626 }); 2627 }; 2628