diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 423f5f13ed..fc28f42355 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -16,11 +16,11 @@ limitations under the License. import { encodeBase64, EventType, MatrixClient, type MatrixError, type MatrixEvent, type Room } from "../../../src"; import { KnownMembership } from "../../../src/@types/membership"; -import { DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership"; +import { DEFAULT_EXPIRE_DURATION } 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"; const mockFocus = { type: "mock" }; @@ -47,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); @@ -74,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); @@ -205,10 +205,11 @@ describe("MatrixRTCSession", () => { const mockFocus = { type: "livekit", livekit_service_url: "https://test.org" }; const joinSessionConfig = {}; - const sessionMembershipData: SessionMembershipData = { + 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], @@ -236,8 +237,8 @@ describe("MatrixRTCSession", () => { client._unstable_sendDelayedStateEvent = sendDelayedStateMock; }); - async function testSession(membershipData: SessionMembershipData): Promise { - sess = MatrixRTCSession.roomSessionForRoom(client, makeMockRoom(membershipData)); + 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))]); @@ -339,7 +340,7 @@ describe("MatrixRTCSession", () => { }; }); }); - sendEventMock = jest.fn(); + sendEventMock = jest.fn().mockResolvedValue(undefined); client.sendStateEvent = sendStateEventMock; client._unstable_sendDelayedStateEvent = sendDelayedStateMock; client.sendEvent = sendEventMock; @@ -368,6 +369,51 @@ describe("MatrixRTCSession", () => { 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(); + }); + it("sends a membership event when joining a call", async () => { const realSetTimeout = setTimeout; jest.useFakeTimers(); @@ -432,7 +478,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(); @@ -443,13 +489,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(); @@ -635,18 +681,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); @@ -697,9 +739,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; @@ -724,9 +764,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 }); @@ -773,10 +811,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 }); @@ -806,6 +840,7 @@ describe("MatrixRTCSession", () => { // update created_ts member2.created_ts = 5000; + mockRoomState(mockRoom, [member1, member2]); const keysSentPromise2 = new Promise((resolve) => { sendEventMock.mockImplementation(resolve); @@ -869,9 +904,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); @@ -900,7 +933,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}` })); } @@ -920,11 +953,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(); @@ -965,9 +994,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 36cb456c70..6fafe726c4 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -80,7 +80,7 @@ describe.each([ // 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); @@ -403,11 +403,11 @@ describe.each([ 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); @@ -449,7 +449,7 @@ describe.each([ 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); @@ -766,7 +766,7 @@ describe.each([ 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/RoomAndToDeviceTransport.spec.ts b/spec/unit/matrixrtc/RoomAndToDeviceTransport.spec.ts index a4ce40aa67..f9a4ec7cd3 100644 --- a/spec/unit/matrixrtc/RoomAndToDeviceTransport.spec.ts +++ b/spec/unit/matrixrtc/RoomAndToDeviceTransport.spec.ts @@ -88,7 +88,7 @@ describe("RoomAndToDeviceTransport", () => { }); it("only sends to device keys when sending a key", async () => { transport.start(); - await transport.sendKey("1235", 0, [mockCallMembership(membershipTemplate, roomId, "@alice:example.org")]); + await transport.sendKey("1235", 0, [mockCallMembership({ ...membershipTemplate, user_id: '@alice:example.org' }, roomId)]); expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(1); expect(roomSendKeySpy).toHaveBeenCalledTimes(0); expect(transport.enabled.room).toBeFalsy(); @@ -118,7 +118,7 @@ describe("RoomAndToDeviceTransport", () => { expect(transport.enabled.room).toBeTruthy(); expect(transport.enabled.toDevice).toBeFalsy(); - await transport.sendKey("1235", 0, [mockCallMembership(membershipTemplate, roomId, "@alice:example.org")]); + await transport.sendKey("1235", 0, [mockCallMembership({ ...membershipTemplate, user_id: '@alice:example.org' }, roomId)]); expect(sendEventMock).toHaveBeenCalledTimes(1); expect(roomSendKeySpy).toHaveBeenCalledTimes(1); expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(0); diff --git a/spec/unit/matrixrtc/ToDeviceKeyTransport.spec.ts b/spec/unit/matrixrtc/ToDeviceKeyTransport.spec.ts index 80ff7fc4e9..3aad2271cd 100644 --- a/spec/unit/matrixrtc/ToDeviceKeyTransport.spec.ts +++ b/spec/unit/matrixrtc/ToDeviceKeyTransport.spec.ts @@ -62,19 +62,16 @@ describe("ToDeviceKeyTransport", () => { const keyIndex = 2; await transport.sendKey(keyBase64Encoded, keyIndex, [ mockCallMembership( - Object.assign({}, membershipTemplate, { device_id: "BOBDEVICE" }), + { ...membershipTemplate, user_id: "@bob:example.org", device_id: "BOBDEVICE" }, roomId, - "@bob:example.org", ), mockCallMembership( - Object.assign({}, membershipTemplate, { device_id: "CARLDEVICE" }), + { ...membershipTemplate, user_id: "@carl:example.org", device_id: "CARLDEVICE" }, roomId, - "@carl:example.org", ), mockCallMembership( - Object.assign({}, membershipTemplate, { device_id: "MATDEVICE" }), + { ...membershipTemplate, user_id: "@mat:example.org", device_id: "MATDEVICE" }, roomId, - "@mat:example.org", ), ]); @@ -154,9 +151,8 @@ describe("ToDeviceKeyTransport", () => { const keyIndex = 2; await transport.sendKey(keyBase64Encoded, keyIndex, [ mockCallMembership( - Object.assign({}, membershipTemplate, { device_id: "MYDEVICE" }), + { ...membershipTemplate, user_id: '@alice:example.org', device_id: 'MYDEVICE' }, roomId, - "@alice:example.org", ), ]); 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 } { diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 0a5ce5bca7..fec1d22cc8 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -28,7 +28,7 @@ import { MembershipManager } from "./NewMembershipManager.ts"; import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts"; import { LegacyMembershipManager } from "./LegacyMembershipManager.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 { @@ -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. @@ -167,7 +176,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. @@ -181,7 +190,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 @@ -429,6 +446,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); @@ -458,11 +480,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; } @@ -545,6 +567,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. */ @@ -583,6 +621,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)