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-timeline-replies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fixed replying to reaction/edit events not replying.
27 changes: 12 additions & 15 deletions src/app/features/room/ThreadDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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() ?? '',
Expand Down
40 changes: 14 additions & 26 deletions src/app/hooks/timeline/useTimelineActions.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -127,42 +126,31 @@ 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();

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,
});
}
Expand Down
168 changes: 168 additions & 0 deletions src/app/utils/room.reply.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, MatrixEvent>,
relations: Record<string, MatrixEvent[]> = {}
) => {
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');
});
});
Loading
Loading