Skip to content

Commit aa79236

Browse files
robintowntoger5
andauthored
Allow sending notification events when starting a call (#4826)
* Make it easier to mock call memberships for specific user IDs * Allow sending notification events when starting a call * rename notify -> notification * replace `joining` concept with `ownMembership` * introduce new `m.rtc.notification` event alongside `m.call.notify` * send new notification event alongside the deprecated one * Test for new notification event type * update relation string to match msc * review * fix doc errors * fix tests + format * remove anything decline related --------- Co-authored-by: Timo <toger5@hotmail.de>
1 parent f8f1bf3 commit aa79236

File tree

10 files changed

+208
-71
lines changed

10 files changed

+208
-71
lines changed

spec/unit/matrixrtc/MatrixRTCSession.spec.ts

Lines changed: 80 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@ limitations under the License.
1616

1717
import { encodeBase64, EventType, MatrixClient, type MatrixError, type MatrixEvent, type Room } from "../../../src";
1818
import { KnownMembership } from "../../../src/@types/membership";
19-
import { type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
2019
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
2120
import { type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
2221
import { secureRandomString } from "../../../src/randomstring";
23-
import { makeMockEvent, makeMockRoom, makeMockRoomState, membershipTemplate, makeKey } from "./mocks";
22+
import { makeMockEvent, makeMockRoom, membershipTemplate, makeKey, type MembershipData, mockRoomState } from "./mocks";
2423
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
2524

2625
const mockFocus = { type: "mock" };
@@ -48,7 +47,7 @@ describe("MatrixRTCSession", () => {
4847

4948
describe("roomSessionForRoom", () => {
5049
it("creates a room-scoped session from room state", () => {
51-
const mockRoom = makeMockRoom(membershipTemplate);
50+
const mockRoom = makeMockRoom([membershipTemplate]);
5251

5352
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
5453
expect(sess?.memberships.length).toEqual(1);
@@ -75,7 +74,7 @@ describe("MatrixRTCSession", () => {
7574
});
7675

7776
it("ignores memberships events of members not in the room", () => {
78-
const mockRoom = makeMockRoom(membershipTemplate);
77+
const mockRoom = makeMockRoom([membershipTemplate]);
7978
mockRoom.hasMembershipState = (state) => state === KnownMembership.Join;
8079
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
8180
expect(sess?.memberships.length).toEqual(0);
@@ -270,9 +269,15 @@ describe("MatrixRTCSession", () => {
270269
describe("joining", () => {
271270
let mockRoom: Room;
272271
let sendEventMock: jest.Mock;
272+
let sendStateEventMock: jest.Mock;
273273

274+
let sentStateEvent: Promise<void>;
274275
beforeEach(() => {
275-
sendEventMock = jest.fn();
276+
sentStateEvent = new Promise((resolve) => {
277+
sendStateEventMock = jest.fn(resolve);
278+
});
279+
sendEventMock = jest.fn().mockResolvedValue(undefined);
280+
client.sendStateEvent = sendStateEventMock;
276281
client.sendEvent = sendEventMock;
277282

278283
client._unstable_updateDelayedEvent = jest.fn();
@@ -298,11 +303,69 @@ describe("MatrixRTCSession", () => {
298303
sess!.joinRoomSession([mockFocus], mockFocus);
299304
expect(sess!.isJoined()).toEqual(true);
300305
});
306+
307+
it("sends a notification when starting a call", async () => {
308+
// Simulate a join, including the update to the room state
309+
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" });
310+
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
311+
mockRoomState(mockRoom, [{ ...membershipTemplate, user_id: client.getUserId()! }]);
312+
sess!.onRTCSessionMemberUpdate();
313+
const ownMembershipId = sess?.memberships[0].eventId;
314+
315+
expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.RTCNotification, {
316+
"m.mentions": { user_ids: [], room: true },
317+
"notification_type": "ring",
318+
"m.relates_to": {
319+
event_id: ownMembershipId,
320+
rel_type: "org.matrix.msc4075.rtc.notification.parent",
321+
},
322+
"lifetime": 30000,
323+
"sender_ts": expect.any(Number),
324+
});
325+
326+
// Check if deprecated notify event is also sent.
327+
expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.CallNotify, {
328+
"application": "m.call",
329+
"m.mentions": { user_ids: [], room: true },
330+
"notify_type": "ring",
331+
"call_id": "",
332+
});
333+
});
334+
335+
it("doesn't send a notification when joining an existing call", async () => {
336+
// Add another member to the call so that it is considered an existing call
337+
mockRoomState(mockRoom, [membershipTemplate]);
338+
sess!.onRTCSessionMemberUpdate();
339+
340+
// Simulate a join, including the update to the room state
341+
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" });
342+
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
343+
mockRoomState(mockRoom, [membershipTemplate, { ...membershipTemplate, user_id: client.getUserId()! }]);
344+
sess!.onRTCSessionMemberUpdate();
345+
346+
expect(client.sendEvent).not.toHaveBeenCalled();
347+
});
348+
349+
it("doesn't send a notification when someone else starts the call faster than us", async () => {
350+
// Simulate a join, including the update to the room state
351+
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" });
352+
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
353+
// But this time we want to simulate a race condition in which we receive a state event
354+
// from someone else, starting the call before our own state event has been sent
355+
mockRoomState(mockRoom, [membershipTemplate]);
356+
sess!.onRTCSessionMemberUpdate();
357+
mockRoomState(mockRoom, [membershipTemplate, { ...membershipTemplate, user_id: client.getUserId()! }]);
358+
sess!.onRTCSessionMemberUpdate();
359+
360+
// We assume that the responsibility to send a notification, if any, lies with the other
361+
// participant that won the race
362+
expect(client.sendEvent).not.toHaveBeenCalled();
363+
});
301364
});
302365

303366
describe("onMembershipsChanged", () => {
304367
it("does not emit if no membership changes", () => {
305-
const mockRoom = makeMockRoom(membershipTemplate);
368+
const mockRoom = makeMockRoom([membershipTemplate]);
306369
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
307370

308371
const onMembershipsChanged = jest.fn();
@@ -313,13 +376,13 @@ describe("MatrixRTCSession", () => {
313376
});
314377

315378
it("emits on membership changes", () => {
316-
const mockRoom = makeMockRoom(membershipTemplate);
379+
const mockRoom = makeMockRoom([membershipTemplate]);
317380
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
318381

319382
const onMembershipsChanged = jest.fn();
320383
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
321384

322-
mockRoom.getLiveTimeline().getState = jest.fn().mockReturnValue(makeMockRoomState([], mockRoom.roomId));
385+
mockRoomState(mockRoom, []);
323386
sess.onRTCSessionMemberUpdate();
324387

325388
expect(onMembershipsChanged).toHaveBeenCalled();
@@ -503,18 +566,14 @@ describe("MatrixRTCSession", () => {
503566
expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1);
504567

505568
// member2 leaves triggering key rotation
506-
mockRoom.getLiveTimeline().getState = jest
507-
.fn()
508-
.mockReturnValue(makeMockRoomState([membershipTemplate], mockRoom.roomId));
569+
mockRoomState(mockRoom, [membershipTemplate]);
509570
sess.onRTCSessionMemberUpdate();
510571

511572
// member2 re-joins which should trigger an immediate re-send
512573
const keysSentPromise2 = new Promise<EncryptionKeysEventContent>((resolve) => {
513574
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
514575
});
515-
mockRoom.getLiveTimeline().getState = jest
516-
.fn()
517-
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId));
576+
mockRoomState(mockRoom, [membershipTemplate, member2]);
518577
sess.onRTCSessionMemberUpdate();
519578
// but, that immediate resend is throttled so we need to wait a bit
520579
jest.advanceTimersByTime(1000);
@@ -565,9 +624,7 @@ describe("MatrixRTCSession", () => {
565624
device_id: "BBBBBBB",
566625
});
567626

568-
mockRoom.getLiveTimeline().getState = jest
569-
.fn()
570-
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId));
627+
mockRoomState(mockRoom, [membershipTemplate, member2]);
571628
sess.onRTCSessionMemberUpdate();
572629

573630
await keysSentPromise2;
@@ -592,9 +649,7 @@ describe("MatrixRTCSession", () => {
592649
});
593650

594651
const mockRoom = makeMockRoom([member1, member2]);
595-
mockRoom.getLiveTimeline().getState = jest
596-
.fn()
597-
.mockReturnValue(makeMockRoomState([member1, member2], mockRoom.roomId));
652+
mockRoomState(mockRoom, [member1, member2]);
598653

599654
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
600655
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
@@ -641,10 +696,6 @@ describe("MatrixRTCSession", () => {
641696
};
642697

643698
const mockRoom = makeMockRoom([member1, member2]);
644-
mockRoom.getLiveTimeline().getState = jest
645-
.fn()
646-
.mockReturnValue(makeMockRoomState([member1, member2], mockRoom.roomId));
647-
648699
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
649700
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
650701

@@ -674,6 +725,7 @@ describe("MatrixRTCSession", () => {
674725

675726
// update created_ts
676727
member2.created_ts = 5000;
728+
mockRoomState(mockRoom, [member1, member2]);
677729

678730
const keysSentPromise2 = new Promise((resolve) => {
679731
sendEventMock.mockImplementation(resolve);
@@ -737,9 +789,7 @@ describe("MatrixRTCSession", () => {
737789
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
738790
});
739791

740-
mockRoom.getLiveTimeline().getState = jest
741-
.fn()
742-
.mockReturnValue(makeMockRoomState([membershipTemplate], mockRoom.roomId));
792+
mockRoomState(mockRoom, [membershipTemplate]);
743793
sess.onRTCSessionMemberUpdate();
744794

745795
jest.advanceTimersByTime(KEY_DELAY);
@@ -784,7 +834,7 @@ describe("MatrixRTCSession", () => {
784834
it("wraps key index around to 0 when it reaches the maximum", async () => {
785835
// this should give us keys with index [0...255, 0, 1]
786836
const membersToTest = 258;
787-
const members: SessionMembershipData[] = [];
837+
const members: MembershipData[] = [];
788838
for (let i = 0; i < membersToTest; i++) {
789839
members.push(Object.assign({}, membershipTemplate, { device_id: `DEVICE${i}` }));
790840
}
@@ -804,11 +854,7 @@ describe("MatrixRTCSession", () => {
804854
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
805855
} else {
806856
// otherwise update the state reducing the membership each time in order to trigger key rotation
807-
mockRoom.getLiveTimeline().getState = jest
808-
.fn()
809-
.mockReturnValue(
810-
makeMockRoomState(members.slice(0, membersToTest - i), mockRoom.roomId),
811-
);
857+
mockRoomState(mockRoom, members.slice(0, membersToTest - i));
812858
}
813859

814860
sess!.onRTCSessionMemberUpdate();
@@ -849,9 +895,7 @@ describe("MatrixRTCSession", () => {
849895
device_id: "BBBBBBB",
850896
});
851897

852-
mockRoom.getLiveTimeline().getState = jest
853-
.fn()
854-
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId));
898+
mockRoomState(mockRoom, [membershipTemplate, member2]);
855899
sess.onRTCSessionMemberUpdate();
856900

857901
await new Promise((resolve) => {

spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,10 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import { type Mock } from "jest-mock";
18-
1917
import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
2018
import { RoomStateEvent } from "../../../src/models/room-state";
2119
import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
22-
import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks";
20+
import { makeMockRoom, membershipTemplate, mockRoomState } from "./mocks";
2321

2422
describe("MatrixRTCSessionManager", () => {
2523
let client: MatrixClient;
@@ -52,19 +50,16 @@ describe("MatrixRTCSessionManager", () => {
5250
it("Fires event when session ends", () => {
5351
const onEnded = jest.fn();
5452
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
55-
const room1 = makeMockRoom(membershipTemplate);
53+
const room1 = makeMockRoom([membershipTemplate]);
5654
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
5755
jest.spyOn(client, "getRoom").mockReturnValue(room1);
5856

5957
client.emit(ClientEvent.Room, room1);
6058

61-
(room1.getLiveTimeline as Mock).mockReturnValue({
62-
getState: jest.fn().mockReturnValue(makeMockRoomState([{}], room1.roomId)),
63-
});
59+
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
6460

6561
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
6662
const membEvent = roomState.getStateEvents("")[0];
67-
6863
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
6964

7065
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));

spec/unit/matrixrtc/MembershipManager.spec.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ describe("MembershipManager", () => {
8181
// Default to fake timers.
8282
jest.useFakeTimers();
8383
client = makeMockClient("@alice:example.org", "AAAAAAA");
84-
room = makeMockRoom(membershipTemplate);
84+
room = makeMockRoom([membershipTemplate]);
8585
// Provide a default mock that is like the default "non error" server behaviour.
8686
(client._unstable_sendDelayedStateEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
8787
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue(undefined);
@@ -436,11 +436,11 @@ describe("MembershipManager", () => {
436436
type: "livekit",
437437
},
438438
],
439-
device_id: client.getDeviceId(),
439+
user_id: client.getUserId()!,
440+
device_id: client.getDeviceId()!,
440441
created_ts: 1000,
441442
},
442443
room.roomId,
443-
client.getUserId()!,
444444
),
445445
);
446446
expect(manager.getActiveFocus()).toStrictEqual(focus);
@@ -482,7 +482,10 @@ describe("MembershipManager", () => {
482482

483483
await manager.onRTCSessionMemberUpdate([
484484
mockCallMembership(membershipTemplate, room.roomId),
485-
mockCallMembership(myMembership as SessionMembershipData, room.roomId, client.getUserId() ?? undefined),
485+
mockCallMembership(
486+
{ ...(myMembership as SessionMembershipData), user_id: client.getUserId()! },
487+
room.roomId,
488+
),
486489
]);
487490

488491
await jest.advanceTimersByTimeAsync(1);
@@ -797,7 +800,7 @@ describe("MembershipManager", () => {
797800

798801
it("Should prefix log with MembershipManager used", () => {
799802
const client = makeMockClient("@alice:example.org", "AAAAAAA");
800-
const room = makeMockRoom(membershipTemplate);
803+
const room = makeMockRoom([membershipTemplate]);
801804

802805
const membershipManager = new MembershipManager(undefined, room, client, () => undefined, logger);
803806

spec/unit/matrixrtc/RTCEncryptionManager.spec.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -664,7 +664,7 @@ describe("RTCEncryptionManager", () => {
664664

665665
await jest.runOnlyPendingTimersAsync();
666666

667-
// The key should have beed re-distributed to the room transport
667+
// The key should have been re-distributed to the room transport
668668
expect(mockRoomTransport.sendKey).toHaveBeenCalled();
669669
expect(mockToDeviceTransport.sendKey).toHaveBeenCalledWith(
670670
expect.any(String),
@@ -677,9 +677,8 @@ describe("RTCEncryptionManager", () => {
677677

678678
function aCallMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership {
679679
return mockCallMembership(
680-
Object.assign({}, membershipTemplate, { device_id: deviceId, created_ts: ts }),
680+
{ ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
681681
"!room:id",
682-
userId,
683682
);
684683
}
685684
});

0 commit comments

Comments
 (0)