fix(threads): resolve root edits/reactions against the room timeline set

Non-thread relations targeting a thread root live only in the room's main
timeline set (Room.eventShouldLiveIn), so the panel never saw root edits
or reactions and re-reacting duplicated instead of toggling. Use the room
set for the root event, the thread set for replies.

Fixes #12

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-12 14:48:34 -04:00
co-authored by Claude Opus 5
parent 9019d7c21e
commit dfccaec9dc
@@ -545,9 +545,19 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
[room, thread, setReplyDraft, editor], [room, thread, setReplyDraft, editor],
); );
// Non-thread relations (reactions, edits) that target the thread root live only in
// the room's main timeline set (matrix-js-sdk Room.eventShouldLiveIn), so lookups
// for the root must use the room set instead of the thread set.
const getRelationTimelineSet = useCallback(
(eventId: string) =>
eventId === thread.id ? room.getUnfilteredTimelineSet() : thread.getUnfilteredTimelineSet(),
[room, thread],
);
const handleReactionToggle = useCallback( const handleReactionToggle = useCallback(
(targetEventId: string, key: string, shortcode?: string) => { (targetEventId: string, key: string, shortcode?: string) => {
const timelineSet = thread.getUnfilteredTimelineSet(); const isRoot = targetEventId === thread.id;
const timelineSet = getRelationTimelineSet(targetEventId);
const relations = getEventReactions(timelineSet, targetEventId); const relations = getEventReactions(timelineSet, targetEventId);
const allReactions = relations?.getSortedAnnotationsByKey() ?? []; const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? []; const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
@@ -563,13 +573,14 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined); (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
mx.sendEvent( mx.sendEvent(
room.roomId, room.roomId,
thread.id, // A reaction on the root is a main-timeline event, not a thread reply.
isRoot ? null : thread.id,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
MessageEvent.Reaction as any, MessageEvent.Reaction as any,
getReactionContent(targetEventId, key, rShortcode), getReactionContent(targetEventId, key, rShortcode),
); );
}, },
[mx, room, thread], [mx, room, thread, getRelationTimelineSet],
); );
const handleEdit = useCallback( const handleEdit = useCallback(
@@ -715,7 +726,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
): ReactNode => { ): ReactNode => {
const mEventId = mEvent.getId(); const mEventId = mEvent.getId();
if (!mEventId) return null; if (!mEventId) return null;
const timelineSet = thread.getUnfilteredTimelineSet(); const timelineSet = getRelationTimelineSet(mEventId);
const reactionRelations = getEventReactions(timelineSet, mEventId); const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations?.getSortedAnnotationsByKey(); const reactions = reactionRelations?.getSortedAnnotationsByKey();
const hasReactions = !!reactions && reactions.length > 0; const hasReactions = !!reactions && reactions.length > 0;
@@ -783,7 +794,6 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
); );
}, },
[ [
thread,
room, room,
messageSpacing, messageSpacing,
messageLayout, messageLayout,
@@ -810,6 +820,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
lotusTerminal, lotusTerminal,
mx, mx,
renderMessageContent, renderMessageContent,
getRelationTimelineSet,
], ],
); );