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. diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index b280cf5d..1b0e6f5f 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,33 @@ 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 +201,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 ?? replyDraft.eventId; + const latestEventId = room ? getLatestThreadEventId(room, threadRootId) : threadRootId; + relatesTo['m.in_reply_to'] = { + event_id: latestEventId, + }; relatesTo.is_falling_back = true; } } else { @@ -461,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) : undefined; + const replyContent = + plainText?.length === 0 ? getReplyContent(replyDraft, room) : undefined; if (replyContent) contents[0]['m.relates_to'] = replyContent; if (threadRootId) { setReplyDraft({ @@ -605,7 +640,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 +827,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() ?? '',