Skip to content

Commit 2a63f59

Browse files
committed
fix(threads): thread drawer empty with classic sync
With classic sync, RoomViewHeader creates Thread objects via room.createThread(id, rootEvent, [], false) — passing no initialEvents. This means thread.events starts as just [rootEvent] (or empty). Two bugs resulted: 1. The gate `if (fromThread.length > 0)` blocked the live-timeline fallback. Since thread.events = [rootEvent], length was 1 (truthy), but after filtering the root out the array was empty — yielding zero replies even though the events were present in the main room timeline. Fix: compute the filtered array first, then gate on its length so the fallback is reached when the thread object has no actual replies yet. 2. Even after #1, subsequent renders and read-receipt logic used thread.events (empty) rather than the live timeline. Fix: add a mount-time useEffect that backfills matching events from the unfiltered live timeline into the Thread object via thread.addEvents() so the authoritative source is populated for future interactions.
1 parent ab138a1 commit 2a63f59

1 file changed

Lines changed: 37 additions & 2 deletions

File tree

src/app/features/room/ThreadDrawer.tsx

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,33 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
398398

399399
const rootEvent = room.findEventById(threadRootId);
400400

401+
// When the drawer is opened with classic sync, room.createThread() may have
402+
// been called with empty initialEvents so thread.events only has the root.
403+
// Backfill events from the main room timeline into the Thread object so the
404+
// authoritative source is populated for subsequent renders and receipts.
405+
useEffect(() => {
406+
const thread = room.getThread(threadRootId);
407+
if (!thread) return;
408+
const hasRepliesInThread = thread.events.some(
409+
(ev) => ev.getId() !== threadRootId && !reactionOrEditEvent(ev)
410+
);
411+
if (hasRepliesInThread) return; // already populated, nothing to do
412+
413+
const liveEvents = room
414+
.getUnfilteredTimelineSet()
415+
.getLiveTimeline()
416+
.getEvents()
417+
.filter(
418+
(ev) =>
419+
ev.threadRootId === threadRootId &&
420+
ev.getId() !== threadRootId &&
421+
!reactionOrEditEvent(ev)
422+
);
423+
if (liveEvents.length > 0) {
424+
thread.addEvents(liveEvents, false);
425+
}
426+
}, [room, threadRootId]);
427+
401428
// Re-render when new thread events arrive (including reactions via ThreadEvent.Update).
402429
useEffect(() => {
403430
const isEventInThread = (mEvent: MatrixEvent): boolean => {
@@ -484,11 +511,19 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
484511
// Use the Thread object if available (authoritative source with full history).
485512
// Fall back to scanning the live room timeline for local echoes and the
486513
// window before the Thread object is registered by the SDK.
514+
// NOTE: With classic sync the Thread object is created via room.createThread()
515+
// with empty initialEvents, so thread.events may only contain the root event.
516+
// We must filter first, then decide whether to fall back — otherwise a thread
517+
// whose events array consists solely of the root event (length === 1) prevents
518+
// the live-timeline fallback from running, and the drawer shows nothing.
487519
const replyEvents: MatrixEvent[] = (() => {
488520
const thread = room.getThread(threadRootId);
489521
const fromThread = thread?.events ?? [];
490-
if (fromThread.length > 0) {
491-
return fromThread.filter((ev) => ev.getId() !== threadRootId && !reactionOrEditEvent(ev));
522+
const filteredFromThread = fromThread.filter(
523+
(ev) => ev.getId() !== threadRootId && !reactionOrEditEvent(ev)
524+
);
525+
if (filteredFromThread.length > 0) {
526+
return filteredFromThread;
492527
}
493528
return room
494529
.getUnfilteredTimelineSet()

0 commit comments

Comments
 (0)