Skip to content
Open
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-reply-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fixed the hang when a message that replies to a message has a reply, and you attempt to start a thread on that message.
20 changes: 13 additions & 7 deletions src/app/features/room/RoomInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ import { safeFile } from '$utils/mimeTypes';
import { fulfilledPromiseSettledResult } from '$utils/common';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
import { getMentionContent, reactionOrEditEvent } from '$utils/room';
import { getMentionContent, isThreadRelationEvent, reactionOrEditEvent } from '$utils/room';
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '$hooks/useCommands';
import { mobileOrTablet } from '$utils/user-agent';
import { useElementSizeObserver } from '$hooks/useElementSizeObserver';
Expand Down Expand Up @@ -162,7 +162,10 @@ 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)
(ev) =>
ev.getId() !== threadRootId &&
!reactionOrEditEvent(ev) &&
isThreadRelationEvent(ev, threadRootId)
);
if (filtered.length > 0) {
return filtered[filtered.length - 1]!.getId() ?? threadRootId;
Expand All @@ -174,7 +177,9 @@ const getLatestThreadEventId = (room: Room, threadRootId: string): string => {
.getEvents()
.filter(
(ev) =>
ev.threadRootId === threadRootId && ev.getId() !== threadRootId && !reactionOrEditEvent(ev)
ev.getId() !== threadRootId &&
!reactionOrEditEvent(ev) &&
isThreadRelationEvent(ev, threadRootId)
);
if (liveEvents.length > 0) {
return liveEvents.at(-1)!.getId() ?? threadRootId;
Expand All @@ -192,10 +197,11 @@ const getReplyContent = (replyDraft: IReplyDraft | undefined, room?: Room): IEve
relatesTo.event_id = replyDraft.relation.event_id;
relatesTo.rel_type = RelationType.Thread;

// Check if this is a reply to a specific message in the thread
// If the user explicitly clicked "reply" on a message (including the thread root),
// we must set is_falling_back=false and target that message directly.
// (replyDraft.body being empty means it's just a seeded thread draft)
if (replyDraft.body && replyDraft.eventId !== replyDraft.relation.event_id) {
// Explicit reply to a specific message — per spec, is_falling_back must be false
if (replyDraft.body) {
// Explicit reply — per spec, is_falling_back must be false
relatesTo['m.in_reply_to'] = {
event_id: replyDraft.eventId,
};
Expand Down Expand Up @@ -305,7 +311,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
useState<AutocompleteQuery<AutocompletePrefix>>();
const [isQuickTextReact, setQuickTextReact] = useState(false);

const sendTypingStatus = useTypingStatusUpdater(mx, roomId);
const sendTypingStatus = useTypingStatusUpdater(mx, roomId, { disabled: !!threadRootId });

const [inputKey, setInputKey] = useState(0);
const getUploadItemKey = useCallback((fileItem: TUploadItem): string => {
Expand Down
23 changes: 17 additions & 6 deletions src/app/features/room/RoomViewHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import { useRoomPermissions } from '$hooks/useRoomPermissions';
import { InviteUserPrompt } from '$components/invite-user-prompt';
import { ContainerColor } from '$styles/ContainerColor.css';
import { useRoomWidgets } from '$hooks/useRoomWidgets';
import { hasThreadRootAggregation, isThreadRelationEvent } from '$utils/room';

import { DirectInvitePrompt } from '$components/direct-invite-prompt';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
Expand Down Expand Up @@ -439,8 +440,9 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) {
// 1. Events that ARE thread roots (have isThreadRoot = true or have replies)
// 2. Events that are IN threads (have threadRootId)
events.forEach((event: MatrixEvent) => {
// Check if this event is a thread root
if (event.isThreadRoot) {
// Check if this event is an actual thread root. `isThreadRoot` can be
// polluted by locally-created Thread shells, so require the server bundle.
if (hasThreadRootAggregation(event)) {
const rootId = event.getId();
if (rootId && !room.getThread(rootId)) {
threadRoots.add(rootId);
Expand All @@ -449,7 +451,11 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) {

// Check if this event is a reply in a thread
const { threadRootId } = event;
if (threadRootId && !room.getThread(threadRootId)) {
if (
threadRootId &&
isThreadRelationEvent(event, threadRootId) &&
!room.getThread(threadRootId)
) {
threadRoots.add(threadRootId);
}
});
Expand All @@ -476,8 +482,9 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) {

// Listen for new timeline events (including pagination)
const handleTimelineEvent = (mEvent: MatrixEvent) => {
// Check if this event is a thread root
if (mEvent.isThreadRoot) {
// Check if this event is an actual thread root. `isThreadRoot` can be
// polluted by locally-created Thread shells, so require the server bundle.
if (hasThreadRootAggregation(mEvent)) {
const rootId = mEvent.getId();
if (rootId && !room.getThread(rootId)) {
const rootEvent = room.findEventById(rootId);
Expand All @@ -489,7 +496,11 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) {

// Check if this is a reply in a thread
const { threadRootId } = mEvent;
if (threadRootId && !room.getThread(threadRootId)) {
if (
threadRootId &&
isThreadRelationEvent(mEvent, threadRootId) &&
!room.getThread(threadRootId)
) {
const rootEvent = room.findEventById(threadRootId);
if (rootEvent) {
room.createThread(threadRootId, rootEvent, [], false);
Expand Down
71 changes: 63 additions & 8 deletions src/app/features/room/ThreadDrawer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,16 @@ type EventInit = {
threadRootId?: string;
/** When set, the event is treated as a reaction/annotation */
relType?: string;
/** Relation target event id */
relEventId?: string;
};

function makeEvent({ id, threadRootId, relType }: EventInit) {
function makeEvent({ id, threadRootId, relType, relEventId }: EventInit) {
return {
getId: () => id,
threadRootId,
getRelation: () => (relType ? { rel_type: relType } : null),
getRelation: () => (relType ? { rel_type: relType, event_id: relEventId } : null),
getWireContent: () => ({}),
getContent: () => ({}),
};
}
Expand Down Expand Up @@ -61,7 +64,12 @@ 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 replyEvent = makeEvent({
id: REPLY_ID,
threadRootId: ROOT_ID,
relType: RelationType.Thread,
relEventId: ROOT_ID,
});

const room = makeRoom({
thread: { events: [rootEvent, replyEvent] as any },
Expand All @@ -76,11 +84,17 @@ describe('getThreadReplyEvents', () => {

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 replyEvent = makeEvent({
id: REPLY_ID,
threadRootId: ROOT_ID,
relType: RelationType.Thread,
relEventId: ROOT_ID,
});
const reactionEvent = makeEvent({
id: REACTION_ID,
threadRootId: ROOT_ID,
relType: RelationType.Annotation,
relEventId: REPLY_ID,
});

const room = makeRoom({
Expand All @@ -99,7 +113,12 @@ describe('getThreadReplyEvents', () => {
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 liveReply = makeEvent({
id: REPLY_ID,
threadRootId: ROOT_ID,
relType: RelationType.Thread,
relEventId: ROOT_ID,
});

const room = makeRoom({
thread: { events: [rootEvent] as any },
Expand All @@ -115,7 +134,12 @@ describe('getThreadReplyEvents', () => {
});

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 liveReply = makeEvent({
id: REPLY_ID,
threadRootId: ROOT_ID,
relType: RelationType.Thread,
relEventId: ROOT_ID,
});

const room = makeRoom({
thread: null,
Expand All @@ -129,8 +153,18 @@ describe('getThreadReplyEvents', () => {
});

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 otherReply = makeEvent({
id: '$other-reply',
threadRootId: '$other-root',
relType: RelationType.Thread,
relEventId: '$other-root',
});
const ourReply = makeEvent({
id: REPLY_ID,
threadRootId: ROOT_ID,
relType: RelationType.Thread,
relEventId: ROOT_ID,
});

const room = makeRoom({
thread: null,
Expand All @@ -155,4 +189,25 @@ describe('getThreadReplyEvents', () => {

expect(result).toHaveLength(0);
});

it('excludes non-thread replies even if the SDK has assigned the same threadRootId', () => {
const rootEvent = makeEvent({ id: ROOT_ID, threadRootId: ROOT_ID });
const plainReply = makeEvent({ id: '$plain-reply', threadRootId: ROOT_ID });
const threadReply = makeEvent({
id: REPLY_ID,
threadRootId: ROOT_ID,
relType: RelationType.Thread,
relEventId: ROOT_ID,
});

const room = makeRoom({
thread: { events: [rootEvent] as any },
liveEvents: [plainReply as any, threadReply as any],
});

const result = getThreadReplyEvents(room as any, ROOT_ID);

expect(result).toHaveLength(1);
expect(result[0]?.getId()).toBe(REPLY_ID);
});
});
24 changes: 17 additions & 7 deletions src/app/features/room/ThreadDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import {
getEditedEvent,
getMemberDisplayName,
isThreadRelationEvent,
reactionOrEditEvent,
unwrapRelationJumpTarget,
} from '$utils/room';
Expand Down Expand Up @@ -79,7 +80,10 @@ export function getThreadReplyEvents(room: Room, threadRootId: string): MatrixEv
const thread = room.getThread(threadRootId);
const fromThread = thread?.events ?? [];
const filteredFromThread = fromThread.filter(
(ev) => ev.getId() !== threadRootId && !reactionOrEditEvent(ev)
(ev) =>
ev.getId() !== threadRootId &&
!reactionOrEditEvent(ev) &&
isThreadRelationEvent(ev, threadRootId)
);
if (filteredFromThread.length > 0) {
return filteredFromThread;
Expand All @@ -90,7 +94,9 @@ export function getThreadReplyEvents(room: Room, threadRootId: string): MatrixEv
.getEvents()
.filter(
(ev) =>
ev.threadRootId === threadRootId && ev.getId() !== threadRootId && !reactionOrEditEvent(ev)
ev.getId() !== threadRootId &&
!reactionOrEditEvent(ev) &&
isThreadRelationEvent(ev, threadRootId)
);
}

Expand Down Expand Up @@ -309,7 +315,10 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
// thread.events is still empty (classic sync path; server-side was already
// populated by paginateEventTimeline inside updateThreadMetadata).
const hasRepliesInThread = currThread.events.some(
(ev) => ev.getId() !== threadRootId && !reactionOrEditEvent(ev)
(ev) =>
ev.getId() !== threadRootId &&
!reactionOrEditEvent(ev) &&
isThreadRelationEvent(ev, threadRootId)
);
if (hasRepliesInThread) return;

Expand All @@ -319,9 +328,9 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
.getEvents()
.filter(
(ev) =>
ev.threadRootId === threadRootId &&
ev.getId() !== threadRootId &&
!reactionOrEditEvent(ev)
!reactionOrEditEvent(ev) &&
isThreadRelationEvent(ev, threadRootId)
);
if (liveEvents.length > 0) {
// thread.addEvents() is typed as void but is internally async; schedule
Expand All @@ -346,7 +355,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
useEffect(() => {
const isEventInThread = (mEvent: MatrixEvent): boolean => {
// Direct thread message or the root itself
if (mEvent.threadRootId === threadRootId || mEvent.getId() === threadRootId) {
if (mEvent.getId() === threadRootId || isThreadRelationEvent(mEvent, threadRootId)) {
return true;
}

Expand All @@ -358,7 +367,8 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
const targetEvent = room.findEventById(targetEventId);
if (
targetEvent &&
(targetEvent.threadRootId === threadRootId || targetEvent.getId() === threadRootId)
(targetEvent.getId() === threadRootId ||
isThreadRelationEvent(targetEvent, threadRootId))
) {
return true;
}
Expand Down
10 changes: 8 additions & 2 deletions src/app/features/room/message/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,13 @@ import {
Username,
UsernameBold,
} from '$components/message';
import { canEditEvent, getEditedEvent, getEventEdits, getMemberAvatarMxc } from '$utils/room';
import {
canEditEvent,
getEditedEvent,
getEventEdits,
getMemberAvatarMxc,
isThreadRelationEvent,
} from '$utils/room';
import { mxcUrlToHttp } from '$utils/matrix';
import type { MessageSpacing } from '$state/settings';
import { getSettings, MessageLayout, settingsAtom } from '$state/settings';
Expand Down Expand Up @@ -839,7 +845,7 @@ function MessageInternal(
setMobileOptionsOpen(true);
});

const isThreadedMessage = mEvent.threadRootId !== undefined;
const isThreadedMessage = isThreadRelationEvent(mEvent, mEvent.threadRootId);
const isStickerMessage = mEvent.getType() === 'm.sticker';

const evtId = mEvent.getId()!;
Expand Down
10 changes: 8 additions & 2 deletions src/app/hooks/timeline/useProcessedTimeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
getTimelineRelativeIndex,
getTimelineEvent,
} from '$utils/timeline';
import { reactionOrEditEvent, isMembershipChanged } from '$utils/room';
import { isMembershipChanged, isThreadRelationEvent, reactionOrEditEvent } from '$utils/room';
import { inSameDay, minuteDifference } from '$utils/time';

export interface UseProcessedTimelineOptions {
Expand Down Expand Up @@ -132,7 +132,13 @@ export function useProcessedTimeline({
}
}

if (!skipThreadFilter && threadRootId !== undefined && threadRootId !== mEventId) return acc;
if (
!skipThreadFilter &&
threadRootId !== undefined &&
threadRootId !== mEventId &&
isThreadRelationEvent(mEvent, threadRootId)
)
return acc;

const isReactionOrEdit = reactionOrEditEvent(mEvent);
if (isReactionOrEdit) return acc;
Expand Down
Loading
Loading