1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-11-23 17:02:25 +03:00

[MatrixRTC] Multi SFU support + m.rtc.member event type support (#5022)

* WIP

* temp

Signed-off-by: Timo K <toger5@hotmail.de>

* Fix imports

* Fix checkSessionsMembershipData thinking foci_preferred is required

* 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 <toger5@hotmail.de>

* use correct event type

Signed-off-by: Timo K <toger5@hotmail.de>

* fix sonar cube conerns

Signed-off-by: Timo K <toger5@hotmail.de>

* callMembership tests

Signed-off-by: Timo K <toger5@hotmail.de>

* make test correct

Signed-off-by: Timo K <toger5@hotmail.de>

* make sonar cube happy (it does not know about the type constraints...)

Signed-off-by: Timo K <toger5@hotmail.de>

* remove created_ts from RtcMembership

Signed-off-by: Timo K <toger5@hotmail.de>

* fix imports

Signed-off-by: Timo K <toger5@hotmail.de>

* Update src/matrixrtc/IMembershipManager.ts

Co-authored-by: Robin <robin@robin.town>

* rename LivekitFocus.ts -> LivekitTransport.ts

Signed-off-by: Timo K <toger5@hotmail.de>

* add details to `getTransport`

Signed-off-by: Timo K <toger5@hotmail.de>

* review

Signed-off-by: Timo K <toger5@hotmail.de>

* use DEFAULT_EXPIRE_DURATION in tests

Signed-off-by: Timo K <toger5@hotmail.de>

* fix test `does not provide focus if the selection method is unknown`

Signed-off-by: Timo K <toger5@hotmail.de>

* Update src/matrixrtc/CallMembership.ts

Co-authored-by: Robin <robin@robin.town>

* Move `m.call.intent` into the `application` section for rtc member events.

Signed-off-by: Timo K <toger5@hotmail.de>

* review on rtc object validation code.

Signed-off-by: Timo K <toger5@hotmail.de>

* user id check

Signed-off-by: Timo K <toger5@hotmail.de>

* review: Refactor RTC membership handling and improve error handling

Signed-off-by: Timo K <toger5@hotmail.de>

* docstring updates

Signed-off-by: Timo K <toger5@hotmail.de>

* add back deprecated `getFocusInUse` & `getActiveFocus`

Signed-off-by: Timo K <toger5@hotmail.de>

* ci

Signed-off-by: Timo K <toger5@hotmail.de>

* Update src/matrixrtc/CallMembership.ts

Co-authored-by: Robin <robin@robin.town>

* lint

Signed-off-by: Timo K <toger5@hotmail.de>

* make test less strict for ew tests

Signed-off-by: Timo K <toger5@hotmail.de>

* Typescript downstream test adjustments

Signed-off-by: Timo K <toger5@hotmail.de>

* err

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Robin <robin@robin.town>
This commit is contained in:
Timo
2025-10-08 21:12:29 +02:00
committed by GitHub
parent 7b3aed8a47
commit fd949fe486
16 changed files with 974 additions and 405 deletions

View File

@@ -19,6 +19,7 @@ import {
CallMembership, CallMembership,
type SessionMembershipData, type SessionMembershipData,
DEFAULT_EXPIRE_DURATION, DEFAULT_EXPIRE_DURATION,
type RtcMembershipData,
} from "../../../src/matrixrtc/CallMembership"; } from "../../../src/matrixrtc/CallMembership";
import { membershipTemplate } from "./mocks"; import { membershipTemplate } from "./mocks";
@@ -26,6 +27,7 @@ function makeMockEvent(originTs = 0): MatrixEvent {
return { return {
getTs: jest.fn().mockReturnValue(originTs), getTs: jest.fn().mockReturnValue(originTs),
getSender: jest.fn().mockReturnValue("@alice:example.org"), getSender: jest.fn().mockReturnValue("@alice:example.org"),
getId: jest.fn().mockReturnValue("$eventid"),
} as unknown as MatrixEvent; } as unknown as MatrixEvent;
} }
@@ -40,12 +42,13 @@ describe("CallMembership", () => {
}); });
const membershipTemplate: SessionMembershipData = { const membershipTemplate: SessionMembershipData = {
call_id: "", "call_id": "",
scope: "m.room", "scope": "m.room",
application: "m.call", "application": "m.call",
device_id: "AAAAAAA", "device_id": "AAAAAAA",
focus_active: { type: "livekit" }, "focus_active": { type: "livekit", focus_selection: "oldest_membership" },
foci_preferred: [{ type: "livekit" }], "foci_preferred": [{ type: "livekit" }],
"m.call.intent": "voice",
}; };
it("rejects membership with no device_id", () => { it("rejects membership with no device_id", () => {
@@ -94,11 +97,271 @@ describe("CallMembership", () => {
it("returns preferred foci", () => { it("returns preferred foci", () => {
const fakeEvent = makeMockEvent(); const fakeEvent = makeMockEvent();
const mockFocus = { type: "this_is_a_mock_focus" }; const mockFocus = { type: "this_is_a_mock_focus" };
const membership = new CallMembership( const membership = new CallMembership(fakeEvent, { ...membershipTemplate, foci_preferred: [mockFocus] });
fakeEvent, expect(membership.transports).toEqual([mockFocus]);
Object.assign({}, membershipTemplate, { foci_preferred: [mockFocus] }), });
);
expect(membership.getPreferredFoci()).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("gets the correct active transport with multi_sfu", () => {
const membership = new CallMembership(makeMockEvent(), {
...membershipTemplate,
foci_preferred: [mockFocus],
focus_active: { type: "livekit", focus_selection: "multi_sfu" },
});
// if we are the oldest member we use our focus.
expect(membership.getTransport(membership)).toStrictEqual(mockFocus);
// If there is an older member we still use our own focus in multi sfu.
expect(membership.getTransport(oldestMembership)).toBe(mockFocus);
});
it("does not provide focus if the selection method is unknown", () => {
const membership = new CallMembership(makeMockEvent(), {
...membershipTemplate,
foci_preferred: [mockFocus],
focus_active: { type: "livekit", focus_selection: "unknown" },
});
// 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);
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(DEFAULT_EXPIRE_DURATION);
expect(membership.getMsUntilExpiry()).toBe(DEFAULT_EXPIRE_DURATION - Date.now());
expect(membership.isExpired()).toBe(true);
});
});
});
describe("RtcMembershipData", () => {
const membershipTemplate: RtcMembershipData = {
slot_id: "m.call#",
application: { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" },
member: { user_id: "@alice:example.org", device_id: "AAAAAAA", id: "xyzHASHxyz" },
rtc_transports: [{ type: "livekit" }],
versions: [],
msc4354_sticky_key: "abc123",
};
it("rejects membership with no slot_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: undefined });
}).toThrow();
});
it("rejects membership with invalid slot_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "invalid_slot_id" });
}).toThrow();
});
it("accepts membership with valid slot_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#" });
}).not.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-wrong-user:user.id" },
});
}).toThrow();
});
it("rejects membership with incorrect sticky_key", () => {
expect(() => {
new CallMembership(makeMockEvent(), membershipTemplate);
}).not.toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
...membershipTemplate,
sticky_key: 1,
msc4354_sticky_key: undefined,
});
}).toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
...membershipTemplate,
sticky_key: "1",
msc4354_sticky_key: undefined,
});
}).not.toThrow();
expect(() => {
new CallMembership(makeMockEvent(), { ...membershipTemplate, msc4354_sticky_key: undefined });
}).toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
...membershipTemplate,
msc4354_sticky_key: 1,
sticky_key: "valid",
});
}).toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
...membershipTemplate,
msc4354_sticky_key: "valid",
sticky_key: "valid",
});
}).not.toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
...membershipTemplate,
msc4354_sticky_key: "valid_but_different",
sticky_key: "valid",
});
}).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" });
});
});
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);
});
}); });
}); });

View File

@@ -14,47 +14,51 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { isLivekitFocus, isLivekitFocusActive, isLivekitFocusConfig } from "../../../src/matrixrtc/LivekitFocus"; import {
isLivekitTransport,
isLivekitFocusSelection,
isLivekitTransportConfig,
} from "../../../src/matrixrtc/LivekitTransport";
describe("LivekitFocus", () => { describe("LivekitFocus", () => {
it("isLivekitFocus", () => { it("isLivekitFocus", () => {
expect( expect(
isLivekitFocus({ isLivekitTransport({
type: "livekit", type: "livekit",
livekit_service_url: "http://test.com", livekit_service_url: "http://test.com",
livekit_alias: "test", livekit_alias: "test",
}), }),
).toBeTruthy(); ).toBeTruthy();
expect(isLivekitFocus({ type: "livekit" })).toBeFalsy(); expect(isLivekitTransport({ type: "livekit" })).toBeFalsy();
expect( 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(); ).toBeFalsy();
expect( 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(); ).toBeFalsy();
expect( 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(); ).toBeFalsy();
}); });
it("isLivekitFocusActive", () => { it("isLivekitFocusActive", () => {
expect( expect(
isLivekitFocusActive({ isLivekitFocusSelection({
type: "livekit", type: "livekit",
focus_selection: "oldest_membership", focus_selection: "oldest_membership",
}), }),
).toBeTruthy(); ).toBeTruthy();
expect(isLivekitFocusActive({ type: "livekit" })).toBeFalsy(); expect(isLivekitFocusSelection({ type: "livekit" })).toBeFalsy();
expect(isLivekitFocusActive({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy(); expect(isLivekitFocusSelection({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy();
}); });
it("isLivekitFocusConfig", () => { it("isLivekitFocusConfig", () => {
expect( expect(
isLivekitFocusConfig({ isLivekitTransportConfig({
type: "livekit", type: "livekit",
livekit_service_url: "http://test.com", livekit_service_url: "http://test.com",
}), }),
).toBeTruthy(); ).toBeTruthy();
expect(isLivekitFocusConfig({ type: "livekit" })).toBeFalsy(); expect(isLivekitTransportConfig({ type: "livekit" })).toBeFalsy();
expect(isLivekitFocusConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy(); expect(isLivekitTransportConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy();
expect(isLivekitFocusConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy(); expect(isLivekitTransportConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy();
}); });
}); });

View File

@@ -53,12 +53,12 @@ describe("MatrixRTCSession", () => {
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess?.memberships.length).toEqual(1); 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].scope).toEqual("m.room");
expect(sess?.memberships[0].application).toEqual("m.call"); expect(sess?.memberships[0].application).toEqual("m.call");
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
expect(sess?.memberships[0].isExpired()).toEqual(false); 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", () => { it("ignores memberships where application is not m.call", () => {
@@ -268,7 +268,6 @@ describe("MatrixRTCSession", () => {
type: "livekit", type: "livekit",
focus_selection: "oldest_membership", focus_selection: "oldest_membership",
}); });
expect(sess.getActiveFocus()).toBe(firstPreferredFocus);
jest.useRealTimers(); jest.useRealTimers();
}); });
it("does not provide focus if the selection method is unknown", () => { it("does not provide focus if the selection method is unknown", () => {
@@ -288,7 +287,7 @@ describe("MatrixRTCSession", () => {
type: "livekit", type: "livekit",
focus_selection: "unknown", focus_selection: "unknown",
}); });
expect(sess.getActiveFocus()).toBe(undefined); expect(sess.memberships.length).toBe(0);
}); });
}); });

View File

@@ -27,12 +27,11 @@ import {
import { import {
MembershipManagerEvent, MembershipManagerEvent,
Status, Status,
type Focus, type Transport,
type LivekitFocusActive,
type SessionMembershipData, type SessionMembershipData,
type LivekitFocusSelection,
} from "../../../src/matrixrtc"; } from "../../../src/matrixrtc";
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks"; import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
import { logger } from "../../../src/logger.ts";
import { MembershipManager } from "../../../src/matrixrtc/MembershipManager.ts"; import { MembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
/** /**
@@ -76,11 +75,11 @@ const callSession = { id: "", application: "m.call" };
describe("MembershipManager", () => { describe("MembershipManager", () => {
let client: MockClient; let client: MockClient;
let room: Room; let room: Room;
const focusActive: LivekitFocusActive = { const focusActive: LivekitFocusSelection = {
focus_selection: "oldest_membership", focus_selection: "oldest_membership",
type: "livekit", type: "livekit",
}; };
const focus: Focus = { const focus: Transport = {
type: "livekit", type: "livekit",
livekit_service_url: "https://active.url", livekit_service_url: "https://active.url",
livekit_alias: "!active:active.url", livekit_alias: "!active:active.url",
@@ -104,12 +103,12 @@ describe("MembershipManager", () => {
describe("isActivated()", () => { describe("isActivated()", () => {
it("defaults to false", () => { it("defaults to false", () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession); const manager = new MembershipManager({}, room, client, callSession);
expect(manager.isActivated()).toEqual(false); expect(manager.isActivated()).toEqual(false);
}); });
it("returns true after join()", () => { it("returns true after join()", () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession); const manager = new MembershipManager({}, room, client, callSession);
manager.join([]); manager.join([]);
expect(manager.isActivated()).toEqual(true); expect(manager.isActivated()).toEqual(true);
}); });
@@ -123,8 +122,8 @@ describe("MembershipManager", () => {
const updateDelayedEventHandle = createAsyncHandle<void>(client._unstable_updateDelayedEvent as Mock); const updateDelayedEventHandle = createAsyncHandle<void>(client._unstable_updateDelayedEvent as Mock);
// Test // Test
const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession); const memberManager = new MembershipManager(undefined, room, client, callSession);
memberManager.join([focus], focusActive); memberManager.join([focus], undefined);
// expects // expects
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" })); await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
expect(client.sendStateEvent).toHaveBeenCalledWith( expect(client.sendStateEvent).toHaveBeenCalledWith(
@@ -152,8 +151,45 @@ describe("MembershipManager", () => {
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
}); });
it("sends a rtc membership event when using `useRtcMemberFormat`", async () => {
// Spys/Mocks
const updateDelayedEventHandle = createAsyncHandle<void>(client._unstable_updateDelayedEvent as Mock);
// Test
const memberManager = new MembershipManager({ useRtcMemberFormat: true }, room, client, callSession);
memberManager.join([], focus);
// expects
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
expect(client.sendStateEvent).toHaveBeenCalledWith(
room.roomId,
"org.matrix.msc4143.rtc.member",
{
application: { type: "m.call" },
member: {
user_id: "@alice:example.org",
id: "_@alice:example.org_AAAAAAA_m.call",
device_id: "AAAAAAA",
},
slot_id: "m.call#",
rtc_transports: [focus],
versions: [],
},
"_@alice:example.org_AAAAAAA_m.call",
);
updateDelayedEventHandle.resolve?.();
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
room.roomId,
{ delay: 8000 },
"org.matrix.msc4143.rtc.member",
{},
"_@alice:example.org_AAAAAAA_m.call",
);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
});
it("reschedules delayed leave event if sending state cancels it", async () => { 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 waitForSendState = waitForMockCall(client.sendStateEvent);
const waitForUpdateDelaye = waitForMockCallOnce( const waitForUpdateDelaye = waitForMockCallOnce(
client._unstable_updateDelayedEvent, client._unstable_updateDelayedEvent,
@@ -228,10 +264,9 @@ describe("MembershipManager", () => {
}, },
room, room,
client, client,
() => undefined,
callSession, callSession,
); );
manager.join([focus], focusActive); manager.join([focus]);
await sendDelayedStateExceedAttempt.then(); // needed to resolve after the send attempt catches await sendDelayedStateExceedAttempt.then(); // needed to resolve after the send attempt catches
await sendDelayedStateAttempt; await sendDelayedStateAttempt;
@@ -286,8 +321,8 @@ describe("MembershipManager", () => {
describe("delayed leave event", () => { describe("delayed leave event", () => {
it("does not try again to schedule a delayed leave event if not supported", () => { it("does not try again to schedule a delayed leave event if not supported", () => {
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock); const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
const manager = new MembershipManager({}, room, client, () => undefined, callSession); const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive); manager.join([focus]);
delayedHandle.reject?.( delayedHandle.reject?.(
new UnsupportedDelayedEventsEndpointError( new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API", "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 () => { it("does try to schedule a delayed leave event again if rate limited", async () => {
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock); const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
const manager = new MembershipManager({}, room, client, () => undefined, callSession); const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive); manager.join([focus]);
delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined)); delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined));
await jest.advanceTimersByTimeAsync(5000); await jest.advanceTimersByTimeAsync(5000);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
}); });
it("uses delayedLeaveEventDelayMs from config", () => { it("uses delayedLeaveEventDelayMs from config", () => {
const manager = new MembershipManager( const manager = new MembershipManager({ delayedLeaveEventDelayMs: 123456 }, room, client, callSession);
{ delayedLeaveEventDelayMs: 123456 }, manager.join([focus]);
room,
client,
() => undefined,
callSession,
);
manager.join([focus], focusActive);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
room.roomId, room.roomId,
{ delay: 123456 }, { delay: 123456 },
@@ -329,11 +358,11 @@ describe("MembershipManager", () => {
{ delayedLeaveEventRestartMs: RESTART_DELAY }, { delayedLeaveEventRestartMs: RESTART_DELAY },
room, room,
client, client,
() => undefined,
callSession, callSession,
); );
// Join with the membership manager // Join with the membership manager
manager.join([focus], focusActive); manager.join([focus]);
expect(manager.status).toBe(Status.Connecting); expect(manager.status).toBe(Status.Connecting);
// Let the scheduler run one iteration so that we can send the join state event // Let the scheduler run one iteration so that we can send the join state event
await jest.runOnlyPendingTimersAsync(); await jest.runOnlyPendingTimersAsync();
@@ -367,11 +396,11 @@ describe("MembershipManager", () => {
{ membershipEventExpiryMs: 1234567 }, { membershipEventExpiryMs: 1234567 },
room, room,
client, client,
() => undefined,
callSession, callSession,
); );
manager.join([focus], focusActive); manager.join([focus]);
await waitForMockCall(client.sendStateEvent); await waitForMockCall(client.sendStateEvent);
expect(client.sendStateEvent).toHaveBeenCalledWith( expect(client.sendStateEvent).toHaveBeenCalledWith(
room.roomId, room.roomId,
@@ -393,11 +422,11 @@ describe("MembershipManager", () => {
}); });
it("does nothing if join called when already joined", async () => { it("does nothing if join called when already joined", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession); const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive); manager.join([focus]);
await waitForMockCall(client.sendStateEvent); await waitForMockCall(client.sendStateEvent);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1); expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
manager.join([focus], focusActive); manager.join([focus]);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1); expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -405,16 +434,16 @@ describe("MembershipManager", () => {
describe("leave()", () => { describe("leave()", () => {
// TODO add rate limit cases. // TODO add rate limit cases.
it("resolves delayed leave event when leave is called", async () => { it("resolves delayed leave event when leave is called", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession); const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive); manager.join([focus]);
await jest.advanceTimersByTimeAsync(1); await jest.advanceTimersByTimeAsync(1);
await manager.leave(); await manager.leave();
expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", "send"); expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", "send");
expect(client.sendStateEvent).toHaveBeenCalled(); expect(client.sendStateEvent).toHaveBeenCalled();
}); });
it("send leave event when leave is called and resolving delayed leave fails", async () => { it("send leave event when leave is called and resolving delayed leave fails", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession); const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive); manager.join([focus]);
await jest.advanceTimersByTimeAsync(1); await jest.advanceTimersByTimeAsync(1);
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown"); (client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
await manager.leave(); await manager.leave();
@@ -428,60 +457,16 @@ describe("MembershipManager", () => {
); );
}); });
it("does nothing if not joined", () => { 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(async () => await manager.leave()).not.toThrow();
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
expect(client.sendStateEvent).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()", () => { describe("onRTCSessionMemberUpdate()", () => {
it("does nothing if not joined", async () => { 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 manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
await jest.advanceTimersToNextTimerAsync(); await jest.advanceTimersToNextTimerAsync();
expect(client.sendStateEvent).not.toHaveBeenCalled(); expect(client.sendStateEvent).not.toHaveBeenCalled();
@@ -489,7 +474,7 @@ describe("MembershipManager", () => {
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled(); expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
}); });
it("does nothing if own membership still present", async () => { 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); manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1); await jest.advanceTimersByTimeAsync(1);
const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2]; const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2];
@@ -513,7 +498,7 @@ describe("MembershipManager", () => {
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled(); expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
}); });
it("recreates membership if it is missing", async () => { 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); manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1); await jest.advanceTimersByTimeAsync(1);
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` // 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 () => { 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); manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1); await jest.advanceTimersByTimeAsync(1);
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` // clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
@@ -564,7 +549,6 @@ describe("MembershipManager", () => {
{ delayedLeaveEventRestartMs: 10_000, delayedLeaveEventDelayMs: 30_000 }, { delayedLeaveEventRestartMs: 10_000, delayedLeaveEventDelayMs: 30_000 },
room, room,
client, client,
() => undefined,
{ id: "", application: "m.call" }, { id: "", application: "m.call" },
); );
manager.join([focus], focusActive); manager.join([focus], focusActive);
@@ -596,7 +580,7 @@ describe("MembershipManager", () => {
{ membershipEventExpiryMs: expire, membershipEventExpiryHeadroomMs: headroom }, { membershipEventExpiryMs: expire, membershipEventExpiryHeadroomMs: headroom },
room, room,
client, client,
() => undefined,
{ id: "", application: "m.call" }, { id: "", application: "m.call" },
); );
manager.join([focus], focusActive); manager.join([focus], focusActive);
@@ -621,14 +605,14 @@ describe("MembershipManager", () => {
describe("status updates", () => { describe("status updates", () => {
it("starts 'Disconnected'", () => { it("starts 'Disconnected'", () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession); const manager = new MembershipManager({}, room, client, callSession);
expect(manager.status).toBe(Status.Disconnected); expect(manager.status).toBe(Status.Disconnected);
}); });
it("emits 'Connection' and 'Connected' after join", async () => { it("emits 'Connection' and 'Connected' after join", async () => {
const handleDelayedEvent = createAsyncHandle<void>(client._unstable_sendDelayedStateEvent); const handleDelayedEvent = createAsyncHandle<void>(client._unstable_sendDelayedStateEvent);
const handleStateEvent = createAsyncHandle<void>(client.sendStateEvent); const handleStateEvent = createAsyncHandle<void>(client.sendStateEvent);
const manager = new MembershipManager({}, room, client, () => undefined, callSession); const manager = new MembershipManager({}, room, client, callSession);
expect(manager.status).toBe(Status.Disconnected); expect(manager.status).toBe(Status.Disconnected);
const connectEmit = jest.fn(); const connectEmit = jest.fn();
manager.on(MembershipManagerEvent.StatusChanged, connectEmit); manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
@@ -642,7 +626,7 @@ describe("MembershipManager", () => {
expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected); expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected);
}); });
it("emits 'Disconnecting' and 'Disconnected' after leave", async () => { 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(); const connectEmit = jest.fn();
manager.on(MembershipManagerEvent.StatusChanged, connectEmit); manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
manager.join([focus], focusActive); 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 () => { it("sends retry if call membership event is still valid at time of retry", async () => {
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent); 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); manager.join([focus], focusActive);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
@@ -685,7 +669,7 @@ describe("MembershipManager", () => {
new Headers({ "Retry-After": "1" }), 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 // Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the
// RateLimit error. // RateLimit error.
manager.join([focus], focusActive); manager.join([focus], focusActive);
@@ -705,7 +689,7 @@ describe("MembershipManager", () => {
it("abandons retry loop if leave() was called before sending state event", async () => { it("abandons retry loop if leave() was called before sending state event", async () => {
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent); 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); manager.join([focus], focusActive);
handle.reject?.( handle.reject?.(
new MatrixError( new MatrixError(
@@ -740,7 +724,7 @@ describe("MembershipManager", () => {
new Headers({ "Retry-After": "1" }), new Headers({ "Retry-After": "1" }),
), ),
); );
const manager = new MembershipManager({}, room, client, () => undefined, callSession); const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive); manager.join([focus], focusActive);
// Hit rate limit // Hit rate limit
@@ -773,7 +757,7 @@ describe("MembershipManager", () => {
new Headers({ "Retry-After": "2" }), 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); manager.join([focus], focusActive, delayEventSendError);
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
@@ -793,7 +777,7 @@ describe("MembershipManager", () => {
new Headers({ "Retry-After": "1" }), 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); manager.join([focus], focusActive, delayEventRestartError);
for (let i = 0; i < 10; i++) { 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 () => { it("falls back to using pure state events when some error occurs while sending delayed events", async () => {
const unrecoverableError = jest.fn(); const unrecoverableError = jest.fn();
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 601)); (client._unstable_sendDelayedStateEvent as Mock<any>).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); manager.join([focus], focusActive, unrecoverableError);
await waitForMockCall(client.sendStateEvent); await waitForMockCall(client.sendStateEvent);
expect(unrecoverableError).not.toHaveBeenCalledWith(); expect(unrecoverableError).not.toHaveBeenCalledWith();
@@ -817,7 +801,6 @@ describe("MembershipManager", () => {
{ networkErrorRetryMs: 1000, maximumNetworkErrorRetryCount: 7 }, { networkErrorRetryMs: 1000, maximumNetworkErrorRetryCount: 7 },
room, room,
client, client,
() => undefined,
callSession, callSession,
); );
manager.join([focus], focusActive, unrecoverableError); manager.join([focus], focusActive, unrecoverableError);
@@ -836,7 +819,7 @@ describe("MembershipManager", () => {
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue( (client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"), 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); manager.join([focus], focusActive, unrecoverableError);
await jest.advanceTimersByTimeAsync(1); await jest.advanceTimersByTimeAsync(1);
@@ -850,7 +833,7 @@ describe("MembershipManager", () => {
{ delayedLeaveEventDelayMs: 10000 }, { delayedLeaveEventDelayMs: 10000 },
room, room,
client, client,
() => undefined,
callSession, callSession,
); );
const { promise: stuckPromise, reject: rejectStuckPromise } = Promise.withResolvers<EmptyObject>(); const { promise: stuckPromise, reject: rejectStuckPromise } = Promise.withResolvers<EmptyObject>();
@@ -904,7 +887,7 @@ describe("MembershipManager", () => {
describe("updateCallIntent()", () => { describe("updateCallIntent()", () => {
it("should fail if the user has not joined the call", async () => { 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. // After joining we want our own focus to be the one we select.
try { try {
await manager.updateCallIntent("video"); await manager.updateCallIntent("video");
@@ -913,7 +896,7 @@ describe("MembershipManager", () => {
}); });
it("can adjust the intent", async () => { it("can adjust the intent", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession); const manager = new MembershipManager({}, room, client, callSession);
manager.join([]); manager.join([]);
expect(manager.isActivated()).toEqual(true); expect(manager.isActivated()).toEqual(true);
const membership = mockCallMembership({ ...membershipTemplate, user_id: client.getUserId()! }, room.roomId); 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 () => { 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([]); manager.join([]);
expect(manager.isActivated()).toEqual(true); expect(manager.isActivated()).toEqual(true);
const membership = mockCallMembership( const membership = mockCallMembership(
@@ -944,7 +927,7 @@ it("Should prefix log with MembershipManager used", () => {
const client = makeMockClient("@alice:example.org", "AAAAAAA"); const client = makeMockClient("@alice:example.org", "AAAAAAA");
const room = makeMockRoom([membershipTemplate]); 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"); const spy = jest.spyOn(console, "error");
// Double join // Double join

View File

@@ -58,7 +58,7 @@ import {
type ICallNotifyContent, type ICallNotifyContent,
} from "../matrixrtc/types.ts"; } from "../matrixrtc/types.ts";
import { type M_POLL_END, type M_POLL_START, type PollEndEventContent, type PollStartEventContent } from "./polls.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 LocalNotificationSettings } from "./local_notifications.ts";
import { type IPushRules } from "./PushRules.ts"; import { type IPushRules } from "./PushRules.ts";
import { type SecretInfo, type SecretStorageKeyDescription } from "../secret-storage.ts"; import { type SecretInfo, type SecretStorageKeyDescription } from "../secret-storage.ts";
@@ -151,6 +151,7 @@ export enum EventType {
GroupCallMemberPrefix = "org.matrix.msc3401.call.member", GroupCallMemberPrefix = "org.matrix.msc3401.call.member",
// MatrixRTC events // MatrixRTC events
RTCMembership = "org.matrix.msc4143.rtc.member",
CallNotify = "org.matrix.msc4075.call.notify", CallNotify = "org.matrix.msc4075.call.notify",
RTCNotification = "org.matrix.msc4075.rtc.notification", RTCNotification = "org.matrix.msc4075.rtc.notification",
RTCDecline = "org.matrix.msc4310.rtc.decline", RTCDecline = "org.matrix.msc4310.rtc.decline",
@@ -369,7 +370,7 @@ export interface StateEvents {
// MSC3401 // MSC3401
[EventType.GroupCallPrefix]: IGroupCallRoomState; [EventType.GroupCallPrefix]: IGroupCallRoomState;
[EventType.GroupCallMemberPrefix]: IGroupCallRoomMemberState | SessionMembershipData | EmptyObject; [EventType.GroupCallMemberPrefix]: IGroupCallRoomMemberState | SessionMembershipData | EmptyObject;
[EventType.RTCMembership]: RtcMembershipData | EmptyObject;
// MSC3089 // MSC3089
[UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent; [UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent;

View File

@@ -14,12 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { type MatrixEvent } from "../matrix.ts"; import { MXID_PATTERN } from "../models/room-member.ts";
import { deepCompare } from "../utils.ts"; import { deepCompare } from "../utils.ts";
import { type Focus } from "./focus.ts"; import { type LivekitFocusSelection } from "./LivekitTransport.ts";
import { isLivekitFocusActive } from "./LivekitFocus.ts"; import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts";
import { type SessionDescription } from "./MatrixRTCSession.ts"; import type { RTCCallIntent, Transport } from "./types.ts";
import { type RTCCallIntent } from "./types.ts"; import { type IContent, type MatrixEvent } from "../models/event.ts";
import { type RelationType } from "../@types/event.ts";
import { logger } from "../logger.ts";
/** /**
* The default duration in milliseconds that a membership is considered valid for. * The default duration in milliseconds that a membership is considered valid for.
@@ -29,6 +31,106 @@ import { type RTCCallIntent } from "./types.ts";
export const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4; export const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4;
type CallScope = "m.room" | "m.user"; 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]: unknown;
};
"rtc_transports": Transport[];
"versions": string[];
"msc4354_sticky_key"?: string;
"sticky_key"?: string;
}
const checkRtcMembershipData = (
data: IContent,
errors: string[],
referenceUserId: string,
): data is RtcMembershipData => {
const prefix = " - ";
// required fields
if (typeof data.slot_id !== "string") {
errors.push(prefix + "slot_id must be string");
} else {
if (data.slot_id.split("#").length !== 2) errors.push(prefix + 'slot_id must include exactly one "#"');
}
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");
// This is not what the spec enforces but there currently are no rules what power levels are required to
// send a m.rtc.member event for a other user. So we add this check for simplicity and to avoid possible attacks until there
// is a proper definition when this is allowed.
else if (data.member.user_id !== referenceUserId) errors.push(prefix + "member.user_id must match the sender");
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");
} else {
if (data.application.type.includes("#")) errors.push(prefix + 'application.type must not include "#"');
}
}
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" || t === null || 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.sticky_key ?? data.msc4354_sticky_key) === undefined) {
errors.push(prefix + "sticky_key or msc4354_sticky_key must be a defined");
}
if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") {
errors.push(prefix + "sticky_key must be a string");
}
if (data.msc4354_sticky_key !== undefined && typeof data.msc4354_sticky_key !== "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
) {
errors.push(prefix + "sticky_key and msc4354_sticky_key must be equal if both are defined");
}
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. * MSC4143 (MatrixRTC) session membership data.
@@ -56,13 +158,13 @@ export type SessionMembershipData = {
/** /**
* The focus selection system this user/membership is using. * 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. * selection system.
*/ */
"foci_preferred": Focus[]; "foci_preferred": Transport[];
/** /**
* Optional field that contains the creation of the session. If it is undefined the creation * Optional field that contains the creation of the session. If it is undefined the creation
@@ -77,7 +179,7 @@ export type SessionMembershipData = {
/** /**
* If the `application` = `"m.call"` this defines if it is a room or user owned call. * 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; "scope"?: CallScope;
@@ -95,16 +197,26 @@ export type SessionMembershipData = {
"m.call.intent"?: RTCCallIntent; "m.call.intent"?: RTCCallIntent;
}; };
const checkSessionsMembershipData = ( const checkSessionsMembershipData = (data: IContent, errors: string[]): data is SessionMembershipData => {
data: Partial<Record<keyof SessionMembershipData, any>>, const prefix = " - ";
errors: string[],
): data is SessionMembershipData => {
const prefix = "Malformed session membership event: ";
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string"); if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string"); 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.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 (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.focus_active === undefined) {
errors.push(prefix + "focus_active has an invalid type");
}
if (
data.foci_preferred !== undefined &&
!(
Array.isArray(data.foci_preferred) &&
data.foci_preferred.every(
(f: Transport) => typeof f === "object" && f !== null && typeof f.type === "string",
)
)
) {
errors.push(prefix + "foci_preferred must be an array of transport objects");
}
// optional parameters // optional parameters
if (data.created_ts !== undefined && typeof data.created_ts !== "number") { if (data.created_ts !== undefined && typeof data.created_ts !== "number") {
errors.push(prefix + "created_ts must be number"); errors.push(prefix + "created_ts must be number");
@@ -120,109 +232,278 @@ const checkSessionsMembershipData = (
return errors.length === 0; return errors.length === 0;
}; };
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 { export class CallMembership {
public static equal(a: CallMembership, b: CallMembership): boolean { public static equal(a?: CallMembership, b?: CallMembership): boolean {
return deepCompare(a.membershipData, b.membershipData); return deepCompare(a?.membershipData, b?.membershipData);
} }
private membershipData: SessionMembershipData;
private membershipData: MembershipData;
/** The parsed data from the Matrix event.
* To access checked eventId and sender from the matrixEvent.
* Class construction will fail if these values cannot get obtained. */
private readonly matrixEventData: { eventId: string; sender: string };
public constructor( public constructor(
private parentEvent: MatrixEvent, /** The Matrix event that this membership is based on */
data: any, private readonly matrixEvent: MatrixEvent,
data: IContent,
) { ) {
const eventId = matrixEvent.getId();
const sender = matrixEvent.getSender();
if (eventId === undefined) throw new Error("parentEvent is missing eventId field");
if (sender === undefined) throw new Error("parentEvent is missing sender field");
const sessionErrors: string[] = []; const sessionErrors: string[] = [];
if (!checkSessionsMembershipData(data, sessionErrors)) { const rtcErrors: string[] = [];
throw Error( if (checkSessionsMembershipData(data, sessionErrors)) {
`unknown CallMembership data. Does not match MSC4143 call.member (${sessionErrors.join(" & ")}) events this could be a legacy membership event: (${data})`, this.membershipData = { kind: "session", data };
); } else if (checkRtcMembershipData(data, rtcErrors, sender)) {
this.membershipData = { kind: "rtc", data };
} else { } else {
this.membershipData = data; const details =
sessionErrors.length < rtcErrors.length
? `Does not match MSC4143 m.call.member:\n${sessionErrors.join("\n")}\n\n`
: `Does not match MSC4143 m.rtc.member:\n${rtcErrors.join("\n")}\n\n`;
const json = "\nevent:\n" + JSON.stringify(data).replaceAll('"', "'");
throw Error(`unknown CallMembership data.\n` + details + json);
}
this.matrixEventData = { eventId, sender };
}
/** @deprecated use userId instead */
public get sender(): string {
return this.userId;
}
public get userId(): string {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.member.user_id;
case "session":
default:
return this.matrixEventData.sender;
} }
} }
public get sender(): string | undefined { public get eventId(): string {
return this.parentEvent.getSender(); return this.matrixEventData.eventId;
}
public get eventId(): string | undefined {
return this.parentEvent.getId();
} }
/** /**
* @deprecated Use sessionDescription.id instead. * The ID of the MatrixRTC slot that this membership belongs to (format `{application}#{id}`).
* This is computed in case SessionMembershipData is used.
*/ */
public get callId(): string { public get slotId(): string {
return this.membershipData.call_id; const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.slot_id;
case "session":
default:
return slotDescriptionToId({ application: this.application, id: data.call_id });
}
} }
public get deviceId(): string { 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":
default:
return data.device_id;
}
} }
public get callIntent(): RTCCallIntent | undefined { public get callIntent(): RTCCallIntent | undefined {
return this.membershipData["m.call.intent"]; const { kind, data } = this.membershipData;
switch (kind) {
case "rtc": {
const intent = data.application["m.call.intent"];
if (typeof intent === "string") {
return intent;
}
logger.warn("RTC membership has invalid m.call.intent");
return undefined;
}
case "session":
default:
return data["m.call.intent"];
}
} }
public get sessionDescription(): SessionDescription { /**
return { * Parsed `slot_id` (format `{application}#{id}`) into its components (application and id).
application: this.membershipData.application, */
id: this.membershipData.call_id, public get slotDescription(): SlotDescription {
}; return slotIdToDescription(this.slotId);
} }
public get application(): string | undefined { public get application(): string {
return this.membershipData.application; const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.application.type;
case "session":
default:
return data.application;
}
}
public get applicationData(): { type: string; [key: string]: unknown } {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.application;
case "session":
default:
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 { public get scope(): CallScope | undefined {
return this.membershipData.scope; const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return undefined;
case "session":
default:
return data.scope;
}
} }
public get membershipID(): string { public get membershipID(): string {
// the createdTs behaves equivalent to the membershipID. // 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. // 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":
default:
return (this.createdTs() ?? "").toString();
}
} }
public createdTs(): number { 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.matrixEvent.getTs();
case "session":
default:
return data.created_ts ?? this.matrixEvent.getTs();
}
} }
/** /**
* Gets the absolute expiry timestamp of the membership. * 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 * @returns The absolute expiry time of the membership as a unix timestamp in milliseconds or undefined if not applicable
*/ */
public getAbsoluteExpiry(): number { public getAbsoluteExpiry(): number | undefined {
// TODO: calculate this from the MatrixRTCSession join configuration directly const { kind, data } = this.membershipData;
return this.createdTs() + (this.membershipData.expires ?? DEFAULT_EXPIRE_DURATION); switch (kind) {
case "rtc":
return undefined;
case "session":
default:
// 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 * @returns The number of milliseconds until the membership expires or undefined if applicable
*/ */
public getMsUntilExpiry(): number { public getMsUntilExpiry(): number | undefined {
// Assume that local clock is sufficiently in sync with other clocks in the distributed system. const { kind } = this.membershipData;
// We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate. switch (kind) {
// The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2 case "rtc":
return this.getAbsoluteExpiry() - Date.now(); 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();
}
} }
/** /**
* @returns true if the membership has expired, otherwise false * @returns true if the membership has expired, otherwise false
*/ */
public isExpired(): boolean { public isExpired(): boolean {
return this.getMsUntilExpiry() <= 0; const { kind } = this.membershipData;
switch (kind) {
case "rtc":
return false;
case "session":
default:
return this.getMsUntilExpiry()! <= 0;
}
} }
public getPreferredFoci(): Focus[] { /**
return this.membershipData.foci_preferred; * ## RTC Membership
* 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.
*
* ## 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.
*/
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 "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;
}
}
return undefined;
} }
public getFocusSelection(): string | undefined { /**
const focusActive = this.membershipData.focus_active; * The focus_active filed of the session membership (m.call.member).
if (isLivekitFocusActive(focusActive)) { * @deprecated focus_active is not used and will be removed in future versions.
return focusActive.focus_selection; */
public getFocusActive(): LivekitFocusSelection | undefined {
const { kind, data } = this.membershipData;
if (kind === "session") return data.focus_active;
return undefined;
}
/**
* The value of the `rtc_transports` field for RTC memberships (m.rtc.member).
* Or the value of the `foci_preferred` field for legacy session memberships (m.call.member).
*/
public get transports(): Transport[] {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.rtc_transports;
case "session":
default:
return data.foci_preferred;
} }
} }
} }

View File

@@ -15,8 +15,7 @@ limitations under the License.
*/ */
import type { CallMembership } from "./CallMembership.ts"; import type { CallMembership } from "./CallMembership.ts";
import type { Focus } from "./focus.ts"; import type { RTCCallIntent, Status, Transport } from "./types.ts";
import type { RTCCallIntent, Status } from "./types.ts";
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
export enum MembershipManagerEvent { export enum MembershipManagerEvent {
@@ -80,10 +79,13 @@ export interface IMembershipManager
/** /**
* Start sending all necessary events to make this user participate in the RTC session. * 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 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. * If multiSfuFocus is set, this is only needed if this client wants to publish to multiple transports simultaneously.
* @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the
* 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. * @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. * 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. * @param timeout the maximum duration in ms until the promise is forced to resolve.
@@ -95,11 +97,6 @@ export interface IMembershipManager
* Call this if the MatrixRTC session members have changed. * Call this if the MatrixRTC session members have changed.
*/ */
onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void>; onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void>;
/**
* The used active focus in the currently joined session.
* @returns the used active focus in the currently joined session or undefined if not joined.
*/
getActiveFocus(): Focus | undefined;
/** /**
* Update the intent of a membership on the call (e.g. user is now providing a video feed) * Update the intent of a membership on the call (e.g. user is now providing a video feed)

View File

@@ -1,39 +0,0 @@
/*
Copyright 2023 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.
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 Focus } from "./focus.ts";
export interface LivekitFocusConfig extends Focus {
type: "livekit";
livekit_service_url: string;
}
export const isLivekitFocusConfig = (object: any): object is LivekitFocusConfig =>
object.type === "livekit" && "livekit_service_url" in object;
export interface LivekitFocus extends LivekitFocusConfig {
livekit_alias: string;
}
export const isLivekitFocus = (object: any): object is LivekitFocus =>
isLivekitFocusConfig(object) && "livekit_alias" in object;
export interface LivekitFocusActive extends Focus {
type: "livekit";
focus_selection: "oldest_membership";
}
export const isLivekitFocusActive = (object: any): object is LivekitFocusActive =>
object.type === "livekit" && "focus_selection" in object;

View File

@@ -0,0 +1,46 @@
/*
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.
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 Transport } from "./types.ts";
export interface LivekitTransportConfig extends Transport {
type: "livekit";
livekit_service_url: string;
}
export const isLivekitTransportConfig = (object: any): object is LivekitTransportConfig =>
object.type === "livekit" && "livekit_service_url" in object;
export interface LivekitTransport extends LivekitTransportConfig {
livekit_alias: string;
}
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.
* Not needed for new implementations.
*/
export interface LivekitFocusSelection extends Transport {
type: "livekit";
focus_selection: "oldest_membership" | "multi_sfu";
}
/**
* @deprecated see LivekitFocusSelection
*/
export const isLivekitFocusSelection = (object: any): object is LivekitFocusSelection =>
object.type === "livekit" && "focus_selection" in object;

View File

@@ -24,17 +24,17 @@ import { KnownMembership } from "../@types/membership.ts";
import { type ISendEventResponse } from "../@types/requests.ts"; import { type ISendEventResponse } from "../@types/requests.ts";
import { CallMembership } from "./CallMembership.ts"; import { CallMembership } from "./CallMembership.ts";
import { RoomStateEvent } from "../models/room-state.ts"; import { RoomStateEvent } from "../models/room-state.ts";
import { type Focus } from "./focus.ts";
import { MembershipManager } from "./MembershipManager.ts"; import { MembershipManager } from "./MembershipManager.ts";
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts"; import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
import { deepCompare, logDurationSync } from "../utils.ts"; import { deepCompare, logDurationSync } from "../utils.ts";
import { import type {
type Statistics, Statistics,
type RTCNotificationType, RTCNotificationType,
type Status, Status,
type IRTCNotificationContent, IRTCNotificationContent,
type ICallNotifyContent, ICallNotifyContent,
type RTCCallIntent, RTCCallIntent,
Transport,
} from "./types.ts"; } from "./types.ts";
import { RoomKeyTransport } from "./RoomKeyTransport.ts"; import { RoomKeyTransport } from "./RoomKeyTransport.ts";
import { import {
@@ -103,10 +103,17 @@ export interface SessionConfig {
/** /**
* The session description is used to identify a session. Used in the state event. * The session description is used to identify a session. Used in the state event.
*/ */
export interface SessionDescription { export interface SlotDescription {
id: string; id: string;
application: 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: // The names follow these principles:
// - we use the technical term delay if the option is related to delayed events. // - 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"}`.) * but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
*/ */
delayedLeaveEventRestartLocalTimeoutMs?: number; delayedLeaveEventRestartLocalTimeoutMs?: number;
useRtcMemberFormat?: boolean;
} }
export interface EncryptionConfig { export interface EncryptionConfig {
@@ -240,8 +248,6 @@ export class MatrixRTCSession extends TypedEventEmitter<
> { > {
private membershipManager?: IMembershipManager; private membershipManager?: IMembershipManager;
private encryptionManager?: IEncryptionManager; private encryptionManager?: IEncryptionManager;
// The session Id of the call, this is the call_id of the call Member event.
private _callId: string | undefined;
private joinConfig?: SessionConfig; private joinConfig?: SessionConfig;
private logger: Logger; private logger: Logger;
@@ -279,33 +285,53 @@ export class MatrixRTCSession extends TypedEventEmitter<
* *
* It can be undefined since the callId is only known once the first membership joins. * 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. * The callId is the property that, per definition, groups memberships into one call.
* @deprecated use `slotId` instead.
*/ */
public get callId(): string | undefined { 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 slotDescriptionToId(this.slotDescription);
} }
/** /**
* Returns all the call memberships for a room that match the provided `sessionDescription`, * Returns all the call memberships for a room that match the provided `sessionDescription`,
* oldest first. * oldest first.
* *
* @deprecated Use `MatrixRTCSession.sessionMembershipsForRoom` instead. * @deprecated Use `MatrixRTCSession.sessionMembershipsForSlot` instead.
*/ */
public static callMembershipsForRoom( public static callMembershipsForRoom(
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">, room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
): CallMembership[] { ): CallMembership[] {
return MatrixRTCSession.sessionMembershipsForRoom(room, { return MatrixRTCSession.sessionMembershipsForSlot(room, {
id: "", id: "",
application: "m.call", application: "m.call",
}); });
} }
/** /**
* Returns all the call memberships for a room that match the provided `sessionDescription`, * @deprecated use `MatrixRTCSession.slotMembershipsForRoom` instead.
* oldest first.
*/ */
public static sessionMembershipsForRoom( public static sessionMembershipsForRoom(
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">, room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
sessionDescription: SessionDescription, 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 sessionMembershipsForSlot(
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
slotDescription: SlotDescription,
): CallMembership[] { ): CallMembership[] {
const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`); const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`);
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
@@ -335,12 +361,16 @@ export class MatrixRTCSession extends TypedEventEmitter<
if (membershipContents.length === 0) continue; if (membershipContents.length === 0) continue;
for (const membershipData of membershipContents) { for (const membershipData of membershipContents) {
if (!("application" in membershipData)) {
// This is a left membership event, ignore it here to not log warnings.
continue;
}
try { try {
const membership = new CallMembership(memberEvent, membershipData); const membership = new CallMembership(memberEvent, membershipData);
if (!deepCompare(membership.sessionDescription, sessionDescription)) { if (!deepCompare(membership.slotDescription, slotDescription)) {
logger.info( 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 slot: ${JSON.stringify(membership.slotDescription)}`,
); );
continue; continue;
} }
@@ -379,26 +409,29 @@ export class MatrixRTCSession extends TypedEventEmitter<
* This method is an alias for `MatrixRTCSession.sessionForRoom` with * This method is an alias for `MatrixRTCSession.sessionForRoom` with
* sessionDescription `{ id: "", application: "m.call" }`. * 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 { 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" }); 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. * Return the MatrixRTC session for the room.
* This returned session can be used to find out if there are active sessions * 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( public static sessionForSlot(client: MatrixClient, room: Room, slotDescription: SlotDescription): MatrixRTCSession {
client: MatrixClient, const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(room, slotDescription);
room: Room,
sessionDescription: SessionDescription,
): MatrixRTCSession {
const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, sessionDescription);
return new MatrixRTCSession(client, room, callMemberships, sessionDescription); return new MatrixRTCSession(client, room, callMemberships, slotDescription);
} }
/** /**
@@ -444,14 +477,14 @@ export class MatrixRTCSession extends TypedEventEmitter<
>, >,
public memberships: CallMembership[], public memberships: CallMembership[],
/** /**
* The session description is used to define the exact session this object is tracking. * The slot description is a virtual address where participants are allowed to meet.
* A session is distinct from another session if one of those properties differ: `roomSubset.roomId`, `sessionDescription.application`, `sessionDescription.id`. * This session will only manage memberships that match this slot description.
* Sessions are distinct if any of those properties are distinct: `roomSubset.roomId`, `slotDescription.application`, `slotDescription.id`.
*/ */
public readonly sessionDescription: SessionDescription, public readonly slotDescription: SlotDescription,
) { ) {
super(); super();
this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`); this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`);
this._callId = memberships[0]?.sessionDescription.id;
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS); const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
// TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager // TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager
roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate); roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
@@ -490,14 +523,18 @@ export class MatrixRTCSession extends TypedEventEmitter<
* This will not subscribe to updates: remember to call subscribe() separately if * This will not subscribe to updates: remember to call subscribe() separately if
* desired. * desired.
* This method will return immediately and the session will be joined in the background. * This method will return immediately and the session will be joined in the background.
* * @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
* @param fociActive - The object representing the active focus. (This depends on the focus type.) * If multiSfuFocus is set, this is only needed if this client wants to publish to multiple transports simultaneously.
* @param fociPreferred - The list of preferred foci this member proposes to use/knows/has access to. * @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the
* For the livekit case this is a list of foci generated from the homeserver well-known, the current rtc session, * membership manager will operate in a multi-SFU connection mode. If `undefined`, an `oldest_membership`
* or optionally other room members homeserver well known. * transport selection will be used instead.
* @param joinConfig - Additional configuration for the joined session. * @param joinConfig - Additional configuration for the joined session.
*/ */
public joinRoomSession(fociPreferred: Focus[], fociActive?: Focus, joinConfig?: JoinSessionConfig): void { public joinRoomSession(
fociPreferred: Transport[],
multiSfuFocus?: Transport,
joinConfig?: JoinSessionConfig,
): void {
if (this.isJoined()) { if (this.isJoined()) {
this.logger.info(`Already joined to session in room ${this.roomSubset.roomId}: ignoring join call`); this.logger.info(`Already joined to session in room ${this.roomSubset.roomId}: ignoring join call`);
return; return;
@@ -508,8 +545,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
joinConfig, joinConfig,
this.roomSubset, this.roomSubset,
this.client, this.client,
() => this.getOldestMembership(), this.slotDescription,
this.sessionDescription,
this.logger, this.logger,
); );
@@ -571,7 +607,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
this.pendingNotificationToSend = this.joinConfig?.notificationType; this.pendingNotificationToSend = this.joinConfig?.notificationType;
// Join! // Join!
this.membershipManager!.join(fociPreferred, fociActive, (e) => { this.membershipManager!.join(fociPreferred, multiSfuFocus, (e) => {
this.logger.error("MembershipManager encountered an unrecoverable error: ", e); this.logger.error("MembershipManager encountered an unrecoverable error: ", e);
this.emit(MatrixRTCSessionEvent.MembershipManagerError, e); this.emit(MatrixRTCSessionEvent.MembershipManagerError, e);
this.emit(MatrixRTCSessionEvent.JoinStateChanged, this.isJoined()); this.emit(MatrixRTCSessionEvent.JoinStateChanged, this.isJoined());
@@ -606,16 +642,23 @@ export class MatrixRTCSession extends TypedEventEmitter<
return await leavePromise; return await leavePromise;
} }
/** /**
* Get the active focus from the current CallMemberState event * This returns the focus in use by the oldest membership.
* @returns The focus that is currently in use to connect to this session. This is undefined * Do not use since this might be just the focus for the oldest membership. others might use a different focus.
* if the client is not connected to this session. * @deprecated use `member.getTransport(session.getOldestMembership())` instead for the specific member you want to get the focus for.
*/ */
public getActiveFocus(): Focus | undefined { public getFocusInUse(): Transport | undefined {
return this.membershipManager?.getActiveFocus(); const oldestMembership = this.getOldestMembership();
return oldestMembership?.getTransport(oldestMembership);
} }
/**
* The used focusActive of the oldest membership (to find out the selection type multi-sfu or oldest membership active focus)
* @deprecated does not work with m.rtc.member. Do not rely on it.
*/
public getActiveFocus(): Transport | undefined {
return this.getOldestMembership()?.getFocusActive();
}
public getOldestMembership(): CallMembership | undefined { public getOldestMembership(): CallMembership | undefined {
return this.memberships[0]; return this.memberships[0];
} }
@@ -646,20 +689,6 @@ export class MatrixRTCSession extends TypedEventEmitter<
await this.membershipManager?.updateCallIntent(callIntent); await this.membershipManager?.updateCallIntent(callIntent);
} }
/**
* 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 * Re-emit an EncryptionKeyChanged event for each tracked encryption key. This can be used to export
* the keys. * the keys.
@@ -777,9 +806,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
*/ */
private recalculateSessionMembers = (): void => { private recalculateSessionMembers = (): void => {
const oldMemberships = this.memberships; 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;
const changed = const changed =
oldMemberships.length != this.memberships.length || oldMemberships.length != this.memberships.length ||

View File

@@ -20,7 +20,7 @@ import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type Room } from "../models/room.ts"; import { type Room } from "../models/room.ts";
import { type RoomState, RoomStateEvent } from "../models/room-state.ts"; import { type RoomState, RoomStateEvent } from "../models/room-state.ts";
import { type MatrixEvent } from "../models/event.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"; import { EventType } from "../@types/event.ts";
export enum MatrixRTCSessionManagerEvents { export enum MatrixRTCSessionManagerEvents {
@@ -56,7 +56,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
public constructor( public constructor(
rootLogger: Logger, rootLogger: Logger,
private client: MatrixClient, private client: MatrixClient,
private readonly sessionDescription: SessionDescription = { id: "", application: "m.call" }, // Default to the Matrix Call application private readonly slotDescription: SlotDescription = { application: "m.call", id: "" }, // Default to the Matrix Call application
) { ) {
super(); super();
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]"); this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
@@ -66,7 +66,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
// We shouldn't need to null-check here, but matrix-client.spec.ts mocks getRooms // 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 :'( // returning nothing, and breaks tests if you change it to return an empty array :'(
for (const room of this.client.getRooms() ?? []) { for (const room of this.client.getRooms() ?? []) {
const session = MatrixRTCSession.sessionForRoom(this.client, room, this.sessionDescription); const session = MatrixRTCSession.sessionForRoom(this.client, room, this.slotDescription);
if (session.memberships.length > 0) { if (session.memberships.length > 0) {
this.roomSessions.set(room.roomId, session); this.roomSessions.set(room.roomId, session);
} }
@@ -102,7 +102,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
if (!this.roomSessions.has(room.roomId)) { if (!this.roomSessions.has(room.roomId)) {
this.roomSessions.set( this.roomSessions.set(
room.roomId, room.roomId,
MatrixRTCSession.sessionForRoom(this.client, room, this.sessionDescription), MatrixRTCSession.sessionForRoom(this.client, room, this.slotDescription),
); );
} }

View File

@@ -15,20 +15,28 @@ limitations under the License.
*/ */
import { AbortError } from "p-retry"; import { AbortError } from "p-retry";
import { EventType } from "../@types/event.ts"; import { EventType, RelationType } from "../@types/event.ts";
import { UpdateDelayedEventAction } from "../@types/requests.ts"; import { UpdateDelayedEventAction } from "../@types/requests.ts";
import { type MatrixClient } from "../client.ts"; import type { MatrixClient } from "../client.ts";
import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts"; import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
import { type Logger, logger as rootLogger } from "../logger.ts"; import { type Logger, logger as rootLogger } from "../logger.ts";
import { type Room } from "../models/room.ts"; import { type Room } from "../models/room.ts";
import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts"; import {
import { type Focus } from "./focus.ts"; type CallMembership,
import { isMyMembership, type RTCCallIntent, Status } from "./types.ts"; DEFAULT_EXPIRE_DURATION,
import { isLivekitFocusActive } from "./LivekitFocus.ts"; type RtcMembershipData,
import { type SessionDescription, type MembershipConfig, type SessionConfig } from "./MatrixRTCSession.ts"; type SessionMembershipData,
} from "./CallMembership.ts";
import { type Transport, isMyMembership, type RTCCallIntent, Status } from "./types.ts";
import {
type SlotDescription,
type MembershipConfig,
type SessionConfig,
slotDescriptionToId,
} from "./MatrixRTCSession.ts";
import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts"; import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
import { import {
MembershipManagerEvent, MembershipManagerEvent,
type IMembershipManager, type IMembershipManager,
@@ -36,7 +44,6 @@ import {
} from "./IMembershipManager.ts"; } from "./IMembershipManager.ts";
/* MembershipActionTypes: /* MembershipActionTypes:
On Join: ───────────────┐ ┌───────────────(1)───────────┐ On Join: ───────────────┐ ┌───────────────(1)───────────┐
▼ ▼ │ ▼ ▼ │
┌────────────────┐ │ ┌────────────────┐ │
@@ -169,18 +176,21 @@ export class MembershipManager
/** /**
* Puts the MembershipManager in a state where it tries to be joined. * Puts the MembershipManager in a state where it tries to be joined.
* It will send delayed events and membership events * It will send delayed events and membership events
* @param fociPreferred * @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
* @param focusActive * If multiSfuFocus is set, this is only needed if this client wants to publish to multiple transports simultaneously.
* @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the
* membership manager will operate in a multi-SFU connection mode. If `undefined`, an `oldest_membership`
* transport selection will be used instead.
* @param onError This will be called once the membership manager encounters an unrecoverable error. * @param onError This will be called once the membership manager encounters an unrecoverable error.
* This should bubble up the the frontend to communicate that the call does not work in the current environment. * This should bubble up the the frontend to communicate that the call does not work in the current environment.
*/ */
public join(fociPreferred: Focus[], focusActive?: Focus, onError?: (error: unknown) => void): void { public join(fociPreferred: Transport[], multiSfuFocus?: Transport, onError?: (error: unknown) => void): void {
if (this.scheduler.running) { if (this.scheduler.running) {
this.logger.error("MembershipManager is already running. Ignoring join request."); this.logger.error("MembershipManager is already running. Ignoring join request.");
return; return;
} }
this.fociPreferred = fociPreferred; this.fociPreferred = fociPreferred;
this.focusActive = focusActive; this.rtcTransport = multiSfuFocus;
this.leavePromiseResolvers = undefined; this.leavePromiseResolvers = undefined;
this.activated = true; this.activated = true;
this.oldStatus = this.status; this.oldStatus = this.status;
@@ -266,25 +276,6 @@ export class MembershipManager
return Promise.resolve(); 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!
const oldestMembership = this.getOldestMembership();
return oldestMembership?.getPreferredFoci()[0];
}
}
public async updateCallIntent(callIntent: RTCCallIntent): Promise<void> { public async updateCallIntent(callIntent: RTCCallIntent): Promise<void> {
if (!this.activated || !this.ownMembership) { if (!this.activated || !this.ownMembership) {
throw Error("You cannot update your intent before joining the call"); throw Error("You cannot update your intent before joining the call");
@@ -302,7 +293,6 @@ export class MembershipManager
* @param joinConfig * @param joinConfig
* @param room * @param room
* @param client * @param client
* @param getOldestMembership
*/ */
public constructor( public constructor(
private joinConfig: (SessionConfig & MembershipConfig) | undefined, private joinConfig: (SessionConfig & MembershipConfig) | undefined,
@@ -315,8 +305,7 @@ export class MembershipManager
| "_unstable_sendDelayedStateEvent" | "_unstable_sendDelayedStateEvent"
| "_unstable_updateDelayedEvent" | "_unstable_updateDelayedEvent"
>, >,
private getOldestMembership: () => CallMembership | undefined, public readonly slotDescription: SlotDescription,
public readonly sessionDescription: SessionDescription,
parentLogger?: Logger, parentLogger?: Logger,
) { ) {
super(); super();
@@ -325,7 +314,9 @@ export class MembershipManager
if (userId === null) throw Error("Missing userId in client"); if (userId === null) throw Error("Missing userId in client");
if (deviceId === null) throw Error("Missing deviceId in client"); if (deviceId === null) throw Error("Missing deviceId in client");
this.deviceId = deviceId; this.deviceId = deviceId;
this.stateKey = this.makeMembershipStateKey(userId, deviceId); // this needs to become a uuid so that consecutive join/leaves result in a key rotation.
// we keep it as a string for now for backwards compatibility.
this.memberId = this.makeMembershipStateKey(userId, deviceId);
this.state = MembershipManager.defaultState; this.state = MembershipManager.defaultState;
this.callIntent = joinConfig?.callIntent; this.callIntent = joinConfig?.callIntent;
this.scheduler = new ActionScheduler((type): Promise<ActionUpdate> => { this.scheduler = new ActionScheduler((type): Promise<ActionUpdate> => {
@@ -371,9 +362,10 @@ export class MembershipManager
} }
// Membership Event static parameters: // Membership Event static parameters:
private deviceId: string; private deviceId: string;
private stateKey: string; private memberId: string;
private fociPreferred?: Focus[]; /** @deprecated This will be removed in favor or rtcTransport becoming a list of actively used transports */
private focusActive?: Focus; private fociPreferred?: Transport[];
private rtcTransport?: Transport;
// Config: // Config:
private delayedLeaveEventDelayMsOverride?: number; private delayedLeaveEventDelayMsOverride?: number;
@@ -406,6 +398,9 @@ export class MembershipManager
private get delayedLeaveEventRestartLocalTimeoutMs(): number { private get delayedLeaveEventRestartLocalTimeoutMs(): number {
return this.joinConfig?.delayedLeaveEventRestartLocalTimeoutMs ?? 2000; return this.joinConfig?.delayedLeaveEventRestartLocalTimeoutMs ?? 2000;
} }
private get useRtcMemberFormat(): boolean {
return this.joinConfig?.useRtcMemberFormat ?? false;
}
// LOOP HANDLER: // LOOP HANDLER:
private async membershipLoopHandler(type: MembershipActionType): Promise<ActionUpdate> { private async membershipLoopHandler(type: MembershipActionType): Promise<ActionUpdate> {
switch (type) { switch (type) {
@@ -472,9 +467,9 @@ export class MembershipManager
{ {
delay: this.delayedLeaveEventDelayMs, delay: this.delayedLeaveEventDelayMs,
}, },
EventType.GroupCallMemberPrefix, this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
{}, // leave event {}, // leave event
this.stateKey, this.memberId,
) )
.then((response) => { .then((response) => {
this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs; this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs;
@@ -659,9 +654,9 @@ export class MembershipManager
return await this.client return await this.client
.sendStateEvent( .sendStateEvent(
this.room.roomId, this.room.roomId,
EventType.GroupCallMemberPrefix, this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
this.makeMyMembership(this.membershipEventExpiryMs), this.makeMyMembership(this.membershipEventExpiryMs),
this.stateKey, this.memberId,
) )
.then(() => { .then(() => {
this.setAndEmitProbablyLeft(false); this.setAndEmitProbablyLeft(false);
@@ -705,9 +700,9 @@ export class MembershipManager
return await this.client return await this.client
.sendStateEvent( .sendStateEvent(
this.room.roomId, this.room.roomId,
EventType.GroupCallMemberPrefix, this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration), this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration),
this.stateKey, this.memberId,
) )
.then(() => { .then(() => {
// Success, we reset retries and schedule update. // Success, we reset retries and schedule update.
@@ -731,7 +726,12 @@ export class MembershipManager
} }
private async sendFallbackLeaveEvent(): Promise<ActionUpdate> { private async sendFallbackLeaveEvent(): Promise<ActionUpdate> {
return await this.client return await this.client
.sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, {}, this.stateKey) .sendStateEvent(
this.room.roomId,
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
{},
this.memberId,
)
.then(() => { .then(() => {
this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent); this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent);
this.state.hasMemberStateEvent = false; this.state.hasMemberStateEvent = false;
@@ -746,7 +746,7 @@ export class MembershipManager
// HELPERS // HELPERS
private makeMembershipStateKey(localUserId: string, localDeviceId: string): string { 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())) { if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) {
return stateKey; return stateKey;
} else { } else {
@@ -757,20 +757,45 @@ export class MembershipManager
/** /**
* Constructs our own membership * Constructs our own membership
*/ */
private makeMyMembership(expires: number): SessionMembershipData { private makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
const hasPreviousEvent = !!this.ownMembership; const ownMembership = this.ownMembership;
return { if (this.useRtcMemberFormat) {
// TODO: use the new format for m.rtc.member events where call_id becomes session.id const relationObject = ownMembership?.eventId
"application": this.sessionDescription.application, ? { "m.relation": { rel_type: RelationType.Reference, event_id: ownMembership?.eventId } }
"call_id": this.sessionDescription.id, : {};
"scope": "m.room", return {
"device_id": this.deviceId, application: {
expires, type: this.slotDescription.application,
"focus_active": { type: "livekit", focus_selection: "oldest_membership" }, ...(this.callIntent ? { "m.call.intent": this.callIntent } : {}),
"foci_preferred": this.fociPreferred ?? [], },
"m.call.intent": this.callIntent, slot_id: slotDescriptionToId(this.slotDescription),
...(hasPreviousEvent ? { created_ts: this.ownMembership?.createdTs() } : undefined), 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 // Error checks and handlers

View File

@@ -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;
}

View File

@@ -15,8 +15,7 @@ limitations under the License.
*/ */
export * from "./CallMembership.ts"; export * from "./CallMembership.ts";
export type * from "./focus.ts"; export * from "./LivekitTransport.ts";
export * from "./LivekitFocus.ts";
export * from "./MatrixRTCSession.ts"; export * from "./MatrixRTCSession.ts";
export * from "./MatrixRTCSessionManager.ts"; export * from "./MatrixRTCSessionManager.ts";
export type * from "./types.ts"; export type * from "./types.ts";

View File

@@ -156,3 +156,11 @@ export type Statistics = {
export const isMyMembership = (m: CallMembership, userId: string, deviceId: string): boolean => export const isMyMembership = (m: CallMembership, userId: string, deviceId: string): boolean =>
m.sender === userId && m.deviceId === deviceId; 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;
}

View File

@@ -388,7 +388,7 @@ export class RoomMember extends TypedEventEmitter<RoomMemberEvent, RoomMemberEve
} }
} }
const MXID_PATTERN = /@.+:.+/; export const MXID_PATTERN = /@.+:.+/;
const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/; const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/;
function shouldDisambiguate(selfUserId: string, displayName?: string, roomState?: RoomState): boolean { function shouldDisambiguate(selfUserId: string, displayName?: string, roomState?: RoomState): boolean {