Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-thread-classic-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fix thread drawer showing no messages when using classic sync.
155 changes: 155 additions & 0 deletions src/app/features/room/ThreadDrawer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* Unit tests for getThreadReplyEvents — the function that decides which events
* to display as replies in the thread drawer.
*
* The bug these tests cover (classic-sync empty thread):
* room.createThread(id, root, [], false) creates a Thread whose .events array
* contains only the root event. After filtering out the root, the old code
* checked `if (fromThread.length > 0)` on the un-filtered array — which was
* truthy — and returned an empty list instead of falling back to the live
* timeline. The fix: filter first, then check.
*/
import { describe, it, expect } from 'vitest';
import { RelationType } from '$types/matrix-sdk';
import { getThreadReplyEvents } from './ThreadDrawer';

// ── Minimal MatrixEvent factory ───────────────────────────────────────────────

type EventInit = {
id: string;
threadRootId?: string;
/** When set, the event is treated as a reaction/annotation */
relType?: string;
};

function makeEvent({ id, threadRootId, relType }: EventInit) {
return {
getId: () => id,
threadRootId,
getRelation: () => (relType ? { rel_type: relType } : null),
getContent: () => ({}),
};
}

// ── Minimal Room factory ──────────────────────────────────────────────────────

type RoomInit = {
thread?: { events: ReturnType<typeof makeEvent>[] } | null;
liveEvents?: ReturnType<typeof makeEvent>[];
};

function makeRoom({ thread = null, liveEvents = [] }: RoomInit) {
return {
getThread: () => thread,
getUnfilteredTimelineSet: () => ({
getLiveTimeline: () => ({
getEvents: () => liveEvents,
}),
}),
};
}

// ── Tests ─────────────────────────────────────────────────────────────────────

const ROOT_ID = '$root-event-id';
const REPLY_ID = '$reply-event-id';
const REACTION_ID = '$reaction-event-id';

describe('getThreadReplyEvents', () => {
it('returns thread events minus the root when the Thread object has replies', () => {
const rootEvent = makeEvent({ id: ROOT_ID, threadRootId: ROOT_ID });
const replyEvent = makeEvent({ id: REPLY_ID, threadRootId: ROOT_ID });

const room = makeRoom({
thread: { events: [rootEvent, replyEvent] as any },
liveEvents: [],
});

const result = getThreadReplyEvents(room as any, ROOT_ID);

expect(result).toHaveLength(1);
expect(result[0].getId()).toBe(REPLY_ID);
});

it('excludes reactions from thread events', () => {
const rootEvent = makeEvent({ id: ROOT_ID, threadRootId: ROOT_ID });
const replyEvent = makeEvent({ id: REPLY_ID, threadRootId: ROOT_ID });
const reactionEvent = makeEvent({
id: REACTION_ID,
threadRootId: ROOT_ID,
relType: RelationType.Annotation,
});

const room = makeRoom({
thread: { events: [rootEvent, replyEvent, reactionEvent] as any },
liveEvents: [],
});

const result = getThreadReplyEvents(room as any, ROOT_ID);

expect(result).toHaveLength(1);
expect(result[0].getId()).toBe(REPLY_ID);
});

// ── Classic-sync empty-thread regression ──────────────────────────────────

it('falls back to the live timeline when thread.events contains only the root (classic-sync case)', () => {
// classic sync: thread created with no initialEvents → events = [rootEvent]
const rootEvent = makeEvent({ id: ROOT_ID, threadRootId: ROOT_ID });
const liveReply = makeEvent({ id: REPLY_ID, threadRootId: ROOT_ID });

const room = makeRoom({
thread: { events: [rootEvent] as any },
liveEvents: [liveReply as any],
});

const result = getThreadReplyEvents(room as any, ROOT_ID);

// Without the fix: `fromThread.length > 0` was truthy → returned filtered
// empty array. With the fix: filtered array is empty → falls back to live.
expect(result).toHaveLength(1);
expect(result[0].getId()).toBe(REPLY_ID);
});

it('falls back to the live timeline when there is no Thread object at all', () => {
const liveReply = makeEvent({ id: REPLY_ID, threadRootId: ROOT_ID });

const room = makeRoom({
thread: null,
liveEvents: [liveReply as any],
});

const result = getThreadReplyEvents(room as any, ROOT_ID);

expect(result).toHaveLength(1);
expect(result[0].getId()).toBe(REPLY_ID);
});

it('excludes events from the live timeline that belong to a different thread', () => {
const otherReply = makeEvent({ id: '$other-reply', threadRootId: '$other-root' });
const ourReply = makeEvent({ id: REPLY_ID, threadRootId: ROOT_ID });

const room = makeRoom({
thread: null,
liveEvents: [otherReply as any, ourReply as any],
});

const result = getThreadReplyEvents(room as any, ROOT_ID);

expect(result).toHaveLength(1);
expect(result[0].getId()).toBe(REPLY_ID);
});

it('returns an empty array when neither the thread nor the live timeline has replies', () => {
const rootEvent = makeEvent({ id: ROOT_ID, threadRootId: ROOT_ID });

const room = makeRoom({
thread: { events: [rootEvent] as any },
liveEvents: [],
});

const result = getThreadReplyEvents(room as any, ROOT_ID);

expect(result).toHaveLength(0);
});
});
78 changes: 58 additions & 20 deletions src/app/features/room/ThreadDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,36 @@ import { RoomInput } from './RoomInput';
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
import * as css from './ThreadDrawer.css';

/**
* Resolve the list of reply events to show in the thread drawer.
*
* Prefers events from the SDK Thread object (authoritative, full history) but
* falls back to scanning the main room timeline when the Thread object was
* created without `initialEvents` (as happens with classic sync). In that
* case `thread.events` contains only the root event, so filtering it yields an
* empty array — we must fall back rather than showing nothing.
*
* Exported for unit testing.
*/
export function getThreadReplyEvents(room: Room, threadRootId: string): MatrixEvent[] {
const thread = room.getThread(threadRootId);
const fromThread = thread?.events ?? [];
const filteredFromThread = fromThread.filter(
(ev) => ev.getId() !== threadRootId && !reactionOrEditEvent(ev)
);
if (filteredFromThread.length > 0) {
return filteredFromThread;
}
return room
.getUnfilteredTimelineSet()
.getLiveTimeline()
.getEvents()
.filter(
(ev) =>
ev.threadRootId === threadRootId && ev.getId() !== threadRootId && !reactionOrEditEvent(ev)
);
}

type ForwardedMessageProps = {
isForwarded: boolean;
originalTimestamp: number;
Expand Down Expand Up @@ -398,6 +428,33 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra

const rootEvent = room.findEventById(threadRootId);

// When the drawer is opened with classic sync, room.createThread() may have
// been called with empty initialEvents so thread.events only has the root.
// Backfill events from the main room timeline into the Thread object so the
// authoritative source is populated for subsequent renders and receipts.
useEffect(() => {
const thread = room.getThread(threadRootId);
if (!thread) return;
const hasRepliesInThread = thread.events.some(
(ev) => ev.getId() !== threadRootId && !reactionOrEditEvent(ev)
);
if (hasRepliesInThread) return; // already populated, nothing to do

const liveEvents = room
.getUnfilteredTimelineSet()
.getLiveTimeline()
.getEvents()
.filter(
(ev) =>
ev.threadRootId === threadRootId &&
ev.getId() !== threadRootId &&
!reactionOrEditEvent(ev)
);
if (liveEvents.length > 0) {
thread.addEvents(liveEvents, false);
}
}, [room, threadRootId]);

// Re-render when new thread events arrive (including reactions via ThreadEvent.Update).
useEffect(() => {
const isEventInThread = (mEvent: MatrixEvent): boolean => {
Expand Down Expand Up @@ -481,26 +538,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
markThreadAsRead();
}, [mx, room, threadRootId, forceUpdate]);

// Use the Thread object if available (authoritative source with full history).
// Fall back to scanning the live room timeline for local echoes and the
// window before the Thread object is registered by the SDK.
const replyEvents: MatrixEvent[] = (() => {
const thread = room.getThread(threadRootId);
const fromThread = thread?.events ?? [];
if (fromThread.length > 0) {
return fromThread.filter((ev) => ev.getId() !== threadRootId && !reactionOrEditEvent(ev));
}
return room
.getUnfilteredTimelineSet()
.getLiveTimeline()
.getEvents()
.filter(
(ev) =>
ev.threadRootId === threadRootId &&
ev.getId() !== threadRootId &&
!reactionOrEditEvent(ev)
);
})();
const replyEvents = getThreadReplyEvents(room, threadRootId);

replyEventsRef.current = replyEvents;

Expand Down
Loading