diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 3835d654e..e5341769e 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -18,9 +18,11 @@ import { IContent, MatrixClient, MatrixEvent, + RelationType, Room, RoomEvent, RoomEventHandlerMap, + ThreadEvent, } from 'matrix-js-sdk'; import { HTMLReactParserOptions } from 'html-react-parser'; import classNames from 'classnames'; @@ -477,6 +479,19 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId)); const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId)); + // Thread summary chips only mount for events that already carry thread data + // (perf: a chip subscribes room-level listeners, so mounting one per rendered + // message would exceed the SDK's emitter cap). This single room-level + // ThreadEvent.New subscription re-renders the timeline once when a brand-new + // thread appears, so the root's chip shows up without unrelated activity. + const [, setThreadNewTick] = useState(0); + useEffect(() => { + const handleThreadNew = () => setThreadNewTick((c) => c + 1); + room.on(ThreadEvent.New, handleThreadNew); + return () => { + room.removeListener(ThreadEvent.New, handleThreadNew); + }; + }, [room]); const powerLevels = usePowerLevelsContext(); const creators = useRoomCreators(room); @@ -1136,9 +1151,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli onReactionToggle={handleReactionToggle} /> )} - {(!threadRootId || threadRootId === mEventId) && ( - - )} + {(!threadRootId || threadRootId === mEventId) && + (mEvent.getThread() !== undefined || + mEvent.getServerAggregatedRelation(RelationType.Thread) !== undefined) && ( + + )} } hideReadReceipts={hideActivity} @@ -1227,9 +1244,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli onReactionToggle={handleReactionToggle} /> )} - {(!threadRootId || threadRootId === mEventId) && ( - - )} + {(!threadRootId || threadRootId === mEventId) && + (mEvent.getThread() !== undefined || + mEvent.getServerAggregatedRelation(RelationType.Thread) !== undefined) && ( + + )} } hideReadReceipts={hideActivity} diff --git a/src/app/features/room/thread/ThreadPanel.tsx b/src/app/features/room/thread/ThreadPanel.tsx index 7d528e7e1..3fb8501b2 100644 --- a/src/app/features/room/thread/ThreadPanel.tsx +++ b/src/app/features/room/thread/ThreadPanel.tsx @@ -95,10 +95,32 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps) ); // Mark the thread read when the panel is open and on each new thread event. + // Deduped on the latest event id: RoomEvent.Timeline re-emits per event during + // backfill and for every edit/reaction, and sendReadReceipt POSTs + // unconditionally — without the guard, opening a thread with N replies would + // fire up to N receipt requests at the same event. + const lastReadEventIdRef = useRef(undefined); useEffect(() => { + lastReadEventIdRef.current = undefined; if (!thread) return undefined; const markRead = () => { - markThreadAsRead(mx, thread, privateReadReceipts); + const events = thread.liveTimeline.getEvents(); + let latestId: string | undefined; + for (let i = events.length - 1; i >= 0; i -= 1) { + const evt = events[i]; + if (evt && !evt.isSending()) { + latestId = evt.getId() ?? undefined; + break; + } + } + if (!latestId || latestId === lastReadEventIdRef.current) return; + lastReadEventIdRef.current = latestId; + markThreadAsRead(mx, thread, privateReadReceipts).catch(() => { + // Allow a retry on the next event if the receipt POST failed. + if (lastReadEventIdRef.current === latestId) { + lastReadEventIdRef.current = undefined; + } + }); }; markRead(); thread.on(ThreadEvent.NewReply, markRead); diff --git a/src/app/features/room/thread/ThreadTimeline.tsx b/src/app/features/room/thread/ThreadTimeline.tsx index 82057e74c..90ba88ea3 100644 --- a/src/app/features/room/thread/ThreadTimeline.tsx +++ b/src/app/features/room/thread/ThreadTimeline.tsx @@ -64,7 +64,7 @@ import { } from '../../../utils/room'; import { useSetting } from '../../../state/hooks/settings'; import { MessageLayout, settingsAtom } from '../../../state/settings'; -import { Message, Reactions } from '../message'; +import { Message, Reactions, EncryptedContent } from '../message'; import { RenderMessageContent } from '../../../components/RenderMessageContent'; import { Image } from '../../../components/media'; import { ImageViewer } from '../../../components/image-viewer'; @@ -406,12 +406,22 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) { setTimeline((ct) => ({ ...ct })); }; const handleUpdate = () => setTimeline((ct) => ({ ...ct })); + // A gappy sync / updateThreadMetadata resets the thread's live timeline — + // the stored linkedTimelines would then point at a detached timeline, so + // reseed the window from the fresh liveTimeline. + const handleReset = () => { + setTimeline(getInitialThreadTimeline(thread, getLinkedTimelines(thread.liveTimeline))); + scrollToBottomRef.current.count += 1; + scrollToBottomRef.current.smooth = false; + }; thread.on(RoomEvent.Timeline, handleTimeline); thread.on(ThreadEvent.Update, handleUpdate); + thread.on(RoomEvent.TimelineReset, handleReset); return () => { thread.removeListener(RoomEvent.Timeline, handleTimeline); thread.removeListener(ThreadEvent.Update, handleUpdate); + thread.removeListener(RoomEvent.TimelineReset, handleReset); }; }, [thread]); @@ -586,61 +596,72 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) { const renderMessageContent = useCallback( (mEvent: MatrixEvent, mEventId: string, timelineSet: EventTimelineSet): ReactNode => { - if (mEvent.isRedacted()) { - return ; - } - const type = mEvent.getType(); - if (type === MessageEvent.Sticker) { + // Evaluated lazily so EncryptedContent can re-run it (re-reading getType()) + // after MatrixEventEvent.Decrypted fires — decryption re-emits NEITHER + // RoomEvent.Timeline nor ThreadEvent.Update, so without this wrapper a + // live-arriving encrypted reply would show "Unable to decrypt" forever. + const renderByType = (): ReactNode => { + if (mEvent.isRedacted()) { + return ; + } + const type = mEvent.getType(); + if (type === MessageEvent.Sticker) { + return ( + ( + } + renderViewer={(p) => } + /> + )} + /> + ); + } + if (type === MessageEvent.RoomMessageEncrypted) { + return ( + + + + ); + } + if (type !== MessageEvent.RoomMessage) { + return ( + + + + ); + } + const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); + const getContent = (() => + editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback; + const senderId = mEvent.getSender() ?? ''; + const senderDisplayName = + getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; return ( - ( - } - renderViewer={(p) => } - /> - )} + setEditHistoryEvent(mEvent) : undefined} + getContent={getContent} + mediaAutoLoad={mediaAutoLoad} + urlPreview={showUrlPreview} + htmlReactParserOptions={htmlReactParserOptions} + linkifyOpts={linkifyOpts} + outlineAttachment={messageLayout === MessageLayout.Bubble} + eventId={mEventId} /> ); + }; + + if (mEvent.getType() === MessageEvent.RoomMessageEncrypted) { + return {renderByType}; } - if (type === MessageEvent.RoomMessageEncrypted) { - return ( - - - - ); - } - if (type !== MessageEvent.RoomMessage) { - return ( - - - - ); - } - const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); - const getContent = (() => - editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback; - const senderId = mEvent.getSender() ?? ''; - const senderDisplayName = - getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; - return ( - setEditHistoryEvent(mEvent) : undefined} - getContent={getContent} - mediaAutoLoad={mediaAutoLoad} - urlPreview={showUrlPreview} - htmlReactParserOptions={htmlReactParserOptions} - linkifyOpts={linkifyOpts} - outlineAttachment={messageLayout === MessageLayout.Bubble} - eventId={mEventId} - /> - ); + return renderByType(); }, [room, mediaAutoLoad, showUrlPreview, htmlReactParserOptions, linkifyOpts, messageLayout], ); diff --git a/src/app/features/room/thread/useThread.ts b/src/app/features/room/thread/useThread.ts index 645b18574..43f170b2e 100644 --- a/src/app/features/room/thread/useThread.ts +++ b/src/app/features/room/thread/useThread.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import { + EventStatus, EventTimeline, MatrixClient, MatrixEvent, @@ -121,7 +122,15 @@ export const useThreadPendingEvents = ( const alreadyInThread = eventId !== undefined && thread?.findEventById(eventId) !== undefined; - const stillPending = isPendingThreadReply(event, threadRootId) && !alreadyInThread; + // Keep a tracked event through the SENT window too: the /send response + // flips status to SENT before /sync delivers the event into the thread + // timeline — dropping it there would make the message flash out of view. + // It falls out on the next LocalEchoUpdated once findEventById sees it. + const trackedAndAwaitingSync = + event.status === EventStatus.SENT && + prev.some((e) => e === event || (eventId !== undefined && e.getId() === eventId)); + const stillPending = + !alreadyInThread && (isPendingThreadReply(event, threadRootId) || trackedAndAwaitingSync); if (stillPending) return [...without, event]; return without.length === prev.length ? prev : without;