Skip to content

Commit 21a6f61

Browse files
author
Germain
authored
Add support for unread thread notifications (#2726)
1 parent ff720e3 commit 21a6f61

16 files changed

+551
-40
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
"fake-indexeddb": "^4.0.0",
102102
"jest": "^29.0.0",
103103
"jest-localstorage-mock": "^2.4.6",
104+
"jest-mock": "^27.5.1",
104105
"jest-sonar-reporter": "^2.0.0",
105106
"jsdoc": "^3.6.6",
106107
"matrix-mock-request": "^2.1.2",

spec/integ/matrix-client-syncing.spec.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ import {
2929
MatrixClient,
3030
ClientEvent,
3131
IndexedDBCryptoStore,
32+
NotificationCountType,
3233
} from "../../src";
34+
import { UNREAD_THREAD_NOTIFICATIONS } from '../../src/@types/sync';
3335
import * as utils from "../test-utils/test-utils";
3436
import { TestClient } from "../TestClient";
3537

@@ -1363,6 +1365,73 @@ describe("MatrixClient syncing", () => {
13631365
});
13641366
});
13651367

1368+
describe("unread notifications", () => {
1369+
const THREAD_ID = "$ThisIsARandomEventId";
1370+
1371+
const syncData = {
1372+
rooms: {
1373+
join: {
1374+
[roomOne]: {
1375+
timeline: {
1376+
events: [
1377+
utils.mkMessage({
1378+
room: roomOne, user: otherUserId, msg: "hello",
1379+
}),
1380+
utils.mkMessage({
1381+
room: roomOne, user: otherUserId, msg: "world",
1382+
}),
1383+
],
1384+
},
1385+
state: {
1386+
events: [
1387+
utils.mkEvent({
1388+
type: "m.room.name", room: roomOne, user: otherUserId,
1389+
content: {
1390+
name: "Room name",
1391+
},
1392+
}),
1393+
utils.mkMembership({
1394+
room: roomOne, mship: "join", user: otherUserId,
1395+
}),
1396+
utils.mkMembership({
1397+
room: roomOne, mship: "join", user: selfUserId,
1398+
}),
1399+
utils.mkEvent({
1400+
type: "m.room.create", room: roomOne, user: selfUserId,
1401+
content: {
1402+
creator: selfUserId,
1403+
},
1404+
}),
1405+
],
1406+
},
1407+
},
1408+
},
1409+
},
1410+
};
1411+
it("should sync unread notifications.", () => {
1412+
syncData.rooms.join[roomOne][UNREAD_THREAD_NOTIFICATIONS.name] = {
1413+
[THREAD_ID]: {
1414+
"highlight_count": 2,
1415+
"notification_count": 5,
1416+
},
1417+
};
1418+
1419+
httpBackend!.when("GET", "/sync").respond(200, syncData);
1420+
1421+
client!.startClient();
1422+
1423+
return Promise.all([
1424+
httpBackend!.flushAllExpected(),
1425+
awaitSyncEvent(),
1426+
]).then(() => {
1427+
const room = client!.getRoom(roomOne);
1428+
1429+
expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(5);
1430+
expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(2);
1431+
});
1432+
});
1433+
});
1434+
13661435
describe("of a room", () => {
13671436
xit("should sync when a join event (which changes state) for the user" +
13681437
" arrives down the event stream (e.g. join from another device)", () => {

spec/test-utils/client.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
Copyright 2022 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 { MethodKeysOf, mocked, MockedObject } from "jest-mock";
18+
19+
import { ClientEventHandlerMap, EmittedEvents, MatrixClient } from "../../src/client";
20+
import { TypedEventEmitter } from "../../src/models/typed-event-emitter";
21+
import { User } from "../../src/models/user";
22+
23+
/**
24+
* Mock client with real event emitter
25+
* useful for testing code that listens
26+
* to MatrixClient events
27+
*/
28+
export class MockClientWithEventEmitter extends TypedEventEmitter<EmittedEvents, ClientEventHandlerMap> {
29+
constructor(mockProperties: Partial<Record<MethodKeysOf<MatrixClient>, unknown>> = {}) {
30+
super();
31+
Object.assign(this, mockProperties);
32+
}
33+
}
34+
35+
/**
36+
* - make a mock client
37+
* - cast the type to mocked(MatrixClient)
38+
* - spy on MatrixClientPeg.get to return the mock
39+
* eg
40+
* ```
41+
* const mockClient = getMockClientWithEventEmitter({
42+
getUserId: jest.fn().mockReturnValue(aliceId),
43+
});
44+
* ```
45+
*/
46+
export const getMockClientWithEventEmitter = (
47+
mockProperties: Partial<Record<MethodKeysOf<MatrixClient>, unknown>>,
48+
): MockedObject<MatrixClient> => {
49+
const mock = mocked(new MockClientWithEventEmitter(mockProperties) as unknown as MatrixClient);
50+
return mock;
51+
};
52+
53+
/**
54+
* Returns basic mocked client methods related to the current user
55+
* ```
56+
* const mockClient = getMockClientWithEventEmitter({
57+
...mockClientMethodsUser('@mytestuser:domain'),
58+
});
59+
* ```
60+
*/
61+
export const mockClientMethodsUser = (userId = '@alice:domain') => ({
62+
getUserId: jest.fn().mockReturnValue(userId),
63+
getUser: jest.fn().mockReturnValue(new User(userId)),
64+
isGuest: jest.fn().mockReturnValue(false),
65+
mxcUrlToHttp: jest.fn().mockReturnValue('mock-mxcUrlToHttp'),
66+
credentials: { userId },
67+
getThreePids: jest.fn().mockResolvedValue({ threepids: [] }),
68+
getAccessToken: jest.fn(),
69+
});
70+
71+
/**
72+
* Returns basic mocked client methods related to rendering events
73+
* ```
74+
* const mockClient = getMockClientWithEventEmitter({
75+
...mockClientMethodsUser('@mytestuser:domain'),
76+
});
77+
* ```
78+
*/
79+
export const mockClientMethodsEvents = () => ({
80+
decryptEventIfNeeded: jest.fn(),
81+
getPushActionsForEvent: jest.fn(),
82+
});
83+
84+
/**
85+
* Returns basic mocked client methods related to server support
86+
*/
87+
export const mockClientMethodsServer = (): Partial<Record<MethodKeysOf<MatrixClient>, unknown>> => ({
88+
doesServerSupportSeparateAddAndBind: jest.fn(),
89+
getIdentityServerUrl: jest.fn(),
90+
getHomeserverUrl: jest.fn(),
91+
getCapabilities: jest.fn().mockReturnValue({}),
92+
doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false),
93+
});
94+

spec/unit/filter.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,17 @@ describe("Filter", function() {
4343
expect(filter.getDefinition()).toEqual(definition);
4444
});
4545
});
46+
47+
describe("setUnreadThreadNotifications", function() {
48+
it("setUnreadThreadNotifications", function() {
49+
filter.setUnreadThreadNotifications(true);
50+
expect(filter.getDefinition()).toEqual({
51+
room: {
52+
timeline: {
53+
unread_thread_notifications: true,
54+
},
55+
},
56+
});
57+
});
58+
});
4659
});

spec/unit/notifications.spec.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/*
2+
Copyright 2022 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 {
18+
EventType,
19+
fixNotificationCountOnDecryption,
20+
MatrixClient,
21+
MatrixEvent,
22+
MsgType,
23+
NotificationCountType,
24+
RelationType,
25+
Room,
26+
} from "../../src/matrix";
27+
import { IActionsObject } from "../../src/pushprocessor";
28+
import { ReEmitter } from "../../src/ReEmitter";
29+
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../test-utils/client";
30+
import { mkEvent, mock } from "../test-utils/test-utils";
31+
32+
let mockClient: MatrixClient;
33+
let room: Room;
34+
let event: MatrixEvent;
35+
let threadEvent: MatrixEvent;
36+
37+
const ROOM_ID = "!roomId:example.org";
38+
let THREAD_ID;
39+
40+
function mkPushAction(notify, highlight): IActionsObject {
41+
return {
42+
notify,
43+
tweaks: {
44+
highlight,
45+
},
46+
};
47+
}
48+
49+
describe("fixNotificationCountOnDecryption", () => {
50+
beforeEach(() => {
51+
mockClient = getMockClientWithEventEmitter({
52+
...mockClientMethodsUser(),
53+
getPushActionsForEvent: jest.fn().mockReturnValue(mkPushAction(true, true)),
54+
getRoom: jest.fn().mockImplementation(() => room),
55+
decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0),
56+
supportsExperimentalThreads: jest.fn().mockReturnValue(true),
57+
});
58+
mockClient.reEmitter = mock(ReEmitter, 'ReEmitter');
59+
60+
room = new Room(ROOM_ID, mockClient, mockClient.getUserId());
61+
room.setUnreadNotificationCount(NotificationCountType.Total, 1);
62+
room.setUnreadNotificationCount(NotificationCountType.Highlight, 0);
63+
64+
event = mkEvent({
65+
type: EventType.RoomMessage,
66+
content: {
67+
msgtype: MsgType.Text,
68+
body: "Hello world!",
69+
},
70+
event: true,
71+
}, mockClient);
72+
73+
THREAD_ID = event.getId();
74+
threadEvent = mkEvent({
75+
type: EventType.RoomMessage,
76+
content: {
77+
"m.relates_to": {
78+
rel_type: RelationType.Thread,
79+
event_id: THREAD_ID,
80+
},
81+
"msgtype": MsgType.Text,
82+
"body": "Thread reply",
83+
},
84+
event: true,
85+
});
86+
room.createThread(THREAD_ID, event, [threadEvent], false);
87+
88+
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 1);
89+
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0);
90+
91+
event.getPushActions = jest.fn().mockReturnValue(mkPushAction(false, false));
92+
threadEvent.getPushActions = jest.fn().mockReturnValue(mkPushAction(false, false));
93+
});
94+
95+
it("changes the room count to highlight on decryption", () => {
96+
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toBe(1);
97+
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toBe(0);
98+
99+
fixNotificationCountOnDecryption(mockClient, event);
100+
101+
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toBe(1);
102+
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toBe(1);
103+
});
104+
105+
it("changes the thread count to highlight on decryption", () => {
106+
expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(1);
107+
expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(0);
108+
109+
fixNotificationCountOnDecryption(mockClient, threadEvent);
110+
111+
expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(1);
112+
expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(1);
113+
});
114+
});

spec/unit/room.spec.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
RoomEvent,
3333
} from "../../src";
3434
import { EventTimeline } from "../../src/models/event-timeline";
35-
import { Room } from "../../src/models/room";
35+
import { NotificationCountType, Room } from "../../src/models/room";
3636
import { RoomState } from "../../src/models/room-state";
3737
import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event";
3838
import { TestClient } from "../TestClient";
@@ -2562,4 +2562,40 @@ describe("Room", function() {
25622562
expect(client.roomNameGenerator).toHaveBeenCalled();
25632563
});
25642564
});
2565+
2566+
describe("thread notifications", () => {
2567+
let room;
2568+
2569+
beforeEach(() => {
2570+
const client = new TestClient(userA).client;
2571+
room = new Room(roomId, client, userA);
2572+
});
2573+
2574+
it("defaults to undefined", () => {
2575+
expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Total)).toBeUndefined();
2576+
expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Highlight)).toBeUndefined();
2577+
});
2578+
2579+
it("lets you set values", () => {
2580+
room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 1);
2581+
2582+
expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Total)).toBe(1);
2583+
expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Highlight)).toBeUndefined();
2584+
2585+
room.setThreadUnreadNotificationCount("123", NotificationCountType.Highlight, 10);
2586+
2587+
expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Total)).toBe(1);
2588+
expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Highlight)).toBe(10);
2589+
});
2590+
2591+
it("lets you reset threads notifications", () => {
2592+
room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 666);
2593+
room.setThreadUnreadNotificationCount("123", NotificationCountType.Highlight, 123);
2594+
2595+
room.resetThreadUnreadNotificationCount();
2596+
2597+
expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Total)).toBeUndefined();
2598+
expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Highlight)).toBeUndefined();
2599+
});
2600+
});
25652601
});

spec/unit/sync-accumulator.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ const RES_WITH_AGE = {
3030
account_data: { events: [] },
3131
ephemeral: { events: [] },
3232
unread_notifications: {},
33+
unread_thread_notifications: {
34+
"$143273582443PhrSn:example.org": {
35+
highlight_count: 0,
36+
notification_count: 1,
37+
},
38+
},
3339
timeline: {
3440
events: [
3541
Object.freeze({
@@ -439,6 +445,13 @@ describe("SyncAccumulator", function() {
439445
Object.keys(RES_WITH_AGE.rooms.join["!foo:bar"].timeline.events[0]),
440446
);
441447
});
448+
449+
it("should retrieve unread thread notifications", () => {
450+
sa.accumulate(RES_WITH_AGE);
451+
const output = sa.getJSON();
452+
expect(output.roomsData.join["!foo:bar"]
453+
.unread_thread_notifications["$143273582443PhrSn:example.org"]).not.toBeUndefined();
454+
});
442455
});
443456
});
444457

0 commit comments

Comments
 (0)