Skip to content

Commit b6d4007

Browse files
author
Germain
authored
Clear notifications when we can infer read status from receipts (#3139)
1 parent b8a8f48 commit b6d4007

File tree

9 files changed

+396
-1
lines changed

9 files changed

+396
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
/*
2+
Copyright 2023 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import "fake-indexeddb/auto";
18+
19+
import HttpBackend from "matrix-mock-request";
20+
21+
import { Category, ISyncResponse, MatrixClient, NotificationCountType, Room } from "../../src";
22+
import { TestClient } from "../TestClient";
23+
24+
describe("MatrixClient syncing", () => {
25+
const userA = "@alice:localhost";
26+
const userB = "@bob:localhost";
27+
28+
const selfUserId = userA;
29+
const selfAccessToken = "aseukfgwef";
30+
31+
let client: MatrixClient | undefined;
32+
let httpBackend: HttpBackend | undefined;
33+
34+
const setupTestClient = (): [MatrixClient, HttpBackend] => {
35+
const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken);
36+
const httpBackend = testClient.httpBackend;
37+
const client = testClient.client;
38+
httpBackend!.when("GET", "/versions").respond(200, {});
39+
httpBackend!.when("GET", "/pushrules").respond(200, {});
40+
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
41+
return [client, httpBackend];
42+
};
43+
44+
beforeEach(() => {
45+
[client, httpBackend] = setupTestClient();
46+
});
47+
48+
afterEach(() => {
49+
httpBackend!.verifyNoOutstandingExpectation();
50+
client!.stopClient();
51+
return httpBackend!.stop();
52+
});
53+
54+
describe("Stuck unread notifications integration tests", () => {
55+
const ROOM_ID = "!room:localhost";
56+
57+
const syncData = getSampleStuckNotificationSyncResponse(ROOM_ID);
58+
59+
it("resets notifications if the last event originates from the logged in user", async () => {
60+
httpBackend!
61+
.when("GET", "/sync")
62+
.check((req) => {
63+
expect(req.queryParams!.filter).toEqual("a filter id");
64+
})
65+
.respond(200, syncData);
66+
67+
client!.store.getSavedSyncToken = jest.fn().mockResolvedValue("this-is-a-token");
68+
client!.startClient({ initialSyncLimit: 1 });
69+
70+
await httpBackend!.flushAllExpected();
71+
72+
const room = client?.getRoom(ROOM_ID);
73+
74+
expect(room).toBeInstanceOf(Room);
75+
expect(room?.getUnreadNotificationCount(NotificationCountType.Total)).toBe(0);
76+
});
77+
});
78+
79+
function getSampleStuckNotificationSyncResponse(roomId: string): Partial<ISyncResponse> {
80+
return {
81+
next_batch: "batch_token",
82+
rooms: {
83+
[Category.Join]: {
84+
[roomId]: {
85+
timeline: {
86+
events: [
87+
{
88+
content: {
89+
creator: userB,
90+
room_version: "9",
91+
},
92+
origin_server_ts: 1,
93+
sender: userB,
94+
state_key: "",
95+
type: "m.room.create",
96+
event_id: "$event1",
97+
},
98+
{
99+
content: {
100+
avatar_url: "",
101+
displayname: userB,
102+
membership: "join",
103+
},
104+
origin_server_ts: 2,
105+
sender: userB,
106+
state_key: userB,
107+
type: "m.room.member",
108+
event_id: "$event2",
109+
},
110+
{
111+
content: {
112+
ban: 50,
113+
events: {
114+
"m.room.avatar": 50,
115+
"m.room.canonical_alias": 50,
116+
"m.room.encryption": 100,
117+
"m.room.history_visibility": 100,
118+
"m.room.name": 50,
119+
"m.room.power_levels": 100,
120+
"m.room.server_acl": 100,
121+
"m.room.tombstone": 100,
122+
},
123+
events_default: 0,
124+
historical: 100,
125+
invite: 0,
126+
kick: 50,
127+
redact: 50,
128+
state_default: 50,
129+
users: {
130+
[userA]: 100,
131+
[userB]: 100,
132+
},
133+
users_default: 0,
134+
},
135+
origin_server_ts: 3,
136+
sender: userB,
137+
state_key: "",
138+
type: "m.room.power_levels",
139+
event_id: "$event3",
140+
},
141+
{
142+
content: {
143+
join_rule: "invite",
144+
},
145+
origin_server_ts: 4,
146+
sender: userB,
147+
state_key: "",
148+
type: "m.room.join_rules",
149+
event_id: "$event4",
150+
},
151+
{
152+
content: {
153+
history_visibility: "shared",
154+
},
155+
origin_server_ts: 5,
156+
sender: userB,
157+
state_key: "",
158+
type: "m.room.history_visibility",
159+
event_id: "$event5",
160+
},
161+
{
162+
content: {
163+
guest_access: "can_join",
164+
},
165+
origin_server_ts: 6,
166+
sender: userB,
167+
state_key: "",
168+
type: "m.room.guest_access",
169+
unsigned: {
170+
age: 1651569,
171+
},
172+
event_id: "$event6",
173+
},
174+
{
175+
content: {
176+
algorithm: "m.megolm.v1.aes-sha2",
177+
},
178+
origin_server_ts: 7,
179+
sender: userB,
180+
state_key: "",
181+
type: "m.room.encryption",
182+
event_id: "$event7",
183+
},
184+
{
185+
content: {
186+
avatar_url: "",
187+
displayname: userA,
188+
is_direct: true,
189+
membership: "invite",
190+
},
191+
origin_server_ts: 8,
192+
sender: userB,
193+
state_key: userA,
194+
type: "m.room.member",
195+
event_id: "$event8",
196+
},
197+
{
198+
content: {
199+
msgtype: "m.text",
200+
body: "hello",
201+
},
202+
origin_server_ts: 9,
203+
sender: userB,
204+
type: "m.room.message",
205+
event_id: "$event9",
206+
},
207+
{
208+
content: {
209+
avatar_url: "",
210+
displayname: userA,
211+
membership: "join",
212+
},
213+
origin_server_ts: 10,
214+
sender: userA,
215+
state_key: userA,
216+
type: "m.room.member",
217+
event_id: "$event10",
218+
},
219+
{
220+
content: {
221+
msgtype: "m.text",
222+
body: "world",
223+
},
224+
origin_server_ts: 11,
225+
sender: userA,
226+
type: "m.room.message",
227+
event_id: "$event11",
228+
},
229+
],
230+
prev_batch: "123",
231+
limited: false,
232+
},
233+
state: {
234+
events: [],
235+
},
236+
account_data: {
237+
events: [
238+
{
239+
type: "m.fully_read",
240+
content: {
241+
event_id: "$dER5V1RCMxzAhHXQJoMjqyuoxpPtK2X6hCb9T8Jg2wU",
242+
},
243+
},
244+
],
245+
},
246+
ephemeral: {
247+
events: [
248+
{
249+
type: "m.receipt",
250+
content: {
251+
$event9: {
252+
"m.read": {
253+
[userA]: {
254+
ts: 100,
255+
},
256+
},
257+
"m.read.private": {
258+
[userA]: {
259+
ts: 100,
260+
},
261+
},
262+
},
263+
dER5V1RCMxzAhHXQJoMjqyuoxpPtK2X6hCb9T8Jg2wU: {
264+
"m.read": {
265+
[userB]: {
266+
ts: 666,
267+
},
268+
},
269+
},
270+
},
271+
},
272+
],
273+
},
274+
unread_notifications: {
275+
notification_count: 1,
276+
highlight_count: 0,
277+
},
278+
summary: {
279+
"m.joined_member_count": 2,
280+
"m.invited_member_count": 0,
281+
"m.heroes": [userB],
282+
},
283+
},
284+
},
285+
[Category.Leave]: {},
286+
[Category.Invite]: {},
287+
},
288+
};
289+
}
290+
});

spec/test-utils/webrtc.ts

+4
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,10 @@ export class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, Emitt
450450
]
451451
>();
452452

453+
public isInitialSyncComplete(): boolean {
454+
return false;
455+
}
456+
453457
public getMediaHandler(): MediaHandler {
454458
return this.mediaHandler.typed();
455459
}

spec/unit/models/thread.spec.ts

+2
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ describe("Thread", () => {
8282
beforeEach(() => {
8383
client = getMockClientWithEventEmitter({
8484
...mockClientMethodsUser(),
85+
isInitialSyncComplete: jest.fn().mockReturnValue(false),
8586
getRoom: jest.fn().mockImplementation(() => room),
8687
decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0),
8788
supportsThreads: jest.fn().mockReturnValue(true),
@@ -193,6 +194,7 @@ describe("Thread", () => {
193194
beforeEach(() => {
194195
client = getMockClientWithEventEmitter({
195196
...mockClientMethodsUser(),
197+
isInitialSyncComplete: jest.fn().mockReturnValue(false),
196198
getRoom: jest.fn().mockImplementation(() => room),
197199
decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0),
198200
supportsThreads: jest.fn().mockReturnValue(true),

spec/unit/notifications.spec.ts

+1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ describe("fixNotificationCountOnDecryption", () => {
5353
beforeEach(() => {
5454
mockClient = getMockClientWithEventEmitter({
5555
...mockClientMethodsUser(),
56+
isInitialSyncComplete: jest.fn().mockReturnValue(false),
5657
getPushActionsForEvent: jest.fn().mockReturnValue(mkPushAction(true, true)),
5758
getRoom: jest.fn().mockImplementation(() => room),
5859
decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0),

spec/unit/room.spec.ts

+1
Original file line numberDiff line numberDiff line change
@@ -3307,6 +3307,7 @@ describe("Room", function () {
33073307
beforeEach(() => {
33083308
client = getMockClientWithEventEmitter({
33093309
...mockClientMethodsUser(),
3310+
isInitialSyncComplete: jest.fn().mockReturnValue(false),
33103311
supportsThreads: jest.fn().mockReturnValue(true),
33113312
});
33123313
});

src/client.ts

+27
Original file line numberDiff line numberDiff line change
@@ -1307,6 +1307,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
13071307
this.on(ClientEvent.Sync, this.startCallEventHandler);
13081308
}
13091309

1310+
this.on(ClientEvent.Sync, this.fixupRoomNotifications);
1311+
13101312
this.timelineSupport = Boolean(opts.timelineSupport);
13111313

13121314
this.cryptoStore = opts.cryptoStore;
@@ -6817,6 +6819,31 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
68176819
}
68186820
};
68196821

6822+
/**
6823+
* Once the client has been initialised, we want to clear notifications we
6824+
* know for a fact should be here.
6825+
* This issue should also be addressed on synapse's side and is tracked as part
6826+
* of https://github.com/matrix-org/synapse/issues/14837
6827+
*
6828+
* We consider a room or a thread as fully read if the current user has sent
6829+
* the last event in the live timeline of that context and if the read receipt
6830+
* we have on record matches.
6831+
*/
6832+
private fixupRoomNotifications = (): void => {
6833+
if (this.isInitialSyncComplete()) {
6834+
const unreadRooms = (this.getRooms() ?? []).filter((room) => {
6835+
return room.getUnreadNotificationCount(NotificationCountType.Total) > 0;
6836+
});
6837+
6838+
for (const room of unreadRooms) {
6839+
const currentUserId = this.getSafeUserId();
6840+
room.fixupNotifications(currentUserId);
6841+
}
6842+
6843+
this.off(ClientEvent.Sync, this.fixupRoomNotifications);
6844+
}
6845+
};
6846+
68206847
/**
68216848
* @returns Promise which resolves: ITurnServerResponse object
68226849
* @returns Rejects: with an error response.

0 commit comments

Comments
 (0)