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;