Skip to content

Allow sending notification events when starting a call #4826

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jul 18, 2025
Merged
116 changes: 80 additions & 36 deletions spec/unit/matrixrtc/MatrixRTCSession.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,10 @@ limitations under the License.

import { encodeBase64, EventType, MatrixClient, type MatrixError, type MatrixEvent, type Room } from "../../../src";
import { KnownMembership } from "../../../src/@types/membership";
import { type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
import { type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
import { secureRandomString } from "../../../src/randomstring";
import { makeMockEvent, makeMockRoom, makeMockRoomState, membershipTemplate, makeKey } from "./mocks";
import { makeMockEvent, makeMockRoom, membershipTemplate, makeKey, type MembershipData, mockRoomState } from "./mocks";
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";

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

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

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

it("ignores memberships events of members not in the room", () => {
const mockRoom = makeMockRoom(membershipTemplate);
const mockRoom = makeMockRoom([membershipTemplate]);
mockRoom.hasMembershipState = (state) => state === KnownMembership.Join;
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
expect(sess?.memberships.length).toEqual(0);
Expand Down Expand Up @@ -270,9 +269,15 @@ describe("MatrixRTCSession", () => {
describe("joining", () => {
let mockRoom: Room;
let sendEventMock: jest.Mock;
let sendStateEventMock: jest.Mock;

let sentStateEvent: Promise<void>;
beforeEach(() => {
sendEventMock = jest.fn();
sentStateEvent = new Promise((resolve) => {
sendStateEventMock = jest.fn(resolve);
});
sendEventMock = jest.fn().mockResolvedValue(undefined);
client.sendStateEvent = sendStateEventMock;
client.sendEvent = sendEventMock;

client._unstable_updateDelayedEvent = jest.fn();
Expand All @@ -298,11 +303,69 @@ describe("MatrixRTCSession", () => {
sess!.joinRoomSession([mockFocus], mockFocus);
expect(sess!.isJoined()).toEqual(true);
});

it("sends a notification when starting a call", async () => {
// Simulate a join, including the update to the room state
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" });
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
mockRoomState(mockRoom, [{ ...membershipTemplate, user_id: client.getUserId()! }]);
sess!.onRTCSessionMemberUpdate();
const ownMembershipId = sess?.memberships[0].eventId;

expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.RTCNotification, {
"m.mentions": { user_ids: [], room: true },
"notification_type": "ring",
"m.relates_to": {
event_id: ownMembershipId,
rel_type: "org.matrix.msc4075.rtc.notification.parent",
},
"lifetime": 30000,
"sender_ts": expect.any(Number),
});

// Check if deprecated notify event is also sent.
expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.CallNotify, {
"application": "m.call",
"m.mentions": { user_ids: [], room: true },
"notify_type": "ring",
"call_id": "",
});
});

it("doesn't send a notification when joining an existing call", async () => {
// Add another member to the call so that it is considered an existing call
mockRoomState(mockRoom, [membershipTemplate]);
sess!.onRTCSessionMemberUpdate();

// Simulate a join, including the update to the room state
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" });
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
mockRoomState(mockRoom, [membershipTemplate, { ...membershipTemplate, user_id: client.getUserId()! }]);
sess!.onRTCSessionMemberUpdate();

expect(client.sendEvent).not.toHaveBeenCalled();
});

it("doesn't send a notification when someone else starts the call faster than us", async () => {
// Simulate a join, including the update to the room state
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" });
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
// But this time we want to simulate a race condition in which we receive a state event
// from someone else, starting the call before our own state event has been sent
mockRoomState(mockRoom, [membershipTemplate]);
sess!.onRTCSessionMemberUpdate();
mockRoomState(mockRoom, [membershipTemplate, { ...membershipTemplate, user_id: client.getUserId()! }]);
sess!.onRTCSessionMemberUpdate();

// We assume that the responsibility to send a notification, if any, lies with the other
// participant that won the race
expect(client.sendEvent).not.toHaveBeenCalled();
});
});

describe("onMembershipsChanged", () => {
it("does not emit if no membership changes", () => {
const mockRoom = makeMockRoom(membershipTemplate);
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);

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

it("emits on membership changes", () => {
const mockRoom = makeMockRoom(membershipTemplate);
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);

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

mockRoom.getLiveTimeline().getState = jest.fn().mockReturnValue(makeMockRoomState([], mockRoom.roomId));
mockRoomState(mockRoom, []);
sess.onRTCSessionMemberUpdate();

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

// member2 leaves triggering key rotation
mockRoom.getLiveTimeline().getState = jest
.fn()
.mockReturnValue(makeMockRoomState([membershipTemplate], mockRoom.roomId));
mockRoomState(mockRoom, [membershipTemplate]);
sess.onRTCSessionMemberUpdate();

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

mockRoom.getLiveTimeline().getState = jest
.fn()
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId));
mockRoomState(mockRoom, [membershipTemplate, member2]);
sess.onRTCSessionMemberUpdate();

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

const mockRoom = makeMockRoom([member1, member2]);
mockRoom.getLiveTimeline().getState = jest
.fn()
.mockReturnValue(makeMockRoomState([member1, member2], mockRoom.roomId));
mockRoomState(mockRoom, [member1, member2]);

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

const mockRoom = makeMockRoom([member1, member2]);
mockRoom.getLiveTimeline().getState = jest
.fn()
.mockReturnValue(makeMockRoomState([member1, member2], mockRoom.roomId));

sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });

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

// update created_ts
member2.created_ts = 5000;
mockRoomState(mockRoom, [member1, member2]);

const keysSentPromise2 = new Promise((resolve) => {
sendEventMock.mockImplementation(resolve);
Expand Down Expand Up @@ -737,9 +789,7 @@ describe("MatrixRTCSession", () => {
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
});

mockRoom.getLiveTimeline().getState = jest
.fn()
.mockReturnValue(makeMockRoomState([membershipTemplate], mockRoom.roomId));
mockRoomState(mockRoom, [membershipTemplate]);
sess.onRTCSessionMemberUpdate();

jest.advanceTimersByTime(KEY_DELAY);
Expand Down Expand Up @@ -784,7 +834,7 @@ describe("MatrixRTCSession", () => {
it("wraps key index around to 0 when it reaches the maximum", async () => {
// this should give us keys with index [0...255, 0, 1]
const membersToTest = 258;
const members: SessionMembershipData[] = [];
const members: MembershipData[] = [];
for (let i = 0; i < membersToTest; i++) {
members.push(Object.assign({}, membershipTemplate, { device_id: `DEVICE${i}` }));
}
Expand All @@ -804,11 +854,7 @@ describe("MatrixRTCSession", () => {
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
} else {
// otherwise update the state reducing the membership each time in order to trigger key rotation
mockRoom.getLiveTimeline().getState = jest
.fn()
.mockReturnValue(
makeMockRoomState(members.slice(0, membersToTest - i), mockRoom.roomId),
);
mockRoomState(mockRoom, members.slice(0, membersToTest - i));
}

sess!.onRTCSessionMemberUpdate();
Expand Down Expand Up @@ -849,9 +895,7 @@ describe("MatrixRTCSession", () => {
device_id: "BBBBBBB",
});

mockRoom.getLiveTimeline().getState = jest
.fn()
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId));
mockRoomState(mockRoom, [membershipTemplate, member2]);
sess.onRTCSessionMemberUpdate();

await new Promise((resolve) => {
Expand Down
11 changes: 3 additions & 8 deletions spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { type Mock } from "jest-mock";

import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
import { RoomStateEvent } from "../../../src/models/room-state";
import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks";
import { makeMockRoom, membershipTemplate, mockRoomState } from "./mocks";

describe("MatrixRTCSessionManager", () => {
let client: MatrixClient;
Expand Down Expand Up @@ -52,19 +50,16 @@ describe("MatrixRTCSessionManager", () => {
it("Fires event when session ends", () => {
const onEnded = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
const room1 = makeMockRoom(membershipTemplate);
const room1 = makeMockRoom([membershipTemplate]);
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
jest.spyOn(client, "getRoom").mockReturnValue(room1);

client.emit(ClientEvent.Room, room1);

(room1.getLiveTimeline as Mock).mockReturnValue({
getState: jest.fn().mockReturnValue(makeMockRoomState([{}], room1.roomId)),
});
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);

const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const membEvent = roomState.getStateEvents("")[0];

client.emit(RoomStateEvent.Events, membEvent, roomState, null);

expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
Expand Down
13 changes: 8 additions & 5 deletions spec/unit/matrixrtc/MembershipManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ describe("MembershipManager", () => {
// Default to fake timers.
jest.useFakeTimers();
client = makeMockClient("@alice:example.org", "AAAAAAA");
room = makeMockRoom(membershipTemplate);
room = makeMockRoom([membershipTemplate]);
// Provide a default mock that is like the default "non error" server behaviour.
(client._unstable_sendDelayedStateEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue(undefined);
Expand Down Expand Up @@ -436,11 +436,11 @@ describe("MembershipManager", () => {
type: "livekit",
},
],
device_id: client.getDeviceId(),
user_id: client.getUserId()!,
device_id: client.getDeviceId()!,
created_ts: 1000,
},
room.roomId,
client.getUserId()!,
),
);
expect(manager.getActiveFocus()).toStrictEqual(focus);
Expand Down Expand Up @@ -482,7 +482,10 @@ describe("MembershipManager", () => {

await manager.onRTCSessionMemberUpdate([
mockCallMembership(membershipTemplate, room.roomId),
mockCallMembership(myMembership as SessionMembershipData, room.roomId, client.getUserId() ?? undefined),
mockCallMembership(
{ ...(myMembership as SessionMembershipData), user_id: client.getUserId()! },
room.roomId,
),
]);

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

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

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

Expand Down
5 changes: 2 additions & 3 deletions spec/unit/matrixrtc/RTCEncryptionManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,7 @@ describe("RTCEncryptionManager", () => {

await jest.runOnlyPendingTimersAsync();

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

function aCallMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership {
return mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: deviceId, created_ts: ts }),
{ ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
"!room:id",
userId,
);
}
});
Loading