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