From 41a2f477d5977c1d7d9b359839f941166caccb4e Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 19 Nov 2024 09:55:12 -0500 Subject: [PATCH 01/30] WIP --- spec/unit/matrixrtc/LivekitFocus.spec.ts | 8 +- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 4 +- src/matrixrtc/CallMembership.ts | 12 +-- src/matrixrtc/IMembershipManager.ts | 5 +- src/matrixrtc/LivekitFocus.ts | 4 +- src/matrixrtc/MatrixRTCSession.ts | 18 +--- src/matrixrtc/MembershipManager.ts | 98 +++++--------------- 7 files changed, 40 insertions(+), 109 deletions(-) diff --git a/spec/unit/matrixrtc/LivekitFocus.spec.ts b/spec/unit/matrixrtc/LivekitFocus.spec.ts index 728d6a68de6..2653511638b 100644 --- a/spec/unit/matrixrtc/LivekitFocus.spec.ts +++ b/spec/unit/matrixrtc/LivekitFocus.spec.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { isLivekitFocus, isLivekitFocusActive, isLivekitFocusConfig } from "../../../src/matrixrtc/LivekitFocus"; +import { isLivekitFocus, isLivekitFocusSelection, isLivekitFocusConfig } from "../../../src/matrixrtc/LivekitFocus"; describe("LivekitFocus", () => { it("isLivekitFocus", () => { @@ -38,13 +38,13 @@ describe("LivekitFocus", () => { }); it("isLivekitFocusActive", () => { expect( - isLivekitFocusActive({ + isLivekitFocusSelection({ type: "livekit", focus_selection: "oldest_membership", }), ).toBeTruthy(); - expect(isLivekitFocusActive({ type: "livekit" })).toBeFalsy(); - expect(isLivekitFocusActive({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy(); + expect(isLivekitFocusSelection({ type: "livekit" })).toBeFalsy(); + expect(isLivekitFocusSelection({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy(); }); it("isLivekitFocusConfig", () => { expect( diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 7a33c3017c2..e47189b6033 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -247,7 +247,7 @@ describe("MatrixRTCSession", () => { type: "livekit", focus_selection: "oldest_membership", }); - expect(sess.getActiveFocus()).toBe(firstPreferredFocus); + expect(sess.resolveActiveFocus()).toBe(firstPreferredFocus); jest.useRealTimers(); }); it("does not provide focus if the selection method is unknown", () => { @@ -267,7 +267,7 @@ describe("MatrixRTCSession", () => { type: "livekit", focus_selection: "unknown", }); - expect(sess.getActiveFocus()).toBe(undefined); + expect(sess.resolveActiveFocus()).toBe(undefined); }); }); diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index b3f50f02867..8bd894045c6 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -17,7 +17,6 @@ limitations under the License. import { type MatrixEvent } from "../matrix.ts"; import { deepCompare } from "../utils.ts"; import { type Focus } from "./focus.ts"; -import { isLivekitFocusActive } from "./LivekitFocus.ts"; import { type SessionDescription } from "./MatrixRTCSession.ts"; /** @@ -61,7 +60,7 @@ export type SessionMembershipData = { * A list of possible foci this uses knows about. One of them might be used based on the focus_active * selection system. */ - foci_preferred: Focus[]; + foci_preferred?: Focus[]; /** * Optional field that contains the creation of the session. If it is undefined the creation @@ -195,13 +194,10 @@ export class CallMembership { } public getPreferredFoci(): Focus[] { - return this.membershipData.foci_preferred; + return this.membershipData.foci_preferred ?? []; } - public getFocusSelection(): string | undefined { - const focusActive = this.membershipData.focus_active; - if (isLivekitFocusActive(focusActive)) { - return focusActive.focus_selection; - } + public getFocusActive(): Focus { + return this.membershipData.focus_active; } } diff --git a/src/matrixrtc/IMembershipManager.ts b/src/matrixrtc/IMembershipManager.ts index b1702ef8e33..b99c9a78638 100644 --- a/src/matrixrtc/IMembershipManager.ts +++ b/src/matrixrtc/IMembershipManager.ts @@ -96,8 +96,7 @@ export interface IMembershipManager */ onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise; /** - * The used active focus in the currently joined session. - * @returns the used active focus in the currently joined session or undefined if not joined. + * Determines the active focus used by the given session member, or undefined if not joined. */ - getActiveFocus(): Focus | undefined; + resolveActiveFocus(member: CallMembership): Focus | undefined; } diff --git a/src/matrixrtc/LivekitFocus.ts b/src/matrixrtc/LivekitFocus.ts index 66d8a0a50be..a799a0b7b0b 100644 --- a/src/matrixrtc/LivekitFocus.ts +++ b/src/matrixrtc/LivekitFocus.ts @@ -31,9 +31,9 @@ export interface LivekitFocus extends LivekitFocusConfig { export const isLivekitFocus = (object: any): object is LivekitFocus => isLivekitFocusConfig(object) && "livekit_alias" in object; -export interface LivekitFocusActive extends Focus { +export interface LivekitFocusSelection extends Focus { type: "livekit"; focus_selection: "oldest_membership"; } -export const isLivekitFocusActive = (object: any): object is LivekitFocusActive => +export const isLivekitFocusSelection = (object: any): object is LivekitFocusSelection => object.type === "livekit" && "focus_selection" in object; diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 07f08c75cb7..b68ed65e7ba 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -606,28 +606,14 @@ export class MatrixRTCSession extends TypedEventEmitter< * @returns The focus that is currently in use to connect to this session. This is undefined * if the client is not connected to this session. */ - public getActiveFocus(): Focus | undefined { - return this.membershipManager?.getActiveFocus(); + public resolveActiveFocus(member: CallMembership): Focus | undefined { + return this.membershipManager?.resolveActiveFocus(member); } public getOldestMembership(): CallMembership | undefined { return this.memberships[0]; } - /** - * This method is used when the user is not yet connected to the Session but wants to know what focus - * the users in the session are using to make a decision how it wants/should connect. - * - * See also `getActiveFocus` - * @returns The focus which should be used when joining this session. - */ - public getFocusInUse(): Focus | undefined { - const oldestMembership = this.getOldestMembership(); - if (oldestMembership?.getFocusSelection() === "oldest_membership") { - return oldestMembership.getPreferredFoci()[0]; - } - } - /** * Re-emit an EncryptionKeyChanged event for each tracked encryption key. This can be used to export * the keys. diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index cdee3ee83a5..f582feaff6c 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -17,65 +17,18 @@ import { AbortError } from "p-retry"; import { EventType } from "../@types/event.ts"; import { UpdateDelayedEventAction } from "../@types/requests.ts"; -import { type MatrixClient } from "../client.ts"; -import { UnsupportedDelayedEventsEndpointError } from "../errors.ts"; +import type { MatrixClient } from "../client.ts"; import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts"; -import { type Logger, logger as rootLogger } from "../logger.ts"; -import { type Room } from "../models/room.ts"; -import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts"; -import { type Focus } from "./focus.ts"; +import { Room } from "../models/room.ts"; +import { CallMembership, DEFAULT_EXPIRE_DURATION, SessionMembershipData } from "./CallMembership.ts"; +import { Focus } from "./focus.ts"; +import { isLivekitFocusSelection } from "./LivekitFocus.ts"; +import { MembershipConfig, SessionDescription } from "./MatrixRTCSession.ts"; +import { TypedEventEmitter, UnsupportedDelayedEventsEndpointError } from "src/matrix.ts"; +import { IMembershipManager, MembershipManagerEvent, MembershipManagerEventHandlerMap } from "./IMembershipManager.ts"; +import { Logger, logger as rootLogger } from "src/logger.ts"; +import { ActionScheduler, ActionUpdate } from "./MembershipManagerActionScheduler.ts"; import { isMyMembership, Status } from "./types.ts"; -import { isLivekitFocusActive } from "./LivekitFocus.ts"; -import { type SessionDescription, type MembershipConfig } from "./MatrixRTCSession.ts"; -import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts"; -import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; -import { - MembershipManagerEvent, - type IMembershipManager, - type MembershipManagerEventHandlerMap, -} from "./IMembershipManager.ts"; - -/* MembershipActionTypes: - -On Join: ───────────────┐ ┌───────────────(1)───────────┐ - ▼ ▼ │ - ┌────────────────┐ │ - │SendDelayedEvent│ ──────(2)───┐ │ - └────────────────┘ │ │ - │(3) │ │ - ▼ │ │ - ┌─────────────┐ │ │ - ┌──────(4)───│SendJoinEvent│────(4)─────┐ │ │ - │ └─────────────┘ │ │ │ - │ ┌─────┐ ┌──────┐ │ │ │ - ▼ ▼ │ │ ▼ ▼ ▼ │ -┌────────────┐ │ │ ┌───────────────────┐ │ -│UpdateExpiry│ (s) (s)|RestartDelayedEvent│ │ -└────────────┘ │ │ └───────────────────┘ │ - │ │ │ │ │ │ - └─────┘ └──────┘ └───────┘ - -On Leave: ───────── STOP ALL ABOVE - ▼ - ┌────────────────────────────────┐ - │ SendScheduledDelayedLeaveEvent │ - └────────────────────────────────┘ - │(5) - ▼ - ┌──────────────┐ - │SendLeaveEvent│ - └──────────────┘ -(1) [Not found error] results in resending the delayed event -(2) [hasMemberEvent = true] Sending the delayed event if we - already have a call member event results jumping to the - RestartDelayedEvent loop directly -(3) [hasMemberEvent = false] if there is not call member event - sending it is the next step -(4) Both (UpdateExpiry and RestartDelayedEvent) actions are - scheduled when successfully sending the state event -(5) Only if delayed event sending failed (fallback) -(s) Successful restart/resend -*/ /** * The different types of actions the MembershipManager can take. @@ -262,22 +215,15 @@ export class MembershipManager return Promise.resolve(); } - public getActiveFocus(): Focus | undefined { - if (this.focusActive) { - // A livekit active focus - if (isLivekitFocusActive(this.focusActive)) { - if (this.focusActive.focus_selection === "oldest_membership") { - const oldestMembership = this.getOldestMembership(); - return oldestMembership?.getPreferredFoci()[0]; - } - } else { - this.logger.warn("Unknown own ActiveFocus type. This makes it impossible to connect to an SFU."); - } - } else { - // We do not understand the membership format (could be legacy). We default to oldestMembership - // Once there are other methods this is a hard error! + public resolveActiveFocus(member: CallMembership): Focus | undefined { + const data = member.getFocusActive(); + if (isLivekitFocusSelection(data) && data.focus_selection === "oldest_membership") { const oldestMembership = this.getOldestMembership(); - return oldestMembership?.getPreferredFoci()[0]; + if (member === oldestMembership) return member.getPreferredFoci()[0]; + if (oldestMembership !== undefined) return this.resolveActiveFocus(oldestMembership); + } else { + // This is a fully resolved focus config + return data; } } @@ -748,8 +694,12 @@ export class MembershipManager scope: "m.room", device_id: this.deviceId, expires, - focus_active: { type: "livekit", focus_selection: "oldest_membership" }, - foci_preferred: this.fociPreferred ?? [], + ...(this.focusActive === undefined + ? { + focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const, + foci_preferred: this.fociPreferred ?? [], + } + : { focus_active: this.focusActive }), }; } From 209eecd813f8522ee9e3ec8332ca88944f9962c6 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 27 Aug 2025 14:00:51 +0200 Subject: [PATCH 02/30] temp Signed-off-by: Timo K --- src/matrixrtc/MembershipManager.ts | 42 ++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index f582feaff6c..19271c40958 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -30,6 +30,48 @@ import { Logger, logger as rootLogger } from "src/logger.ts"; import { ActionScheduler, ActionUpdate } from "./MembershipManagerActionScheduler.ts"; import { isMyMembership, Status } from "./types.ts"; +/* MembershipActionTypes: + +On Join: ───────────────┐ ┌───────────────(1)───────────┐ + ▼ ▼ │ + ┌────────────────┐ │ + │SendDelayedEvent│ ──────(2)───┐ │ + └────────────────┘ │ │ + │(3) │ │ + ▼ │ │ + ┌─────────────┐ │ │ + ┌──────(4)───│SendJoinEvent│────(4)─────┐ │ │ + │ └─────────────┘ │ │ │ + │ ┌─────┐ ┌──────┐ │ │ │ + ▼ ▼ │ │ ▼ ▼ ▼ │ +┌────────────┐ │ │ ┌───────────────────┐ │ +│UpdateExpiry│ (s) (s)|RestartDelayedEvent│ │ +└────────────┘ │ │ └───────────────────┘ │ + │ │ │ │ │ │ + └─────┘ └──────┘ └───────┘ + +On Leave: ───────── STOP ALL ABOVE + ▼ + ┌────────────────────────────────┐ + │ SendScheduledDelayedLeaveEvent │ + └────────────────────────────────┘ + │(5) + ▼ + ┌──────────────┐ + │SendLeaveEvent│ + └──────────────┘ +(1) [Not found error] results in resending the delayed event +(2) [hasMemberEvent = true] Sending the delayed event if we + already have a call member event results jumping to the + RestartDelayedEvent loop directly +(3) [hasMemberEvent = false] if there is not call member event + sending it is the next step +(4) Both (UpdateExpiry and RestartDelayedEvent) actions are + scheduled when successfully sending the state event +(5) Only if delayed event sending failed (fallback) +(s) Successful restart/resend +*/ + /** * The different types of actions the MembershipManager can take. * @internal From 6156d4ce10f1f83235180513965199cb0feb207e Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 27 Aug 2025 14:41:07 +0200 Subject: [PATCH 03/30] Fix imports --- src/matrixrtc/MembershipManager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index 19271c40958..0f6111600e3 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -24,9 +24,9 @@ import { CallMembership, DEFAULT_EXPIRE_DURATION, SessionMembershipData } from " import { Focus } from "./focus.ts"; import { isLivekitFocusSelection } from "./LivekitFocus.ts"; import { MembershipConfig, SessionDescription } from "./MatrixRTCSession.ts"; -import { TypedEventEmitter, UnsupportedDelayedEventsEndpointError } from "src/matrix.ts"; +import { TypedEventEmitter, UnsupportedDelayedEventsEndpointError } from "../matrix.ts"; import { IMembershipManager, MembershipManagerEvent, MembershipManagerEventHandlerMap } from "./IMembershipManager.ts"; -import { Logger, logger as rootLogger } from "src/logger.ts"; +import { Logger, logger as rootLogger } from "../logger.ts"; import { ActionScheduler, ActionUpdate } from "./MembershipManagerActionScheduler.ts"; import { isMyMembership, Status } from "./types.ts"; From b61e39a81458fb02d76d384e9c4bbef30fcd516a Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 27 Aug 2025 15:30:36 +0200 Subject: [PATCH 04/30] Fix checkSessionsMembershipData thinking foci_preferred is required --- src/matrixrtc/CallMembership.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 8bd894045c6..b34644525a5 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -93,7 +93,7 @@ const checkSessionsMembershipData = (data: any, errors: string[]): data is Sessi if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string"); if (typeof data.application !== "string") errors.push(prefix + "application must be a string"); if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string"); - if (!Array.isArray(data.foci_preferred)) errors.push(prefix + "foci_preferred must be an array"); + if (data.foci_preferred !== undefined && !Array.isArray(data.foci_preferred)) errors.push(prefix + "foci_preferred must be an array"); // optional parameters if (data.created_ts && typeof data.created_ts !== "number") errors.push(prefix + "created_ts must be number"); From 29879e83842cc6428fd24b95bf3edffb066c116f Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 30 Sep 2025 14:14:31 +0200 Subject: [PATCH 05/30] incorporate CallMembership changes - rename Focus -> Transport - add RtcMembershipData (next to `sessionMembershipData`) - make `new CallMembership` initializable with both - move oldest member calculation into CallMembership Signed-off-by: Timo K --- spec/unit/matrixrtc/CallMembership.spec.ts | 140 ++++++++- spec/unit/matrixrtc/LivekitFocus.spec.ts | 24 +- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 10 +- spec/unit/matrixrtc/MembershipManager.spec.ts | 189 ++++++------ src/@types/event.ts | 8 +- src/matrixrtc/CallMembership.ts | 277 +++++++++++++++--- src/matrixrtc/IMembershipManager.ts | 12 +- src/matrixrtc/LivekitFocus.ts | 23 +- src/matrixrtc/MatrixRTCSession.ts | 114 ++++--- src/matrixrtc/MatrixRTCSessionManager.ts | 4 +- src/matrixrtc/MembershipManager.ts | 120 ++++---- src/matrixrtc/focus.ts | 25 -- src/matrixrtc/index.ts | 1 - src/matrixrtc/types.ts | 8 + src/models/room-member.ts | 2 +- 15 files changed, 650 insertions(+), 307 deletions(-) delete mode 100644 src/matrixrtc/focus.ts diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index cfa98ebd2bf..528f46cace3 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -19,6 +19,7 @@ import { CallMembership, type SessionMembershipData, DEFAULT_EXPIRE_DURATION, + type RtcMembershipData, } from "../../../src/matrixrtc/CallMembership"; import { membershipTemplate } from "./mocks"; @@ -44,7 +45,7 @@ describe("CallMembership", () => { scope: "m.room", application: "m.call", device_id: "AAAAAAA", - focus_active: { type: "livekit" }, + focus_active: { type: "livekit", focus_selection: "oldest_membership" }, foci_preferred: [{ type: "livekit" }], }; @@ -94,11 +95,138 @@ describe("CallMembership", () => { it("returns preferred foci", () => { const fakeEvent = makeMockEvent(); const mockFocus = { type: "this_is_a_mock_focus" }; - const membership = new CallMembership( - fakeEvent, - Object.assign({}, membershipTemplate, { foci_preferred: [mockFocus] }), - ); - expect(membership.getPreferredFoci()).toEqual([mockFocus]); + const membership = new CallMembership(fakeEvent, { ...membershipTemplate, foci_preferred: [mockFocus] }); + expect(membership.transports).toEqual([mockFocus]); + }); + describe("getTransport", () => { + const mockFocus = { type: "this_is_a_mock_focus" }; + const oldestMembership = new CallMembership(makeMockEvent(), 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" }, + }); + + // if we are the oldest member we use our focus. + expect(membership.getTransport(membership)).toStrictEqual(mockFocus); + + // If there is an older member we use its focus. + expect(membership.getTransport(oldestMembership)).toBe(membershipTemplate.foci_preferred[0]); + }); + + 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: "multi_sfu" }, + }); + + // if we are the oldest member 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); + }); + }); + }); + + describe("RtcMembershipData", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const membershipTemplate: RtcMembershipData = { + slot_id: "m.call#1", + application: { type: "m.call" }, + member: { user_id: "@alice:example.org", device_id: "AAAAAAA", id: "xyzHASHxyz" }, + rtc_transports: [{ type: "livekit" }], + versions: [], + }; + + it("rejects membership with no slot_id", () => { + expect(() => { + new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: undefined }); + }).toThrow(); + }); + + it("rejects membership with no application", () => { + expect(() => { + new CallMembership(makeMockEvent(), { ...membershipTemplate, application: undefined }); + }).toThrow(); + }); + + it("rejects membership with incorrect application", () => { + expect(() => { + new CallMembership(makeMockEvent(), { + ...membershipTemplate, + application: { wrong_type_key: "unknown" }, + }); + }).toThrow(); + }); + + it("rejects membership with no member", () => { + expect(() => { + new CallMembership(makeMockEvent(), { ...membershipTemplate, member: undefined }); + }).toThrow(); + }); + + it("rejects membership with incorrect member", () => { + expect(() => { + new CallMembership(makeMockEvent(), { ...membershipTemplate, member: { i: "test" } }); + }).toThrow(); + expect(() => { + new CallMembership(makeMockEvent(), { + ...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" }, + }); + }).toThrow(); + expect(() => { + new CallMembership(makeMockEvent(), { + ...membershipTemplate, + member: { id: "test", device_id: "test", user_id: "@@test" }, + }); + }).toThrow(); + expect(() => { + new CallMembership(makeMockEvent(), { + ...membershipTemplate, + member: { id: "test", device_id: "test", user_id: "@test:user.id" }, + }); + }).not.toThrow(); + }); + + it("considers memberships unexpired if local age low enough", () => { + // TODO link prev event + }); + + it("considers memberships expired if local age large enough", () => { + // TODO link prev event + }); + + 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); + + // if we are the oldest member we use our focus. + expect(membership.getTransport(membership)).toStrictEqual({ type: "livekit" }); + + // If there is an older member we use our own focus focus. (RtcMembershipData always uses multi sfu) + expect(membership.getTransport(oldestMembership)).toStrictEqual({ type: "livekit" }); + }); }); }); diff --git a/spec/unit/matrixrtc/LivekitFocus.spec.ts b/spec/unit/matrixrtc/LivekitFocus.spec.ts index 2653511638b..7da0aebb575 100644 --- a/spec/unit/matrixrtc/LivekitFocus.spec.ts +++ b/spec/unit/matrixrtc/LivekitFocus.spec.ts @@ -14,26 +14,30 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { isLivekitFocus, isLivekitFocusSelection, isLivekitFocusConfig } from "../../../src/matrixrtc/LivekitFocus"; +import { + isLivekitTransport, + isLivekitFocusSelection, + isLivekitTransportConfig, +} from "../../../src/matrixrtc/LivekitFocus"; describe("LivekitFocus", () => { it("isLivekitFocus", () => { expect( - isLivekitFocus({ + isLivekitTransport({ type: "livekit", livekit_service_url: "http://test.com", livekit_alias: "test", }), ).toBeTruthy(); - expect(isLivekitFocus({ type: "livekit" })).toBeFalsy(); + expect(isLivekitTransport({ type: "livekit" })).toBeFalsy(); expect( - isLivekitFocus({ type: "not-livekit", livekit_service_url: "http://test.com", livekit_alias: "test" }), + isLivekitTransport({ type: "not-livekit", livekit_service_url: "http://test.com", livekit_alias: "test" }), ).toBeFalsy(); expect( - isLivekitFocus({ type: "livekit", other_service_url: "http://test.com", livekit_alias: "test" }), + isLivekitTransport({ type: "livekit", other_service_url: "http://test.com", livekit_alias: "test" }), ).toBeFalsy(); expect( - isLivekitFocus({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }), + isLivekitTransport({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }), ).toBeFalsy(); }); it("isLivekitFocusActive", () => { @@ -48,13 +52,13 @@ describe("LivekitFocus", () => { }); it("isLivekitFocusConfig", () => { expect( - isLivekitFocusConfig({ + isLivekitTransportConfig({ type: "livekit", livekit_service_url: "http://test.com", }), ).toBeTruthy(); - expect(isLivekitFocusConfig({ type: "livekit" })).toBeFalsy(); - expect(isLivekitFocusConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy(); - expect(isLivekitFocusConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy(); + expect(isLivekitTransportConfig({ type: "livekit" })).toBeFalsy(); + expect(isLivekitTransportConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy(); + expect(isLivekitTransportConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy(); }); }); diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 71d46548355..390a201fe46 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -53,12 +53,12 @@ describe("MatrixRTCSession", () => { sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess?.memberships.length).toEqual(1); - expect(sess?.memberships[0].sessionDescription.id).toEqual(""); + expect(sess?.memberships[0].slotDescription.id).toEqual(""); expect(sess?.memberships[0].scope).toEqual("m.room"); expect(sess?.memberships[0].application).toEqual("m.call"); expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); expect(sess?.memberships[0].isExpired()).toEqual(false); - expect(sess?.sessionDescription.id).toEqual(""); + expect(sess?.slotDescription.id).toEqual(""); }); it("ignores memberships where application is not m.call", () => { @@ -268,7 +268,9 @@ describe("MatrixRTCSession", () => { type: "livekit", focus_selection: "oldest_membership", }); - expect(sess.resolveActiveFocus()).toBe(firstPreferredFocus); + expect(sess.resolveActiveFocus(sess.memberships.find((m) => m.deviceId === "old"))).toBe( + firstPreferredFocus, + ); jest.useRealTimers(); }); it("does not provide focus if the selection method is unknown", () => { @@ -288,7 +290,7 @@ describe("MatrixRTCSession", () => { type: "livekit", focus_selection: "unknown", }); - expect(sess.resolveActiveFocus()).toBe(undefined); + expect(sess.resolveActiveFocus(sess.memberships.find((m) => m.deviceId === "old"))).toBe(undefined); }); }); diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index c22ab18390c..01eb5856bfc 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -27,12 +27,11 @@ import { import { MembershipManagerEvent, Status, - type Focus, - type LivekitFocusActive, + type Transport, type SessionMembershipData, + type LivekitFocusSelection, } from "../../../src/matrixrtc"; import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks"; -import { logger } from "../../../src/logger.ts"; import { MembershipManager } from "../../../src/matrixrtc/MembershipManager.ts"; /** @@ -76,11 +75,11 @@ const callSession = { id: "", application: "m.call" }; describe("MembershipManager", () => { let client: MockClient; let room: Room; - const focusActive: LivekitFocusActive = { + const focusActive: LivekitFocusSelection = { focus_selection: "oldest_membership", type: "livekit", }; - const focus: Focus = { + const focus: Transport = { type: "livekit", livekit_service_url: "https://active.url", livekit_alias: "!active:active.url", @@ -104,12 +103,12 @@ describe("MembershipManager", () => { describe("isActivated()", () => { it("defaults to false", () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); expect(manager.isActivated()).toEqual(false); }); it("returns true after join()", () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); manager.join([]); expect(manager.isActivated()).toEqual(true); }); @@ -123,8 +122,8 @@ describe("MembershipManager", () => { const updateDelayedEventHandle = createAsyncHandle(client._unstable_updateDelayedEvent as Mock); // Test - const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession); - memberManager.join([focus], focusActive); + const memberManager = new MembershipManager(undefined, room, client, callSession); + memberManager.join([focus], undefined); // expects await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" })); expect(client.sendStateEvent).toHaveBeenCalledWith( @@ -152,8 +151,45 @@ describe("MembershipManager", () => { expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); }); + it("sends a rtc membership event when using `useRtcMemberFormat`", async () => { + // Spys/Mocks + + const updateDelayedEventHandle = createAsyncHandle(client._unstable_updateDelayedEvent as Mock); + + // Test + const memberManager = new MembershipManager({ useRtcMemberFormat: true }, room, client, callSession); + memberManager.join([], focus); + // expects + await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" })); + expect(client.sendStateEvent).toHaveBeenCalledWith( + room.roomId, + "org.matrix.msc3401.call.member", + { + application: { type: "m.call", id: "" }, + member: { + user_id: "@alice:example.org", + id: "_@alice:example.org_AAAAAAA_m.call", + device_id: "AAAAAAA", + }, + slot_id: "m.call#", + rtc_transports: [focus], + versions: [], + }, + "_@alice:example.org_AAAAAAA_m.call", + ); + updateDelayedEventHandle.resolve?.(); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( + room.roomId, + { delay: 8000 }, + "org.matrix.msc3401.call.member", + {}, + "_@alice:example.org_AAAAAAA_m.call", + ); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); + }); + it("reschedules delayed leave event if sending state cancels it", async () => { - const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession); + const memberManager = new MembershipManager(undefined, room, client, callSession); const waitForSendState = waitForMockCall(client.sendStateEvent); const waitForUpdateDelaye = waitForMockCallOnce( client._unstable_updateDelayedEvent, @@ -228,10 +264,9 @@ describe("MembershipManager", () => { }, room, client, - () => undefined, callSession, ); - manager.join([focus], focusActive); + manager.join([focus]); await sendDelayedStateExceedAttempt.then(); // needed to resolve after the send attempt catches await sendDelayedStateAttempt; @@ -286,8 +321,8 @@ describe("MembershipManager", () => { describe("delayed leave event", () => { it("does not try again to schedule a delayed leave event if not supported", () => { const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock); - const manager = new MembershipManager({}, room, client, () => undefined, callSession); - manager.join([focus], focusActive); + const manager = new MembershipManager({}, room, client, callSession); + manager.join([focus]); delayedHandle.reject?.( new UnsupportedDelayedEventsEndpointError( "Server does not support the delayed events API", @@ -298,21 +333,15 @@ describe("MembershipManager", () => { }); it("does try to schedule a delayed leave event again if rate limited", async () => { const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock); - const manager = new MembershipManager({}, room, client, () => undefined, callSession); - manager.join([focus], focusActive); + const manager = new MembershipManager({}, room, client, callSession); + manager.join([focus]); delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined)); await jest.advanceTimersByTimeAsync(5000); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); }); it("uses delayedLeaveEventDelayMs from config", () => { - const manager = new MembershipManager( - { delayedLeaveEventDelayMs: 123456 }, - room, - client, - () => undefined, - callSession, - ); - manager.join([focus], focusActive); + const manager = new MembershipManager({ delayedLeaveEventDelayMs: 123456 }, room, client, callSession); + manager.join([focus]); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( room.roomId, { delay: 123456 }, @@ -329,11 +358,11 @@ describe("MembershipManager", () => { { delayedLeaveEventRestartMs: RESTART_DELAY }, room, client, - () => undefined, + callSession, ); // Join with the membership manager - manager.join([focus], focusActive); + manager.join([focus]); expect(manager.status).toBe(Status.Connecting); // Let the scheduler run one iteration so that we can send the join state event await jest.runOnlyPendingTimersAsync(); @@ -367,11 +396,11 @@ describe("MembershipManager", () => { { membershipEventExpiryMs: 1234567 }, room, client, - () => undefined, + callSession, ); - manager.join([focus], focusActive); + manager.join([focus]); await waitForMockCall(client.sendStateEvent); expect(client.sendStateEvent).toHaveBeenCalledWith( room.roomId, @@ -393,11 +422,11 @@ describe("MembershipManager", () => { }); it("does nothing if join called when already joined", async () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); - manager.join([focus], focusActive); + const manager = new MembershipManager({}, room, client, callSession); + manager.join([focus]); await waitForMockCall(client.sendStateEvent); expect(client.sendStateEvent).toHaveBeenCalledTimes(1); - manager.join([focus], focusActive); + manager.join([focus]); expect(client.sendStateEvent).toHaveBeenCalledTimes(1); }); }); @@ -405,16 +434,16 @@ describe("MembershipManager", () => { describe("leave()", () => { // TODO add rate limit cases. it("resolves delayed leave event when leave is called", async () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); - manager.join([focus], focusActive); + const manager = new MembershipManager({}, room, client, callSession); + manager.join([focus]); await jest.advanceTimersByTimeAsync(1); await manager.leave(); expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", "send"); expect(client.sendStateEvent).toHaveBeenCalled(); }); it("send leave event when leave is called and resolving delayed leave fails", async () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); - manager.join([focus], focusActive); + const manager = new MembershipManager({}, room, client, callSession); + manager.join([focus]); await jest.advanceTimersByTimeAsync(1); (client._unstable_updateDelayedEvent as Mock).mockRejectedValue("unknown"); await manager.leave(); @@ -428,60 +457,16 @@ describe("MembershipManager", () => { ); }); it("does nothing if not joined", () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); expect(async () => await manager.leave()).not.toThrow(); expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); expect(client.sendStateEvent).not.toHaveBeenCalled(); }); }); - describe("getsActiveFocus", () => { - it("gets the correct active focus with oldest_membership", () => { - const getOldestMembership = jest.fn(); - const manager = new MembershipManager({}, room, client, getOldestMembership, callSession); - // Before joining the active focus should be undefined (see FocusInUse on MatrixRTCSession) - expect(manager.getActiveFocus()).toBe(undefined); - manager.join([focus], focusActive); - // After joining we want our own focus to be the one we select. - getOldestMembership.mockReturnValue( - mockCallMembership( - { - ...membershipTemplate, - foci_preferred: [ - { - livekit_alias: "!active:active.url", - livekit_service_url: "https://active.url", - type: "livekit", - }, - ], - user_id: client.getUserId()!, - device_id: client.getDeviceId()!, - created_ts: 1000, - }, - room.roomId, - ), - ); - expect(manager.getActiveFocus()).toStrictEqual(focus); - getOldestMembership.mockReturnValue( - mockCallMembership( - Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }), - room.roomId, - ), - ); - // If there is an older member we use its focus. - expect(manager.getActiveFocus()).toBe(membershipTemplate.foci_preferred[0]); - }); - - it("does not provide focus if the selection method is unknown", () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); - manager.join([focus], Object.assign(focusActive, { type: "unknown_type" })); - expect(manager.getActiveFocus()).toBe(undefined); - }); - }); - describe("onRTCSessionMemberUpdate()", () => { it("does nothing if not joined", async () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]); await jest.advanceTimersToNextTimerAsync(); expect(client.sendStateEvent).not.toHaveBeenCalled(); @@ -489,7 +474,7 @@ describe("MembershipManager", () => { expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled(); }); it("does nothing if own membership still present", async () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); manager.join([focus], focusActive); await jest.advanceTimersByTimeAsync(1); const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2]; @@ -513,7 +498,7 @@ describe("MembershipManager", () => { expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled(); }); it("recreates membership if it is missing", async () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); manager.join([focus], focusActive); await jest.advanceTimersByTimeAsync(1); // clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` @@ -531,7 +516,7 @@ describe("MembershipManager", () => { }); it("updates the UpdateExpiry entry in the action scheduler", async () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); manager.join([focus], focusActive); await jest.advanceTimersByTimeAsync(1); // clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` @@ -564,7 +549,6 @@ describe("MembershipManager", () => { { delayedLeaveEventRestartMs: 10_000, delayedLeaveEventDelayMs: 30_000 }, room, client, - () => undefined, { id: "", application: "m.call" }, ); manager.join([focus], focusActive); @@ -596,7 +580,7 @@ describe("MembershipManager", () => { { membershipEventExpiryMs: expire, membershipEventExpiryHeadroomMs: headroom }, room, client, - () => undefined, + { id: "", application: "m.call" }, ); manager.join([focus], focusActive); @@ -621,14 +605,14 @@ describe("MembershipManager", () => { describe("status updates", () => { it("starts 'Disconnected'", () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); expect(manager.status).toBe(Status.Disconnected); }); it("emits 'Connection' and 'Connected' after join", async () => { const handleDelayedEvent = createAsyncHandle(client._unstable_sendDelayedStateEvent); const handleStateEvent = createAsyncHandle(client.sendStateEvent); - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); expect(manager.status).toBe(Status.Disconnected); const connectEmit = jest.fn(); manager.on(MembershipManagerEvent.StatusChanged, connectEmit); @@ -642,7 +626,7 @@ describe("MembershipManager", () => { expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected); }); it("emits 'Disconnecting' and 'Disconnected' after leave", async () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); const connectEmit = jest.fn(); manager.on(MembershipManagerEvent.StatusChanged, connectEmit); manager.join([focus], focusActive); @@ -658,7 +642,7 @@ describe("MembershipManager", () => { it("sends retry if call membership event is still valid at time of retry", async () => { const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent); - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); manager.join([focus], focusActive); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); @@ -685,7 +669,7 @@ describe("MembershipManager", () => { new Headers({ "Retry-After": "1" }), ), ); - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); // Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the // RateLimit error. manager.join([focus], focusActive); @@ -705,7 +689,7 @@ describe("MembershipManager", () => { it("abandons retry loop if leave() was called before sending state event", async () => { const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent); - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); manager.join([focus], focusActive); handle.reject?.( new MatrixError( @@ -740,7 +724,7 @@ describe("MembershipManager", () => { new Headers({ "Retry-After": "1" }), ), ); - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); manager.join([focus], focusActive); // Hit rate limit @@ -773,7 +757,7 @@ describe("MembershipManager", () => { new Headers({ "Retry-After": "2" }), ), ); - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); manager.join([focus], focusActive, delayEventSendError); for (let i = 0; i < 10; i++) { @@ -793,7 +777,7 @@ describe("MembershipManager", () => { new Headers({ "Retry-After": "1" }), ), ); - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); manager.join([focus], focusActive, delayEventRestartError); for (let i = 0; i < 10; i++) { @@ -804,7 +788,7 @@ describe("MembershipManager", () => { it("falls back to using pure state events when some error occurs while sending delayed events", async () => { const unrecoverableError = jest.fn(); (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue(new HTTPError("unknown", 601)); - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); manager.join([focus], focusActive, unrecoverableError); await waitForMockCall(client.sendStateEvent); expect(unrecoverableError).not.toHaveBeenCalledWith(); @@ -817,7 +801,6 @@ describe("MembershipManager", () => { { networkErrorRetryMs: 1000, maximumNetworkErrorRetryCount: 7 }, room, client, - () => undefined, callSession, ); manager.join([focus], focusActive, unrecoverableError); @@ -836,7 +819,7 @@ describe("MembershipManager", () => { (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue( new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"), ); - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); manager.join([focus], focusActive, unrecoverableError); await jest.advanceTimersByTimeAsync(1); @@ -850,7 +833,7 @@ describe("MembershipManager", () => { { delayedLeaveEventDelayMs: 10000 }, room, client, - () => undefined, + callSession, ); const { promise: stuckPromise, reject: rejectStuckPromise } = Promise.withResolvers(); @@ -904,7 +887,7 @@ describe("MembershipManager", () => { describe("updateCallIntent()", () => { it("should fail if the user has not joined the call", async () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); // After joining we want our own focus to be the one we select. try { await manager.updateCallIntent("video"); @@ -913,7 +896,7 @@ describe("MembershipManager", () => { }); it("can adjust the intent", async () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); manager.join([]); expect(manager.isActivated()).toEqual(true); const membership = mockCallMembership({ ...membershipTemplate, user_id: client.getUserId()! }, room.roomId); @@ -926,7 +909,7 @@ describe("MembershipManager", () => { }); it("does nothing if the intent doesn't change", async () => { - const manager = new MembershipManager({ callIntent: "video" }, room, client, () => undefined, callSession); + const manager = new MembershipManager({ callIntent: "video" }, room, client, callSession); manager.join([]); expect(manager.isActivated()).toEqual(true); const membership = mockCallMembership( @@ -944,7 +927,7 @@ it("Should prefix log with MembershipManager used", () => { const client = makeMockClient("@alice:example.org", "AAAAAAA"); const room = makeMockRoom([membershipTemplate]); - const membershipManager = new MembershipManager(undefined, room, client, () => undefined, callSession, logger); + const membershipManager = new MembershipManager(undefined, room, client, callSession); const spy = jest.spyOn(console, "error"); // Double join diff --git a/src/@types/event.ts b/src/@types/event.ts index 6e4d0ddff19..7ec82777240 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -58,7 +58,7 @@ import { type ICallNotifyContent, } from "../matrixrtc/types.ts"; import { type M_POLL_END, type M_POLL_START, type PollEndEventContent, type PollStartEventContent } from "./polls.ts"; -import { type SessionMembershipData } from "../matrixrtc/CallMembership.ts"; +import { type RtcMembershipData, type SessionMembershipData } from "../matrixrtc/CallMembership.ts"; import { type LocalNotificationSettings } from "./local_notifications.ts"; import { type IPushRules } from "./PushRules.ts"; import { type SecretInfo, type SecretStorageKeyDescription } from "../secret-storage.ts"; @@ -368,7 +368,11 @@ export interface StateEvents { // MSC3401 [EventType.GroupCallPrefix]: IGroupCallRoomState; - [EventType.GroupCallMemberPrefix]: IGroupCallRoomMemberState | SessionMembershipData | EmptyObject; + [EventType.GroupCallMemberPrefix]: + | IGroupCallRoomMemberState + | SessionMembershipData + | RtcMembershipData + | EmptyObject; // MSC3089 [UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent; diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index cb20c567ed4..5da96294f23 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -14,11 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { type MatrixEvent } from "../matrix.ts"; +import { MXID_PATTERN } from "../models/room-member.ts"; import { deepCompare } from "../utils.ts"; -import { type Focus } from "./focus.ts"; -import { type SessionDescription } from "./MatrixRTCSession.ts"; -import { type RTCCallIntent } from "./types.ts"; +import { isLivekitFocusSelection, type LivekitFocusSelection } from "./LivekitFocus.ts"; +import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts"; +import { type RTCCallIntent, type Transport } from "./types.ts"; +import { type RelationType } from "src/types.ts"; +import { type MatrixEvent } from "../models/event.ts"; /** * The default duration in milliseconds that a membership is considered valid for. @@ -28,6 +30,91 @@ import { type RTCCallIntent } from "./types.ts"; 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 { + "slot_id": string; + "member": Member; + "m.relates_to"?: { + event_id: string; + rel_type: RelationType.Reference; + }; + "application": { + type: string; + // other application specific keys + [key: string]: any; + }; + "rtc_transports": Transport[]; + "versions": string[]; + "created_ts"?: number; + "sticky_key"?: string; + /** + * The intent of the call from the perspective of this user. This may be an audio call, video call or + * something else. + */ + "m.call.intent"?: RTCCallIntent; +} + +const checkRtcMembershipData = ( + data: Partial>, + errors: string[], +): data is RtcMembershipData => { + const prefix = "Malformed rtc membership event: "; + + // required fields + if (typeof data.slot_id !== "string") errors.push(prefix + "slot_id must be string"); + if (typeof data.member !== "object" || data.member === null) { + errors.push(prefix + "member must be an object"); + } else { + if (typeof data.member.user_id !== "string") errors.push(prefix + "member.user_id must be string"); + else if (!MXID_PATTERN.test(data.member.user_id)) errors.push(prefix + "member.user_id must be a valid mxid"); + if (typeof data.member.device_id !== "string") errors.push(prefix + "member.device_id must be string"); + if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string"); + } + if (typeof data.application !== "object" || data.application === null) { + errors.push(prefix + "application must be an object"); + } else { + if (typeof data.application.type !== "string") errors.push(prefix + "application.type must be a string"); + } + if (data.rtc_transports === undefined || !Array.isArray(data.rtc_transports)) { + errors.push(prefix + "rtc_transports must be an array"); + } else { + // validate that each transport has at least a string 'type' + for (const t of data.rtc_transports) { + if (typeof t !== "object" || typeof (t as any).type !== "string") { + errors.push(prefix + "rtc_transports entries must be objects with a string type"); + break; + } + } + } + if (data.versions === undefined || !Array.isArray(data.versions)) { + errors.push(prefix + "versions must be an array"); + } else if (!data.versions.every((v) => typeof v === "string")) { + errors.push(prefix + "versions must be an array of strings"); + } + + // optional fields + if (data.created_ts !== undefined && typeof data.created_ts !== "number") { + errors.push(prefix + "created_ts must be number"); + } + if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") { + errors.push(prefix + "sticky_key must be a string"); + } + if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") { + errors.push(prefix + "m.call.intent must be a string"); + } + if (data["m.relates_to"] !== undefined) { + const rel = data["m.relates_to"] as RtcMembershipData["m.relates_to"]; + if (typeof rel !== "object" || rel === null) { + errors.push(prefix + "m.relates_to must be an object if provided"); + } else { + if (typeof rel.event_id !== "string") errors.push(prefix + "m.relates_to.event_id must be a string"); + if (rel.rel_type !== "m.reference") errors.push(prefix + "m.relates_to.rel_type must be m.reference"); + } + } + + return errors.length === 0; +}; /** * MSC4143 (MatrixRTC) session membership data. @@ -55,13 +142,13 @@ export type SessionMembershipData = { /** * The focus selection system this user/membership is using. */ - "focus_active": Focus; + "focus_active": LivekitFocusSelection; /** - * A list of possible foci this uses knows about. One of them might be used based on the focus_active + * A list of possible foci this user knows about. One of them might be used based on the focus_active * selection system. */ - "foci_preferred"?: Focus[]; + "foci_preferred": Transport[]; /** * Optional field that contains the creation of the session. If it is undefined the creation @@ -76,7 +163,7 @@ export type SessionMembershipData = { /** * If the `application` = `"m.call"` this defines if it is a room or user owned call. - * There can always be one room scroped call but multiple user owned calls (breakout sessions) + * There can always be one room scoped call but multiple user owned calls (breakout sessions) */ "scope"?: CallScope; @@ -103,8 +190,12 @@ const checkSessionsMembershipData = ( if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string"); if (typeof data.application !== "string") errors.push(prefix + "application must be a string"); if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string"); - if (data.foci_preferred !== undefined && !Array.isArray(data.foci_preferred)) - {errors.push(prefix + "foci_preferred must be an array");} + if (data.focus_active !== undefined && !isLivekitFocusSelection(data.focus_active)) { + errors.push(prefix + "focus_active has an invalid type"); + } + if (data.foci_preferred !== undefined && !Array.isArray(data.foci_preferred)) { + errors.push(prefix + "foci_preferred must be an array"); + } // optional parameters if (data.created_ts !== undefined && typeof data.created_ts !== "number") { errors.push(prefix + "created_ts must be number"); @@ -120,28 +211,43 @@ const checkSessionsMembershipData = ( return errors.length === 0; }; +type MembershipData = { kind: "rtc"; data: RtcMembershipData } | { kind: "session"; data: SessionMembershipData }; export class CallMembership { public static equal(a: CallMembership, b: CallMembership): boolean { + if (a === undefined || b === undefined) return a === b; return deepCompare(a.membershipData, b.membershipData); } - private membershipData: SessionMembershipData; + + private membershipData: MembershipData; public constructor( private parentEvent: MatrixEvent, data: any, ) { const sessionErrors: string[] = []; - if (!checkSessionsMembershipData(data, sessionErrors)) { + const rtcErrors: string[] = []; + if (checkSessionsMembershipData(data, sessionErrors)) { + this.membershipData = { kind: "session", data }; + } else if (checkRtcMembershipData(data, rtcErrors)) { + this.membershipData = { kind: "rtc", data }; + } else { throw Error( - `unknown CallMembership data. Does not match MSC4143 call.member (${sessionErrors.join(" & ")}) events this could be a legacy membership event: (${data})`, + `unknown CallMembership data.` + + `Does not match MSC4143 call.member (${sessionErrors.join(" & ")})\n` + + `Does not match MSC4143 rtc.member (${rtcErrors.join(" & ")})\n` + + `events this could be a legacy membership event: (${data})`, ); - } else { - this.membershipData = data; } } public get sender(): string | undefined { - return this.parentEvent.getSender(); + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return data.member.user_id; + case "session": + return this.parentEvent.getSender(); + } } public get eventId(): string | undefined { @@ -149,77 +255,156 @@ export class CallMembership { } /** - * @deprecated Use sessionDescription.id instead. + * The slot id to find all member building one session `slot_id` (format `{application}#{id}`). + * This is computed in case SessionMembershipData is used. */ - public get callId(): string { - return this.membershipData.call_id; + public get slotId(): string { + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return data.slot_id; + case "session": + return slotDescriptionToId({ application: this.application, id: data.call_id }); + } } public get deviceId(): string { - return this.membershipData.device_id; + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return data.member.device_id; + case "session": + return data.device_id; + } } public get callIntent(): RTCCallIntent | undefined { - return this.membershipData["m.call.intent"]; + return this.membershipData.data["m.call.intent"]; } - public get sessionDescription(): SessionDescription { - return { - application: this.membershipData.application, - id: this.membershipData.call_id, - }; + /** + * Parsed `slot_id` (format `{application}#{id}`) into its components (application and id). + */ + public get slotDescription(): SlotDescription { + return slotIdToDescription(this.slotId); } - public get application(): string | undefined { - return this.membershipData.application; + public get application(): string { + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return data.application.type; + case "session": + return data.application; + } } public get scope(): CallScope | undefined { - return this.membershipData.scope; + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return undefined; + case "session": + return data.scope; + } } public get membershipID(): string { // the createdTs behaves equivalent to the membershipID. - // we only need the field for the legacy member envents where we needed to update them + // we only need the field for the legacy member events where we needed to update them // synapse ignores sending state events if they have the same content. - return this.createdTs().toString(); + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return data.member.id; + case "session": + return (this.createdTs() ?? "").toString(); + } } public createdTs(): number { - return this.membershipData.created_ts ?? this.parentEvent.getTs(); + 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.parentEvent.getTs(); + case "session": + return data.created_ts ?? this.parentEvent.getTs(); + } } /** * Gets the absolute expiry timestamp of the membership. * @returns The absolute expiry time of the membership as a unix timestamp in milliseconds or undefined if not applicable */ - public getAbsoluteExpiry(): number { - // TODO: calculate this from the MatrixRTCSession join configuration directly - return this.createdTs() + (this.membershipData.expires ?? DEFAULT_EXPIRE_DURATION); + public getAbsoluteExpiry(): number | undefined { + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return undefined; + case "session": + // TODO: calculate this from the MatrixRTCSession join configuration directly + return this.createdTs() + (data.expires ?? DEFAULT_EXPIRE_DURATION); + } } /** * @returns The number of milliseconds until the membership expires or undefined if applicable */ - public getMsUntilExpiry(): number { - // 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(); + public getMsUntilExpiry(): number | undefined { + const { kind } = this.membershipData; + switch (kind) { + case "rtc": + return undefined; + case "session": + // 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 { - return this.getMsUntilExpiry() <= 0; + const { kind } = this.membershipData; + switch (kind) { + case "rtc": + return false; + case "session": + return this.getMsUntilExpiry()! <= 0; + } } - public getPreferredFoci(): Focus[] { - return this.membershipData.foci_preferred ?? []; + /** + * + * @param oldestMembership For backwards compatibility with session membership (legacy). + * @returns + */ + public getTransport(oldestMembership: CallMembership): Transport | undefined { + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return data.rtc_transports[0]; + case "session": + switch (data.focus_active.focus_selection) { + case "oldest_membership": + if (CallMembership.equal(this, oldestMembership)) return data.foci_preferred[0]; + if (oldestMembership !== undefined) return oldestMembership.getTransport(oldestMembership); + break; + case "multi_sfu": + return data.foci_preferred[0]; + } + } } - - public getFocusActive(): Focus { - return this.membershipData.focus_active; + public get transports(): Transport[] { + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return data.rtc_transports; + case "session": + return data.foci_preferred; + } } } diff --git a/src/matrixrtc/IMembershipManager.ts b/src/matrixrtc/IMembershipManager.ts index fb0b3d3b5b8..cf6963fdc18 100644 --- a/src/matrixrtc/IMembershipManager.ts +++ b/src/matrixrtc/IMembershipManager.ts @@ -15,8 +15,7 @@ limitations under the License. */ import type { CallMembership } from "./CallMembership.ts"; -import type { Focus } from "./focus.ts"; -import type { RTCCallIntent, Status } from "./types.ts"; +import type { RTCCallIntent, Status, Transport } from "./types.ts"; import { type TypedEventEmitter } from "../models/typed-event-emitter.ts"; export enum MembershipManagerEvent { @@ -80,10 +79,11 @@ export interface IMembershipManager /** * Start sending all necessary events to make this user participate in the RTC session. * @param fociPreferred the list of preferred foci to use in the joined RTC membership event. - * @param fociActive the active focus to use in the joined RTC membership event. + * @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the + * membership manager will use multi sfu. Use `undefined` to not use `oldest_membership` selection based sfu. * @throws can throw if it exceeds a configured maximum retry. */ - join(fociPreferred: Focus[], fociActive?: Focus, onError?: (error: unknown) => void): void; + join(fociPreferred: Transport[], multiSfuFocus?: Transport, onError?: (error: unknown) => void): void; /** * Send all necessary events to make this user leave the RTC session. * @param timeout the maximum duration in ms until the promise is forced to resolve. @@ -95,10 +95,6 @@ export interface IMembershipManager * Call this if the MatrixRTC session members have changed. */ onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise; - /** - * Determines the active focus used by the given session member, or undefined if not joined. - */ - resolveActiveFocus(member: CallMembership): Focus | undefined; /** * Update the intent of a membership on the call (e.g. user is now providing a video feed) diff --git a/src/matrixrtc/LivekitFocus.ts b/src/matrixrtc/LivekitFocus.ts index a799a0b7b0b..6c17ffc6a6d 100644 --- a/src/matrixrtc/LivekitFocus.ts +++ b/src/matrixrtc/LivekitFocus.ts @@ -14,26 +14,33 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { type Focus } from "./focus.ts"; +import { type Transport } from "./types.ts"; -export interface LivekitFocusConfig extends Focus { +export interface LivekitTransportConfig extends Transport { type: "livekit"; livekit_service_url: string; } -export const isLivekitFocusConfig = (object: any): object is LivekitFocusConfig => +export const isLivekitTransportConfig = (object: any): object is LivekitTransportConfig => object.type === "livekit" && "livekit_service_url" in object; -export interface LivekitFocus extends LivekitFocusConfig { +export interface LivekitTransport extends LivekitTransportConfig { livekit_alias: string; } -export const isLivekitFocus = (object: any): object is LivekitFocus => - isLivekitFocusConfig(object) && "livekit_alias" in object; +export const isLivekitTransport = (object: any): object is LivekitTransport => + isLivekitTransportConfig(object) && "livekit_alias" in object; -export interface LivekitFocusSelection extends Focus { +/** + * Deprecated, this is just needed for the old focus active / focus fields of a call membership. + * Not needed for new implementations. + */ +export interface LivekitFocusSelection extends Transport { type: "livekit"; - focus_selection: "oldest_membership"; + focus_selection: "oldest_membership" | "multi_sfu"; } +/** + * deprecated see LivekitFocusSelection + */ export const isLivekitFocusSelection = (object: any): object is LivekitFocusSelection => object.type === "livekit" && "focus_selection" in object; diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 5ff40819d60..4fcc449f56a 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -24,17 +24,17 @@ import { KnownMembership } from "../@types/membership.ts"; import { type ISendEventResponse } from "../@types/requests.ts"; import { CallMembership } from "./CallMembership.ts"; import { RoomStateEvent } from "../models/room-state.ts"; -import { type Focus } from "./focus.ts"; import { MembershipManager } from "./MembershipManager.ts"; import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts"; import { deepCompare, logDurationSync } from "../utils.ts"; -import { - type Statistics, - type RTCNotificationType, - type Status, - type IRTCNotificationContent, - type ICallNotifyContent, - type RTCCallIntent, +import type { + Statistics, + RTCNotificationType, + Status, + IRTCNotificationContent, + ICallNotifyContent, + RTCCallIntent, + Transport, } from "./types.ts"; import { RoomKeyTransport } from "./RoomKeyTransport.ts"; import { @@ -103,10 +103,17 @@ export interface SessionConfig { /** * The session description is used to identify a session. Used in the state event. */ -export interface SessionDescription { +export interface SlotDescription { id: string; application: string; } +export function slotIdToDescription(slotId: string): SlotDescription { + const [application, id] = slotId.split("#"); + return { application, id }; +} +export function slotDescriptionToId(slotDescription: SlotDescription): string { + return `${slotDescription.application}#${slotDescription.id}`; +} // The names follow these principles: // - we use the technical term delay if the option is related to delayed events. @@ -185,6 +192,7 @@ export interface MembershipConfig { * but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.) */ delayedLeaveEventRestartLocalTimeoutMs?: number; + useRtcMemberFormat?: boolean; } export interface EncryptionConfig { @@ -241,7 +249,7 @@ export class MatrixRTCSession extends TypedEventEmitter< private membershipManager?: IMembershipManager; private encryptionManager?: IEncryptionManager; // The session Id of the call, this is the call_id of the call Member event. - private _callId: string | undefined; + private _slotId: string | undefined; private joinConfig?: SessionConfig; private logger: Logger; @@ -279,33 +287,53 @@ export class MatrixRTCSession extends TypedEventEmitter< * * It can be undefined since the callId is only known once the first membership joins. * The callId is the property that, per definition, groups memberships into one call. + * @deprecated use `slotId` instead. */ public get callId(): string | undefined { - return this._callId; + return this.slotDescription?.id; + } + /** + * The slotId of the call. + * `{application}#{appSpecificId}` + * It can be undefined since the slotId is only known once the first membership joins. + * The slotId is the property that, per definition, groups memberships into one call. + */ + public get slotId(): string | undefined { + return this._slotId; } /** * Returns all the call memberships for a room that match the provided `sessionDescription`, * oldest first. * - * @deprecated Use `MatrixRTCSession.sessionMembershipsForRoom` instead. + * @deprecated Use `MatrixRTCSession.sessionMembershipsForSlot` instead. */ public static callMembershipsForRoom( room: Pick, ): CallMembership[] { - return MatrixRTCSession.sessionMembershipsForRoom(room, { + return MatrixRTCSession.sessionMembershipsForSlot(room, { id: "", application: "m.call", }); } + /** + * @deprecated use `MatrixRTCSession.slotMembershipsForRoom` instead. + */ + public static sessionMembershipsForRoom( + room: Pick, + sessionDescription: SlotDescription, + ): CallMembership[] { + return this.sessionMembershipsForSlot(room, sessionDescription); + } + /** * Returns all the call memberships for a room that match the provided `sessionDescription`, * oldest first. */ - public static sessionMembershipsForRoom( + public static sessionMembershipsForSlot( room: Pick, - sessionDescription: SessionDescription, + slotDescription: SlotDescription, ): CallMembership[] { const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`); const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); @@ -338,9 +366,9 @@ export class MatrixRTCSession extends TypedEventEmitter< try { const membership = new CallMembership(memberEvent, membershipData); - if (!deepCompare(membership.sessionDescription, sessionDescription)) { + if (!deepCompare(membership.slotDescription, slotDescription)) { logger.info( - `Ignoring membership of user ${membership.sender} for a different session: ${JSON.stringify(membership.sessionDescription)}`, + `Ignoring membership of user ${membership.sender} for a different session: ${JSON.stringify(membership.slotDescription)}`, ); continue; } @@ -379,26 +407,29 @@ export class MatrixRTCSession extends TypedEventEmitter< * This method is an alias for `MatrixRTCSession.sessionForRoom` with * sessionDescription `{ id: "", application: "m.call" }`. * - * @deprecated Use `MatrixRTCSession.sessionForRoom` with sessionDescription `{ id: "", application: "m.call" }` instead. + * @deprecated Use `MatrixRTCSession.sessionForSlot` with sessionDescription `{ id: "", application: "m.call" }` instead. */ public static roomSessionForRoom(client: MatrixClient, room: Room): MatrixRTCSession { - const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, { id: "", application: "m.call" }); + const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(room, { id: "", application: "m.call" }); return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" }); } + /** + * @deprecated Use `MatrixRTCSession.sessionForSlot` instead. + */ + public static sessionForRoom(client: MatrixClient, room: Room, slotDescription: SlotDescription): MatrixRTCSession { + return this.sessionForSlot(client, room, slotDescription); + } + /** * Return the MatrixRTC session for the room. * This returned session can be used to find out if there are active sessions - * for the requested room and `sessionDescription`. + * for the requested room and `slotDescription`. */ - public static sessionForRoom( - client: MatrixClient, - room: Room, - sessionDescription: SessionDescription, - ): MatrixRTCSession { - const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, sessionDescription); + public static sessionForSlot(client: MatrixClient, room: Room, slotDescription: SlotDescription): MatrixRTCSession { + const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(room, slotDescription); - return new MatrixRTCSession(client, room, callMemberships, sessionDescription); + return new MatrixRTCSession(client, room, callMemberships, slotDescription); } /** @@ -445,13 +476,13 @@ export class MatrixRTCSession extends TypedEventEmitter< public memberships: CallMembership[], /** * The session description is used to define the exact session this object is tracking. - * A session is distinct from another session if one of those properties differ: `roomSubset.roomId`, `sessionDescription.application`, `sessionDescription.id`. + * A session is distinct from another session if one of those properties differ: `roomSubset.roomId`, `slotDescription.application`, `slotDescription.id`. */ - public readonly sessionDescription: SessionDescription, + public readonly slotDescription: SlotDescription, ) { super(); this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`); - this._callId = memberships[0]?.sessionDescription.id; + this._slotId = memberships[0]?.slotId; const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS); // TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate); @@ -497,7 +528,7 @@ export class MatrixRTCSession extends TypedEventEmitter< * or optionally other room members homeserver well known. * @param joinConfig - Additional configuration for the joined session. */ - public joinRoomSession(fociPreferred: Focus[], fociActive?: Focus, joinConfig?: JoinSessionConfig): void { + public joinRoomSession(fociPreferred: Transport[], fociActive?: Transport, joinConfig?: JoinSessionConfig): void { if (this.isJoined()) { this.logger.info(`Already joined to session in room ${this.roomSubset.roomId}: ignoring join call`); return; @@ -508,8 +539,7 @@ export class MatrixRTCSession extends TypedEventEmitter< joinConfig, this.roomSubset, this.client, - () => this.getOldestMembership(), - this.sessionDescription, + this.slotDescription, this.logger, ); @@ -608,12 +638,18 @@ export class MatrixRTCSession extends TypedEventEmitter< } /** - * Get the active focus from the current CallMemberState event + * Get the focus in use from a specific specified member. + * @param member The member for which to get the active focus. If undefined, the own membership is used. * @returns The focus that is currently in use to connect to this session. This is undefined * if the client is not connected to this session. - */ - public resolveActiveFocus(member: CallMembership): Focus | undefined { - return this.membershipManager?.resolveActiveFocus(member); + * @deprecated use `member.getTransport(session.getOldestMembership())` instead if you want to get the active transport for a specific member. + */ + public resolveActiveFocus(member?: CallMembership): Transport | undefined { + const oldestMembership = this.getOldestMembership(); + if (!oldestMembership) return undefined; + const m = member === undefined ? this.membershipManager?.ownMembership : member; + if (!m) return undefined; + return m.getTransport(oldestMembership); } public getOldestMembership(): CallMembership | undefined { @@ -763,9 +799,9 @@ export class MatrixRTCSession extends TypedEventEmitter< */ private recalculateSessionMembers = (): void => { const oldMemberships = this.memberships; - this.memberships = MatrixRTCSession.sessionMembershipsForRoom(this.room, this.sessionDescription); + this.memberships = MatrixRTCSession.sessionMembershipsForSlot(this.room, this.slotDescription); - this._callId = this._callId ?? this.memberships[0]?.sessionDescription.id; + this._slotId = this._slotId ?? this.memberships[0]?.slotId; const changed = oldMemberships.length != this.memberships.length || diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index cc25105d977..f2f49cc9136 100644 --- a/src/matrixrtc/MatrixRTCSessionManager.ts +++ b/src/matrixrtc/MatrixRTCSessionManager.ts @@ -20,7 +20,7 @@ import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { type Room } from "../models/room.ts"; import { type RoomState, RoomStateEvent } from "../models/room-state.ts"; import { type MatrixEvent } from "../models/event.ts"; -import { MatrixRTCSession, type SessionDescription } from "./MatrixRTCSession.ts"; +import { MatrixRTCSession, type SlotDescription } from "./MatrixRTCSession.ts"; import { EventType } from "../@types/event.ts"; export enum MatrixRTCSessionManagerEvents { @@ -56,7 +56,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter void): void { + public join(fociPreferred: Transport[], multiSfuFocus?: Transport, onError?: (error: unknown) => void): void { if (this.scheduler.running) { this.logger.error("MembershipManager is already running. Ignoring join request."); return; } this.fociPreferred = fociPreferred; - this.focusActive = focusActive; + this.rtcTransport = multiSfuFocus; this.leavePromiseResolvers = undefined; this.activated = true; this.oldStatus = this.status; @@ -266,18 +274,6 @@ export class MembershipManager return Promise.resolve(); } - public resolveActiveFocus(member: CallMembership): Focus | undefined { - const data = member.getFocusActive(); - if (isLivekitFocusSelection(data) && data.focus_selection === "oldest_membership") { - const oldestMembership = this.getOldestMembership(); - if (member === oldestMembership) return member.getPreferredFoci()[0]; - if (oldestMembership !== undefined) return this.resolveActiveFocus(oldestMembership); - } else { - // This is a fully resolved focus config - return data; - } - } - public async updateCallIntent(callIntent: RTCCallIntent): Promise { if (!this.activated || !this.ownMembership) { throw Error("You cannot update your intent before joining the call"); @@ -295,7 +291,6 @@ export class MembershipManager * @param joinConfig * @param room * @param client - * @param getOldestMembership */ public constructor( private joinConfig: (SessionConfig & MembershipConfig) | undefined, @@ -308,8 +303,7 @@ export class MembershipManager | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent" >, - private getOldestMembership: () => CallMembership | undefined, - public readonly sessionDescription: SessionDescription, + public readonly slotDescription: SlotDescription, parentLogger?: Logger, ) { super(); @@ -318,7 +312,7 @@ export class MembershipManager if (userId === null) throw Error("Missing userId in client"); if (deviceId === null) throw Error("Missing deviceId in client"); this.deviceId = deviceId; - this.stateKey = this.makeMembershipStateKey(userId, deviceId); + this.memberId = this.makeMembershipStateKey(userId, deviceId); this.state = MembershipManager.defaultState; this.callIntent = joinConfig?.callIntent; this.scheduler = new ActionScheduler((type): Promise => { @@ -364,9 +358,10 @@ export class MembershipManager } // Membership Event static parameters: private deviceId: string; - private stateKey: string; - private fociPreferred?: Focus[]; - private focusActive?: Focus; + private memberId: string; + /** @deprecated This will be removed in favor or rtcTransport becoming a list of actively used transports */ + private fociPreferred?: Transport[]; + private rtcTransport?: Transport; // Config: private delayedLeaveEventDelayMsOverride?: number; @@ -399,6 +394,9 @@ export class MembershipManager private get delayedLeaveEventRestartLocalTimeoutMs(): number { return this.joinConfig?.delayedLeaveEventRestartLocalTimeoutMs ?? 2000; } + private get useRtcMemberFormat(): boolean { + return this.joinConfig?.useRtcMemberFormat ?? false; + } // LOOP HANDLER: private async membershipLoopHandler(type: MembershipActionType): Promise { switch (type) { @@ -467,7 +465,7 @@ export class MembershipManager }, EventType.GroupCallMemberPrefix, {}, // leave event - this.stateKey, + this.memberId, ) .then((response) => { this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs; @@ -654,7 +652,7 @@ export class MembershipManager this.room.roomId, EventType.GroupCallMemberPrefix, this.makeMyMembership(this.membershipEventExpiryMs), - this.stateKey, + this.memberId, ) .then(() => { this.setAndEmitProbablyLeft(false); @@ -700,7 +698,7 @@ export class MembershipManager this.room.roomId, EventType.GroupCallMemberPrefix, this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration), - this.stateKey, + this.memberId, ) .then(() => { // Success, we reset retries and schedule update. @@ -724,7 +722,7 @@ export class MembershipManager } private async sendFallbackLeaveEvent(): Promise { return await this.client - .sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, {}, this.stateKey) + .sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, {}, this.memberId) .then(() => { this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent); this.state.hasMemberStateEvent = false; @@ -739,7 +737,7 @@ export class MembershipManager // HELPERS private makeMembershipStateKey(localUserId: string, localDeviceId: string): string { - const stateKey = `${localUserId}_${localDeviceId}_${this.sessionDescription.application}${this.sessionDescription.id}`; + const stateKey = `${localUserId}_${localDeviceId}_${this.slotDescription.application}${this.slotDescription.id}`; if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) { return stateKey; } else { @@ -750,24 +748,42 @@ export class MembershipManager /** * Constructs our own membership */ - private makeMyMembership(expires: number): SessionMembershipData { - const hasPreviousEvent = !!this.ownMembership; - return { - // TODO: use the new format for m.rtc.member events where call_id becomes session.id - "application": this.sessionDescription.application, - "call_id": this.sessionDescription.id, - "scope": "m.room", - "device_id": this.deviceId, - expires, - "m.call.intent": this.callIntent, - ...(this.focusActive === undefined - ? { - focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const, - foci_preferred: this.fociPreferred ?? [], - } - : { focus_active: this.focusActive }), - ...(hasPreviousEvent ? { created_ts: this.ownMembership?.createdTs() } : undefined), - }; + private makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData { + const ownMembership = this.ownMembership; + if (this.useRtcMemberFormat) { + const relationObject = ownMembership?.eventId + ? { "m.relation": { rel_type: RelationType.Reference, event_id: ownMembership?.eventId } } + : {}; + return { + application: { type: this.slotDescription.application, id: this.slotDescription.id }, + slot_id: slotDescriptionToId(this.slotDescription), + rtc_transports: this.rtcTransport ? [this.rtcTransport] : [], + member: { device_id: this.deviceId, user_id: this.client.getUserId()!, id: this.memberId }, + versions: [], + ...relationObject, + }; + } else { + const focusObjects = + this.rtcTransport === undefined + ? { + focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const, + foci_preferred: this.fociPreferred ?? [], + } + : { + focus_active: { type: "livekit", focus_selection: "multi_sfu" } as const, + foci_preferred: [this.rtcTransport, ...(this.fociPreferred ?? [])], + }; + return { + "application": this.slotDescription.application, + "call_id": this.slotDescription.id, + "scope": "m.room", + "device_id": this.deviceId, + expires, + "m.call.intent": this.callIntent, + ...focusObjects, + ...(ownMembership !== undefined ? { created_ts: ownMembership.createdTs() } : undefined), + }; + } } // Error checks and handlers diff --git a/src/matrixrtc/focus.ts b/src/matrixrtc/focus.ts deleted file mode 100644 index cf9836dd450..00000000000 --- a/src/matrixrtc/focus.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* -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. -*/ - -/** - * Information about a MatrixRTC conference focus. The only attribute that - * the js-sdk (currently) knows about is the type: applications can extend - * this class for different types of focus. - */ -export interface Focus { - type: string; - [key: string]: unknown; -} diff --git a/src/matrixrtc/index.ts b/src/matrixrtc/index.ts index 40ab6919f5b..e383b3f1043 100644 --- a/src/matrixrtc/index.ts +++ b/src/matrixrtc/index.ts @@ -15,7 +15,6 @@ limitations under the License. */ export * from "./CallMembership.ts"; -export type * from "./focus.ts"; export * from "./LivekitFocus.ts"; export * from "./MatrixRTCSession.ts"; export * from "./MatrixRTCSessionManager.ts"; diff --git a/src/matrixrtc/types.ts b/src/matrixrtc/types.ts index b344a22d8b4..08c32a20628 100644 --- a/src/matrixrtc/types.ts +++ b/src/matrixrtc/types.ts @@ -156,3 +156,11 @@ export type Statistics = { export const isMyMembership = (m: CallMembership, userId: string, deviceId: string): boolean => m.sender === userId && m.deviceId === deviceId; + +/** + * A RTC transport is a JSON object that describes how to connect to a RTC member. + */ +export interface Transport { + type: string; + [key: string]: unknown; +} diff --git a/src/models/room-member.ts b/src/models/room-member.ts index 6cf702e8aa3..a2711d38ebe 100644 --- a/src/models/room-member.ts +++ b/src/models/room-member.ts @@ -388,7 +388,7 @@ export class RoomMember extends TypedEventEmitter Date: Tue, 30 Sep 2025 14:25:15 +0200 Subject: [PATCH 06/30] use correct event type Signed-off-by: Timo K --- src/@types/event.ts | 9 +++------ src/matrixrtc/MembershipManager.ts | 13 +++++++++---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/@types/event.ts b/src/@types/event.ts index 7ec82777240..1364d9ca75f 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -151,6 +151,7 @@ export enum EventType { GroupCallMemberPrefix = "org.matrix.msc3401.call.member", // MatrixRTC events + RTCMembership = "org.matrix.msc4143.rtc.member", CallNotify = "org.matrix.msc4075.call.notify", RTCNotification = "org.matrix.msc4075.rtc.notification", RTCDecline = "org.matrix.msc4310.rtc.decline", @@ -368,12 +369,8 @@ export interface StateEvents { // MSC3401 [EventType.GroupCallPrefix]: IGroupCallRoomState; - [EventType.GroupCallMemberPrefix]: - | IGroupCallRoomMemberState - | SessionMembershipData - | RtcMembershipData - | EmptyObject; - + [EventType.GroupCallMemberPrefix]: IGroupCallRoomMemberState | SessionMembershipData | EmptyObject; + [EventType.RTCMembership]: RtcMembershipData | EmptyObject; // MSC3089 [UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent; diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index 617ae91931f..35183753b58 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -463,7 +463,7 @@ export class MembershipManager { delay: this.delayedLeaveEventDelayMs, }, - EventType.GroupCallMemberPrefix, + this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, {}, // leave event this.memberId, ) @@ -650,7 +650,7 @@ export class MembershipManager return await this.client .sendStateEvent( this.room.roomId, - EventType.GroupCallMemberPrefix, + this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, this.makeMyMembership(this.membershipEventExpiryMs), this.memberId, ) @@ -696,7 +696,7 @@ export class MembershipManager return await this.client .sendStateEvent( this.room.roomId, - EventType.GroupCallMemberPrefix, + this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration), this.memberId, ) @@ -722,7 +722,12 @@ export class MembershipManager } private async sendFallbackLeaveEvent(): Promise { return await this.client - .sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, {}, this.memberId) + .sendStateEvent( + this.room.roomId, + this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, + {}, + this.memberId, + ) .then(() => { this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent); this.state.hasMemberStateEvent = false; From bb7c23d6e5cef8163afc678c413a323936fc25f8 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 30 Sep 2025 14:34:06 +0200 Subject: [PATCH 07/30] fix sonar cube conerns Signed-off-by: Timo K --- src/matrixrtc/CallMembership.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 5da96294f23..3b0071955e9 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -220,6 +220,7 @@ export class CallMembership { private membershipData: MembershipData; + private parentEventData: { eventId: string; sender: string }; public constructor( private parentEvent: MatrixEvent, data: any, @@ -238,20 +239,27 @@ export class CallMembership { `events this could be a legacy membership event: (${data})`, ); } + + const eventId = parentEvent.getId(); + const sender = parentEvent.getSender(); + + if (eventId === undefined) throw new Error("parentEvent is missing eventId field"); + if (sender === undefined) throw new Error("parentEvent is missing sender field"); + this.parentEventData = { eventId, sender }; } - public get sender(): string | undefined { + public get sender(): string { const { kind, data } = this.membershipData; switch (kind) { case "rtc": return data.member.user_id; case "session": - return this.parentEvent.getSender(); + return this.parentEventData.sender; } } - public get eventId(): string | undefined { - return this.parentEvent.getId(); + public get eventId(): string { + return this.parentEventData.eventId; } /** @@ -306,6 +314,8 @@ export class CallMembership { return undefined; case "session": return data.scope; + default: + return undefined; } } From 8a5a8cd0cf6311b282baa2382adcefe321dad108 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 30 Sep 2025 14:53:31 +0200 Subject: [PATCH 08/30] callMembership tests Signed-off-by: Timo K --- spec/unit/matrixrtc/CallMembership.spec.ts | 102 ++++++++++++++++++--- src/matrixrtc/CallMembership.ts | 10 ++ 2 files changed, 101 insertions(+), 11 deletions(-) diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 528f46cace3..2d21b038bca 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -27,6 +27,7 @@ function makeMockEvent(originTs = 0): MatrixEvent { return { getTs: jest.fn().mockReturnValue(originTs), getSender: jest.fn().mockReturnValue("@alice:example.org"), + getId: jest.fn().mockReturnValue("$eventid"), } as unknown as MatrixEvent; } @@ -41,12 +42,13 @@ describe("CallMembership", () => { }); 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" }], + "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", () => { @@ -98,6 +100,7 @@ describe("CallMembership", () => { const membership = new CallMembership(fakeEvent, { ...membershipTemplate, foci_preferred: [mockFocus] }); expect(membership.transports).toEqual([mockFocus]); }); + describe("getTransport", () => { const mockFocus = { type: "this_is_a_mock_focus" }; const oldestMembership = new CallMembership(makeMockEvent(), membershipTemplate); @@ -129,6 +132,42 @@ describe("CallMembership", () => { expect(membership.getTransport(oldestMembership)).toBe(mockFocus); }); }); + describe("correct values from computed fields", () => { + const membership = new CallMembership(makeMockEvent(), membershipTemplate); + it("returns correct sender", () => { + expect(membership.sender).toBe("@alice:example.org"); + }); + it("returns correct eventId", () => { + expect(membership.eventId).toBe("$eventid"); + }); + it("returns correct slot_id", () => { + expect(membership.slotId).toBe("m.call#"); + expect(membership.slotDescription).toStrictEqual({ id: "", application: "m.call" }); + }); + it("returns correct deviceId", () => { + expect(membership.deviceId).toBe("AAAAAAA"); + }); + it("returns correct call intent", () => { + expect(membership.callIntent).toBe("voice"); + }); + it("returns correct application", () => { + expect(membership.application).toStrictEqual("m.call"); + }); + it("returns correct applicationData", () => { + expect(membership.applicationData).toStrictEqual({ "type": "m.call", "m.call.intent": "voice" }); + }); + it("returns correct scope", () => { + expect(membership.scope).toBe("m.room"); + }); + it("returns correct membershipID", () => { + expect(membership.membershipID).toBe("0"); + }); + it("returns correct unused fields", () => { + expect(membership.getAbsoluteExpiry()).toBe(14400000); + expect(membership.getMsUntilExpiry()).toBe(14400000 - Date.now()); + expect(membership.isExpired()).toBe(true); + }); + }); }); describe("RtcMembershipData", () => { @@ -141,11 +180,12 @@ describe("CallMembership", () => { }); const membershipTemplate: RtcMembershipData = { - slot_id: "m.call#1", - application: { type: "m.call" }, - member: { user_id: "@alice:example.org", device_id: "AAAAAAA", id: "xyzHASHxyz" }, - rtc_transports: [{ type: "livekit" }], - versions: [], + "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" }], + "m.call.intent": "voice", + "versions": [], }; it("rejects membership with no slot_id", () => { @@ -228,6 +268,46 @@ describe("CallMembership", () => { expect(membership.getTransport(oldestMembership)).toStrictEqual({ type: "livekit" }); }); }); + describe("correct values from computed fields", () => { + const membership = new CallMembership(makeMockEvent(), membershipTemplate); + it("returns correct sender", () => { + expect(membership.sender).toBe("@alice:example.org"); + }); + it("returns correct eventId", () => { + expect(membership.eventId).toBe("$eventid"); + }); + it("returns correct slot_id", () => { + expect(membership.slotId).toBe("m.call#"); + expect(membership.slotDescription).toStrictEqual({ id: "", application: "m.call" }); + }); + it("returns correct deviceId", () => { + expect(membership.deviceId).toBe("AAAAAAA"); + }); + it("returns correct call intent", () => { + expect(membership.callIntent).toBe("voice"); + }); + it("returns correct application", () => { + expect(membership.application).toStrictEqual("m.call"); + }); + it("returns correct applicationData", () => { + expect(membership.applicationData).toStrictEqual({ + "type": "m.call", + "m.call.id": "", + "m.call.intent": "voice", + }); + }); + it("returns correct scope", () => { + expect(membership.scope).toBe(undefined); + }); + it("returns correct membershipID", () => { + expect(membership.membershipID).toBe("xyzHASHxyz"); + }); + it("returns correct unused fields", () => { + expect(membership.getAbsoluteExpiry()).toBe(undefined); + expect(membership.getMsUntilExpiry()).toBe(undefined); + expect(membership.isExpired()).toBe(false); + }); + }); }); describe("expiry calculation", () => { diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 3b0071955e9..6e1fac59557 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -306,7 +306,17 @@ export class CallMembership { return data.application; } } + public get applicationData(): { type: string } & Record { + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return data.application; + case "session": + return { "type": data.application, "m.call.intent": data["m.call.intent"] }; + } + } + /** @deprecated scope is not used and will be removed in future versions. replaced by application specific types.*/ public get scope(): CallScope | undefined { const { kind, data } = this.membershipData; switch (kind) { From 25f4d6f8fbe0507b48e2ef0e80f6acd91006994a Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 30 Sep 2025 15:00:43 +0200 Subject: [PATCH 09/30] make test correct Signed-off-by: Timo K --- spec/unit/matrixrtc/MembershipManager.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index 01eb5856bfc..e63a01ecb79 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -163,7 +163,7 @@ describe("MembershipManager", () => { await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" })); expect(client.sendStateEvent).toHaveBeenCalledWith( room.roomId, - "org.matrix.msc3401.call.member", + "org.matrix.msc4143.rtc.member", { application: { type: "m.call", id: "" }, member: { @@ -181,7 +181,7 @@ describe("MembershipManager", () => { expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( room.roomId, { delay: 8000 }, - "org.matrix.msc3401.call.member", + "org.matrix.msc4143.rtc.member", {}, "_@alice:example.org_AAAAAAA_m.call", ); From 84a3d56f90bddee2a29ac46611e686faeaa8d74d Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 30 Sep 2025 15:58:25 +0200 Subject: [PATCH 10/30] make sonar cube happy (it does not know about the type constraints...) Signed-off-by: Timo K --- src/matrixrtc/CallMembership.ts | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 6e1fac59557..756fd425ff8 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -253,7 +253,7 @@ export class CallMembership { switch (kind) { case "rtc": return data.member.user_id; - case "session": + default: // "session": return this.parentEventData.sender; } } @@ -271,7 +271,7 @@ export class CallMembership { switch (kind) { case "rtc": return data.slot_id; - case "session": + default: // "session": return slotDescriptionToId({ application: this.application, id: data.call_id }); } } @@ -281,7 +281,7 @@ export class CallMembership { switch (kind) { case "rtc": return data.member.device_id; - case "session": + default: // "session": return data.device_id; } } @@ -302,7 +302,7 @@ export class CallMembership { switch (kind) { case "rtc": return data.application.type; - case "session": + default: // "session": return data.application; } } @@ -311,7 +311,7 @@ export class CallMembership { switch (kind) { case "rtc": return data.application; - case "session": + default: // "session": return { "type": data.application, "m.call.intent": data["m.call.intent"] }; } } @@ -322,10 +322,8 @@ export class CallMembership { switch (kind) { case "rtc": return undefined; - case "session": + default: // "session": return data.scope; - default: - return undefined; } } @@ -337,7 +335,7 @@ export class CallMembership { switch (kind) { case "rtc": return data.member.id; - case "session": + default: // "session": return (this.createdTs() ?? "").toString(); } } @@ -348,7 +346,7 @@ export class CallMembership { case "rtc": // TODO we need to read the referenced (relation) event if available to get the real created_ts return this.parentEvent.getTs(); - case "session": + default: // "session": return data.created_ts ?? this.parentEvent.getTs(); } } @@ -362,7 +360,7 @@ export class CallMembership { switch (kind) { case "rtc": return undefined; - case "session": + default: // "session": // TODO: calculate this from the MatrixRTCSession join configuration directly return this.createdTs() + (data.expires ?? DEFAULT_EXPIRE_DURATION); } @@ -376,7 +374,7 @@ export class CallMembership { switch (kind) { case "rtc": return undefined; - case "session": + default: // "session": // 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 @@ -392,7 +390,7 @@ export class CallMembership { switch (kind) { case "rtc": return false; - case "session": + default: // "session": return this.getMsUntilExpiry()! <= 0; } } @@ -409,21 +407,22 @@ export class CallMembership { return data.rtc_transports[0]; case "session": switch (data.focus_active.focus_selection) { + case "multi_sfu": + return data.foci_preferred[0]; case "oldest_membership": if (CallMembership.equal(this, oldestMembership)) return data.foci_preferred[0]; if (oldestMembership !== undefined) return oldestMembership.getTransport(oldestMembership); break; - case "multi_sfu": - return data.foci_preferred[0]; } } + return undefined; } public get transports(): Transport[] { const { kind, data } = this.membershipData; switch (kind) { case "rtc": return data.rtc_transports; - case "session": + default: // "session": return data.foci_preferred; } } From 5bc970cb6c7494a1ba545ae7e16aa68bd17f7dbd Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 30 Sep 2025 16:38:08 +0200 Subject: [PATCH 11/30] remove created_ts from RtcMembership Signed-off-by: Timo K --- src/matrixrtc/CallMembership.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 756fd425ff8..b9830f98827 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -46,7 +46,6 @@ export interface RtcMembershipData { }; "rtc_transports": Transport[]; "versions": string[]; - "created_ts"?: number; "sticky_key"?: string; /** * The intent of the call from the perspective of this user. This may be an audio call, video call or @@ -94,9 +93,6 @@ const checkRtcMembershipData = ( } // optional fields - if (data.created_ts !== undefined && typeof data.created_ts !== "number") { - errors.push(prefix + "created_ts must be number"); - } if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") { errors.push(prefix + "sticky_key must be a string"); } From d94d02d19b9f17c724b5919b185fea3413dbf7a2 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 30 Sep 2025 16:46:32 +0200 Subject: [PATCH 12/30] fix imports Signed-off-by: Timo K --- src/matrixrtc/CallMembership.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index b9830f98827..4908e7e5209 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -18,9 +18,9 @@ import { MXID_PATTERN } from "../models/room-member.ts"; import { deepCompare } from "../utils.ts"; import { isLivekitFocusSelection, type LivekitFocusSelection } from "./LivekitFocus.ts"; import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts"; -import { type RTCCallIntent, type Transport } from "./types.ts"; -import { type RelationType } from "src/types.ts"; +import type { RTCCallIntent, Transport } from "./types.ts"; import { type MatrixEvent } from "../models/event.ts"; +import { type RelationType } from "../@types/event.ts"; /** * The default duration in milliseconds that a membership is considered valid for. From 74b793c77e1aed587b842469c83b451492aedbae Mon Sep 17 00:00:00 2001 From: Timo <16718859+toger5@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:14:27 +0200 Subject: [PATCH 13/30] Update src/matrixrtc/IMembershipManager.ts Co-authored-by: Robin --- src/matrixrtc/IMembershipManager.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/matrixrtc/IMembershipManager.ts b/src/matrixrtc/IMembershipManager.ts index cf6963fdc18..8a000a578e0 100644 --- a/src/matrixrtc/IMembershipManager.ts +++ b/src/matrixrtc/IMembershipManager.ts @@ -80,7 +80,8 @@ export interface IMembershipManager * Start sending all necessary events to make this user participate in the RTC session. * @param fociPreferred the list of preferred foci to use in the joined RTC membership event. * @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the - * membership manager will use multi sfu. Use `undefined` to not use `oldest_membership` selection based sfu. + * membership manager will operate in a multi-SFU connection mode. If `undefined`, an `oldest_membership` + * transport selection will be used instead. * @throws can throw if it exceeds a configured maximum retry. */ join(fociPreferred: Transport[], multiSfuFocus?: Transport, onError?: (error: unknown) => void): void; From e829a7b81b8894390f8d7fffb8f970ec8135ad47 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 1 Oct 2025 10:29:20 +0200 Subject: [PATCH 14/30] rename LivekitFocus.ts -> LivekitTransport.ts Signed-off-by: Timo K --- .../{LivekitFocus.spec.ts => LivekitTransport.spec.ts} | 2 +- src/matrixrtc/CallMembership.ts | 2 +- src/matrixrtc/{LivekitFocus.ts => LivekitTransport.ts} | 4 ++-- src/matrixrtc/index.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) rename spec/unit/matrixrtc/{LivekitFocus.spec.ts => LivekitTransport.spec.ts} (98%) rename src/matrixrtc/{LivekitFocus.ts => LivekitTransport.ts} (91%) diff --git a/spec/unit/matrixrtc/LivekitFocus.spec.ts b/spec/unit/matrixrtc/LivekitTransport.spec.ts similarity index 98% rename from spec/unit/matrixrtc/LivekitFocus.spec.ts rename to spec/unit/matrixrtc/LivekitTransport.spec.ts index 7da0aebb575..04f04a1357e 100644 --- a/spec/unit/matrixrtc/LivekitFocus.spec.ts +++ b/spec/unit/matrixrtc/LivekitTransport.spec.ts @@ -18,7 +18,7 @@ import { isLivekitTransport, isLivekitFocusSelection, isLivekitTransportConfig, -} from "../../../src/matrixrtc/LivekitFocus"; +} from "../../../src/matrixrtc/LivekitTransport"; describe("LivekitFocus", () => { it("isLivekitFocus", () => { diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 4908e7e5209..04f0f27702c 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -16,7 +16,7 @@ limitations under the License. import { MXID_PATTERN } from "../models/room-member.ts"; import { deepCompare } from "../utils.ts"; -import { isLivekitFocusSelection, type LivekitFocusSelection } from "./LivekitFocus.ts"; +import { isLivekitFocusSelection, type LivekitFocusSelection } from "./LivekitTransport.ts"; import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts"; import type { RTCCallIntent, Transport } from "./types.ts"; import { type MatrixEvent } from "../models/event.ts"; diff --git a/src/matrixrtc/LivekitFocus.ts b/src/matrixrtc/LivekitTransport.ts similarity index 91% rename from src/matrixrtc/LivekitFocus.ts rename to src/matrixrtc/LivekitTransport.ts index 6c17ffc6a6d..61b2d49d061 100644 --- a/src/matrixrtc/LivekitFocus.ts +++ b/src/matrixrtc/LivekitTransport.ts @@ -32,7 +32,7 @@ export const isLivekitTransport = (object: any): object is LivekitTransport => isLivekitTransportConfig(object) && "livekit_alias" in object; /** - * Deprecated, this is just needed for the old focus active / focus fields of a call membership. + * @deprecated, this is just needed for the old focus active / focus fields of a call membership. * Not needed for new implementations. */ export interface LivekitFocusSelection extends Transport { @@ -40,7 +40,7 @@ export interface LivekitFocusSelection extends Transport { focus_selection: "oldest_membership" | "multi_sfu"; } /** - * deprecated see LivekitFocusSelection + * @deprecated see LivekitFocusSelection */ export const isLivekitFocusSelection = (object: any): object is LivekitFocusSelection => object.type === "livekit" && "focus_selection" in object; diff --git a/src/matrixrtc/index.ts b/src/matrixrtc/index.ts index e383b3f1043..9f52bec6f27 100644 --- a/src/matrixrtc/index.ts +++ b/src/matrixrtc/index.ts @@ -15,7 +15,7 @@ limitations under the License. */ export * from "./CallMembership.ts"; -export * from "./LivekitFocus.ts"; +export * from "./LivekitTransport.ts"; export * from "./MatrixRTCSession.ts"; export * from "./MatrixRTCSessionManager.ts"; export type * from "./types.ts"; From f70cb140f8f6910282f3fbbcf4f39aa98e672130 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 1 Oct 2025 10:36:44 +0200 Subject: [PATCH 15/30] add details to `getTransport` Signed-off-by: Timo K --- src/matrixrtc/CallMembership.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 04f0f27702c..8cd82ac9472 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -208,6 +208,7 @@ const checkSessionsMembershipData = ( }; type MembershipData = { kind: "rtc"; data: RtcMembershipData } | { kind: "session"; data: SessionMembershipData }; +// TODO: Rename to RtcMembership once we removed the legacy SessionMembership from this file. export class CallMembership { public static equal(a: CallMembership, b: CallMembership): boolean { if (a === undefined || b === undefined) return a === b; @@ -392,10 +393,23 @@ export class CallMembership { } /** + * ## RTC Membership + * Gets the transport to use for this RTC membership (m.rtc.member). + * This will return the primary transport that is used by this call membership to publish their media. + * Directly relates to the `rtc_transports` field. * - * @param oldestMembership For backwards compatibility with session membership (legacy). - * @returns + * ## Legacy session membership + * In case of a legacy session membership (m.call.member) this will return the selected transport where + * media is published. How this selection happens depends on the `focus_active` field of the session membership. + * If the `focus_selection` is `oldest_membership` this will return the transport of the oldest membership + * in the room (based on the `created_ts` field of the session membership). + * If the `focus_selection` is `multi_sfu` it will return the first transport of the `foci_preferred` list. + * (`multi_sfu` is equivalent to how `m.rtc.member` `rtc_transports` work). + * @param oldestMembership For backwards compatibility with session membership (legacy). Unused in case of RTC membership. + * Always required to make the consumer not care if it deals with RTC or session memberships. + * @returns The transport this membership uses to publish media or undefined if no transport is available. */ + // TODO: make this return all transports used to publish media once this is supported. public getTransport(oldestMembership: CallMembership): Transport | undefined { const { kind, data } = this.membershipData; switch (kind) { From 11f610d7c70a03bc0f2a2aa26cdfee8f1e266b3f Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 7 Oct 2025 14:44:57 +0200 Subject: [PATCH 16/30] review Signed-off-by: Timo K --- spec/unit/matrixrtc/CallMembership.spec.ts | 8 --- src/matrixrtc/CallMembership.ts | 71 ++++++++++++++-------- src/matrixrtc/LivekitTransport.ts | 2 +- src/matrixrtc/MatrixRTCSession.ts | 4 ++ src/models/room-member.ts | 2 +- 5 files changed, 52 insertions(+), 35 deletions(-) diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 2d21b038bca..712151f99d0 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -171,14 +171,6 @@ describe("CallMembership", () => { }); describe("RtcMembershipData", () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - const membershipTemplate: RtcMembershipData = { "slot_id": "m.call#", "application": { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" }, diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 8cd82ac9472..ce15159ecfa 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -46,6 +46,7 @@ export interface RtcMembershipData { }; "rtc_transports": Transport[]; "versions": string[]; + "msc4354_sticky_key"?: string; "sticky_key"?: string; /** * The intent of the call from the perspective of this user. This may be an audio call, video call or @@ -93,7 +94,8 @@ const checkRtcMembershipData = ( } // optional fields - if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") { + const stickyKey = data.sticky_key ?? data.msc4354_sticky_key; + if (stickyKey !== undefined && typeof stickyKey !== "string") { errors.push(prefix + "sticky_key must be a string"); } if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") { @@ -210,16 +212,20 @@ const checkSessionsMembershipData = ( type MembershipData = { kind: "rtc"; data: RtcMembershipData } | { kind: "session"; data: SessionMembershipData }; // TODO: Rename to RtcMembership once we removed the legacy SessionMembership from this file. export class CallMembership { - public static equal(a: CallMembership, b: CallMembership): boolean { + public static equal(a?: CallMembership, b?: CallMembership): boolean { if (a === undefined || b === undefined) return a === b; return deepCompare(a.membershipData, b.membershipData); } private membershipData: MembershipData; - private parentEventData: { eventId: string; sender: string }; + /** 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 matrixEventData: { eventId: string; sender: string }; public constructor( - private parentEvent: MatrixEvent, + /** The Matrix event that this membership is based on */ + private matrixEvent: MatrixEvent, data: any, ) { const sessionErrors: string[] = []; @@ -237,12 +243,12 @@ export class CallMembership { ); } - const eventId = parentEvent.getId(); - const sender = parentEvent.getSender(); + 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"); - this.parentEventData = { eventId, sender }; + this.matrixEventData = { eventId, sender }; } public get sender(): string { @@ -250,13 +256,14 @@ export class CallMembership { switch (kind) { case "rtc": return data.member.user_id; - default: // "session": - return this.parentEventData.sender; + case "session": + default: + return this.matrixEventData.sender; } } public get eventId(): string { - return this.parentEventData.eventId; + return this.matrixEventData.eventId; } /** @@ -268,7 +275,8 @@ export class CallMembership { switch (kind) { case "rtc": return data.slot_id; - default: // "session": + case "session": + default: return slotDescriptionToId({ application: this.application, id: data.call_id }); } } @@ -278,7 +286,8 @@ export class CallMembership { switch (kind) { case "rtc": return data.member.device_id; - default: // "session": + case "session": + default: return data.device_id; } } @@ -299,7 +308,8 @@ export class CallMembership { switch (kind) { case "rtc": return data.application.type; - default: // "session": + case "session": + default: return data.application; } } @@ -308,7 +318,8 @@ export class CallMembership { switch (kind) { case "rtc": return data.application; - default: // "session": + case "session": + default: return { "type": data.application, "m.call.intent": data["m.call.intent"] }; } } @@ -319,7 +330,8 @@ export class CallMembership { switch (kind) { case "rtc": return undefined; - default: // "session": + case "session": + default: return data.scope; } } @@ -332,7 +344,8 @@ export class CallMembership { switch (kind) { case "rtc": return data.member.id; - default: // "session": + case "session": + default: return (this.createdTs() ?? "").toString(); } } @@ -342,9 +355,10 @@ export class CallMembership { switch (kind) { case "rtc": // TODO we need to read the referenced (relation) event if available to get the real created_ts - return this.parentEvent.getTs(); - default: // "session": - return data.created_ts ?? this.parentEvent.getTs(); + return this.matrixEvent.getTs(); + case "session": + default: + return data.created_ts ?? this.matrixEvent.getTs(); } } @@ -357,7 +371,8 @@ export class CallMembership { switch (kind) { case "rtc": return undefined; - default: // "session": + case "session": + default: // TODO: calculate this from the MatrixRTCSession join configuration directly return this.createdTs() + (data.expires ?? DEFAULT_EXPIRE_DURATION); } @@ -371,7 +386,8 @@ export class CallMembership { switch (kind) { case "rtc": return undefined; - default: // "session": + 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 @@ -387,14 +403,15 @@ export class CallMembership { switch (kind) { case "rtc": return false; - default: // "session": + case "session": + default: return this.getMsUntilExpiry()! <= 0; } } /** * ## RTC Membership - * Gets the transport to use for this RTC membership (m.rtc.member). + * Gets the primary transport to use for this RTC membership (m.rtc.member). * This will return the primary transport that is used by this call membership to publish their media. * Directly relates to the `rtc_transports` field. * @@ -409,7 +426,6 @@ export class CallMembership { * Always required to make the consumer not care if it deals with RTC or session memberships. * @returns The transport this membership uses to publish media or undefined if no transport is available. */ - // TODO: make this return all transports used to publish media once this is supported. public getTransport(oldestMembership: CallMembership): Transport | undefined { const { kind, data } = this.membershipData; switch (kind) { @@ -427,12 +443,17 @@ export class CallMembership { } 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). + */ public get transports(): Transport[] { const { kind, data } = this.membershipData; switch (kind) { case "rtc": return data.rtc_transports; - default: // "session": + case "session": + default: return data.foci_preferred; } } diff --git a/src/matrixrtc/LivekitTransport.ts b/src/matrixrtc/LivekitTransport.ts index 61b2d49d061..eda11f554e5 100644 --- a/src/matrixrtc/LivekitTransport.ts +++ b/src/matrixrtc/LivekitTransport.ts @@ -1,5 +1,5 @@ /* -Copyright 2023 New Vector Ltd +Copyright 2025 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 4fcc449f56a..76b693b20ab 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -363,6 +363,10 @@ export class MatrixRTCSession extends TypedEventEmitter< if (membershipContents.length === 0) continue; for (const membershipData of membershipContents) { + if (!("application" in membershipData)) { + // This is a left membership event, ignore it here to not log warnings. + continue; + } try { const membership = new CallMembership(memberEvent, membershipData); diff --git a/src/models/room-member.ts b/src/models/room-member.ts index a2711d38ebe..afe72d4ef51 100644 --- a/src/models/room-member.ts +++ b/src/models/room-member.ts @@ -388,7 +388,7 @@ export class RoomMember extends TypedEventEmitter Date: Tue, 7 Oct 2025 15:00:27 +0200 Subject: [PATCH 17/30] use DEFAULT_EXPIRE_DURATION in tests Signed-off-by: Timo K --- spec/unit/matrixrtc/CallMembership.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 712151f99d0..0a25ba22e5b 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -163,8 +163,8 @@ describe("CallMembership", () => { expect(membership.membershipID).toBe("0"); }); it("returns correct unused fields", () => { - expect(membership.getAbsoluteExpiry()).toBe(14400000); - expect(membership.getMsUntilExpiry()).toBe(14400000 - Date.now()); + expect(membership.getAbsoluteExpiry()).toBe(DEFAULT_EXPIRE_DURATION); + expect(membership.getMsUntilExpiry()).toBe(DEFAULT_EXPIRE_DURATION - Date.now()); expect(membership.isExpired()).toBe(true); }); }); From 4643844597f8bd0196714ecc1c7fafd3f3f6669d Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 7 Oct 2025 15:02:54 +0200 Subject: [PATCH 18/30] fix test `does not provide focus if the selection method is unknown` Signed-off-by: Timo K --- spec/unit/matrixrtc/CallMembership.spec.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 0a25ba22e5b..6105fc963fe 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -118,7 +118,7 @@ describe("CallMembership", () => { expect(membership.getTransport(oldestMembership)).toBe(membershipTemplate.foci_preferred[0]); }); - it("does not provide focus if the selection method is unknown", () => { + it("gets the correct active transport with multi_sfu", () => { const membership = new CallMembership(makeMockEvent(), { ...membershipTemplate, foci_preferred: [mockFocus], @@ -131,6 +131,16 @@ describe("CallMembership", () => { // 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" }, + }); + + // 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); From 8a21ff69bb55d6d03ca5f9964d05ca1287c73f3a Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 2 Oct 2025 12:48:05 +0200 Subject: [PATCH 19/30] Add the parent event to the CallMembership. Signed-off-by: Timo K --- spec/unit/matrixrtc/CallMembership.spec.ts | 148 +++++++++------- src/matrixrtc/CallMembership.ts | 30 ++-- src/matrixrtc/MatrixRTCSession.ts | 192 +++++++++++---------- 3 files changed, 195 insertions(+), 175 deletions(-) diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 6105fc963fe..0a0ef4b504b 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -23,11 +23,12 @@ import { } from "../../../src/matrixrtc/CallMembership"; import { membershipTemplate } from "./mocks"; -function makeMockEvent(originTs = 0): MatrixEvent { +function makeMockEvent(originTs = 0, content = {}): MatrixEvent { return { getTs: jest.fn().mockReturnValue(originTs), getSender: jest.fn().mockReturnValue("@alice:example.org"), getId: jest.fn().mockReturnValue("$eventid"), + getContent: jest.fn().mockReturnValue(content), } as unknown as MatrixEvent; } @@ -53,63 +54,64 @@ describe("CallMembership", () => { it("rejects membership with no device_id", () => { expect(() => { - new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined })); + new CallMembership(makeMockEvent(0, Object.assign({}, membershipTemplate, { device_id: undefined }))); }).toThrow(); }); it("rejects membership with no call_id", () => { expect(() => { - new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined })); + new CallMembership(makeMockEvent(0, Object.assign({}, membershipTemplate, { call_id: undefined }))); }).toThrow(); }); it("allow membership with no scope", () => { expect(() => { - new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined })); + new CallMembership(makeMockEvent(0, Object.assign({}, membershipTemplate, { scope: undefined }))); }).not.toThrow(); }); it("uses event timestamp if no created_ts", () => { - const membership = new CallMembership(makeMockEvent(12345), membershipTemplate); + const membership = new CallMembership(makeMockEvent(12345, membershipTemplate)); expect(membership.createdTs()).toEqual(12345); }); it("uses created_ts if present", () => { const membership = new CallMembership( - makeMockEvent(12345), - Object.assign({}, membershipTemplate, { created_ts: 67890 }), + makeMockEvent(12345, Object.assign({}, membershipTemplate, { created_ts: 67890 })), ); expect(membership.createdTs()).toEqual(67890); }); it("considers memberships unexpired if local age low enough", () => { - const fakeEvent = makeMockEvent(1000); + const fakeEvent = makeMockEvent(1000, membershipTemplate); fakeEvent.getTs = jest.fn().mockReturnValue(Date.now() - (DEFAULT_EXPIRE_DURATION - 1)); - expect(new CallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(false); + expect(new CallMembership(fakeEvent).isExpired()).toEqual(false); }); it("considers memberships expired if local age large enough", () => { - const fakeEvent = makeMockEvent(1000); + const fakeEvent = makeMockEvent(1000, membershipTemplate); fakeEvent.getTs = jest.fn().mockReturnValue(Date.now() - (DEFAULT_EXPIRE_DURATION + 1)); - expect(new CallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(true); + expect(new CallMembership(fakeEvent).isExpired()).toEqual(true); }); it("returns preferred foci", () => { - const fakeEvent = makeMockEvent(); const mockFocus = { type: "this_is_a_mock_focus" }; - const membership = new CallMembership(fakeEvent, { ...membershipTemplate, foci_preferred: [mockFocus] }); + const fakeEvent = makeMockEvent(0, { ...membershipTemplate, foci_preferred: [mockFocus] }); + const membership = new CallMembership(fakeEvent); expect(membership.transports).toEqual([mockFocus]); }); describe("getTransport", () => { const mockFocus = { type: "this_is_a_mock_focus" }; - const oldestMembership = new CallMembership(makeMockEvent(), membershipTemplate); + const oldestMembership = new CallMembership(makeMockEvent(0, membershipTemplate)); it("gets the correct active transport with oldest_membership", () => { - const membership = new CallMembership(makeMockEvent(), { - ...membershipTemplate, - foci_preferred: [mockFocus], - focus_active: { type: "livekit", focus_selection: "oldest_membership" }, - }); + const membership = new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + foci_preferred: [mockFocus], + focus_active: { type: "livekit", focus_selection: "oldest_membership" }, + }), + ); // if we are the oldest member we use our focus. expect(membership.getTransport(membership)).toStrictEqual(mockFocus); @@ -119,11 +121,13 @@ describe("CallMembership", () => { }); it("gets the correct active transport with multi_sfu", () => { - const membership = new CallMembership(makeMockEvent(), { - ...membershipTemplate, - foci_preferred: [mockFocus], - focus_active: { type: "livekit", focus_selection: "multi_sfu" }, - }); + const membership = new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + foci_preferred: [mockFocus], + focus_active: { type: "livekit", focus_selection: "multi_sfu" }, + }), + ); // if we are the oldest member we use our focus. expect(membership.getTransport(membership)).toStrictEqual(mockFocus); @@ -132,18 +136,20 @@ describe("CallMembership", () => { expect(membership.getTransport(oldestMembership)).toBe(mockFocus); }); it("does not provide focus if the selection method is unknown", () => { - const membership = new CallMembership(makeMockEvent(), { - ...membershipTemplate, - foci_preferred: [mockFocus], - focus_active: { type: "livekit", focus_selection: "unknown" }, - }); + const membership = new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + foci_preferred: [mockFocus], + focus_active: { type: "livekit", focus_selection: "unknown" }, + }), + ); // if we are the oldest member we use our focus. expect(membership.getTransport(membership)).toBeUndefined(); }); }); describe("correct values from computed fields", () => { - const membership = new CallMembership(makeMockEvent(), membershipTemplate); + const membership = new CallMembership(makeMockEvent(0, membershipTemplate)); it("returns correct sender", () => { expect(membership.sender).toBe("@alice:example.org"); }); @@ -192,58 +198,68 @@ describe("CallMembership", () => { it("rejects membership with no slot_id", () => { expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: undefined }); + new CallMembership(makeMockEvent(0, { ...membershipTemplate, slot_id: undefined })); }).toThrow(); }); it("rejects membership with no application", () => { expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, application: undefined }); + new CallMembership(makeMockEvent(0, { ...membershipTemplate, application: undefined })); }).toThrow(); }); it("rejects membership with incorrect application", () => { expect(() => { - new CallMembership(makeMockEvent(), { - ...membershipTemplate, - application: { wrong_type_key: "unknown" }, - }); + new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + application: { wrong_type_key: "unknown" }, + }), + ); }).toThrow(); }); it("rejects membership with no member", () => { expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, member: undefined }); + new CallMembership(makeMockEvent(0, { ...membershipTemplate, member: undefined })); }).toThrow(); }); it("rejects membership with incorrect member", () => { expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, member: { i: "test" } }); + new CallMembership(makeMockEvent(0, { ...membershipTemplate, member: { i: "test" } })); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { - ...membershipTemplate, - member: { id: "test", device_id: "test", user_id_wrong: "test" }, - }); + new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + member: { id: "test", device_id: "test", user_id_wrong: "test" }, + }), + ); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { - ...membershipTemplate, - member: { id: "test", device_id_wrong: "test", user_id_wrong: "test" }, - }); + new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + member: { id: "test", device_id_wrong: "test", user_id_wrong: "test" }, + }), + ); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { - ...membershipTemplate, - member: { id: "test", device_id: "test", user_id: "@@test" }, - }); + new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + member: { id: "test", device_id: "test", user_id: "@@test" }, + }), + ); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { - ...membershipTemplate, - member: { id: "test", device_id: "test", user_id: "@test:user.id" }, - }); + new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + member: { id: "test", device_id: "test", user_id: "@test:user.id" }, + }), + ); }).not.toThrow(); }); @@ -257,11 +273,13 @@ describe("CallMembership", () => { describe("getTransport", () => { it("gets the correct active transport with oldest_membership", () => { - const oldestMembership = new CallMembership(makeMockEvent(), { - ...membershipTemplate, - rtc_transports: [{ type: "oldest_transport" }], - }); - const membership = new CallMembership(makeMockEvent(), membershipTemplate); + const oldestMembership = new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + rtc_transports: [{ type: "oldest_transport" }], + }), + ); + const membership = new CallMembership(makeMockEvent(0, membershipTemplate)); // if we are the oldest member we use our focus. expect(membership.getTransport(membership)).toStrictEqual({ type: "livekit" }); @@ -271,7 +289,7 @@ describe("CallMembership", () => { }); }); describe("correct values from computed fields", () => { - const membership = new CallMembership(makeMockEvent(), membershipTemplate); + const membership = new CallMembership(makeMockEvent(0, membershipTemplate)); it("returns correct sender", () => { expect(membership.sender).toBe("@alice:example.org"); }); @@ -304,9 +322,9 @@ describe("CallMembership", () => { it("returns correct membershipID", () => { expect(membership.membershipID).toBe("xyzHASHxyz"); }); - it("returns correct unused fields", () => { - expect(membership.getAbsoluteExpiry()).toBe(undefined); - expect(membership.getMsUntilExpiry()).toBe(undefined); + it("returns correct expiration fields", () => { + expect(membership.getAbsoluteExpiry()).toBe(DEFAULT_EXPIRE_DURATION); + expect(membership.getMsUntilExpiry()).toBe(DEFAULT_EXPIRE_DURATION - Date.now()); expect(membership.isExpired()).toBe(false); }); }); @@ -318,8 +336,8 @@ describe("CallMembership", () => { beforeEach(() => { // server origin timestamp for this event is 1000 - fakeEvent = makeMockEvent(1000); - membership = new CallMembership(fakeEvent!, membershipTemplate); + fakeEvent = makeMockEvent(1000, membershipTemplate); + membership = new CallMembership(fakeEvent!); jest.useFakeTimers(); }); diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index ce15159ecfa..f92af88aedf 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -223,11 +223,17 @@ export class CallMembership { * To access checked eventId and sender from the matrixEvent. * Class construction will fail if these values cannot get obtained. */ private matrixEventData: { eventId: string; sender: string }; + /** + * Constructs a CallMembership from a Matrix event. + * @param matrixEvent The Matrix event that this membership is based on + * @param relatedEvent The fetched event linked via the `event_id` from the `m.relates_to` field if present. + * @throws if the data does not match any known membership format. + */ public constructor( - /** The Matrix event that this membership is based on */ private matrixEvent: MatrixEvent, - data: any, + private relatedEvent?: MatrixEvent, ) { + const data = matrixEvent.getContent() as any; const sessionErrors: string[] = []; const rtcErrors: string[] = []; if (checkSessionsMembershipData(data, sessionErrors)) { @@ -354,8 +360,7 @@ export class CallMembership { const { kind, data } = this.membershipData; switch (kind) { case "rtc": - // TODO we need to read the referenced (relation) event if available to get the real created_ts - return this.matrixEvent.getTs(); + return this.relatedEvent?.getTs() ?? this.matrixEvent.getTs(); case "session": default: return data.created_ts ?? this.matrixEvent.getTs(); @@ -370,7 +375,7 @@ export class CallMembership { const { kind, data } = this.membershipData; switch (kind) { case "rtc": - return undefined; + return this.createdTs() + DEFAULT_EXPIRE_DURATION; case "session": default: // TODO: calculate this from the MatrixRTCSession join configuration directly @@ -382,17 +387,10 @@ export class CallMembership { * @returns The number of milliseconds until the membership expires or undefined if applicable */ public getMsUntilExpiry(): number | undefined { - const { kind } = this.membershipData; - switch (kind) { - case "rtc": - return undefined; - case "session": - default: - // Assume that local clock is sufficiently in sync with other clocks in the distributed system. - // We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate. - // The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2 - return this.getAbsoluteExpiry()! - Date.now(); - } + // Assume that local clock is sufficiently in sync with other clocks in the distributed system. + // We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate. + // The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2 + return this.getAbsoluteExpiry()! - Date.now(); } /** diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 76b693b20ab..6706a93f4de 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -50,6 +50,7 @@ import { } from "./RoomAndToDeviceKeyTransport.ts"; import { TypedReEmitter } from "../ReEmitter.ts"; import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts"; +import { MatrixEvent } from "../models/event.ts"; /** * Events emitted by MatrixRTCSession @@ -308,10 +309,10 @@ export class MatrixRTCSession extends TypedEventEmitter< * * @deprecated Use `MatrixRTCSession.sessionMembershipsForSlot` instead. */ - public static callMembershipsForRoom( - room: Pick, - ): CallMembership[] { - return MatrixRTCSession.sessionMembershipsForSlot(room, { + public static async callMembershipsForRoom( + room: Pick, + ): Promise { + return await MatrixRTCSession.sessionMembershipsForSlot(room, { id: "", application: "m.call", }); @@ -320,21 +321,22 @@ export class MatrixRTCSession extends TypedEventEmitter< /** * @deprecated use `MatrixRTCSession.slotMembershipsForRoom` instead. */ - public static sessionMembershipsForRoom( - room: Pick, + public static async sessionMembershipsForRoom( + room: Pick, sessionDescription: SlotDescription, - ): CallMembership[] { - return this.sessionMembershipsForSlot(room, sessionDescription); + ): Promise { + return await this.sessionMembershipsForSlot(room, sessionDescription); } /** * Returns all the call memberships for a room that match the provided `sessionDescription`, * oldest first. */ - public static sessionMembershipsForSlot( - room: Pick, + public static async sessionMembershipsForSlot( + room: Pick, slotDescription: SlotDescription, - ): CallMembership[] { + existingMemberships?: CallMembership[], + ): Promise { const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`); const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); if (!roomState) { @@ -342,54 +344,41 @@ export class MatrixRTCSession extends TypedEventEmitter< throw new Error("Could't get state for room " + room.roomId); } const callMemberEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix); - const callMemberships: CallMembership[] = []; - for (const memberEvent of callMemberEvents) { - const content = memberEvent.getContent(); - const eventKeysCount = Object.keys(content).length; - // Dont even bother about empty events (saves us from costly type/"key in" checks in bigger rooms) - if (eventKeysCount === 0) continue; - - const membershipContents: any[] = []; - - // We first decide if its a MSC4143 event (per device state key) - if (eventKeysCount > 1 && "focus_active" in content) { - // We have a MSC4143 event membership event - membershipContents.push(content); - } else if (eventKeysCount === 1 && "memberships" in content) { - logger.warn(`Legacy event found. Those are ignored, they do not contribute to the MatrixRTC session`); - } - if (membershipContents.length === 0) continue; + for (const memberEvent of callMemberEvents) { + let membership = existingMemberships?.find((m) => m.eventId === memberEvent.getId()); + if (!membership) { + const relatedEventId = memberEvent.relationEventId; + const relatedEvent = relatedEventId + ? room.findEventById(relatedEventId) + : new MatrixEvent(await room.client.fetchRoomEvent(room.roomId, relatedEventId!)); - for (const membershipData of membershipContents) { - if (!("application" in membershipData)) { - // This is a left membership event, ignore it here to not log warnings. - continue; - } try { - const membership = new CallMembership(memberEvent, membershipData); - - if (!deepCompare(membership.slotDescription, slotDescription)) { - logger.info( - `Ignoring membership of user ${membership.sender} for a different session: ${JSON.stringify(membership.slotDescription)}`, - ); - continue; - } - - if (membership.isExpired()) { - logger.info(`Ignoring expired device membership ${membership.sender}/${membership.deviceId}`); - continue; - } - if (!room.hasMembershipState(membership.sender ?? "", KnownMembership.Join)) { - logger.info(`Ignoring membership of user ${membership.sender} who is not in the room.`); - continue; - } - callMemberships.push(membership); + membership = new CallMembership(memberEvent, relatedEvent); } catch (e) { logger.warn("Couldn't construct call membership: ", e); + continue; } + // static check for newly created memberships + if (!deepCompare(membership.slotDescription, slotDescription)) { + logger.info( + `Ignoring membership of user ${membership.sender} for a different session: ${JSON.stringify(membership.slotDescription)}`, + ); + continue; + } + } + + // Dynamic checks for all (including existing) memberships + if (membership.isExpired()) { + logger.info(`Ignoring expired device membership ${membership.sender}/${membership.deviceId}`); + continue; + } + if (!room.hasMembershipState(membership.sender ?? "", KnownMembership.Join)) { + logger.info(`Ignoring membership of user ${membership.sender} who is not in the room.`); + continue; } + callMemberships.push(membership); } callMemberships.sort((a, b) => a.createdTs() - b.createdTs()); @@ -413,15 +402,22 @@ export class MatrixRTCSession extends TypedEventEmitter< * * @deprecated Use `MatrixRTCSession.sessionForSlot` with sessionDescription `{ id: "", application: "m.call" }` instead. */ - public static roomSessionForRoom(client: MatrixClient, room: Room): MatrixRTCSession { - const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(room, { id: "", application: "m.call" }); + public static async roomSessionForRoom(client: MatrixClient, room: Room): Promise { + const callMemberships = await MatrixRTCSession.sessionMembershipsForSlot(room, { + id: "", + application: "m.call", + }); return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" }); } /** * @deprecated Use `MatrixRTCSession.sessionForSlot` instead. */ - public static sessionForRoom(client: MatrixClient, room: Room, slotDescription: SlotDescription): MatrixRTCSession { + public static async sessionForRoom( + client: MatrixClient, + room: Room, + slotDescription: SlotDescription, + ): Promise { return this.sessionForSlot(client, room, slotDescription); } @@ -430,8 +426,12 @@ export class MatrixRTCSession extends TypedEventEmitter< * This returned session can be used to find out if there are active sessions * for the requested room and `slotDescription`. */ - public static sessionForSlot(client: MatrixClient, room: Room, slotDescription: SlotDescription): MatrixRTCSession { - const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(room, slotDescription); + public static async sessionForSlot( + client: MatrixClient, + room: Room, + slotDescription: SlotDescription, + ): Promise { + const callMemberships = await MatrixRTCSession.sessionMembershipsForSlot(room, slotDescription); return new MatrixRTCSession(client, room, callMemberships, slotDescription); } @@ -803,46 +803,50 @@ export class MatrixRTCSession extends TypedEventEmitter< */ private recalculateSessionMembers = (): void => { const oldMemberships = this.memberships; - this.memberships = MatrixRTCSession.sessionMembershipsForSlot(this.room, this.slotDescription); - - this._slotId = this._slotId ?? this.memberships[0]?.slotId; - - const changed = - oldMemberships.length != this.memberships.length || - oldMemberships.some((m, i) => !CallMembership.equal(m, this.memberships[i])); - - if (changed) { - this.logger.info( - `Memberships for call in room ${this.roomSubset.roomId} have changed: emitting (${this.memberships.length} members)`, - ); - logDurationSync(this.logger, "emit MatrixRTCSessionEvent.MembershipsChanged", () => { - this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships); - }); - - void this.membershipManager?.onRTCSessionMemberUpdate(this.memberships); - // The `ownMembership` will be set when calling `onRTCSessionMemberUpdate`. - const ownMembership = this.membershipManager?.ownMembership; - if (this.pendingNotificationToSend && ownMembership && oldMemberships.length === 0) { - // If we're the first member in the call, we're responsible for - // sending the notification event - if (ownMembership.eventId && this.joinConfig?.notificationType) { - this.sendCallNotify( - ownMembership.eventId, - this.joinConfig.notificationType, - ownMembership.callIntent, + void MatrixRTCSession.sessionMembershipsForSlot(this.room, this.slotDescription, oldMemberships).then( + (newMemberships) => { + this.memberships = newMemberships; + this._slotId = this._slotId ?? this.memberships[0]?.slotId; + + const changed = + oldMemberships.length != this.memberships.length || + // If they have the same length, this is enough to check "changed" + oldMemberships.some((m, i) => !CallMembership.equal(m, this.memberships[i])); + + if (changed) { + this.logger.info( + `Memberships for call in room ${this.roomSubset.roomId} have changed: emitting (${this.memberships.length} members)`, ); - } else { - this.logger.warn("Own membership eventId is undefined, cannot send call notification"); + logDurationSync(this.logger, "emit MatrixRTCSessionEvent.MembershipsChanged", () => { + this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships); + }); + + void this.membershipManager?.onRTCSessionMemberUpdate(this.memberships); + // The `ownMembership` will be set when calling `onRTCSessionMemberUpdate`. + const ownMembership = this.membershipManager?.ownMembership; + if (this.pendingNotificationToSend && ownMembership && oldMemberships.length === 0) { + // If we're the first member in the call, we're responsible for + // sending the notification event + if (ownMembership.eventId && this.joinConfig?.notificationType) { + this.sendCallNotify( + ownMembership.eventId, + this.joinConfig.notificationType, + ownMembership.callIntent, + ); + } else { + this.logger.warn("Own membership eventId is undefined, cannot send call notification"); + } + } + // If anyone else joins the session it is no longer our responsibility to send the notification. + // (If we were the joiner we already did sent the notification in the block above.) + if (this.memberships.length > 0) this.pendingNotificationToSend = undefined; } - } - // If anyone else joins the session it is no longer our responsibility to send the notification. - // (If we were the joiner we already did sent the notification in the block above.) - if (this.memberships.length > 0) this.pendingNotificationToSend = undefined; - } - // This also needs to be done if `changed` = false - // A member might have updated their fingerprint (created_ts) - void this.encryptionManager?.onMembershipsUpdate(oldMemberships); + // This also needs to be done if `changed` = false + // A member might have updated their fingerprint (created_ts) + void this.encryptionManager?.onMembershipsUpdate(oldMemberships); - this.setExpiryTimer(); + this.setExpiryTimer(); + }, + ); }; } From 4bbb2401421c459fd7de430565cd29a27c3ac9d1 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 6 Oct 2025 15:06:03 +0200 Subject: [PATCH 20/30] fix lints Signed-off-by: Timo K --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 125 ++++++++++--------- src/matrixrtc/MatrixRTCSession.ts | 119 ++++++++++-------- src/matrixrtc/MatrixRTCSessionManager.ts | 16 +-- 3 files changed, 141 insertions(+), 119 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 390a201fe46..f030f4235ce 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -38,6 +38,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 +50,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 +63,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 +90,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 +152,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 +187,29 @@ 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); }); }); 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 +218,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 +231,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 +239,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 +251,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,7 +264,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", @@ -273,7 +275,7 @@ describe("MatrixRTCSession", () => { ); 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", @@ -284,7 +286,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", @@ -300,7 +302,7 @@ describe("MatrixRTCSession", () => { let sendStateEventMock: jest.Mock; let sentStateEvent: Promise; - beforeEach(() => { + beforeEach(async () => { sentStateEvent = new Promise((resolve) => { sendStateEventMock = jest.fn(resolve); }); @@ -311,7 +313,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 () => { @@ -347,8 +349,14 @@ describe("MatrixRTCSession", () => { sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" }); await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]); + const { resolve: r, promise: p } = Promise.withResolvers(); + sess?.once(MatrixRTCSessionEvent.JoinStateChanged, r); + await p; mockRoomState(mockRoom, [{ ...membershipTemplate, user_id: client.getUserId()! }]); sess!.onRTCSessionMemberUpdate(); + const { resolve, promise } = Promise.withResolvers(); + sess?.once(MatrixRTCSessionEvent.MembershipsChanged, resolve); + await promise; const ownMembershipId = sess?.memberships[0].eventId; expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.RTCNotification, { @@ -361,7 +369,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", @@ -498,9 +505,9 @@ 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); @@ -509,9 +516,9 @@ describe("MatrixRTCSession", () => { 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); @@ -555,7 +562,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" }); @@ -566,7 +573,7 @@ describe("MatrixRTCSession", () => { client.encryptAndSendToDevice = sendToDeviceMock; mockRoom = makeMockRoom([]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); }); afterEach(async () => { @@ -685,7 +692,7 @@ describe("MatrixRTCSession", () => { device_id: "BBBBBBB", }); const mockRoom = makeMockRoom([membershipTemplate, member2]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); // joining will trigger an initial key send const keysSentPromise1 = new Promise((resolve) => { @@ -734,7 +741,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)); @@ -785,7 +792,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; @@ -830,7 +837,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; @@ -894,7 +901,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( @@ -984,7 +991,7 @@ 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 @@ -1010,7 +1017,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); @@ -1051,7 +1058,7 @@ 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, @@ -1074,7 +1081,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", { @@ -1099,7 +1106,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", { @@ -1125,7 +1132,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", { @@ -1176,7 +1183,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( @@ -1235,7 +1242,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( @@ -1277,9 +1284,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( @@ -1302,7 +1309,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); @@ -1356,9 +1363,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 }); @@ -1372,9 +1379,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/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 6706a93f4de..fe483c3b286 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"; @@ -50,7 +51,6 @@ import { } from "./RoomAndToDeviceKeyTransport.ts"; import { TypedReEmitter } from "../ReEmitter.ts"; import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts"; -import { MatrixEvent } from "../models/event.ts"; /** * Events emitted by MatrixRTCSession @@ -311,8 +311,9 @@ export class MatrixRTCSession extends TypedEventEmitter< */ public static async callMembershipsForRoom( room: Pick, + client: Pick, ): Promise { - return await MatrixRTCSession.sessionMembershipsForSlot(room, { + return await MatrixRTCSession.sessionMembershipsForSlot(room, client, { id: "", application: "m.call", }); @@ -323,9 +324,10 @@ export class MatrixRTCSession extends TypedEventEmitter< */ public static async sessionMembershipsForRoom( room: Pick, + client: Pick, sessionDescription: SlotDescription, ): Promise { - return await this.sessionMembershipsForSlot(room, sessionDescription); + return await this.sessionMembershipsForSlot(room, client, sessionDescription); } /** @@ -334,6 +336,7 @@ export class MatrixRTCSession extends TypedEventEmitter< */ public static async sessionMembershipsForSlot( room: Pick, + client: Pick, slotDescription: SlotDescription, existingMemberships?: CallMembership[], ): Promise { @@ -350,9 +353,18 @@ export class MatrixRTCSession extends TypedEventEmitter< let membership = existingMemberships?.find((m) => m.eventId === memberEvent.getId()); if (!membership) { const relatedEventId = memberEvent.relationEventId; + const getRelatedMatrixEvent = async (): Promise => { + const eventData = await client + .fetchRoomEvent(room.roomId, relatedEventId!) + .catch((e) => + logger.error(`Could not get related event ${relatedEventId} for call membership`, e), + ); + + return eventData ? new MatrixEvent(eventData) : undefined; + }; const relatedEvent = relatedEventId ? room.findEventById(relatedEventId) - : new MatrixEvent(await room.client.fetchRoomEvent(room.roomId, relatedEventId!)); + : await getRelatedMatrixEvent(); try { membership = new CallMembership(memberEvent, relatedEvent); @@ -403,7 +415,7 @@ export class MatrixRTCSession extends TypedEventEmitter< * @deprecated Use `MatrixRTCSession.sessionForSlot` with sessionDescription `{ id: "", application: "m.call" }` instead. */ public static async roomSessionForRoom(client: MatrixClient, room: Room): Promise { - const callMemberships = await MatrixRTCSession.sessionMembershipsForSlot(room, { + const callMemberships = await MatrixRTCSession.sessionMembershipsForSlot(room, client, { id: "", application: "m.call", }); @@ -413,7 +425,7 @@ export class MatrixRTCSession extends TypedEventEmitter< /** * @deprecated Use `MatrixRTCSession.sessionForSlot` instead. */ - public static async sessionForRoom( + public static sessionForRoom( client: MatrixClient, room: Room, slotDescription: SlotDescription, @@ -431,7 +443,7 @@ export class MatrixRTCSession extends TypedEventEmitter< room: Room, slotDescription: SlotDescription, ): Promise { - const callMemberships = await MatrixRTCSession.sessionMembershipsForSlot(room, slotDescription); + const callMemberships = await MatrixRTCSession.sessionMembershipsForSlot(room, client, slotDescription); return new MatrixRTCSession(client, room, callMemberships, slotDescription); } @@ -472,6 +484,7 @@ export class MatrixRTCSession extends TypedEventEmitter< | "off" | "on" | "decryptEventIfNeeded" + | "fetchRoomEvent" >, private roomSubset: Pick< Room, @@ -784,14 +797,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(); + void this.recalculateSessionMembers(); }; /** @@ -801,52 +814,54 @@ export class MatrixRTCSession extends TypedEventEmitter< * * This function should be called when the room members or call memberships might have changed. */ - private recalculateSessionMembers = (): void => { + private recalculateSessionMembers = async (): Promise => { const oldMemberships = this.memberships; - void MatrixRTCSession.sessionMembershipsForSlot(this.room, this.slotDescription, oldMemberships).then( - (newMemberships) => { - this.memberships = newMemberships; - this._slotId = this._slotId ?? this.memberships[0]?.slotId; - - const changed = - oldMemberships.length != this.memberships.length || - // If they have the same length, this is enough to check "changed" - oldMemberships.some((m, i) => !CallMembership.equal(m, this.memberships[i])); - - if (changed) { - this.logger.info( - `Memberships for call in room ${this.roomSubset.roomId} have changed: emitting (${this.memberships.length} members)`, + const newMemberships = await MatrixRTCSession.sessionMembershipsForSlot( + this.room, + this.client, + this.slotDescription, + oldMemberships, + ); + this.memberships = newMemberships; + this._slotId = this._slotId ?? this.memberships[0]?.slotId; + + const changed = + oldMemberships.length != this.memberships.length || + // If they have the same length, this is enough to check "changed" + oldMemberships.some((m, i) => !CallMembership.equal(m, this.memberships[i])); + + if (changed) { + this.logger.info( + `Memberships for call in room ${this.roomSubset.roomId} have changed: emitting (${this.memberships.length} members)`, + ); + logDurationSync(this.logger, "emit MatrixRTCSessionEvent.MembershipsChanged", () => { + this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships); + }); + + void this.membershipManager?.onRTCSessionMemberUpdate(this.memberships); + // The `ownMembership` will be set when calling `onRTCSessionMemberUpdate`. + const ownMembership = this.membershipManager?.ownMembership; + if (this.pendingNotificationToSend && ownMembership && oldMemberships.length === 0) { + // If we're the first member in the call, we're responsible for + // sending the notification event + if (ownMembership.eventId && this.joinConfig?.notificationType) { + this.sendCallNotify( + ownMembership.eventId, + this.joinConfig.notificationType, + ownMembership.callIntent, ); - logDurationSync(this.logger, "emit MatrixRTCSessionEvent.MembershipsChanged", () => { - this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships); - }); - - void this.membershipManager?.onRTCSessionMemberUpdate(this.memberships); - // The `ownMembership` will be set when calling `onRTCSessionMemberUpdate`. - const ownMembership = this.membershipManager?.ownMembership; - if (this.pendingNotificationToSend && ownMembership && oldMemberships.length === 0) { - // If we're the first member in the call, we're responsible for - // sending the notification event - if (ownMembership.eventId && this.joinConfig?.notificationType) { - this.sendCallNotify( - ownMembership.eventId, - this.joinConfig.notificationType, - ownMembership.callIntent, - ); - } else { - this.logger.warn("Own membership eventId is undefined, cannot send call notification"); - } - } - // If anyone else joins the session it is no longer our responsibility to send the notification. - // (If we were the joiner we already did sent the notification in the block above.) - if (this.memberships.length > 0) this.pendingNotificationToSend = undefined; + } else { + this.logger.warn("Own membership eventId is undefined, cannot send call notification"); } - // This also needs to be done if `changed` = false - // A member might have updated their fingerprint (created_ts) - void this.encryptionManager?.onMembershipsUpdate(oldMemberships); + } + // If anyone else joins the session it is no longer our responsibility to send the notification. + // (If we were the joiner we already did sent the notification in the block above.) + if (this.memberships.length > 0) this.pendingNotificationToSend = undefined; + } + // This also needs to be done if `changed` = false + // A member might have updated their fingerprint (created_ts) + void this.encryptionManager?.onMembershipsUpdate(oldMemberships); - this.setExpiryTimer(); - }, - ); + this.setExpiryTimer(); }; } diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index f2f49cc9136..6f60b6ed16c 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.sessionDescription); + const session = await MatrixRTCSession.sessionForRoom(this.client, room, this.sessionDescription); 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.sessionDescription), + await MatrixRTCSession.sessionForRoom(this.client, room, this.sessionDescription), ); } @@ -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, From aa1cbe90ef97a7e47bc2bf2e31142e8f87c4963f Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 6 Oct 2025 17:49:46 +0200 Subject: [PATCH 21/30] correct usage of methods that now became async Signed-off-by: Timo K --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 52 ++++++++++---------- src/matrixrtc/MatrixRTCSession.ts | 8 +-- src/matrixrtc/MatrixRTCSessionManager.ts | 2 +- src/matrixrtc/MembershipManager.ts | 38 +++++++------- 4 files changed, 50 insertions(+), 50 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index f030f4235ce..86b0562cbdb 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -304,7 +304,10 @@ describe("MatrixRTCSession", () => { let sentStateEvent: Promise; 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; @@ -349,14 +352,8 @@ describe("MatrixRTCSession", () => { sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" }); await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]); - const { resolve: r, promise: p } = Promise.withResolvers(); - sess?.once(MatrixRTCSessionEvent.JoinStateChanged, r); - await p; mockRoomState(mockRoom, [{ ...membershipTemplate, user_id: client.getUserId()! }]); - sess!.onRTCSessionMemberUpdate(); - const { resolve, promise } = Promise.withResolvers(); - sess?.once(MatrixRTCSessionEvent.MembershipsChanged, resolve); - await promise; + await sess!.onRTCSessionMemberUpdate(); const ownMembershipId = sess?.memberships[0].eventId; expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.RTCNotification, { @@ -425,7 +422,8 @@ describe("MatrixRTCSession", () => { }, ]); - sess!.onRTCSessionMemberUpdate(); + await sess!.onRTCSessionMemberUpdate(); + const ownMembershipId = sess?.memberships[0].eventId; expect(sess!.getConsensusCallIntent()).toEqual("audio"); @@ -476,13 +474,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(); }); @@ -494,9 +492,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 @@ -511,7 +509,7 @@ describe("MatrixRTCSession", () => { const onMembershipsChanged = jest.fn(); sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); - sess.onRTCSessionMemberUpdate(); + await sess.onRTCSessionMemberUpdate(); expect(onMembershipsChanged).not.toHaveBeenCalled(); }); @@ -524,8 +522,8 @@ describe("MatrixRTCSession", () => { sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); mockRoomState(mockRoom, []); - sess.onRTCSessionMemberUpdate(); + await sess.onRTCSessionMemberUpdate(); expect(onMembershipsChanged).toHaveBeenCalled(); }); @@ -708,14 +706,14 @@ describe("MatrixRTCSession", () => { // member2 leaves triggering key rotation mockRoomState(mockRoom, [membershipTemplate]); - sess.onRTCSessionMemberUpdate(); + 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(); + await sess.onRTCSessionMemberUpdate(); // but, that immediate resend is throttled so we need to wait a bit jest.advanceTimersByTime(1000); const { keys } = await keysSentPromise2; @@ -766,7 +764,7 @@ describe("MatrixRTCSession", () => { }); mockRoomState(mockRoom, [membershipTemplate, member2]); - sess.onRTCSessionMemberUpdate(); + await sess.onRTCSessionMemberUpdate(); await keysSentPromise2; @@ -813,7 +811,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 { @@ -858,7 +856,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 @@ -873,7 +871,7 @@ describe("MatrixRTCSession", () => { }); // this should re-send the key - sess.onRTCSessionMemberUpdate(); + await sess.onRTCSessionMemberUpdate(); await keysSentPromise2; @@ -931,8 +929,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. @@ -998,7 +998,7 @@ describe("MatrixRTCSession", () => { mockRoomState(mockRoom, members.slice(0, membersToTest - i)); } - sess!.onRTCSessionMemberUpdate(); + await sess!.onRTCSessionMemberUpdate(); // advance time to avoid key throttling jest.advanceTimersByTime(10000); @@ -1037,7 +1037,7 @@ describe("MatrixRTCSession", () => { }); mockRoomState(mockRoom, [membershipTemplate, member2]); - sess.onRTCSessionMemberUpdate(); + await sess.onRTCSessionMemberUpdate(); await new Promise((resolve) => { realSetTimeout(resolve); @@ -1064,7 +1064,7 @@ describe("MatrixRTCSession", () => { manageMediaKeys: true, useExperimentalToDeviceTransport: true, }); - sess.onRTCSessionMemberUpdate(); + await sess.onRTCSessionMemberUpdate(); await keySentPromise; diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index fe483c3b286..5410eeb5ef3 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -731,7 +731,7 @@ export class MatrixRTCSession extends TypedEventEmitter< } if (soonestExpiry != undefined) { - this.expiryTimeout = setTimeout(this.onRTCSessionMemberUpdate, soonestExpiry); + this.expiryTimeout = setTimeout(() => void this.onRTCSessionMemberUpdate(), soonestExpiry); } } @@ -803,8 +803,8 @@ export class MatrixRTCSession extends TypedEventEmitter< /** * Call this when something changed that may impacts the current MatrixRTC members in this session. */ - public onRTCSessionMemberUpdate = (): void => { - void this.recalculateSessionMembers(); + public onRTCSessionMemberUpdate = (): Promise => { + return this.recalculateSessionMembers(); }; /** @@ -838,7 +838,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) { diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index 6f60b6ed16c..577a240f11e 100644 --- a/src/matrixrtc/MatrixRTCSessionManager.ts +++ b/src/matrixrtc/MatrixRTCSessionManager.ts @@ -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 35183753b58..4e83a8605ca 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -398,7 +398,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. @@ -418,19 +418,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: { @@ -442,7 +442,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 @@ -452,12 +452,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, { @@ -514,9 +514,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; @@ -557,7 +557,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() @@ -579,7 +579,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, ]) @@ -618,8 +618,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; @@ -646,8 +646,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, @@ -691,9 +691,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, @@ -720,8 +720,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, From 65a346146bcbc4bf5b284b184ebf7ab2594d2e86 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 7 Oct 2025 15:52:36 +0200 Subject: [PATCH 22/30] fix lints Signed-off-by: Timo K --- spec/unit/matrixrtc/mocks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index d61670d79fa..2ddee28d8cf 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -146,7 +146,7 @@ export function mockRTCEvent({ user_id: sender, ...membershipData }: MembershipD } 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 } { From e9dafb57350c9b687b2e2b676523ca890fcf59e1 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 7 Oct 2025 17:11:21 +0200 Subject: [PATCH 23/30] fix tests Signed-off-by: Timo K --- .../matrixrtc/MatrixRTCSessionManager.spec.ts | 45 ++++++++++++++----- spec/unit/matrixrtc/MembershipManager.spec.ts | 7 +-- spec/unit/matrixrtc/RoomKeyTransport.spec.ts | 4 +- src/matrixrtc/MatrixRTCSession.ts | 4 +- 4 files changed, 41 insertions(+), 19 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts index 9472dc16edd..d5982f33964 100644 --- a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts @@ -23,9 +23,9 @@ 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]); 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); @@ -63,34 +68,48 @@ 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 { 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([membershipTemplate]); 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 }]); - 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" }]); @@ -104,6 +123,7 @@ describe("MatrixRTCSessionManager", () => { jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]); client.emit(ClientEvent.Room, room2); + await startPromise; expect(onStarted).toHaveBeenCalled(); onStarted.mockClear(); @@ -113,6 +133,7 @@ describe("MatrixRTCSessionManager", () => { 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(); diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index e63a01ecb79..dd079b72493 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -33,6 +33,7 @@ import { } from "../../../src/matrixrtc"; import { makeMockClient, makeMockRoom, membershipTemplate, 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. @@ -923,18 +924,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 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/RoomKeyTransport.spec.ts b/spec/unit/matrixrtc/RoomKeyTransport.spec.ts index f08cced850d..43888ace8f8 100644 --- a/spec/unit/matrixrtc/RoomKeyTransport.spec.ts +++ b/spec/unit/matrixrtc/RoomKeyTransport.spec.ts @@ -31,7 +31,7 @@ describe("RoomKeyTransport", () => { let mockLogger: Mocked; const onCallEncryptionMock = jest.fn(); - beforeEach(() => { + beforeEach(async () => { onCallEncryptionMock.mockReset(); mockLogger = { debug: jest.fn(), @@ -50,7 +50,7 @@ describe("RoomKeyTransport", () => { }; room = makeMockRoom([membershipTemplate]); 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/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 5410eeb5ef3..7ddf84ac2b9 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -363,8 +363,8 @@ export class MatrixRTCSession extends TypedEventEmitter< return eventData ? new MatrixEvent(eventData) : undefined; }; const relatedEvent = relatedEventId - ? room.findEventById(relatedEventId) - : await getRelatedMatrixEvent(); + ? (room.findEventById(relatedEventId) ?? (await getRelatedMatrixEvent())) + : undefined; try { membership = new CallMembership(memberEvent, relatedEvent); From 383b219f94655ba860cc9e52c8ee44bb804f561b Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 7 Oct 2025 17:28:02 +0200 Subject: [PATCH 24/30] add test Signed-off-by: Timo K --- spec/unit/matrixrtc/CallMembership.spec.ts | 65 ++++++++++------------ spec/unit/matrixrtc/mocks.ts | 15 ++--- 2 files changed, 38 insertions(+), 42 deletions(-) diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 0a0ef4b504b..da143227342 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -15,12 +15,7 @@ limitations under the License. */ import { type MatrixEvent } from "../../../src"; -import { - CallMembership, - type SessionMembershipData, - DEFAULT_EXPIRE_DURATION, - type RtcMembershipData, -} from "../../../src/matrixrtc/CallMembership"; +import { CallMembership, DEFAULT_EXPIRE_DURATION, type RtcMembershipData } from "../../../src/matrixrtc/CallMembership"; import { membershipTemplate } from "./mocks"; function makeMockEvent(originTs = 0, content = {}): MatrixEvent { @@ -42,16 +37,6 @@ 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(0, Object.assign({}, membershipTemplate, { device_id: undefined }))); @@ -184,6 +169,19 @@ 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", () => { @@ -328,28 +326,25 @@ describe("CallMembership", () => { expect(membership.isExpired()).toBe(false); }); }); - }); - - describe("expiry calculation", () => { - let fakeEvent: MatrixEvent; - let membership: CallMembership; - - beforeEach(() => { - // server origin timestamp for this event is 1000 - fakeEvent = makeMockEvent(1000, membershipTemplate); - membership = new CallMembership(fakeEvent!); - jest.useFakeTimers(); - }); + describe("expiry calculation", () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); - 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 + expect(membership.getMsUntilExpiry()).toEqual(DEFAULT_EXPIRE_DURATION - 1500); + }); }); }); }); diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index 2ddee28d8cf..3d266d90ec4 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -23,13 +23,13 @@ import { secureRandomString } from "../../../src/randomstring"; export type MembershipData = (SessionMembershipData | {}) & { 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: [ + "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 +41,7 @@ export const membershipTemplate: SessionMembershipData & { user_id: string } = { type: "livekit", }, ], + "m.call.intent": "voice", }; export type MockClient = Pick< From 3c2f9b48b0fab5514629f874fb7165977a0597d8 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 7 Oct 2025 18:31:38 +0200 Subject: [PATCH 25/30] sonar cubes coaching ;) Signed-off-by: Timo K --- spec/unit/matrixrtc/CallMembership.spec.ts | 15 ++--- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 57 +++++++++++++++- .../matrixrtc/MatrixRTCSessionManager.spec.ts | 22 +++--- spec/unit/matrixrtc/MembershipManager.spec.ts | 23 ++++--- .../matrixrtc/RTCEncryptionManager.spec.ts | 4 +- spec/unit/matrixrtc/RoomKeyTransport.spec.ts | 4 +- spec/unit/matrixrtc/mocks.ts | 38 +++++++---- src/matrixrtc/CallMembership.ts | 8 +-- src/matrixrtc/MatrixRTCSession.ts | 67 ++++++++++--------- 9 files changed, 151 insertions(+), 87 deletions(-) diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index da143227342..51403da514f 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -15,8 +15,8 @@ limitations under the License. */ import { type MatrixEvent } from "../../../src"; -import { CallMembership, DEFAULT_EXPIRE_DURATION, type RtcMembershipData } from "../../../src/matrixrtc/CallMembership"; -import { membershipTemplate } from "./mocks"; +import { CallMembership, DEFAULT_EXPIRE_DURATION } from "../../../src/matrixrtc/CallMembership"; +import { rtcMembershipTemplate, sessionMembershipTemplate } from "./mocks"; function makeMockEvent(originTs = 0, content = {}): MatrixEvent { return { @@ -29,6 +29,7 @@ function makeMockEvent(originTs = 0, content = {}): MatrixEvent { describe("CallMembership", () => { describe("SessionMembershipData", () => { + const membershipTemplate = sessionMembershipTemplate; beforeEach(() => { jest.useFakeTimers(); }); @@ -185,15 +186,7 @@ describe("CallMembership", () => { }); 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" }], - "m.call.intent": "voice", - "versions": [], - }; - + const membershipTemplate = rtcMembershipTemplate; it("rejects membership with no slot_id", () => { expect(() => { new CallMembership(makeMockEvent(0, { ...membershipTemplate, slot_id: undefined })); diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 86b0562cbdb..822d33e82e4 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -14,12 +14,29 @@ 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, + sessionMembershipTemplate as membershipTemplate, + makeKey, + type MembershipData, + mockRoomState, + rtcMembershipTemplate, + sessionMembershipTemplate, +} from "./mocks"; import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts"; const mockFocus = { type: "mock" }; @@ -50,6 +67,7 @@ describe("MatrixRTCSession", () => { }); describe("roomSessionForRoom", () => { + const membershipTemplate = sessionMembershipTemplate; it("creates a room-scoped session from room state", async () => { const mockRoom = makeMockRoom([membershipTemplate]); @@ -206,6 +224,41 @@ describe("MatrixRTCSession", () => { 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]); + mockRoom.findEventById = jest + .fn() + .mockImplementation((id) => + id === "id" + ? new MatrixEvent({ content: { ...rtcMembershipTemplate }, origin_server_ts: 100 }) + : undefined, + ); + sess = await MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); + expect(sess.memberships[0].createdTs()).toBe(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]); + mockRoom.findEventById = jest.fn().mockReturnValue(undefined); + client.fetchRoomEvent = jest + .fn() + .mockResolvedValue({ content: { ...rtcMembershipTemplate }, origin_server_ts: 100 }); + sess = await MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); + expect(sess.memberships[0].createdTs()).toBe(100); + }); }); describe("getOldestMembership", () => { diff --git a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts index d5982f33964..7d2f60f8aca 100644 --- a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts @@ -17,7 +17,7 @@ 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", () => { @@ -42,7 +42,7 @@ describe("MatrixRTCSessionManager", () => { }); try { - const room1 = makeMockRoom([membershipTemplate]); + const room1 = makeMockRoom([sessionMembershipTemplate]); jest.spyOn(client, "getRooms").mockReturnValue([room1]); client.emit(ClientEvent.Room, room1); @@ -58,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); @@ -76,14 +76,14 @@ describe("MatrixRTCSessionManager", () => { client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionEnded, rEnd); client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionStarted, rStart); - const room1 = makeMockRoom([membershipTemplate]); + 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); @@ -112,14 +112,14 @@ describe("MatrixRTCSessionManager", () => { }); 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); @@ -127,7 +127,7 @@ describe("MatrixRTCSessionManager", () => { 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)!; @@ -137,7 +137,7 @@ describe("MatrixRTCSessionManager", () => { 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)!; @@ -153,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 dd079b72493..b62a792dcf6 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -31,7 +31,7 @@ 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"; @@ -90,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); @@ -385,7 +385,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(); @@ -468,7 +468,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(); @@ -485,7 +485,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, @@ -508,7 +508,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(); @@ -531,7 +531,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); @@ -900,7 +900,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); @@ -914,7 +917,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]); @@ -927,7 +930,7 @@ describe("MembershipManager", () => { 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); diff --git a/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts b/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts index de88d47f608..00098a5eae1 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 43888ace8f8..f0770f97f8b 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"; @@ -48,7 +48,7 @@ describe("RoomKeyTransport", () => { roomEventEncryptionKeysReceivedTotalAge: 0, }, }; - room = makeMockRoom([membershipTemplate]); + room = makeMockRoom([sessionMembershipTemplate]); client = new MatrixClient({ baseUrl: "base_url" }); await client.matrixRTC.start(); transport = new RoomKeyTransport(room, client, statistics, { diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index 3d266d90ec4..bc53b282bb4 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -16,13 +16,17 @@ 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 } = { +export const sessionMembershipTemplate: SessionMembershipData & { user_id: string } = { "application": "m.call", "call_id": "", "user_id": "@mock:user.example", @@ -44,6 +48,15 @@ export const membershipTemplate: SessionMembershipData & { user_id: string } = { "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" }], + "m.call.intent": "voice", + "versions": [], +}; + export type MockClient = Pick< MatrixClient, | "getUserId" @@ -131,15 +144,14 @@ 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: "mock_event_id", + sender, + type, + content, + room_id: roomId, + origin_server_ts: timestamp ?? 0, + }); } export function mockRTCEvent({ user_id: sender, ...membershipData }: MembershipData, roomId: string): MatrixEvent { diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index f92af88aedf..6afbabb41dd 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -230,8 +230,8 @@ export class CallMembership { * @throws if the data does not match any known membership format. */ public constructor( - private matrixEvent: MatrixEvent, - private relatedEvent?: MatrixEvent, + private readonly matrixEvent: MatrixEvent, + private readonly relatedEvent?: MatrixEvent, ) { const data = matrixEvent.getContent() as any; const sessionErrors: string[] = []; @@ -252,8 +252,8 @@ export class CallMembership { 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"); + if (eventId === undefined) throw new Error("CallMembership matrixEvent is missing eventId field"); + if (sender === undefined) throw new Error("CallMembership matrixEvent is missing sender field"); this.matrixEventData = { eventId, sender }; } diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 7ddf84ac2b9..7cdb7d19d85 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -349,37 +349,40 @@ export class MatrixRTCSession extends TypedEventEmitter< const callMemberEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix); const callMemberships: CallMembership[] = []; + 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)); + + 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; + }; + for (const memberEvent of callMemberEvents) { let membership = existingMemberships?.find((m) => m.eventId === memberEvent.getId()); - if (!membership) { - const relatedEventId = memberEvent.relationEventId; - const getRelatedMatrixEvent = async (): Promise => { - const eventData = await client - .fetchRoomEvent(room.roomId, relatedEventId!) - .catch((e) => - logger.error(`Could not get related event ${relatedEventId} for call membership`, e), - ); - - return eventData ? new MatrixEvent(eventData) : undefined; - }; - const relatedEvent = relatedEventId - ? (room.findEventById(relatedEventId) ?? (await getRelatedMatrixEvent())) - : undefined; - - try { - membership = new CallMembership(memberEvent, relatedEvent); - } catch (e) { - logger.warn("Couldn't construct call membership: ", e); - continue; - } - // static check for newly created memberships - if (!deepCompare(membership.slotDescription, slotDescription)) { - logger.info( - `Ignoring membership of user ${membership.sender} for a different session: ${JSON.stringify(membership.slotDescription)}`, - ); - continue; - } - } + if (!membership) membership = await createMembership(memberEvent); + if (!membership) continue; // Dynamic checks for all (including existing) memberships if (membership.isExpired()) { @@ -394,7 +397,7 @@ export class MatrixRTCSession extends TypedEventEmitter< } 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]), @@ -814,7 +817,7 @@ export class MatrixRTCSession extends TypedEventEmitter< * * This function should be called when the room members or call memberships might have changed. */ - private recalculateSessionMembers = async (): Promise => { + private async recalculateSessionMembers(): Promise { const oldMemberships = this.memberships; const newMemberships = await MatrixRTCSession.sessionMembershipsForSlot( this.room, @@ -863,5 +866,5 @@ export class MatrixRTCSession extends TypedEventEmitter< void this.encryptionManager?.onMembershipsUpdate(oldMemberships); this.setExpiryTimer(); - }; + } } From 9946143014af184ee405b058189f447cd5c68b33 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 7 Oct 2025 20:10:04 +0200 Subject: [PATCH 26/30] fix mocking issues Signed-off-by: Timo K --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 19 +++++++++++++------ spec/unit/matrixrtc/mocks.ts | 4 ++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 822d33e82e4..3de0eab8015 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -30,7 +30,6 @@ import { secureRandomString } from "../../../src/randomstring"; import { makeMockEvent, makeMockRoom, - sessionMembershipTemplate as membershipTemplate, makeKey, type MembershipData, mockRoomState, @@ -46,6 +45,7 @@ const textEncoder = new TextEncoder(); const callSession = { id: "", application: "m.call" }; describe("MatrixRTCSession", () => { + const membershipTemplate = sessionMembershipTemplate; let client: MatrixClient; let sess: MatrixRTCSession | undefined; @@ -67,7 +67,6 @@ describe("MatrixRTCSession", () => { }); describe("roomSessionForRoom", () => { - const membershipTemplate = sessionMembershipTemplate; it("creates a room-scoped session from room state", async () => { const mockRoom = makeMockRoom([membershipTemplate]); @@ -412,6 +411,7 @@ describe("MatrixRTCSession", () => { 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", @@ -431,6 +431,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": { @@ -738,11 +739,17 @@ 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]); + const mockRoom = makeMockRoom([ownMembership, member2]); sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); // joining will trigger an initial key send @@ -758,14 +765,14 @@ describe("MatrixRTCSession", () => { expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); // member2 leaves triggering key rotation - mockRoomState(mockRoom, [membershipTemplate]); + 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]); + mockRoomState(mockRoom, [ownMembership, member2]); await sess.onRTCSessionMemberUpdate(); // but, that immediate resend is throttled so we need to wait a bit jest.advanceTimersByTime(1000); diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index bc53b282bb4..b8a96eb39d6 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -145,7 +145,7 @@ export function makeMockEvent( timestamp?: number, ): MatrixEvent { return new MatrixEvent({ - event_id: "mock_event_id", + event_id: secureRandomString(8), sender, type, content, @@ -155,7 +155,7 @@ export function makeMockEvent( } 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 { From b4abbfcc16a607ce0b62426363528fa443c93975 Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 9 Oct 2025 10:52:05 +0200 Subject: [PATCH 27/30] introduce updateTs and connectedTs to fix expiration logic (+ fix tests) Signed-off-by: Timo K --- spec/unit/matrixrtc/CallMembership.spec.ts | 10 +++++-- src/matrixrtc/CallMembership.ts | 35 +++++++++++++++++++--- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index fd1bb6d5e17..a1fe08f7f8f 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -337,12 +337,18 @@ describe("CallMembership", () => { const now = Date.now(); const startEv = makeMockEvent(now - DEFAULT_EXPIRE_DURATION - 100, membershipTemplate); const membershipWithRel = new CallMembership( - //update 1100 ms later (so the update is still after expiry) - makeMockEvent(now - DEFAULT_EXPIRE_DURATION + 1000, membershipTemplate), + //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()); }); diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 77a37bbf740..77b2a2db6f6 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -394,7 +394,19 @@ 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": @@ -405,19 +417,34 @@ export class CallMembership { } } + /** @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 this.createdTs() + DEFAULT_EXPIRE_DURATION; + // 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); } } From 61b05a02dc0055565cc6591b0cce2e49a11cafac Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 9 Oct 2025 11:44:18 +0200 Subject: [PATCH 28/30] review valere Signed-off-by: Timo K --- spec/unit/matrixrtc/CallMembership.spec.ts | 19 ++++++++++------ spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 2 +- src/matrixrtc/CallMembership.ts | 19 ++++++++-------- src/models/room-sticky-events.ts | 24 ++++++++++++++++++++ 4 files changed, 46 insertions(+), 18 deletions(-) diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index a1fe08f7f8f..83c99496178 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -81,29 +81,35 @@ describe("CallMembership", () => { }); it("returns preferred foci", () => { - const mockFocus = { type: "this_is_a_mock_focus" }; + 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).toEqual([mockFocus]); + 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 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(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", () => { @@ -115,7 +121,7 @@ describe("CallMembership", () => { }), ); - // if we are the oldest member we use our focus. + // 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. @@ -130,7 +136,6 @@ describe("CallMembership", () => { }), ); - // if we are the oldest member we use our focus. expect(membership.getTransport(membership)).toBeUndefined(); }); }); diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 69c1c02a9ac..1c64e53bfee 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -38,7 +38,7 @@ import { } 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(); diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 77b2a2db6f6..99a34315d29 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 "src/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"); } diff --git a/src/models/room-sticky-events.ts b/src/models/room-sticky-events.ts index 3e9e26a2bd5..d0fcaea7258 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", } From 06a46ac6f63c30f9887bb8816071b7360b5447ce Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 9 Oct 2025 14:40:23 +0200 Subject: [PATCH 29/30] add sticky_key to makeMyMembership Signed-off-by: Timo K --- src/matrixrtc/CallMembership.ts | 2 +- src/matrixrtc/MembershipManager.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 99a34315d29..ce61720c337 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -22,7 +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 "src/models/room-sticky-events.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. diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index dee132bafbe..a102b466239 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)───────────┐ @@ -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 = From 4608506288c6beaa252982d224e996e23e51f681 Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 9 Oct 2025 14:58:31 +0200 Subject: [PATCH 30/30] fix ts tests Signed-off-by: Timo K --- spec/unit/matrixrtc/CallMembership.spec.ts | 11 ++++++----- spec/unit/matrixrtc/MembershipManager.spec.ts | 2 ++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 83c99496178..a96e94f9927 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -377,7 +377,8 @@ describe("CallMembership", () => { }); describe("correct values from computed fields", () => { - const membership = new CallMembership(makeMockEvent(0, membershipTemplate)); + const now = Date.now(); + const membership = new CallMembership(makeMockEvent(now, membershipTemplate)); it("returns correct sender", () => { expect(membership.sender).toBe("@alice:example.org"); }); @@ -411,8 +412,8 @@ describe("CallMembership", () => { expect(membership.membershipID).toBe("xyzHASHxyz"); }); it("returns correct expiration fields", () => { - expect(membership.getAbsoluteExpiry()).toBe(DEFAULT_EXPIRE_DURATION); - expect(membership.getMsUntilExpiry()).toBe(DEFAULT_EXPIRE_DURATION - Date.now()); + expect(membership.getAbsoluteExpiry()).toBe(now + DEFAULT_EXPIRE_DURATION); + expect(membership.getMsUntilExpiry()).toBe(now + DEFAULT_EXPIRE_DURATION - Date.now()); expect(membership.isExpired()).toBe(false); }); }); @@ -432,8 +433,8 @@ describe("CallMembership", () => { const initialEvent = makeMockEvent(500, membershipTemplate); const membership = new CallMembership(fakeEvent, initialEvent); jest.setSystemTime(2000); - // should be using absolute expiry time - expect(membership.getMsUntilExpiry()).toEqual(DEFAULT_EXPIRE_DURATION - 1500); + // 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/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index d881cacd2d5..211c74cde48 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -162,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", @@ -175,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", );