This repository has no description
0

Configure Feed

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

services.matrix: self-host Cinny with thread drawer on matrix.lassul.us

Cinny has no thread overview UI, which makes the Zulip bridge's stream rooms
(one Matrix thread per Zulip topic) hard to navigate. Carry smokku's
"threads-ui" thread drawer/browser (PR cinnyapp/cinny#2787, closed only
because Cinny froze PRs during their SDK rewrite) as a patch over the
packaged v4.12.2 release and serve it at the matrix.lassul.us root.

The patch is the squashed feature diff resolved against v4.12.2; the only
conflict was RoomViewHeader.tsx (additive, alongside the call button). The
lockfile is unchanged so nixpkgs' npmDepsHash still applies. Re-resolve the
patch against the new tag on each cinny bump.

Assisted-by: Claude Code (claude-opus-4-8[1m])

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