diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 6105fc963..0a0ef4b50 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -23,11 +23,12 @@ import { } from "../../../src/matrixrtc/CallMembership"; import { membershipTemplate } from "./mocks"; -function makeMockEvent(originTs = 0): MatrixEvent { +function makeMockEvent(originTs = 0, content = {}): MatrixEvent { return { getTs: jest.fn().mockReturnValue(originTs), getSender: jest.fn().mockReturnValue("@alice:example.org"), getId: jest.fn().mockReturnValue("$eventid"), + getContent: jest.fn().mockReturnValue(content), } as unknown as MatrixEvent; } @@ -53,63 +54,64 @@ describe("CallMembership", () => { it("rejects membership with no device_id", () => { expect(() => { - new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined })); + new CallMembership(makeMockEvent(0, Object.assign({}, membershipTemplate, { device_id: undefined }))); }).toThrow(); }); it("rejects membership with no call_id", () => { expect(() => { - new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined })); + new CallMembership(makeMockEvent(0, Object.assign({}, membershipTemplate, { call_id: undefined }))); }).toThrow(); }); it("allow membership with no scope", () => { expect(() => { - new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined })); + new CallMembership(makeMockEvent(0, Object.assign({}, membershipTemplate, { scope: undefined }))); }).not.toThrow(); }); it("uses event timestamp if no created_ts", () => { - const membership = new CallMembership(makeMockEvent(12345), membershipTemplate); + const membership = new CallMembership(makeMockEvent(12345, membershipTemplate)); expect(membership.createdTs()).toEqual(12345); }); it("uses created_ts if present", () => { const membership = new CallMembership( - makeMockEvent(12345), - Object.assign({}, membershipTemplate, { created_ts: 67890 }), + makeMockEvent(12345, Object.assign({}, membershipTemplate, { created_ts: 67890 })), ); expect(membership.createdTs()).toEqual(67890); }); it("considers memberships unexpired if local age low enough", () => { - const fakeEvent = makeMockEvent(1000); + const fakeEvent = makeMockEvent(1000, membershipTemplate); fakeEvent.getTs = jest.fn().mockReturnValue(Date.now() - (DEFAULT_EXPIRE_DURATION - 1)); - expect(new CallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(false); + expect(new CallMembership(fakeEvent).isExpired()).toEqual(false); }); it("considers memberships expired if local age large enough", () => { - const fakeEvent = makeMockEvent(1000); + const fakeEvent = makeMockEvent(1000, membershipTemplate); fakeEvent.getTs = jest.fn().mockReturnValue(Date.now() - (DEFAULT_EXPIRE_DURATION + 1)); - expect(new CallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(true); + expect(new CallMembership(fakeEvent).isExpired()).toEqual(true); }); it("returns preferred foci", () => { - const fakeEvent = makeMockEvent(); const mockFocus = { type: "this_is_a_mock_focus" }; - const membership = new CallMembership(fakeEvent, { ...membershipTemplate, foci_preferred: [mockFocus] }); + const fakeEvent = makeMockEvent(0, { ...membershipTemplate, foci_preferred: [mockFocus] }); + const membership = new CallMembership(fakeEvent); expect(membership.transports).toEqual([mockFocus]); }); describe("getTransport", () => { const mockFocus = { type: "this_is_a_mock_focus" }; - const oldestMembership = new CallMembership(makeMockEvent(), membershipTemplate); + const oldestMembership = new CallMembership(makeMockEvent(0, membershipTemplate)); it("gets the correct active transport with oldest_membership", () => { - const membership = new CallMembership(makeMockEvent(), { - ...membershipTemplate, - foci_preferred: [mockFocus], - focus_active: { type: "livekit", focus_selection: "oldest_membership" }, - }); + const membership = new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + foci_preferred: [mockFocus], + focus_active: { type: "livekit", focus_selection: "oldest_membership" }, + }), + ); // if we are the oldest member we use our focus. expect(membership.getTransport(membership)).toStrictEqual(mockFocus); @@ -119,11 +121,13 @@ describe("CallMembership", () => { }); it("gets the correct active transport with multi_sfu", () => { - const membership = new CallMembership(makeMockEvent(), { - ...membershipTemplate, - foci_preferred: [mockFocus], - focus_active: { type: "livekit", focus_selection: "multi_sfu" }, - }); + const membership = new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + foci_preferred: [mockFocus], + focus_active: { type: "livekit", focus_selection: "multi_sfu" }, + }), + ); // if we are the oldest member we use our focus. expect(membership.getTransport(membership)).toStrictEqual(mockFocus); @@ -132,18 +136,20 @@ describe("CallMembership", () => { expect(membership.getTransport(oldestMembership)).toBe(mockFocus); }); it("does not provide focus if the selection method is unknown", () => { - const membership = new CallMembership(makeMockEvent(), { - ...membershipTemplate, - foci_preferred: [mockFocus], - focus_active: { type: "livekit", focus_selection: "unknown" }, - }); + const membership = new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + foci_preferred: [mockFocus], + focus_active: { type: "livekit", focus_selection: "unknown" }, + }), + ); // if we are the oldest member we use our focus. expect(membership.getTransport(membership)).toBeUndefined(); }); }); describe("correct values from computed fields", () => { - const membership = new CallMembership(makeMockEvent(), membershipTemplate); + const membership = new CallMembership(makeMockEvent(0, membershipTemplate)); it("returns correct sender", () => { expect(membership.sender).toBe("@alice:example.org"); }); @@ -192,58 +198,68 @@ describe("CallMembership", () => { it("rejects membership with no slot_id", () => { expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: undefined }); + new CallMembership(makeMockEvent(0, { ...membershipTemplate, slot_id: undefined })); }).toThrow(); }); it("rejects membership with no application", () => { expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, application: undefined }); + new CallMembership(makeMockEvent(0, { ...membershipTemplate, application: undefined })); }).toThrow(); }); it("rejects membership with incorrect application", () => { expect(() => { - new CallMembership(makeMockEvent(), { - ...membershipTemplate, - application: { wrong_type_key: "unknown" }, - }); + new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + application: { wrong_type_key: "unknown" }, + }), + ); }).toThrow(); }); it("rejects membership with no member", () => { expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, member: undefined }); + new CallMembership(makeMockEvent(0, { ...membershipTemplate, member: undefined })); }).toThrow(); }); it("rejects membership with incorrect member", () => { expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, member: { i: "test" } }); + new CallMembership(makeMockEvent(0, { ...membershipTemplate, member: { i: "test" } })); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { - ...membershipTemplate, - member: { id: "test", device_id: "test", user_id_wrong: "test" }, - }); + new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + member: { id: "test", device_id: "test", user_id_wrong: "test" }, + }), + ); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { - ...membershipTemplate, - member: { id: "test", device_id_wrong: "test", user_id_wrong: "test" }, - }); + new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + member: { id: "test", device_id_wrong: "test", user_id_wrong: "test" }, + }), + ); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { - ...membershipTemplate, - member: { id: "test", device_id: "test", user_id: "@@test" }, - }); + new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + member: { id: "test", device_id: "test", user_id: "@@test" }, + }), + ); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { - ...membershipTemplate, - member: { id: "test", device_id: "test", user_id: "@test:user.id" }, - }); + new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + member: { id: "test", device_id: "test", user_id: "@test:user.id" }, + }), + ); }).not.toThrow(); }); @@ -257,11 +273,13 @@ describe("CallMembership", () => { describe("getTransport", () => { it("gets the correct active transport with oldest_membership", () => { - const oldestMembership = new CallMembership(makeMockEvent(), { - ...membershipTemplate, - rtc_transports: [{ type: "oldest_transport" }], - }); - const membership = new CallMembership(makeMockEvent(), membershipTemplate); + const oldestMembership = new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + rtc_transports: [{ type: "oldest_transport" }], + }), + ); + const membership = new CallMembership(makeMockEvent(0, membershipTemplate)); // if we are the oldest member we use our focus. expect(membership.getTransport(membership)).toStrictEqual({ type: "livekit" }); @@ -271,7 +289,7 @@ describe("CallMembership", () => { }); }); describe("correct values from computed fields", () => { - const membership = new CallMembership(makeMockEvent(), membershipTemplate); + const membership = new CallMembership(makeMockEvent(0, membershipTemplate)); it("returns correct sender", () => { expect(membership.sender).toBe("@alice:example.org"); }); @@ -304,9 +322,9 @@ describe("CallMembership", () => { it("returns correct membershipID", () => { expect(membership.membershipID).toBe("xyzHASHxyz"); }); - it("returns correct unused fields", () => { - expect(membership.getAbsoluteExpiry()).toBe(undefined); - expect(membership.getMsUntilExpiry()).toBe(undefined); + it("returns correct expiration fields", () => { + expect(membership.getAbsoluteExpiry()).toBe(DEFAULT_EXPIRE_DURATION); + expect(membership.getMsUntilExpiry()).toBe(DEFAULT_EXPIRE_DURATION - Date.now()); expect(membership.isExpired()).toBe(false); }); }); @@ -318,8 +336,8 @@ describe("CallMembership", () => { beforeEach(() => { // server origin timestamp for this event is 1000 - fakeEvent = makeMockEvent(1000); - membership = new CallMembership(fakeEvent!, membershipTemplate); + fakeEvent = makeMockEvent(1000, membershipTemplate); + membership = new CallMembership(fakeEvent!); jest.useFakeTimers(); }); diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index ce15159ec..f92af88ae 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -223,11 +223,17 @@ export class CallMembership { * To access checked eventId and sender from the matrixEvent. * Class construction will fail if these values cannot get obtained. */ private matrixEventData: { eventId: string; sender: string }; + /** + * Constructs a CallMembership from a Matrix event. + * @param matrixEvent The Matrix event that this membership is based on + * @param relatedEvent The fetched event linked via the `event_id` from the `m.relates_to` field if present. + * @throws if the data does not match any known membership format. + */ public constructor( - /** The Matrix event that this membership is based on */ private matrixEvent: MatrixEvent, - data: any, + private relatedEvent?: MatrixEvent, ) { + const data = matrixEvent.getContent() as any; const sessionErrors: string[] = []; const rtcErrors: string[] = []; if (checkSessionsMembershipData(data, sessionErrors)) { @@ -354,8 +360,7 @@ export class CallMembership { const { kind, data } = this.membershipData; switch (kind) { case "rtc": - // TODO we need to read the referenced (relation) event if available to get the real created_ts - return this.matrixEvent.getTs(); + return this.relatedEvent?.getTs() ?? this.matrixEvent.getTs(); case "session": default: return data.created_ts ?? this.matrixEvent.getTs(); @@ -370,7 +375,7 @@ export class CallMembership { const { kind, data } = this.membershipData; switch (kind) { case "rtc": - return undefined; + return this.createdTs() + DEFAULT_EXPIRE_DURATION; case "session": default: // TODO: calculate this from the MatrixRTCSession join configuration directly @@ -382,17 +387,10 @@ export class CallMembership { * @returns The number of milliseconds until the membership expires or undefined if applicable */ public getMsUntilExpiry(): number | undefined { - const { kind } = this.membershipData; - switch (kind) { - case "rtc": - return undefined; - case "session": - default: - // Assume that local clock is sufficiently in sync with other clocks in the distributed system. - // We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate. - // The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2 - return this.getAbsoluteExpiry()! - Date.now(); - } + // Assume that local clock is sufficiently in sync with other clocks in the distributed system. + // We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate. + // The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2 + return this.getAbsoluteExpiry()! - Date.now(); } /** diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 76b693b20..6706a93f4 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -50,6 +50,7 @@ import { } from "./RoomAndToDeviceKeyTransport.ts"; import { TypedReEmitter } from "../ReEmitter.ts"; import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts"; +import { MatrixEvent } from "../models/event.ts"; /** * Events emitted by MatrixRTCSession @@ -308,10 +309,10 @@ export class MatrixRTCSession extends TypedEventEmitter< * * @deprecated Use `MatrixRTCSession.sessionMembershipsForSlot` instead. */ - public static callMembershipsForRoom( - room: Pick, - ): CallMembership[] { - return MatrixRTCSession.sessionMembershipsForSlot(room, { + public static async callMembershipsForRoom( + room: Pick, + ): Promise { + return await MatrixRTCSession.sessionMembershipsForSlot(room, { id: "", application: "m.call", }); @@ -320,21 +321,22 @@ export class MatrixRTCSession extends TypedEventEmitter< /** * @deprecated use `MatrixRTCSession.slotMembershipsForRoom` instead. */ - public static sessionMembershipsForRoom( - room: Pick, + public static async sessionMembershipsForRoom( + room: Pick, sessionDescription: SlotDescription, - ): CallMembership[] { - return this.sessionMembershipsForSlot(room, sessionDescription); + ): Promise { + return await this.sessionMembershipsForSlot(room, sessionDescription); } /** * Returns all the call memberships for a room that match the provided `sessionDescription`, * oldest first. */ - public static sessionMembershipsForSlot( - room: Pick, + public static async sessionMembershipsForSlot( + room: Pick, slotDescription: SlotDescription, - ): CallMembership[] { + existingMemberships?: CallMembership[], + ): Promise { const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`); const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); if (!roomState) { @@ -342,54 +344,41 @@ export class MatrixRTCSession extends TypedEventEmitter< throw new Error("Could't get state for room " + room.roomId); } const callMemberEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix); - const callMemberships: CallMembership[] = []; + for (const memberEvent of callMemberEvents) { - const content = memberEvent.getContent(); - const eventKeysCount = Object.keys(content).length; - // Dont even bother about empty events (saves us from costly type/"key in" checks in bigger rooms) - if (eventKeysCount === 0) continue; + let membership = existingMemberships?.find((m) => m.eventId === memberEvent.getId()); + if (!membership) { + const relatedEventId = memberEvent.relationEventId; + const relatedEvent = relatedEventId + ? room.findEventById(relatedEventId) + : new MatrixEvent(await room.client.fetchRoomEvent(room.roomId, relatedEventId!)); - const membershipContents: any[] = []; - - // We first decide if its a MSC4143 event (per device state key) - if (eventKeysCount > 1 && "focus_active" in content) { - // We have a MSC4143 event membership event - membershipContents.push(content); - } else if (eventKeysCount === 1 && "memberships" in content) { - logger.warn(`Legacy event found. Those are ignored, they do not contribute to the MatrixRTC session`); - } - - if (membershipContents.length === 0) continue; - - for (const membershipData of membershipContents) { - if (!("application" in membershipData)) { - // This is a left membership event, ignore it here to not log warnings. - continue; - } try { - const membership = new CallMembership(memberEvent, membershipData); - - if (!deepCompare(membership.slotDescription, slotDescription)) { - logger.info( - `Ignoring membership of user ${membership.sender} for a different session: ${JSON.stringify(membership.slotDescription)}`, - ); - continue; - } - - if (membership.isExpired()) { - logger.info(`Ignoring expired device membership ${membership.sender}/${membership.deviceId}`); - continue; - } - if (!room.hasMembershipState(membership.sender ?? "", KnownMembership.Join)) { - logger.info(`Ignoring membership of user ${membership.sender} who is not in the room.`); - continue; - } - callMemberships.push(membership); + membership = new CallMembership(memberEvent, relatedEvent); } catch (e) { logger.warn("Couldn't construct call membership: ", e); + continue; + } + // static check for newly created memberships + if (!deepCompare(membership.slotDescription, slotDescription)) { + logger.info( + `Ignoring membership of user ${membership.sender} for a different session: ${JSON.stringify(membership.slotDescription)}`, + ); + continue; } } + + // Dynamic checks for all (including existing) memberships + if (membership.isExpired()) { + logger.info(`Ignoring expired device membership ${membership.sender}/${membership.deviceId}`); + continue; + } + if (!room.hasMembershipState(membership.sender ?? "", KnownMembership.Join)) { + logger.info(`Ignoring membership of user ${membership.sender} who is not in the room.`); + continue; + } + callMemberships.push(membership); } callMemberships.sort((a, b) => a.createdTs() - b.createdTs()); @@ -413,15 +402,22 @@ export class MatrixRTCSession extends TypedEventEmitter< * * @deprecated Use `MatrixRTCSession.sessionForSlot` with sessionDescription `{ id: "", application: "m.call" }` instead. */ - public static roomSessionForRoom(client: MatrixClient, room: Room): MatrixRTCSession { - const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(room, { id: "", application: "m.call" }); + public static async roomSessionForRoom(client: MatrixClient, room: Room): Promise { + const callMemberships = await MatrixRTCSession.sessionMembershipsForSlot(room, { + id: "", + application: "m.call", + }); return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" }); } /** * @deprecated Use `MatrixRTCSession.sessionForSlot` instead. */ - public static sessionForRoom(client: MatrixClient, room: Room, slotDescription: SlotDescription): MatrixRTCSession { + public static async sessionForRoom( + client: MatrixClient, + room: Room, + slotDescription: SlotDescription, + ): Promise { return this.sessionForSlot(client, room, slotDescription); } @@ -430,8 +426,12 @@ export class MatrixRTCSession extends TypedEventEmitter< * This returned session can be used to find out if there are active sessions * for the requested room and `slotDescription`. */ - public static sessionForSlot(client: MatrixClient, room: Room, slotDescription: SlotDescription): MatrixRTCSession { - const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(room, slotDescription); + public static async sessionForSlot( + client: MatrixClient, + room: Room, + slotDescription: SlotDescription, + ): Promise { + const callMemberships = await MatrixRTCSession.sessionMembershipsForSlot(room, slotDescription); return new MatrixRTCSession(client, room, callMemberships, slotDescription); } @@ -803,46 +803,50 @@ export class MatrixRTCSession extends TypedEventEmitter< */ private recalculateSessionMembers = (): void => { const oldMemberships = this.memberships; - this.memberships = MatrixRTCSession.sessionMembershipsForSlot(this.room, this.slotDescription); + void MatrixRTCSession.sessionMembershipsForSlot(this.room, this.slotDescription, oldMemberships).then( + (newMemberships) => { + this.memberships = newMemberships; + this._slotId = this._slotId ?? this.memberships[0]?.slotId; - this._slotId = this._slotId ?? this.memberships[0]?.slotId; + const changed = + oldMemberships.length != this.memberships.length || + // If they have the same length, this is enough to check "changed" + oldMemberships.some((m, i) => !CallMembership.equal(m, this.memberships[i])); - const changed = - oldMemberships.length != this.memberships.length || - oldMemberships.some((m, i) => !CallMembership.equal(m, this.memberships[i])); - - if (changed) { - this.logger.info( - `Memberships for call in room ${this.roomSubset.roomId} have changed: emitting (${this.memberships.length} members)`, - ); - logDurationSync(this.logger, "emit MatrixRTCSessionEvent.MembershipsChanged", () => { - this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships); - }); - - 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, - ownMembership.callIntent, + if (changed) { + this.logger.info( + `Memberships for call in room ${this.roomSubset.roomId} have changed: emitting (${this.memberships.length} members)`, ); - } 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) - void this.encryptionManager?.onMembershipsUpdate(oldMemberships); + logDurationSync(this.logger, "emit MatrixRTCSessionEvent.MembershipsChanged", () => { + this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships); + }); - this.setExpiryTimer(); + 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, + ownMembership.callIntent, + ); + } 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) + void this.encryptionManager?.onMembershipsUpdate(oldMemberships); + + this.setExpiryTimer(); + }, + ); }; }