diff --git a/.changeset/fix-thread-classic-sync.md b/.changeset/fix-thread-classic-sync.md new file mode 100644 index 000000000..bb8cda0b5 --- /dev/null +++ b/.changeset/fix-thread-classic-sync.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix thread drawer showing no messages when using classic sync. diff --git a/src/app/features/room/ThreadDrawer.test.ts b/src/app/features/room/ThreadDrawer.test.ts new file mode 100644 index 000000000..ef7863480 --- /dev/null +++ b/src/app/features/room/ThreadDrawer.test.ts @@ -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[] } | null; + liveEvents?: ReturnType[]; +}; + +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); + }); +}); diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 00957bc34..23ef1ff0a 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -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; @@ -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 => { @@ -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;