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-fallback-reply-spec.md
Original file line number Diff line number Diff line change
@@ -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.
47 changes: 41 additions & 6 deletions src/app/features/room/RoomInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { isKeyHotkey } from 'is-hotkey';
import {
EventType,
IContent,
MatrixEvent,
MsgType,
RelationType,
Room,
Expand Down Expand Up @@ -114,6 +115,7 @@ import { settingsAtom } from '$state/settings';
import {
getMemberDisplayName,
getMentionContent,
reactionOrEditEvent,
trimReplyFromBody,
trimReplyFromFormattedBody,
} from '$utils/room';
Expand Down Expand Up @@ -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 = {};
Expand All @@ -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 {
Expand Down Expand Up @@ -461,7 +495,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
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({
Expand Down Expand Up @@ -605,7 +640,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
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] });
Expand Down Expand Up @@ -792,7 +827,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
info,
};
if (replyDraft) {
content['m.relates_to'] = getReplyContent(replyDraft);
content['m.relates_to'] = getReplyContent(replyDraft, room);
if (threadRootId) {
setReplyDraft({
userId: mx.getUserId() ?? '',
Expand Down
Loading