Skip to content

Commit 9c8093e

Browse files
authored
Fix reactions in threads sometimes causing stuck notifications (#3146)
* Associate event with thread before adding it to the thread timeline * Make sure events can be added to thread correctly * Write initial test case * Add additional comment for why the code had to be reordered
1 parent aec1c11 commit 9c8093e

File tree

2 files changed

+101
-3
lines changed

2 files changed

+101
-3
lines changed

spec/integ/matrix-client-unread-notifications.spec.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,21 @@ import "fake-indexeddb/auto";
1818

1919
import HttpBackend from "matrix-mock-request";
2020

21-
import { Category, ISyncResponse, MatrixClient, NotificationCountType, Room } from "../../src";
21+
import {
22+
Category,
23+
ClientEvent,
24+
EventType,
25+
ISyncResponse,
26+
MatrixClient,
27+
MatrixEvent,
28+
NotificationCountType,
29+
RelationType,
30+
Room,
31+
} from "../../src";
2232
import { TestClient } from "../TestClient";
33+
import { ReceiptType } from "../../src/@types/read_receipts";
34+
import { mkThread } from "../test-utils/thread";
35+
import { SyncState } from "../../src/sync";
2336

2437
describe("MatrixClient syncing", () => {
2538
const userA = "@alice:localhost";
@@ -51,6 +64,86 @@ describe("MatrixClient syncing", () => {
5164
return httpBackend!.stop();
5265
});
5366

67+
it("reactions in thread set the correct timeline to unread", async () => {
68+
const roomId = "!room:localhost";
69+
70+
// start the client, and wait for it to initialise
71+
httpBackend!.when("GET", "/sync").respond(200, {
72+
next_batch: "s_5_3",
73+
rooms: {
74+
[Category.Join]: {},
75+
[Category.Leave]: {},
76+
[Category.Invite]: {},
77+
},
78+
});
79+
client!.startClient({ threadSupport: true });
80+
await Promise.all([
81+
httpBackend?.flushAllExpected(),
82+
new Promise<void>((resolve) => {
83+
client!.on(ClientEvent.Sync, (state) => state === SyncState.Syncing && resolve());
84+
}),
85+
]);
86+
87+
const room = new Room(roomId, client!, selfUserId);
88+
jest.spyOn(client!, "getRoom").mockImplementation((id) => (id === roomId ? room : null));
89+
90+
const thread = mkThread({ room, client: client!, authorId: selfUserId, participantUserIds: [selfUserId] });
91+
const threadReply = thread.events.at(-1)!;
92+
room.addLiveEvents([thread.rootEvent]);
93+
94+
// Initialize read receipt datastructure before testing the reaction
95+
room.addReceiptToStructure(thread.rootEvent.getId()!, ReceiptType.Read, selfUserId, { ts: 1 }, false);
96+
thread.thread.addReceiptToStructure(
97+
threadReply.getId()!,
98+
ReceiptType.Read,
99+
selfUserId,
100+
{ thread_id: thread.thread.id, ts: 1 },
101+
false,
102+
);
103+
expect(room.getReadReceiptForUserId(selfUserId, false)?.eventId).toEqual(thread.rootEvent.getId());
104+
expect(thread.thread.getReadReceiptForUserId(selfUserId, false)?.eventId).toEqual(threadReply.getId());
105+
106+
const reactionEventId = `$9-${Math.random()}-${Math.random()}`;
107+
let lastEvent: MatrixEvent | null = null;
108+
jest.spyOn(client! as any, "sendEventHttpRequest").mockImplementation((event) => {
109+
lastEvent = event as MatrixEvent;
110+
return { event_id: reactionEventId };
111+
});
112+
113+
await client!.sendEvent(roomId, EventType.Reaction, {
114+
"m.relates_to": {
115+
rel_type: RelationType.Annotation,
116+
event_id: threadReply.getId(),
117+
key: "",
118+
},
119+
});
120+
121+
expect(lastEvent!.getId()).toEqual(reactionEventId);
122+
room.handleRemoteEcho(new MatrixEvent(lastEvent!.event), lastEvent!);
123+
124+
// Our ideal state after this is the following:
125+
//
126+
// Room: [synthetic: threadroot, actual: threadroot]
127+
// Thread: [synthetic: threadreaction, actual: threadreply]
128+
//
129+
// The reaction and reply are both in the thread, and their receipts should be isolated to the thread.
130+
// The reaction has not been acknowledged in a dedicated read receipt message, so only the synthetic receipt
131+
// should be updated.
132+
133+
// Ensure the synthetic receipt for the room has not been updated
134+
expect(room.getReadReceiptForUserId(selfUserId, false)?.eventId).toEqual(thread.rootEvent.getId());
135+
expect(room.getEventReadUpTo(selfUserId, false)).toEqual(thread.rootEvent.getId());
136+
// Ensure the actual receipt for the room has not been updated
137+
expect(room.getReadReceiptForUserId(selfUserId, true)?.eventId).toEqual(thread.rootEvent.getId());
138+
expect(room.getEventReadUpTo(selfUserId, true)).toEqual(thread.rootEvent.getId());
139+
// Ensure the synthetic receipt for the thread has been updated
140+
expect(thread.thread.getReadReceiptForUserId(selfUserId, false)?.eventId).toEqual(reactionEventId);
141+
expect(thread.thread.getEventReadUpTo(selfUserId, false)).toEqual(reactionEventId);
142+
// Ensure the actual receipt for the thread has not been updated
143+
expect(thread.thread.getReadReceiptForUserId(selfUserId, true)?.eventId).toEqual(threadReply.getId());
144+
expect(thread.thread.getEventReadUpTo(selfUserId, true)).toEqual(threadReply.getId());
145+
});
146+
54147
describe("Stuck unread notifications integration tests", () => {
55148
const ROOM_ID = "!room:localhost";
56149

src/models/room.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2151,14 +2151,17 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
21512151
// a reference to the cached receipts anymore.
21522152
this.cachedThreadReadReceipts.delete(threadId);
21532153

2154+
// If we managed to create a thread and figure out its `id` then we can use it
2155+
// This has to happen before thread.addEvents, because that adds events to the eventtimeline, and the
2156+
// eventtimeline sometimes looks up thread information via the room.
2157+
this.threads.set(thread.id, thread);
2158+
21542159
// This is necessary to be able to jump to events in threads:
21552160
// If we jump to an event in a thread where neither the event, nor the root,
21562161
// nor any thread event are loaded yet, we'll load the event as well as the thread root, create the thread,
21572162
// and pass the event through this.
21582163
thread.addEvents(events, false);
21592164

2160-
// If we managed to create a thread and figure out its `id` then we can use it
2161-
this.threads.set(thread.id, thread);
21622165
this.reEmitter.reEmit(thread, [
21632166
ThreadEvent.Delete,
21642167
ThreadEvent.Update,
@@ -2467,6 +2470,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
24672470

24682471
const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(remoteEvent);
24692472
const thread = threadId ? this.getThread(threadId) : null;
2473+
thread?.setEventMetadata(localEvent);
24702474
thread?.timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId);
24712475

24722476
if (shouldLiveInRoom) {
@@ -2548,6 +2552,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
25482552

25492553
const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(event);
25502554
const thread = threadId ? this.getThread(threadId) : undefined;
2555+
thread?.setEventMetadata(event);
25512556
thread?.timelineSet.replaceEventId(oldEventId, newEventId!);
25522557

25532558
if (shouldLiveInRoom) {

0 commit comments

Comments
 (0)