Skip to content

Commit fa71c0a

Browse files
author
Evie Gauthier
committed
feat(threads): implement thread side panel with full functionality
- Add ThreadDrawer component with message rendering and input - Add ThreadBrowser panel for viewing all threads in a room - Add thread chips on messages showing reply count and participants - Enable message actions in threads (edit, react, reply, delete) - Add emoji and sticker rendering support in threads - Filter thread replies from main timeline to avoid duplicates - Auto-create Thread objects when starting threads or syncing from other devices - Add unread thread badge to header icon (Discord-style) - Auto-open thread drawer when navigating to thread events from notifications - Reorder room header icons: search, pinned, threads, widgets, members, more - Add automatic read receipts when viewing threads Fixes thread browser showing empty list and inbox notifications not opening threads.
1 parent 83d20e0 commit fa71c0a

18 files changed

Lines changed: 2335 additions & 67 deletions

.changeset/feat-threads.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
sable: minor
3+
---
4+
5+
Add thread support with side panel, browser, unread badges, and cross-device sync

src/app/components/editor/Editor.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,6 @@ import { CustomElement } from './slate';
2626
import * as css from './Editor.css';
2727
import { toggleKeyboardShortcut } from './keyboard';
2828

29-
const initialValue: CustomElement[] = [
30-
{
31-
type: BlockType.Paragraph,
32-
children: [{ text: '' }],
33-
},
34-
];
35-
3629
const withInline = (editor: Editor): Editor => {
3730
const { isInline } = editor;
3831

@@ -96,6 +89,15 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
9689
},
9790
ref
9891
) => {
92+
// Each <Slate> instance must receive its own fresh node objects.
93+
// Sharing a module-level constant causes Slate's global NODE_TO_ELEMENT
94+
// WeakMap to be overwritten when multiple editors are mounted at the same
95+
// time (e.g. RoomInput + MessageEditor in the thread drawer), leading to
96+
// "Unable to find the path for Slate node" crashes.
97+
const [slateInitialValue] = useState<CustomElement[]>(() => [
98+
{ type: BlockType.Paragraph, children: [{ text: '' }] },
99+
]);
100+
99101
const renderElement = useCallback(
100102
(props: RenderElementProps) => <RenderElement {...props} />,
101103
[]
@@ -132,7 +134,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
132134

133135
return (
134136
<div className={`${css.Editor} ${className || ''}`} ref={ref}>
135-
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
137+
<Slate editor={editor} initialValue={slateInitialValue} onChange={onChange}>
136138
{top}
137139
<Box alignItems="Start">
138140
{before && (

src/app/features/room/Room.tsx

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { useCallback } from 'react';
1+
import { useCallback, useEffect } from 'react';
22
import { Box, Line } from 'folds';
33
import { useParams } from 'react-router-dom';
44
import { isKeyHotkey } from 'is-hotkey';
5-
import { useAtomValue } from 'jotai';
5+
import { useAtom, useAtomValue } from 'jotai';
66
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
77
import { useSetting } from '$state/hooks/settings';
88
import { settingsAtom } from '$state/settings';
@@ -15,10 +15,14 @@ import { useRoomMembers } from '$hooks/useRoomMembers';
1515
import { CallView } from '$features/call/CallView';
1616
import { WidgetsDrawer } from '$features/widgets/WidgetsDrawer';
1717
import { callChatAtom } from '$state/callEmbed';
18+
import { roomIdToOpenThreadAtomFamily } from '$state/room/roomToOpenThread';
19+
import { roomIdToThreadBrowserAtomFamily } from '$state/room/roomToThreadBrowser';
1820
import { RoomViewHeader } from './RoomViewHeader';
1921
import { MembersDrawer } from './MembersDrawer';
2022
import { RoomView } from './RoomView';
2123
import { CallChatView } from './CallChatView';
24+
import { ThreadDrawer } from './ThreadDrawer';
25+
import { ThreadBrowser } from './ThreadBrowser';
2226

2327
export function Room() {
2428
const { eventId } = useParams();
@@ -32,6 +36,30 @@ export function Room() {
3236
const powerLevels = usePowerLevels(room);
3337
const members = useRoomMembers(mx, room.roomId);
3438
const chat = useAtomValue(callChatAtom);
39+
const [openThreadId, setOpenThread] = useAtom(roomIdToOpenThreadAtomFamily(room.roomId));
40+
const [threadBrowserOpen, setThreadBrowserOpen] = useAtom(
41+
roomIdToThreadBrowserAtomFamily(room.roomId)
42+
);
43+
44+
// If navigating to an event in a thread, open the thread drawer
45+
useEffect(() => {
46+
if (!eventId) return;
47+
48+
const event = room.findEventById(eventId);
49+
if (!event) return;
50+
51+
const { threadRootId } = event;
52+
if (threadRootId) {
53+
// Ensure Thread object exists
54+
if (!room.getThread(threadRootId)) {
55+
const rootEvent = room.findEventById(threadRootId);
56+
if (rootEvent) {
57+
room.createThread(threadRootId, rootEvent, [], false);
58+
}
59+
}
60+
setOpenThread(threadRootId);
61+
}
62+
}, [eventId, room, setOpenThread]);
3563

3664
useKeyDown(
3765
window,
@@ -49,7 +77,7 @@ export function Room() {
4977

5078
return (
5179
<PowerLevelsContextProvider value={powerLevels}>
52-
<Box grow="Yes">
80+
<Box grow="Yes" style={{ position: 'relative' }}>
5381
{callView && (screenSize === ScreenSize.Desktop || !chat) && (
5482
<Box grow="Yes" direction="Column">
5583
<RoomViewHeader callView />
@@ -87,6 +115,52 @@ export function Room() {
87115
<WidgetsDrawer key={`widgets-${room.roomId}`} room={room} />
88116
</>
89117
)}
118+
{screenSize === ScreenSize.Desktop && openThreadId && (
119+
<>
120+
<Line variant="Background" direction="Vertical" size="300" />
121+
<ThreadDrawer
122+
key={`thread-${room.roomId}-${openThreadId}`}
123+
room={room}
124+
threadRootId={openThreadId}
125+
onClose={() => setOpenThread(undefined)}
126+
/>
127+
</>
128+
)}
129+
{screenSize === ScreenSize.Desktop && threadBrowserOpen && !openThreadId && (
130+
<>
131+
<Line variant="Background" direction="Vertical" size="300" />
132+
<ThreadBrowser
133+
key={`thread-browser-${room.roomId}`}
134+
room={room}
135+
onOpenThread={(id) => {
136+
setOpenThread(id);
137+
setThreadBrowserOpen(false);
138+
}}
139+
onClose={() => setThreadBrowserOpen(false)}
140+
/>
141+
</>
142+
)}
143+
{screenSize !== ScreenSize.Desktop && openThreadId && (
144+
<ThreadDrawer
145+
key={`thread-${room.roomId}-${openThreadId}`}
146+
room={room}
147+
threadRootId={openThreadId}
148+
onClose={() => setOpenThread(undefined)}
149+
overlay
150+
/>
151+
)}
152+
{screenSize !== ScreenSize.Desktop && threadBrowserOpen && !openThreadId && (
153+
<ThreadBrowser
154+
key={`thread-browser-${room.roomId}`}
155+
room={room}
156+
onOpenThread={(id) => {
157+
setOpenThread(id);
158+
setThreadBrowserOpen(false);
159+
}}
160+
onClose={() => setThreadBrowserOpen(false)}
161+
overlay
162+
/>
163+
)}
90164
</Box>
91165
</PowerLevelsContextProvider>
92166
);

src/app/features/room/RoomInput.tsx

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,13 @@ interface RoomInputProps {
185185
fileDropContainerRef: RefObject<HTMLElement>;
186186
roomId: string;
187187
room: Room;
188+
threadRootId?: string;
188189
}
189190
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
190-
({ editor, fileDropContainerRef, roomId, room }, ref) => {
191+
({ editor, fileDropContainerRef, roomId, room, threadRootId }, ref) => {
192+
// When in thread mode, isolate drafts by thread root ID so thread replies
193+
// don't clobber the main room draft (and vice versa).
194+
const draftKey = threadRootId ?? roomId;
191195
const mx = useMatrixClient();
192196
const useAuthentication = useMediaAuthentication();
193197
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
@@ -203,8 +207,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
203207
const permissions = useRoomPermissions(creators, powerLevels);
204208
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
205209

206-
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
207-
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
210+
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(draftKey));
211+
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(draftKey));
208212
const replyUserID = replyDraft?.userId;
209213

210214
const { color: replyUsernameColor, font: replyUsernameFont } = useSableCosmetics(
@@ -213,7 +217,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
213217
);
214218

215219
const [uploadBoard, setUploadBoard] = useState(true);
216-
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
220+
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(draftKey));
217221
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
218222
roomUploadAtomFamily,
219223
selectedFiles.map((f) => f.file)
@@ -326,6 +330,26 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
326330
replyBodyJSX = scaleSystemEmoji(strippedBody);
327331
}
328332

333+
// Seed the reply draft with the thread relation whenever we're in thread
334+
// mode (e.g. on first render or when the thread root changes). We use the
335+
// current user's ID as userId so that the mention logic skips it.
336+
useEffect(() => {
337+
if (!threadRootId) return;
338+
setReplyDraft((prev) => {
339+
if (
340+
prev?.relation?.rel_type === RelationType.Thread &&
341+
prev.relation.event_id === threadRootId
342+
)
343+
return prev;
344+
return {
345+
userId: mx.getUserId() ?? '',
346+
eventId: threadRootId,
347+
body: '',
348+
relation: { rel_type: RelationType.Thread, event_id: threadRootId },
349+
};
350+
});
351+
}, [threadRootId, setReplyDraft, mx]);
352+
329353
useEffect(() => {
330354
Transforms.insertFragment(editor, msgDraft);
331355
}, [editor, msgDraft]);
@@ -341,7 +365,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
341365
resetEditor(editor);
342366
resetEditorHistory(editor);
343367
},
344-
[roomId, editor, setMsgDraft]
368+
[draftKey, editor, setMsgDraft]
345369
);
346370

347371
const handleFileMetadata = useCallback(
@@ -409,12 +433,21 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
409433
if (contents.length > 0) {
410434
const replyContent = plainText?.length === 0 ? getReplyContent(replyDraft) : undefined;
411435
if (replyContent) contents[0]['m.relates_to'] = replyContent;
412-
setReplyDraft(undefined);
436+
if (threadRootId) {
437+
setReplyDraft({
438+
userId: mx.getUserId() ?? '',
439+
eventId: threadRootId,
440+
body: '',
441+
relation: { rel_type: RelationType.Thread, event_id: threadRootId },
442+
});
443+
} else {
444+
setReplyDraft(undefined);
445+
}
413446
}
414447

415448
await Promise.all(
416449
contents.map((content) =>
417-
mx.sendMessage(roomId, content as any).catch((error: unknown) => {
450+
mx.sendMessage(roomId, threadRootId ?? null, content as any).catch((error: unknown) => {
418451
log.error('failed to send uploaded message', { roomId }, error);
419452
throw error;
420453
})
@@ -537,7 +570,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
537570
resetEditor(editor);
538571
resetEditorHistory(editor);
539572
setInputKey((prev) => prev + 1);
540-
setReplyDraft(undefined);
573+
if (threadRootId) {
574+
// Re-seed the thread reply draft so the next message also goes to the thread.
575+
setReplyDraft({
576+
userId: mx.getUserId() ?? '',
577+
eventId: threadRootId,
578+
body: '',
579+
relation: { rel_type: RelationType.Thread, event_id: threadRootId },
580+
});
581+
} else {
582+
setReplyDraft(undefined);
583+
}
541584
sendTypingStatus(false);
542585
};
543586
if (scheduledTime) {
@@ -561,7 +604,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
561604
} else if (editingScheduledDelayId) {
562605
try {
563606
await cancelDelayedEvent(mx, editingScheduledDelayId);
564-
mx.sendMessage(roomId, content as any);
607+
mx.sendMessage(roomId, threadRootId ?? null, content as any);
565608
invalidate();
566609
setEditingScheduledDelayId(null);
567610
resetInput();
@@ -570,7 +613,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
570613
}
571614
} else {
572615
resetInput();
573-
mx.sendMessage(roomId, content as any).catch((error: unknown) => {
616+
mx.sendMessage(roomId, threadRootId ?? null, content as any).catch((error: unknown) => {
574617
log.error('failed to send message', { roomId }, error);
575618
});
576619
}
@@ -580,6 +623,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
580623
canSendReaction,
581624
mx,
582625
roomId,
626+
threadRootId,
583627
replyDraft,
584628
scheduledTime,
585629
editingScheduledDelayId,
@@ -683,7 +727,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
683727
};
684728
if (replyDraft) {
685729
content['m.relates_to'] = getReplyContent(replyDraft);
686-
setReplyDraft(undefined);
730+
if (threadRootId) {
731+
setReplyDraft({
732+
userId: mx.getUserId() ?? '',
733+
eventId: threadRootId,
734+
body: '',
735+
relation: { rel_type: RelationType.Thread, event_id: threadRootId },
736+
});
737+
} else {
738+
setReplyDraft(undefined);
739+
}
687740
}
688741
mx.sendEvent(roomId, EventType.Sticker, content);
689742
};
@@ -841,7 +894,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
841894
</Box>
842895
</div>
843896
)}
844-
{replyDraft && (
897+
{replyDraft && !threadRootId && (
845898
<div>
846899
<Box
847900
alignItems="Center"

0 commit comments

Comments
 (0)