This repository has no description
0

Configure Feed

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

superconfig / 2configs / services / matrix / cinny-threads-ui.patch
95 kB 2577 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..0ed8efd 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,106 @@ 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+ 1088+ return () => { 1089+ room.removeListener(ThreadEvent.New, updateThreadCounts as any); 1090+ room.removeListener(ThreadEvent.Update, updateThreadCounts as any); 1091+ room.removeListener(ThreadEvent.NewReply, updateThreadCounts as any); 1092+ }; 1093+ }, [room]); 1094 1095 const handleSearchClick = () => { 1096 const searchParams: _SearchPathSearchParams = { 1097@@ -442,6 +550,10 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { 1098 openSettings(room.roomId, parentSpace?.roomId, RoomSettingsPage.MembersPage); 1099 return; 1100 } 1101+ if (!peopleDrawer) { 1102+ setOpenThread(undefined); 1103+ setThreadBrowserOpen(false); 1104+ } 1105 setPeopleDrawer(!peopleDrawer); 1106 }; 1107 1108@@ -597,6 +709,56 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { 1109 {!room.isCallRoom() && livekitSupported && rtcSupported && hasCallPermission && ( 1110 <CallButton /> 1111 )} 1112+ <TooltipProvider 1113+ position="Bottom" 1114+ offset={4} 1115+ tooltip={ 1116+ <Tooltip> 1117+ <Text>Threads</Text> 1118+ </Tooltip> 1119+ } 1120+ > 1121+ {(triggerRef) => ( 1122+ <IconButton 1123+ fill="None" 1124+ ref={triggerRef} 1125+ onClick={() => { 1126+ if (openThreadId) { 1127+ setOpenThread(undefined); 1128+ setThreadBrowserOpen(true); 1129+ return; 1130+ } 1131+ 1132+ if (!threadBrowserOpen) { 1133+ setPeopleDrawer(false); 1134+ } 1135+ setThreadBrowserOpen(!threadBrowserOpen); 1136+ }} 1137+ aria-pressed={threadBrowserOpen || !!openThreadId} 1138+ style={{ position: 'relative' }} 1139+ > 1140+ {unreadThreadsCount > 0 && ( 1141+ <Badge 1142+ style={{ 1143+ position: 'absolute', 1144+ left: toRem(3), 1145+ top: toRem(3), 1146+ }} 1147+ variant={hasThreadHighlights ? 'Critical' : 'Secondary'} 1148+ size="400" 1149+ fill="Solid" 1150+ radii="Pill" 1151+ > 1152+ <Text as="span" size="L400"> 1153+ {unreadThreadsCount} 1154+ </Text> 1155+ </Badge> 1156+ )} 1157+ <Icon size="400" src={Icons.Thread} filled={threadBrowserOpen || !!openThreadId} /> 1158+ </IconButton> 1159+ )} 1160+ </TooltipProvider> 1161+ 1162 {screenSize === ScreenSize.Desktop && ( 1163 <TooltipProvider 1164 position="Bottom" 1165@@ -612,8 +774,17 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { 1166 } 1167 > 1168 {(triggerRef) => ( 1169- <IconButton fill="None" ref={triggerRef} onClick={handleMemberToggle}> 1170- <Icon size="400" src={Icons.User} /> 1171+ <IconButton 1172+ fill="None" 1173+ ref={triggerRef} 1174+ onClick={handleMemberToggle} 1175+ aria-pressed={peopleDrawer && !openThreadId && !threadBrowserOpen} 1176+ > 1177+ <Icon 1178+ size="400" 1179+ src={Icons.User} 1180+ filled={peopleDrawer && !openThreadId && !threadBrowserOpen} 1181+ /> 1182 </IconButton> 1183 )} 1184 </TooltipProvider> 1185diff --git a/src/app/features/room/ThreadBrowser.tsx b/src/app/features/room/ThreadBrowser.tsx 1186new file mode 100644 1187index 0000000..8deb54a 1188--- /dev/null 1189+++ b/src/app/features/room/ThreadBrowser.tsx 1190@@ -0,0 +1,372 @@ 1191+import React, { 1192+ ChangeEventHandler, 1193+ MouseEventHandler, 1194+ useCallback, 1195+ useEffect, 1196+ useMemo, 1197+ useRef, 1198+ useState, 1199+} from 'react'; 1200+import { 1201+ Box, 1202+ Header, 1203+ Icon, 1204+ IconButton, 1205+ Icons, 1206+ Input, 1207+ Scroll, 1208+ Text, 1209+ Avatar, 1210+ config, 1211+ Chip, 1212+} from 'folds'; 1213+import { MatrixEvent, Room } from 'matrix-js-sdk'; 1214+import { Thread, ThreadEvent } from 'matrix-js-sdk/lib/models/thread'; 1215+import { HTMLReactParserOptions } from 'html-react-parser'; 1216+import { Opts as LinkifyOpts } from 'linkifyjs'; 1217+import { useMatrixClient } from '../../hooks/useMatrixClient'; 1218+import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; 1219+import { useRoomNavigate } from '../../hooks/useRoomNavigate'; 1220+import { getMemberAvatarMxc, getMemberDisplayName, reactionOrEditEvent } from '../../utils/room'; 1221+import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; 1222+import { UserAvatar } from '../../components/user-avatar'; 1223+import { 1224+ AvatarBase, 1225+ ModernLayout, 1226+ RedactedContent, 1227+ Time, 1228+ Username, 1229+ UsernameBold, 1230+ Reply, 1231+} from '../../components/message'; 1232+import { RenderMessageContent } from '../../components/RenderMessageContent'; 1233+import { settingsAtom } from '../../state/settings'; 1234+import { useSetting } from '../../state/hooks/settings'; 1235+import { GetContentCallback } from '../../../types/matrix/room'; 1236+import { useMentionClickHandler } from '../../hooks/useMentionClickHandler'; 1237+import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; 1238+import { 1239+ factoryRenderLinkifyWithMention, 1240+ getReactCustomHtmlParser, 1241+ LINKIFY_OPTS, 1242+ makeMentionCustomProps, 1243+ renderMatrixMention, 1244+} from '../../plugins/react-custom-html-parser'; 1245+import { EncryptedContent } from './message'; 1246+import * as css from './ThreadDrawer.css'; 1247+ 1248+type ThreadPreviewProps = { 1249+ room: Room; 1250+ thread: Thread; 1251+ onClick: (threadId: string) => void; 1252+}; 1253+ 1254+function ThreadPreview({ room, thread, onClick }: ThreadPreviewProps) { 1255+ const mx = useMatrixClient(); 1256+ const useAuthentication = useMediaAuthentication(); 1257+ const { navigateRoom } = useRoomNavigate(); 1258+ const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); 1259+ const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); 1260+ const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); 1261+ const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); 1262+ const mentionClickHandler = useMentionClickHandler(room.roomId); 1263+ const spoilerClickHandler = useSpoilerClickHandler(); 1264+ 1265+ const linkifyOpts = useMemo<LinkifyOpts>( 1266+ () => ({ 1267+ ...LINKIFY_OPTS, 1268+ render: factoryRenderLinkifyWithMention((href: string) => 1269+ renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)) 1270+ ), 1271+ }), 1272+ [mx, room.roomId, mentionClickHandler] 1273+ ); 1274+ 1275+ const htmlReactParserOptions = useMemo<HTMLReactParserOptions>( 1276+ () => 1277+ getReactCustomHtmlParser(mx, room.roomId, { 1278+ linkifyOpts, 1279+ handleSpoilerClick: spoilerClickHandler, 1280+ handleMentionClick: mentionClickHandler, 1281+ useAuthentication, 1282+ }), 1283+ [mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication] 1284+ ); 1285+ 1286+ const handleJumpClick: MouseEventHandler = useCallback( 1287+ (evt) => { 1288+ evt.stopPropagation(); 1289+ onClick(thread.id); 1290+ navigateRoom(room.roomId, thread.id); 1291+ }, 1292+ [onClick, navigateRoom, room.roomId, thread.id] 1293+ ); 1294+ 1295+ const { rootEvent } = thread; 1296+ if (!rootEvent) return null; 1297+ 1298+ const senderId = rootEvent.getSender() ?? ''; 1299+ const displayName = 1300+ getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; 1301+ const senderAvatarMxc = getMemberAvatarMxc(room, senderId); 1302+ const getContent = (() => rootEvent.getContent()) as GetContentCallback; 1303+ 1304+ const replyCount = thread.events.filter( 1305+ (ev: MatrixEvent) => ev.getId() !== thread.id && !reactionOrEditEvent(ev) 1306+ ).length; 1307+ 1308+ const lastReply = thread.events 1309+ .filter((ev: MatrixEvent) => ev.getId() !== thread.id && !reactionOrEditEvent(ev)) 1310+ .at(-1); 1311+ const lastSenderId = lastReply?.getSender() ?? ''; 1312+ const lastDisplayName = 1313+ getMemberDisplayName(room, lastSenderId) ?? getMxIdLocalPart(lastSenderId) ?? lastSenderId; 1314+ const lastContent = lastReply?.getContent(); 1315+ const lastBody: string = typeof lastContent?.body === 'string' ? lastContent.body : ''; 1316+ 1317+ return ( 1318+ <Box 1319+ as="button" 1320+ direction="Column" 1321+ gap="100" 1322+ className={css.ThreadBrowserItem} 1323+ onClick={() => onClick(thread.id)} 1324+ > 1325+ <ModernLayout 1326+ before={ 1327+ <AvatarBase> 1328+ <Avatar size="300"> 1329+ <UserAvatar 1330+ userId={senderId} 1331+ src={ 1332+ senderAvatarMxc 1333+ ? mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? 1334+ undefined 1335+ : undefined 1336+ } 1337+ alt={displayName} 1338+ renderFallback={() => <Icon size="200" src={Icons.User} filled />} 1339+ /> 1340+ </Avatar> 1341+ </AvatarBase> 1342+ } 1343+ > 1344+ <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes"> 1345+ <Box gap="200" alignItems="Baseline"> 1346+ <Username> 1347+ <Text as="span" truncate> 1348+ <UsernameBold>{displayName}</UsernameBold> 1349+ </Text> 1350+ </Username> 1351+ <Time 1352+ ts={rootEvent.getTs()} 1353+ hour24Clock={hour24Clock} 1354+ dateFormatString={dateFormatString} 1355+ /> 1356+ </Box> 1357+ <Box shrink="No"> 1358+ <Chip data-event-id={thread.id} onClick={handleJumpClick} radii="Pill"> 1359+ <Text size="T200">Jump</Text> 1360+ </Chip> 1361+ </Box> 1362+ </Box> 1363+ {rootEvent.replyEventId && ( 1364+ <Reply 1365+ room={room} 1366+ replyEventId={rootEvent.replyEventId} 1367+ threadRootId={rootEvent.threadRootId} 1368+ onClick={handleJumpClick} 1369+ /> 1370+ )} 1371+ <Box 1372+ direction="Column" 1373+ style={{ 1374+ maxHeight: '200px', 1375+ overflow: 'auto', 1376+ flexShrink: 0, 1377+ }} 1378+ > 1379+ <EncryptedContent mEvent={rootEvent}> 1380+ {() => { 1381+ if (rootEvent.isRedacted()) { 1382+ return <RedactedContent />; 1383+ } 1384+ 1385+ return ( 1386+ <RenderMessageContent 1387+ displayName={displayName} 1388+ msgType={rootEvent.getContent().msgtype ?? ''} 1389+ ts={rootEvent.getTs()} 1390+ getContent={getContent} 1391+ edited={!!rootEvent.replacingEvent()} 1392+ mediaAutoLoad={mediaAutoLoad} 1393+ urlPreview={urlPreview} 1394+ htmlReactParserOptions={htmlReactParserOptions} 1395+ linkifyOpts={linkifyOpts} 1396+ outlineAttachment 1397+ /> 1398+ ); 1399+ }} 1400+ </EncryptedContent> 1401+ </Box> 1402+ {replyCount > 0 && ( 1403+ <Box gap="100" alignItems="Center" style={{ marginTop: config.space.S200 }}> 1404+ <Text size="T200" priority="300" style={{ flexShrink: 0 }}> 1405+ {replyCount} {replyCount === 1 ? 'reply' : 'replies'} 1406+ </Text> 1407+ {lastReply && lastBody && ( 1408+ <Text 1409+ size="T200" 1410+ priority="300" 1411+ style={{ 1412+ overflow: 'hidden', 1413+ textOverflow: 'ellipsis', 1414+ whiteSpace: 'nowrap', 1415+ minWidth: 0, 1416+ }} 1417+ > 1418+ · {lastDisplayName}: {lastBody.slice(0, 60)} 1419+ </Text> 1420+ )} 1421+ </Box> 1422+ )} 1423+ </ModernLayout> 1424+ </Box> 1425+ ); 1426+} 1427+ 1428+type ThreadBrowserProps = { 1429+ room: Room; 1430+ onOpenThread: (threadId: string) => void; 1431+ onClose: () => void; 1432+ overlay?: boolean; 1433+}; 1434+ 1435+export function ThreadBrowser({ room, onOpenThread, onClose, overlay }: ThreadBrowserProps) { 1436+ const [, forceUpdate] = useState(0); 1437+ const [query, setQuery] = useState(''); 1438+ const searchRef = useRef<HTMLInputElement>(null); 1439+ 1440+ // Re-render when threads change. 1441+ useEffect(() => { 1442+ const onUpdate = () => forceUpdate((n) => n + 1); 1443+ room.on(ThreadEvent.New as any, onUpdate); 1444+ room.on(ThreadEvent.Update as any, onUpdate); 1445+ room.on(ThreadEvent.NewReply as any, onUpdate); 1446+ return () => { 1447+ room.off(ThreadEvent.New as any, onUpdate); 1448+ room.off(ThreadEvent.Update as any, onUpdate); 1449+ room.off(ThreadEvent.NewReply as any, onUpdate); 1450+ }; 1451+ }, [room]); 1452+ 1453+ const allThreads = room.getThreads().sort((a: Thread, b: Thread) => { 1454+ const aTs = a.events.at(-1)?.getTs() ?? a.rootEvent?.getTs() ?? 0; 1455+ const bTs = b.events.at(-1)?.getTs() ?? b.rootEvent?.getTs() ?? 0; 1456+ return bTs - aTs; 1457+ }); 1458+ 1459+ const lowerQuery = query.trim().toLowerCase(); 1460+ const threads = lowerQuery 1461+ ? allThreads.filter((t: Thread) => { 1462+ const body = t.rootEvent?.getContent()?.body ?? ''; 1463+ return typeof body === 'string' && body.toLowerCase().includes(lowerQuery); 1464+ }) 1465+ : allThreads; 1466+ 1467+ const handleSearchChange: ChangeEventHandler<HTMLInputElement> = (e) => { 1468+ setQuery(e.target.value); 1469+ }; 1470+ 1471+ return ( 1472+ <Box 1473+ className={overlay ? css.ThreadDrawerOverlay : css.ThreadDrawer} 1474+ direction="Column" 1475+ shrink="No" 1476+ > 1477+ <Header className={css.ThreadDrawerHeader} variant="Background" size="600"> 1478+ <Box grow="Yes" alignItems="Center" gap="200"> 1479+ <Icon size="200" src={Icons.Thread} /> 1480+ <Text size="H5" truncate> 1481+ Threads 1482+ </Text> 1483+ </Box> 1484+ <Box alignItems="Center" gap="200" shrink="No"> 1485+ <Text size="T300" priority="300" truncate> 1486+ # {room.name} 1487+ </Text> 1488+ <IconButton onClick={onClose} variant="Background" aria-label="Close threads"> 1489+ <Icon src={Icons.Cross} /> 1490+ </IconButton> 1491+ </Box> 1492+ </Header> 1493+ 1494+ <Box 1495+ direction="Column" 1496+ gap="100" 1497+ style={{ padding: `${config.space.S200} ${config.space.S300}` }} 1498+ shrink="No" 1499+ > 1500+ <Input 1501+ ref={searchRef} 1502+ value={query} 1503+ onChange={handleSearchChange} 1504+ placeholder="Search threads..." 1505+ variant="Surface" 1506+ size="400" 1507+ radii="400" 1508+ before={<Icon size="50" src={Icons.Search} />} 1509+ after={ 1510+ query ? ( 1511+ <IconButton 1512+ size="300" 1513+ radii="300" 1514+ variant="SurfaceVariant" 1515+ onClick={() => { 1516+ setQuery(''); 1517+ searchRef.current?.focus(); 1518+ }} 1519+ aria-label="Clear search" 1520+ > 1521+ <Icon size="50" src={Icons.Cross} /> 1522+ </IconButton> 1523+ ) : undefined 1524+ } 1525+ /> 1526+ </Box> 1527+ 1528+ <Box className={css.ThreadDrawerContent} grow="Yes" direction="Column"> 1529+ <Scroll 1530+ variant="Background" 1531+ visibility="Hover" 1532+ direction="Vertical" 1533+ hideTrack 1534+ style={{ flexGrow: 1 }} 1535+ > 1536+ {threads.length === 0 ? ( 1537+ <Box 1538+ direction="Column" 1539+ alignItems="Center" 1540+ justifyContent="Center" 1541+ style={{ padding: config.space.S400, gap: config.space.S200 }} 1542+ > 1543+ <Icon size="400" src={Icons.Thread} /> 1544+ <Text size="T300" align="Center"> 1545+ {lowerQuery ? 'No threads match your search.' : 'No threads yet.'} 1546+ </Text> 1547+ </Box> 1548+ ) : ( 1549+ <Box 1550+ direction="Column" 1551+ style={{ padding: `${config.space.S100} ${config.space.S200}` }} 1552+ > 1553+ {threads.map((thread: Thread) => ( 1554+ <ThreadPreview key={thread.id} room={room} thread={thread} onClick={onOpenThread} /> 1555+ ))} 1556+ </Box> 1557+ )} 1558+ </Scroll> 1559+ </Box> 1560+ </Box> 1561+ ); 1562+} 1563diff --git a/src/app/features/room/ThreadDrawer.css.ts b/src/app/features/room/ThreadDrawer.css.ts 1564new file mode 100644 1565index 0000000..b579e74 1566--- /dev/null 1567+++ b/src/app/features/room/ThreadDrawer.css.ts 1568@@ -0,0 +1,67 @@ 1569+import { style, globalStyle } from '@vanilla-extract/css'; 1570+import { config, color, toRem } from 'folds'; 1571+ 1572+export const ThreadDrawer = style({ 1573+ width: toRem(490), 1574+ height: '100%', 1575+ display: 'flex', 1576+ flexDirection: 'column', 1577+ overflow: 'hidden', 1578+}); 1579+ 1580+export const messageList = style({ 1581+ display: 'flex', 1582+ flexDirection: 'column', 1583+ width: '100%', 1584+}); 1585+ 1586+globalStyle(`body ${messageList} [data-message-id]`, { 1587+ transition: 'background-color 0.1s ease-in-out !important', 1588+}); 1589+ 1590+globalStyle(`body ${messageList} [data-message-id]:hover`, { 1591+ backgroundColor: `${color.Background.ContainerHover} !important`, 1592+}); 1593+ 1594+export const ThreadDrawerHeader = style({ 1595+ flexShrink: 0, 1596+ padding: `0 ${config.space.S200} 0 ${config.space.S300}`, 1597+ borderBottomWidth: config.borderWidth.B300, 1598+}); 1599+ 1600+export const ThreadDrawerContent = style({ 1601+ position: 'relative', 1602+ overflow: 'hidden', 1603+ flexGrow: 1, 1604+ minHeight: 0, // Ensure flex child can shrink below content size 1605+}); 1606+ 1607+export const ThreadDrawerInput = style({ 1608+ flexShrink: 0, 1609+}); 1610+ 1611+export const ThreadDrawerOverlay = style({ 1612+ position: 'absolute', 1613+ inset: 0, 1614+ zIndex: 10, 1615+ width: '100%', 1616+ display: 'flex', 1617+ flexDirection: 'column', 1618+ overflow: 'hidden', 1619+ backgroundColor: color.Background.Container, 1620+}); 1621+ 1622+export const ThreadBrowserItem = style({ 1623+ width: '100%', 1624+ padding: `${config.space.S200} ${config.space.S100}`, 1625+ borderRadius: config.radii.R300, 1626+ textAlign: 'left', 1627+ cursor: 'pointer', 1628+ background: 'none', 1629+ border: 'none', 1630+ color: 'inherit', 1631+ transition: 'background-color 0.1s ease-in-out', 1632+ ':hover': { 1633+ backgroundColor: color.Background.ContainerHover, 1634+ }, 1635+}); 1636diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx 1637new file mode 100644 1638index 0000000..9de30c5 1639--- /dev/null 1640+++ b/src/app/features/room/ThreadDrawer.tsx 1641@@ -0,0 +1,724 @@ 1642+import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; 1643+import { Box, Header, Icon, IconButton, Icons, Scroll, Text, config } from 'folds'; 1644+import { RelationType } from 'matrix-js-sdk/lib/@types/event'; 1645+import { ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts'; 1646+import { MatrixEvent } from 'matrix-js-sdk/lib/models/event'; 1647+import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk/lib/models/room'; 1648+import { ThreadEvent } from 'matrix-js-sdk/lib/models/thread'; 1649+import { useAtomValue, useSetAtom } from 'jotai'; 1650+import { ReactEditor } from 'slate-react'; 1651+import { HTMLReactParserOptions } from 'html-react-parser'; 1652+import { Opts as LinkifyOpts } from 'linkifyjs'; 1653+import { ImageContent, MSticker, RedactedContent, Reply } from '../../components/message'; 1654+import { RenderMessageContent } from '../../components/RenderMessageContent'; 1655+import { Image } from '../../components/media'; 1656+import { ImageViewer } from '../../components/image-viewer'; 1657+import { 1658+ factoryRenderLinkifyWithMention, 1659+ getReactCustomHtmlParser, 1660+ LINKIFY_OPTS, 1661+ makeMentionCustomProps, 1662+ renderMatrixMention, 1663+} from '../../plugins/react-custom-html-parser'; 1664+import { 1665+ getEditedEvent, 1666+ getEventReactions, 1667+ getMemberDisplayName, 1668+ reactionOrEditEvent, 1669+} from '../../utils/room'; 1670+import { getMxIdLocalPart, toggleReaction } from '../../utils/matrix'; 1671+import { minuteDifference } from '../../utils/time'; 1672+import { useMatrixClient } from '../../hooks/useMatrixClient'; 1673+import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; 1674+import { MessageLayout, MessageSpacing, settingsAtom } from '../../state/settings'; 1675+import { useSetting } from '../../state/hooks/settings'; 1676+import { createMentionElement, moveCursor, useEditor } from '../../components/editor'; 1677+import { useMentionClickHandler } from '../../hooks/useMentionClickHandler'; 1678+import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; 1679+import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room'; 1680+import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; 1681+import { useRoomPermissions } from '../../hooks/useRoomPermissions'; 1682+import { useRoomCreators } from '../../hooks/useRoomCreators'; 1683+import { useImagePackRooms } from '../../hooks/useImagePackRooms'; 1684+import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; 1685+import { IReplyDraft, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts'; 1686+import { roomToParentsAtom } from '../../state/room/roomToParents'; 1687+import { EncryptedContent, Message, Reactions } from './message'; 1688+import { RoomInput } from './RoomInput'; 1689+import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; 1690+import * as css from './ThreadDrawer.css'; 1691+ 1692+type ThreadMessageProps = { 1693+ room: Room; 1694+ mEvent: MatrixEvent; 1695+ threadRootId: string; 1696+ editId: string | undefined; 1697+ onEditId: (id?: string) => void; 1698+ messageLayout: MessageLayout; 1699+ messageSpacing: MessageSpacing; 1700+ canDelete: boolean; 1701+ canSendReaction: boolean; 1702+ canPinEvent: boolean; 1703+ imagePackRooms: Room[]; 1704+ hour24Clock: boolean; 1705+ dateFormatString: string; 1706+ onUserClick: MouseEventHandler<HTMLButtonElement>; 1707+ onUsernameClick: MouseEventHandler<HTMLButtonElement>; 1708+ onReplyClick: MouseEventHandler<HTMLButtonElement>; 1709+ onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; 1710+ linkifyOpts: LinkifyOpts; 1711+ htmlReactParserOptions: HTMLReactParserOptions; 1712+ showHideReads: boolean; 1713+ showDeveloperTools: boolean; 1714+ onReferenceClick: MouseEventHandler<HTMLButtonElement>; 1715+ jumpToEventId?: string; 1716+ collapse?: boolean; 1717+}; 1718+ 1719+function ThreadMessage({ 1720+ room, 1721+ threadRootId: threadRootIdProp, 1722+ mEvent, 1723+ editId, 1724+ onEditId, 1725+ messageLayout, 1726+ messageSpacing, 1727+ canDelete, 1728+ canSendReaction, 1729+ collapse = false, 1730+ canPinEvent, 1731+ imagePackRooms, 1732+ hour24Clock, 1733+ dateFormatString, 1734+ onUserClick, 1735+ onUsernameClick, 1736+ onReplyClick, 1737+ onReactionToggle, 1738+ linkifyOpts, 1739+ htmlReactParserOptions, 1740+ showHideReads, 1741+ showDeveloperTools, 1742+ onReferenceClick, 1743+ jumpToEventId, 1744+}: ThreadMessageProps) { 1745+ // Use the thread's own timeline set so reactions/edits on thread events are found correctly 1746+ const threadTimelineSet = room.getThread(threadRootIdProp)?.timelineSet; 1747+ const timelineSet = threadTimelineSet ?? room.getUnfilteredTimelineSet(); 1748+ const mEventId = mEvent.getId()!; 1749+ const senderId = mEvent.getSender() ?? ''; 1750+ const senderDisplayName = 1751+ getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; 1752+ 1753+ const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); 1754+ const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); 1755+ const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); 1756+ const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; 1757+ 1758+ const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); 1759+ const editedNewContent = editedEvent?.getContent()['m.new_content']; 1760+ const baseContent = mEvent.getContent(); 1761+ const safeContent = 1762+ Object.keys(baseContent).length > 0 ? baseContent : mEvent.getOriginalContent(); 1763+ const getContent = (() => editedNewContent ?? safeContent) as GetContentCallback; 1764+ 1765+ const reactionRelations = getEventReactions(timelineSet, mEventId); 1766+ const reactions = reactionRelations?.getSortedAnnotationsByKey(); 1767+ const hasReactions = reactions && reactions.length > 0; 1768+ 1769+ const { replyEventId } = mEvent; 1770+ 1771+ return ( 1772+ <Message 1773+ key={mEvent.getId()} 1774+ data-message-id={mEventId} 1775+ room={room} 1776+ mEvent={mEvent} 1777+ messageSpacing={messageSpacing} 1778+ messageLayout={messageLayout} 1779+ collapse={collapse} 1780+ highlight={jumpToEventId === mEventId} 1781+ edit={editId === mEventId} 1782+ canDelete={canDelete} 1783+ canSendReaction={canSendReaction} 1784+ canPinEvent={canPinEvent} 1785+ imagePackRooms={imagePackRooms} 1786+ relations={hasReactions ? reactionRelations : undefined} 1787+ onUserClick={onUserClick} 1788+ onUsernameClick={onUsernameClick} 1789+ onReplyClick={onReplyClick} 1790+ onReactionToggle={onReactionToggle} 1791+ onEditId={onEditId} 1792+ hour24Clock={hour24Clock} 1793+ dateFormatString={dateFormatString} 1794+ hideReadReceipts={showHideReads} 1795+ hideThreadButton 1796+ showDeveloperTools={showDeveloperTools} 1797+ reply={ 1798+ replyEventId && 1799+ replyEventId !== threadRootIdProp && ( 1800+ <Reply 1801+ room={room} 1802+ timelineSet={timelineSet} 1803+ replyEventId={replyEventId} 1804+ onClick={onReferenceClick} 1805+ /> 1806+ ) 1807+ } 1808+ reactions={ 1809+ hasReactions && reactionRelations ? ( 1810+ <Reactions 1811+ style={{ marginTop: config.space.S200 }} 1812+ room={room} 1813+ relations={reactionRelations} 1814+ mEventId={mEventId} 1815+ canSendReaction={canSendReaction} 1816+ onReactionToggle={onReactionToggle} 1817+ /> 1818+ ) : undefined 1819+ } 1820+ > 1821+ {mEvent.isRedacted() ? ( 1822+ <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} /> 1823+ ) : ( 1824+ <EncryptedContent mEvent={mEvent}> 1825+ {() => { 1826+ if (mEvent.isRedacted()) 1827+ return ( 1828+ <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} /> 1829+ ); 1830+ 1831+ if (mEvent.getType() === MessageEvent.Sticker) 1832+ return ( 1833+ <MSticker 1834+ content={mEvent.getContent()} 1835+ renderImageContent={(props) => ( 1836+ <ImageContent 1837+ {...props} 1838+ autoPlay={mediaAutoLoad} 1839+ renderImage={(p) => <Image {...p} loading="lazy" />} 1840+ renderViewer={(p) => <ImageViewer {...p} />} 1841+ /> 1842+ )} 1843+ /> 1844+ ); 1845+ 1846+ if (mEvent.getType() === MessageEvent.RoomMessage) { 1847+ return ( 1848+ <RenderMessageContent 1849+ displayName={senderDisplayName} 1850+ msgType={(editedNewContent ?? safeContent).msgtype ?? ''} 1851+ ts={mEvent.getTs()} 1852+ edited={!!editedEvent} 1853+ getContent={getContent} 1854+ mediaAutoLoad={mediaAutoLoad} 1855+ urlPreview={showUrlPreview} 1856+ htmlReactParserOptions={htmlReactParserOptions} 1857+ linkifyOpts={linkifyOpts} 1858+ outlineAttachment={messageLayout === MessageLayout.Bubble} 1859+ /> 1860+ ); 1861+ } 1862+ 1863+ return ( 1864+ <RenderMessageContent 1865+ displayName={senderDisplayName} 1866+ msgType={(editedNewContent ?? safeContent).msgtype ?? ''} 1867+ ts={mEvent.getTs()} 1868+ edited={!!editedEvent} 1869+ getContent={getContent} 1870+ mediaAutoLoad={mediaAutoLoad} 1871+ urlPreview={showUrlPreview} 1872+ htmlReactParserOptions={htmlReactParserOptions} 1873+ linkifyOpts={linkifyOpts} 1874+ outlineAttachment={messageLayout === MessageLayout.Bubble} 1875+ /> 1876+ ); 1877+ }} 1878+ </EncryptedContent> 1879+ )} 1880+ </Message> 1881+ ); 1882+} 1883+ 1884+type ThreadDrawerProps = { 1885+ room: Room; 1886+ threadRootId: string; 1887+ onClose: () => void; 1888+ overlay?: boolean; 1889+}; 1890+ 1891+export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDrawerProps) { 1892+ const mx = useMatrixClient(); 1893+ const drawerRef = useRef<HTMLDivElement>(null); 1894+ const editor = useEditor(); 1895+ const [, forceUpdate] = useState(0); 1896+ const [editId, setEditId] = useState<string | undefined>(undefined); 1897+ const [jumpToEventId, setJumpToEventId] = useState<string | undefined>(undefined); 1898+ const scrollRef = useRef<HTMLDivElement>(null); 1899+ const prevReplyCountRef = useRef(0); 1900+ const replyEventsRef = useRef<MatrixEvent[]>([]); 1901+ const useAuthentication = useMediaAuthentication(); 1902+ const mentionClickHandler = useMentionClickHandler(room.roomId); 1903+ const spoilerClickHandler = useSpoilerClickHandler(); 1904+ 1905+ // Settings 1906+ const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); 1907+ const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); 1908+ const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); 1909+ const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); 1910+ const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); 1911+ const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); 1912+ 1913+ // Memoized parsing options 1914+ const linkifyOpts = useMemo<LinkifyOpts>( 1915+ () => ({ 1916+ ...LINKIFY_OPTS, 1917+ render: factoryRenderLinkifyWithMention((href) => 1918+ renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)) 1919+ ), 1920+ }), 1921+ [mx, room, mentionClickHandler] 1922+ ); 1923+ 1924+ const htmlReactParserOptions = useMemo<HTMLReactParserOptions>( 1925+ () => 1926+ getReactCustomHtmlParser(mx, room.roomId, { 1927+ linkifyOpts, 1928+ useAuthentication, 1929+ handleSpoilerClick: spoilerClickHandler, 1930+ handleMentionClick: mentionClickHandler, 1931+ }), 1932+ [mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication] 1933+ ); 1934+ 1935+ // Power levels & permissions 1936+ const powerLevels = usePowerLevelsContext(); 1937+ const creators = useRoomCreators(room); 1938+ const permissions = useRoomPermissions(creators, powerLevels); 1939+ const canRedact = permissions.action('redact', mx.getSafeUserId()); 1940+ const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId()); 1941+ const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId()); 1942+ const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId()); 1943+ 1944+ // Image packs 1945+ const roomToParents = useAtomValue(roomToParentsAtom); 1946+ const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents); 1947+ 1948+ // Reply draft (keyed by threadRootId to match RoomInput's draftKey logic) 1949+ const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(threadRootId)); 1950+ const replyDraft = useAtomValue(roomIdToReplyDraftAtomFamily(threadRootId)); 1951+ const activeReplyId = replyDraft?.eventId; 1952+ 1953+ // User profile popup 1954+ const openUserRoomProfile = useOpenUserRoomProfile(); 1955+ 1956+ const rootEvent = room.findEventById(threadRootId); 1957+ 1958+ // Re-render when new thread events arrive (including reactions via ThreadEvent.Update). 1959+ useEffect(() => { 1960+ const isEventInThread = (mEvent: MatrixEvent): boolean => { 1961+ // Direct thread message or the root itself 1962+ if (mEvent.threadRootId === threadRootId || mEvent.getId() === threadRootId) { 1963+ return true; 1964+ } 1965+ 1966+ // Check if this is a reaction/edit targeting an event in this thread 1967+ if (reactionOrEditEvent(mEvent)) { 1968+ const relation = mEvent.getRelation(); 1969+ const targetEventId = relation?.event_id; 1970+ if (targetEventId) { 1971+ const targetEvent = room.findEventById(targetEventId); 1972+ if ( 1973+ targetEvent && 1974+ (targetEvent.threadRootId === threadRootId || targetEvent.getId() === threadRootId) 1975+ ) { 1976+ return true; 1977+ } 1978+ } 1979+ } 1980+ 1981+ return false; 1982+ }; 1983+ 1984+ const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (mEvent) => { 1985+ if (isEventInThread(mEvent)) { 1986+ forceUpdate((n) => n + 1); 1987+ } 1988+ }; 1989+ const onRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent) => { 1990+ if (isEventInThread(mEvent)) { 1991+ forceUpdate((n) => n + 1); 1992+ } 1993+ }; 1994+ const onThreadUpdate = () => forceUpdate((n) => n + 1); 1995+ room.on(RoomEvent.Timeline, onTimeline); 1996+ room.on(RoomEvent.Redaction, onRedaction); 1997+ (room as any).on(ThreadEvent.Update, onThreadUpdate); 1998+ (room as any).on(ThreadEvent.NewReply, onThreadUpdate); 1999+ return () => { 2000+ room.removeListener(RoomEvent.Timeline, onTimeline); 2001+ room.removeListener(RoomEvent.Redaction, onRedaction); 2002+ (room as any).removeListener(ThreadEvent.Update, onThreadUpdate); 2003+ (room as any).removeListener(ThreadEvent.NewReply, onThreadUpdate); 2004+ }; 2005+ }, [mx, room, threadRootId]); 2006+ 2007+ // Use the Thread object if available (authoritative source with full history). 2008+ // Fall back to scanning the live room timeline for local echoes and the 2009+ // window before the Thread object is registered by the SDK. 2010+ const replyEvents: MatrixEvent[] = (() => { 2011+ const thread = room.getThread(threadRootId); 2012+ const fromThread = thread?.events ?? []; 2013+ if (fromThread.length > 0) { 2014+ return fromThread.filter( 2015+ (ev: MatrixEvent) => ev.getId() !== threadRootId && !reactionOrEditEvent(ev) 2016+ ); 2017+ } 2018+ return room 2019+ .getUnfilteredTimelineSet() 2020+ .getLiveTimeline() 2021+ .getEvents() 2022+ .filter( 2023+ (ev: MatrixEvent) => 2024+ ev.threadRootId === threadRootId && 2025+ ev.getId() !== threadRootId && 2026+ !reactionOrEditEvent(ev) 2027+ ); 2028+ })(); 2029+ 2030+ replyEventsRef.current = replyEvents; 2031+ 2032+ // Mark thread as read when viewing it and when new messages arrive 2033+ useEffect(() => { 2034+ const markThreadAsRead = async () => { 2035+ const thread = room.getThread(threadRootId); 2036+ if (!thread) return; 2037+ 2038+ const events = thread.events || []; 2039+ if (events.length === 0) return; 2040+ 2041+ const lastEvent = events[events.length - 1]; 2042+ if (!lastEvent || lastEvent.isSending()) return; 2043+ 2044+ const userId = mx.getUserId(); 2045+ if (!userId) return; 2046+ 2047+ const readUpToId = thread.getEventReadUpTo(userId, false); 2048+ const lastEventId = lastEvent.getId(); 2049+ 2050+ // Only send receipt if we haven't already read up to the last event 2051+ if (readUpToId !== lastEventId) { 2052+ try { 2053+ await mx.sendReadReceipt( 2054+ lastEvent, 2055+ hideActivity ? ReceiptType.ReadPrivate : ReceiptType.Read 2056+ ); 2057+ } catch (err) { 2058+ // eslint-disable-next-line no-console 2059+ console.warn('Failed to send thread read receipt:', err); 2060+ } 2061+ } 2062+ }; 2063+ 2064+ markThreadAsRead(); 2065+ }, [mx, room, threadRootId, replyEvents.length, hideActivity]); 2066+ 2067+ // Auto-scroll to bottom when event count grows (if the user is near the bottom). 2068+ useEffect(() => { 2069+ const el = scrollRef.current; 2070+ if (!el) return; 2071+ const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150; 2072+ if (prevReplyCountRef.current === 0 || isAtBottom) { 2073+ el.scrollTop = el.scrollHeight; 2074+ } 2075+ prevReplyCountRef.current = replyEvents.length; 2076+ }, [replyEvents.length]); 2077+ 2078+ const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback( 2079+ (evt) => { 2080+ evt.preventDefault(); 2081+ evt.stopPropagation(); 2082+ const userId = evt.currentTarget.getAttribute('data-user-id'); 2083+ if (!userId) return; 2084+ openUserRoomProfile( 2085+ room.roomId, 2086+ undefined, 2087+ userId, 2088+ evt.currentTarget.getBoundingClientRect() 2089+ ); 2090+ }, 2091+ [room, openUserRoomProfile] 2092+ ); 2093+ 2094+ const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback( 2095+ (evt) => { 2096+ evt.preventDefault(); 2097+ const userId = evt.currentTarget.getAttribute('data-user-id'); 2098+ if (!userId) return; 2099+ const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; 2100+ editor.insertNode( 2101+ createMentionElement( 2102+ userId, 2103+ name.startsWith('@') ? name : `@${name}`, 2104+ userId === mx.getUserId() 2105+ ) 2106+ ); 2107+ ReactEditor.focus(editor); 2108+ moveCursor(editor); 2109+ }, 2110+ [mx, room, editor] 2111+ ); 2112+ 2113+ const handleReplyClick = useCallback( 2114+ (evt: Parameters<MouseEventHandler<HTMLButtonElement>>[0]) => { 2115+ const replyId = evt.currentTarget.getAttribute('data-event-id'); 2116+ if (!replyId) { 2117+ // In thread mode, resetting means going back to base thread draft 2118+ setReplyDraft({ 2119+ userId: mx.getUserId() ?? '', 2120+ eventId: threadRootId, 2121+ body: '', 2122+ relation: { rel_type: RelationType.Thread, event_id: threadRootId }, 2123+ }); 2124+ return; 2125+ } 2126+ const replyEvt = room.findEventById(replyId); 2127+ if (!replyEvt) return; 2128+ const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet()); 2129+ const content = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent(); 2130+ const { body, formatted_body: formattedBody } = content; 2131+ const senderId = replyEvt.getSender(); 2132+ if (senderId) { 2133+ const draft: IReplyDraft = { 2134+ userId: senderId, 2135+ eventId: replyId, 2136+ body: typeof body === 'string' ? body : '', 2137+ formattedBody, 2138+ relation: { rel_type: RelationType.Thread, event_id: threadRootId }, 2139+ }; 2140+ // Only toggle off if we're actively replying to this event (non-empty body distinguishes 2141+ // a real reply draft from the seeded base-thread draft, which has body: ''). 2142+ if (activeReplyId === replyId && replyDraft?.body) { 2143+ // Toggle off — reset to base thread draft 2144+ setReplyDraft({ 2145+ userId: mx.getUserId() ?? '', 2146+ eventId: threadRootId, 2147+ body: '', 2148+ relation: { rel_type: RelationType.Thread, event_id: threadRootId }, 2149+ }); 2150+ } else { 2151+ setReplyDraft(draft); 2152+ } 2153+ } 2154+ }, 2155+ [mx, room, setReplyDraft, activeReplyId, threadRootId, replyDraft] 2156+ ); 2157+ 2158+ const handleReactionToggle = useCallback( 2159+ (targetEventId: string, key: string, shortcode?: string) => { 2160+ const threadTimelineSet = room.getThread(threadRootId)?.timelineSet; 2161+ toggleReaction(mx, room, targetEventId, key, shortcode, threadTimelineSet); 2162+ }, 2163+ [mx, room, threadRootId] 2164+ ); 2165+ 2166+ const handleEdit = useCallback( 2167+ (evtId?: string) => { 2168+ setEditId(evtId); 2169+ if (!evtId) { 2170+ ReactEditor.focus(editor); 2171+ moveCursor(editor); 2172+ } 2173+ }, 2174+ [editor] 2175+ ); 2176+ 2177+ const handleOpenReply: MouseEventHandler<HTMLButtonElement> = useCallback( 2178+ (evt) => { 2179+ const targetId = evt.currentTarget.getAttribute('data-event-id'); 2180+ if (!targetId) return; 2181+ const isRoot = targetId === threadRootId; 2182+ const isInReplies = replyEventsRef.current.some((e) => e.getId() === targetId); 2183+ if (!isRoot && !isInReplies) return; 2184+ setJumpToEventId(targetId); 2185+ setTimeout(() => setJumpToEventId(undefined), 2500); 2186+ const el = drawerRef.current; 2187+ if (el) { 2188+ const target = el.querySelector(`[data-message-id="${targetId}"]`); 2189+ target?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); 2190+ } 2191+ }, 2192+ [threadRootId] 2193+ ); 2194+ 2195+ const sharedMessageProps = { 2196+ room, 2197+ threadRootId, 2198+ editId, 2199+ onEditId: handleEdit, 2200+ messageLayout, 2201+ messageSpacing, 2202+ canDelete: canRedact || canDeleteOwn, 2203+ canSendReaction, 2204+ canPinEvent, 2205+ imagePackRooms, 2206+ hour24Clock, 2207+ dateFormatString, 2208+ onUserClick: handleUserClick, 2209+ onUsernameClick: handleUsernameClick, 2210+ onReplyClick: handleReplyClick, 2211+ onReactionToggle: handleReactionToggle, 2212+ linkifyOpts, 2213+ htmlReactParserOptions, 2214+ showHideReads: hideActivity, 2215+ showDeveloperTools, 2216+ onReferenceClick: handleOpenReply, 2217+ jumpToEventId, 2218+ }; 2219+ 2220+ // Latest thread event for the following indicator (latest reply, or root if no replies) 2221+ const threadParticipantIds = new Set( 2222+ [rootEvent, ...replyEvents].map((ev) => ev?.getSender()).filter(Boolean) as string[] 2223+ ); 2224+ const latestThreadEventId = ( 2225+ replyEvents.length > 0 ? replyEvents[replyEvents.length - 1] : rootEvent 2226+ )?.getId(); 2227+ 2228+ return ( 2229+ <Box 2230+ ref={drawerRef} 2231+ className={overlay ? css.ThreadDrawerOverlay : css.ThreadDrawer} 2232+ direction="Column" 2233+ shrink="No" 2234+ > 2235+ {/* Header */} 2236+ <Header className={css.ThreadDrawerHeader} variant="Background" size="600"> 2237+ <Box grow="Yes" alignItems="Center" gap="200"> 2238+ <Icon size="200" src={Icons.Thread} /> 2239+ <Text size="H5" truncate> 2240+ Thread 2241+ </Text> 2242+ </Box> 2243+ <Box alignItems="Center" gap="200" shrink="No"> 2244+ <Text size="T300" priority="300" truncate> 2245+ # {room.name} 2246+ </Text> 2247+ <IconButton onClick={onClose} variant="Background" aria-label="Close thread"> 2248+ <Icon src={Icons.Cross} /> 2249+ </IconButton> 2250+ </Box> 2251+ </Header> 2252+ 2253+ {/* Thread root message */} 2254+ {rootEvent && ( 2255+ <Scroll 2256+ variant="Background" 2257+ visibility="Hover" 2258+ direction="Vertical" 2259+ hideTrack={false} 2260+ style={{ 2261+ maxHeight: '200px', 2262+ flexShrink: 0, 2263+ height: 'auto', 2264+ }} 2265+ > 2266+ <Box 2267+ className={css.messageList} 2268+ direction="Column" 2269+ style={{ 2270+ padding: `${config.space.S200} 0`, 2271+ }} 2272+ > 2273+ <ThreadMessage {...sharedMessageProps} mEvent={rootEvent} /> 2274+ </Box> 2275+ </Scroll> 2276+ )} 2277+ 2278+ {/* Replies */} 2279+ <Box className={css.ThreadDrawerContent} grow="Yes" direction="Column"> 2280+ <Scroll 2281+ ref={scrollRef} 2282+ variant="Background" 2283+ visibility="Hover" 2284+ direction="Vertical" 2285+ hideTrack 2286+ style={{ flexGrow: 1 }} 2287+ > 2288+ {replyEvents.length === 0 ? ( 2289+ <Box 2290+ direction="Column" 2291+ alignItems="Center" 2292+ justifyContent="Center" 2293+ style={{ padding: config.space.S400, gap: config.space.S200 }} 2294+ > 2295+ <Icon size="400" src={Icons.Thread} /> 2296+ <Text size="T300" align="Center"> 2297+ No replies yet. Start the thread below! 2298+ </Text> 2299+ </Box> 2300+ ) : ( 2301+ <> 2302+ {/* Reply count label inside scroll area */} 2303+ <Box 2304+ style={{ 2305+ padding: `${config.space.S200} ${config.space.S400}`, 2306+ flexShrink: 0, 2307+ }} 2308+ > 2309+ <Text size="T300" priority="300"> 2310+ {replyEvents.length} {replyEvents.length === 1 ? 'reply' : 'replies'} 2311+ </Text> 2312+ </Box> 2313+ <Box 2314+ className={css.messageList} 2315+ direction="Column" 2316+ style={{ padding: `${config.space.S600} 0` }} 2317+ > 2318+ {replyEvents.map((mEvent, i) => { 2319+ const prevEvent = i > 0 ? replyEvents[i - 1] : undefined; 2320+ const collapse = 2321+ prevEvent !== undefined && 2322+ prevEvent.getSender() === mEvent.getSender() && 2323+ prevEvent.getType() === mEvent.getType() && 2324+ minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2; 2325+ return ( 2326+ <ThreadMessage 2327+ key={mEvent.getId()} 2328+ {...sharedMessageProps} 2329+ mEvent={mEvent} 2330+ collapse={collapse} 2331+ /> 2332+ ); 2333+ })} 2334+ </Box> 2335+ </> 2336+ )} 2337+ </Scroll> 2338+ </Box> 2339+ 2340+ {/* Thread input */} 2341+ <Box className={css.ThreadDrawerInput} direction="Column" shrink="No"> 2342+ <div style={{ padding: `0 ${config.space.S400}` }}> 2343+ <RoomInput 2344+ key={threadRootId} 2345+ room={room} 2346+ roomId={room.roomId} 2347+ threadRootId={threadRootId} 2348+ editor={editor} 2349+ fileDropContainerRef={drawerRef} 2350+ /> 2351+ </div> 2352+ {hideActivity ? ( 2353+ <RoomViewFollowingPlaceholder /> 2354+ ) : ( 2355+ <RoomViewFollowing 2356+ style={{ backgroundColor: 'transparent' }} 2357+ room={room} 2358+ threadEventId={latestThreadEventId} 2359+ participantIds={threadParticipantIds} 2360+ /> 2361+ )} 2362+ </Box> 2363+ </Box> 2364+ ); 2365+} 2366diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx 2367index 2d87e9b..3e7fd5b 100644 2368--- a/src/app/features/room/message/Message.tsx 2369+++ b/src/app/features/room/message/Message.tsx 2370@@ -676,6 +676,7 @@ export type MessageProps = { 2371 reply?: ReactNode; 2372 reactions?: ReactNode; 2373 hideReadReceipts?: boolean; 2374+ hideThreadButton?: boolean; 2375 showDeveloperTools?: boolean; 2376 memberPowerTag?: MemberPowerTag; 2377 accessibleTagColors?: Map<string, string>; 2378@@ -707,6 +708,7 @@ export const Message = as<'div', MessageProps>( 2379 reply, 2380 reactions, 2381 hideReadReceipts, 2382+ hideThreadButton, 2383 showDeveloperTools, 2384 memberPowerTag, 2385 accessibleTagColors, 2386@@ -814,7 +816,7 @@ export const Message = as<'div', MessageProps>( 2387 ); 2388 2389 const msgContentJSX = ( 2390- <Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}> 2391+ <Box direction="Column" alignSelf="Start" style={{ width: '100%' }}> 2392 {reply} 2393 {edit && onEditId ? ( 2394 <MessageEditor 2395@@ -872,8 +874,6 @@ export const Message = as<'div', MessageProps>( 2396 }, 100); 2397 }; 2398 2399- const isThreadedMessage = mEvent.threadRootId !== undefined; 2400- 2401 return ( 2402 <MessageBase 2403 className={classNames(css.MessageBase, className, { 2404@@ -938,7 +938,7 @@ export const Message = as<'div', MessageProps>( 2405 > 2406 <Icon src={Icons.ReplyArrow} size="100" /> 2407 </IconButton> 2408- {!isThreadedMessage && ( 2409+ {!hideThreadButton && ( 2410 <IconButton 2411 onClick={(ev) => onReplyClick(ev, true)} 2412 data-event-id={mEvent.getId()} 2413@@ -1028,7 +1028,7 @@ export const Message = as<'div', MessageProps>( 2414 Reply 2415 </Text> 2416 </MenuItem> 2417- {!isThreadedMessage && ( 2418+ {!hideThreadButton && ( 2419 <MenuItem 2420 size="300" 2421 after={<Icon src={Icons.ThreadPlus} size="100" />} 2422diff --git a/src/app/state/room/roomToOpenThread.ts b/src/app/state/room/roomToOpenThread.ts 2423new file mode 100644 2424index 0000000..0a60fa4 2425--- /dev/null 2426+++ b/src/app/state/room/roomToOpenThread.ts 2427@@ -0,0 +1,14 @@ 2428+import { atom } from 'jotai'; 2429+import { atomFamily } from 'jotai/utils'; 2430+ 2431+const createOpenThreadAtom = () => atom<string | undefined>(undefined); 2432+export type TOpenThreadAtom = ReturnType<typeof createOpenThreadAtom>; 2433+ 2434+/** 2435+ * Tracks the currently-open thread root event ID per room. 2436+ * Key: roomId 2437+ * Value: eventId of the thread root, or undefined if no thread is open. 2438+ */ 2439+export const roomIdToOpenThreadAtomFamily = atomFamily<string, TOpenThreadAtom>(() => 2440+ createOpenThreadAtom() 2441+); 2442diff --git a/src/app/state/room/roomToThreadBrowser.ts b/src/app/state/room/roomToThreadBrowser.ts 2443new file mode 100644 2444index 0000000..3d89631 2445--- /dev/null 2446+++ b/src/app/state/room/roomToThreadBrowser.ts 2447@@ -0,0 +1,13 @@ 2448+import { atom } from 'jotai'; 2449+import { atomFamily } from 'jotai/utils'; 2450+ 2451+const createThreadBrowserAtom = () => atom<boolean>(false); 2452+export type TThreadBrowserAtom = ReturnType<typeof createThreadBrowserAtom>; 2453+ 2454+/** 2455+ * Tracks whether the thread browser panel is open per room. 2456+ * Key: roomId 2457+ */ 2458+export const roomIdToThreadBrowserAtomFamily = atomFamily<string, TThreadBrowserAtom>(() => 2459+ createThreadBrowserAtom() 2460+); 2461diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts 2462index 4c86c4e..56d4851 100644 2463--- a/src/app/utils/matrix.ts 2464+++ b/src/app/utils/matrix.ts 2465@@ -5,6 +5,7 @@ import { 2466 } from 'browser-encrypt-attachment'; 2467 import { 2468 EventTimeline, 2469+ EventTimelineSet, 2470 MatrixClient, 2471 MatrixError, 2472 MatrixEvent, 2473@@ -16,8 +17,8 @@ import { 2474 import to from 'await-to-js'; 2475 import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common'; 2476 import { AccountDataEvent } from '../../types/matrix/accountData'; 2477-import { getStateEvent } from './room'; 2478-import { Membership, StateEvent } from '../../types/matrix/room'; 2479+import { getEventReactions, getReactionContent, getStateEvent } from './room'; 2480+import { Membership, MessageEvent, StateEvent } from '../../types/matrix/room'; 2481 2482 const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; 2483 2484@@ -372,3 +373,40 @@ export const creatorsSupported = (version: string): boolean => { 2485 const unsupportedVersion = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11']; 2486 return !unsupportedVersion.includes(version); 2487 }; 2488+ 2489+export const toggleReaction = ( 2490+ mx: MatrixClient, 2491+ room: Room, 2492+ targetEventId: string, 2493+ key: string, 2494+ shortcode?: string, 2495+ timelineSet?: EventTimelineSet 2496+) => { 2497+ const relations = getEventReactions( 2498+ timelineSet ?? room.getUnfilteredTimelineSet(), 2499+ targetEventId 2500+ ); 2501+ const allReactions = relations?.getSortedAnnotationsByKey() ?? []; 2502+ const [, reactionsSet] = allReactions.find(([reactionKey]) => reactionKey === key) ?? []; 2503+ const reactions: MatrixEvent[] = reactionsSet ? Array.from(reactionsSet) : []; 2504+ const userId = mx.getUserId(); 2505+ if (!userId) { 2506+ return; 2507+ } 2508+ const myReaction = reactions.find(factoryEventSentBy(userId)); 2509+ 2510+ const myReactionId = myReaction?.getId(); 2511+ if (myReaction && myReactionId && myReaction.isRelation()) { 2512+ mx.redactEvent(room.roomId, myReactionId); 2513+ return; 2514+ } 2515+ 2516+ const resolvedShortcode = 2517+ shortcode || (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined); 2518+ 2519+ mx.sendEvent( 2520+ room.roomId, 2521+ MessageEvent.Reaction as any, 2522+ getReactionContent(targetEventId, key, resolvedShortcode) 2523+ ); 2524+}; 2525diff --git a/src/app/utils/notifications.ts b/src/app/utils/notifications.ts 2526index a23bd1a..3f5bc7e 100644 2527--- a/src/app/utils/notifications.ts 2528+++ b/src/app/utils/notifications.ts 2529@@ -1,17 +1,21 @@ 2530-import { MatrixClient, ReceiptType } from 'matrix-js-sdk'; 2531+import { MatrixClient, ReceiptType, RelationType } from 'matrix-js-sdk'; 2532 2533 export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) { 2534 const room = mx.getRoom(roomId); 2535 if (!room) return; 2536 2537 const timeline = room.getLiveTimeline().getEvents(); 2538- const readEventId = room.getEventReadUpTo(mx.getUserId()!); 2539+ const userId = mx.getUserId(); 2540+ if (!userId) return; 2541+ const readEventId = room.getEventReadUpTo(userId); 2542 2543 const getLatestValidEvent = () => { 2544 for (let i = timeline.length - 1; i >= 0; i -= 1) { 2545 const latestEvent = timeline[i]; 2546 if (latestEvent.getId() === readEventId) return null; 2547- if (!latestEvent.isSending()) return latestEvent; 2548+ if (!latestEvent.isRelation(RelationType.Thread) && !latestEvent.isSending()) { 2549+ return latestEvent; 2550+ } 2551 } 2552 return null; 2553 }; 2554diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts 2555index 05dfeb2..a29d52b 100644 2556--- a/src/app/utils/room.ts 2557+++ b/src/app/utils/room.ts 2558@@ -203,6 +203,7 @@ export const isNotificationEvent = (mEvent: MatrixEvent) => { 2559 2560 if (mEvent.isRedacted()) return false; 2561 if (mEvent.getRelation()?.rel_type === 'm.replace') return false; 2562+ if (mEvent.isRelation(RelationType.Thread)) return false; 2563 2564 return true; 2565 }; 2566diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts 2567index 498d4f7..f391945 100644 2568--- a/src/client/initMatrix.ts 2569+++ b/src/client/initMatrix.ts 2570@@ -43,6 +43,7 @@ export const initClient = async (session: Session): Promise<MatrixClient> => { 2571 export const startClient = async (mx: MatrixClient) => { 2572 await mx.startClient({ 2573 lazyLoadMembers: true, 2574+ threadSupport: true, 2575 }); 2576 }; 2577