You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-11-25 05:23:13 +03:00
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>
This commit is contained in:
@@ -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";
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ describe("CallMembership", () => {
|
|||||||
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" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,11 +95,138 @@ 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] }),
|
});
|
||||||
);
|
describe("getTransport", () => {
|
||||||
expect(membership.getPreferredFoci()).toEqual([mockFocus]);
|
const mockFocus = { type: "this_is_a_mock_focus" };
|
||||||
|
const oldestMembership = new CallMembership(makeMockEvent(), membershipTemplate);
|
||||||
|
it("gets the correct active transport with oldest_membership", () => {
|
||||||
|
const membership = new CallMembership(makeMockEvent(), {
|
||||||
|
...membershipTemplate,
|
||||||
|
foci_preferred: [mockFocus],
|
||||||
|
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// if we are the oldest member we use our focus.
|
||||||
|
expect(membership.getTransport(membership)).toStrictEqual(mockFocus);
|
||||||
|
|
||||||
|
// If there is an older member we use its focus.
|
||||||
|
expect(membership.getTransport(oldestMembership)).toBe(membershipTemplate.foci_preferred[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not provide focus if the selection method is unknown", () => {
|
||||||
|
const membership = new CallMembership(makeMockEvent(), {
|
||||||
|
...membershipTemplate,
|
||||||
|
foci_preferred: [mockFocus],
|
||||||
|
focus_active: { type: "livekit", focus_selection: "multi_sfu" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// if we are the oldest member we use our focus.
|
||||||
|
expect(membership.getTransport(membership)).toStrictEqual(mockFocus);
|
||||||
|
|
||||||
|
// If there is an older member we still use our own focus in multi sfu.
|
||||||
|
expect(membership.getTransport(oldestMembership)).toBe(mockFocus);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RtcMembershipData", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
const membershipTemplate: RtcMembershipData = {
|
||||||
|
slot_id: "m.call#1",
|
||||||
|
application: { type: "m.call" },
|
||||||
|
member: { user_id: "@alice:example.org", device_id: "AAAAAAA", id: "xyzHASHxyz" },
|
||||||
|
rtc_transports: [{ type: "livekit" }],
|
||||||
|
versions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("rejects membership with no slot_id", () => {
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: undefined });
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects membership with no application", () => {
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), { ...membershipTemplate, application: undefined });
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects membership with incorrect application", () => {
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), {
|
||||||
|
...membershipTemplate,
|
||||||
|
application: { wrong_type_key: "unknown" },
|
||||||
|
});
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects membership with no member", () => {
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), { ...membershipTemplate, member: undefined });
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects membership with incorrect member", () => {
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), { ...membershipTemplate, member: { i: "test" } });
|
||||||
|
}).toThrow();
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), {
|
||||||
|
...membershipTemplate,
|
||||||
|
member: { id: "test", device_id: "test", user_id_wrong: "test" },
|
||||||
|
});
|
||||||
|
}).toThrow();
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), {
|
||||||
|
...membershipTemplate,
|
||||||
|
member: { id: "test", device_id_wrong: "test", user_id_wrong: "test" },
|
||||||
|
});
|
||||||
|
}).toThrow();
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), {
|
||||||
|
...membershipTemplate,
|
||||||
|
member: { id: "test", device_id: "test", user_id: "@@test" },
|
||||||
|
});
|
||||||
|
}).toThrow();
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), {
|
||||||
|
...membershipTemplate,
|
||||||
|
member: { id: "test", device_id: "test", user_id: "@test:user.id" },
|
||||||
|
});
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("considers memberships unexpired if local age low enough", () => {
|
||||||
|
// TODO link prev event
|
||||||
|
});
|
||||||
|
|
||||||
|
it("considers memberships expired if local age large enough", () => {
|
||||||
|
// TODO link prev event
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getTransport", () => {
|
||||||
|
it("gets the correct active transport with oldest_membership", () => {
|
||||||
|
const oldestMembership = new CallMembership(makeMockEvent(), {
|
||||||
|
...membershipTemplate,
|
||||||
|
rtc_transports: [{ type: "oldest_transport" }],
|
||||||
|
});
|
||||||
|
const membership = new CallMembership(makeMockEvent(), membershipTemplate);
|
||||||
|
|
||||||
|
// if we are the oldest member we use our focus.
|
||||||
|
expect(membership.getTransport(membership)).toStrictEqual({ type: "livekit" });
|
||||||
|
|
||||||
|
// If there is an older member we use our own focus focus. (RtcMembershipData always uses multi sfu)
|
||||||
|
expect(membership.getTransport(oldestMembership)).toStrictEqual({ type: "livekit" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,26 +14,30 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { isLivekitFocus, isLivekitFocusSelection, isLivekitFocusConfig } from "../../../src/matrixrtc/LivekitFocus";
|
import {
|
||||||
|
isLivekitTransport,
|
||||||
|
isLivekitFocusSelection,
|
||||||
|
isLivekitTransportConfig,
|
||||||
|
} from "../../../src/matrixrtc/LivekitFocus";
|
||||||
|
|
||||||
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", () => {
|
||||||
@@ -48,13 +52,13 @@ describe("LivekitFocus", () => {
|
|||||||
});
|
});
|
||||||
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,9 @@ describe("MatrixRTCSession", () => {
|
|||||||
type: "livekit",
|
type: "livekit",
|
||||||
focus_selection: "oldest_membership",
|
focus_selection: "oldest_membership",
|
||||||
});
|
});
|
||||||
expect(sess.resolveActiveFocus()).toBe(firstPreferredFocus);
|
expect(sess.resolveActiveFocus(sess.memberships.find((m) => m.deviceId === "old"))).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 +290,7 @@ describe("MatrixRTCSession", () => {
|
|||||||
type: "livekit",
|
type: "livekit",
|
||||||
focus_selection: "unknown",
|
focus_selection: "unknown",
|
||||||
});
|
});
|
||||||
expect(sess.resolveActiveFocus()).toBe(undefined);
|
expect(sess.resolveActiveFocus(sess.memberships.find((m) => m.deviceId === "old"))).toBe(undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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.msc3401.call.member",
|
||||||
|
{
|
||||||
|
application: { type: "m.call", id: "" },
|
||||||
|
member: {
|
||||||
|
user_id: "@alice:example.org",
|
||||||
|
id: "_@alice:example.org_AAAAAAA_m.call",
|
||||||
|
device_id: "AAAAAAA",
|
||||||
|
},
|
||||||
|
slot_id: "m.call#",
|
||||||
|
rtc_transports: [focus],
|
||||||
|
versions: [],
|
||||||
|
},
|
||||||
|
"_@alice:example.org_AAAAAAA_m.call",
|
||||||
|
);
|
||||||
|
updateDelayedEventHandle.resolve?.();
|
||||||
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
|
||||||
|
room.roomId,
|
||||||
|
{ delay: 8000 },
|
||||||
|
"org.matrix.msc3401.call.member",
|
||||||
|
{},
|
||||||
|
"_@alice:example.org_AAAAAAA_m.call",
|
||||||
|
);
|
||||||
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("reschedules delayed leave event if sending state cancels it", async () => {
|
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
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -368,7 +368,11 @@ export interface StateEvents {
|
|||||||
|
|
||||||
// MSC3401
|
// MSC3401
|
||||||
[EventType.GroupCallPrefix]: IGroupCallRoomState;
|
[EventType.GroupCallPrefix]: IGroupCallRoomState;
|
||||||
[EventType.GroupCallMemberPrefix]: IGroupCallRoomMemberState | SessionMembershipData | EmptyObject;
|
[EventType.GroupCallMemberPrefix]:
|
||||||
|
| IGroupCallRoomMemberState
|
||||||
|
| SessionMembershipData
|
||||||
|
| RtcMembershipData
|
||||||
|
| EmptyObject;
|
||||||
|
|
||||||
// MSC3089
|
// MSC3089
|
||||||
[UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent;
|
[UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent;
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ 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 { isLivekitFocusSelection, type LivekitFocusSelection } from "./LivekitFocus.ts";
|
||||||
import { type SessionDescription } from "./MatrixRTCSession.ts";
|
import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts";
|
||||||
import { type RTCCallIntent } from "./types.ts";
|
import { type RTCCallIntent, type Transport } from "./types.ts";
|
||||||
|
import { type RelationType } from "src/types.ts";
|
||||||
|
import { type MatrixEvent } from "../models/event.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default duration in milliseconds that a membership is considered valid for.
|
* The default duration in milliseconds that a membership is considered valid for.
|
||||||
@@ -28,6 +30,91 @@ import { type RTCCallIntent } from "./types.ts";
|
|||||||
export const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4;
|
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]: any;
|
||||||
|
};
|
||||||
|
"rtc_transports": Transport[];
|
||||||
|
"versions": string[];
|
||||||
|
"created_ts"?: number;
|
||||||
|
"sticky_key"?: string;
|
||||||
|
/**
|
||||||
|
* The intent of the call from the perspective of this user. This may be an audio call, video call or
|
||||||
|
* something else.
|
||||||
|
*/
|
||||||
|
"m.call.intent"?: RTCCallIntent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkRtcMembershipData = (
|
||||||
|
data: Partial<Record<keyof RtcMembershipData, any>>,
|
||||||
|
errors: string[],
|
||||||
|
): data is RtcMembershipData => {
|
||||||
|
const prefix = "Malformed rtc membership event: ";
|
||||||
|
|
||||||
|
// required fields
|
||||||
|
if (typeof data.slot_id !== "string") errors.push(prefix + "slot_id must be string");
|
||||||
|
if (typeof data.member !== "object" || data.member === null) {
|
||||||
|
errors.push(prefix + "member must be an object");
|
||||||
|
} else {
|
||||||
|
if (typeof data.member.user_id !== "string") errors.push(prefix + "member.user_id must be string");
|
||||||
|
else if (!MXID_PATTERN.test(data.member.user_id)) errors.push(prefix + "member.user_id must be a valid mxid");
|
||||||
|
if (typeof data.member.device_id !== "string") errors.push(prefix + "member.device_id must be string");
|
||||||
|
if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string");
|
||||||
|
}
|
||||||
|
if (typeof data.application !== "object" || data.application === null) {
|
||||||
|
errors.push(prefix + "application must be an object");
|
||||||
|
} else {
|
||||||
|
if (typeof data.application.type !== "string") errors.push(prefix + "application.type must be a string");
|
||||||
|
}
|
||||||
|
if (data.rtc_transports === undefined || !Array.isArray(data.rtc_transports)) {
|
||||||
|
errors.push(prefix + "rtc_transports must be an array");
|
||||||
|
} else {
|
||||||
|
// validate that each transport has at least a string 'type'
|
||||||
|
for (const t of data.rtc_transports) {
|
||||||
|
if (typeof t !== "object" || typeof (t as any).type !== "string") {
|
||||||
|
errors.push(prefix + "rtc_transports entries must be objects with a string type");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.versions === undefined || !Array.isArray(data.versions)) {
|
||||||
|
errors.push(prefix + "versions must be an array");
|
||||||
|
} else if (!data.versions.every((v) => typeof v === "string")) {
|
||||||
|
errors.push(prefix + "versions must be an array of strings");
|
||||||
|
}
|
||||||
|
|
||||||
|
// optional fields
|
||||||
|
if (data.created_ts !== undefined && typeof data.created_ts !== "number") {
|
||||||
|
errors.push(prefix + "created_ts must be number");
|
||||||
|
}
|
||||||
|
if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") {
|
||||||
|
errors.push(prefix + "sticky_key must be a string");
|
||||||
|
}
|
||||||
|
if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") {
|
||||||
|
errors.push(prefix + "m.call.intent must be a string");
|
||||||
|
}
|
||||||
|
if (data["m.relates_to"] !== undefined) {
|
||||||
|
const rel = data["m.relates_to"] as RtcMembershipData["m.relates_to"];
|
||||||
|
if (typeof rel !== "object" || rel === null) {
|
||||||
|
errors.push(prefix + "m.relates_to must be an object if provided");
|
||||||
|
} else {
|
||||||
|
if (typeof rel.event_id !== "string") errors.push(prefix + "m.relates_to.event_id must be a string");
|
||||||
|
if (rel.rel_type !== "m.reference") errors.push(prefix + "m.relates_to.rel_type must be m.reference");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MSC4143 (MatrixRTC) session membership data.
|
* MSC4143 (MatrixRTC) session membership data.
|
||||||
@@ -55,13 +142,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
|
||||||
@@ -76,7 +163,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;
|
||||||
|
|
||||||
@@ -103,8 +190,12 @@ const checkSessionsMembershipData = (
|
|||||||
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 (data.foci_preferred !== undefined && !Array.isArray(data.foci_preferred))
|
if (data.focus_active !== undefined && !isLivekitFocusSelection(data.focus_active)) {
|
||||||
{errors.push(prefix + "foci_preferred must be an array");}
|
errors.push(prefix + "focus_active has an invalid type");
|
||||||
|
}
|
||||||
|
if (data.foci_preferred !== undefined && !Array.isArray(data.foci_preferred)) {
|
||||||
|
errors.push(prefix + "foci_preferred must be an array");
|
||||||
|
}
|
||||||
// optional parameters
|
// 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,28 +211,43 @@ const checkSessionsMembershipData = (
|
|||||||
return errors.length === 0;
|
return errors.length === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MembershipData = { kind: "rtc"; data: RtcMembershipData } | { kind: "session"; data: SessionMembershipData };
|
||||||
export class CallMembership {
|
export class CallMembership {
|
||||||
public static equal(a: CallMembership, b: CallMembership): boolean {
|
public static equal(a: CallMembership, b: CallMembership): boolean {
|
||||||
|
if (a === undefined || b === undefined) return a === b;
|
||||||
return deepCompare(a.membershipData, b.membershipData);
|
return deepCompare(a.membershipData, b.membershipData);
|
||||||
}
|
}
|
||||||
private membershipData: SessionMembershipData;
|
|
||||||
|
private membershipData: MembershipData;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private parentEvent: MatrixEvent,
|
private parentEvent: MatrixEvent,
|
||||||
data: any,
|
data: any,
|
||||||
) {
|
) {
|
||||||
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)) {
|
||||||
|
this.membershipData = { kind: "rtc", data };
|
||||||
} else {
|
} else {
|
||||||
this.membershipData = data;
|
throw Error(
|
||||||
|
`unknown CallMembership data.` +
|
||||||
|
`Does not match MSC4143 call.member (${sessionErrors.join(" & ")})\n` +
|
||||||
|
`Does not match MSC4143 rtc.member (${rtcErrors.join(" & ")})\n` +
|
||||||
|
`events this could be a legacy membership event: (${data})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public get sender(): string | undefined {
|
public get sender(): string | undefined {
|
||||||
return this.parentEvent.getSender();
|
const { kind, data } = this.membershipData;
|
||||||
|
switch (kind) {
|
||||||
|
case "rtc":
|
||||||
|
return data.member.user_id;
|
||||||
|
case "session":
|
||||||
|
return this.parentEvent.getSender();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public get eventId(): string | undefined {
|
public get eventId(): string | undefined {
|
||||||
@@ -149,77 +255,156 @@ export class CallMembership {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated Use sessionDescription.id instead.
|
* The slot id to find all member building one session `slot_id` (format `{application}#{id}`).
|
||||||
|
* This is computed in case SessionMembershipData is used.
|
||||||
*/
|
*/
|
||||||
public get callId(): string {
|
public get slotId(): string {
|
||||||
return this.membershipData.call_id;
|
const { kind, data } = this.membershipData;
|
||||||
|
switch (kind) {
|
||||||
|
case "rtc":
|
||||||
|
return data.slot_id;
|
||||||
|
case "session":
|
||||||
|
return slotDescriptionToId({ application: this.application, id: data.call_id });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public get deviceId(): string {
|
public get deviceId(): string {
|
||||||
return this.membershipData.device_id;
|
const { kind, data } = this.membershipData;
|
||||||
|
switch (kind) {
|
||||||
|
case "rtc":
|
||||||
|
return data.member.device_id;
|
||||||
|
case "session":
|
||||||
|
return data.device_id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public get callIntent(): RTCCallIntent | undefined {
|
public get callIntent(): RTCCallIntent | undefined {
|
||||||
return this.membershipData["m.call.intent"];
|
return this.membershipData.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":
|
||||||
|
return data.application;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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":
|
||||||
|
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":
|
||||||
|
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.parentEvent.getTs();
|
||||||
|
case "session":
|
||||||
|
return data.created_ts ?? this.parentEvent.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":
|
||||||
|
// 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":
|
||||||
|
// 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":
|
||||||
|
return this.getMsUntilExpiry()! <= 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public getPreferredFoci(): Focus[] {
|
/**
|
||||||
return this.membershipData.foci_preferred ?? [];
|
*
|
||||||
|
* @param oldestMembership For backwards compatibility with session membership (legacy).
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
public getTransport(oldestMembership: CallMembership): Transport | undefined {
|
||||||
|
const { kind, data } = this.membershipData;
|
||||||
|
switch (kind) {
|
||||||
|
case "rtc":
|
||||||
|
return data.rtc_transports[0];
|
||||||
|
case "session":
|
||||||
|
switch (data.focus_active.focus_selection) {
|
||||||
|
case "oldest_membership":
|
||||||
|
if (CallMembership.equal(this, oldestMembership)) return data.foci_preferred[0];
|
||||||
|
if (oldestMembership !== undefined) return oldestMembership.getTransport(oldestMembership);
|
||||||
|
break;
|
||||||
|
case "multi_sfu":
|
||||||
|
return data.foci_preferred[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
public get transports(): Transport[] {
|
||||||
public getFocusActive(): Focus {
|
const { kind, data } = this.membershipData;
|
||||||
return this.membershipData.focus_active;
|
switch (kind) {
|
||||||
|
case "rtc":
|
||||||
|
return data.rtc_transports;
|
||||||
|
case "session":
|
||||||
|
return data.foci_preferred;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,11 @@ 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.
|
* @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the
|
||||||
|
* membership manager will use multi sfu. Use `undefined` to not use `oldest_membership` selection based sfu.
|
||||||
* @throws can throw if it exceeds a configured maximum retry.
|
* @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,10 +95,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>;
|
||||||
/**
|
|
||||||
* Determines the active focus used by the given session member, or undefined if not joined.
|
|
||||||
*/
|
|
||||||
resolveActiveFocus(member: CallMembership): Focus | undefined;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the intent of a membership on the call (e.g. user is now providing a video feed)
|
* Update the intent of a membership on the call (e.g. user is now providing a video feed)
|
||||||
|
|||||||
@@ -14,26 +14,33 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type Focus } from "./focus.ts";
|
import { type Transport } from "./types.ts";
|
||||||
|
|
||||||
export interface LivekitFocusConfig extends Focus {
|
export interface LivekitTransportConfig extends Transport {
|
||||||
type: "livekit";
|
type: "livekit";
|
||||||
livekit_service_url: string;
|
livekit_service_url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isLivekitFocusConfig = (object: any): object is LivekitFocusConfig =>
|
export const isLivekitTransportConfig = (object: any): object is LivekitTransportConfig =>
|
||||||
object.type === "livekit" && "livekit_service_url" in object;
|
object.type === "livekit" && "livekit_service_url" in object;
|
||||||
|
|
||||||
export interface LivekitFocus extends LivekitFocusConfig {
|
export interface LivekitTransport extends LivekitTransportConfig {
|
||||||
livekit_alias: string;
|
livekit_alias: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isLivekitFocus = (object: any): object is LivekitFocus =>
|
export const isLivekitTransport = (object: any): object is LivekitTransport =>
|
||||||
isLivekitFocusConfig(object) && "livekit_alias" in object;
|
isLivekitTransportConfig(object) && "livekit_alias" in object;
|
||||||
|
|
||||||
export interface LivekitFocusSelection extends Focus {
|
/**
|
||||||
|
* Deprecated, this is just needed for the old focus active / focus fields of a call membership.
|
||||||
|
* Not needed for new implementations.
|
||||||
|
*/
|
||||||
|
export interface LivekitFocusSelection extends Transport {
|
||||||
type: "livekit";
|
type: "livekit";
|
||||||
focus_selection: "oldest_membership";
|
focus_selection: "oldest_membership" | "multi_sfu";
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* deprecated see LivekitFocusSelection
|
||||||
|
*/
|
||||||
export const isLivekitFocusSelection = (object: any): object is LivekitFocusSelection =>
|
export const isLivekitFocusSelection = (object: any): object is LivekitFocusSelection =>
|
||||||
object.type === "livekit" && "focus_selection" in object;
|
object.type === "livekit" && "focus_selection" in object;
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -241,7 +249,7 @@ 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.
|
// The session Id of the call, this is the call_id of the call Member event.
|
||||||
private _callId: string | undefined;
|
private _slotId: string | undefined;
|
||||||
private joinConfig?: SessionConfig;
|
private joinConfig?: SessionConfig;
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
@@ -279,33 +287,53 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
*
|
*
|
||||||
* It can be undefined since the callId is only known once the first membership joins.
|
* 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 this._slotId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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);
|
||||||
@@ -338,9 +366,9 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
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 session: ${JSON.stringify(membership.slotDescription)}`,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -379,26 +407,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -445,13 +476,13 @@ 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 session description is used to define the exact session this object is tracking.
|
||||||
* A session is distinct from another session if one of those properties differ: `roomSubset.roomId`, `sessionDescription.application`, `sessionDescription.id`.
|
* A session is distinct from another session if one of those properties differ: `roomSubset.roomId`, `slotDescription.application`, `slotDescription.id`.
|
||||||
*/
|
*/
|
||||||
public readonly sessionDescription: SessionDescription,
|
public readonly slotDescription: SlotDescription,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`);
|
this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`);
|
||||||
this._callId = memberships[0]?.sessionDescription.id;
|
this._slotId = memberships[0]?.slotId;
|
||||||
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
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);
|
||||||
@@ -497,7 +528,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
* or optionally other room members homeserver well known.
|
* or optionally other room members homeserver well known.
|
||||||
* @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[], fociActive?: 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 +539,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,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -608,12 +638,18 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the active focus from the current CallMemberState event
|
* Get the focus in use from a specific specified member.
|
||||||
|
* @param member The member for which to get the active focus. If undefined, the own membership is used.
|
||||||
* @returns The focus that is currently in use to connect to this session. This is undefined
|
* @returns The focus that is currently in use to connect to this session. This is undefined
|
||||||
* if the client is not connected to this session.
|
* if the client is not connected to this session.
|
||||||
|
* @deprecated use `member.getTransport(session.getOldestMembership())` instead if you want to get the active transport for a specific member.
|
||||||
*/
|
*/
|
||||||
public resolveActiveFocus(member: CallMembership): Focus | undefined {
|
public resolveActiveFocus(member?: CallMembership): Transport | undefined {
|
||||||
return this.membershipManager?.resolveActiveFocus(member);
|
const oldestMembership = this.getOldestMembership();
|
||||||
|
if (!oldestMembership) return undefined;
|
||||||
|
const m = member === undefined ? this.membershipManager?.ownMembership : member;
|
||||||
|
if (!m) return undefined;
|
||||||
|
return m.getTransport(oldestMembership);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getOldestMembership(): CallMembership | undefined {
|
public getOldestMembership(): CallMembership | undefined {
|
||||||
@@ -763,9 +799,9 @@ 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;
|
this._slotId = this._slotId ?? this.memberships[0]?.slotId;
|
||||||
|
|
||||||
const changed =
|
const changed =
|
||||||
oldMemberships.length != this.memberships.length ||
|
oldMemberships.length != this.memberships.length ||
|
||||||
|
|||||||
@@ -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 sessionDescription: SlotDescription = { application: "m.call", id: "" }, // Default to the Matrix Call application
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
|
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
|
||||||
|
|||||||
@@ -15,17 +15,25 @@ 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 { 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 { isLivekitFocusSelection } 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 { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
|
||||||
@@ -36,7 +44,6 @@ import {
|
|||||||
} from "./IMembershipManager.ts";
|
} from "./IMembershipManager.ts";
|
||||||
|
|
||||||
/* MembershipActionTypes:
|
/* MembershipActionTypes:
|
||||||
|
|
||||||
On Join: ───────────────┐ ┌───────────────(1)───────────┐
|
On Join: ───────────────┐ ┌───────────────(1)───────────┐
|
||||||
▼ ▼ │
|
▼ ▼ │
|
||||||
┌────────────────┐ │
|
┌────────────────┐ │
|
||||||
@@ -170,17 +177,18 @@ 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
|
||||||
* @param focusActive
|
* @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the
|
||||||
|
* membership manager will use multi sfu. Use `undefined` to not use `oldest_membership` selection based sfu.
|
||||||
* @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,18 +274,6 @@ export class MembershipManager
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
public resolveActiveFocus(member: CallMembership): Focus | undefined {
|
|
||||||
const data = member.getFocusActive();
|
|
||||||
if (isLivekitFocusSelection(data) && data.focus_selection === "oldest_membership") {
|
|
||||||
const oldestMembership = this.getOldestMembership();
|
|
||||||
if (member === oldestMembership) return member.getPreferredFoci()[0];
|
|
||||||
if (oldestMembership !== undefined) return this.resolveActiveFocus(oldestMembership);
|
|
||||||
} else {
|
|
||||||
// This is a fully resolved focus config
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async updateCallIntent(callIntent: RTCCallIntent): Promise<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");
|
||||||
@@ -295,7 +291,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,
|
||||||
@@ -308,8 +303,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();
|
||||||
@@ -318,7 +312,7 @@ 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.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> => {
|
||||||
@@ -364,9 +358,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;
|
||||||
@@ -399,6 +394,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) {
|
||||||
@@ -467,7 +465,7 @@ export class MembershipManager
|
|||||||
},
|
},
|
||||||
EventType.GroupCallMemberPrefix,
|
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;
|
||||||
@@ -654,7 +652,7 @@ export class MembershipManager
|
|||||||
this.room.roomId,
|
this.room.roomId,
|
||||||
EventType.GroupCallMemberPrefix,
|
EventType.GroupCallMemberPrefix,
|
||||||
this.makeMyMembership(this.membershipEventExpiryMs),
|
this.makeMyMembership(this.membershipEventExpiryMs),
|
||||||
this.stateKey,
|
this.memberId,
|
||||||
)
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.setAndEmitProbablyLeft(false);
|
this.setAndEmitProbablyLeft(false);
|
||||||
@@ -700,7 +698,7 @@ export class MembershipManager
|
|||||||
this.room.roomId,
|
this.room.roomId,
|
||||||
EventType.GroupCallMemberPrefix,
|
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.
|
||||||
@@ -724,7 +722,7 @@ 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, EventType.GroupCallMemberPrefix, {}, this.memberId)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent);
|
this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent);
|
||||||
this.state.hasMemberStateEvent = false;
|
this.state.hasMemberStateEvent = false;
|
||||||
@@ -739,7 +737,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 {
|
||||||
@@ -750,24 +748,42 @@ 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: { type: this.slotDescription.application, id: this.slotDescription.id },
|
||||||
expires,
|
slot_id: slotDescriptionToId(this.slotDescription),
|
||||||
"m.call.intent": this.callIntent,
|
rtc_transports: this.rtcTransport ? [this.rtcTransport] : [],
|
||||||
...(this.focusActive === undefined
|
member: { device_id: this.deviceId, user_id: this.client.getUserId()!, id: this.memberId },
|
||||||
? {
|
versions: [],
|
||||||
focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const,
|
...relationObject,
|
||||||
foci_preferred: this.fociPreferred ?? [],
|
};
|
||||||
}
|
} else {
|
||||||
: { focus_active: this.focusActive }),
|
const focusObjects =
|
||||||
...(hasPreviousEvent ? { created_ts: this.ownMembership?.createdTs() } : undefined),
|
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
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,6 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./CallMembership.ts";
|
export * from "./CallMembership.ts";
|
||||||
export type * from "./focus.ts";
|
|
||||||
export * from "./LivekitFocus.ts";
|
export * from "./LivekitFocus.ts";
|
||||||
export * from "./MatrixRTCSession.ts";
|
export * from "./MatrixRTCSession.ts";
|
||||||
export * from "./MatrixRTCSessionManager.ts";
|
export * from "./MatrixRTCSessionManager.ts";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user