From 559a61c9feb3a8665b9410bcefc6aba7f3190691 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Fri, 8 May 2026 16:26:25 -0500 Subject: [PATCH 1/2] attempt to fix thread hang bug --- src/app/features/room/RoomInput.tsx | 20 ++++-- src/app/features/room/RoomViewHeader.tsx | 23 ++++-- src/app/features/room/ThreadDrawer.test.ts | 71 ++++++++++++++++--- src/app/features/room/ThreadDrawer.tsx | 24 +++++-- src/app/features/room/message/Message.tsx | 10 ++- .../hooks/timeline/useProcessedTimeline.ts | 10 ++- .../timeline/useTimelineEventRenderer.tsx | 37 +++++++--- src/app/hooks/useTypingStatusUpdater.ts | 14 +++- src/app/utils/room.ts | 25 +++++++ src/app/utils/timeline.ts | 10 ++- 10 files changed, 200 insertions(+), 44 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 36469c3c6..850cffb50 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -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'; @@ -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; @@ -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; @@ -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, }; @@ -305,7 +311,7 @@ export const RoomInput = forwardRef( useState>(); 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 => { diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index c804f54ab..f45e6172f 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -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'; @@ -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); @@ -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); } }); @@ -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); @@ -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); diff --git a/src/app/features/room/ThreadDrawer.test.ts b/src/app/features/room/ThreadDrawer.test.ts index 51eebd904..fc4263a04 100644 --- a/src/app/features/room/ThreadDrawer.test.ts +++ b/src/app/features/room/ThreadDrawer.test.ts @@ -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: () => ({}), }; } @@ -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 }, @@ -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({ @@ -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 }, @@ -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, @@ -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, @@ -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); + }); }); diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 39cc66728..e8fb9ddd8 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -26,6 +26,7 @@ import { import { getEditedEvent, getMemberDisplayName, + isThreadRelationEvent, reactionOrEditEvent, unwrapRelationJumpTarget, } from '$utils/room'; @@ -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; @@ -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) ); } @@ -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; @@ -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 @@ -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; } @@ -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; } diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 090d78135..e495715e6 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -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'; @@ -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()!; diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index 56f7145f2..9609dafc0 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -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 { @@ -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; diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx index 131f7f457..b4d54d825 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx @@ -47,6 +47,7 @@ import { getEventReactions, getMemberDisplayName, isMembershipChanged, + isThreadRelationEvent, reactionOrEditEvent, getMemberAvatarMxc, } from '$utils/room'; @@ -130,7 +131,8 @@ function ThreadReplyChip({ // Prefer thread.events when available so avatars and preview text are populated. if (thread) { const fromThread = thread.events.filter( - (ev) => ev.getId() !== mEventId && !reactionOrEditEvent(ev) + (ev) => + ev.getId() !== mEventId && !reactionOrEditEvent(ev) && isThreadRelationEvent(ev, mEventId) ); if (fromThread.length > 0) return fromThread; } @@ -138,7 +140,8 @@ function ThreadReplyChip({ return linkedTimelines .flatMap((tl) => tl.getEvents()) .filter( - (ev) => ev.threadRootId === mEventId && ev.getId() !== mEventId && !reactionOrEditEvent(ev) + (ev) => + ev.getId() !== mEventId && !reactionOrEditEvent(ev) && isThreadRelationEvent(ev, mEventId) ); }, [room, mEventId, thread, counter]); @@ -354,12 +357,18 @@ export function useTimelineEventRenderer({ { [EventType.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => { const { replyEventId: rawReplyEventId, threadRootId } = mEvent; + const isThreadRel = isThreadRelationEvent(mEvent, threadRootId); + const actualThreadRootId = isThreadRel ? threadRootId : undefined; + const explicitInReplyTo = mEvent.getWireContent()?.['m.relates_to']?.['m.in_reply_to'] + ?.event_id as unknown; + const threadReplyTargetId = + isThreadRel && typeof explicitInReplyTo === 'string' ? explicitInReplyTo : undefined; // In the thread drawer (hideThreadChip=true), suppress reply headers for events // that only have m.in_reply_to as a non-thread-client fallback (is_falling_back: true). const replyEventId = hideThreadChip && mEvent.getWireContent()?.['m.relates_to']?.is_falling_back ? undefined - : rawReplyEventId; + : (threadReplyTargetId ?? rawReplyEventId); const reactionRelations = getEventReactions(timelineSet, mEventId); const reactions = reactionRelations?.getSortedAnnotationsByKey(); @@ -446,7 +455,7 @@ export function useTimelineEventRenderer({ room={room} timelineSet={timelineSet} replyEventId={replyEventId} - threadRootId={hideThreadChip ? undefined : threadRootId} + threadRootId={hideThreadChip ? undefined : actualThreadRootId} mentions={baseContent['m.mentions']} onClick={handleOpenReply} /> @@ -509,10 +518,16 @@ export function useTimelineEventRenderer({ }, [EventType.RoomMessageEncrypted]: (mEventId, mEvent, item, timelineSet, collapse) => { const { replyEventId: rawReplyEventId, threadRootId } = mEvent; + const isThreadRel = isThreadRelationEvent(mEvent, threadRootId); + const actualThreadRootId = isThreadRel ? threadRootId : undefined; + const explicitInReplyTo = mEvent.getWireContent()?.['m.relates_to']?.['m.in_reply_to'] + ?.event_id as unknown; + const threadReplyTargetId = + isThreadRel && typeof explicitInReplyTo === 'string' ? explicitInReplyTo : undefined; const replyEventId = hideThreadChip && mEvent.getWireContent()?.['m.relates_to']?.is_falling_back ? undefined - : rawReplyEventId; + : (threadReplyTargetId ?? rawReplyEventId); const reactionRelations = getEventReactions(timelineSet, mEventId); const reactions = reactionRelations?.getSortedAnnotationsByKey(); @@ -563,7 +578,7 @@ export function useTimelineEventRenderer({ room={room} timelineSet={timelineSet} replyEventId={replyEventId} - threadRootId={hideThreadChip ? undefined : threadRootId} + threadRootId={hideThreadChip ? undefined : actualThreadRootId} onClick={handleOpenReply} /> ) @@ -673,10 +688,16 @@ export function useTimelineEventRenderer({ }, [EventType.Sticker]: (mEventId, mEvent, item, timelineSet, collapse) => { const { replyEventId: rawReplyEventId, threadRootId } = mEvent; + const isThreadRel = isThreadRelationEvent(mEvent, threadRootId); + const actualThreadRootId = isThreadRel ? threadRootId : undefined; + const explicitInReplyTo = mEvent.getWireContent()?.['m.relates_to']?.['m.in_reply_to'] + ?.event_id as unknown; + const threadReplyTargetId = + isThreadRel && typeof explicitInReplyTo === 'string' ? explicitInReplyTo : undefined; const replyEventId = hideThreadChip && mEvent.getWireContent()?.['m.relates_to']?.is_falling_back ? undefined - : rawReplyEventId; + : (threadReplyTargetId ?? rawReplyEventId); const reactionRelations = getEventReactions(timelineSet, mEventId); const reactions = reactionRelations?.getSortedAnnotationsByKey(); @@ -719,7 +740,7 @@ export function useTimelineEventRenderer({ room={room} timelineSet={timelineSet} replyEventId={replyEventId} - threadRootId={hideThreadChip ? undefined : threadRootId} + threadRootId={hideThreadChip ? undefined : actualThreadRootId} mentions={content['m.mentions']} onClick={handleOpenReply} /> diff --git a/src/app/hooks/useTypingStatusUpdater.ts b/src/app/hooks/useTypingStatusUpdater.ts index 88851f8f2..bd35b5609 100644 --- a/src/app/hooks/useTypingStatusUpdater.ts +++ b/src/app/hooks/useTypingStatusUpdater.ts @@ -4,12 +4,22 @@ import { TYPING_TIMEOUT_MS } from '$state/typingMembers'; type TypingStatusUpdater = (typing: boolean) => void; -export const useTypingStatusUpdater = (mx: MatrixClient, roomId: string): TypingStatusUpdater => { +type TypingStatusUpdaterOptions = { + disabled?: boolean; +}; + +export const useTypingStatusUpdater = ( + mx: MatrixClient, + roomId: string, + options?: TypingStatusUpdaterOptions +): TypingStatusUpdater => { const statusSentTsRef = useRef(0); + const disabled = options?.disabled ?? false; const sendTypingStatus: TypingStatusUpdater = useMemo(() => { statusSentTsRef.current = 0; return (typing) => { + if (disabled) return; if (typing) { if (Date.now() - statusSentTsRef.current < TYPING_TIMEOUT_MS) { return; @@ -35,7 +45,7 @@ export const useTypingStatusUpdater = (mx: MatrixClient, roomId: string): Typing } statusSentTsRef.current = 0; }; - }, [mx, roomId]); + }, [mx, roomId, disabled]); return sendTypingStatus; }; diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index f252d95b5..e15630c79 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -7,6 +7,7 @@ import type { IPowerLevelsContent, IPushRule, IPushRules, + IThreadBundledRelationship, MatrixClient, MatrixEvent, Room, @@ -679,6 +680,30 @@ export const reactionOrEditEvent = (mEvent: MatrixEvent): boolean => { return false; }; +export const isThreadRelationEvent = (mEvent: MatrixEvent, threadRootId?: string): boolean => { + const relation = + mEvent.getRelation?.() ?? + ( + mEvent.getWireContent?.() as { + 'm.relates_to'?: { rel_type?: unknown; event_id?: unknown }; + } + )?.['m.relates_to'] ?? + ( + mEvent.getContent?.() as { + 'm.relates_to'?: { rel_type?: unknown; event_id?: unknown }; + } + )?.['m.relates_to']; + + return ( + relation?.rel_type === (RelationType.Thread as string) && + (threadRootId === undefined || relation.event_id === threadRootId) + ); +}; + +export const hasThreadRootAggregation = (mEvent: MatrixEvent): boolean => + (mEvent.getServerAggregatedRelation?.(RelationType.Thread as string) + ?.count ?? 0) > 0; + /** * Timeline rows skip reactions, edits, and other relation-only events. When jumping * to a reply target, unwrap to the event that is actually rendered (root of an diff --git a/src/app/utils/timeline.ts b/src/app/utils/timeline.ts index ce99dd7e5..3a8566be8 100644 --- a/src/app/utils/timeline.ts +++ b/src/app/utils/timeline.ts @@ -1,6 +1,11 @@ import type { EventTimeline, MatrixEvent, Room } from '$types/matrix-sdk'; import { Direction } from '$types/matrix-sdk'; -import { roomHaveNotification, roomHaveUnread, reactionOrEditEvent } from '$utils/room'; +import { + isThreadRelationEvent, + reactionOrEditEvent, + roomHaveNotification, + roomHaveUnread, +} from '$utils/room'; export const PAGINATION_LIMIT = 60; @@ -161,7 +166,8 @@ export const getThreadReplyCount = (room: Room, mEventId: string): number => { const threadEvents = tl .getEvents() .filter( - (ev) => ev.threadRootId === mEventId && ev.getId() !== mEventId && !reactionOrEditEvent(ev) + (ev) => + ev.getId() !== mEventId && !reactionOrEditEvent(ev) && isThreadRelationEvent(ev, mEventId) ); return acc + threadEvents.length; }, 0); From a37b32b91d8b49b9052e3846fe1d8f99192a6ba1 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Fri, 8 May 2026 16:28:44 -0500 Subject: [PATCH 2/2] changeset --- .changeset/fix-thread-reply-hang.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-thread-reply-hang.md diff --git a/.changeset/fix-thread-reply-hang.md b/.changeset/fix-thread-reply-hang.md new file mode 100644 index 000000000..ae8098b18 --- /dev/null +++ b/.changeset/fix-thread-reply-hang.md @@ -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.