This repository has no description
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} {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+ · {latestSenderName}: {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