diff --git a/.changeset/fix-timeline-replies.md b/.changeset/fix-timeline-replies.md new file mode 100644 index 000000000..47de064ea --- /dev/null +++ b/.changeset/fix-timeline-replies.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fixed replying to reaction/edit events not replying. diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 8a3b206bc..4c631a5af 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -25,10 +25,11 @@ import { renderMatrixMention, } from '$plugins/react-custom-html-parser'; import { - getEditedEvent, + extractReplyDraftBody, getMemberDisplayName, isThreadRelationEvent, reactionOrEditEvent, + resolveReplyDraftTarget, unwrapRelationJumpTarget, } from '$utils/room'; import { getMxIdLocalPart, toggleReaction } from '$utils/matrix'; @@ -67,7 +68,6 @@ import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollo import * as css from './ThreadDrawer.css'; import { SidebarResizer } from '$pages/client/sidebar/SidebarResizer'; import { mobileOrTablet } from '$utils/user-agent'; -import { M_TEXT } from 'matrix-js-sdk'; /** * Resolve the list of reply events to show in the thread drawer. @@ -569,28 +569,25 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra }); return; } - const replyEvt = room.findEventById(replyId); - if (!replyEvt) return; - const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet()); - const content = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent(); - const { body, formatted_body: formattedBody } = content; - const msc1767body = content[M_TEXT.name]; + const resolved = resolveReplyDraftTarget(room, replyId); + if (!resolved) return; + const { eventId: draftEventId, replyEvt } = resolved; + const { body, formattedBody } = extractReplyDraftBody( + replyEvt, + room.getUnfilteredTimelineSet() + ); const senderId = replyEvt.getSender(); if (senderId) { const draft: IReplyDraft = { userId: senderId, - eventId: replyId, - body: - (body as string) ?? - (msc1767body as string) ?? - (msc1767body as { body: string }).body ?? - '', + eventId: draftEventId, + body, formattedBody, relation: { rel_type: RelationType.Thread, event_id: threadRootId }, }; // Only toggle off if we're actively replying to this event (non-empty body distinguishes // a real reply draft from the seeded base-thread draft, which has body: ''). - if (activeReplyId === replyId && replyDraft?.body) { + if (activeReplyId === draftEventId && replyDraft?.body) { // Toggle off — reset to base thread draft setReplyDraft({ userId: mx.getUserId() ?? '', diff --git a/src/app/hooks/timeline/useTimelineActions.ts b/src/app/hooks/timeline/useTimelineActions.ts index 090125476..02bab7962 100644 --- a/src/app/hooks/timeline/useTimelineActions.ts +++ b/src/app/hooks/timeline/useTimelineActions.ts @@ -1,16 +1,15 @@ import type { MouseEventHandler } from 'react'; import { useCallback } from 'react'; -import type { MatrixClient, Room, MatrixEvent, IContent } from '$types/matrix-sdk'; +import type { MatrixClient, Room, MatrixEvent } from '$types/matrix-sdk'; import type { UserProfile } from '$hooks/useUserProfile'; import { EventStatus } from '$types/matrix-sdk'; import type { Editor } from 'slate'; import { ReactEditor } from 'slate-react'; import { getMxIdLocalPart, toggleReaction } from '$utils/matrix'; -import { getMemberDisplayName, getEditedEvent } from '$utils/room'; +import { extractReplyDraftBody, getMemberDisplayName, resolveReplyDraftTarget } from '$utils/room'; import { createMentionElement, moveCursor } from '$components/editor'; import * as prefix from '$unstable/prefixes'; -import { M_TEXT } from 'matrix-js-sdk'; export interface UseTimelineActionsOptions { room: Room; @@ -127,28 +126,21 @@ export function useTimelineActions({ const triggerReply = useCallback( (replyId: string, startThread = false) => { - if (activeReplyId === replyId) { - setReplyDraft(undefined); - return; - } - - const replyEvt = room.findEventById(replyId); - if (!replyEvt) return; + const resolved = resolveReplyDraftTarget(room, replyId); + if (!resolved) return; - const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet()); + const { eventId: draftEventId, replyEvt } = resolved; - let editedNewContent: unknown; - - if (editedReply) { - editedNewContent = editedReply.getContent()['m.new_content']; + if (activeReplyId === draftEventId) { + setReplyDraft(undefined); + return; } - const content: IContent = (editedNewContent ?? replyEvt.getContent()) as IContent; - const { body, formatted_body: formattedBody } = content; - const msc1767body = content[M_TEXT.name]; + const timelineSet = room.getUnfilteredTimelineSet(); + const { body, formattedBody } = extractReplyDraftBody(replyEvt, timelineSet); const { 'm.relates_to': relation } = startThread - ? { 'm.relates_to': { rel_type: 'm.thread', event_id: replyId } } + ? { 'm.relates_to': { rel_type: 'm.thread', event_id: draftEventId } } : replyEvt.getWireContent(); const senderId = replyEvt.getSender(); @@ -156,13 +148,9 @@ export function useTimelineActions({ if (senderId) { setReplyDraft({ userId: senderId, - eventId: replyId, - body: - (body as string) ?? - (msc1767body as string) ?? - (msc1767body as { body: string }).body ?? - '', - formattedBody: typeof formattedBody === 'string' ? formattedBody : '', + eventId: draftEventId, + body, + formattedBody, relation, }); } diff --git a/src/app/utils/room.reply.test.ts b/src/app/utils/room.reply.test.ts new file mode 100644 index 000000000..f91731b17 --- /dev/null +++ b/src/app/utils/room.reply.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest'; +import { EventType, RelationType } from '$types/matrix-sdk'; +import type { EventTimelineSet, MatrixEvent, Room } from '$types/matrix-sdk'; +import { extractReplyDraftBody, findRoomEventById, resolveReplyDraftTarget } from './room'; + +/* oxlint-disable typescript/no-explicit-any */ + +const createMessageEvent = (id: string, body: string, sender = '@alice:example.com') => + ({ + getId: () => id, + getType: () => EventType.RoomMessage, + getSender: () => sender, + getContent: () => ({ body, msgtype: 'm.text' }), + getWireContent: () => ({ body, msgtype: 'm.text' }), + getRelation: () => undefined, + isRedaction: () => false, + isRedacted: () => false, + }) as unknown as MatrixEvent; + +const createReactionEvent = (id: string, targetId: string) => + ({ + getId: () => id, + getType: () => EventType.Reaction, + getSender: () => '@bob:example.com', + getContent: () => ({ + 'm.relates_to': { + rel_type: RelationType.Annotation, + event_id: targetId, + key: '👍', + }, + }), + getWireContent: () => ({ + 'm.relates_to': { + rel_type: RelationType.Annotation, + event_id: targetId, + key: '👍', + }, + }), + getRelation: () => ({ + rel_type: RelationType.Annotation, + event_id: targetId, + key: '👍', + }), + isRedaction: () => false, + isRedacted: () => false, + }) as unknown as MatrixEvent; + +const createEditEvent = (id: string, targetId: string, newBody: string) => + ({ + getId: () => id, + getType: () => EventType.RoomMessage, + getSender: () => '@alice:example.com', + getContent: () => ({ + 'm.new_content': { body: newBody, msgtype: 'm.text' }, + 'm.relates_to': { + rel_type: RelationType.Replace, + event_id: targetId, + }, + }), + getWireContent: () => ({ + 'm.new_content': { body: newBody, msgtype: 'm.text' }, + 'm.relates_to': { + rel_type: RelationType.Replace, + event_id: targetId, + }, + }), + getRelation: () => ({ + rel_type: RelationType.Replace, + event_id: targetId, + }), + isRedaction: () => false, + isRedacted: () => false, + }) as unknown as MatrixEvent; + +const createTimelineSet = ( + events: Record, + relations: Record = {} +) => { + const parents = Object.values(events).filter( + (event) => event.getType() === EventType.RoomMessage + ); + + return { + findEventById: (id: string) => events[id], + getTimelines: () => [{ getEvents: () => parents }], + relations: { + getChildEventsForEvent: (eventId: string, relType: string, eventType: string) => { + const key = `${eventId}:${relType}:${eventType}`; + const relEvents = relations[key] ?? []; + return { + getRelations: () => relEvents, + }; + }, + }, + } as unknown as EventTimelineSet; +}; + +describe('findRoomEventById', () => { + it('finds relation-only events via timeline relations', () => { + const message = createMessageEvent('$msg', 'hello'); + const reaction = createReactionEvent('$reaction', '$msg'); + const timelineSet = createTimelineSet( + { $msg: message }, + { + [`$msg:${RelationType.Annotation}:${EventType.Reaction}`]: [reaction], + } + ); + + const room = { + findEventById: () => undefined, + getUnfilteredTimelineSet: () => timelineSet, + } as unknown as Room; + + expect(findRoomEventById(room, '$reaction', timelineSet)?.getId()).toBe('$reaction'); + }); +}); + +describe('resolveReplyDraftTarget', () => { + it('keeps reaction replies on the reaction event', () => { + const message = createMessageEvent('$msg', 'hello there'); + const reaction = createReactionEvent('$reaction', '$msg'); + const timelineSet = createTimelineSet( + { $msg: message }, + { + [`$msg:${RelationType.Annotation}:${EventType.Reaction}`]: [reaction], + } + ); + + const room = { + findEventById: (id: string) => (id === '$msg' ? message : undefined), + getUnfilteredTimelineSet: () => timelineSet, + } as unknown as Room; + + const resolved = resolveReplyDraftTarget(room, '$reaction', timelineSet); + expect(resolved?.eventId).toBe('$reaction'); + expect(resolved?.replyEvt.getId()).toBe('$reaction'); + + const { body } = extractReplyDraftBody(resolved!.replyEvt, timelineSet); + expect(body).toBe(''); + }); + + it('keeps edit replies on the edit event with edited body text', () => { + const message = createMessageEvent('$msg', 'hello there'); + const edit = createEditEvent('$edit', '$msg', 'hello edited'); + const timelineSet = createTimelineSet( + { $msg: message }, + { + [`$msg:${RelationType.Replace}:${EventType.RoomMessage}`]: [edit], + } + ); + + const room = { + findEventById: (id: string) => { + if (id === '$msg') return message; + if (id === '$edit') return edit; + return undefined; + }, + getUnfilteredTimelineSet: () => timelineSet, + } as unknown as Room; + + const resolved = resolveReplyDraftTarget(room, '$edit', timelineSet); + expect(resolved?.eventId).toBe('$edit'); + expect(resolved?.replyEvt.getId()).toBe('$edit'); + + const { body } = extractReplyDraftBody(resolved!.replyEvt, timelineSet); + expect(body).toBe('hello edited'); + }); +}); diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 0eb3b4ca5..f22ee3c17 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -1032,6 +1032,96 @@ export const unwrapRelationJumpTarget = (room: Room, eventId: string, maxHops = return current; }; +const findRelationChildEvent = ( + timelineSet: EventTimelineSet, + eventId: string +): MatrixEvent | undefined => { + for (const timeline of timelineSet.getTimelines()) { + for (const parent of timeline.getEvents()) { + const parentId = parent.getId(); + if (!parentId) continue; + + const reactionRelations = getEventReactions(timelineSet, parentId); + if (reactionRelations) { + const reaction = reactionRelations + .getRelations() + .find((candidate) => candidate.getId() === eventId); + if (reaction) return reaction; + } + + const editRelations = getEventEdits(timelineSet, parentId, parent.getType()); + if (editRelations) { + const edit = editRelations + .getRelations() + .find((candidate) => candidate.getId() === eventId); + if (edit) return edit; + } + } + } + return undefined; +}; + +export const findRoomEventById = ( + room: Room, + eventId: string, + timelineSet?: EventTimelineSet +): MatrixEvent | undefined => { + const set = timelineSet ?? room.getUnfilteredTimelineSet(); + return ( + set.findEventById(eventId) ?? + room.findEventById(eventId) ?? + findRelationChildEvent(set, eventId) + ); +}; + +export type ResolvedReplyDraftTarget = { + eventId: string; + replyEvt: MatrixEvent; +}; + +export const extractReplyDraftBody = ( + replyEvt: MatrixEvent, + timelineSet: EventTimelineSet +): { body: string; formattedBody: string } => { + const replyId = replyEvt.getId(); + const editedReply = + replyId !== undefined && !isEditEvent(replyEvt) + ? getEditedEvent(replyId, replyEvt, timelineSet) + : undefined; + const editedNewContent = editedReply?.getContent()['m.new_content']; + const content = (editedNewContent ?? replyEvt.getContent()) as Record; + const { body, formatted_body: formattedBody } = content; + const msc1767body = content['m.text']; + + const resolvedBody = + (typeof body === 'string' ? body : undefined) ?? + (typeof msc1767body === 'string' + ? msc1767body + : (msc1767body as { body?: string } | undefined)?.body) ?? + getMessageVersionBody(replyEvt) ?? + ''; + + return { + body: resolvedBody, + formattedBody: typeof formattedBody === 'string' ? formattedBody : '', + }; +}; + +export const resolveReplyDraftTarget = ( + room: Room, + clickedEventId: string, + timelineSet?: EventTimelineSet +): ResolvedReplyDraftTarget | undefined => { + const set = timelineSet ?? room.getUnfilteredTimelineSet(); + const replyEvt = findRoomEventById(room, clickedEventId, set); + if (!replyEvt) return undefined; + + const eventId = replyEvt.getId(); + if (!eventId) return undefined; + + return { eventId, replyEvt }; +}; + export const getMentionContent = (userIds: string[], room: boolean): IMentions => { const mMentions: IMentions = {}; if (userIds.length > 0) {