Skip to content

Commit 621f402

Browse files
authored
Merge pull request #288 from SableClient/fix/thread-fallback-reply-spec
fix: set m.in_reply_to fallback to latest thread event per spec
2 parents c3cc021 + 6a36f5d commit 621f402

2 files changed

Lines changed: 46 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
default: patch
3+
---
4+
5+
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.

src/app/features/room/RoomInput.tsx

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { isKeyHotkey } from 'is-hotkey';
1414
import {
1515
EventType,
1616
IContent,
17+
MatrixEvent,
1718
MsgType,
1819
RelationType,
1920
Room,
@@ -114,6 +115,7 @@ import { settingsAtom } from '$state/settings';
114115
import {
115116
getMemberDisplayName,
116117
getMentionContent,
118+
reactionOrEditEvent,
117119
trimReplyFromBody,
118120
trimReplyFromFormattedBody,
119121
} from '$utils/room';
@@ -160,7 +162,33 @@ import {
160162
import { CommandAutocomplete } from './CommandAutocomplete';
161163
import { AudioMessageRecorder } from './AudioMessageRecorder';
162164

163-
const getReplyContent = (replyDraft: IReplyDraft | undefined): IEventRelation => {
165+
// Returns the event ID of the most recent non-reaction/non-edit event in a thread,
166+
// falling back to the thread root if no replies exist yet.
167+
const getLatestThreadEventId = (room: Room, threadRootId: string): string => {
168+
const thread = room.getThread(threadRootId);
169+
const threadEvents: MatrixEvent[] = thread?.events ?? [];
170+
const filtered = threadEvents.filter(
171+
(ev) => ev.getId() !== threadRootId && !reactionOrEditEvent(ev)
172+
);
173+
if (filtered.length > 0) {
174+
return filtered[filtered.length - 1].getId() ?? threadRootId;
175+
}
176+
// Fall back to the live timeline if the Thread object hasn't been registered yet
177+
const liveEvents = room
178+
.getUnfilteredTimelineSet()
179+
.getLiveTimeline()
180+
.getEvents()
181+
.filter(
182+
(ev) =>
183+
ev.threadRootId === threadRootId && ev.getId() !== threadRootId && !reactionOrEditEvent(ev)
184+
);
185+
if (liveEvents.length > 0) {
186+
return liveEvents[liveEvents.length - 1].getId() ?? threadRootId;
187+
}
188+
return threadRootId;
189+
};
190+
191+
const getReplyContent = (replyDraft: IReplyDraft | undefined, room?: Room): IEventRelation => {
164192
if (!replyDraft) return {};
165193

166194
const relatesTo: IEventRelation = {};
@@ -173,13 +201,19 @@ const getReplyContent = (replyDraft: IReplyDraft | undefined): IEventRelation =>
173201
// Check if this is a reply to a specific message in the thread
174202
// (replyDraft.body being empty means it's just a seeded thread draft)
175203
if (replyDraft.body && replyDraft.eventId !== replyDraft.relation.event_id) {
176-
// This is a reply to a message within the thread
204+
// Explicit reply to a specific message — per spec, is_falling_back must be false
177205
relatesTo['m.in_reply_to'] = {
178206
event_id: replyDraft.eventId,
179207
};
180208
relatesTo.is_falling_back = false;
181209
} else {
182-
// This is just a regular thread message
210+
// Regular thread message — per spec, include fallback m.in_reply_to pointing to the
211+
// most recent thread message so unthreaded clients can display it as a reply chain
212+
const threadRootId = replyDraft.relation.event_id ?? replyDraft.eventId;
213+
const latestEventId = room ? getLatestThreadEventId(room, threadRootId) : threadRootId;
214+
relatesTo['m.in_reply_to'] = {
215+
event_id: latestEventId,
216+
};
183217
relatesTo.is_falling_back = true;
184218
}
185219
} else {
@@ -461,7 +495,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
461495
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
462496

463497
if (contents.length > 0) {
464-
const replyContent = plainText?.length === 0 ? getReplyContent(replyDraft) : undefined;
498+
const replyContent =
499+
plainText?.length === 0 ? getReplyContent(replyDraft, room) : undefined;
465500
if (replyContent) contents[0]['m.relates_to'] = replyContent;
466501
if (threadRootId) {
467502
setReplyDraft({
@@ -605,7 +640,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
605640
content.formatted_body = formattedBody;
606641
}
607642
if (replyDraft) {
608-
content['m.relates_to'] = getReplyContent(replyDraft);
643+
content['m.relates_to'] = getReplyContent(replyDraft, room);
609644
}
610645
const invalidate = () =>
611646
queryClient.invalidateQueries({ queryKey: ['delayedEvents', roomId] });
@@ -792,7 +827,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
792827
info,
793828
};
794829
if (replyDraft) {
795-
content['m.relates_to'] = getReplyContent(replyDraft);
830+
content['m.relates_to'] = getReplyContent(replyDraft, room);
796831
if (threadRootId) {
797832
setReplyDraft({
798833
userId: mx.getUserId() ?? '',

0 commit comments

Comments
 (0)