diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 3e394119a2..00db7cbd34 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -14,13 +14,30 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { encodeBase64, EventType, MatrixClient, type MatrixError, type MatrixEvent, type Room } from "../../../src"; +import { + encodeBase64, + type EventTimeline, + EventType, + MatrixClient, + type MatrixError, + type MatrixEvent, + type Room, +} from "../../../src"; import { KnownMembership } from "../../../src/@types/membership"; import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession"; import { Status, type EncryptionKeysEventContent } from "../../../src/matrixrtc/types"; -import { secureRandomString } from "../../../src/randomstring"; -import { makeMockEvent, makeMockRoom, membershipTemplate, makeKey, type MembershipData, mockRoomState } from "./mocks"; +import { + makeMockEvent, + makeMockRoom, + membershipTemplate, + makeKey, + type MembershipData, + mockRoomState, + mockRTCEvent, +} from "./mocks"; import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts"; +import { RoomStickyEventsEvent, type StickyMatrixEvent } from "../../../src/models/room-sticky-events.ts"; +import { StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts"; const mockFocus = { type: "mock" }; @@ -47,91 +64,183 @@ describe("MatrixRTCSession", () => { sess = undefined; }); - describe("roomSessionForRoom", () => { - it("creates a room-scoped session from room state", () => { - const mockRoom = makeMockRoom([membershipTemplate]); + describe.each([ + { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + testCreateSticky: false, + createWithDefaults: true, // Create MatrixRTCSession with defaults + }, + { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + testCreateSticky: false, + }, + { + listenForStickyEvents: false, + listenForMemberStateEvents: true, + testCreateSticky: false, + }, + { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + testCreateSticky: true, + }, + { + listenForStickyEvents: true, + listenForMemberStateEvents: false, + testCreateSticky: true, + }, + ])( + "roomSessionForRoom listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForMemberStateEvents testCreateSticky=$testCreateSticky", + (testConfig) => { + it(`will ${testConfig.listenForMemberStateEvents ? "" : "NOT"} throw if the room does not have any state stored`, () => { + const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); + mockRoom.getLiveTimeline.mockReturnValue({ + getState: jest.fn().mockReturnValue(undefined), + } as unknown as EventTimeline); + if (testConfig.listenForMemberStateEvents) { + // eslint-disable-next-line jest/no-conditional-expect + expect(() => { + MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig); + }).toThrow(); + } else { + // eslint-disable-next-line jest/no-conditional-expect + expect(() => { + MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig); + }).not.toThrow(); + } + }); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships.length).toEqual(1); - expect(sess?.memberships[0].slotDescription.id).toEqual(""); - expect(sess?.memberships[0].scope).toEqual("m.room"); - expect(sess?.memberships[0].application).toEqual("m.call"); - expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); - expect(sess?.memberships[0].isExpired()).toEqual(false); - expect(sess?.slotDescription.id).toEqual(""); - }); + it("creates a room-scoped session from room state", () => { + const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); - it("ignores memberships where application is not m.call", () => { - const testMembership = Object.assign({}, membershipTemplate, { - application: "not-m.call", + sess = MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess?.memberships.length).toEqual(1); + expect(sess?.memberships[0].slotDescription.id).toEqual(""); + expect(sess?.memberships[0].scope).toEqual("m.room"); + expect(sess?.memberships[0].application).toEqual("m.call"); + expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); + expect(sess?.memberships[0].isExpired()).toEqual(false); + expect(sess?.slotDescription.id).toEqual(""); }); - const mockRoom = makeMockRoom([testMembership]); - const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships).toHaveLength(0); - }); - it("ignores memberships where callId is not empty", () => { - const testMembership = Object.assign({}, membershipTemplate, { - call_id: "not-empty", - scope: "m.room", + it("ignores memberships where application is not m.call", () => { + const testMembership = Object.assign({}, membershipTemplate, { + application: "not-m.call", + }); + const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky); + const sess = MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess?.memberships).toHaveLength(0); }); - const mockRoom = makeMockRoom([testMembership]); - const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships).toHaveLength(0); - }); - it("ignores expired memberships events", () => { - jest.useFakeTimers(); - const expiredMembership = Object.assign({}, membershipTemplate); - expiredMembership.expires = 1000; - expiredMembership.device_id = "EXPIRED"; - const mockRoom = makeMockRoom([membershipTemplate, expiredMembership]); + it("ignores memberships where callId is not empty", () => { + const testMembership = Object.assign({}, membershipTemplate, { + call_id: "not-empty", + scope: "m.room", + }); + const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky); + const sess = MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess?.memberships).toHaveLength(0); + }); - jest.advanceTimersByTime(2000); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships.length).toEqual(1); - expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); - jest.useRealTimers(); - }); + it("ignores expired memberships events", () => { + jest.useFakeTimers(); + const expiredMembership = Object.assign({}, membershipTemplate); + expiredMembership.expires = 1000; + expiredMembership.device_id = "EXPIRED"; + const mockRoom = makeMockRoom([membershipTemplate, expiredMembership], testConfig.testCreateSticky); + + jest.advanceTimersByTime(2000); + sess = MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess?.memberships.length).toEqual(1); + expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); + jest.useRealTimers(); + }); - it("ignores memberships events of members not in the room", () => { - const mockRoom = makeMockRoom([membershipTemplate]); - mockRoom.hasMembershipState = (state) => state === KnownMembership.Join; - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships.length).toEqual(0); - }); + it("ignores memberships events of members not in the room", () => { + const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); + mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join); + sess = MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess?.memberships.length).toEqual(0); + }); - it("honours created_ts", () => { - jest.useFakeTimers(); - jest.setSystemTime(500); - const expiredMembership = Object.assign({}, membershipTemplate); - expiredMembership.created_ts = 500; - expiredMembership.expires = 1000; - const mockRoom = makeMockRoom([expiredMembership]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500); - jest.useRealTimers(); - }); + it("ignores memberships events with no sender", () => { + // Force the sender to be undefined. + const mockRoom = makeMockRoom([{ ...membershipTemplate, user_id: "" }], testConfig.testCreateSticky); + mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join); + sess = MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess?.memberships.length).toEqual(0); + }); - it("returns empty session if no membership events are present", () => { - const mockRoom = makeMockRoom([]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships).toHaveLength(0); - }); + it("honours created_ts", () => { + jest.useFakeTimers(); + jest.setSystemTime(500); + const expiredMembership = Object.assign({}, membershipTemplate); + expiredMembership.created_ts = 500; + expiredMembership.expires = 1000; + const mockRoom = makeMockRoom([expiredMembership], testConfig.testCreateSticky); + sess = MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500); + jest.useRealTimers(); + }); + + it("returns empty session if no membership events are present", () => { + const mockRoom = makeMockRoom([], testConfig.testCreateSticky); + sess = MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess?.memberships).toHaveLength(0); + }); - it("safely ignores events with no memberships section", () => { - const roomId = secureRandomString(8); - const event = { - getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), - getContent: jest.fn().mockReturnValue({}), - getSender: jest.fn().mockReturnValue("@mock:user.example"), - getTs: jest.fn().mockReturnValue(1000), - getLocalAge: jest.fn().mockReturnValue(0), - }; - const mockRoom = { - ...makeMockRoom([]), - roomId, - getLiveTimeline: jest.fn().mockReturnValue({ + it("safely ignores events with no memberships section", () => { + const event = { + getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), + getContent: jest.fn().mockReturnValue({}), + getSender: jest.fn().mockReturnValue("@mock:user.example"), + getTs: jest.fn().mockReturnValue(1000), + getLocalAge: jest.fn().mockReturnValue(0), + }; + const mockRoom = makeMockRoom([]); + mockRoom.getLiveTimeline.mockReturnValue({ getState: jest.fn().mockReturnValue({ on: jest.fn(), off: jest.fn(), @@ -148,25 +257,26 @@ describe("MatrixRTCSession", () => { ], ]), }), - }), - }; - sess = MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession); - expect(sess.memberships).toHaveLength(0); - }); + } as unknown as EventTimeline); + sess = MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess.memberships).toHaveLength(0); + }); - it("safely ignores events with junk memberships section", () => { - const roomId = secureRandomString(8); - const event = { - getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), - getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }), - getSender: jest.fn().mockReturnValue("@mock:user.example"), - getTs: jest.fn().mockReturnValue(1000), - getLocalAge: jest.fn().mockReturnValue(0), - }; - const mockRoom = { - ...makeMockRoom([]), - roomId, - getLiveTimeline: jest.fn().mockReturnValue({ + it("safely ignores events with junk memberships section", () => { + const event = { + getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), + getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }), + getSender: jest.fn().mockReturnValue("@mock:user.example"), + getTs: jest.fn().mockReturnValue(1000), + getLocalAge: jest.fn().mockReturnValue(0), + }; + const mockRoom = makeMockRoom([]); + mockRoom.getLiveTimeline.mockReturnValue({ getState: jest.fn().mockReturnValue({ on: jest.fn(), off: jest.fn(), @@ -183,26 +293,133 @@ describe("MatrixRTCSession", () => { ], ]), }), - }), - }; - sess = MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession); - expect(sess.memberships).toHaveLength(0); + } as unknown as EventTimeline); + sess = MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess.memberships).toHaveLength(0); + }); + + it("ignores memberships with no device_id", () => { + const testMembership = Object.assign({}, membershipTemplate); + (testMembership.device_id as string | undefined) = undefined; + const mockRoom = makeMockRoom([testMembership]); + const sess = MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess.memberships).toHaveLength(0); + }); + + it("ignores memberships with no call_id", () => { + const testMembership = Object.assign({}, membershipTemplate); + (testMembership.call_id as string | undefined) = undefined; + const mockRoom = makeMockRoom([testMembership]); + sess = MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess.memberships).toHaveLength(0); + }); + }, + ); + + describe("roomSessionForRoom combined state", () => { + it("perfers sticky events when both membership and sticky events appear for the same user", () => { + // Create a room with identical member state and sticky state for the same user. + const mockRoom = makeMockRoom([membershipTemplate]); + mockRoom._unstable_getStickyEvents.mockImplementation(() => { + const ev = mockRTCEvent( + { + ...membershipTemplate, + msc4354_sticky_key: `_${membershipTemplate.user_id}_${membershipTemplate.device_id}`, + }, + mockRoom.roomId, + ); + return [ev as StickyMatrixEvent]; + }); + + // Expect for there to be one membership as the state has been merged down. + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + }); + expect(sess?.memberships.length).toEqual(1); + expect(sess?.memberships[0].slotDescription.id).toEqual(""); + expect(sess?.memberships[0].scope).toEqual("m.room"); + expect(sess?.memberships[0].application).toEqual("m.call"); + expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); + expect(sess?.memberships[0].isExpired()).toEqual(false); + expect(sess?.slotDescription.id).toEqual(""); }); + it("combines sticky and membership events when both exist", () => { + // Create a room with identical member state and sticky state for the same user. + const mockRoom = makeMockRoom([membershipTemplate]); + const stickyUserId = "@stickyev:user.example"; + mockRoom._unstable_getStickyEvents.mockImplementation(() => { + const ev = mockRTCEvent( + { + ...membershipTemplate, + user_id: stickyUserId, + msc4354_sticky_key: `_${stickyUserId}_${membershipTemplate.device_id}`, + }, + mockRoom.roomId, + 15000, + Date.now() - 1000, // Sticky event comes first. + ); + return [ev as StickyMatrixEvent]; + }); + + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + }); + + const memberships = sess.memberships; + expect(memberships.length).toEqual(2); + expect(memberships[0].sender).toEqual(stickyUserId); + expect(memberships[0].slotDescription.id).toEqual(""); + expect(memberships[0].scope).toEqual("m.room"); + expect(memberships[0].application).toEqual("m.call"); + expect(memberships[0].deviceId).toEqual("AAAAAAA"); + expect(memberships[0].isExpired()).toEqual(false); + + // Then state + expect(memberships[1].sender).toEqual(membershipTemplate.user_id); - it("ignores memberships with no device_id", () => { - const testMembership = Object.assign({}, membershipTemplate); - (testMembership.device_id as string | undefined) = undefined; - const mockRoom = makeMockRoom([testMembership]); - const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess.memberships).toHaveLength(0); + expect(sess?.slotDescription.id).toEqual(""); }); + it("handles an incoming sticky event to an existing session", () => { + const mockRoom = makeMockRoom([membershipTemplate]); + const stickyUserId = "@stickyev:user.example"; - it("ignores memberships with no call_id", () => { - const testMembership = Object.assign({}, membershipTemplate); - (testMembership.call_id as string | undefined) = undefined; - const mockRoom = makeMockRoom([testMembership]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess.memberships).toHaveLength(0); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + }); + expect(sess.memberships.length).toEqual(1); + const stickyEv = mockRTCEvent( + { + ...membershipTemplate, + user_id: stickyUserId, + msc4354_sticky_key: `_${stickyUserId}_${membershipTemplate.device_id}`, + }, + mockRoom.roomId, + 15000, + Date.now() - 1000, // Sticky event comes first. + ) as StickyMatrixEvent; + mockRoom._unstable_getStickyEvents.mockImplementation(() => { + return [stickyEv]; + }); + mockRoom.emit(RoomStickyEventsEvent.Update, [stickyEv], [], []); + expect(sess.memberships.length).toEqual(2); }); }); @@ -329,6 +546,12 @@ describe("MatrixRTCSession", () => { expect(sess!.isJoined()).toEqual(true); }); + it("uses the sticky events membership manager implementation", () => { + sess!.joinRoomSession([mockFocus], mockFocus, { unstableSendStickyEvents: true }); + expect(sess!.isJoined()).toEqual(true); + expect(sess!["membershipManager"] instanceof StickyEventMembershipManager).toEqual(true); + }); + it("sends a notification when starting a call and emit DidSendCallNotification", async () => { // Simulate a join, including the update to the room state // Ensure sendEvent returns event IDs so the DidSendCallNotification payload includes them @@ -345,7 +568,7 @@ describe("MatrixRTCSession", () => { 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(); + (sess as any).recalculateSessionMembers(); const ownMembershipId = sess?.memberships[0].eventId; expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.RTCNotification, { @@ -415,7 +638,7 @@ describe("MatrixRTCSession", () => { }, ]); - sess!.onRTCSessionMemberUpdate(); + (sess as any).recalculateSessionMembers(); const ownMembershipId = sess?.memberships[0].eventId; expect(sess!.getConsensusCallIntent()).toEqual("audio"); @@ -466,13 +689,13 @@ describe("MatrixRTCSession", () => { 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(); + (sess as any).recalculateSessionMembers(); // 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(); + (sess as any).recalculateSessionMembers(); expect(client.sendEvent).not.toHaveBeenCalled(); }); @@ -484,9 +707,9 @@ describe("MatrixRTCSession", () => { // 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(); + (sess as any).recalculateSessionMembers(); mockRoomState(mockRoom, [membershipTemplate, { ...membershipTemplate, user_id: client.getUserId()! }]); - sess!.onRTCSessionMemberUpdate(); + (sess as any).recalculateSessionMembers(); // We assume that the responsibility to send a notification, if any, lies with the other // participant that won the race @@ -501,7 +724,7 @@ describe("MatrixRTCSession", () => { const onMembershipsChanged = jest.fn(); sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); - sess.onRTCSessionMemberUpdate(); + (sess as any).recalculateSessionMembers(); expect(onMembershipsChanged).not.toHaveBeenCalled(); }); @@ -514,7 +737,7 @@ describe("MatrixRTCSession", () => { sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); mockRoomState(mockRoom, []); - sess.onRTCSessionMemberUpdate(); + (sess as any).recalculateSessionMembers(); expect(onMembershipsChanged).toHaveBeenCalled(); }); @@ -698,14 +921,14 @@ describe("MatrixRTCSession", () => { // member2 leaves triggering key rotation mockRoomState(mockRoom, [membershipTemplate]); - sess.onRTCSessionMemberUpdate(); + (sess as any).recalculateSessionMembers(); // member2 re-joins which should trigger an immediate re-send const keysSentPromise2 = new Promise((resolve) => { sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); }); mockRoomState(mockRoom, [membershipTemplate, member2]); - sess.onRTCSessionMemberUpdate(); + (sess as any).recalculateSessionMembers(); // but, that immediate resend is throttled so we need to wait a bit jest.advanceTimersByTime(1000); const { keys } = await keysSentPromise2; @@ -756,7 +979,7 @@ describe("MatrixRTCSession", () => { }); mockRoomState(mockRoom, [membershipTemplate, member2]); - sess.onRTCSessionMemberUpdate(); + (sess as any).recalculateSessionMembers(); await keysSentPromise2; @@ -803,7 +1026,7 @@ describe("MatrixRTCSession", () => { sendEventMock.mockClear(); // these should be a no-op: - sess.onRTCSessionMemberUpdate(); + (sess as any).recalculateSessionMembers(); expect(sendEventMock).toHaveBeenCalledTimes(0); expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); } finally { @@ -848,7 +1071,7 @@ describe("MatrixRTCSession", () => { sendEventMock.mockClear(); // this should be a no-op: - sess.onRTCSessionMemberUpdate(); + (sess as any).recalculateSessionMembers(); expect(sendEventMock).toHaveBeenCalledTimes(0); // advance time to avoid key throttling @@ -863,7 +1086,7 @@ describe("MatrixRTCSession", () => { }); // this should re-send the key - sess.onRTCSessionMemberUpdate(); + (sess as any).recalculateSessionMembers(); await keysSentPromise2; @@ -921,7 +1144,7 @@ describe("MatrixRTCSession", () => { }); mockRoomState(mockRoom, [membershipTemplate]); - sess.onRTCSessionMemberUpdate(); + (sess as any).recalculateSessionMembers(); jest.advanceTimersByTime(KEY_DELAY); expect(sendKeySpy).toHaveBeenCalledTimes(1); @@ -988,7 +1211,7 @@ describe("MatrixRTCSession", () => { mockRoomState(mockRoom, members.slice(0, membersToTest - i)); } - sess!.onRTCSessionMemberUpdate(); + (sess as any).recalculateSessionMembers(); // advance time to avoid key throttling jest.advanceTimersByTime(10000); @@ -1027,7 +1250,7 @@ describe("MatrixRTCSession", () => { }); mockRoomState(mockRoom, [membershipTemplate, member2]); - sess.onRTCSessionMemberUpdate(); + (sess as any).recalculateSessionMembers(); await new Promise((resolve) => { realSetTimeout(resolve); @@ -1054,7 +1277,8 @@ describe("MatrixRTCSession", () => { manageMediaKeys: true, useExperimentalToDeviceTransport: true, }); - sess.onRTCSessionMemberUpdate(); + + (sess as any).recalculateSessionMembers(); await keySentPromise; diff --git a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts index 9472dc16ed..1db8d0d53b 100644 --- a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts @@ -14,136 +14,151 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ClientEvent, EventTimeline, MatrixClient } from "../../../src"; +import { ClientEvent, EventTimeline, MatrixClient, type Room } from "../../../src"; import { RoomStateEvent } from "../../../src/models/room-state"; import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager"; -import { makeMockRoom, membershipTemplate, mockRoomState } from "./mocks"; +import { makeMockRoom, type MembershipData, membershipTemplate, mockRoomState, mockRTCEvent } from "./mocks"; import { logger } from "../../../src/logger"; - -describe("MatrixRTCSessionManager", () => { - let client: MatrixClient; - - beforeEach(() => { - client = new MatrixClient({ baseUrl: "base_url" }); - client.matrixRTC.start(); - }); - - afterEach(() => { - client.stopClient(); - client.matrixRTC.stop(); - }); - - it("Fires event when session starts", () => { - const onStarted = jest.fn(); - client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); - - try { - const room1 = makeMockRoom([membershipTemplate]); - jest.spyOn(client, "getRooms").mockReturnValue([room1]); - - client.emit(ClientEvent.Room, room1); - expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); - } finally { - client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); +import { RoomStickyEventsEvent } from "../../../src/models/room-sticky-events"; + +describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( + "MatrixRTCSessionManager ($eventKind)", + ({ eventKind }) => { + let client: MatrixClient; + + function sendLeaveMembership(room: Room, membershipData: MembershipData[]): void { + if (eventKind === "memberState") { + mockRoomState(room, [{ user_id: membershipTemplate.user_id }]); + const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!; + const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0]; + roomState.emit(RoomStateEvent.Events, membEvent, roomState, null); + } else { + const previousData = membershipData.splice(0, 1, { + user_id: membershipTemplate.user_id, + msc4354_sticky_key: membershipTemplate.msc4354_sticky_key, + })[0]; + const current = mockRTCEvent(membershipData[0], room.roomId, 10000); + const previous = mockRTCEvent(previousData, room.roomId, 10000); + room.emit(RoomStickyEventsEvent.Update, [], [{ current, previous }], []); + } } - }); - it("Doesn't fire event if unrelated sessions starts", () => { - const onStarted = jest.fn(); - client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); - - try { - const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]); + beforeEach(() => { + client = new MatrixClient({ baseUrl: "base_url" }); + client.matrixRTC.start(); + }); + + afterEach(() => { + client.stopClient(); + client.matrixRTC.stop(); + }); + + it("Fires event when session starts", () => { + const onStarted = jest.fn(); + client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + + try { + const room1 = makeMockRoom([membershipTemplate], eventKind === "sticky"); + jest.spyOn(client, "getRooms").mockReturnValue([room1]); + + client.emit(ClientEvent.Room, room1); + expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); + } finally { + client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + } + }); + + it("Doesn't fire event if unrelated sessions starts", () => { + const onStarted = jest.fn(); + client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + + try { + const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }], eventKind === "sticky"); + jest.spyOn(client, "getRooms").mockReturnValue([room1]); + + client.emit(ClientEvent.Room, room1); + expect(onStarted).not.toHaveBeenCalled(); + } finally { + client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + } + }); + + it("Fires event when session ends", () => { + const onEnded = jest.fn(); + client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); + const membershipData: MembershipData[] = [membershipTemplate]; + const room1 = makeMockRoom(membershipData, eventKind === "sticky"); jest.spyOn(client, "getRooms").mockReturnValue([room1]); - + jest.spyOn(client, "getRoom").mockReturnValue(room1); client.emit(ClientEvent.Room, room1); - expect(onStarted).not.toHaveBeenCalled(); - } finally { - client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); - } - }); - - it("Fires event when session ends", () => { - const onEnded = jest.fn(); - client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); - const room1 = makeMockRoom([membershipTemplate]); - jest.spyOn(client, "getRooms").mockReturnValue([room1]); - jest.spyOn(client, "getRoom").mockReturnValue(room1); - - client.emit(ClientEvent.Room, room1); - - mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]); - - const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!; - const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0]; - client.emit(RoomStateEvent.Events, membEvent, roomState, null); - expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); - }); - - it("Fires correctly with for with custom sessionDescription", () => { - const onStarted = jest.fn(); - const onEnded = jest.fn(); - // create a session manager with a custom session description - const sessionManager = new MatrixRTCSessionManager(logger, client, { id: "test", application: "m.notCall" }); - - // manually start the session manager (its not the default one started by the client) - sessionManager.start(); - sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); - sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); - - try { - const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]); + sendLeaveMembership(room1, membershipData); + + expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); + }); + + it("Fires correctly with custom sessionDescription", () => { + const onStarted = jest.fn(); + const onEnded = jest.fn(); + // create a session manager with a custom session description + const sessionManager = new MatrixRTCSessionManager(logger, client, { + id: "test", + application: "m.notCall", + }); + + // manually start the session manager (its not the default one started by the client) + sessionManager.start(); + sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); + sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + + try { + // Create a session for applicaation m.other, we ignore this session ecause it lacks a call_id + const room1MembershipData: MembershipData[] = [{ ...membershipTemplate, application: "m.other" }]; + const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky"); + jest.spyOn(client, "getRooms").mockReturnValue([room1]); + client.emit(ClientEvent.Room, room1); + expect(onStarted).not.toHaveBeenCalled(); + onStarted.mockClear(); + + // Create a session for applicaation m.notCall. We expect this call to be tracked because it has a call_id + const room2MembershipData: MembershipData[] = [ + { ...membershipTemplate, application: "m.notCall", call_id: "test" }, + ]; + const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky"); + jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]); + client.emit(ClientEvent.Room, room2); + expect(onStarted).toHaveBeenCalled(); + onStarted.mockClear(); + + // Stop room1's RTC session. Tracked. + jest.spyOn(client, "getRoom").mockReturnValue(room2); + sendLeaveMembership(room2, room2MembershipData); + expect(onEnded).toHaveBeenCalled(); + onEnded.mockClear(); + + // Stop room1's RTC session. Not tracked. + jest.spyOn(client, "getRoom").mockReturnValue(room1); + sendLeaveMembership(room1, room1MembershipData); + expect(onEnded).not.toHaveBeenCalled(); + } finally { + client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); + } + }); + + it("Doesn't fire event if unrelated sessions ends", () => { + const onEnded = jest.fn(); + client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); + const membership: MembershipData[] = [{ ...membershipTemplate, application: "m.other_app" }]; + const room1 = makeMockRoom(membership, eventKind === "sticky"); jest.spyOn(client, "getRooms").mockReturnValue([room1]); - - client.emit(ClientEvent.Room, room1); - expect(onStarted).not.toHaveBeenCalled(); - onStarted.mockClear(); - - const room2 = makeMockRoom([{ ...membershipTemplate, application: "m.notCall", call_id: "test" }]); - jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]); - - client.emit(ClientEvent.Room, room2); - expect(onStarted).toHaveBeenCalled(); - onStarted.mockClear(); - - mockRoomState(room2, [{ user_id: membershipTemplate.user_id }]); - jest.spyOn(client, "getRoom").mockReturnValue(room2); - - const roomState = room2.getLiveTimeline().getState(EventTimeline.FORWARDS)!; - const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0]; - client.emit(RoomStateEvent.Events, membEvent, roomState, null); - expect(onEnded).toHaveBeenCalled(); - onEnded.mockClear(); - - mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]); jest.spyOn(client, "getRoom").mockReturnValue(room1); - const roomStateOther = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!; - const membEventOther = roomStateOther.getStateEvents("org.matrix.msc3401.call.member")[0]; - client.emit(RoomStateEvent.Events, membEventOther, roomStateOther, null); - expect(onEnded).not.toHaveBeenCalled(); - } finally { - client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); - client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); - } - }); - - it("Doesn't fire event if unrelated sessions ends", () => { - const onEnded = jest.fn(); - client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); - const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other_app" }]); - jest.spyOn(client, "getRooms").mockReturnValue([room1]); - jest.spyOn(client, "getRoom").mockReturnValue(room1); - - client.emit(ClientEvent.Room, room1); - - mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]); + client.emit(ClientEvent.Room, room1); - const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!; - const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0]; - client.emit(RoomStateEvent.Events, membEvent, roomState, null); + sendLeaveMembership(room1, membership); - expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); - }); -}); + expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); + }); + }, +); diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index 1e0e0b5e79..8c4f90c002 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -23,6 +23,7 @@ import { MatrixError, UnsupportedDelayedEventsEndpointError, type Room, + MAX_STICKY_DURATION_MS, } from "../../../src"; import { MembershipManagerEvent, @@ -32,7 +33,7 @@ import { type LivekitFocusSelection, } from "../../../src/matrixrtc"; import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks"; -import { MembershipManager } from "../../../src/matrixrtc/MembershipManager.ts"; +import { MembershipManager, StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts"; /** * Create a promise that will resolve once a mocked method is called. @@ -93,7 +94,9 @@ describe("MembershipManager", () => { // 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); - (client.sendStateEvent as Mock).mockResolvedValue(undefined); + (client._unstable_sendStickyEvent as Mock).mockResolvedValue({ event_id: "id" }); + (client._unstable_sendStickyDelayedEvent as Mock).mockResolvedValue({ delay_id: "id" }); + (client.sendStateEvent as Mock).mockResolvedValue({ event_id: "id" }); }); afterEach(() => { @@ -151,43 +154,6 @@ describe("MembershipManager", () => { expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); }); - it("sends a rtc membership event when using `useRtcMemberFormat`", async () => { - // Spys/Mocks - - const updateDelayedEventHandle = createAsyncHandle(client._unstable_updateDelayedEvent as Mock); - - // Test - const memberManager = new MembershipManager({ useRtcMemberFormat: true }, room, client, callSession); - memberManager.join([], focus); - // expects - await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" })); - expect(client.sendStateEvent).toHaveBeenCalledWith( - room.roomId, - "org.matrix.msc4143.rtc.member", - { - application: { type: "m.call" }, - member: { - user_id: "@alice:example.org", - id: "_@alice:example.org_AAAAAAA_m.call", - device_id: "AAAAAAA", - }, - slot_id: "m.call#", - rtc_transports: [focus], - versions: [], - }, - "_@alice:example.org_AAAAAAA_m.call", - ); - updateDelayedEventHandle.resolve?.(); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( - room.roomId, - { delay: 8000 }, - "org.matrix.msc4143.rtc.member", - {}, - "_@alice:example.org_AAAAAAA_m.call", - ); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); - }); - it("reschedules delayed leave event if sending state cancels it", async () => { const memberManager = new MembershipManager(undefined, room, client, callSession); const waitForSendState = waitForMockCall(client.sendStateEvent); @@ -921,6 +887,63 @@ describe("MembershipManager", () => { expect(client.sendStateEvent).toHaveBeenCalledTimes(0); }); }); + + describe("StickyEventMembershipManager", () => { + beforeEach(() => { + // Provide a default mock that is like the default "non error" server behaviour. + (client._unstable_sendStickyDelayedEvent as Mock).mockResolvedValue({ delay_id: "id" }); + (client._unstable_sendStickyEvent as Mock).mockResolvedValue(undefined); + }); + + describe("join()", () => { + describe("sends an rtc membership event", () => { + it("sends a membership event and schedules delayed leave when joining a call", async () => { + const updateDelayedEventHandle = createAsyncHandle( + client._unstable_updateDelayedEvent as Mock, + ); + const memberManager = new StickyEventMembershipManager(undefined, room, client, callSession); + + memberManager.join([], focus); + + await waitForMockCall(client._unstable_sendStickyEvent, Promise.resolve({ event_id: "id" })); + // Test we sent the initial join + expect(client._unstable_sendStickyEvent).toHaveBeenCalledWith( + room.roomId, + 3600000, + null, + "org.matrix.msc4143.rtc.member", + { + application: { type: "m.call" }, + member: { + user_id: "@alice:example.org", + id: "_@alice:example.org_AAAAAAA_m.call", + device_id: "AAAAAAA", + }, + slot_id: "m.call#", + rtc_transports: [focus], + versions: [], + msc4354_sticky_key: "_@alice:example.org_AAAAAAA_m.call", + }, + ); + updateDelayedEventHandle.resolve?.(); + + // Ensure we have sent the delayed disconnect event. + expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledWith( + room.roomId, + MAX_STICKY_DURATION_MS, + { delay: 8000 }, + null, + "org.matrix.msc4143.rtc.member", + { + msc4354_sticky_key: "_@alice:example.org_AAAAAAA_m.call", + }, + ); + // ..once + expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledTimes(1); + }); + }); + }); + }); }); it("Should prefix log with MembershipManager used", () => { diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index d61670d79f..8753c57e4a 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -14,11 +14,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventEmitter } from "stream"; - -import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src"; +import { type Mocked } from "jest-mock"; + +import { + EventType, + type Room, + RoomEvent, + type MatrixClient, + type MatrixEvent, + Direction, + TypedEventEmitter, +} from "../../../src"; import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership"; import { secureRandomString } from "../../../src/randomstring"; +import { type StickyMatrixEvent } from "../../../src/models/room-sticky-events"; export type MembershipData = (SessionMembershipData | {}) & { user_id: string }; @@ -51,6 +60,8 @@ export type MockClient = Pick< | "sendStateEvent" | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent" + | "_unstable_sendStickyEvent" + | "_unstable_sendStickyDelayedEvent" | "cancelPendingEvent" >; /** @@ -65,39 +76,47 @@ export function makeMockClient(userId: string, deviceId: string): MockClient { cancelPendingEvent: jest.fn(), _unstable_updateDelayedEvent: jest.fn(), _unstable_sendDelayedStateEvent: jest.fn(), + _unstable_sendStickyEvent: jest.fn(), + _unstable_sendStickyDelayedEvent: jest.fn(), }; } export function makeMockRoom( membershipData: MembershipData[], -): Room & { emitTimelineEvent: (event: MatrixEvent) => void } { + useStickyEvents = false, +): Mocked void }> { const roomId = secureRandomString(8); // Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()` - const roomState = makeMockRoomState(membershipData, roomId); - const room = Object.assign(new EventEmitter(), { + const roomState = makeMockRoomState(useStickyEvents ? [] : membershipData, roomId); + const ts = Date.now(); + const room = Object.assign(new TypedEventEmitter(), { roomId: roomId, hasMembershipState: jest.fn().mockReturnValue(true), getLiveTimeline: jest.fn().mockReturnValue({ getState: jest.fn().mockReturnValue(roomState), }), getVersion: jest.fn().mockReturnValue("default"), - }) as unknown as Room; + _unstable_getStickyEvents: jest + .fn() + .mockImplementation(() => + useStickyEvents ? membershipData.map((m) => mockRTCEvent(m, roomId, 10000, ts)) : [], + ) as any, + }); return Object.assign(room, { emitTimelineEvent: (event: MatrixEvent) => room.emit(RoomEvent.Timeline, event, room, undefined, false, {} as any), - }); + }) as unknown as Mocked void }>; } 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}`]; }); - return { - on: jest.fn(), - off: jest.fn(), + return Object.assign(new TypedEventEmitter(), { getStateEvents: (_: string, stateKey: string) => { if (stateKey !== undefined) return keysAndEvents.find(([k]) => k === stateKey)?.[1]; return events; @@ -116,11 +135,17 @@ function makeMockRoomState(membershipData: MembershipData[], roomId: string) { }, ], ]), - }; + }); } export function mockRoomState(room: Room, membershipData: MembershipData[]): void { - room.getLiveTimeline().getState = jest.fn().mockReturnValue(makeMockRoomState(membershipData, room.roomId)); + const prevState = room.getLiveTimeline().getState(Direction.Forward)!; + const newState = makeMockRoomState(membershipData, room.roomId); + room.getLiveTimeline().getState = jest + .fn() + .mockReturnValue( + Object.assign(prevState, { events: newState.events, getStateEvents: newState.getStateEvents }), + ); } export function makeMockEvent( @@ -129,6 +154,7 @@ export function makeMockEvent( roomId: string | undefined, content: any, timestamp?: number, + stateKey?: string, ): MatrixEvent { return { getType: jest.fn().mockReturnValue(type), @@ -137,12 +163,28 @@ export function makeMockEvent( getTs: jest.fn().mockReturnValue(timestamp ?? Date.now()), getRoomId: jest.fn().mockReturnValue(roomId), getId: jest.fn().mockReturnValue(secureRandomString(8)), + getStateKey: jest.fn().mockReturnValue(stateKey), isDecryptionFailure: jest.fn().mockReturnValue(false), } as unknown as MatrixEvent; } -export function mockRTCEvent({ user_id: sender, ...membershipData }: MembershipData, roomId: string): MatrixEvent { - return makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData); +export function mockRTCEvent( + { user_id: sender, ...membershipData }: MembershipData, + roomId: string, + stickyDuration?: number, + timestamp?: number, +): StickyMatrixEvent { + return { + ...makeMockEvent( + EventType.GroupCallMemberPrefix, + sender, + roomId, + membershipData, + timestamp, + !stickyDuration && "device_id" in membershipData ? `_${sender}_${membershipData.device_id}` : "", + ), + unstableStickyExpiresAt: stickyDuration, + } as unknown as StickyMatrixEvent; } export function mockCallMembership(membershipData: MembershipData, roomId: string): CallMembership { diff --git a/src/@types/event.ts b/src/@types/event.ts index 1364d9ca75..61c4dd2fa0 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -338,6 +338,7 @@ export interface TimelineEvents { [M_BEACON.name]: MBeaconEventContent; [M_POLL_START.name]: PollStartEventContent; [M_POLL_END.name]: PollEndEventContent; + [EventType.RTCMembership]: RtcMembershipData | { msc4354_sticky_key?: string }; } /** diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 59ff3778e7..95e458f083 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -195,6 +195,10 @@ export type SessionMembershipData = { * something else. */ "m.call.intent"?: RTCCallIntent; + /** + * the sticky key for sticky events packed application + device_id making up the used slot + device. + */ + "msc4354_sticky_key"?: string; }; const checkSessionsMembershipData = (data: IContent, errors: string[]): data is SessionMembershipData => { diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 9a61a7238b..5b96317bce 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -24,7 +24,7 @@ import { KnownMembership } from "../@types/membership.ts"; import { type ISendEventResponse } from "../@types/requests.ts"; import { CallMembership } from "./CallMembership.ts"; import { RoomStateEvent } from "../models/room-state.ts"; -import { MembershipManager } from "./MembershipManager.ts"; +import { MembershipManager, StickyEventMembershipManager } from "./MembershipManager.ts"; import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts"; import { deepCompare, logDurationSync } from "../utils.ts"; import type { @@ -50,6 +50,8 @@ import { } from "./RoomAndToDeviceKeyTransport.ts"; import { TypedReEmitter } from "../ReEmitter.ts"; import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts"; +import { type MatrixEvent } from "src/matrix.ts"; +import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "../models/room-sticky-events.ts"; /** * Events emitted by MatrixRTCSession @@ -73,6 +75,7 @@ export type MatrixRTCSessionEventHandlerMap = { [MatrixRTCSessionEvent.MembershipsChanged]: ( oldMemberships: CallMembership[], newMemberships: CallMembership[], + session: MatrixRTCSession, ) => void; [MatrixRTCSessionEvent.JoinStateChanged]: (isJoined: boolean) => void; [MatrixRTCSessionEvent.EncryptionKeyChanged]: ( @@ -123,14 +126,6 @@ export function slotDescriptionToId(slotDescription: SlotDescription): string { // - we use a `Ms` postfix if the option is a duration to avoid using words like: // `time`, `duration`, `delay`, `timeout`... that might be mistaken/confused with technical terms. export interface MembershipConfig { - /** - * Use the new Manager. - * - * Default: `false`. - * @deprecated does nothing anymore we always default to the new membership manager. - */ - useNewMembershipManager?: boolean; - /** * The timeout (in milliseconds) after we joined the call, that our membership should expire * unless we have explicitly updated it. @@ -192,7 +187,14 @@ export interface MembershipConfig { * but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.) */ delayedLeaveEventRestartLocalTimeoutMs?: number; - useRtcMemberFormat?: boolean; + + /** + * Send membership using sticky events rather than state events. + * This also make the client use the new m.rtc.member MSC4354 event format. (instead of m.call.member) + * + * **WARNING**: This is an unstable feature and not all clients will support it. + */ + unstableSendStickyEvents?: boolean; } export interface EncryptionConfig { @@ -238,6 +240,11 @@ export interface EncryptionConfig { } export type JoinSessionConfig = SessionConfig & MembershipConfig & EncryptionConfig; +interface SessionMembershipsForRoomOpts { + listenForStickyEvents: boolean; + listenForMemberStateEvents: boolean; +} + /** * A MatrixRTCSession manages the membership & properties of a MatrixRTC session. * This class doesn't deal with media at all, just membership & properties of a session. @@ -307,7 +314,7 @@ export class MatrixRTCSession extends TypedEventEmitter< * @deprecated Use `MatrixRTCSession.sessionMembershipsForSlot` instead. */ public static callMembershipsForRoom( - room: Pick, + room: Pick, ): CallMembership[] { return MatrixRTCSession.sessionMembershipsForSlot(room, { id: "", @@ -319,7 +326,7 @@ export class MatrixRTCSession extends TypedEventEmitter< * @deprecated use `MatrixRTCSession.slotMembershipsForRoom` instead. */ public static sessionMembershipsForRoom( - room: Pick, + room: Pick, sessionDescription: SlotDescription, ): CallMembership[] { return this.sessionMembershipsForSlot(room, sessionDescription); @@ -330,21 +337,47 @@ export class MatrixRTCSession extends TypedEventEmitter< * oldest first. */ public static sessionMembershipsForSlot( - room: Pick, + room: Pick, slotDescription: SlotDescription, + // default both true this implied we combine sticky and state events for the final call state + // (prefer sticky events in case of a duplicate) + { listenForStickyEvents, listenForMemberStateEvents }: SessionMembershipsForRoomOpts = { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + }, ): CallMembership[] { const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`); - const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); - if (!roomState) { - logger.warn("Couldn't get state for room " + room.roomId); - throw new Error("Could't get state for room " + room.roomId); + let callMemberEvents = [] as MatrixEvent[]; + if (listenForStickyEvents) { + // prefill with sticky events + callMemberEvents = [...room._unstable_getStickyEvents()].filter( + (e) => e.getType() === EventType.GroupCallMemberPrefix, + ); + } + if (listenForMemberStateEvents) { + const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); + if (!roomState) { + logger.warn("Couldn't get state for room " + room.roomId); + throw new Error("Could't get state for room " + room.roomId); + } + const callMemberStateEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix); + callMemberEvents = callMemberEvents.concat( + callMemberStateEvents.filter( + (callMemberStateEvent) => + !callMemberEvents.some( + // only care about state events which have keys which we have not yet seen in the sticky events. + (stickyEvent) => + stickyEvent.getContent().msc4354_sticky_key === callMemberStateEvent.getStateKey(), + ), + ), + ); } - const callMemberEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix); const callMemberships: CallMembership[] = []; for (const memberEvent of callMemberEvents) { const content = memberEvent.getContent(); - const eventKeysCount = Object.keys(content).length; + // Ignore sticky keys for the count + const eventKeysCount = Object.keys(content).filter((k) => k !== "msc4354_sticky_key").length; // Dont even bother about empty events (saves us from costly type/"key in" checks in bigger rooms) if (eventKeysCount === 0) continue; @@ -411,8 +444,16 @@ 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 roomSessionForRoom( + client: MatrixClient, + room: Room, + opts?: SessionMembershipsForRoomOpts, + ): MatrixRTCSession { + const callMemberships = MatrixRTCSession.sessionMembershipsForSlot( + room, + { id: "", application: "m.call" }, + opts, + ); return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" }); } @@ -428,9 +469,13 @@ 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 sessionForSlot( + client: MatrixClient, + room: Room, + slotDescription: SlotDescription, + opts?: SessionMembershipsForRoomOpts, + ): MatrixRTCSession { + const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(room, slotDescription, opts); return new MatrixRTCSession(client, room, callMemberships, slotDescription); } @@ -439,11 +484,14 @@ export class MatrixRTCSession extends TypedEventEmitter< * this class. * Outside of tests this most likely will be a full room, however. * @deprecated Relying on a full Room object being available here is an anti-pattern. You should be tracking - * the room object in your own code and passing it in when needed. + * the room object in your own code and passing it in when needed. use roomId instead. */ public get room(): Room { return this.roomSubset as Room; } + public get roomId(): string { + return this.roomSubset.roomId; + } /** * This constructs a room session. When using MatrixRTC inside the js-sdk this is expected @@ -461,10 +509,12 @@ export class MatrixRTCSession extends TypedEventEmitter< MatrixClient, | "getUserId" | "getDeviceId" + | "sendEvent" | "sendStateEvent" | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent" - | "sendEvent" + | "_unstable_sendStickyEvent" + | "_unstable_sendStickyDelayedEvent" | "cancelPendingEvent" | "encryptAndSendToDevice" | "off" @@ -486,11 +536,12 @@ export class MatrixRTCSession extends TypedEventEmitter< super(); this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`); const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS); - // TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate); + roomState?.on(RoomStateEvent.Events, this.onRoomStateUpdate); + this.roomSubset.on(RoomStickyEventsEvent.Update, this.onStickyEventUpdate); + this.setExpiryTimer(); } - /* * Returns true if we intend to be participating in the MatrixRTC session. * This is determined by checking if the relativeExpiry has been set. @@ -510,7 +561,10 @@ export class MatrixRTCSession extends TypedEventEmitter< } const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS); roomState?.off(RoomStateEvent.Members, this.onRoomMemberUpdate); + roomState?.off(RoomStateEvent.Events, this.onRoomStateUpdate); + this.roomSubset.off(RoomStickyEventsEvent.Update, this.onStickyEventUpdate); } + private reEmitter = new TypedReEmitter< MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent, MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap & MembershipManagerEventHandlerMap @@ -540,14 +594,15 @@ export class MatrixRTCSession extends TypedEventEmitter< return; } else { // Create MembershipManager and pass the RTCSession logger (with room id info) - - this.membershipManager = new MembershipManager( - joinConfig, - this.roomSubset, - this.client, - this.slotDescription, - this.logger, - ); + this.membershipManager = joinConfig?.unstableSendStickyEvents + ? new StickyEventMembershipManager( + joinConfig, + this.roomSubset, + this.client, + this.slotDescription, + this.logger, + ) + : new MembershipManager(joinConfig, this.roomSubset, this.client, this.slotDescription, this.logger); this.reEmitter.reEmit(this.membershipManager!, [ MembershipManagerEvent.ProbablyLeft, @@ -721,7 +776,7 @@ export class MatrixRTCSession extends TypedEventEmitter< } if (soonestExpiry != undefined) { - this.expiryTimeout = setTimeout(this.onRTCSessionMemberUpdate, soonestExpiry); + this.expiryTimeout = setTimeout(this.recalculateSessionMembers, soonestExpiry); } } @@ -786,17 +841,42 @@ export class MatrixRTCSession extends TypedEventEmitter< /** * Call this when the Matrix room members have changed. */ - public onRoomMemberUpdate = (): void => { + private readonly onRoomMemberUpdate = (): void => { this.recalculateSessionMembers(); }; /** - * Call this when something changed that may impacts the current MatrixRTC members in this session. + * Call this when a sticky event update has occured. + */ + private readonly onStickyEventUpdate: RoomStickyEventsMap[RoomStickyEventsEvent.Update] = ( + added, + updated, + removed, + ): void => { + this.logger.debug("Sticky event update", { added, updated, removed }); + if ( + [...added, ...removed, ...updated.flatMap((v) => [v.current, v.previous])].some( + (e) => e.getType() === EventType.GroupCallMemberPrefix, + ) + ) { + this.recalculateSessionMembers(); + } + }; + /** + * Call this when a sticky event update has occured. */ - public onRTCSessionMemberUpdate = (): void => { + private readonly onRoomStateUpdate = (event: MatrixEvent): void => { + if (event.getType() !== EventType.GroupCallMemberPrefix) return; this.recalculateSessionMembers(); }; + // /** + // * Call this when something changed that may impacts the current MatrixRTC members in this session. + // */ + // public onRTCSessionMemberUpdate = (): void => { + // this.recalculateSessionMembers(); + // }; + /** * Call this when anything that could impact rtc memberships has changed: Room Members or RTC members. * @@ -817,7 +897,7 @@ export class MatrixRTCSession extends TypedEventEmitter< `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); + this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships, this); }); void this.membershipManager?.onRTCSessionMemberUpdate(this.memberships); diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index 792132d273..4fe2fb9452 100644 --- a/src/matrixrtc/MatrixRTCSessionManager.ts +++ b/src/matrixrtc/MatrixRTCSessionManager.ts @@ -18,10 +18,11 @@ import { type Logger } from "../logger.ts"; import { type MatrixClient, ClientEvent } from "../client.ts"; import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { type Room } from "../models/room.ts"; -import { type RoomState, RoomStateEvent } from "../models/room-state.ts"; +import { RoomStateEvent } from "../models/room-state.ts"; import { type MatrixEvent } from "../models/event.ts"; -import { MatrixRTCSession, type SlotDescription } from "./MatrixRTCSession.ts"; +import { MatrixRTCSession, MatrixRTCSessionEvent, type SlotDescription } from "./MatrixRTCSession.ts"; import { EventType } from "../@types/event.ts"; +import { type CallMembership } from "./CallMembership.ts"; export enum MatrixRTCSessionManagerEvents { // A member has joined the MatrixRTC session, creating an active session in a room where there wasn't previously @@ -66,23 +67,22 @@ export class MatrixRTCSessionManager extends TypedEventEmitter 0) { - this.roomSessions.set(room.roomId, session); - } + this.createSessionIfNeeded(room); } this.client.on(ClientEvent.Room, this.onRoom); + this.client.on(ClientEvent.Event, this.onEvent); this.client.on(RoomStateEvent.Events, this.onRoomState); } public stop(): void { for (const sess of this.roomSessions.values()) { + sess.off(MatrixRTCSessionEvent.MembershipsChanged, this.onRtcMembershipChange); void sess.stop(); } this.roomSessions.clear(); - this.client.off(ClientEvent.Room, this.onRoom); + this.client.off(ClientEvent.Event, this.onEvent); this.client.off(RoomStateEvent.Events, this.onRoomState); } @@ -96,55 +96,59 @@ export class MatrixRTCSessionManager extends TypedEventEmitter 0) this.emit(MatrixRTCSessionManagerEvents.SessionStarted, room.roomId, sess); + return sess; + } + + private readonly onRtcMembershipChange = ( + oldM: CallMembership[], + newM: CallMembership[], + session: MatrixRTCSession, + ): void => { + if (oldM.length > 0 && newM.length === 0) { + this.logger.trace(`Session ended for ${session.roomId}`); + this.emit(MatrixRTCSessionManagerEvents.SessionEnded, session.roomId, session); + } else if (oldM.length === 0 && newM.length > 0) { + this.logger.trace(`Session started for ${session.roomId}`); + this.emit(MatrixRTCSessionManagerEvents.SessionStarted, session.roomId, session); + } + }; + + // Possible cases in which we need to create a session if one doesn't already exist: + private createSessionIfNeeded(room: Room): void { + if (!this.roomSessions.has(room.roomId)) this.createRoomSession(room); + } private onRoom = (room: Room): void => { - this.refreshRoom(room); + this.createSessionIfNeeded(room); }; + private readonly onEvent = (event: MatrixEvent): void => { + if (event.getType() !== EventType.GroupCallMemberPrefix) return; + if (!event.unstableStickyExpiresAt) return; // Not sticky, not interested. - private onRoomState = (event: MatrixEvent, _state: RoomState): void => { const room = this.client.getRoom(event.getRoomId()); - if (!room) { - this.logger.error(`Got room state event for unknown room ${event.getRoomId()}!`); - return; - } + if (!room) return; - if (event.getType() == EventType.GroupCallMemberPrefix) { - this.refreshRoom(room); - } + this.createSessionIfNeeded(room); }; + private readonly onRoomState = (event: MatrixEvent): void => { + if (event.getType() !== EventType.GroupCallMemberPrefix) return; - private refreshRoom(room: Room): void { - const isNewSession = !this.roomSessions.has(room.roomId); - const session = this.getRoomSession(room); - - const wasActiveAndKnown = session.memberships.length > 0 && !isNewSession; - // This needs to be here and the event listener cannot be setup in the MatrixRTCSession, - // because we need the update to happen between: - // wasActiveAndKnown = session.memberships.length > 0 and - // nowActive = session.memberships.length - // Alternatively we would need to setup some event emission when the RTC session ended. - session.onRTCSessionMemberUpdate(); - - const nowActive = session.memberships.length > 0; - - if (wasActiveAndKnown && !nowActive) { - this.logger.trace(`Session ended for ${room.roomId} (${session.memberships.length} members)`); - this.emit(MatrixRTCSessionManagerEvents.SessionEnded, room.roomId, this.roomSessions.get(room.roomId)!); - } else if (!wasActiveAndKnown && nowActive) { - this.logger.trace(`Session started for ${room.roomId} (${session.memberships.length} members)`); - this.emit(MatrixRTCSessionManagerEvents.SessionStarted, room.roomId, this.roomSessions.get(room.roomId)!); - } - } + const room = this.client.getRoom(event.getRoomId()); + if (!room) return; + this.createSessionIfNeeded(room); + }; } diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index 45f0a5ce9f..6c2e46418a 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -16,7 +16,11 @@ limitations under the License. import { AbortError } from "p-retry"; import { EventType, RelationType } from "../@types/event.ts"; -import { UpdateDelayedEventAction } from "../@types/requests.ts"; +import { + type ISendEventResponse, + type SendDelayedEventResponse, + UpdateDelayedEventAction, +} from "../@types/requests.ts"; import type { MatrixClient } from "../client.ts"; import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts"; import { type Logger, logger as rootLogger } from "../logger.ts"; @@ -42,6 +46,7 @@ import { type IMembershipManager, type MembershipManagerEventHandlerMap, } from "./IMembershipManager.ts"; +import { type EmptyObject } from "src/matrix.ts"; /* MembershipActionTypes: On Join: ───────────────┐ ┌───────────────(1)───────────┐ @@ -84,6 +89,8 @@ On Leave: ───────── STOP ALL ABOVE (s) Successful restart/resend */ +const STICK_DURATION_MS = 60 * 60 * 1000; // 60 minutes + /** * The different types of actions the MembershipManager can take. * @internal @@ -144,6 +151,23 @@ export interface MembershipManagerState { probablyLeft: boolean; } +function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate { + return { + insert: [{ ts: Date.now() + (offset ?? 0), type }], + }; +} + +function createReplaceActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate { + return { + replace: [{ ts: Date.now() + (offset ?? 0), type }], + }; +} + +type MembershipManagerClient = Pick< + MatrixClient, + "getUserId" | "getDeviceId" | "sendStateEvent" | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent" +>; + /** * This class is responsible for sending all events relating to the own membership of a matrixRTC call. * It has the following tasks: @@ -162,8 +186,8 @@ export class MembershipManager implements IMembershipManager { private activated = false; - private logger: Logger; - private callIntent: RTCCallIntent | undefined; + private readonly logger: Logger; + protected callIntent: RTCCallIntent | undefined; public isActivated(): boolean { return this.activated; @@ -295,16 +319,9 @@ export class MembershipManager * @param client */ public constructor( - private joinConfig: (SessionConfig & MembershipConfig) | undefined, - private room: Pick, - private client: Pick< - MatrixClient, - | "getUserId" - | "getDeviceId" - | "sendStateEvent" - | "_unstable_sendDelayedStateEvent" - | "_unstable_updateDelayedEvent" - >, + private readonly joinConfig: (SessionConfig & MembershipConfig) | undefined, + protected readonly room: Pick, + protected readonly client: MembershipManagerClient, public readonly slotDescription: SlotDescription, parentLogger?: Logger, ) { @@ -361,11 +378,11 @@ export class MembershipManager }; } // Membership Event static parameters: - private deviceId: string; - private memberId: string; + protected deviceId: string; + protected memberId: string; + protected rtcTransport?: Transport; /** @deprecated This will be removed in favor or rtcTransport becoming a list of actively used transports */ private fociPreferred?: Transport[]; - private rtcTransport?: Transport; // Config: private delayedLeaveEventDelayMsOverride?: number; @@ -380,9 +397,13 @@ export class MembershipManager return this.joinConfig?.membershipEventExpiryHeadroomMs ?? 5_000; } private computeNextExpiryActionTs(iteration: number): number { - return this.state.startTime + this.membershipEventExpiryMs * iteration - this.membershipEventExpiryHeadroomMs; + return ( + this.state.startTime + + Math.min(this.membershipEventExpiryMs, STICK_DURATION_MS) * iteration - + this.membershipEventExpiryHeadroomMs + ); } - private get delayedLeaveEventDelayMs(): number { + protected get delayedLeaveEventDelayMs(): number { return this.delayedLeaveEventDelayMsOverride ?? this.joinConfig?.delayedLeaveEventDelayMs ?? 8_000; } private get delayedLeaveEventRestartMs(): number { @@ -394,13 +415,10 @@ export class MembershipManager private get maximumNetworkErrorRetryCount(): number { return this.joinConfig?.maximumNetworkErrorRetryCount ?? 10; } - private get delayedLeaveEventRestartLocalTimeoutMs(): number { return this.joinConfig?.delayedLeaveEventRestartLocalTimeoutMs ?? 2000; } - private get useRtcMemberFormat(): boolean { - return this.joinConfig?.useRtcMemberFormat ?? false; - } + // LOOP HANDLER: private async membershipLoopHandler(type: MembershipActionType): Promise { switch (type) { @@ -455,22 +473,23 @@ export class MembershipManager } } + // an abstraction to switch between sending state or a sticky event + protected clientSendDelayedDisconnectMembership: () => Promise = () => + this.client._unstable_sendDelayedStateEvent( + this.room.roomId, + { delay: this.delayedLeaveEventDelayMs }, + EventType.GroupCallMemberPrefix, + {}, + this.memberId, + ); + // HANDLERS (used in the membershipLoopHandler) private async sendOrResendDelayedLeaveEvent(): Promise { // We can reach this at the start of a call (where we do not yet have a membership: state.hasMemberStateEvent=false) // or during a call if the state event canceled our delayed event or caused by an unexpected error that removed our delayed event. // (Another client could have canceled it, the homeserver might have removed/lost it due to a restart, ...) // In the `then` and `catch` block we treat both cases differently. "if (this.state.hasMemberStateEvent) {} else {}" - return await this.client - ._unstable_sendDelayedStateEvent( - this.room.roomId, - { - delay: this.delayedLeaveEventDelayMs, - }, - this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, - {}, // leave event - this.memberId, - ) + return await this.clientSendDelayedDisconnectMembership() .then((response) => { this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs; this.setAndEmitProbablyLeft(false); @@ -494,7 +513,7 @@ export class MembershipManager if (this.manageMaxDelayExceededSituation(e)) { return createInsertActionUpdate(repeatActionType); } - const update = this.actionUpdateFromErrors(e, repeatActionType, "sendDelayedStateEvent"); + const update = this.actionUpdateFromErrors(e, repeatActionType, "_unstable_sendDelayedStateEvent"); if (update) return update; if (this.state.hasMemberStateEvent) { @@ -650,14 +669,19 @@ export class MembershipManager }); } + protected clientSendMembership: ( + myMembership: RtcMembershipData | SessionMembershipData | EmptyObject, + ) => Promise = (myMembership) => { + return this.client.sendStateEvent( + this.room.roomId, + EventType.GroupCallMemberPrefix, + myMembership as EmptyObject | SessionMembershipData, + this.memberId, + ); + }; + private async sendJoinEvent(): Promise { - return await this.client - .sendStateEvent( - this.room.roomId, - this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, - this.makeMyMembership(this.membershipEventExpiryMs), - this.memberId, - ) + return await this.clientSendMembership(this.makeMyMembership(this.membershipEventExpiryMs)) .then(() => { this.setAndEmitProbablyLeft(false); this.state.startTime = Date.now(); @@ -697,13 +721,9 @@ export class MembershipManager private async updateExpiryOnJoinedEvent(): Promise { const nextExpireUpdateIteration = this.state.expireUpdateIterations + 1; - return await this.client - .sendStateEvent( - this.room.roomId, - this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, - this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration), - this.memberId, - ) + return await this.clientSendMembership( + this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration), + ) .then(() => { // Success, we reset retries and schedule update. this.resetRateLimitCounter(MembershipActionType.UpdateExpiry); @@ -725,13 +745,7 @@ export class MembershipManager }); } private async sendFallbackLeaveEvent(): Promise { - return await this.client - .sendStateEvent( - this.room.roomId, - this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, - {}, - this.memberId, - ) + return await this.clientSendMembership({}) .then(() => { this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent); this.state.hasMemberStateEvent = false; @@ -757,45 +771,29 @@ export class MembershipManager /** * Constructs our own membership */ - private makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData { + protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData { const ownMembership = this.ownMembership; - if (this.useRtcMemberFormat) { - const relationObject = ownMembership?.eventId - ? { "m.relation": { rel_type: RelationType.Reference, event_id: ownMembership?.eventId } } - : {}; - return { - application: { - type: this.slotDescription.application, - ...(this.callIntent ? { "m.call.intent": this.callIntent } : {}), - }, - slot_id: slotDescriptionToId(this.slotDescription), - rtc_transports: this.rtcTransport ? [this.rtcTransport] : [], - member: { device_id: this.deviceId, user_id: this.client.getUserId()!, id: this.memberId }, - versions: [], - ...relationObject, - }; - } else { - const focusObjects = - this.rtcTransport === undefined - ? { - focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const, - foci_preferred: this.fociPreferred ?? [], - } - : { - focus_active: { type: "livekit", focus_selection: "multi_sfu" } as const, - foci_preferred: [this.rtcTransport, ...(this.fociPreferred ?? [])], - }; - return { - "application": this.slotDescription.application, - "call_id": this.slotDescription.id, - "scope": "m.room", - "device_id": this.deviceId, - expires, - "m.call.intent": this.callIntent, - ...focusObjects, - ...(ownMembership !== undefined ? { created_ts: ownMembership.createdTs() } : undefined), - }; - } + + const focusObjects = + this.rtcTransport === undefined + ? { + focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const, + foci_preferred: this.fociPreferred ?? [], + } + : { + focus_active: { type: "livekit", focus_selection: "multi_sfu" } as const, + foci_preferred: [this.rtcTransport, ...(this.fociPreferred ?? [])], + }; + return { + "application": this.slotDescription.application, + "call_id": this.slotDescription.id, + "scope": "m.room", + "device_id": this.deviceId, + expires, + "m.call.intent": this.callIntent, + ...focusObjects, + ...(ownMembership !== undefined ? { created_ts: ownMembership.createdTs() } : undefined), + }; } // Error checks and handlers @@ -830,7 +828,7 @@ export class MembershipManager return false; } - private actionUpdateFromErrors( + protected actionUpdateFromErrors( error: unknown, type: MembershipActionType, method: string, @@ -878,7 +876,7 @@ export class MembershipManager return createInsertActionUpdate(type, resendDelay); } - throw Error("Exceeded maximum retries for " + type + " attempts (client." + method + "): " + (error as Error)); + throw Error("Exceeded maximum retries for " + type + " attempts (client." + method + ")", { cause: error }); } /** @@ -1022,14 +1020,68 @@ export class MembershipManager } } -function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate { - return { - insert: [{ ts: Date.now() + (offset ?? 0), type }], - }; -} +/** + * Implementation of the Membership manager that uses sticky events + * rather than state events. + */ +export class StickyEventMembershipManager extends MembershipManager { + public constructor( + joinConfig: (SessionConfig & MembershipConfig) | undefined, + room: Pick, + private readonly clientWithSticky: MembershipManagerClient & + Pick, + sessionDescription: SlotDescription, + parentLogger?: Logger, + ) { + super(joinConfig, room, clientWithSticky, sessionDescription, parentLogger); + } -function createReplaceActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate { - return { - replace: [{ ts: Date.now() + (offset ?? 0), type }], + protected clientSendDelayedDisconnectMembership: () => Promise = () => + this.clientWithSticky._unstable_sendStickyDelayedEvent( + this.room.roomId, + STICK_DURATION_MS, + { delay: this.delayedLeaveEventDelayMs }, + null, + EventType.RTCMembership, + { msc4354_sticky_key: this.memberId }, + ); + + protected clientSendMembership: ( + myMembership: RtcMembershipData | SessionMembershipData | EmptyObject, + ) => Promise = (myMembership) => { + return this.clientWithSticky._unstable_sendStickyEvent( + this.room.roomId, + STICK_DURATION_MS, + null, + EventType.RTCMembership, + { ...myMembership, msc4354_sticky_key: this.memberId }, + ); }; + + private static nameMap = new Map([ + ["sendStateEvent", "_unstable_sendStickyEvent"], + ["sendDelayedStateEvent", "_unstable_sendStickyDelayedEvent"], + ]); + protected actionUpdateFromErrors(e: unknown, t: MembershipActionType, m: string): ActionUpdate | undefined { + return super.actionUpdateFromErrors(e, t, StickyEventMembershipManager.nameMap.get(m) ?? "unknown"); + } + + protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData { + const ownMembership = this.ownMembership; + + const relationObject = ownMembership?.eventId + ? { "m.relation": { rel_type: RelationType.Reference, event_id: ownMembership?.eventId } } + : {}; + return { + application: { + type: this.slotDescription.application, + ...(this.callIntent ? { "m.call.intent": this.callIntent } : {}), + }, + slot_id: slotDescriptionToId(this.slotDescription), + rtc_transports: this.rtcTransport ? [this.rtcTransport] : [], + member: { device_id: this.deviceId, user_id: this.client.getUserId()!, id: this.memberId }, + versions: [], + ...relationObject, + }; + } } diff --git a/src/models/room-sticky-events.ts b/src/models/room-sticky-events.ts index 3e9e26a2bd..54e4143c8d 100644 --- a/src/models/room-sticky-events.ts +++ b/src/models/room-sticky-events.ts @@ -8,7 +8,7 @@ export enum RoomStickyEventsEvent { Update = "RoomStickyEvents.Update", } -type StickyMatrixEvent = MatrixEvent & { unstableStickyExpiresAt: number }; +export type StickyMatrixEvent = MatrixEvent & { unstableStickyExpiresAt: number }; export type RoomStickyEventsMap = { /**