diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 95c7140b13..a96e94f992 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -15,24 +15,21 @@ limitations under the License. */ import { type MatrixEvent } from "../../../src"; -import { - CallMembership, - type SessionMembershipData, - DEFAULT_EXPIRE_DURATION, - type RtcMembershipData, -} from "../../../src/matrixrtc/CallMembership"; -import { membershipTemplate } from "./mocks"; - -function makeMockEvent(originTs = 0): MatrixEvent { +import { rtcMembershipTemplate, sessionMembershipTemplate } from "./mocks"; +import { CallMembership, DEFAULT_EXPIRE_DURATION } from "../../../src/matrixrtc/CallMembership"; + +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; } describe("CallMembership", () => { describe("SessionMembershipData", () => { + const membershipTemplate = sessionMembershipTemplate; beforeEach(() => { jest.useFakeTimers(); }); @@ -41,109 +38,109 @@ describe("CallMembership", () => { jest.useRealTimers(); }); - const membershipTemplate: SessionMembershipData = { - "call_id": "", - "scope": "m.room", - "application": "m.call", - "device_id": "AAAAAAA", - "focus_active": { type: "livekit", focus_selection: "oldest_membership" }, - "foci_preferred": [{ type: "livekit" }], - "m.call.intent": "voice", - }; - 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] }); - expect(membership.transports).toEqual([mockFocus]); + const mockFocus = { type: "livekit", livekit_service_url: "https://example.org" }; + const fakeEvent = makeMockEvent(0, { ...membershipTemplate, foci_preferred: [mockFocus] }); + const membership = new CallMembership(fakeEvent); + expect(membership.transports.length).toEqual(1); + expect(membership.transports[0]).toEqual(expect.objectContaining({ type: "livekit" })); }); describe("getTransport", () => { - const mockFocus = { type: "this_is_a_mock_focus" }; - const oldestMembership = new CallMembership(makeMockEvent(), membershipTemplate); + const mockFocus = { type: "livekit", livekit_service_url: "https://example.org" }; + 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, + // The list of foci_preferred provided by the homeserver. (in the test example we just provide one) + // The oldest member logic will use the first item in this list. + // The multi-sfu logic will (theoretically) also use all the items in the list at once + // (currently the js-sdk sets it to only one item in multi-sfu mode). + 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); + expect(membership.getTransport(membership)).toEqual(expect.objectContaining({ type: "livekit" })); + expect(membership.transports[0]).toEqual(expect.objectContaining({ type: "livekit" })); // If there is an older member we use its focus. - expect(membership.getTransport(oldestMembership)).toBe(membershipTemplate.foci_preferred[0]); + expect(membership.getTransport(oldestMembership)).toEqual(expect.objectContaining({ type: "livekit" })); }); 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" }, - }); - - // if we are the oldest member we use our focus. + const membership = new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + foci_preferred: [mockFocus], + focus_active: { type: "livekit", focus_selection: "multi_sfu" }, + }), + ); + + // We use our focus. expect(membership.getTransport(membership)).toStrictEqual(mockFocus); // If there is an older member we still use our own focus in multi sfu. 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"); }); @@ -178,143 +175,198 @@ describe("CallMembership", () => { expect(membership.isExpired()).toBe(true); }); }); + describe("expiry calculation", () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it("calculates time until expiry", () => { + // server origin timestamp for this event is 1000 + const fakeEvent = makeMockEvent(1000, membershipTemplate); + const membership = new CallMembership(fakeEvent); + jest.setSystemTime(2000); + // should be using absolute expiry time + expect(membership.getMsUntilExpiry()).toEqual(DEFAULT_EXPIRE_DURATION - 1000); + }); + }); }); describe("RtcMembershipData", () => { - const membershipTemplate: RtcMembershipData = { - slot_id: "m.call#", - application: { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" }, - member: { user_id: "@alice:example.org", device_id: "AAAAAAA", id: "xyzHASHxyz" }, - rtc_transports: [{ type: "livekit" }], - versions: [], - msc4354_sticky_key: "abc123", - }; + const membershipTemplate = rtcMembershipTemplate; 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 invalid slot_id", () => { expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "invalid_slot_id" }); + new CallMembership(makeMockEvent(0, { ...membershipTemplate, slot_id: "invalid_slot_id" })); }).toThrow(); }); it("accepts membership with valid slot_id", () => { expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#" }); + new CallMembership(makeMockEvent(0, { ...membershipTemplate, slot_id: "m.call#" })); }).not.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-wrong-user:user.id" }, - }); + new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + member: { id: "test", device_id: "test", user_id: "@test-wrong-user:user.id" }, + }), + ); }).toThrow(); }); it("rejects membership with incorrect sticky_key", () => { expect(() => { - new CallMembership(makeMockEvent(), membershipTemplate); + new CallMembership(makeMockEvent(0, membershipTemplate)); }).not.toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { - ...membershipTemplate, - sticky_key: 1, - msc4354_sticky_key: undefined, - }); + new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + sticky_key: 1, + msc4354_sticky_key: undefined, + }), + ); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { - ...membershipTemplate, - sticky_key: "1", - msc4354_sticky_key: undefined, - }); + new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + sticky_key: "1", + msc4354_sticky_key: undefined, + }), + ); }).not.toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, msc4354_sticky_key: undefined }); + new CallMembership(makeMockEvent(0, { ...membershipTemplate, msc4354_sticky_key: undefined })); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { - ...membershipTemplate, - msc4354_sticky_key: 1, - sticky_key: "valid", - }); + new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + msc4354_sticky_key: 1, + sticky_key: "valid", + }), + ); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { - ...membershipTemplate, - msc4354_sticky_key: "valid", - sticky_key: "valid", - }); + new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + msc4354_sticky_key: "valid", + sticky_key: "valid", + }), + ); }).not.toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { - ...membershipTemplate, - msc4354_sticky_key: "valid_but_different", - sticky_key: "valid", - }); + new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + msc4354_sticky_key: "valid_but_different", + sticky_key: "valid", + }), + ); }).toThrow(); }); it("considers memberships unexpired if local age low enough", () => { - // TODO link prev event + const now = Date.now(); + const startEv = makeMockEvent(now - DEFAULT_EXPIRE_DURATION + 100, membershipTemplate); + const membershipWithRel = new CallMembership( + //update 900 ms later + makeMockEvent(now - DEFAULT_EXPIRE_DURATION + 1000, membershipTemplate), + startEv, + ); + const membershipWithoutRel = new CallMembership(startEv); + expect(membershipWithRel.isExpired()).toEqual(false); + expect(membershipWithoutRel.isExpired()).toEqual(false); + expect(membershipWithoutRel.createdTs()).toEqual(membershipWithRel.createdTs()); }); it("considers memberships expired if local age large enough", () => { - // TODO link prev event + const now = Date.now(); + const startEv = makeMockEvent(now - DEFAULT_EXPIRE_DURATION - 100, membershipTemplate); + const membershipWithRel = new CallMembership( + //update 50 ms later (so the update is still expired) + makeMockEvent(now - DEFAULT_EXPIRE_DURATION - 50, membershipTemplate), + startEv, + ); + const membershipWithRelUnexpired = new CallMembership( + //update 200 ms later (due to the update the member is NOT expired) + makeMockEvent(now - DEFAULT_EXPIRE_DURATION + 100, membershipTemplate), + startEv, + ); + const membershipWithoutRel = new CallMembership(startEv); + expect(membershipWithRel.isExpired()).toEqual(true); + expect(membershipWithRelUnexpired.isExpired()).toEqual(false); + expect(membershipWithoutRel.isExpired()).toEqual(true); + expect(membershipWithoutRel.createdTs()).toEqual(membershipWithRel.createdTs()); }); 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" }); @@ -323,8 +375,10 @@ describe("CallMembership", () => { expect(membership.getTransport(oldestMembership)).toStrictEqual({ type: "livekit" }); }); }); + describe("correct values from computed fields", () => { - const membership = new CallMembership(makeMockEvent(), membershipTemplate); + const now = Date.now(); + const membership = new CallMembership(makeMockEvent(now, membershipTemplate)); it("returns correct sender", () => { expect(membership.sender).toBe("@alice:example.org"); }); @@ -357,34 +411,31 @@ 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(now + DEFAULT_EXPIRE_DURATION); + expect(membership.getMsUntilExpiry()).toBe(now + DEFAULT_EXPIRE_DURATION - Date.now()); expect(membership.isExpired()).toBe(false); }); }); - }); - describe("expiry calculation", () => { - let fakeEvent: MatrixEvent; - let membership: CallMembership; + describe("expiry calculation", () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); - beforeEach(() => { - // server origin timestamp for this event is 1000 - fakeEvent = makeMockEvent(1000); - membership = new CallMembership(fakeEvent!, membershipTemplate); - - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); + afterEach(() => { + jest.useRealTimers(); + }); - it("calculates time until expiry", () => { - jest.setSystemTime(2000); - // should be using absolute expiry time - expect(membership.getMsUntilExpiry()).toEqual(DEFAULT_EXPIRE_DURATION - 1000); + it("calculates time until expiry", () => { + // server origin timestamp for this event is 1000 + // The related event used for created_ts is at 500 + const fakeEvent = makeMockEvent(1000, membershipTemplate); + const initialEvent = makeMockEvent(500, membershipTemplate); + const membership = new CallMembership(fakeEvent, initialEvent); + jest.setSystemTime(2000); + // should be using absolute expiry time from the new event (ts = 1000) + expect(membership.getMsUntilExpiry()).toEqual(DEFAULT_EXPIRE_DURATION - 1000); + }); }); }); }); diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 3e394119a2..1c64e53bfe 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -14,21 +14,38 @@ 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, + EventType, + MatrixClient, + MatrixEvent, + RelationType, + type MatrixError, + 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, + makeKey, + type MembershipData, + mockRoomState, + rtcMembershipTemplate, + sessionMembershipTemplate, +} from "./mocks"; import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts"; -const mockFocus = { type: "mock" }; +const mockFocus = { type: "livekit", livekit_service_url: "https://test.org" }; const textEncoder = new TextEncoder(); const callSession = { id: "", application: "m.call" }; describe("MatrixRTCSession", () => { + const membershipTemplate = sessionMembershipTemplate; let client: MatrixClient; let sess: MatrixRTCSession | undefined; @@ -38,6 +55,8 @@ describe("MatrixRTCSession", () => { client.getDeviceId = jest.fn().mockReturnValue("AAAAAAA"); client.sendEvent = jest.fn().mockResolvedValue({ event_id: "success" }); client.decryptEventIfNeeded = jest.fn(); + client.fetchRoomEvent = jest.fn().mockResolvedValue(undefined); + client._unstable_sendDelayedStateEvent = jest.fn().mockResolvedValue({ event_id: "success" }); }); afterEach(async () => { @@ -48,10 +67,10 @@ describe("MatrixRTCSession", () => { }); describe("roomSessionForRoom", () => { - it("creates a room-scoped session from room state", () => { + it("creates a room-scoped session from room state", async () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await 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"); @@ -61,26 +80,26 @@ describe("MatrixRTCSession", () => { expect(sess?.slotDescription.id).toEqual(""); }); - it("ignores memberships where application is not m.call", () => { + it("ignores memberships where application is not m.call", async () => { const testMembership = Object.assign({}, membershipTemplate, { application: "not-m.call", }); const mockRoom = makeMockRoom([testMembership]); - const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + const sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess?.memberships).toHaveLength(0); }); - it("ignores memberships where callId is not empty", () => { + it("ignores memberships where callId is not empty", async () => { const testMembership = Object.assign({}, membershipTemplate, { call_id: "not-empty", scope: "m.room", }); const mockRoom = makeMockRoom([testMembership]); - const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + const sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess?.memberships).toHaveLength(0); }); - it("ignores expired memberships events", () => { + it("ignores expired memberships events", async () => { jest.useFakeTimers(); const expiredMembership = Object.assign({}, membershipTemplate); expiredMembership.expires = 1000; @@ -88,38 +107,38 @@ describe("MatrixRTCSession", () => { const mockRoom = makeMockRoom([membershipTemplate, expiredMembership]); jest.advanceTimersByTime(2000); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); 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", () => { + it("ignores memberships events of members not in the room", async () => { const mockRoom = makeMockRoom([membershipTemplate]); mockRoom.hasMembershipState = (state) => state === KnownMembership.Join; - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess?.memberships.length).toEqual(0); }); - it("honours created_ts", () => { + it("honours created_ts", async () => { 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); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500); jest.useRealTimers(); }); - it("returns empty session if no membership events are present", () => { + it("returns empty session if no membership events are present", async () => { const mockRoom = makeMockRoom([]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess?.memberships).toHaveLength(0); }); - it("safely ignores events with no memberships section", () => { + it("safely ignores events with no memberships section", async () => { const roomId = secureRandomString(8); const event = { getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), @@ -150,11 +169,11 @@ describe("MatrixRTCSession", () => { }), }), }; - sess = MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession); expect(sess.memberships).toHaveLength(0); }); - it("safely ignores events with junk memberships section", () => { + it("safely ignores events with junk memberships section", async () => { const roomId = secureRandomString(8); const event = { getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), @@ -185,29 +204,67 @@ describe("MatrixRTCSession", () => { }), }), }; - sess = MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession); expect(sess.memberships).toHaveLength(0); }); - it("ignores memberships with no device_id", () => { + it("ignores memberships with no device_id", async () => { const testMembership = Object.assign({}, membershipTemplate); (testMembership.device_id as string | undefined) = undefined; const mockRoom = makeMockRoom([testMembership]); - const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + const sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess.memberships).toHaveLength(0); }); - it("ignores memberships with no call_id", () => { + it("ignores memberships with no call_id", async () => { const testMembership = Object.assign({}, membershipTemplate); (testMembership.call_id as string | undefined) = undefined; const mockRoom = makeMockRoom([testMembership]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess.memberships).toHaveLength(0); }); + it("fetches related events if needed from room", async () => { + const testMembership = { + ...rtcMembershipTemplate, + "m.relates_to": { event_id: "id", rel_type: RelationType.Reference as const }, + // hack for simple makeMockRoom construction + "user_id": rtcMembershipTemplate.member.user_id, + }; + + const mockRoom = makeMockRoom([testMembership]); + const now = Date.now(); + mockRoom.findEventById = jest + .fn() + .mockImplementation((id) => + id === "id" + ? new MatrixEvent({ content: { ...rtcMembershipTemplate }, origin_server_ts: now + 100 }) + : undefined, + ); + sess = await MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); + expect(sess.memberships[0].createdTs()).toBe(now + 100); + }); + it("fetches related events if needed from cs api", async () => { + const testMembership = { + ...rtcMembershipTemplate, + "m.relates_to": { event_id: "id", rel_type: RelationType.Reference as const }, + // hack for simple makeMockRoom construction + "user_id": rtcMembershipTemplate.member.user_id, + }; + + const mockRoom = makeMockRoom([testMembership]); + const now = Date.now(); + + mockRoom.findEventById = jest.fn().mockReturnValue(undefined); + client.fetchRoomEvent = jest + .fn() + .mockResolvedValue({ content: { ...rtcMembershipTemplate }, origin_server_ts: now + 100 }); + sess = await MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); + expect(sess.memberships[0].createdTs()).toBe(now + 100); + }); }); describe("getOldestMembership", () => { - it("returns the oldest membership event", () => { + it("returns the oldest membership event", async () => { jest.useFakeTimers(); jest.setSystemTime(4000); const mockRoom = makeMockRoom([ @@ -216,7 +273,7 @@ describe("MatrixRTCSession", () => { Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }), ]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess.getOldestMembership()!.deviceId).toEqual("old"); jest.useRealTimers(); }); @@ -229,7 +286,7 @@ describe("MatrixRTCSession", () => { [undefined, "audio", "audio"], ["audio", "audio", "audio"], ["audio", "video", undefined], - ])("gets correct consensus for %s + %s = %s", (intentA, intentB, result) => { + ])("gets correct consensus for %s + %s = %s", async (intentA, intentB, result) => { jest.useFakeTimers(); jest.setSystemTime(4000); const mockRoom = makeMockRoom([ @@ -237,7 +294,7 @@ describe("MatrixRTCSession", () => { Object.assign({}, membershipTemplate, { "m.call.intent": intentB }), ]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess.getConsensusCallIntent()).toEqual(result); jest.useRealTimers(); }); @@ -249,7 +306,7 @@ describe("MatrixRTCSession", () => { livekit_service_url: "https://active.url", livekit_alias: "!active:active.url", }; - it("gets the correct active focus with oldest_membership", () => { + it("gets the correct active focus with oldest_membership", async () => { jest.useFakeTimers(); jest.setSystemTime(3000); const mockRoom = makeMockRoom([ @@ -262,15 +319,17 @@ describe("MatrixRTCSession", () => { Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }), ]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], { type: "livekit", focus_selection: "oldest_membership", }); + const oldest = sess.memberships.find((m) => m.deviceId === "old"); + expect(oldest?.getTransport(sess.getOldestMembership()!)).toBe(firstPreferredFocus); jest.useRealTimers(); }); - it("does not provide focus if the selection method is unknown", () => { + it("does not provide focus if the selection method is unknown", async () => { const mockRoom = makeMockRoom([ Object.assign({}, membershipTemplate, { device_id: "foo", @@ -281,7 +340,7 @@ describe("MatrixRTCSession", () => { Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }), ]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], { type: "livekit", @@ -297,9 +356,12 @@ describe("MatrixRTCSession", () => { let sendStateEventMock: jest.Mock; let sentStateEvent: Promise; - beforeEach(() => { + beforeEach(async () => { sentStateEvent = new Promise((resolve) => { - sendStateEventMock = jest.fn(resolve); + sendStateEventMock = jest.fn().mockImplementation(() => { + resolve(); + return Promise.resolve({ event_id: "id" }); + }); }); sendEventMock = jest.fn().mockResolvedValue(undefined); client.sendStateEvent = sendStateEventMock; @@ -308,7 +370,7 @@ describe("MatrixRTCSession", () => { client._unstable_updateDelayedEvent = jest.fn(); mockRoom = makeMockRoom([]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); }); afterEach(async () => { @@ -345,12 +407,13 @@ 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(); + await sess!.onRTCSessionMemberUpdate(); const ownMembershipId = sess?.memberships[0].eventId; expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.RTCNotification, { "m.mentions": { user_ids: [], room: true }, "notification_type": "ring", + "m.call.intent": "voice", "m.relates_to": { event_id: ownMembershipId, rel_type: "m.reference", @@ -358,7 +421,6 @@ describe("MatrixRTCSession", () => { "lifetime": 30000, "sender_ts": expect.any(Number), }); - // Check if deprecated notify event is also sent. expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.CallNotify, { "application": "m.call", @@ -371,6 +433,7 @@ describe("MatrixRTCSession", () => { expect(didSendEventFn).toHaveBeenCalledWith( { "event_id": "new-evt", + "m.call.intent": "voice", "lifetime": 30000, "m.mentions": { room: true, user_ids: [] }, "m.relates_to": { @@ -415,7 +478,8 @@ describe("MatrixRTCSession", () => { }, ]); - sess!.onRTCSessionMemberUpdate(); + await sess!.onRTCSessionMemberUpdate(); + const ownMembershipId = sess?.memberships[0].eventId; expect(sess!.getConsensusCallIntent()).toEqual("audio"); @@ -466,13 +530,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(); + await sess!.onRTCSessionMemberUpdate(); // Simulate a join, including the update to the room state sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" }); await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]); mockRoomState(mockRoom, [membershipTemplate, { ...membershipTemplate, user_id: client.getUserId()! }]); - sess!.onRTCSessionMemberUpdate(); + await sess!.onRTCSessionMemberUpdate(); expect(client.sendEvent).not.toHaveBeenCalled(); }); @@ -484,9 +548,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(); + await sess!.onRTCSessionMemberUpdate(); mockRoomState(mockRoom, [membershipTemplate, { ...membershipTemplate, user_id: client.getUserId()! }]); - sess!.onRTCSessionMemberUpdate(); + await sess!.onRTCSessionMemberUpdate(); // We assume that the responsibility to send a notification, if any, lies with the other // participant that won the race @@ -495,27 +559,27 @@ describe("MatrixRTCSession", () => { }); describe("onMembershipsChanged", () => { - it("does not emit if no membership changes", () => { + it("does not emit if no membership changes", async () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); const onMembershipsChanged = jest.fn(); sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); - sess.onRTCSessionMemberUpdate(); + await sess.onRTCSessionMemberUpdate(); expect(onMembershipsChanged).not.toHaveBeenCalled(); }); - it("emits on membership changes", () => { + it("emits on membership changes", async () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); const onMembershipsChanged = jest.fn(); sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); mockRoomState(mockRoom, []); - sess.onRTCSessionMemberUpdate(); + await sess.onRTCSessionMemberUpdate(); expect(onMembershipsChanged).toHaveBeenCalled(); }); @@ -552,7 +616,7 @@ describe("MatrixRTCSession", () => { let sendEventMock: jest.Mock; let sendToDeviceMock: jest.Mock; - beforeEach(() => { + beforeEach(async () => { sendStateEventMock = jest.fn().mockResolvedValue({ event_id: "id" }); sendDelayedStateMock = jest.fn().mockResolvedValue({ event_id: "id" }); sendEventMock = jest.fn().mockResolvedValue({ event_id: "id" }); @@ -563,7 +627,7 @@ describe("MatrixRTCSession", () => { client.encryptAndSendToDevice = sendToDeviceMock; mockRoom = makeMockRoom([]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); }); afterEach(async () => { @@ -677,12 +741,18 @@ describe("MatrixRTCSession", () => { it("re-sends key if a new member joins even if a key rotation is in progress", async () => { jest.useFakeTimers(); try { + const ownMembership = { + ...membershipTemplate, + user_id: client.getUserId()!, + device_id: client.getDeviceId()!, + }; // session with two members - const member2 = Object.assign({}, membershipTemplate, { + const member2 = Object.assign({}, ownMembership, { device_id: "BBBBBBB", + user_id: "@bob:example.org", }); - const mockRoom = makeMockRoom([membershipTemplate, member2]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + const mockRoom = makeMockRoom([ownMembership, member2]); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); // joining will trigger an initial key send const keysSentPromise1 = new Promise((resolve) => { @@ -697,15 +767,15 @@ describe("MatrixRTCSession", () => { expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); // member2 leaves triggering key rotation - mockRoomState(mockRoom, [membershipTemplate]); - sess.onRTCSessionMemberUpdate(); + mockRoomState(mockRoom, [ownMembership]); + await sess.onRTCSessionMemberUpdate(); // 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(); + mockRoomState(mockRoom, [ownMembership, member2]); + await sess.onRTCSessionMemberUpdate(); // but, that immediate resend is throttled so we need to wait a bit jest.advanceTimersByTime(1000); const { keys } = await keysSentPromise2; @@ -731,7 +801,7 @@ describe("MatrixRTCSession", () => { jest.useFakeTimers(); try { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); const keysSentPromise1 = new Promise((resolve) => { sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); @@ -756,7 +826,7 @@ describe("MatrixRTCSession", () => { }); mockRoomState(mockRoom, [membershipTemplate, member2]); - sess.onRTCSessionMemberUpdate(); + await sess.onRTCSessionMemberUpdate(); await keysSentPromise2; @@ -782,7 +852,7 @@ describe("MatrixRTCSession", () => { const mockRoom = makeMockRoom([member1, member2]); mockRoomState(mockRoom, [member1, member2]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); await keysSentPromise1; @@ -803,7 +873,7 @@ describe("MatrixRTCSession", () => { sendEventMock.mockClear(); // these should be a no-op: - sess.onRTCSessionMemberUpdate(); + await sess.onRTCSessionMemberUpdate(); expect(sendEventMock).toHaveBeenCalledTimes(0); expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); } finally { @@ -827,7 +897,7 @@ describe("MatrixRTCSession", () => { }; const mockRoom = makeMockRoom([member1, member2]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); await keysSentPromise1; @@ -848,7 +918,7 @@ describe("MatrixRTCSession", () => { sendEventMock.mockClear(); // this should be a no-op: - sess.onRTCSessionMemberUpdate(); + await sess.onRTCSessionMemberUpdate(); expect(sendEventMock).toHaveBeenCalledTimes(0); // advance time to avoid key throttling @@ -863,7 +933,7 @@ describe("MatrixRTCSession", () => { }); // this should re-send the key - sess.onRTCSessionMemberUpdate(); + await sess.onRTCSessionMemberUpdate(); await keysSentPromise2; @@ -891,7 +961,7 @@ describe("MatrixRTCSession", () => { device_id: "BBBBBBB", }); const mockRoom = makeMockRoom([membershipTemplate, member2]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); const onMyEncryptionKeyChanged = jest.fn(); sess.on( @@ -921,8 +991,10 @@ describe("MatrixRTCSession", () => { }); mockRoomState(mockRoom, [membershipTemplate]); - sess.onRTCSessionMemberUpdate(); - + const { promise, resolve } = Promise.withResolvers(); + sess.once(MatrixRTCSessionEvent.MembershipsChanged, resolve); + await sess.onRTCSessionMemberUpdate(); + await promise; jest.advanceTimersByTime(KEY_DELAY); expect(sendKeySpy).toHaveBeenCalledTimes(1); // check that we send the key with index 1 even though the send gets delayed when leaving. @@ -981,14 +1053,14 @@ describe("MatrixRTCSession", () => { if (i === 0) { // if first time around then set up the session - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); } else { // otherwise update the state reducing the membership each time in order to trigger key rotation mockRoomState(mockRoom, members.slice(0, membersToTest - i)); } - sess!.onRTCSessionMemberUpdate(); + await sess!.onRTCSessionMemberUpdate(); // advance time to avoid key throttling jest.advanceTimersByTime(10000); @@ -1007,7 +1079,7 @@ describe("MatrixRTCSession", () => { jest.useFakeTimers(); try { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); const keysSentPromise1 = new Promise((resolve) => { sendEventMock.mockImplementation(resolve); @@ -1027,7 +1099,7 @@ describe("MatrixRTCSession", () => { }); mockRoomState(mockRoom, [membershipTemplate, member2]); - sess.onRTCSessionMemberUpdate(); + await sess.onRTCSessionMemberUpdate(); await new Promise((resolve) => { realSetTimeout(resolve); @@ -1048,13 +1120,13 @@ describe("MatrixRTCSession", () => { }); const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true, useExperimentalToDeviceTransport: true, }); - sess.onRTCSessionMemberUpdate(); + await sess.onRTCSessionMemberUpdate(); await keySentPromise; @@ -1071,7 +1143,7 @@ describe("MatrixRTCSession", () => { describe("receiving", () => { it("collects keys from encryption events", async () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", { @@ -1096,7 +1168,7 @@ describe("MatrixRTCSession", () => { it("collects keys at non-zero indices", async () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", { @@ -1122,7 +1194,7 @@ describe("MatrixRTCSession", () => { it("collects keys by merging", async () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", { @@ -1173,7 +1245,7 @@ describe("MatrixRTCSession", () => { it("ignores older keys at same index", async () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( makeMockEvent( @@ -1232,7 +1304,7 @@ describe("MatrixRTCSession", () => { it("key timestamps are treated as monotonic", async () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( makeMockEvent( @@ -1274,9 +1346,9 @@ describe("MatrixRTCSession", () => { ); }); - it("ignores keys event for the local participant", () => { + it("ignores keys event for the local participant", async () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( @@ -1299,7 +1371,7 @@ describe("MatrixRTCSession", () => { jest.useFakeTimers(); try { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); // defaults to getTs() jest.setSystemTime(1000); @@ -1353,9 +1425,9 @@ describe("MatrixRTCSession", () => { }); }); describe("read status", () => { - it("returns the correct probablyLeft status", () => { + it("returns the correct probablyLeft status", async () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess!.probablyLeft).toBe(undefined); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); @@ -1369,9 +1441,9 @@ describe("MatrixRTCSession", () => { expect(sess!.probablyLeft).toBe(true); }); - it("returns membershipStatus once joinRoomSession got called", () => { + it("returns membershipStatus once joinRoomSession got called", async () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess!.membershipStatus).toBe(undefined); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); diff --git a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts index 9472dc16ed..7d2f60f8ac 100644 --- a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts @@ -17,15 +17,15 @@ limitations under the License. import { ClientEvent, EventTimeline, MatrixClient } from "../../../src"; import { RoomStateEvent } from "../../../src/models/room-state"; import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager"; -import { makeMockRoom, membershipTemplate, mockRoomState } from "./mocks"; +import { makeMockRoom, sessionMembershipTemplate, mockRoomState } from "./mocks"; import { logger } from "../../../src/logger"; describe("MatrixRTCSessionManager", () => { let client: MatrixClient; - beforeEach(() => { + beforeEach(async () => { client = new MatrixClient({ baseUrl: "base_url" }); - client.matrixRTC.start(); + await client.matrixRTC.start(); }); afterEach(() => { @@ -33,15 +33,20 @@ describe("MatrixRTCSessionManager", () => { client.matrixRTC.stop(); }); - it("Fires event when session starts", () => { + it("Fires event when session starts", async () => { const onStarted = jest.fn(); - client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + const { promise, resolve } = Promise.withResolvers(); + client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, (...v) => { + onStarted(...v); + resolve(); + }); try { - const room1 = makeMockRoom([membershipTemplate]); + const room1 = makeMockRoom([sessionMembershipTemplate]); jest.spyOn(client, "getRooms").mockReturnValue([room1]); client.emit(ClientEvent.Room, room1); + await promise; expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); } finally { client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); @@ -53,7 +58,7 @@ describe("MatrixRTCSessionManager", () => { client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); try { - const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]); + const room1 = makeMockRoom([{ ...sessionMembershipTemplate, application: "m.other" }]); jest.spyOn(client, "getRooms").mockReturnValue([room1]); client.emit(ClientEvent.Room, room1); @@ -63,60 +68,76 @@ describe("MatrixRTCSessionManager", () => { } }); - it("Fires event when session ends", () => { + it("Fires event when session ends", async () => { const onEnded = jest.fn(); - client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); - const room1 = makeMockRoom([membershipTemplate]); + const { promise: endPromise, resolve: rEnd } = Promise.withResolvers(); + client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); + const { promise: startPromise, resolve: rStart } = Promise.withResolvers(); + client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionEnded, rEnd); + client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionStarted, rStart); + + const room1 = makeMockRoom([sessionMembershipTemplate]); jest.spyOn(client, "getRooms").mockReturnValue([room1]); jest.spyOn(client, "getRoom").mockReturnValue(room1); client.emit(ClientEvent.Room, room1); + await startPromise; - mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]); - + mockRoomState(room1, [{ user_id: sessionMembershipTemplate.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); - + await endPromise; expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); }); - it("Fires correctly with for with custom sessionDescription", () => { + it("Fires correctly with for with custom sessionDescription", async () => { 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); + await sessionManager.start(); + const { promise: startPromise, resolve: rStart } = Promise.withResolvers(); + const { promise: endPromise, resolve: rEnd } = Promise.withResolvers(); + + sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, (v) => { + onEnded(v); + rEnd(); + }); + sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, (v) => { + onStarted(v); + rStart(); + }); try { - const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]); + const room1 = makeMockRoom([{ ...sessionMembershipTemplate, application: "m.other" }]); 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" }]); + const room2 = makeMockRoom([{ ...sessionMembershipTemplate, application: "m.notCall", call_id: "test" }]); jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]); client.emit(ClientEvent.Room, room2); + await startPromise; expect(onStarted).toHaveBeenCalled(); onStarted.mockClear(); - mockRoomState(room2, [{ user_id: membershipTemplate.user_id }]); + mockRoomState(room2, [{ user_id: sessionMembershipTemplate.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); + await endPromise; expect(onEnded).toHaveBeenCalled(); onEnded.mockClear(); - mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]); + mockRoomState(room1, [{ user_id: sessionMembershipTemplate.user_id }]); jest.spyOn(client, "getRoom").mockReturnValue(room1); const roomStateOther = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!; @@ -132,13 +153,13 @@ describe("MatrixRTCSessionManager", () => { 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" }]); + const room1 = makeMockRoom([{ ...sessionMembershipTemplate, 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 }]); + mockRoomState(room1, [{ user_id: sessionMembershipTemplate.user_id }]); const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!; const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0]; diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index 1e0e0b5e79..211c74cde4 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -31,8 +31,9 @@ import { type SessionMembershipData, type LivekitFocusSelection, } from "../../../src/matrixrtc"; -import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks"; +import { makeMockClient, makeMockRoom, sessionMembershipTemplate, mockCallMembership, type MockClient } from "./mocks"; import { MembershipManager } from "../../../src/matrixrtc/MembershipManager.ts"; +import { waitFor } from "../../test-utils/test-utils.ts"; /** * Create a promise that will resolve once a mocked method is called. @@ -89,7 +90,7 @@ describe("MembershipManager", () => { // Default to fake timers. jest.useFakeTimers(); client = makeMockClient("@alice:example.org", "AAAAAAA"); - room = makeMockRoom([membershipTemplate]); + room = makeMockRoom([sessionMembershipTemplate]); // 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); @@ -161,6 +162,7 @@ describe("MembershipManager", () => { memberManager.join([], focus); // expects await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" })); + // This should check for send sticky once we merge with the sticky matrixRTC branch. expect(client.sendStateEvent).toHaveBeenCalledWith( room.roomId, "org.matrix.msc4143.rtc.member", @@ -174,6 +176,7 @@ describe("MembershipManager", () => { slot_id: "m.call#", rtc_transports: [focus], versions: [], + msc4354_sticky_key: "_@alice:example.org_AAAAAAA_m.call", }, "_@alice:example.org_AAAAAAA_m.call", ); @@ -384,7 +387,7 @@ describe("MembershipManager", () => { const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent); await jest.advanceTimersByTimeAsync(RESTART_DELAY); // first simulate the sync, then resolve sending the delayed event. - await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]); + await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]); resolve({ delay_id: "id" }); // Let the scheduler run one iteration so that the new join gets sent await jest.runOnlyPendingTimersAsync(); @@ -467,7 +470,7 @@ describe("MembershipManager", () => { describe("onRTCSessionMemberUpdate()", () => { it("does nothing if not joined", async () => { const manager = new MembershipManager({}, room, client, callSession); - await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]); + await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]); await jest.advanceTimersToNextTimerAsync(); expect(client.sendStateEvent).not.toHaveBeenCalled(); expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); @@ -484,7 +487,7 @@ describe("MembershipManager", () => { (client._unstable_sendDelayedStateEvent as Mock).mockClear(); await manager.onRTCSessionMemberUpdate([ - mockCallMembership(membershipTemplate, room.roomId), + mockCallMembership(sessionMembershipTemplate, room.roomId), mockCallMembership( { ...(myMembership as SessionMembershipData), user_id: client.getUserId()! }, room.roomId, @@ -507,7 +510,7 @@ describe("MembershipManager", () => { (client._unstable_sendDelayedStateEvent as Mock).mockClear(); // Our own membership is removed: - await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]); + await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]); await jest.advanceTimersByTimeAsync(1); expect(client.sendStateEvent).toHaveBeenCalled(); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled(); @@ -530,7 +533,7 @@ describe("MembershipManager", () => { const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent); await jest.advanceTimersByTimeAsync(10_000); - await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]); + await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]); resolve({ delay_id: "id" }); await jest.advanceTimersByTimeAsync(10_000); @@ -899,7 +902,10 @@ describe("MembershipManager", () => { const manager = new MembershipManager({}, room, client, callSession); manager.join([]); expect(manager.isActivated()).toEqual(true); - const membership = mockCallMembership({ ...membershipTemplate, user_id: client.getUserId()! }, room.roomId); + const membership = mockCallMembership( + { ...sessionMembershipTemplate, user_id: client.getUserId()! }, + room.roomId, + ); await manager.onRTCSessionMemberUpdate([membership]); await manager.updateCallIntent("video"); expect(client.sendStateEvent).toHaveBeenCalledTimes(2); @@ -913,7 +919,7 @@ describe("MembershipManager", () => { manager.join([]); expect(manager.isActivated()).toEqual(true); const membership = mockCallMembership( - { ...membershipTemplate, "user_id": client.getUserId()!, "m.call.intent": "video" }, + { ...sessionMembershipTemplate, "user_id": client.getUserId()!, "m.call.intent": "video" }, room.roomId, ); await manager.onRTCSessionMemberUpdate([membership]); @@ -923,18 +929,18 @@ describe("MembershipManager", () => { }); }); -it("Should prefix log with MembershipManager used", () => { +it("Should prefix log with MembershipManager used", async () => { + const spy = jest.spyOn(console, "error"); const client = makeMockClient("@alice:example.org", "AAAAAAA"); - const room = makeMockRoom([membershipTemplate]); + const room = makeMockRoom([sessionMembershipTemplate]); const membershipManager = new MembershipManager(undefined, room, client, callSession); - const spy = jest.spyOn(console, "error"); // Double join membershipManager.join([]); membershipManager.join([]); - expect(spy).toHaveBeenCalled(); + await waitFor(() => expect(spy).toHaveBeenCalled()); const logline: string = spy.mock.calls[0][0]; expect(logline.startsWith("[MembershipManager]")).toBe(true); }); diff --git a/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts b/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts index de88d47f60..00098a5eae 100644 --- a/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts +++ b/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts @@ -20,7 +20,7 @@ import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManage import { type CallMembership, type Statistics } from "../../../src/matrixrtc"; import { type ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts"; import { KeyTransportEvents, type KeyTransportEventsHandlerMap } from "../../../src/matrixrtc/IKeyTransport.ts"; -import { membershipTemplate, mockCallMembership } from "./mocks.ts"; +import { sessionMembershipTemplate, mockCallMembership } from "./mocks.ts"; import { decodeBase64, TypedEventEmitter } from "../../../src"; import { RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts"; import { type RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport.ts"; @@ -864,7 +864,7 @@ describe("RTCEncryptionManager", () => { function aCallMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership { return mockCallMembership( - { ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts }, + { ...sessionMembershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts }, "!room:id", ); } diff --git a/spec/unit/matrixrtc/RoomKeyTransport.spec.ts b/spec/unit/matrixrtc/RoomKeyTransport.spec.ts index f08cced850..f0770f97f8 100644 --- a/spec/unit/matrixrtc/RoomKeyTransport.spec.ts +++ b/spec/unit/matrixrtc/RoomKeyTransport.spec.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { makeMockEvent, makeMockRoom, membershipTemplate, makeKey } from "./mocks"; +import { makeMockEvent, makeMockRoom, sessionMembershipTemplate, makeKey } from "./mocks"; import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport"; import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport"; import { EventType, MatrixClient, RoomEvent } from "../../../src"; @@ -31,7 +31,7 @@ describe("RoomKeyTransport", () => { let mockLogger: Mocked; const onCallEncryptionMock = jest.fn(); - beforeEach(() => { + beforeEach(async () => { onCallEncryptionMock.mockReset(); mockLogger = { debug: jest.fn(), @@ -48,9 +48,9 @@ describe("RoomKeyTransport", () => { roomEventEncryptionKeysReceivedTotalAge: 0, }, }; - room = makeMockRoom([membershipTemplate]); + room = makeMockRoom([sessionMembershipTemplate]); client = new MatrixClient({ baseUrl: "base_url" }); - client.matrixRTC.start(); + await client.matrixRTC.start(); transport = new RoomKeyTransport(room, client, statistics, { getChild: jest.fn().mockReturnValue(mockLogger), } as unknown as Mocked); diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index d61670d79f..f522f2881e 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -16,20 +16,24 @@ limitations under the License. import { EventEmitter } from "stream"; -import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src"; -import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership"; +import { EventType, MatrixEvent, type Room, RoomEvent, type MatrixClient } from "../../../src"; +import { + CallMembership, + type RtcMembershipData, + type SessionMembershipData, +} from "../../../src/matrixrtc/CallMembership"; import { secureRandomString } from "../../../src/randomstring"; -export type MembershipData = (SessionMembershipData | {}) & { user_id: string }; +export type MembershipData = (SessionMembershipData | RtcMembershipData | {}) & { user_id: string }; -export const membershipTemplate: SessionMembershipData & { user_id: string } = { - application: "m.call", - call_id: "", - user_id: "@mock:user.example", - device_id: "AAAAAAA", - scope: "m.room", - focus_active: { type: "livekit", focus_selection: "oldest_membership" }, - foci_preferred: [ +export const sessionMembershipTemplate: SessionMembershipData & { user_id: string } = { + "application": "m.call", + "call_id": "", + "user_id": "@mock:user.example", + "device_id": "AAAAAAA", + "scope": "m.room", + "focus_active": { type: "livekit", focus_selection: "oldest_membership" }, + "foci_preferred": [ { livekit_alias: "!alias:something.org", livekit_service_url: "https://livekit-jwt.something.io", @@ -41,6 +45,16 @@ export const membershipTemplate: SessionMembershipData & { user_id: string } = { type: "livekit", }, ], + "m.call.intent": "voice", +}; + +export const rtcMembershipTemplate: RtcMembershipData = { + slot_id: "m.call#", + application: { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" }, + member: { user_id: "@alice:example.org", device_id: "AAAAAAA", id: "xyzHASHxyz" }, + rtc_transports: [{ type: "livekit" }], + msc4354_sticky_key: "my_sticky_key", + versions: [], }; export type MockClient = Pick< @@ -130,23 +144,22 @@ export function makeMockEvent( content: any, timestamp?: number, ): MatrixEvent { - return { - getType: jest.fn().mockReturnValue(type), - getContent: jest.fn().mockReturnValue(content), - getSender: jest.fn().mockReturnValue(sender), - getTs: jest.fn().mockReturnValue(timestamp ?? Date.now()), - getRoomId: jest.fn().mockReturnValue(roomId), - getId: jest.fn().mockReturnValue(secureRandomString(8)), - isDecryptionFailure: jest.fn().mockReturnValue(false), - } as unknown as MatrixEvent; + return new MatrixEvent({ + event_id: secureRandomString(8), + sender, + type, + content, + room_id: roomId, + origin_server_ts: timestamp ?? 0, + }); } export function mockRTCEvent({ user_id: sender, ...membershipData }: MembershipData, roomId: string): MatrixEvent { - return makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData); + return makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData, Date.now()); } export function mockCallMembership(membershipData: MembershipData, roomId: string): CallMembership { - return new CallMembership(mockRTCEvent(membershipData, roomId), membershipData); + return new CallMembership(mockRTCEvent(membershipData, roomId)); } export function makeKey(id: number, key: string): { key: string; index: number } { diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 59ff3778e7..ce61720c33 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -22,6 +22,7 @@ import type { RTCCallIntent, Transport } from "./types.ts"; import { type IContent, type MatrixEvent } from "../models/event.ts"; import { type RelationType } from "../@types/event.ts"; import { logger } from "../logger.ts"; +import { UNSTABLE_STICKY_KEY, type StickyKeyContent } from "../models/room-sticky-events.ts"; /** * The default duration in milliseconds that a membership is considered valid for. @@ -33,7 +34,7 @@ export const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4; type CallScope = "m.room" | "m.user"; type Member = { user_id: string; device_id: string; id: string }; -export interface RtcMembershipData { +export type RtcMembershipData = { "slot_id": string; "member": Member; "m.relates_to"?: { @@ -47,9 +48,7 @@ export interface RtcMembershipData { }; "rtc_transports": Transport[]; "versions": string[]; - "msc4354_sticky_key"?: string; - "sticky_key"?: string; -} +} & StickyKeyContent; const checkRtcMembershipData = ( data: IContent, @@ -103,19 +102,19 @@ const checkRtcMembershipData = ( } // optional fields - if ((data.sticky_key ?? data.msc4354_sticky_key) === undefined) { + if ((data[UNSTABLE_STICKY_KEY.name] ?? data[UNSTABLE_STICKY_KEY.altName]) === undefined) { errors.push(prefix + "sticky_key or msc4354_sticky_key must be a defined"); } - if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") { + if (data[UNSTABLE_STICKY_KEY.name] !== undefined && typeof data[UNSTABLE_STICKY_KEY.name] !== "string") { errors.push(prefix + "sticky_key must be a string"); } - if (data.msc4354_sticky_key !== undefined && typeof data.msc4354_sticky_key !== "string") { + if (data[UNSTABLE_STICKY_KEY.altName] !== undefined && typeof data[UNSTABLE_STICKY_KEY.altName] !== "string") { errors.push(prefix + "msc4354_sticky_key must be a string"); } if ( - data.sticky_key !== undefined && - data.msc4354_sticky_key !== undefined && - data.sticky_key !== data.msc4354_sticky_key + data[UNSTABLE_STICKY_KEY.name] !== undefined && + data[UNSTABLE_STICKY_KEY.altName] !== undefined && + data[UNSTABLE_STICKY_KEY.name] !== data[UNSTABLE_STICKY_KEY.altName] ) { errors.push(prefix + "sticky_key and msc4354_sticky_key must be equal if both are defined"); } @@ -244,18 +243,23 @@ export class CallMembership { /** The parsed data from the Matrix event. * To access checked eventId and sender from the matrixEvent. * Class construction will fail if these values cannot get obtained. */ - private readonly matrixEventData: { eventId: string; sender: string }; + 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 readonly matrixEvent: MatrixEvent, - data: IContent, + private readonly relatedEvent?: MatrixEvent, ) { + const data = matrixEvent.getContent() as any; + const eventId = matrixEvent.getId(); const sender = matrixEvent.getSender(); - if (eventId === undefined) throw new Error("parentEvent is missing eventId field"); if (sender === undefined) throw new Error("parentEvent is missing sender field"); - const sessionErrors: string[] = []; const rtcErrors: string[] = []; if (checkSessionsMembershipData(data, sessionErrors)) { @@ -389,31 +393,57 @@ export class CallMembership { } } - public createdTs(): number { + /** + * The last update to this membership. + * @returns The timestamp when this membership was last updated. + */ + public updatedTs(): number { + return this.matrixEvent.getTs(); + } + + /** + * The ts of the initial connection of this membership. + * @returns The timestamp when this membership was initially connected. + */ + public connectedTs(): number { 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(); } } + /** @deprecated use connectedTs instead */ + public createdTs(): number { + return this.connectedTs(); + } + /** * Gets the absolute expiry timestamp of the membership. + * + * The absolute expiry based on DEFAULT_EXPIRE_DURATION and `sessionData.expires`. + * + * ### Note: + * This concept is not required for m.rtc.member sticky events anymore. the sticky timeout serves takes care of + * this logic automatically (removing member events that have were missed to get removed manually or via delayed events) + * So this is mostly relevant for legacy m.call.member events. + * It is planned to remove manual expiration logic entirely once m.rtc.member is widely adopted. + * * @returns The absolute expiry time of the membership as a unix timestamp in milliseconds or undefined if not applicable */ public getAbsoluteExpiry(): number | undefined { const { kind, data } = this.membershipData; switch (kind) { case "rtc": - return undefined; + // Rtc events do not have an data.expires field that gets updated + // Instead the sticky event is resent. + return this.updatedTs() + DEFAULT_EXPIRE_DURATION; case "session": default: - // TODO: calculate this from the MatrixRTCSession join configuration directly - return this.createdTs() + (data.expires ?? DEFAULT_EXPIRE_DURATION); + return this.connectedTs() + (data.expires ?? DEFAULT_EXPIRE_DURATION); } } @@ -421,31 +451,17 @@ 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(); } /** * @returns true if the membership has expired, otherwise false */ public isExpired(): boolean { - const { kind } = this.membershipData; - switch (kind) { - case "rtc": - return false; - case "session": - default: - return this.getMsUntilExpiry()! <= 0; - } + return this.getMsUntilExpiry()! <= 0; } /** @@ -492,6 +508,7 @@ export class CallMembership { if (kind === "session") return data.focus_active; return undefined; } + /** * The value of the `rtc_transports` field for RTC memberships (m.rtc.member). * Or the value of the `foci_preferred` field for legacy session memberships (m.call.member). diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 9a61a7238b..429f3fa4ab 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -17,6 +17,7 @@ limitations under the License. import { type Logger, logger as rootLogger } from "../logger.ts"; import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { EventTimeline } from "../models/event-timeline.ts"; +import { MatrixEvent } from "../models/event.ts"; import { type Room } from "../models/room.ts"; import { type MatrixClient } from "../client.ts"; import { EventType, RelationType } from "../@types/event.ts"; @@ -306,10 +307,11 @@ 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, + client: Pick, + ): Promise { + return await MatrixRTCSession.sessionMembershipsForSlot(room, client, { id: "", application: "m.call", }); @@ -318,21 +320,24 @@ export class MatrixRTCSession extends TypedEventEmitter< /** * @deprecated use `MatrixRTCSession.slotMembershipsForRoom` instead. */ - public static sessionMembershipsForRoom( - room: Pick, + public static async sessionMembershipsForRoom( + room: Pick, + client: Pick, sessionDescription: SlotDescription, - ): CallMembership[] { - return this.sessionMembershipsForSlot(room, sessionDescription); + ): Promise { + return await this.sessionMembershipsForSlot(room, client, 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, + client: Pick, slotDescription: SlotDescription, - ): CallMembership[] { + existingMemberships?: CallMembership[], + ): Promise { const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`); const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); if (!roomState) { @@ -340,58 +345,57 @@ 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; - - 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; + const createMembership = async (memberEvent: MatrixEvent): Promise => { + const relatedEventId = memberEvent.relationEventId; + const fetchRelatedEvent = async (): Promise => { + const eventData = await client + .fetchRoomEvent(room.roomId, relatedEventId!) + .catch((e) => logger.error(`Could not get related event ${relatedEventId} for call membership`, e)); - 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); + return eventData ? new MatrixEvent(eventData) : undefined; + }; + const relatedEvent = relatedEventId + ? (room.findEventById(relatedEventId) ?? (await fetchRelatedEvent())) + : undefined; + + let membership = undefined; + try { + membership = new CallMembership(memberEvent, relatedEvent); + } catch (e) { + logger.warn("Couldn't construct call membership: ", e); + return undefined; + } + // 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)}`, + ); + return undefined; + } + return membership; + }; - if (!deepCompare(membership.slotDescription, slotDescription)) { - logger.info( - `Ignoring membership of user ${membership.sender} for a different slot: ${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); - } catch (e) { - logger.warn("Couldn't construct call membership: ", e); - } + for (const memberEvent of callMemberEvents) { + let membership = existingMemberships?.find((m) => m.eventId === memberEvent.getId()); + if (!membership) membership = await createMembership(memberEvent); + if (!membership) 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()); - if (callMemberships.length > 1) { + if (callMemberships.length >= 1) { logger.debug( `Call memberships in room ${room.roomId}, in order: `, callMemberships.map((m) => [m.createdTs(), m.sender]), @@ -411,15 +415,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, client, { + 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 sessionForRoom( + client: MatrixClient, + room: Room, + slotDescription: SlotDescription, + ): Promise { return this.sessionForSlot(client, room, slotDescription); } @@ -428,8 +439,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, client, slotDescription); return new MatrixRTCSession(client, room, callMemberships, slotDescription); } @@ -459,6 +474,8 @@ export class MatrixRTCSession extends TypedEventEmitter< public constructor( private readonly client: Pick< MatrixClient, + | "on" + | "off" | "getUserId" | "getDeviceId" | "sendStateEvent" @@ -467,13 +484,12 @@ export class MatrixRTCSession extends TypedEventEmitter< | "sendEvent" | "cancelPendingEvent" | "encryptAndSendToDevice" - | "off" - | "on" | "decryptEventIfNeeded" + | "fetchRoomEvent" >, private roomSubset: Pick< Room, - "getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState" | "on" | "off" + "on" | "off" | "getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState" | "findEventById" >, public memberships: CallMembership[], /** @@ -721,7 +737,7 @@ export class MatrixRTCSession extends TypedEventEmitter< } if (soonestExpiry != undefined) { - this.expiryTimeout = setTimeout(this.onRTCSessionMemberUpdate, soonestExpiry); + this.expiryTimeout = setTimeout(() => void this.onRTCSessionMemberUpdate(), soonestExpiry); } } @@ -787,14 +803,14 @@ export class MatrixRTCSession extends TypedEventEmitter< * Call this when the Matrix room members have changed. */ public onRoomMemberUpdate = (): void => { - this.recalculateSessionMembers(); + void this.recalculateSessionMembers(); }; /** * Call this when something changed that may impacts the current MatrixRTC members in this session. */ - public onRTCSessionMemberUpdate = (): void => { - this.recalculateSessionMembers(); + public onRTCSessionMemberUpdate = (): Promise => { + return this.recalculateSessionMembers(); }; /** @@ -804,12 +820,18 @@ export class MatrixRTCSession extends TypedEventEmitter< * * This function should be called when the room members or call memberships might have changed. */ - private recalculateSessionMembers = (): void => { + private async recalculateSessionMembers(): Promise { const oldMemberships = this.memberships; - this.memberships = MatrixRTCSession.sessionMembershipsForSlot(this.room, this.slotDescription); + this.memberships = await MatrixRTCSession.sessionMembershipsForSlot( + this.roomSubset, + this.client, + this.slotDescription, + oldMemberships, + ); 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])); if (changed) { @@ -820,7 +842,7 @@ export class MatrixRTCSession extends TypedEventEmitter< this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships); }); - void this.membershipManager?.onRTCSessionMemberUpdate(this.memberships); + await 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) { @@ -845,5 +867,5 @@ export class MatrixRTCSession extends TypedEventEmitter< void this.encryptionManager?.onMembershipsUpdate(oldMemberships); this.setExpiryTimer(); - }; + } } diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index 792132d273..5139ac7aed 100644 --- a/src/matrixrtc/MatrixRTCSessionManager.ts +++ b/src/matrixrtc/MatrixRTCSessionManager.ts @@ -62,11 +62,11 @@ export class MatrixRTCSessionManager extends TypedEventEmitter { // We shouldn't need to null-check here, but matrix-client.spec.ts mocks getRooms // returning nothing, and breaks tests if you change it to return an empty array :'( for (const room of this.client.getRooms() ?? []) { - const session = MatrixRTCSession.sessionForRoom(this.client, room, this.slotDescription); + const session = await MatrixRTCSession.sessionForRoom(this.client, room, this.slotDescription); if (session.memberships.length > 0) { this.roomSessions.set(room.roomId, session); } @@ -98,11 +98,11 @@ export class MatrixRTCSessionManager extends TypedEventEmitter { if (!this.roomSessions.has(room.roomId)) { this.roomSessions.set( room.roomId, - MatrixRTCSession.sessionForRoom(this.client, room, this.slotDescription), + await MatrixRTCSession.sessionForRoom(this.client, room, this.slotDescription), ); } @@ -110,7 +110,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter { - this.refreshRoom(room); + void this.refreshRoom(room); }; private onRoomState = (event: MatrixEvent, _state: RoomState): void => { @@ -121,13 +121,13 @@ export class MatrixRTCSessionManager extends TypedEventEmitter { const isNewSession = !this.roomSessions.has(room.roomId); - const session = this.getRoomSession(room); + const session = await 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, @@ -135,7 +135,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter 0 and // nowActive = session.memberships.length // Alternatively we would need to setup some event emission when the RTC session ended. - session.onRTCSessionMemberUpdate(); + await session.onRTCSessionMemberUpdate(); const nowActive = session.memberships.length > 0; diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index 45f0a5ce9f..a102b46623 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -42,6 +42,7 @@ import { type IMembershipManager, type MembershipManagerEventHandlerMap, } from "./IMembershipManager.ts"; +import { UNSTABLE_STICKY_KEY } from "../models/room-sticky-events.ts"; /* MembershipActionTypes: On Join: ───────────────┐ ┌───────────────(1)───────────┐ @@ -402,7 +403,7 @@ export class MembershipManager return this.joinConfig?.useRtcMemberFormat ?? false; } // LOOP HANDLER: - private async membershipLoopHandler(type: MembershipActionType): Promise { + private membershipLoopHandler(type: MembershipActionType): Promise { switch (type) { case MembershipActionType.SendDelayedEvent: { // Before we start we check if we come from a state where we have a delay id. @@ -422,19 +423,19 @@ export class MembershipManager case MembershipActionType.RestartDelayedEvent: { if (!this.state.delayId) { // Delay id got reset. This action was used to check if the hs canceled the delayed event when the join state got sent. - return createInsertActionUpdate(MembershipActionType.SendDelayedEvent); + return Promise.resolve(createInsertActionUpdate(MembershipActionType.SendDelayedEvent)); } return this.restartDelayedEvent(this.state.delayId); } case MembershipActionType.SendScheduledDelayedLeaveEvent: { // We are already good if (!this.state.hasMemberStateEvent) { - return { replace: [] }; + return Promise.resolve({ replace: [] }); } if (this.state.delayId) { return this.sendScheduledDelayedLeaveEventOrFallbackToSendLeaveEvent(this.state.delayId); } else { - return createInsertActionUpdate(MembershipActionType.SendLeaveEvent); + return Promise.resolve(createInsertActionUpdate(MembershipActionType.SendLeaveEvent)); } } case MembershipActionType.SendJoinEvent: { @@ -446,7 +447,7 @@ export class MembershipManager case MembershipActionType.SendLeaveEvent: { // We are good already if (!this.state.hasMemberStateEvent) { - return { replace: [] }; + return Promise.resolve({ replace: [] }); } // This is only a fallback in case we do not have working delayed events support. // first we should try to just send the scheduled leave event @@ -456,12 +457,12 @@ export class MembershipManager } // HANDLERS (used in the membershipLoopHandler) - private async sendOrResendDelayedLeaveEvent(): Promise { + private 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 + return this.client ._unstable_sendDelayedStateEvent( this.room.roomId, { @@ -518,9 +519,9 @@ export class MembershipManager }); } - private async cancelKnownDelayIdBeforeSendDelayedEvent(delayId: string): Promise { + private cancelKnownDelayIdBeforeSendDelayedEvent(delayId: string): Promise { // Remove all running updates and restarts - return await this.client + return this.client ._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Cancel) .then(() => { this.state.delayId = undefined; @@ -561,7 +562,7 @@ export class MembershipManager this.emit(MembershipManagerEvent.ProbablyLeft, this.state.probablyLeft); } - private async restartDelayedEvent(delayId: string): Promise { + private restartDelayedEvent(delayId: string): Promise { // Compute the duration until we expect the server to send the delayed leave event. const durationUntilServerDelayedLeave = this.state.expectedServerDelayLeaveTs ? this.state.expectedServerDelayLeaveTs - Date.now() @@ -583,7 +584,7 @@ export class MembershipManager // The obvious choice here would be to use the `IRequestOpts` to set the timeout. Since this call might be forwarded // to the widget driver this information would get lost. That is why we mimic the AbortError using the race. - return await Promise.race([ + return Promise.race([ this.client._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Restart), abortPromise, ]) @@ -622,8 +623,8 @@ export class MembershipManager }); } - private async sendScheduledDelayedLeaveEventOrFallbackToSendLeaveEvent(delayId: string): Promise { - return await this.client + private sendScheduledDelayedLeaveEventOrFallbackToSendLeaveEvent(delayId: string): Promise { + return this.client ._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Send) .then(() => { this.state.hasMemberStateEvent = false; @@ -650,8 +651,8 @@ export class MembershipManager }); } - private async sendJoinEvent(): Promise { - return await this.client + private sendJoinEvent(): Promise { + return this.client .sendStateEvent( this.room.roomId, this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, @@ -695,9 +696,9 @@ export class MembershipManager }); } - private async updateExpiryOnJoinedEvent(): Promise { + private updateExpiryOnJoinedEvent(): Promise { const nextExpireUpdateIteration = this.state.expireUpdateIterations + 1; - return await this.client + return this.client .sendStateEvent( this.room.roomId, this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, @@ -724,8 +725,8 @@ export class MembershipManager throw e; }); } - private async sendFallbackLeaveEvent(): Promise { - return await this.client + private sendFallbackLeaveEvent(): Promise { + return this.client .sendStateEvent( this.room.roomId, this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, @@ -773,6 +774,7 @@ export class MembershipManager member: { device_id: this.deviceId, user_id: this.client.getUserId()!, id: this.memberId }, versions: [], ...relationObject, + [UNSTABLE_STICKY_KEY.name]: this.memberId, }; } else { const focusObjects = diff --git a/src/models/room-sticky-events.ts b/src/models/room-sticky-events.ts index 3e9e26a2bd..d0fcaea725 100644 --- a/src/models/room-sticky-events.ts +++ b/src/models/room-sticky-events.ts @@ -1,9 +1,33 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { type EitherAnd, UnstableValue } from "matrix-events-sdk"; + import { logger as loggerInstance } from "../logger.ts"; import { type MatrixEvent } from "./event.ts"; import { TypedEventEmitter } from "./typed-event-emitter.ts"; const logger = loggerInstance.getChild("RoomStickyEvents"); +export const UNSTABLE_STICKY_KEY = new UnstableValue("sticky_key", "msc4354_sticky_key"); +export type StickyKeyContent = EitherAnd< + { [UNSTABLE_STICKY_KEY.name]: string }, + { [UNSTABLE_STICKY_KEY.altName]: string } +>; + export enum RoomStickyEventsEvent { Update = "RoomStickyEvents.Update", }