diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 06f409a3bd..d8a718017b 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); @@ -270,9 +269,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(); @@ -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(); @@ -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(); @@ -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((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 +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; @@ -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 }); @@ -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 }); @@ -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); @@ -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); @@ -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}` })); } @@ -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(); @@ -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) => { 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..482439eca9 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,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); @@ -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); diff --git a/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts b/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts index 0e620e167a..19c5be8ee7 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 }), + { ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts }, "!room:id", - userId, ); } }); 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/@types/event.ts b/src/@types/event.ts index 479f7aef81..fd906266e7 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,7 @@ 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_RTCNotificationParent = "org.matrix.msc4075.rtc.notification.parent", } export enum MsgType { @@ -325,6 +331,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/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 aaaf998042..4da3dc5ab2 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 } 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"; @@ -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) + */ + notificationType?: RTCNotificationType; +} + // 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,10 @@ 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; + + 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 @@ -447,6 +459,9 @@ export class MatrixRTCSession extends TypedEventEmitter< } } + this.joinConfig = joinConfig; + this.pendingNotificationToSend = this.joinConfig?.notificationType; + // Join! this.membershipManager!.join(fociPreferred, fociActive, (e) => { this.logger.error("MembershipManager encountered an unrecoverable error: ", e); @@ -547,6 +562,35 @@ export class MatrixRTCSession extends TypedEventEmitter< } } + /** + * Sends a notification corresponding to the configured notify type. + */ + private sendCallNotify(parentEventId: string, notificationType: RTCNotificationType): 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)); + } + /** * Call this when the Matrix room members have changed. */ @@ -587,6 +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 (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.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) 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; diff --git a/src/matrixrtc/types.ts b/src/matrixrtc/types.ts index 3239e593cb..73c5a01700 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"; +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",