From 43dba190ff8affdc12f8cf6208d7742b1604e068 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 13:31:29 -0400 Subject: [PATCH 1/4] fix: set m.in_reply_to fallback to latest thread event per spec --- src/app/features/room/RoomInput.tsx | 48 +++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index b280cf5d..e79395f5 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -14,6 +14,7 @@ import { isKeyHotkey } from 'is-hotkey'; import { EventType, IContent, + MatrixEvent, MsgType, RelationType, Room, @@ -114,6 +115,7 @@ import { settingsAtom } from '$state/settings'; import { getMemberDisplayName, getMentionContent, + reactionOrEditEvent, trimReplyFromBody, trimReplyFromFormattedBody, } from '$utils/room'; @@ -160,7 +162,35 @@ import { import { CommandAutocomplete } from './CommandAutocomplete'; import { AudioMessageRecorder } from './AudioMessageRecorder'; -const getReplyContent = (replyDraft: IReplyDraft | undefined): IEventRelation => { +// Returns the event ID of the most recent non-reaction/non-edit event in a thread, +// falling back to the thread root if no replies exist yet. +const getLatestThreadEventId = (room: Room, threadRootId: string): string => { + const thread = room.getThread(threadRootId); + const threadEvents: MatrixEvent[] = thread?.events ?? []; + const filtered = threadEvents.filter( + (ev) => ev.getId() !== threadRootId && !reactionOrEditEvent(ev) + ); + if (filtered.length > 0) { + return filtered[filtered.length - 1].getId() ?? threadRootId; + } + // Fall back to the live timeline if the Thread object hasn't been registered yet + const liveEvents = room + .getUnfilteredTimelineSet() + .getLiveTimeline() + .getEvents() + .filter( + (ev) => + ev.threadRootId === threadRootId && + ev.getId() !== threadRootId && + !reactionOrEditEvent(ev) + ); + if (liveEvents.length > 0) { + return liveEvents[liveEvents.length - 1].getId() ?? threadRootId; + } + return threadRootId; +}; + +const getReplyContent = (replyDraft: IReplyDraft | undefined, room?: Room): IEventRelation => { if (!replyDraft) return {}; const relatesTo: IEventRelation = {}; @@ -173,13 +203,19 @@ const getReplyContent = (replyDraft: IReplyDraft | undefined): IEventRelation => // Check if this is a reply to a specific message in the thread // (replyDraft.body being empty means it's just a seeded thread draft) if (replyDraft.body && replyDraft.eventId !== replyDraft.relation.event_id) { - // This is a reply to a message within the thread + // Explicit reply to a specific message — per spec, is_falling_back must be false relatesTo['m.in_reply_to'] = { event_id: replyDraft.eventId, }; relatesTo.is_falling_back = false; } else { - // This is just a regular thread message + // Regular thread message — per spec, include fallback m.in_reply_to pointing to the + // most recent thread message so unthreaded clients can display it as a reply chain + const threadRootId = replyDraft.relation.event_id; + const latestEventId = room ? getLatestThreadEventId(room, threadRootId) : threadRootId; + relatesTo['m.in_reply_to'] = { + event_id: latestEventId, + }; relatesTo.is_falling_back = true; } } else { @@ -461,7 +497,7 @@ export const RoomInput = forwardRef( const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); if (contents.length > 0) { - const replyContent = plainText?.length === 0 ? getReplyContent(replyDraft) : undefined; + const replyContent = plainText?.length === 0 ? getReplyContent(replyDraft, room) : undefined; if (replyContent) contents[0]['m.relates_to'] = replyContent; if (threadRootId) { setReplyDraft({ @@ -605,7 +641,7 @@ export const RoomInput = forwardRef( content.formatted_body = formattedBody; } if (replyDraft) { - content['m.relates_to'] = getReplyContent(replyDraft); + content['m.relates_to'] = getReplyContent(replyDraft, room); } const invalidate = () => queryClient.invalidateQueries({ queryKey: ['delayedEvents', roomId] }); @@ -792,7 +828,7 @@ export const RoomInput = forwardRef( info, }; if (replyDraft) { - content['m.relates_to'] = getReplyContent(replyDraft); + content['m.relates_to'] = getReplyContent(replyDraft, room); if (threadRootId) { setReplyDraft({ userId: mx.getUserId() ?? '', From b74cb0bb28ff473d960cbbf7adbda2048a50d396 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 13:56:08 -0400 Subject: [PATCH 2/4] chore: add changeset for thread fallback reply fix --- .changeset/fix-thread-fallback-reply-spec.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-thread-fallback-reply-spec.md diff --git a/.changeset/fix-thread-fallback-reply-spec.md b/.changeset/fix-thread-fallback-reply-spec.md new file mode 100644 index 00000000..6eecf251 --- /dev/null +++ b/.changeset/fix-thread-fallback-reply-spec.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix thread messages to include the required `m.in_reply_to` fallback pointing to the latest thread event, so unthreaded clients can display the reply chain correctly per the Matrix spec. From df8e5e0cc78e830b7fc5d0be6f08f03b9441f705 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 13:58:13 -0400 Subject: [PATCH 3/4] style: fix prettier formatting in RoomInput --- src/app/features/room/RoomInput.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index e79395f5..bde6a2de 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -180,9 +180,7 @@ const getLatestThreadEventId = (room: Room, threadRootId: string): string => { .getEvents() .filter( (ev) => - ev.threadRootId === threadRootId && - ev.getId() !== threadRootId && - !reactionOrEditEvent(ev) + ev.threadRootId === threadRootId && ev.getId() !== threadRootId && !reactionOrEditEvent(ev) ); if (liveEvents.length > 0) { return liveEvents[liveEvents.length - 1].getId() ?? threadRootId; @@ -497,7 +495,8 @@ export const RoomInput = forwardRef( const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); if (contents.length > 0) { - const replyContent = plainText?.length === 0 ? getReplyContent(replyDraft, room) : undefined; + const replyContent = + plainText?.length === 0 ? getReplyContent(replyDraft, room) : undefined; if (replyContent) contents[0]['m.relates_to'] = replyContent; if (threadRootId) { setReplyDraft({ From 6a36f5dc1c36cc2f46b96bd967747d837a4fc581 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 14:03:34 -0400 Subject: [PATCH 4/4] fix: resolve string | undefined type error for thread event_id --- src/app/features/room/RoomInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index bde6a2de..1b0e6f5f 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -209,7 +209,7 @@ const getReplyContent = (replyDraft: IReplyDraft | undefined, room?: Room): IEve } else { // Regular thread message — per spec, include fallback m.in_reply_to pointing to the // most recent thread message so unthreaded clients can display it as a reply chain - const threadRootId = replyDraft.relation.event_id; + const threadRootId = replyDraft.relation.event_id ?? replyDraft.eventId; const latestEventId = room ? getLatestThreadEventId(room, threadRootId) : threadRootId; relatesTo['m.in_reply_to'] = { event_id: latestEventId,