From d376e942c936243679b78dbc6e6a7d020f87a448 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 29 Sep 2025 12:36:05 +0100 Subject: [PATCH] Updates and tests --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 243 ++++++++++--------- spec/unit/matrixrtc/mocks.ts | 7 +- src/matrixrtc/MatrixRTCSession.ts | 79 +++--- src/matrixrtc/MembershipManager.ts | 166 +++++++------ 4 files changed, 275 insertions(+), 220 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 70cbe927d..b5238235d 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -18,7 +18,6 @@ import { encodeBase64, EventType, MatrixClient, type MatrixError, type MatrixEve 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 { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts"; @@ -47,91 +46,111 @@ describe("MatrixRTCSession", () => { sess = undefined; }); - describe("roomSessionForRoom", () => { - it("creates a room-scoped session from room state", () => { - const mockRoom = makeMockRoom([membershipTemplate]); + describe.each([ + { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + testCreateSticky: false, + }, + { + listenForStickyEvents: false, + listenForMemberStateEvents: true, + testCreateSticky: false, + }, + { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + testCreateSticky: true, + }, + { + listenForStickyEvents: true, + listenForMemberStateEvents: false, + testCreateSticky: true, + }, + ])( + "roomSessionForRoom listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForStickyEvents testCreateSticky=$testCreateSticky", + (testConfig) => { + it("creates a room-scoped session from room state", () => { + const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships.length).toEqual(1); - expect(sess?.memberships[0].sessionDescription.id).toEqual(""); - expect(sess?.memberships[0].scope).toEqual("m.room"); - expect(sess?.memberships[0].application).toEqual("m.call"); - expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); - expect(sess?.memberships[0].isExpired()).toEqual(false); - expect(sess?.sessionDescription.id).toEqual(""); - }); - - it("ignores memberships where application is not m.call", () => { - const testMembership = Object.assign({}, membershipTemplate, { - application: "not-m.call", + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + expect(sess?.memberships.length).toEqual(1); + expect(sess?.memberships[0].sessionDescription.id).toEqual(""); + expect(sess?.memberships[0].scope).toEqual("m.room"); + expect(sess?.memberships[0].application).toEqual("m.call"); + expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); + expect(sess?.memberships[0].isExpired()).toEqual(false); + expect(sess?.sessionDescription.id).toEqual(""); }); - const mockRoom = makeMockRoom([testMembership]); - const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships).toHaveLength(0); - }); - it("ignores memberships where callId is not empty", () => { - const testMembership = Object.assign({}, membershipTemplate, { - call_id: "not-empty", - scope: "m.room", + it("ignores memberships where application is not m.call", () => { + const testMembership = Object.assign({}, membershipTemplate, { + application: "not-m.call", + }); + const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky); + const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + expect(sess?.memberships).toHaveLength(0); }); - const mockRoom = makeMockRoom([testMembership]); - const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships).toHaveLength(0); - }); - it("ignores expired memberships events", () => { - jest.useFakeTimers(); - const expiredMembership = Object.assign({}, membershipTemplate); - expiredMembership.expires = 1000; - expiredMembership.device_id = "EXPIRED"; - const mockRoom = makeMockRoom([membershipTemplate, expiredMembership]); + it("ignores memberships where callId is not empty", () => { + const testMembership = Object.assign({}, membershipTemplate, { + call_id: "not-empty", + scope: "m.room", + }); + const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky); + const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + expect(sess?.memberships).toHaveLength(0); + }); - jest.advanceTimersByTime(2000); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships.length).toEqual(1); - expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); - jest.useRealTimers(); - }); + it("ignores expired memberships events", () => { + jest.useFakeTimers(); + const expiredMembership = Object.assign({}, membershipTemplate); + expiredMembership.expires = 1000; + expiredMembership.device_id = "EXPIRED"; + const mockRoom = makeMockRoom([membershipTemplate, expiredMembership], testConfig.testCreateSticky); - it("ignores memberships events of members not in the room", () => { - const mockRoom = makeMockRoom([membershipTemplate]); - mockRoom.hasMembershipState = (state) => state === KnownMembership.Join; - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships.length).toEqual(0); - }); + jest.advanceTimersByTime(2000); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + expect(sess?.memberships.length).toEqual(1); + expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); + jest.useRealTimers(); + }); - it("honours created_ts", () => { - jest.useFakeTimers(); - jest.setSystemTime(500); - const expiredMembership = Object.assign({}, membershipTemplate); - expiredMembership.created_ts = 500; - expiredMembership.expires = 1000; - const mockRoom = makeMockRoom([expiredMembership]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500); - jest.useRealTimers(); - }); + it("ignores memberships events of members not in the room", () => { + const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); + mockRoom.hasMembershipState = (state) => state === KnownMembership.Join; + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + expect(sess?.memberships.length).toEqual(0); + }); - it("returns empty session if no membership events are present", () => { - const mockRoom = makeMockRoom([]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships).toHaveLength(0); - }); + it("honours created_ts", () => { + jest.useFakeTimers(); + jest.setSystemTime(500); + const expiredMembership = Object.assign({}, membershipTemplate); + expiredMembership.created_ts = 500; + expiredMembership.expires = 1000; + const mockRoom = makeMockRoom([expiredMembership], testConfig.testCreateSticky); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500); + jest.useRealTimers(); + }); - it("safely ignores events with no memberships section", () => { - const roomId = secureRandomString(8); - const event = { - getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), - getContent: jest.fn().mockReturnValue({}), - getSender: jest.fn().mockReturnValue("@mock:user.example"), - getTs: jest.fn().mockReturnValue(1000), - getLocalAge: jest.fn().mockReturnValue(0), - }; - const mockRoom = { - ...makeMockRoom([]), - roomId, - getLiveTimeline: jest.fn().mockReturnValue({ + it("returns empty session if no membership events are present", () => { + const mockRoom = makeMockRoom([], testConfig.testCreateSticky); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + expect(sess?.memberships).toHaveLength(0); + }); + + it("safely ignores events with no memberships section", () => { + const event = { + getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), + getContent: jest.fn().mockReturnValue({}), + getSender: jest.fn().mockReturnValue("@mock:user.example"), + getTs: jest.fn().mockReturnValue(1000), + getLocalAge: jest.fn().mockReturnValue(0), + }; + const mockRoom = makeMockRoom([]); + mockRoom.getLiveTimeline = jest.fn().mockReturnValue({ getState: jest.fn().mockReturnValue({ on: jest.fn(), off: jest.fn(), @@ -148,25 +167,21 @@ describe("MatrixRTCSession", () => { ], ]), }), - }), - }; - sess = MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession); - expect(sess.memberships).toHaveLength(0); - }); + }); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + expect(sess.memberships).toHaveLength(0); + }); - it("safely ignores events with junk memberships section", () => { - const roomId = secureRandomString(8); - const event = { - getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), - getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }), - getSender: jest.fn().mockReturnValue("@mock:user.example"), - getTs: jest.fn().mockReturnValue(1000), - getLocalAge: jest.fn().mockReturnValue(0), - }; - const mockRoom = { - ...makeMockRoom([]), - roomId, - getLiveTimeline: jest.fn().mockReturnValue({ + it("safely ignores events with junk memberships section", () => { + const event = { + getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), + getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }), + getSender: jest.fn().mockReturnValue("@mock:user.example"), + getTs: jest.fn().mockReturnValue(1000), + getLocalAge: jest.fn().mockReturnValue(0), + }; + const mockRoom = makeMockRoom([]); + mockRoom.getLiveTimeline = jest.fn().mockReturnValue({ getState: jest.fn().mockReturnValue({ on: jest.fn(), off: jest.fn(), @@ -183,28 +198,28 @@ describe("MatrixRTCSession", () => { ], ]), }), - }), - }; - sess = MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession); - expect(sess.memberships).toHaveLength(0); - }); + }); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + expect(sess.memberships).toHaveLength(0); + }); - it("ignores memberships with no device_id", () => { - const testMembership = Object.assign({}, membershipTemplate); - (testMembership.device_id as string | undefined) = undefined; - const mockRoom = makeMockRoom([testMembership]); - const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess.memberships).toHaveLength(0); - }); + it("ignores memberships with no device_id", () => { + const testMembership = Object.assign({}, membershipTemplate); + (testMembership.device_id as string | undefined) = undefined; + const mockRoom = makeMockRoom([testMembership]); + const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + expect(sess.memberships).toHaveLength(0); + }); - it("ignores memberships with no call_id", () => { - const testMembership = Object.assign({}, membershipTemplate); - (testMembership.call_id as string | undefined) = undefined; - const mockRoom = makeMockRoom([testMembership]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess.memberships).toHaveLength(0); - }); - }); + it("ignores memberships with no call_id", () => { + const testMembership = Object.assign({}, membershipTemplate); + (testMembership.call_id as string | undefined) = undefined; + const mockRoom = makeMockRoom([testMembership]); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + expect(sess.memberships).toHaveLength(0); + }); + }, + ); describe("getOldestMembership", () => { it("returns the oldest membership event", () => { diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index ea0dfe76f..5cd87d171 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -74,10 +74,11 @@ export function makeMockClient(userId: string, deviceId: string): MockClient { export function makeMockRoom( membershipData: MembershipData[], + useStickyEvents = false, ): Room & { emitTimelineEvent: (event: MatrixEvent) => void } { const roomId = secureRandomString(8); // Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()` - const roomState = makeMockRoomState(membershipData, roomId); + const roomState = makeMockRoomState(useStickyEvents ? [] : membershipData, roomId); const room = Object.assign(new EventEmitter(), { roomId: roomId, hasMembershipState: jest.fn().mockReturnValue(true), @@ -85,7 +86,9 @@ export function makeMockRoom( getState: jest.fn().mockReturnValue(roomState), }), getVersion: jest.fn().mockReturnValue("default"), - unstableGetStickyEvents: jest.fn().mockReturnValue([]), + unstableGetStickyEvents: jest + .fn() + .mockReturnValue(useStickyEvents ? membershipData.map((m) => mockRTCEvent(m, roomId)) : []), }) as unknown as Room; return Object.assign(room, { emitTimelineEvent: (event: MatrixEvent) => diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 7b5ee96cb..e4e69a424 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -25,7 +25,7 @@ import { type ISendEventResponse } from "../@types/requests.ts"; import { CallMembership } from "./CallMembership.ts"; import { RoomStateEvent } from "../models/room-state.ts"; import { type Focus } from "./focus.ts"; -import { MembershipManager } from "./MembershipManager.ts"; +import { MembershipManager, StickyEventMembershipManager } from "./MembershipManager.ts"; import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts"; import { deepCompare, logDurationSync } from "../utils.ts"; import { @@ -117,14 +117,6 @@ export interface SessionDescription { // - we use a `Ms` postfix if the option is a duration to avoid using words like: // `time`, `duration`, `delay`, `timeout`... that might be mistaken/confused with technical terms. export interface MembershipConfig { - /** - * Use the new Manager. - * - * Default: `false`. - * @deprecated does nothing anymore we always default to the new membership manager. - */ - useNewMembershipManager?: boolean; - /** * The timeout (in milliseconds) after we joined the call, that our membership should expire * unless we have explicitly updated it. @@ -188,10 +180,11 @@ export interface MembershipConfig { delayedLeaveEventRestartLocalTimeoutMs?: number; /** - * If the membership manager should publish its own membership via sticky events or via the room state. - * @default false (room state) + * Send membership using sticky events rather than state events. + * + * **WARNING**: This is an unstable feature and not all clients will support it. */ - useStickyEvents?: boolean; + unstableSendStickyEvents?: boolean; } export interface EncryptionConfig { @@ -237,6 +230,11 @@ export interface EncryptionConfig { } export type JoinSessionConfig = SessionConfig & MembershipConfig & EncryptionConfig; +interface SessionMembershipsForRoomOpts { + listenForStickyEvents: boolean; + listenForMemberStateEvents: boolean; +} + /** * A MatrixRTCSession manages the membership & properties of a MatrixRTC session. * This class doesn't deal with media at all, just membership & properties of a session. @@ -315,18 +313,21 @@ export class MatrixRTCSession extends TypedEventEmitter< sessionDescription: SessionDescription, // default both true this implied we combine sticky and state events for the final call state // (prefer sticky events in case of a duplicate) - useStickyEvents: boolean = true, - useStateEvents: boolean = true, + { listenForStickyEvents, listenForMemberStateEvents }: SessionMembershipsForRoomOpts = { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + }, ): CallMembership[] { const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`); let callMemberEvents = [] as MatrixEvent[]; - if (useStickyEvents) { + if (listenForStickyEvents) { + logger.info("useStickyEvents"); // prefill with sticky events callMemberEvents = Array.from(room.unstableGetStickyEvents()).filter( (e) => e.getType() === EventType.GroupCallMemberPrefix, ); } - if (useStateEvents) { + if (listenForMemberStateEvents) { const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); if (!roomState) { logger.warn("Couldn't get state for room " + room.roomId); @@ -337,7 +338,7 @@ export class MatrixRTCSession extends TypedEventEmitter< callMemberStateEvents.filter((e) => callMemberEvents.some((stickyEvent) => stickyEvent.getContent().state_key === e.getStateKey()), ); - callMemberEvents.concat(callMemberStateEvents); + callMemberEvents = callMemberEvents.concat(callMemberStateEvents); } const callMemberships: CallMembership[] = []; @@ -406,8 +407,16 @@ export class MatrixRTCSession extends TypedEventEmitter< * * @deprecated Use `MatrixRTCSession.sessionForRoom` with sessionDescription `{ id: "", application: "m.call" }` instead. */ - public static roomSessionForRoom(client: MatrixClient, room: Room): MatrixRTCSession { - const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, { id: "", application: "m.call" }); + public static roomSessionForRoom( + client: MatrixClient, + room: Room, + opts?: SessionMembershipsForRoomOpts, + ): MatrixRTCSession { + const callMemberships = MatrixRTCSession.sessionMembershipsForRoom( + room, + { id: "", application: "m.call" }, + opts, + ); return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" }); } @@ -420,8 +429,9 @@ export class MatrixRTCSession extends TypedEventEmitter< client: MatrixClient, room: Room, sessionDescription: SessionDescription, + opts?: SessionMembershipsForRoomOpts, ): MatrixRTCSession { - const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, sessionDescription); + const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, sessionDescription, opts); return new MatrixRTCSession(client, room, callMemberships, sessionDescription); } @@ -507,6 +517,7 @@ export class MatrixRTCSession extends TypedEventEmitter< roomState?.off(RoomStateEvent.Members, this.onRoomMemberUpdate); this.roomSubset.off(RoomEvent.StickyEvents, this.onStickyEventUpdate); } + private reEmitter = new TypedReEmitter< MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent, MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap & MembershipManagerEventHandlerMap @@ -532,15 +543,23 @@ export class MatrixRTCSession extends TypedEventEmitter< return; } else { // Create MembershipManager and pass the RTCSession logger (with room id info) - - this.membershipManager = new MembershipManager( - joinConfig, - this.roomSubset, - this.client, - () => this.getOldestMembership(), - this.sessionDescription, - this.logger, - ); + this.membershipManager = joinConfig?.unstableSendStickyEvents + ? new StickyEventMembershipManager( + joinConfig, + this.roomSubset, + this.client, + () => this.getOldestMembership(), + this.sessionDescription, + this.logger, + ) + : new MembershipManager( + joinConfig, + this.roomSubset, + this.client, + () => this.getOldestMembership(), + this.sessionDescription, + this.logger, + ); this.reEmitter.reEmit(this.membershipManager!, [ MembershipManagerEvent.ProbablyLeft, @@ -790,7 +809,7 @@ export class MatrixRTCSession extends TypedEventEmitter< this.recalculateSessionMembers(); }; - private onStickyEventUpdate = (added: MatrixEvent[], _removed: MatrixEvent[], room: Room): void => { + private onStickyEventUpdate = (added: MatrixEvent[], _removed: MatrixEvent[]): void => { if ([...added, ..._removed].some((e) => e.getType() === EventType.GroupCallMemberPrefix)) { this.recalculateSessionMembers(); } diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index 35131c767..0db9a5fb1 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -144,6 +144,18 @@ export interface MembershipManagerState { probablyLeft: boolean; } +function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate { + return { + insert: [{ ts: Date.now() + (offset ?? 0), type }], + }; +} + +function createReplaceActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate { + return { + replace: [{ ts: Date.now() + (offset ?? 0), type }], + }; +} + /** * This class is responsible for sending all events relating to the own membership of a matrixRTC call. * It has the following tasks: @@ -313,7 +325,7 @@ export class MembershipManager */ public constructor( private joinConfig: (SessionConfig & MembershipConfig) | undefined, - private room: Pick, + protected room: Pick, private client: Pick< MatrixClient, | "getUserId" @@ -321,8 +333,6 @@ export class MembershipManager | "sendStateEvent" | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent" - | "_unstable_sendStickyEvent" - | "_unstable_sendStickyDelayedEvent" >, private getOldestMembership: () => CallMembership | undefined, public readonly sessionDescription: SessionDescription, @@ -380,7 +390,7 @@ export class MembershipManager } // Membership Event static parameters: private deviceId: string; - private stateKey: string; + protected stateKey: string; private fociPreferred?: Focus[]; private focusActive?: Focus; @@ -403,7 +413,7 @@ export class MembershipManager this.membershipEventExpiryHeadroomMs ); } - private get delayedLeaveEventDelayMs(): number { + protected get delayedLeaveEventDelayMs(): number { return this.delayedLeaveEventDelayMsOverride ?? this.joinConfig?.delayedLeaveEventDelayMs ?? 8_000; } private get delayedLeaveEventRestartMs(): number { @@ -420,10 +430,6 @@ export class MembershipManager return this.joinConfig?.delayedLeaveEventRestartLocalTimeoutMs ?? 2000; } - private get useStickyEvents(): boolean { - return this.joinConfig?.useStickyEvents ?? false; - } - // LOOP HANDLER: private async membershipLoopHandler(type: MembershipActionType): Promise { switch (type) { @@ -479,27 +485,16 @@ export class MembershipManager } // an abstraction to switch between sending state or a sticky event - private clientSendDelayedEvent: (myMembership: EmptyObject) => Promise = ( + protected clientSendDelayedEvent: (myMembership: EmptyObject) => Promise = ( myMembership, ) => - this.useStickyEvents - ? this.client._unstable_sendStickyDelayedEvent( - this.room.roomId, - STICK_DURATION_MS, - { delay: this.delayedLeaveEventDelayMs }, - null, - EventType.GroupCallMemberPrefix, - Object.assign(myMembership, { sticky_key: this.stateKey }), - ) - : this.client._unstable_sendDelayedStateEvent( - this.room.roomId, - { delay: this.delayedLeaveEventDelayMs }, - EventType.GroupCallMemberPrefix, - myMembership, - this.stateKey, - ); - private sendDelayedEventMethodName: () => string = () => - this.useStickyEvents ? "_unstable_sendStickyDelayedEvent" : "_unstable_sendDelayedStateEvent"; + this.client._unstable_sendDelayedStateEvent( + this.room.roomId, + { delay: this.delayedLeaveEventDelayMs }, + EventType.GroupCallMemberPrefix, + myMembership, + this.stateKey, + ); // HANDLERS (used in the membershipLoopHandler) private async sendOrResendDelayedLeaveEvent(): Promise { @@ -531,7 +526,7 @@ export class MembershipManager if (this.manageMaxDelayExceededSituation(e)) { return createInsertActionUpdate(repeatActionType); } - const update = this.actionUpdateFromErrors(e, repeatActionType, this.sendDelayedEventMethodName()); + const update = this.actionUpdateFromErrors(e, repeatActionType, "_unstable_sendDelayedStateEvent"); if (update) return update; if (this.state.hasMemberStateEvent) { @@ -687,25 +682,10 @@ export class MembershipManager }); } - private clientSendMembership: (myMembership: SessionMembershipData | EmptyObject) => Promise = ( - myMembership, - ) => - this.useStickyEvents - ? this.client._unstable_sendStickyEvent( - this.room.roomId, - STICK_DURATION_MS, - null, - EventType.GroupCallMemberPrefix, - Object.assign(myMembership, { sticky_key: this.stateKey }), - ) - : this.client.sendStateEvent( - this.room.roomId, - EventType.GroupCallMemberPrefix, - myMembership, - this.stateKey, - ); - private sendMembershipMethodName: () => string = () => - this.useStickyEvents ? "_unstable_sendStickyEvent" : "sendStateEvent"; + protected clientSendMembership: (myMembership: SessionMembershipData | EmptyObject) => Promise = + (myMembership) => + this.client.sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, myMembership, this.stateKey); + private async sendJoinEvent(): Promise { return await this.clientSendMembership(this.makeMyMembership(this.membershipEventExpiryMs)) .then(() => { @@ -739,11 +719,7 @@ export class MembershipManager }; }) .catch((e) => { - const update = this.actionUpdateFromErrors( - e, - MembershipActionType.SendJoinEvent, - this.sendMembershipMethodName(), - ); + const update = this.actionUpdateFromErrors(e, MembershipActionType.SendJoinEvent, "sendStateEvent"); if (update) return update; throw e; }); @@ -768,11 +744,7 @@ export class MembershipManager }; }) .catch((e) => { - const update = this.actionUpdateFromErrors( - e, - MembershipActionType.UpdateExpiry, - this.sendMembershipMethodName(), - ); + const update = this.actionUpdateFromErrors(e, MembershipActionType.UpdateExpiry, "sendStateEvent"); if (update) return update; throw e; @@ -786,11 +758,7 @@ export class MembershipManager return { replace: [] }; }) .catch((e) => { - const update = this.actionUpdateFromErrors( - e, - MembershipActionType.SendLeaveEvent, - this.sendMembershipMethodName(), - ); + const update = this.actionUpdateFromErrors(e, MembershipActionType.SendLeaveEvent, "sendStateEvent"); if (update) return update; throw e; }); @@ -857,7 +825,7 @@ export class MembershipManager return false; } - private actionUpdateFromErrors( + protected actionUpdateFromErrors( error: unknown, type: MembershipActionType, method: string, @@ -905,7 +873,7 @@ export class MembershipManager return createInsertActionUpdate(type, resendDelay); } - throw Error("Exceeded maximum retries for " + type + " attempts (client." + method + "): " + (error as Error)); + throw Error("Exceeded maximum retries for " + type + " attempts (client." + method + ")", { cause: error }); } /** @@ -1049,14 +1017,64 @@ export class MembershipManager } } -function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate { - return { - insert: [{ ts: Date.now() + (offset ?? 0), type }], - }; -} +/** + * Implementation of the Membership manager that uses sticky events + * rather than state events. + */ +export class StickyEventMembershipManager extends MembershipManager { + public constructor( + joinConfig: (SessionConfig & MembershipConfig) | undefined, + room: Pick, + private readonly clientWithSticky: Pick< + MatrixClient, + | "getUserId" + | "getDeviceId" + | "sendStateEvent" + | "_unstable_sendDelayedStateEvent" + | "_unstable_updateDelayedEvent" + | "_unstable_sendStickyEvent" + | "_unstable_sendStickyDelayedEvent" + >, + getOldestMembership: () => CallMembership | undefined, + sessionDescription: SessionDescription, + parentLogger?: Logger, + ) { + super(joinConfig, room, clientWithSticky, getOldestMembership, sessionDescription, parentLogger); + } -function createReplaceActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate { - return { - replace: [{ ts: Date.now() + (offset ?? 0), type }], - }; + protected clientSendDelayedEvent: (myMembership: EmptyObject) => Promise = ( + myMembership, + ) => + this.clientWithSticky._unstable_sendStickyDelayedEvent( + this.room.roomId, + STICK_DURATION_MS, + { delay: this.delayedLeaveEventDelayMs }, + null, + EventType.GroupCallMemberPrefix, + Object.assign(myMembership, { sticky_key: this.stateKey }), + ); + + protected clientSendMembership: (myMembership: SessionMembershipData | EmptyObject) => Promise = + (myMembership) => + this.clientWithSticky._unstable_sendStickyEvent( + this.room.roomId, + STICK_DURATION_MS, + null, + EventType.GroupCallMemberPrefix, + Object.assign(myMembership, { sticky_key: this.stateKey }), + ); + + protected actionUpdateFromErrors( + error: unknown, + type: MembershipActionType, + method: string, + ): ActionUpdate | undefined { + // Override method name. + if (method === "sendStateEvent") { + method = "_unstable_sendStickyEvent"; + } else if (method === "_unstable_sendDelayedStateEvent") { + method = "_unstable_sendStickyDelayedEvent"; + } + return super.actionUpdateFromErrors(error, type, method); + } }