From ebb973b0ea2d0df4ed763a2f2e2d1bc49ac2d5bd Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 20 May 2025 17:26:42 -0400 Subject: [PATCH 01/12] Make it easier to mock call memberships for specific user IDs --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 103 ++++++++++++------ .../matrixrtc/MatrixRTCSessionManager.spec.ts | 11 +- spec/unit/matrixrtc/MembershipManager.spec.ts | 10 +- spec/unit/matrixrtc/mocks.ts | 24 ++-- 4 files changed, 89 insertions(+), 59 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 06f409a3bd..6c0e1693be 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -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" }; @@ -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); @@ -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); @@ -202,6 +201,59 @@ describe("MatrixRTCSession", () => { }); }); + describe("updateCallMembershipEvent", () => { + const mockFocus = { type: "livekit", livekit_service_url: "https://test.org" }; + const joinSessionConfig = {}; + + const sessionMembershipData: MembershipData = { + call_id: "", + scope: "m.room", + application: "m.call", + user_id: "@mock:user.example", + device_id: "AAAAAAA_session", + focus_active: mockFocus, + foci_preferred: [mockFocus], + }; + + let sendStateEventMock: jest.Mock; + let sendDelayedStateMock: jest.Mock; + + let sentStateEvent: Promise; + let sentDelayedState: Promise; + + beforeEach(() => { + sentStateEvent = new Promise((resolve) => { + sendStateEventMock = jest.fn(resolve); + }); + sentDelayedState = new Promise((resolve) => { + sendDelayedStateMock = jest.fn(() => { + resolve(); + return { + delay_id: "id", + }; + }); + }); + client.sendStateEvent = sendStateEventMock; + client._unstable_sendDelayedStateEvent = sendDelayedStateMock; + }); + + async function testSession(membershipData: MembershipData): Promise { + sess = MatrixRTCSession.roomSessionForRoom(client, makeMockRoom([membershipData])); + + sess.joinRoomSession([mockFocus], mockFocus, joinSessionConfig); + await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 500))]); + + expect(sendStateEventMock).toHaveBeenCalledTimes(1); + + await Promise.race([sentDelayedState, new Promise((resolve) => setTimeout(resolve, 500))]); + expect(sendDelayedStateMock).toHaveBeenCalledTimes(1); + } + + it("sends events", async () => { + await testSession(sessionMembershipData); + }); + }); + describe("getOldestMembership", () => { it("returns the oldest membership event", () => { jest.useFakeTimers(); @@ -302,7 +354,7 @@ describe("MatrixRTCSession", () => { 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(); @@ -313,13 +365,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(); @@ -503,18 +555,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((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); @@ -565,9 +613,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; @@ -592,9 +638,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 }); @@ -641,10 +685,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 }); @@ -674,6 +714,7 @@ describe("MatrixRTCSession", () => { // update created_ts member2.created_ts = 5000; + mockRoomState(mockRoom, [member1, member2]); const keysSentPromise2 = new Promise((resolve) => { sendEventMock.mockImplementation(resolve); @@ -737,9 +778,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); @@ -784,7 +823,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}` })); } @@ -804,11 +843,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(); @@ -849,9 +884,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) => { diff --git a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts index 5cdb9278f0..377e0eaf09 100644 --- a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts @@ -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; @@ -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)); diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index 18b2fb6309..0d1b35171b 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -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).mockResolvedValue({ delay_id: "id" }); (client._unstable_updateDelayedEvent as Mock).mockResolvedValue(undefined); @@ -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); @@ -482,7 +482,7 @@ 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); @@ -797,7 +797,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); diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index f20a9364ef..d61670d79f 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -20,11 +20,12 @@ import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership"; import { secureRandomString } from "../../../src/randomstring"; -type MembershipData = SessionMembershipData[] | SessionMembershipData | {}; +export type MembershipData = (SessionMembershipData | {}) & { user_id: string }; -export const membershipTemplate: SessionMembershipData = { +export const membershipTemplate: SessionMembershipData & { user_id: string } = { application: "m.call", call_id: "", + user_id: "@mock:user.example", device_id: "AAAAAAA", scope: "m.room", focus_active: { type: "livekit", focus_selection: "oldest_membership" }, @@ -68,7 +69,7 @@ export function makeMockClient(userId: string, deviceId: string): MockClient { } export function makeMockRoom( - membershipData: MembershipData, + membershipData: MembershipData[], ): Room & { emitTimelineEvent: (event: MatrixEvent) => void } { const roomId = secureRandomString(8); // Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()` @@ -87,10 +88,8 @@ export function makeMockRoom( }); } -export function makeMockRoomState(membershipData: MembershipData, roomId: string) { - const events = Array.isArray(membershipData) - ? membershipData.map((m) => mockRTCEvent(m, roomId)) - : [mockRTCEvent(membershipData, roomId)]; +function makeMockRoomState(membershipData: MembershipData[], roomId: string) { + const events = membershipData.map((m) => mockRTCEvent(m, roomId)); const keysAndEvents = events.map((e) => { const data = e.getContent() as SessionMembershipData; return [`_${e.sender?.userId}_${data.device_id}`]; @@ -120,6 +119,10 @@ export function makeMockRoomState(membershipData: MembershipData, roomId: string }; } +export function mockRoomState(room: Room, membershipData: MembershipData[]): void { + room.getLiveTimeline().getState = jest.fn().mockReturnValue(makeMockRoomState(membershipData, room.roomId)); +} + export function makeMockEvent( type: string, sender: string, @@ -138,13 +141,12 @@ export function makeMockEvent( } as unknown as MatrixEvent; } -export function mockRTCEvent(membershipData: MembershipData, roomId: string, customSender?: string): MatrixEvent { - const sender = customSender ?? "@mock:user.example"; +export function mockRTCEvent({ user_id: sender, ...membershipData }: MembershipData, roomId: string): MatrixEvent { return makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData); } -export function mockCallMembership(membershipData: MembershipData, roomId: string, sender?: string): CallMembership { - return new CallMembership(mockRTCEvent(membershipData, roomId, sender), membershipData); +export function mockCallMembership(membershipData: MembershipData, roomId: string): CallMembership { + return new CallMembership(mockRTCEvent(membershipData, roomId), membershipData); } export function makeKey(id: number, key: string): { key: string; index: number } { From b3b45cdd364ab11492a813117359f061b14dfff1 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 5 May 2025 19:25:46 +0200 Subject: [PATCH 02/12] Allow sending notification events when starting a call --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 53 ++++++++++++++++++- src/matrixrtc/MatrixRTCSession.ts | 55 ++++++++++++++++++-- 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 6c0e1693be..32a0f5b1c9 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -322,9 +322,15 @@ describe("MatrixRTCSession", () => { describe("joining", () => { let mockRoom: Room; let sendEventMock: jest.Mock; + let sendStateEventMock: jest.Mock; + let sentStateEvent: Promise; 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(); @@ -350,6 +356,51 @@ 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, { notifyType: "ring" }); + await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]); + mockRoomState(mockRoom, [{ ...membershipTemplate, user_id: client.getUserId()! }]); + sess!.onRTCSessionMemberUpdate(); + + 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, { notifyType: "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, { notifyType: "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", () => { diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index aaaf998042..ec4abb52f8 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -27,7 +27,7 @@ import { KnownMembership } from "../@types/membership.ts"; import { MembershipManager } from "./MembershipManager.ts"; import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts"; import { logDurationSync } from "../utils.ts"; -import { type Statistics } from "./types.ts"; +import { type Statistics, type CallNotifyType, isMyMembership } from "./types.ts"; import { RoomKeyTransport } from "./RoomKeyTransport.ts"; import type { IMembershipManager } from "./IMembershipManager.ts"; import { RTCEncryptionManager } from "./RTCEncryptionManager.ts"; @@ -65,6 +65,15 @@ export type MatrixRTCSessionEventHandlerMap = { ) => void; [MatrixRTCSessionEvent.MembershipManagerError]: (error: unknown) => void; }; + +export interface SessionConfig { + /** + * What kind of notification to send when starting the session. + * @default `undefined` (no notification) + */ + notifyType?: CallNotifyType; +} + // The names follow these principles: // - we use the technical term delay if the option is related to delayed events. // - we use delayedLeaveEvent if the option is related to the delayed leave event. @@ -168,7 +177,7 @@ export interface EncryptionConfig { */ useKeyDelay?: number; } -export type JoinSessionConfig = MembershipConfig & EncryptionConfig; +export type JoinSessionConfig = SessionConfig & MembershipConfig & EncryptionConfig; /** * A MatrixRTCSession manages the membership & properties of a MatrixRTC session. @@ -182,7 +191,15 @@ export class MatrixRTCSession extends TypedEventEmitter< private encryptionManager?: IEncryptionManager; // The session Id of the call, this is the call_id of the call Member event. private _callId: string | undefined; + private joinConfig?: SessionConfig; private logger: Logger; + + /** + * Whether we're trying to join the session but still waiting for room state + * to reflect our own membership. + */ + private joining = false; + /** * This timeout is responsible to track any expiration. We need to know when we have to start * to ignore other call members. There is no callback for this. This timeout will always be configured to @@ -447,6 +464,11 @@ export class MatrixRTCSession extends TypedEventEmitter< } } + this.joinConfig = joinConfig; + const userId = this.client.getUserId()!; + const deviceId = this.client.getDeviceId()!; + this.joining = !this.memberships.some((m) => isMyMembership(m, userId, deviceId)); + // Join! this.membershipManager!.join(fociPreferred, fociActive, (e) => { this.logger.error("MembershipManager encountered an unrecoverable error: ", e); @@ -476,11 +498,11 @@ export class MatrixRTCSession extends TypedEventEmitter< this.logger.info(`Leaving call session in room ${this.roomSubset.roomId}`); + this.joining = false; this.encryptionManager!.leave(); - const leavePromise = this.membershipManager!.leave(timeout); - this.emit(MatrixRTCSessionEvent.JoinStateChanged, false); + this.emit(MatrixRTCSessionEvent.JoinStateChanged, false); return await leavePromise; } @@ -547,6 +569,22 @@ export class MatrixRTCSession extends TypedEventEmitter< } } + /** + * Sends a notification corresponding to the configured notify type. + */ + private sendCallNotify(): void { + if (this.joinConfig?.notifyType !== undefined) { + this.client + .sendEvent(this.roomSubset.roomId, EventType.CallNotify, { + "application": "m.call", + "m.mentions": { user_ids: [], room: true }, + "notify_type": this.joinConfig.notifyType, + "call_id": this.callId!, + }) + .catch((e) => this.logger.error("Failed to send call notification", e)); + } + } + /** * Call this when the Matrix room members have changed. */ @@ -587,6 +625,15 @@ export class MatrixRTCSession extends TypedEventEmitter< }); void this.membershipManager?.onRTCSessionMemberUpdate(this.memberships); + + const userId = this.client.getUserId()!; + const deviceId = this.client.getDeviceId()!; + if (this.joining && this.memberships.some((m) => isMyMembership(m, userId, deviceId))) { + this.joining = false; + // If we're the first member in the call, we're responsible for + // sending the notification event + if (oldMemberships.length === 0) this.sendCallNotify(); + } } // This also needs to be done if `changed` = false // A member might have updated their fingerprint (created_ts) From 370689f6c90935008e07d50989308fb8b7cc1083 Mon Sep 17 00:00:00 2001 From: Timo Date: Thu, 17 Jul 2025 17:52:49 +0200 Subject: [PATCH 03/12] rename notify -> notification --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 6 +++--- src/matrixrtc/MatrixRTCSession.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 32a0f5b1c9..0500af3830 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -359,7 +359,7 @@ describe("MatrixRTCSession", () => { it("sends a notification when starting a call", async () => { // Simulate a join, including the update to the room state - sess!.joinRoomSession([mockFocus], mockFocus, { notifyType: "ring" }); + 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(); @@ -378,7 +378,7 @@ describe("MatrixRTCSession", () => { sess!.onRTCSessionMemberUpdate(); // Simulate a join, including the update to the room state - sess!.joinRoomSession([mockFocus], mockFocus, { notifyType: "ring" }); + 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(); @@ -388,7 +388,7 @@ describe("MatrixRTCSession", () => { 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, { notifyType: "ring" }); + 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 diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index ec4abb52f8..40e5c2634f 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -71,7 +71,7 @@ export interface SessionConfig { * What kind of notification to send when starting the session. * @default `undefined` (no notification) */ - notifyType?: CallNotifyType; + notificationType?: CallNotifyType; } // The names follow these principles: @@ -573,12 +573,12 @@ export class MatrixRTCSession extends TypedEventEmitter< * Sends a notification corresponding to the configured notify type. */ private sendCallNotify(): void { - if (this.joinConfig?.notifyType !== undefined) { + if (this.joinConfig?.notificationType !== undefined) { this.client .sendEvent(this.roomSubset.roomId, EventType.CallNotify, { "application": "m.call", "m.mentions": { user_ids: [], room: true }, - "notify_type": this.joinConfig.notifyType, + "notify_type": this.joinConfig.notificationType, "call_id": this.callId!, }) .catch((e) => this.logger.error("Failed to send call notification", e)); From 46d38a93bc98712cce8c5c9d6b7ce326e8e44a40 Mon Sep 17 00:00:00 2001 From: Timo Date: Thu, 17 Jul 2025 18:34:28 +0200 Subject: [PATCH 04/12] replace `joining` concept with `ownMembership` --- src/matrixrtc/IMembershipManager.ts | 6 +++++- src/matrixrtc/MatrixRTCSession.ts | 23 +++++++---------------- src/matrixrtc/MembershipManager.ts | 13 ++++++++++++- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/matrixrtc/IMembershipManager.ts b/src/matrixrtc/IMembershipManager.ts index bff4381798..0af127ac65 100644 --- a/src/matrixrtc/IMembershipManager.ts +++ b/src/matrixrtc/IMembershipManager.ts @@ -55,9 +55,13 @@ export interface IMembershipManager { * Get the actual connection status of the manager. */ get status(): Status; + /** - * The current status while the manager is activated + * The Current own state event if the manger is connected. + * `undefined` if not connected. */ + get ownMembership(): CallMembership | undefined; + /** * Start sending all necessary events to make this user participate in the RTC session. * @param fociPreferred the list of preferred foci to use in the joined RTC membership event. diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 40e5c2634f..13f25ad0a4 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -194,12 +194,6 @@ export class MatrixRTCSession extends TypedEventEmitter< private joinConfig?: SessionConfig; private logger: Logger; - /** - * Whether we're trying to join the session but still waiting for room state - * to reflect our own membership. - */ - private joining = false; - /** * This timeout is responsible to track any expiration. We need to know when we have to start * to ignore other call members. There is no callback for this. This timeout will always be configured to @@ -465,9 +459,6 @@ export class MatrixRTCSession extends TypedEventEmitter< } this.joinConfig = joinConfig; - const userId = this.client.getUserId()!; - const deviceId = this.client.getDeviceId()!; - this.joining = !this.memberships.some((m) => isMyMembership(m, userId, deviceId)); // Join! this.membershipManager!.join(fociPreferred, fociActive, (e) => { @@ -498,7 +489,6 @@ export class MatrixRTCSession extends TypedEventEmitter< this.logger.info(`Leaving call session in room ${this.roomSubset.roomId}`); - this.joining = false; this.encryptionManager!.leave(); const leavePromise = this.membershipManager!.leave(timeout); @@ -625,14 +615,15 @@ export class MatrixRTCSession extends TypedEventEmitter< }); void this.membershipManager?.onRTCSessionMemberUpdate(this.memberships); - - const userId = this.client.getUserId()!; - const deviceId = this.client.getDeviceId()!; - if (this.joining && this.memberships.some((m) => isMyMembership(m, userId, deviceId))) { - this.joining = false; + const ownMembership = this.membershipManager?.ownMembership; + if (ownMembership && oldMemberships.length === 0) { // If we're the first member in the call, we're responsible for // sending the notification event - if (oldMemberships.length === 0) this.sendCallNotify(); + if (ownMembership.eventId) { + this.sendCallNotify(ownMembership.eventId); + } else { + this.logger.warn("Own membership eventId is undefined, cannot send call notification"); + } } } // This also needs to be done if `changed` = false diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index 6839469c42..b834594e1e 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -221,7 +221,13 @@ export class MembershipManager public async onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise { const userId = this.client.getUserId(); const deviceId = this.client.getDeviceId(); - if (userId && deviceId && this.isJoined() && !memberships.some((m) => isMyMembership(m, userId, deviceId))) { + if (!userId || !deviceId) { + this.logger.error("MembershipManager.onRTCSessionMemberUpdate called without user or device id"); + return Promise.resolve(); + } + this._ownMembership = memberships.find((m) => isMyMembership(m, userId, deviceId)); + + if (this.isActivated() && !this._ownMembership) { // If one of these actions are scheduled or are getting inserted in the next iteration, we should already // take care of our missing membership. const sendingMembershipActions = [ @@ -310,6 +316,11 @@ export class MembershipManager }, this.logger); } + private _ownMembership?: CallMembership; + public get ownMembership(): CallMembership | undefined { + return this._ownMembership; + } + // scheduler private oldStatus?: Status; private scheduler: ActionScheduler; From 4d19d8556ef48090a4e55a5e204af2062ee3379f Mon Sep 17 00:00:00 2001 From: Timo Date: Thu, 17 Jul 2025 18:35:05 +0200 Subject: [PATCH 05/12] introduce new `m.rtc.notification` event alongside `m.call.notify` --- src/@types/event.ts | 10 +++++++++- src/matrixrtc/types.ts | 18 ++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/@types/event.ts b/src/@types/event.ts index 479f7aef81..4124deab59 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -51,7 +51,11 @@ import { type SDPStreamMetadata, type SDPStreamMetadataKey, } from "../webrtc/callEventTypes.ts"; -import { type EncryptionKeysEventContent, type ICallNotifyContent } from "../matrixrtc/types.ts"; +import { + type IRTCNotificationContent, + type EncryptionKeysEventContent, + type ICallNotifyContent, +} from "../matrixrtc/types.ts"; import { type M_POLL_END, type M_POLL_START, type PollEndEventContent, type PollStartEventContent } from "./polls.ts"; import { type SessionMembershipData } from "../matrixrtc/CallMembership.ts"; import { type LocalNotificationSettings } from "./local_notifications.ts"; @@ -147,6 +151,7 @@ export enum EventType { // MatrixRTC events CallNotify = "org.matrix.msc4075.call.notify", + RTCNotification = "org.matrix.msc4075.rtc.notification", } export enum RelationType { @@ -158,6 +163,8 @@ export enum RelationType { // moreover, our tests currently use the unstable prefix. Use THREAD_RELATION_TYPE.name. // Once we support *only* the stable prefix, THREAD_RELATION_TYPE can die and we can switch to this. Thread = "m.thread", + unstable_RTCParentEvent = "org.matrix.msc4075.rtc.parent_event", + unstable_RTCDecline = "org.matrix.msc4075.rtc.decline", } export enum MsgType { @@ -325,6 +332,7 @@ export interface TimelineEvents { [EventType.CallSDPStreamMetadataChangedPrefix]: MCallBase & { [SDPStreamMetadataKey]: SDPStreamMetadata }; [EventType.CallEncryptionKeysPrefix]: EncryptionKeysEventContent; [EventType.CallNotify]: ICallNotifyContent; + [EventType.RTCNotification]: IRTCNotificationContent; [M_BEACON.name]: MBeaconEventContent; [M_POLL_START.name]: PollStartEventContent; [M_POLL_END.name]: PollEndEventContent; diff --git a/src/matrixrtc/types.ts b/src/matrixrtc/types.ts index 3239e593cb..acb1834ef8 100644 --- a/src/matrixrtc/types.ts +++ b/src/matrixrtc/types.ts @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ import type { IMentions } from "../matrix.ts"; +import type { RelationEvent } from "../types.ts"; import type { CallMembership } from "./CallMembership.ts"; export type ParticipantId = string; @@ -80,9 +81,13 @@ export interface EncryptionKeysToDeviceEventContent { // Why is this needed? sent_ts?: number; } - +/** + * @deprecated Use `RTCNotificationType` instead. + */ export type CallNotifyType = "ring" | "notify"; - +/** + * @deprecated Use `IRTCNotificationContent` instead. + */ export interface ICallNotifyContent { "application": string; "m.mentions": IMentions; @@ -90,6 +95,15 @@ export interface ICallNotifyContent { "call_id": string; } +export type RTCNotificationType = "ring" | "notification" | "decline"; +export interface IRTCNotificationContent extends RelationEvent { + "m.mentions": IMentions; + "decline_reason"?: string; + "notification_type": RTCNotificationType; + "sender_ts": number; + "lifetime": number; +} + export enum Status { Disconnected = "Disconnected", Connecting = "Connecting", From 3c385a1d74c6e449af0ff61bf6250f4a19a4ad46 Mon Sep 17 00:00:00 2001 From: Timo Date: Thu, 17 Jul 2025 18:35:37 +0200 Subject: [PATCH 06/12] send new notification event alongside the deprecated one --- src/matrixrtc/MatrixRTCSession.ts | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 13f25ad0a4..2f718a2544 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -19,7 +19,7 @@ import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { EventTimeline } from "../models/event-timeline.ts"; import { type Room } from "../models/room.ts"; import { type MatrixClient } from "../client.ts"; -import { EventType } from "../@types/event.ts"; +import { EventType, RelationType } from "../@types/event.ts"; import { CallMembership } from "./CallMembership.ts"; import { RoomStateEvent } from "../models/room-state.ts"; import { type Focus } from "./focus.ts"; @@ -27,7 +27,7 @@ import { KnownMembership } from "../@types/membership.ts"; import { MembershipManager } from "./MembershipManager.ts"; import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts"; import { logDurationSync } from "../utils.ts"; -import { type Statistics, type CallNotifyType, isMyMembership } from "./types.ts"; +import { type Statistics, type RTCNotificationType } from "./types.ts"; import { RoomKeyTransport } from "./RoomKeyTransport.ts"; import type { IMembershipManager } from "./IMembershipManager.ts"; import { RTCEncryptionManager } from "./RTCEncryptionManager.ts"; @@ -71,7 +71,7 @@ export interface SessionConfig { * What kind of notification to send when starting the session. * @default `undefined` (no notification) */ - notificationType?: CallNotifyType; + notificationType?: Exclude; } // The names follow these principles: @@ -562,16 +562,32 @@ export class MatrixRTCSession extends TypedEventEmitter< /** * Sends a notification corresponding to the configured notify type. */ - private sendCallNotify(): void { - if (this.joinConfig?.notificationType !== undefined) { + private sendCallNotify(parentEventId: string): void { + const notificationType = this.joinConfig?.notificationType; + if (notificationType !== undefined) { + // Send legacy event: + this.client .sendEvent(this.roomSubset.roomId, EventType.CallNotify, { "application": "m.call", "m.mentions": { user_ids: [], room: true }, - "notify_type": this.joinConfig.notificationType, + "notify_type": notificationType === "notification" ? "notify" : notificationType, "call_id": this.callId!, }) .catch((e) => this.logger.error("Failed to send call notification", e)); + // Send new event: + this.client + .sendEvent(this.roomSubset.roomId, EventType.RTCNotification, { + "m.mentions": { user_ids: [], room: true }, + "notification_type": notificationType, + "m.relates_to": { + event_id: parentEventId, + rel_type: RelationType.unstable_RTCParentEvent, + }, + "sender_ts": Date.now(), + "lifetime": 30_000, // 30 seconds + }) + .catch((e) => this.logger.error("Failed to send call notification", e)); } } From ad793c1fba7935ae38bb071edc375b9bb5b41748 Mon Sep 17 00:00:00 2001 From: Timo Date: Fri, 18 Jul 2025 11:48:24 +0200 Subject: [PATCH 07/12] Test for new notification event type --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 0500af3830..51358a75ea 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -363,7 +363,20 @@ describe("MatrixRTCSession", () => { 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 }, From bf95e553c4a84ae5282c3b5f191d6132b127c19b Mon Sep 17 00:00:00 2001 From: Timo Date: Fri, 18 Jul 2025 11:48:37 +0200 Subject: [PATCH 08/12] update relation string to match msc --- src/@types/event.ts | 4 ++-- src/matrixrtc/MatrixRTCSession.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/@types/event.ts b/src/@types/event.ts index 4124deab59..bd35f516f3 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -163,8 +163,8 @@ export enum RelationType { // moreover, our tests currently use the unstable prefix. Use THREAD_RELATION_TYPE.name. // Once we support *only* the stable prefix, THREAD_RELATION_TYPE can die and we can switch to this. Thread = "m.thread", - unstable_RTCParentEvent = "org.matrix.msc4075.rtc.parent_event", - unstable_RTCDecline = "org.matrix.msc4075.rtc.decline", + unstable_RTCNotificationParent = "org.matrix.msc4075.rtc.notification.parent", + unstable_RTCNotificationDecline = "org.matrix.msc4075.rtc.notification.decline", } export enum MsgType { diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 2f718a2544..56c2349daf 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -582,7 +582,7 @@ export class MatrixRTCSession extends TypedEventEmitter< "notification_type": notificationType, "m.relates_to": { event_id: parentEventId, - rel_type: RelationType.unstable_RTCParentEvent, + rel_type: RelationType.unstable_RTCNotificationParent, }, "sender_ts": Date.now(), "lifetime": 30_000, // 30 seconds From 3abd8bbbde649b96c32c71f55867a5650d95bbc0 Mon Sep 17 00:00:00 2001 From: Timo Date: Fri, 18 Jul 2025 12:55:47 +0200 Subject: [PATCH 09/12] review --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 55 +--------------- src/@types/event.ts | 1 - src/matrixrtc/MatrixRTCSession.ts | 66 +++++++++++--------- 3 files changed, 36 insertions(+), 86 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 51358a75ea..d8a718017b 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -201,59 +201,6 @@ describe("MatrixRTCSession", () => { }); }); - describe("updateCallMembershipEvent", () => { - const mockFocus = { type: "livekit", livekit_service_url: "https://test.org" }; - const joinSessionConfig = {}; - - const sessionMembershipData: MembershipData = { - call_id: "", - scope: "m.room", - application: "m.call", - user_id: "@mock:user.example", - device_id: "AAAAAAA_session", - focus_active: mockFocus, - foci_preferred: [mockFocus], - }; - - let sendStateEventMock: jest.Mock; - let sendDelayedStateMock: jest.Mock; - - let sentStateEvent: Promise; - let sentDelayedState: Promise; - - beforeEach(() => { - sentStateEvent = new Promise((resolve) => { - sendStateEventMock = jest.fn(resolve); - }); - sentDelayedState = new Promise((resolve) => { - sendDelayedStateMock = jest.fn(() => { - resolve(); - return { - delay_id: "id", - }; - }); - }); - client.sendStateEvent = sendStateEventMock; - client._unstable_sendDelayedStateEvent = sendDelayedStateMock; - }); - - async function testSession(membershipData: MembershipData): Promise { - sess = MatrixRTCSession.roomSessionForRoom(client, makeMockRoom([membershipData])); - - sess.joinRoomSession([mockFocus], mockFocus, joinSessionConfig); - await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 500))]); - - expect(sendStateEventMock).toHaveBeenCalledTimes(1); - - await Promise.race([sentDelayedState, new Promise((resolve) => setTimeout(resolve, 500))]); - expect(sendDelayedStateMock).toHaveBeenCalledTimes(1); - } - - it("sends events", async () => { - await testSession(sessionMembershipData); - }); - }); - describe("getOldestMembership", () => { it("returns the oldest membership event", () => { jest.useFakeTimers(); @@ -376,7 +323,7 @@ describe("MatrixRTCSession", () => { "sender_ts": expect.any(Number), }); - // check if deprecated notify event is also sent + // 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 }, diff --git a/src/@types/event.ts b/src/@types/event.ts index bd35f516f3..fd906266e7 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -164,7 +164,6 @@ export enum RelationType { // Once we support *only* the stable prefix, THREAD_RELATION_TYPE can die and we can switch to this. Thread = "m.thread", unstable_RTCNotificationParent = "org.matrix.msc4075.rtc.notification.parent", - unstable_RTCNotificationDecline = "org.matrix.msc4075.rtc.notification.decline", } export enum MsgType { diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 56c2349daf..fe8fc7e3e7 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -194,6 +194,7 @@ export class MatrixRTCSession extends TypedEventEmitter< private joinConfig?: SessionConfig; private logger: Logger; + private pendingNotificationToSend: undefined | Exclude; /** * This timeout is responsible to track any expiration. We need to know when we have to start * to ignore other call members. There is no callback for this. This timeout will always be configured to @@ -459,6 +460,7 @@ export class MatrixRTCSession extends TypedEventEmitter< } this.joinConfig = joinConfig; + this.pendingNotificationToSend = this.joinConfig?.notificationType; // Join! this.membershipManager!.join(fociPreferred, fociActive, (e) => { @@ -490,9 +492,10 @@ export class MatrixRTCSession extends TypedEventEmitter< this.logger.info(`Leaving call session in room ${this.roomSubset.roomId}`); this.encryptionManager!.leave(); - const leavePromise = this.membershipManager!.leave(timeout); + const leavePromise = this.membershipManager!.leave(timeout); this.emit(MatrixRTCSessionEvent.JoinStateChanged, false); + return await leavePromise; } @@ -562,33 +565,30 @@ export class MatrixRTCSession extends TypedEventEmitter< /** * Sends a notification corresponding to the configured notify type. */ - private sendCallNotify(parentEventId: string): void { - const notificationType = this.joinConfig?.notificationType; - if (notificationType !== undefined) { - // Send legacy event: - - this.client - .sendEvent(this.roomSubset.roomId, EventType.CallNotify, { - "application": "m.call", - "m.mentions": { user_ids: [], room: true }, - "notify_type": notificationType === "notification" ? "notify" : notificationType, - "call_id": this.callId!, - }) - .catch((e) => this.logger.error("Failed to send call notification", e)); - // Send new event: - this.client - .sendEvent(this.roomSubset.roomId, EventType.RTCNotification, { - "m.mentions": { user_ids: [], room: true }, - "notification_type": notificationType, - "m.relates_to": { - event_id: parentEventId, - rel_type: RelationType.unstable_RTCNotificationParent, - }, - "sender_ts": Date.now(), - "lifetime": 30_000, // 30 seconds - }) - .catch((e) => this.logger.error("Failed to send call notification", e)); - } + private sendCallNotify(parentEventId: string, notificationType: Exclude): void { + // Send legacy event: + this.client + .sendEvent(this.roomSubset.roomId, EventType.CallNotify, { + "application": "m.call", + "m.mentions": { user_ids: [], room: true }, + "notify_type": notificationType === "notification" ? "notify" : notificationType, + "call_id": this.callId!, + }) + .catch((e) => this.logger.error("Failed to send call notification", e)); + + // Send new event: + this.client + .sendEvent(this.roomSubset.roomId, EventType.RTCNotification, { + "m.mentions": { user_ids: [], room: true }, + "notification_type": notificationType, + "m.relates_to": { + event_id: parentEventId, + rel_type: RelationType.unstable_RTCNotificationParent, + }, + "sender_ts": Date.now(), + "lifetime": 30_000, // 30 seconds + }) + .catch((e) => this.logger.error("Failed to send call notification", e)); } /** @@ -631,16 +631,20 @@ export class MatrixRTCSession extends TypedEventEmitter< }); void this.membershipManager?.onRTCSessionMemberUpdate(this.memberships); + // The `ownMembership` will be set when calling `onRTCSessionMemberUpdate`. const ownMembership = this.membershipManager?.ownMembership; - if (ownMembership && oldMemberships.length === 0) { + if (this.pendingNotificationToSend && ownMembership && oldMemberships.length === 0) { // If we're the first member in the call, we're responsible for // sending the notification event - if (ownMembership.eventId) { - this.sendCallNotify(ownMembership.eventId); + if (ownMembership.eventId && this.joinConfig?.notificationType) { + this.sendCallNotify(ownMembership.eventId, this.joinConfig.notificationType); } else { this.logger.warn("Own membership eventId is undefined, cannot send call notification"); } } + // If anyone else joins the session it is no longer our responsibility to send the notification. + // (If we were the joiner we already did sent the notification in the block above.) + if (this.memberships.length > 0) this.pendingNotificationToSend = undefined; } // This also needs to be done if `changed` = false // A member might have updated their fingerprint (created_ts) From 4cb7170cf1b592e9bbc7e2fe177820da691e61f0 Mon Sep 17 00:00:00 2001 From: Timo Date: Fri, 18 Jul 2025 13:01:30 +0200 Subject: [PATCH 10/12] fix doc errors --- spec/unit/matrixrtc/RTCEncryptionManager.spec.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts b/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts index 0e620e167a..914787fe4c 100644 --- a/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts +++ b/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts @@ -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), @@ -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 }), + Object.assign({}, membershipTemplate, { userId, device_id: deviceId, created_ts: ts }), "!room:id", - userId, ); } }); From 1fefe29cfe641ea29ee325c39802f5e46f76cb33 Mon Sep 17 00:00:00 2001 From: Timo Date: Fri, 18 Jul 2025 13:07:11 +0200 Subject: [PATCH 11/12] fix tests + format --- spec/unit/matrixrtc/MembershipManager.spec.ts | 5 ++++- spec/unit/matrixrtc/RTCEncryptionManager.spec.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index 0d1b35171b..482439eca9 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -482,7 +482,10 @@ describe("MembershipManager", () => { await manager.onRTCSessionMemberUpdate([ mockCallMembership(membershipTemplate, room.roomId), - mockCallMembership({ ...myMembership as SessionMembershipData, user_id: client.getUserId()! }, room.roomId), + mockCallMembership( + { ...(myMembership as SessionMembershipData), user_id: client.getUserId()! }, + room.roomId, + ), ]); await jest.advanceTimersByTimeAsync(1); diff --git a/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts b/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts index 914787fe4c..19c5be8ee7 100644 --- a/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts +++ b/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts @@ -677,7 +677,7 @@ describe("RTCEncryptionManager", () => { function aCallMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership { return mockCallMembership( - Object.assign({}, membershipTemplate, { userId, device_id: deviceId, created_ts: ts }), + { ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts }, "!room:id", ); } From 49e6985694c98d9ab42c1008a5e2afbd13b16145 Mon Sep 17 00:00:00 2001 From: Timo Date: Fri, 18 Jul 2025 13:13:40 +0200 Subject: [PATCH 12/12] remove anything decline related --- src/matrixrtc/MatrixRTCSession.ts | 6 +++--- src/matrixrtc/types.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index fe8fc7e3e7..4da3dc5ab2 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -71,7 +71,7 @@ export interface SessionConfig { * What kind of notification to send when starting the session. * @default `undefined` (no notification) */ - notificationType?: Exclude; + notificationType?: RTCNotificationType; } // The names follow these principles: @@ -194,7 +194,7 @@ export class MatrixRTCSession extends TypedEventEmitter< private joinConfig?: SessionConfig; private logger: Logger; - private pendingNotificationToSend: undefined | Exclude; + private pendingNotificationToSend: undefined | RTCNotificationType; /** * This timeout is responsible to track any expiration. We need to know when we have to start * to ignore other call members. There is no callback for this. This timeout will always be configured to @@ -565,7 +565,7 @@ export class MatrixRTCSession extends TypedEventEmitter< /** * Sends a notification corresponding to the configured notify type. */ - private sendCallNotify(parentEventId: string, notificationType: Exclude): void { + private sendCallNotify(parentEventId: string, notificationType: RTCNotificationType): void { // Send legacy event: this.client .sendEvent(this.roomSubset.roomId, EventType.CallNotify, { diff --git a/src/matrixrtc/types.ts b/src/matrixrtc/types.ts index acb1834ef8..73c5a01700 100644 --- a/src/matrixrtc/types.ts +++ b/src/matrixrtc/types.ts @@ -95,7 +95,7 @@ export interface ICallNotifyContent { "call_id": string; } -export type RTCNotificationType = "ring" | "notification" | "decline"; +export type RTCNotificationType = "ring" | "notification"; export interface IRTCNotificationContent extends RelationEvent { "m.mentions": IMentions; "decline_reason"?: string;