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
+23 -1
View File
@@ -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<string | undefined>(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);