fix(threads): review-wave fixes — decryption re-render, receipt dedupe, chip perf

Two-reviewer audit of the thread stack; confirmed findings fixed:
- ThreadTimeline: wrap encrypted events in EncryptedContent so a live-arriving
  E2EE reply re-renders when its key decrypts (decryption emits neither
  RoomEvent.Timeline nor ThreadEvent.Update — previously stuck at "Unable to
  decrypt").
- ThreadPanel: mark-read deduped on the latest event id (RoomEvent.Timeline
  re-emits per backfilled event/edit/reaction; previously up to N receipt POSTs
  per panel open) + rejection handled with retry.
- RoomTimeline: ThreadSummary chips now mount only for events carrying thread
  data (each chip holds a room-level listener; one per rendered message would
  blow the SDK's 100-listener emitter cap) with a single room-level
  ThreadEvent.New tick for new-thread liveness.
- useThreadPendingEvents: keep a sent reply visible through the /send-response→
  /sync window (was flashing out of the pending strip before landing).
- ThreadTimeline: reseed the window on RoomEvent.TimelineReset (gappy sync left
  a detached timeline).

Documented-acceptable (reviewer-noted): thread typing shows as room typing (no
per-thread typing in the spec; Element matches), thread panel + members drawer
can be open together, scheduled-send is thread-unaware but unreachable there.

Gates: tsc clean, eslint 0 errors, build OK, 616/617 tests (1 IDB skip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 21:58:42 -04:00
parent aa62df9c75
commit 440c1fe948
4 changed files with 131 additions and 60 deletions
+73 -52
View File
@@ -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 <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />;
}
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 <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />;
}
const type = mEvent.getType();
if (type === MessageEvent.Sticker) {
return (
<MSticker
content={mEvent.getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
/>
);
}
if (type === MessageEvent.RoomMessageEncrypted) {
return (
<Text>
<MessageNotDecryptedContent />
</Text>
);
}
if (type !== MessageEvent.RoomMessage) {
return (
<Text>
<MessageUnsupportedContent />
</Text>
);
}
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 (
<MSticker
content={mEvent.getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
<RenderMessageContent
displayName={senderDisplayName}
msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()}
edited={!!editedEvent}
onEditHistoryClick={editedEvent ? () => 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 <EncryptedContent mEvent={mEvent}>{renderByType}</EncryptedContent>;
}
if (type === MessageEvent.RoomMessageEncrypted) {
return (
<Text>
<MessageNotDecryptedContent />
</Text>
);
}
if (type !== MessageEvent.RoomMessage) {
return (
<Text>
<MessageUnsupportedContent />
</Text>
);
}
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 (
<RenderMessageContent
displayName={senderDisplayName}
msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()}
edited={!!editedEvent}
onEditHistoryClick={editedEvent ? () => 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],
);