You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-11-23 17:02:25 +03:00
[MatrixRTC] Multi SFU support + m.rtc.member event type support (#5022)
* WIP * temp Signed-off-by: Timo K <toger5@hotmail.de> * Fix imports * Fix checkSessionsMembershipData thinking foci_preferred is required * incorporate CallMembership changes - rename Focus -> Transport - add RtcMembershipData (next to `sessionMembershipData`) - make `new CallMembership` initializable with both - move oldest member calculation into CallMembership Signed-off-by: Timo K <toger5@hotmail.de> * use correct event type Signed-off-by: Timo K <toger5@hotmail.de> * fix sonar cube conerns Signed-off-by: Timo K <toger5@hotmail.de> * callMembership tests Signed-off-by: Timo K <toger5@hotmail.de> * make test correct Signed-off-by: Timo K <toger5@hotmail.de> * make sonar cube happy (it does not know about the type constraints...) Signed-off-by: Timo K <toger5@hotmail.de> * remove created_ts from RtcMembership Signed-off-by: Timo K <toger5@hotmail.de> * fix imports Signed-off-by: Timo K <toger5@hotmail.de> * Update src/matrixrtc/IMembershipManager.ts Co-authored-by: Robin <robin@robin.town> * rename LivekitFocus.ts -> LivekitTransport.ts Signed-off-by: Timo K <toger5@hotmail.de> * add details to `getTransport` Signed-off-by: Timo K <toger5@hotmail.de> * review Signed-off-by: Timo K <toger5@hotmail.de> * use DEFAULT_EXPIRE_DURATION in tests Signed-off-by: Timo K <toger5@hotmail.de> * fix test `does not provide focus if the selection method is unknown` Signed-off-by: Timo K <toger5@hotmail.de> * Update src/matrixrtc/CallMembership.ts Co-authored-by: Robin <robin@robin.town> * Move `m.call.intent` into the `application` section for rtc member events. Signed-off-by: Timo K <toger5@hotmail.de> * review on rtc object validation code. Signed-off-by: Timo K <toger5@hotmail.de> * user id check Signed-off-by: Timo K <toger5@hotmail.de> * review: Refactor RTC membership handling and improve error handling Signed-off-by: Timo K <toger5@hotmail.de> * docstring updates Signed-off-by: Timo K <toger5@hotmail.de> * add back deprecated `getFocusInUse` & `getActiveFocus` Signed-off-by: Timo K <toger5@hotmail.de> * ci Signed-off-by: Timo K <toger5@hotmail.de> * Update src/matrixrtc/CallMembership.ts Co-authored-by: Robin <robin@robin.town> * lint Signed-off-by: Timo K <toger5@hotmail.de> * make test less strict for ew tests Signed-off-by: Timo K <toger5@hotmail.de> * Typescript downstream test adjustments Signed-off-by: Timo K <toger5@hotmail.de> * err Signed-off-by: Timo K <toger5@hotmail.de> --------- Signed-off-by: Timo K <toger5@hotmail.de> Co-authored-by: Robin <robin@robin.town>
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
|||||||
CallMembership,
|
CallMembership,
|
||||||
type SessionMembershipData,
|
type SessionMembershipData,
|
||||||
DEFAULT_EXPIRE_DURATION,
|
DEFAULT_EXPIRE_DURATION,
|
||||||
|
type RtcMembershipData,
|
||||||
} from "../../../src/matrixrtc/CallMembership";
|
} from "../../../src/matrixrtc/CallMembership";
|
||||||
import { membershipTemplate } from "./mocks";
|
import { membershipTemplate } from "./mocks";
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ function makeMockEvent(originTs = 0): MatrixEvent {
|
|||||||
return {
|
return {
|
||||||
getTs: jest.fn().mockReturnValue(originTs),
|
getTs: jest.fn().mockReturnValue(originTs),
|
||||||
getSender: jest.fn().mockReturnValue("@alice:example.org"),
|
getSender: jest.fn().mockReturnValue("@alice:example.org"),
|
||||||
|
getId: jest.fn().mockReturnValue("$eventid"),
|
||||||
} as unknown as MatrixEvent;
|
} as unknown as MatrixEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,12 +42,13 @@ describe("CallMembership", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const membershipTemplate: SessionMembershipData = {
|
const membershipTemplate: SessionMembershipData = {
|
||||||
call_id: "",
|
"call_id": "",
|
||||||
scope: "m.room",
|
"scope": "m.room",
|
||||||
application: "m.call",
|
"application": "m.call",
|
||||||
device_id: "AAAAAAA",
|
"device_id": "AAAAAAA",
|
||||||
focus_active: { type: "livekit" },
|
"focus_active": { type: "livekit", focus_selection: "oldest_membership" },
|
||||||
foci_preferred: [{ type: "livekit" }],
|
"foci_preferred": [{ type: "livekit" }],
|
||||||
|
"m.call.intent": "voice",
|
||||||
};
|
};
|
||||||
|
|
||||||
it("rejects membership with no device_id", () => {
|
it("rejects membership with no device_id", () => {
|
||||||
@@ -94,11 +97,271 @@ describe("CallMembership", () => {
|
|||||||
it("returns preferred foci", () => {
|
it("returns preferred foci", () => {
|
||||||
const fakeEvent = makeMockEvent();
|
const fakeEvent = makeMockEvent();
|
||||||
const mockFocus = { type: "this_is_a_mock_focus" };
|
const mockFocus = { type: "this_is_a_mock_focus" };
|
||||||
const membership = new CallMembership(
|
const membership = new CallMembership(fakeEvent, { ...membershipTemplate, foci_preferred: [mockFocus] });
|
||||||
fakeEvent,
|
expect(membership.transports).toEqual([mockFocus]);
|
||||||
Object.assign({}, membershipTemplate, { foci_preferred: [mockFocus] }),
|
});
|
||||||
);
|
|
||||||
expect(membership.getPreferredFoci()).toEqual([mockFocus]);
|
describe("getTransport", () => {
|
||||||
|
const mockFocus = { type: "this_is_a_mock_focus" };
|
||||||
|
const oldestMembership = new CallMembership(makeMockEvent(), membershipTemplate);
|
||||||
|
it("gets the correct active transport with oldest_membership", () => {
|
||||||
|
const membership = new CallMembership(makeMockEvent(), {
|
||||||
|
...membershipTemplate,
|
||||||
|
foci_preferred: [mockFocus],
|
||||||
|
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// if we are the oldest member we use our focus.
|
||||||
|
expect(membership.getTransport(membership)).toStrictEqual(mockFocus);
|
||||||
|
|
||||||
|
// If there is an older member we use its focus.
|
||||||
|
expect(membership.getTransport(oldestMembership)).toBe(membershipTemplate.foci_preferred[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gets the correct active transport with multi_sfu", () => {
|
||||||
|
const membership = new CallMembership(makeMockEvent(), {
|
||||||
|
...membershipTemplate,
|
||||||
|
foci_preferred: [mockFocus],
|
||||||
|
focus_active: { type: "livekit", focus_selection: "multi_sfu" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// if we are the oldest member we use our focus.
|
||||||
|
expect(membership.getTransport(membership)).toStrictEqual(mockFocus);
|
||||||
|
|
||||||
|
// If there is an older member we still use our own focus in multi sfu.
|
||||||
|
expect(membership.getTransport(oldestMembership)).toBe(mockFocus);
|
||||||
|
});
|
||||||
|
it("does not provide focus if the selection method is unknown", () => {
|
||||||
|
const membership = new CallMembership(makeMockEvent(), {
|
||||||
|
...membershipTemplate,
|
||||||
|
foci_preferred: [mockFocus],
|
||||||
|
focus_active: { type: "livekit", focus_selection: "unknown" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// if we are the oldest member we use our focus.
|
||||||
|
expect(membership.getTransport(membership)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("correct values from computed fields", () => {
|
||||||
|
const membership = new CallMembership(makeMockEvent(), membershipTemplate);
|
||||||
|
it("returns correct sender", () => {
|
||||||
|
expect(membership.sender).toBe("@alice:example.org");
|
||||||
|
});
|
||||||
|
it("returns correct eventId", () => {
|
||||||
|
expect(membership.eventId).toBe("$eventid");
|
||||||
|
});
|
||||||
|
it("returns correct slot_id", () => {
|
||||||
|
expect(membership.slotId).toBe("m.call#");
|
||||||
|
expect(membership.slotDescription).toStrictEqual({ id: "", application: "m.call" });
|
||||||
|
});
|
||||||
|
it("returns correct deviceId", () => {
|
||||||
|
expect(membership.deviceId).toBe("AAAAAAA");
|
||||||
|
});
|
||||||
|
it("returns correct call intent", () => {
|
||||||
|
expect(membership.callIntent).toBe("voice");
|
||||||
|
});
|
||||||
|
it("returns correct application", () => {
|
||||||
|
expect(membership.application).toStrictEqual("m.call");
|
||||||
|
});
|
||||||
|
it("returns correct applicationData", () => {
|
||||||
|
expect(membership.applicationData).toStrictEqual({ "type": "m.call", "m.call.intent": "voice" });
|
||||||
|
});
|
||||||
|
it("returns correct scope", () => {
|
||||||
|
expect(membership.scope).toBe("m.room");
|
||||||
|
});
|
||||||
|
it("returns correct membershipID", () => {
|
||||||
|
expect(membership.membershipID).toBe("0");
|
||||||
|
});
|
||||||
|
it("returns correct unused fields", () => {
|
||||||
|
expect(membership.getAbsoluteExpiry()).toBe(DEFAULT_EXPIRE_DURATION);
|
||||||
|
expect(membership.getMsUntilExpiry()).toBe(DEFAULT_EXPIRE_DURATION - Date.now());
|
||||||
|
expect(membership.isExpired()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RtcMembershipData", () => {
|
||||||
|
const membershipTemplate: RtcMembershipData = {
|
||||||
|
slot_id: "m.call#",
|
||||||
|
application: { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" },
|
||||||
|
member: { user_id: "@alice:example.org", device_id: "AAAAAAA", id: "xyzHASHxyz" },
|
||||||
|
rtc_transports: [{ type: "livekit" }],
|
||||||
|
versions: [],
|
||||||
|
msc4354_sticky_key: "abc123",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("rejects membership with no slot_id", () => {
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: undefined });
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
it("rejects membership with invalid slot_id", () => {
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "invalid_slot_id" });
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
it("accepts membership with valid slot_id", () => {
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#" });
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects membership with no application", () => {
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), { ...membershipTemplate, application: undefined });
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects membership with incorrect application", () => {
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), {
|
||||||
|
...membershipTemplate,
|
||||||
|
application: { wrong_type_key: "unknown" },
|
||||||
|
});
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects membership with no member", () => {
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), { ...membershipTemplate, member: undefined });
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects membership with incorrect member", () => {
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), { ...membershipTemplate, member: { i: "test" } });
|
||||||
|
}).toThrow();
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), {
|
||||||
|
...membershipTemplate,
|
||||||
|
member: { id: "test", device_id: "test", user_id_wrong: "test" },
|
||||||
|
});
|
||||||
|
}).toThrow();
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), {
|
||||||
|
...membershipTemplate,
|
||||||
|
member: { id: "test", device_id_wrong: "test", user_id_wrong: "test" },
|
||||||
|
});
|
||||||
|
}).toThrow();
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), {
|
||||||
|
...membershipTemplate,
|
||||||
|
member: { id: "test", device_id: "test", user_id: "@@test" },
|
||||||
|
});
|
||||||
|
}).toThrow();
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), {
|
||||||
|
...membershipTemplate,
|
||||||
|
member: { id: "test", device_id: "test", user_id: "@test-wrong-user:user.id" },
|
||||||
|
});
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
it("rejects membership with incorrect sticky_key", () => {
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), membershipTemplate);
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), {
|
||||||
|
...membershipTemplate,
|
||||||
|
sticky_key: 1,
|
||||||
|
msc4354_sticky_key: undefined,
|
||||||
|
});
|
||||||
|
}).toThrow();
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), {
|
||||||
|
...membershipTemplate,
|
||||||
|
sticky_key: "1",
|
||||||
|
msc4354_sticky_key: undefined,
|
||||||
|
});
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), { ...membershipTemplate, msc4354_sticky_key: undefined });
|
||||||
|
}).toThrow();
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), {
|
||||||
|
...membershipTemplate,
|
||||||
|
msc4354_sticky_key: 1,
|
||||||
|
sticky_key: "valid",
|
||||||
|
});
|
||||||
|
}).toThrow();
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), {
|
||||||
|
...membershipTemplate,
|
||||||
|
msc4354_sticky_key: "valid",
|
||||||
|
sticky_key: "valid",
|
||||||
|
});
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(() => {
|
||||||
|
new CallMembership(makeMockEvent(), {
|
||||||
|
...membershipTemplate,
|
||||||
|
msc4354_sticky_key: "valid_but_different",
|
||||||
|
sticky_key: "valid",
|
||||||
|
});
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("considers memberships unexpired if local age low enough", () => {
|
||||||
|
// TODO link prev event
|
||||||
|
});
|
||||||
|
|
||||||
|
it("considers memberships expired if local age large enough", () => {
|
||||||
|
// TODO link prev event
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getTransport", () => {
|
||||||
|
it("gets the correct active transport with oldest_membership", () => {
|
||||||
|
const oldestMembership = new CallMembership(makeMockEvent(), {
|
||||||
|
...membershipTemplate,
|
||||||
|
rtc_transports: [{ type: "oldest_transport" }],
|
||||||
|
});
|
||||||
|
const membership = new CallMembership(makeMockEvent(), membershipTemplate);
|
||||||
|
|
||||||
|
// if we are the oldest member we use our focus.
|
||||||
|
expect(membership.getTransport(membership)).toStrictEqual({ type: "livekit" });
|
||||||
|
|
||||||
|
// If there is an older member we use our own focus focus. (RtcMembershipData always uses multi sfu)
|
||||||
|
expect(membership.getTransport(oldestMembership)).toStrictEqual({ type: "livekit" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("correct values from computed fields", () => {
|
||||||
|
const membership = new CallMembership(makeMockEvent(), membershipTemplate);
|
||||||
|
it("returns correct sender", () => {
|
||||||
|
expect(membership.sender).toBe("@alice:example.org");
|
||||||
|
});
|
||||||
|
it("returns correct eventId", () => {
|
||||||
|
expect(membership.eventId).toBe("$eventid");
|
||||||
|
});
|
||||||
|
it("returns correct slot_id", () => {
|
||||||
|
expect(membership.slotId).toBe("m.call#");
|
||||||
|
expect(membership.slotDescription).toStrictEqual({ id: "", application: "m.call" });
|
||||||
|
});
|
||||||
|
it("returns correct deviceId", () => {
|
||||||
|
expect(membership.deviceId).toBe("AAAAAAA");
|
||||||
|
});
|
||||||
|
it("returns correct call intent", () => {
|
||||||
|
expect(membership.callIntent).toBe("voice");
|
||||||
|
});
|
||||||
|
it("returns correct application", () => {
|
||||||
|
expect(membership.application).toStrictEqual("m.call");
|
||||||
|
});
|
||||||
|
it("returns correct applicationData", () => {
|
||||||
|
expect(membership.applicationData).toStrictEqual({
|
||||||
|
"type": "m.call",
|
||||||
|
"m.call.id": "",
|
||||||
|
"m.call.intent": "voice",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("returns correct scope", () => {
|
||||||
|
expect(membership.scope).toBe(undefined);
|
||||||
|
});
|
||||||
|
it("returns correct membershipID", () => {
|
||||||
|
expect(membership.membershipID).toBe("xyzHASHxyz");
|
||||||
|
});
|
||||||
|
it("returns correct unused fields", () => {
|
||||||
|
expect(membership.getAbsoluteExpiry()).toBe(undefined);
|
||||||
|
expect(membership.getMsUntilExpiry()).toBe(undefined);
|
||||||
|
expect(membership.isExpired()).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,47 +14,51 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { isLivekitFocus, isLivekitFocusActive, isLivekitFocusConfig } from "../../../src/matrixrtc/LivekitFocus";
|
import {
|
||||||
|
isLivekitTransport,
|
||||||
|
isLivekitFocusSelection,
|
||||||
|
isLivekitTransportConfig,
|
||||||
|
} from "../../../src/matrixrtc/LivekitTransport";
|
||||||
|
|
||||||
describe("LivekitFocus", () => {
|
describe("LivekitFocus", () => {
|
||||||
it("isLivekitFocus", () => {
|
it("isLivekitFocus", () => {
|
||||||
expect(
|
expect(
|
||||||
isLivekitFocus({
|
isLivekitTransport({
|
||||||
type: "livekit",
|
type: "livekit",
|
||||||
livekit_service_url: "http://test.com",
|
livekit_service_url: "http://test.com",
|
||||||
livekit_alias: "test",
|
livekit_alias: "test",
|
||||||
}),
|
}),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(isLivekitFocus({ type: "livekit" })).toBeFalsy();
|
expect(isLivekitTransport({ type: "livekit" })).toBeFalsy();
|
||||||
expect(
|
expect(
|
||||||
isLivekitFocus({ type: "not-livekit", livekit_service_url: "http://test.com", livekit_alias: "test" }),
|
isLivekitTransport({ type: "not-livekit", livekit_service_url: "http://test.com", livekit_alias: "test" }),
|
||||||
).toBeFalsy();
|
).toBeFalsy();
|
||||||
expect(
|
expect(
|
||||||
isLivekitFocus({ type: "livekit", other_service_url: "http://test.com", livekit_alias: "test" }),
|
isLivekitTransport({ type: "livekit", other_service_url: "http://test.com", livekit_alias: "test" }),
|
||||||
).toBeFalsy();
|
).toBeFalsy();
|
||||||
expect(
|
expect(
|
||||||
isLivekitFocus({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }),
|
isLivekitTransport({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }),
|
||||||
).toBeFalsy();
|
).toBeFalsy();
|
||||||
});
|
});
|
||||||
it("isLivekitFocusActive", () => {
|
it("isLivekitFocusActive", () => {
|
||||||
expect(
|
expect(
|
||||||
isLivekitFocusActive({
|
isLivekitFocusSelection({
|
||||||
type: "livekit",
|
type: "livekit",
|
||||||
focus_selection: "oldest_membership",
|
focus_selection: "oldest_membership",
|
||||||
}),
|
}),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(isLivekitFocusActive({ type: "livekit" })).toBeFalsy();
|
expect(isLivekitFocusSelection({ type: "livekit" })).toBeFalsy();
|
||||||
expect(isLivekitFocusActive({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy();
|
expect(isLivekitFocusSelection({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy();
|
||||||
});
|
});
|
||||||
it("isLivekitFocusConfig", () => {
|
it("isLivekitFocusConfig", () => {
|
||||||
expect(
|
expect(
|
||||||
isLivekitFocusConfig({
|
isLivekitTransportConfig({
|
||||||
type: "livekit",
|
type: "livekit",
|
||||||
livekit_service_url: "http://test.com",
|
livekit_service_url: "http://test.com",
|
||||||
}),
|
}),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(isLivekitFocusConfig({ type: "livekit" })).toBeFalsy();
|
expect(isLivekitTransportConfig({ type: "livekit" })).toBeFalsy();
|
||||||
expect(isLivekitFocusConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy();
|
expect(isLivekitTransportConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy();
|
||||||
expect(isLivekitFocusConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy();
|
expect(isLivekitTransportConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -53,12 +53,12 @@ describe("MatrixRTCSession", () => {
|
|||||||
|
|
||||||
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
|
||||||
expect(sess?.memberships.length).toEqual(1);
|
expect(sess?.memberships.length).toEqual(1);
|
||||||
expect(sess?.memberships[0].sessionDescription.id).toEqual("");
|
expect(sess?.memberships[0].slotDescription.id).toEqual("");
|
||||||
expect(sess?.memberships[0].scope).toEqual("m.room");
|
expect(sess?.memberships[0].scope).toEqual("m.room");
|
||||||
expect(sess?.memberships[0].application).toEqual("m.call");
|
expect(sess?.memberships[0].application).toEqual("m.call");
|
||||||
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
||||||
expect(sess?.memberships[0].isExpired()).toEqual(false);
|
expect(sess?.memberships[0].isExpired()).toEqual(false);
|
||||||
expect(sess?.sessionDescription.id).toEqual("");
|
expect(sess?.slotDescription.id).toEqual("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores memberships where application is not m.call", () => {
|
it("ignores memberships where application is not m.call", () => {
|
||||||
@@ -268,7 +268,6 @@ describe("MatrixRTCSession", () => {
|
|||||||
type: "livekit",
|
type: "livekit",
|
||||||
focus_selection: "oldest_membership",
|
focus_selection: "oldest_membership",
|
||||||
});
|
});
|
||||||
expect(sess.getActiveFocus()).toBe(firstPreferredFocus);
|
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
});
|
});
|
||||||
it("does not provide focus if the selection method is unknown", () => {
|
it("does not provide focus if the selection method is unknown", () => {
|
||||||
@@ -288,7 +287,7 @@ describe("MatrixRTCSession", () => {
|
|||||||
type: "livekit",
|
type: "livekit",
|
||||||
focus_selection: "unknown",
|
focus_selection: "unknown",
|
||||||
});
|
});
|
||||||
expect(sess.getActiveFocus()).toBe(undefined);
|
expect(sess.memberships.length).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,12 +27,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
MembershipManagerEvent,
|
MembershipManagerEvent,
|
||||||
Status,
|
Status,
|
||||||
type Focus,
|
type Transport,
|
||||||
type LivekitFocusActive,
|
|
||||||
type SessionMembershipData,
|
type SessionMembershipData,
|
||||||
|
type LivekitFocusSelection,
|
||||||
} from "../../../src/matrixrtc";
|
} from "../../../src/matrixrtc";
|
||||||
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
|
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
|
||||||
import { logger } from "../../../src/logger.ts";
|
|
||||||
import { MembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
|
import { MembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,11 +75,11 @@ const callSession = { id: "", application: "m.call" };
|
|||||||
describe("MembershipManager", () => {
|
describe("MembershipManager", () => {
|
||||||
let client: MockClient;
|
let client: MockClient;
|
||||||
let room: Room;
|
let room: Room;
|
||||||
const focusActive: LivekitFocusActive = {
|
const focusActive: LivekitFocusSelection = {
|
||||||
focus_selection: "oldest_membership",
|
focus_selection: "oldest_membership",
|
||||||
type: "livekit",
|
type: "livekit",
|
||||||
};
|
};
|
||||||
const focus: Focus = {
|
const focus: Transport = {
|
||||||
type: "livekit",
|
type: "livekit",
|
||||||
livekit_service_url: "https://active.url",
|
livekit_service_url: "https://active.url",
|
||||||
livekit_alias: "!active:active.url",
|
livekit_alias: "!active:active.url",
|
||||||
@@ -104,12 +103,12 @@ describe("MembershipManager", () => {
|
|||||||
|
|
||||||
describe("isActivated()", () => {
|
describe("isActivated()", () => {
|
||||||
it("defaults to false", () => {
|
it("defaults to false", () => {
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
expect(manager.isActivated()).toEqual(false);
|
expect(manager.isActivated()).toEqual(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns true after join()", () => {
|
it("returns true after join()", () => {
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
manager.join([]);
|
manager.join([]);
|
||||||
expect(manager.isActivated()).toEqual(true);
|
expect(manager.isActivated()).toEqual(true);
|
||||||
});
|
});
|
||||||
@@ -123,8 +122,8 @@ describe("MembershipManager", () => {
|
|||||||
const updateDelayedEventHandle = createAsyncHandle<void>(client._unstable_updateDelayedEvent as Mock);
|
const updateDelayedEventHandle = createAsyncHandle<void>(client._unstable_updateDelayedEvent as Mock);
|
||||||
|
|
||||||
// Test
|
// Test
|
||||||
const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession);
|
const memberManager = new MembershipManager(undefined, room, client, callSession);
|
||||||
memberManager.join([focus], focusActive);
|
memberManager.join([focus], undefined);
|
||||||
// expects
|
// expects
|
||||||
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
|
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
|
||||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||||
@@ -152,8 +151,45 @@ describe("MembershipManager", () => {
|
|||||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends a rtc membership event when using `useRtcMemberFormat`", async () => {
|
||||||
|
// Spys/Mocks
|
||||||
|
|
||||||
|
const updateDelayedEventHandle = createAsyncHandle<void>(client._unstable_updateDelayedEvent as Mock);
|
||||||
|
|
||||||
|
// Test
|
||||||
|
const memberManager = new MembershipManager({ useRtcMemberFormat: true }, room, client, callSession);
|
||||||
|
memberManager.join([], focus);
|
||||||
|
// expects
|
||||||
|
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
|
||||||
|
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||||
|
room.roomId,
|
||||||
|
"org.matrix.msc4143.rtc.member",
|
||||||
|
{
|
||||||
|
application: { type: "m.call" },
|
||||||
|
member: {
|
||||||
|
user_id: "@alice:example.org",
|
||||||
|
id: "_@alice:example.org_AAAAAAA_m.call",
|
||||||
|
device_id: "AAAAAAA",
|
||||||
|
},
|
||||||
|
slot_id: "m.call#",
|
||||||
|
rtc_transports: [focus],
|
||||||
|
versions: [],
|
||||||
|
},
|
||||||
|
"_@alice:example.org_AAAAAAA_m.call",
|
||||||
|
);
|
||||||
|
updateDelayedEventHandle.resolve?.();
|
||||||
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
|
||||||
|
room.roomId,
|
||||||
|
{ delay: 8000 },
|
||||||
|
"org.matrix.msc4143.rtc.member",
|
||||||
|
{},
|
||||||
|
"_@alice:example.org_AAAAAAA_m.call",
|
||||||
|
);
|
||||||
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("reschedules delayed leave event if sending state cancels it", async () => {
|
it("reschedules delayed leave event if sending state cancels it", async () => {
|
||||||
const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession);
|
const memberManager = new MembershipManager(undefined, room, client, callSession);
|
||||||
const waitForSendState = waitForMockCall(client.sendStateEvent);
|
const waitForSendState = waitForMockCall(client.sendStateEvent);
|
||||||
const waitForUpdateDelaye = waitForMockCallOnce(
|
const waitForUpdateDelaye = waitForMockCallOnce(
|
||||||
client._unstable_updateDelayedEvent,
|
client._unstable_updateDelayedEvent,
|
||||||
@@ -228,10 +264,9 @@ describe("MembershipManager", () => {
|
|||||||
},
|
},
|
||||||
room,
|
room,
|
||||||
client,
|
client,
|
||||||
() => undefined,
|
|
||||||
callSession,
|
callSession,
|
||||||
);
|
);
|
||||||
manager.join([focus], focusActive);
|
manager.join([focus]);
|
||||||
|
|
||||||
await sendDelayedStateExceedAttempt.then(); // needed to resolve after the send attempt catches
|
await sendDelayedStateExceedAttempt.then(); // needed to resolve after the send attempt catches
|
||||||
await sendDelayedStateAttempt;
|
await sendDelayedStateAttempt;
|
||||||
@@ -286,8 +321,8 @@ describe("MembershipManager", () => {
|
|||||||
describe("delayed leave event", () => {
|
describe("delayed leave event", () => {
|
||||||
it("does not try again to schedule a delayed leave event if not supported", () => {
|
it("does not try again to schedule a delayed leave event if not supported", () => {
|
||||||
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
|
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
manager.join([focus], focusActive);
|
manager.join([focus]);
|
||||||
delayedHandle.reject?.(
|
delayedHandle.reject?.(
|
||||||
new UnsupportedDelayedEventsEndpointError(
|
new UnsupportedDelayedEventsEndpointError(
|
||||||
"Server does not support the delayed events API",
|
"Server does not support the delayed events API",
|
||||||
@@ -298,21 +333,15 @@ describe("MembershipManager", () => {
|
|||||||
});
|
});
|
||||||
it("does try to schedule a delayed leave event again if rate limited", async () => {
|
it("does try to schedule a delayed leave event again if rate limited", async () => {
|
||||||
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
|
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
manager.join([focus], focusActive);
|
manager.join([focus]);
|
||||||
delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined));
|
delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined));
|
||||||
await jest.advanceTimersByTimeAsync(5000);
|
await jest.advanceTimersByTimeAsync(5000);
|
||||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
it("uses delayedLeaveEventDelayMs from config", () => {
|
it("uses delayedLeaveEventDelayMs from config", () => {
|
||||||
const manager = new MembershipManager(
|
const manager = new MembershipManager({ delayedLeaveEventDelayMs: 123456 }, room, client, callSession);
|
||||||
{ delayedLeaveEventDelayMs: 123456 },
|
manager.join([focus]);
|
||||||
room,
|
|
||||||
client,
|
|
||||||
() => undefined,
|
|
||||||
callSession,
|
|
||||||
);
|
|
||||||
manager.join([focus], focusActive);
|
|
||||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
|
||||||
room.roomId,
|
room.roomId,
|
||||||
{ delay: 123456 },
|
{ delay: 123456 },
|
||||||
@@ -329,11 +358,11 @@ describe("MembershipManager", () => {
|
|||||||
{ delayedLeaveEventRestartMs: RESTART_DELAY },
|
{ delayedLeaveEventRestartMs: RESTART_DELAY },
|
||||||
room,
|
room,
|
||||||
client,
|
client,
|
||||||
() => undefined,
|
|
||||||
callSession,
|
callSession,
|
||||||
);
|
);
|
||||||
// Join with the membership manager
|
// Join with the membership manager
|
||||||
manager.join([focus], focusActive);
|
manager.join([focus]);
|
||||||
expect(manager.status).toBe(Status.Connecting);
|
expect(manager.status).toBe(Status.Connecting);
|
||||||
// Let the scheduler run one iteration so that we can send the join state event
|
// Let the scheduler run one iteration so that we can send the join state event
|
||||||
await jest.runOnlyPendingTimersAsync();
|
await jest.runOnlyPendingTimersAsync();
|
||||||
@@ -367,11 +396,11 @@ describe("MembershipManager", () => {
|
|||||||
{ membershipEventExpiryMs: 1234567 },
|
{ membershipEventExpiryMs: 1234567 },
|
||||||
room,
|
room,
|
||||||
client,
|
client,
|
||||||
() => undefined,
|
|
||||||
callSession,
|
callSession,
|
||||||
);
|
);
|
||||||
|
|
||||||
manager.join([focus], focusActive);
|
manager.join([focus]);
|
||||||
await waitForMockCall(client.sendStateEvent);
|
await waitForMockCall(client.sendStateEvent);
|
||||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||||
room.roomId,
|
room.roomId,
|
||||||
@@ -393,11 +422,11 @@ describe("MembershipManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does nothing if join called when already joined", async () => {
|
it("does nothing if join called when already joined", async () => {
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
manager.join([focus], focusActive);
|
manager.join([focus]);
|
||||||
await waitForMockCall(client.sendStateEvent);
|
await waitForMockCall(client.sendStateEvent);
|
||||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||||
manager.join([focus], focusActive);
|
manager.join([focus]);
|
||||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -405,16 +434,16 @@ describe("MembershipManager", () => {
|
|||||||
describe("leave()", () => {
|
describe("leave()", () => {
|
||||||
// TODO add rate limit cases.
|
// TODO add rate limit cases.
|
||||||
it("resolves delayed leave event when leave is called", async () => {
|
it("resolves delayed leave event when leave is called", async () => {
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
manager.join([focus], focusActive);
|
manager.join([focus]);
|
||||||
await jest.advanceTimersByTimeAsync(1);
|
await jest.advanceTimersByTimeAsync(1);
|
||||||
await manager.leave();
|
await manager.leave();
|
||||||
expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", "send");
|
expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", "send");
|
||||||
expect(client.sendStateEvent).toHaveBeenCalled();
|
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it("send leave event when leave is called and resolving delayed leave fails", async () => {
|
it("send leave event when leave is called and resolving delayed leave fails", async () => {
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
manager.join([focus], focusActive);
|
manager.join([focus]);
|
||||||
await jest.advanceTimersByTimeAsync(1);
|
await jest.advanceTimersByTimeAsync(1);
|
||||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
|
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
|
||||||
await manager.leave();
|
await manager.leave();
|
||||||
@@ -428,60 +457,16 @@ describe("MembershipManager", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
it("does nothing if not joined", () => {
|
it("does nothing if not joined", () => {
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
expect(async () => await manager.leave()).not.toThrow();
|
expect(async () => await manager.leave()).not.toThrow();
|
||||||
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
|
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
|
||||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getsActiveFocus", () => {
|
|
||||||
it("gets the correct active focus with oldest_membership", () => {
|
|
||||||
const getOldestMembership = jest.fn();
|
|
||||||
const manager = new MembershipManager({}, room, client, getOldestMembership, callSession);
|
|
||||||
// Before joining the active focus should be undefined (see FocusInUse on MatrixRTCSession)
|
|
||||||
expect(manager.getActiveFocus()).toBe(undefined);
|
|
||||||
manager.join([focus], focusActive);
|
|
||||||
// After joining we want our own focus to be the one we select.
|
|
||||||
getOldestMembership.mockReturnValue(
|
|
||||||
mockCallMembership(
|
|
||||||
{
|
|
||||||
...membershipTemplate,
|
|
||||||
foci_preferred: [
|
|
||||||
{
|
|
||||||
livekit_alias: "!active:active.url",
|
|
||||||
livekit_service_url: "https://active.url",
|
|
||||||
type: "livekit",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
user_id: client.getUserId()!,
|
|
||||||
device_id: client.getDeviceId()!,
|
|
||||||
created_ts: 1000,
|
|
||||||
},
|
|
||||||
room.roomId,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(manager.getActiveFocus()).toStrictEqual(focus);
|
|
||||||
getOldestMembership.mockReturnValue(
|
|
||||||
mockCallMembership(
|
|
||||||
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
|
|
||||||
room.roomId,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
// If there is an older member we use its focus.
|
|
||||||
expect(manager.getActiveFocus()).toBe(membershipTemplate.foci_preferred[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not provide focus if the selection method is unknown", () => {
|
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
|
||||||
manager.join([focus], Object.assign(focusActive, { type: "unknown_type" }));
|
|
||||||
expect(manager.getActiveFocus()).toBe(undefined);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("onRTCSessionMemberUpdate()", () => {
|
describe("onRTCSessionMemberUpdate()", () => {
|
||||||
it("does nothing if not joined", async () => {
|
it("does nothing if not joined", async () => {
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
|
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
|
||||||
await jest.advanceTimersToNextTimerAsync();
|
await jest.advanceTimersToNextTimerAsync();
|
||||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||||
@@ -489,7 +474,7 @@ describe("MembershipManager", () => {
|
|||||||
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
|
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it("does nothing if own membership still present", async () => {
|
it("does nothing if own membership still present", async () => {
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
manager.join([focus], focusActive);
|
manager.join([focus], focusActive);
|
||||||
await jest.advanceTimersByTimeAsync(1);
|
await jest.advanceTimersByTimeAsync(1);
|
||||||
const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2];
|
const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2];
|
||||||
@@ -513,7 +498,7 @@ describe("MembershipManager", () => {
|
|||||||
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
|
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it("recreates membership if it is missing", async () => {
|
it("recreates membership if it is missing", async () => {
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
manager.join([focus], focusActive);
|
manager.join([focus], focusActive);
|
||||||
await jest.advanceTimersByTimeAsync(1);
|
await jest.advanceTimersByTimeAsync(1);
|
||||||
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
|
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
|
||||||
@@ -531,7 +516,7 @@ describe("MembershipManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("updates the UpdateExpiry entry in the action scheduler", async () => {
|
it("updates the UpdateExpiry entry in the action scheduler", async () => {
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
manager.join([focus], focusActive);
|
manager.join([focus], focusActive);
|
||||||
await jest.advanceTimersByTimeAsync(1);
|
await jest.advanceTimersByTimeAsync(1);
|
||||||
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
|
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
|
||||||
@@ -564,7 +549,6 @@ describe("MembershipManager", () => {
|
|||||||
{ delayedLeaveEventRestartMs: 10_000, delayedLeaveEventDelayMs: 30_000 },
|
{ delayedLeaveEventRestartMs: 10_000, delayedLeaveEventDelayMs: 30_000 },
|
||||||
room,
|
room,
|
||||||
client,
|
client,
|
||||||
() => undefined,
|
|
||||||
{ id: "", application: "m.call" },
|
{ id: "", application: "m.call" },
|
||||||
);
|
);
|
||||||
manager.join([focus], focusActive);
|
manager.join([focus], focusActive);
|
||||||
@@ -596,7 +580,7 @@ describe("MembershipManager", () => {
|
|||||||
{ membershipEventExpiryMs: expire, membershipEventExpiryHeadroomMs: headroom },
|
{ membershipEventExpiryMs: expire, membershipEventExpiryHeadroomMs: headroom },
|
||||||
room,
|
room,
|
||||||
client,
|
client,
|
||||||
() => undefined,
|
|
||||||
{ id: "", application: "m.call" },
|
{ id: "", application: "m.call" },
|
||||||
);
|
);
|
||||||
manager.join([focus], focusActive);
|
manager.join([focus], focusActive);
|
||||||
@@ -621,14 +605,14 @@ describe("MembershipManager", () => {
|
|||||||
|
|
||||||
describe("status updates", () => {
|
describe("status updates", () => {
|
||||||
it("starts 'Disconnected'", () => {
|
it("starts 'Disconnected'", () => {
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
expect(manager.status).toBe(Status.Disconnected);
|
expect(manager.status).toBe(Status.Disconnected);
|
||||||
});
|
});
|
||||||
it("emits 'Connection' and 'Connected' after join", async () => {
|
it("emits 'Connection' and 'Connected' after join", async () => {
|
||||||
const handleDelayedEvent = createAsyncHandle<void>(client._unstable_sendDelayedStateEvent);
|
const handleDelayedEvent = createAsyncHandle<void>(client._unstable_sendDelayedStateEvent);
|
||||||
const handleStateEvent = createAsyncHandle<void>(client.sendStateEvent);
|
const handleStateEvent = createAsyncHandle<void>(client.sendStateEvent);
|
||||||
|
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
expect(manager.status).toBe(Status.Disconnected);
|
expect(manager.status).toBe(Status.Disconnected);
|
||||||
const connectEmit = jest.fn();
|
const connectEmit = jest.fn();
|
||||||
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
|
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
|
||||||
@@ -642,7 +626,7 @@ describe("MembershipManager", () => {
|
|||||||
expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected);
|
expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected);
|
||||||
});
|
});
|
||||||
it("emits 'Disconnecting' and 'Disconnected' after leave", async () => {
|
it("emits 'Disconnecting' and 'Disconnected' after leave", async () => {
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
const connectEmit = jest.fn();
|
const connectEmit = jest.fn();
|
||||||
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
|
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
|
||||||
manager.join([focus], focusActive);
|
manager.join([focus], focusActive);
|
||||||
@@ -658,7 +642,7 @@ describe("MembershipManager", () => {
|
|||||||
it("sends retry if call membership event is still valid at time of retry", async () => {
|
it("sends retry if call membership event is still valid at time of retry", async () => {
|
||||||
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||||
|
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
manager.join([focus], focusActive);
|
manager.join([focus], focusActive);
|
||||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
@@ -685,7 +669,7 @@ describe("MembershipManager", () => {
|
|||||||
new Headers({ "Retry-After": "1" }),
|
new Headers({ "Retry-After": "1" }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
// Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the
|
// Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the
|
||||||
// RateLimit error.
|
// RateLimit error.
|
||||||
manager.join([focus], focusActive);
|
manager.join([focus], focusActive);
|
||||||
@@ -705,7 +689,7 @@ describe("MembershipManager", () => {
|
|||||||
it("abandons retry loop if leave() was called before sending state event", async () => {
|
it("abandons retry loop if leave() was called before sending state event", async () => {
|
||||||
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||||
|
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
manager.join([focus], focusActive);
|
manager.join([focus], focusActive);
|
||||||
handle.reject?.(
|
handle.reject?.(
|
||||||
new MatrixError(
|
new MatrixError(
|
||||||
@@ -740,7 +724,7 @@ describe("MembershipManager", () => {
|
|||||||
new Headers({ "Retry-After": "1" }),
|
new Headers({ "Retry-After": "1" }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
manager.join([focus], focusActive);
|
manager.join([focus], focusActive);
|
||||||
|
|
||||||
// Hit rate limit
|
// Hit rate limit
|
||||||
@@ -773,7 +757,7 @@ describe("MembershipManager", () => {
|
|||||||
new Headers({ "Retry-After": "2" }),
|
new Headers({ "Retry-After": "2" }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
manager.join([focus], focusActive, delayEventSendError);
|
manager.join([focus], focusActive, delayEventSendError);
|
||||||
|
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
@@ -793,7 +777,7 @@ describe("MembershipManager", () => {
|
|||||||
new Headers({ "Retry-After": "1" }),
|
new Headers({ "Retry-After": "1" }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
manager.join([focus], focusActive, delayEventRestartError);
|
manager.join([focus], focusActive, delayEventRestartError);
|
||||||
|
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
@@ -804,7 +788,7 @@ describe("MembershipManager", () => {
|
|||||||
it("falls back to using pure state events when some error occurs while sending delayed events", async () => {
|
it("falls back to using pure state events when some error occurs while sending delayed events", async () => {
|
||||||
const unrecoverableError = jest.fn();
|
const unrecoverableError = jest.fn();
|
||||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 601));
|
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 601));
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
manager.join([focus], focusActive, unrecoverableError);
|
manager.join([focus], focusActive, unrecoverableError);
|
||||||
await waitForMockCall(client.sendStateEvent);
|
await waitForMockCall(client.sendStateEvent);
|
||||||
expect(unrecoverableError).not.toHaveBeenCalledWith();
|
expect(unrecoverableError).not.toHaveBeenCalledWith();
|
||||||
@@ -817,7 +801,6 @@ describe("MembershipManager", () => {
|
|||||||
{ networkErrorRetryMs: 1000, maximumNetworkErrorRetryCount: 7 },
|
{ networkErrorRetryMs: 1000, maximumNetworkErrorRetryCount: 7 },
|
||||||
room,
|
room,
|
||||||
client,
|
client,
|
||||||
() => undefined,
|
|
||||||
callSession,
|
callSession,
|
||||||
);
|
);
|
||||||
manager.join([focus], focusActive, unrecoverableError);
|
manager.join([focus], focusActive, unrecoverableError);
|
||||||
@@ -836,7 +819,7 @@ describe("MembershipManager", () => {
|
|||||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
|
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
|
||||||
new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"),
|
new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"),
|
||||||
);
|
);
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
manager.join([focus], focusActive, unrecoverableError);
|
manager.join([focus], focusActive, unrecoverableError);
|
||||||
await jest.advanceTimersByTimeAsync(1);
|
await jest.advanceTimersByTimeAsync(1);
|
||||||
|
|
||||||
@@ -850,7 +833,7 @@ describe("MembershipManager", () => {
|
|||||||
{ delayedLeaveEventDelayMs: 10000 },
|
{ delayedLeaveEventDelayMs: 10000 },
|
||||||
room,
|
room,
|
||||||
client,
|
client,
|
||||||
() => undefined,
|
|
||||||
callSession,
|
callSession,
|
||||||
);
|
);
|
||||||
const { promise: stuckPromise, reject: rejectStuckPromise } = Promise.withResolvers<EmptyObject>();
|
const { promise: stuckPromise, reject: rejectStuckPromise } = Promise.withResolvers<EmptyObject>();
|
||||||
@@ -904,7 +887,7 @@ describe("MembershipManager", () => {
|
|||||||
|
|
||||||
describe("updateCallIntent()", () => {
|
describe("updateCallIntent()", () => {
|
||||||
it("should fail if the user has not joined the call", async () => {
|
it("should fail if the user has not joined the call", async () => {
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
// After joining we want our own focus to be the one we select.
|
// After joining we want our own focus to be the one we select.
|
||||||
try {
|
try {
|
||||||
await manager.updateCallIntent("video");
|
await manager.updateCallIntent("video");
|
||||||
@@ -913,7 +896,7 @@ describe("MembershipManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("can adjust the intent", async () => {
|
it("can adjust the intent", async () => {
|
||||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({}, room, client, callSession);
|
||||||
manager.join([]);
|
manager.join([]);
|
||||||
expect(manager.isActivated()).toEqual(true);
|
expect(manager.isActivated()).toEqual(true);
|
||||||
const membership = mockCallMembership({ ...membershipTemplate, user_id: client.getUserId()! }, room.roomId);
|
const membership = mockCallMembership({ ...membershipTemplate, user_id: client.getUserId()! }, room.roomId);
|
||||||
@@ -926,7 +909,7 @@ describe("MembershipManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does nothing if the intent doesn't change", async () => {
|
it("does nothing if the intent doesn't change", async () => {
|
||||||
const manager = new MembershipManager({ callIntent: "video" }, room, client, () => undefined, callSession);
|
const manager = new MembershipManager({ callIntent: "video" }, room, client, callSession);
|
||||||
manager.join([]);
|
manager.join([]);
|
||||||
expect(manager.isActivated()).toEqual(true);
|
expect(manager.isActivated()).toEqual(true);
|
||||||
const membership = mockCallMembership(
|
const membership = mockCallMembership(
|
||||||
@@ -944,7 +927,7 @@ it("Should prefix log with MembershipManager used", () => {
|
|||||||
const client = makeMockClient("@alice:example.org", "AAAAAAA");
|
const client = makeMockClient("@alice:example.org", "AAAAAAA");
|
||||||
const room = makeMockRoom([membershipTemplate]);
|
const room = makeMockRoom([membershipTemplate]);
|
||||||
|
|
||||||
const membershipManager = new MembershipManager(undefined, room, client, () => undefined, callSession, logger);
|
const membershipManager = new MembershipManager(undefined, room, client, callSession);
|
||||||
|
|
||||||
const spy = jest.spyOn(console, "error");
|
const spy = jest.spyOn(console, "error");
|
||||||
// Double join
|
// Double join
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ import {
|
|||||||
type ICallNotifyContent,
|
type ICallNotifyContent,
|
||||||
} from "../matrixrtc/types.ts";
|
} from "../matrixrtc/types.ts";
|
||||||
import { type M_POLL_END, type M_POLL_START, type PollEndEventContent, type PollStartEventContent } from "./polls.ts";
|
import { type M_POLL_END, type M_POLL_START, type PollEndEventContent, type PollStartEventContent } from "./polls.ts";
|
||||||
import { type SessionMembershipData } from "../matrixrtc/CallMembership.ts";
|
import { type RtcMembershipData, type SessionMembershipData } from "../matrixrtc/CallMembership.ts";
|
||||||
import { type LocalNotificationSettings } from "./local_notifications.ts";
|
import { type LocalNotificationSettings } from "./local_notifications.ts";
|
||||||
import { type IPushRules } from "./PushRules.ts";
|
import { type IPushRules } from "./PushRules.ts";
|
||||||
import { type SecretInfo, type SecretStorageKeyDescription } from "../secret-storage.ts";
|
import { type SecretInfo, type SecretStorageKeyDescription } from "../secret-storage.ts";
|
||||||
@@ -151,6 +151,7 @@ export enum EventType {
|
|||||||
GroupCallMemberPrefix = "org.matrix.msc3401.call.member",
|
GroupCallMemberPrefix = "org.matrix.msc3401.call.member",
|
||||||
|
|
||||||
// MatrixRTC events
|
// MatrixRTC events
|
||||||
|
RTCMembership = "org.matrix.msc4143.rtc.member",
|
||||||
CallNotify = "org.matrix.msc4075.call.notify",
|
CallNotify = "org.matrix.msc4075.call.notify",
|
||||||
RTCNotification = "org.matrix.msc4075.rtc.notification",
|
RTCNotification = "org.matrix.msc4075.rtc.notification",
|
||||||
RTCDecline = "org.matrix.msc4310.rtc.decline",
|
RTCDecline = "org.matrix.msc4310.rtc.decline",
|
||||||
@@ -369,7 +370,7 @@ export interface StateEvents {
|
|||||||
// MSC3401
|
// MSC3401
|
||||||
[EventType.GroupCallPrefix]: IGroupCallRoomState;
|
[EventType.GroupCallPrefix]: IGroupCallRoomState;
|
||||||
[EventType.GroupCallMemberPrefix]: IGroupCallRoomMemberState | SessionMembershipData | EmptyObject;
|
[EventType.GroupCallMemberPrefix]: IGroupCallRoomMemberState | SessionMembershipData | EmptyObject;
|
||||||
|
[EventType.RTCMembership]: RtcMembershipData | EmptyObject;
|
||||||
// MSC3089
|
// MSC3089
|
||||||
[UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent;
|
[UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent;
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type MatrixEvent } from "../matrix.ts";
|
import { MXID_PATTERN } from "../models/room-member.ts";
|
||||||
import { deepCompare } from "../utils.ts";
|
import { deepCompare } from "../utils.ts";
|
||||||
import { type Focus } from "./focus.ts";
|
import { type LivekitFocusSelection } from "./LivekitTransport.ts";
|
||||||
import { isLivekitFocusActive } from "./LivekitFocus.ts";
|
import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts";
|
||||||
import { type SessionDescription } from "./MatrixRTCSession.ts";
|
import type { RTCCallIntent, Transport } from "./types.ts";
|
||||||
import { type RTCCallIntent } from "./types.ts";
|
import { type IContent, type MatrixEvent } from "../models/event.ts";
|
||||||
|
import { type RelationType } from "../@types/event.ts";
|
||||||
|
import { logger } from "../logger.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default duration in milliseconds that a membership is considered valid for.
|
* The default duration in milliseconds that a membership is considered valid for.
|
||||||
@@ -29,6 +31,106 @@ import { type RTCCallIntent } from "./types.ts";
|
|||||||
export const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4;
|
export const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4;
|
||||||
|
|
||||||
type CallScope = "m.room" | "m.user";
|
type CallScope = "m.room" | "m.user";
|
||||||
|
type Member = { user_id: string; device_id: string; id: string };
|
||||||
|
|
||||||
|
export interface RtcMembershipData {
|
||||||
|
"slot_id": string;
|
||||||
|
"member": Member;
|
||||||
|
"m.relates_to"?: {
|
||||||
|
event_id: string;
|
||||||
|
rel_type: RelationType.Reference;
|
||||||
|
};
|
||||||
|
"application": {
|
||||||
|
type: string;
|
||||||
|
// other application specific keys
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
"rtc_transports": Transport[];
|
||||||
|
"versions": string[];
|
||||||
|
"msc4354_sticky_key"?: string;
|
||||||
|
"sticky_key"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkRtcMembershipData = (
|
||||||
|
data: IContent,
|
||||||
|
errors: string[],
|
||||||
|
referenceUserId: string,
|
||||||
|
): data is RtcMembershipData => {
|
||||||
|
const prefix = " - ";
|
||||||
|
|
||||||
|
// required fields
|
||||||
|
if (typeof data.slot_id !== "string") {
|
||||||
|
errors.push(prefix + "slot_id must be string");
|
||||||
|
} else {
|
||||||
|
if (data.slot_id.split("#").length !== 2) errors.push(prefix + 'slot_id must include exactly one "#"');
|
||||||
|
}
|
||||||
|
if (typeof data.member !== "object" || data.member === null) {
|
||||||
|
errors.push(prefix + "member must be an object");
|
||||||
|
} else {
|
||||||
|
if (typeof data.member.user_id !== "string") errors.push(prefix + "member.user_id must be string");
|
||||||
|
else if (!MXID_PATTERN.test(data.member.user_id)) errors.push(prefix + "member.user_id must be a valid mxid");
|
||||||
|
// This is not what the spec enforces but there currently are no rules what power levels are required to
|
||||||
|
// send a m.rtc.member event for a other user. So we add this check for simplicity and to avoid possible attacks until there
|
||||||
|
// is a proper definition when this is allowed.
|
||||||
|
else if (data.member.user_id !== referenceUserId) errors.push(prefix + "member.user_id must match the sender");
|
||||||
|
if (typeof data.member.device_id !== "string") errors.push(prefix + "member.device_id must be string");
|
||||||
|
if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string");
|
||||||
|
}
|
||||||
|
if (typeof data.application !== "object" || data.application === null) {
|
||||||
|
errors.push(prefix + "application must be an object");
|
||||||
|
} else {
|
||||||
|
if (typeof data.application.type !== "string") {
|
||||||
|
errors.push(prefix + "application.type must be a string");
|
||||||
|
} else {
|
||||||
|
if (data.application.type.includes("#")) errors.push(prefix + 'application.type must not include "#"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.rtc_transports === undefined || !Array.isArray(data.rtc_transports)) {
|
||||||
|
errors.push(prefix + "rtc_transports must be an array");
|
||||||
|
} else {
|
||||||
|
// validate that each transport has at least a string 'type'
|
||||||
|
for (const t of data.rtc_transports) {
|
||||||
|
if (typeof t !== "object" || t === null || typeof (t as any).type !== "string") {
|
||||||
|
errors.push(prefix + "rtc_transports entries must be objects with a string type");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.versions === undefined || !Array.isArray(data.versions)) {
|
||||||
|
errors.push(prefix + "versions must be an array");
|
||||||
|
} else if (!data.versions.every((v) => typeof v === "string")) {
|
||||||
|
errors.push(prefix + "versions must be an array of strings");
|
||||||
|
}
|
||||||
|
|
||||||
|
// optional fields
|
||||||
|
if ((data.sticky_key ?? data.msc4354_sticky_key) === undefined) {
|
||||||
|
errors.push(prefix + "sticky_key or msc4354_sticky_key must be a defined");
|
||||||
|
}
|
||||||
|
if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") {
|
||||||
|
errors.push(prefix + "sticky_key must be a string");
|
||||||
|
}
|
||||||
|
if (data.msc4354_sticky_key !== undefined && typeof data.msc4354_sticky_key !== "string") {
|
||||||
|
errors.push(prefix + "msc4354_sticky_key must be a string");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
data.sticky_key !== undefined &&
|
||||||
|
data.msc4354_sticky_key !== undefined &&
|
||||||
|
data.sticky_key !== data.msc4354_sticky_key
|
||||||
|
) {
|
||||||
|
errors.push(prefix + "sticky_key and msc4354_sticky_key must be equal if both are defined");
|
||||||
|
}
|
||||||
|
if (data["m.relates_to"] !== undefined) {
|
||||||
|
const rel = data["m.relates_to"] as RtcMembershipData["m.relates_to"];
|
||||||
|
if (typeof rel !== "object" || rel === null) {
|
||||||
|
errors.push(prefix + "m.relates_to must be an object if provided");
|
||||||
|
} else {
|
||||||
|
if (typeof rel.event_id !== "string") errors.push(prefix + "m.relates_to.event_id must be a string");
|
||||||
|
if (rel.rel_type !== "m.reference") errors.push(prefix + "m.relates_to.rel_type must be m.reference");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MSC4143 (MatrixRTC) session membership data.
|
* MSC4143 (MatrixRTC) session membership data.
|
||||||
@@ -56,13 +158,13 @@ export type SessionMembershipData = {
|
|||||||
/**
|
/**
|
||||||
* The focus selection system this user/membership is using.
|
* The focus selection system this user/membership is using.
|
||||||
*/
|
*/
|
||||||
"focus_active": Focus;
|
"focus_active": LivekitFocusSelection;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A list of possible foci this uses knows about. One of them might be used based on the focus_active
|
* A list of possible foci this user knows about. One of them might be used based on the focus_active
|
||||||
* selection system.
|
* selection system.
|
||||||
*/
|
*/
|
||||||
"foci_preferred": Focus[];
|
"foci_preferred": Transport[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional field that contains the creation of the session. If it is undefined the creation
|
* Optional field that contains the creation of the session. If it is undefined the creation
|
||||||
@@ -77,7 +179,7 @@ export type SessionMembershipData = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* If the `application` = `"m.call"` this defines if it is a room or user owned call.
|
* If the `application` = `"m.call"` this defines if it is a room or user owned call.
|
||||||
* There can always be one room scroped call but multiple user owned calls (breakout sessions)
|
* There can always be one room scoped call but multiple user owned calls (breakout sessions)
|
||||||
*/
|
*/
|
||||||
"scope"?: CallScope;
|
"scope"?: CallScope;
|
||||||
|
|
||||||
@@ -95,16 +197,26 @@ export type SessionMembershipData = {
|
|||||||
"m.call.intent"?: RTCCallIntent;
|
"m.call.intent"?: RTCCallIntent;
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkSessionsMembershipData = (
|
const checkSessionsMembershipData = (data: IContent, errors: string[]): data is SessionMembershipData => {
|
||||||
data: Partial<Record<keyof SessionMembershipData, any>>,
|
const prefix = " - ";
|
||||||
errors: string[],
|
|
||||||
): data is SessionMembershipData => {
|
|
||||||
const prefix = "Malformed session membership event: ";
|
|
||||||
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
|
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
|
||||||
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
|
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
|
||||||
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
|
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
|
||||||
if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string");
|
if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string");
|
||||||
if (!Array.isArray(data.foci_preferred)) errors.push(prefix + "foci_preferred must be an array");
|
if (data.focus_active === undefined) {
|
||||||
|
errors.push(prefix + "focus_active has an invalid type");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
data.foci_preferred !== undefined &&
|
||||||
|
!(
|
||||||
|
Array.isArray(data.foci_preferred) &&
|
||||||
|
data.foci_preferred.every(
|
||||||
|
(f: Transport) => typeof f === "object" && f !== null && typeof f.type === "string",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
errors.push(prefix + "foci_preferred must be an array of transport objects");
|
||||||
|
}
|
||||||
// optional parameters
|
// optional parameters
|
||||||
if (data.created_ts !== undefined && typeof data.created_ts !== "number") {
|
if (data.created_ts !== undefined && typeof data.created_ts !== "number") {
|
||||||
errors.push(prefix + "created_ts must be number");
|
errors.push(prefix + "created_ts must be number");
|
||||||
@@ -120,109 +232,278 @@ const checkSessionsMembershipData = (
|
|||||||
return errors.length === 0;
|
return errors.length === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MembershipData = { kind: "rtc"; data: RtcMembershipData } | { kind: "session"; data: SessionMembershipData };
|
||||||
|
// TODO: Rename to RtcMembership once we removed the legacy SessionMembership from this file.
|
||||||
export class CallMembership {
|
export class CallMembership {
|
||||||
public static equal(a: CallMembership, b: CallMembership): boolean {
|
public static equal(a?: CallMembership, b?: CallMembership): boolean {
|
||||||
return deepCompare(a.membershipData, b.membershipData);
|
return deepCompare(a?.membershipData, b?.membershipData);
|
||||||
}
|
}
|
||||||
private membershipData: SessionMembershipData;
|
|
||||||
|
|
||||||
|
private membershipData: MembershipData;
|
||||||
|
|
||||||
|
/** The parsed data from the Matrix event.
|
||||||
|
* To access checked eventId and sender from the matrixEvent.
|
||||||
|
* Class construction will fail if these values cannot get obtained. */
|
||||||
|
private readonly matrixEventData: { eventId: string; sender: string };
|
||||||
public constructor(
|
public constructor(
|
||||||
private parentEvent: MatrixEvent,
|
/** The Matrix event that this membership is based on */
|
||||||
data: any,
|
private readonly matrixEvent: MatrixEvent,
|
||||||
|
data: IContent,
|
||||||
) {
|
) {
|
||||||
|
const eventId = matrixEvent.getId();
|
||||||
|
const sender = matrixEvent.getSender();
|
||||||
|
|
||||||
|
if (eventId === undefined) throw new Error("parentEvent is missing eventId field");
|
||||||
|
if (sender === undefined) throw new Error("parentEvent is missing sender field");
|
||||||
|
|
||||||
const sessionErrors: string[] = [];
|
const sessionErrors: string[] = [];
|
||||||
if (!checkSessionsMembershipData(data, sessionErrors)) {
|
const rtcErrors: string[] = [];
|
||||||
throw Error(
|
if (checkSessionsMembershipData(data, sessionErrors)) {
|
||||||
`unknown CallMembership data. Does not match MSC4143 call.member (${sessionErrors.join(" & ")}) events this could be a legacy membership event: (${data})`,
|
this.membershipData = { kind: "session", data };
|
||||||
);
|
} else if (checkRtcMembershipData(data, rtcErrors, sender)) {
|
||||||
|
this.membershipData = { kind: "rtc", data };
|
||||||
} else {
|
} else {
|
||||||
this.membershipData = data;
|
const details =
|
||||||
|
sessionErrors.length < rtcErrors.length
|
||||||
|
? `Does not match MSC4143 m.call.member:\n${sessionErrors.join("\n")}\n\n`
|
||||||
|
: `Does not match MSC4143 m.rtc.member:\n${rtcErrors.join("\n")}\n\n`;
|
||||||
|
const json = "\nevent:\n" + JSON.stringify(data).replaceAll('"', "'");
|
||||||
|
throw Error(`unknown CallMembership data.\n` + details + json);
|
||||||
|
}
|
||||||
|
this.matrixEventData = { eventId, sender };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated use userId instead */
|
||||||
|
public get sender(): string {
|
||||||
|
return this.userId;
|
||||||
|
}
|
||||||
|
public get userId(): string {
|
||||||
|
const { kind, data } = this.membershipData;
|
||||||
|
switch (kind) {
|
||||||
|
case "rtc":
|
||||||
|
return data.member.user_id;
|
||||||
|
case "session":
|
||||||
|
default:
|
||||||
|
return this.matrixEventData.sender;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public get sender(): string | undefined {
|
public get eventId(): string {
|
||||||
return this.parentEvent.getSender();
|
return this.matrixEventData.eventId;
|
||||||
}
|
|
||||||
|
|
||||||
public get eventId(): string | undefined {
|
|
||||||
return this.parentEvent.getId();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated Use sessionDescription.id instead.
|
* The ID of the MatrixRTC slot that this membership belongs to (format `{application}#{id}`).
|
||||||
|
* This is computed in case SessionMembershipData is used.
|
||||||
*/
|
*/
|
||||||
public get callId(): string {
|
public get slotId(): string {
|
||||||
return this.membershipData.call_id;
|
const { kind, data } = this.membershipData;
|
||||||
|
switch (kind) {
|
||||||
|
case "rtc":
|
||||||
|
return data.slot_id;
|
||||||
|
case "session":
|
||||||
|
default:
|
||||||
|
return slotDescriptionToId({ application: this.application, id: data.call_id });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public get deviceId(): string {
|
public get deviceId(): string {
|
||||||
return this.membershipData.device_id;
|
const { kind, data } = this.membershipData;
|
||||||
|
switch (kind) {
|
||||||
|
case "rtc":
|
||||||
|
return data.member.device_id;
|
||||||
|
case "session":
|
||||||
|
default:
|
||||||
|
return data.device_id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public get callIntent(): RTCCallIntent | undefined {
|
public get callIntent(): RTCCallIntent | undefined {
|
||||||
return this.membershipData["m.call.intent"];
|
const { kind, data } = this.membershipData;
|
||||||
|
switch (kind) {
|
||||||
|
case "rtc": {
|
||||||
|
const intent = data.application["m.call.intent"];
|
||||||
|
if (typeof intent === "string") {
|
||||||
|
return intent;
|
||||||
|
}
|
||||||
|
logger.warn("RTC membership has invalid m.call.intent");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
case "session":
|
||||||
|
default:
|
||||||
|
return data["m.call.intent"];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public get sessionDescription(): SessionDescription {
|
/**
|
||||||
return {
|
* Parsed `slot_id` (format `{application}#{id}`) into its components (application and id).
|
||||||
application: this.membershipData.application,
|
*/
|
||||||
id: this.membershipData.call_id,
|
public get slotDescription(): SlotDescription {
|
||||||
};
|
return slotIdToDescription(this.slotId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get application(): string | undefined {
|
public get application(): string {
|
||||||
return this.membershipData.application;
|
const { kind, data } = this.membershipData;
|
||||||
|
switch (kind) {
|
||||||
|
case "rtc":
|
||||||
|
return data.application.type;
|
||||||
|
case "session":
|
||||||
|
default:
|
||||||
|
return data.application;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public get applicationData(): { type: string; [key: string]: unknown } {
|
||||||
|
const { kind, data } = this.membershipData;
|
||||||
|
switch (kind) {
|
||||||
|
case "rtc":
|
||||||
|
return data.application;
|
||||||
|
case "session":
|
||||||
|
default:
|
||||||
|
return { "type": data.application, "m.call.intent": data["m.call.intent"] };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated scope is not used and will be removed in future versions. replaced by application specific types.*/
|
||||||
public get scope(): CallScope | undefined {
|
public get scope(): CallScope | undefined {
|
||||||
return this.membershipData.scope;
|
const { kind, data } = this.membershipData;
|
||||||
|
switch (kind) {
|
||||||
|
case "rtc":
|
||||||
|
return undefined;
|
||||||
|
case "session":
|
||||||
|
default:
|
||||||
|
return data.scope;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public get membershipID(): string {
|
public get membershipID(): string {
|
||||||
// the createdTs behaves equivalent to the membershipID.
|
// the createdTs behaves equivalent to the membershipID.
|
||||||
// we only need the field for the legacy member envents where we needed to update them
|
// we only need the field for the legacy member events where we needed to update them
|
||||||
// synapse ignores sending state events if they have the same content.
|
// synapse ignores sending state events if they have the same content.
|
||||||
return this.createdTs().toString();
|
const { kind, data } = this.membershipData;
|
||||||
|
switch (kind) {
|
||||||
|
case "rtc":
|
||||||
|
return data.member.id;
|
||||||
|
case "session":
|
||||||
|
default:
|
||||||
|
return (this.createdTs() ?? "").toString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public createdTs(): number {
|
public createdTs(): number {
|
||||||
return this.membershipData.created_ts ?? this.parentEvent.getTs();
|
const { kind, data } = this.membershipData;
|
||||||
|
switch (kind) {
|
||||||
|
case "rtc":
|
||||||
|
// TODO we need to read the referenced (relation) event if available to get the real created_ts
|
||||||
|
return this.matrixEvent.getTs();
|
||||||
|
case "session":
|
||||||
|
default:
|
||||||
|
return data.created_ts ?? this.matrixEvent.getTs();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the absolute expiry timestamp of the membership.
|
* Gets the absolute expiry timestamp of the membership.
|
||||||
* @returns The absolute expiry time of the membership as a unix timestamp in milliseconds or undefined if not applicable
|
* @returns The absolute expiry time of the membership as a unix timestamp in milliseconds or undefined if not applicable
|
||||||
*/
|
*/
|
||||||
public getAbsoluteExpiry(): number {
|
public getAbsoluteExpiry(): number | undefined {
|
||||||
// TODO: calculate this from the MatrixRTCSession join configuration directly
|
const { kind, data } = this.membershipData;
|
||||||
return this.createdTs() + (this.membershipData.expires ?? DEFAULT_EXPIRE_DURATION);
|
switch (kind) {
|
||||||
|
case "rtc":
|
||||||
|
return undefined;
|
||||||
|
case "session":
|
||||||
|
default:
|
||||||
|
// TODO: calculate this from the MatrixRTCSession join configuration directly
|
||||||
|
return this.createdTs() + (data.expires ?? DEFAULT_EXPIRE_DURATION);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns The number of milliseconds until the membership expires or undefined if applicable
|
* @returns The number of milliseconds until the membership expires or undefined if applicable
|
||||||
*/
|
*/
|
||||||
public getMsUntilExpiry(): number {
|
public getMsUntilExpiry(): number | undefined {
|
||||||
// Assume that local clock is sufficiently in sync with other clocks in the distributed system.
|
const { kind } = this.membershipData;
|
||||||
// We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate.
|
switch (kind) {
|
||||||
// The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2
|
case "rtc":
|
||||||
return this.getAbsoluteExpiry() - Date.now();
|
return undefined;
|
||||||
|
case "session":
|
||||||
|
default:
|
||||||
|
// Assume that local clock is sufficiently in sync with other clocks in the distributed system.
|
||||||
|
// We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate.
|
||||||
|
// The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2
|
||||||
|
return this.getAbsoluteExpiry()! - Date.now();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns true if the membership has expired, otherwise false
|
* @returns true if the membership has expired, otherwise false
|
||||||
*/
|
*/
|
||||||
public isExpired(): boolean {
|
public isExpired(): boolean {
|
||||||
return this.getMsUntilExpiry() <= 0;
|
const { kind } = this.membershipData;
|
||||||
|
switch (kind) {
|
||||||
|
case "rtc":
|
||||||
|
return false;
|
||||||
|
case "session":
|
||||||
|
default:
|
||||||
|
return this.getMsUntilExpiry()! <= 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public getPreferredFoci(): Focus[] {
|
/**
|
||||||
return this.membershipData.foci_preferred;
|
* ## RTC Membership
|
||||||
|
* Gets the primary transport to use for this RTC membership (m.rtc.member).
|
||||||
|
* This will return the primary transport that is used by this call membership to publish their media.
|
||||||
|
* Directly relates to the `rtc_transports` field.
|
||||||
|
*
|
||||||
|
* ## Legacy session membership
|
||||||
|
* In case of a legacy session membership (m.call.member) this will return the selected transport where
|
||||||
|
* media is published. How this selection happens depends on the `focus_active` field of the session membership.
|
||||||
|
* If the `focus_selection` is `oldest_membership` this will return the transport of the oldest membership
|
||||||
|
* in the room (based on the `created_ts` field of the session membership).
|
||||||
|
* If the `focus_selection` is `multi_sfu` it will return the first transport of the `foci_preferred` list.
|
||||||
|
* (`multi_sfu` is equivalent to how `m.rtc.member` `rtc_transports` work).
|
||||||
|
* @param oldestMembership For backwards compatibility with session membership (legacy). Unused in case of RTC membership.
|
||||||
|
* Always required to make the consumer not care if it deals with RTC or session memberships.
|
||||||
|
* @returns The transport this membership uses to publish media or undefined if no transport is available.
|
||||||
|
*/
|
||||||
|
public getTransport(oldestMembership: CallMembership): Transport | undefined {
|
||||||
|
const { kind, data } = this.membershipData;
|
||||||
|
switch (kind) {
|
||||||
|
case "rtc":
|
||||||
|
return data.rtc_transports[0];
|
||||||
|
case "session":
|
||||||
|
switch (data.focus_active.focus_selection) {
|
||||||
|
case "multi_sfu":
|
||||||
|
return data.foci_preferred[0];
|
||||||
|
case "oldest_membership":
|
||||||
|
if (CallMembership.equal(this, oldestMembership)) return data.foci_preferred[0];
|
||||||
|
if (oldestMembership !== undefined) return oldestMembership.getTransport(oldestMembership);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getFocusSelection(): string | undefined {
|
/**
|
||||||
const focusActive = this.membershipData.focus_active;
|
* The focus_active filed of the session membership (m.call.member).
|
||||||
if (isLivekitFocusActive(focusActive)) {
|
* @deprecated focus_active is not used and will be removed in future versions.
|
||||||
return focusActive.focus_selection;
|
*/
|
||||||
|
public getFocusActive(): LivekitFocusSelection | undefined {
|
||||||
|
const { kind, data } = this.membershipData;
|
||||||
|
if (kind === "session") return data.focus_active;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* The value of the `rtc_transports` field for RTC memberships (m.rtc.member).
|
||||||
|
* Or the value of the `foci_preferred` field for legacy session memberships (m.call.member).
|
||||||
|
*/
|
||||||
|
public get transports(): Transport[] {
|
||||||
|
const { kind, data } = this.membershipData;
|
||||||
|
switch (kind) {
|
||||||
|
case "rtc":
|
||||||
|
return data.rtc_transports;
|
||||||
|
case "session":
|
||||||
|
default:
|
||||||
|
return data.foci_preferred;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CallMembership } from "./CallMembership.ts";
|
import type { CallMembership } from "./CallMembership.ts";
|
||||||
import type { Focus } from "./focus.ts";
|
import type { RTCCallIntent, Status, Transport } from "./types.ts";
|
||||||
import type { RTCCallIntent, Status } from "./types.ts";
|
|
||||||
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||||
|
|
||||||
export enum MembershipManagerEvent {
|
export enum MembershipManagerEvent {
|
||||||
@@ -80,10 +79,13 @@ export interface IMembershipManager
|
|||||||
/**
|
/**
|
||||||
* Start sending all necessary events to make this user participate in the RTC session.
|
* Start sending all necessary events to make this user participate in the RTC session.
|
||||||
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
|
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
|
||||||
* @param fociActive the active focus to use in the joined RTC membership event.
|
* If multiSfuFocus is set, this is only needed if this client wants to publish to multiple transports simultaneously.
|
||||||
|
* @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the
|
||||||
|
* membership manager will operate in a multi-SFU connection mode. If `undefined`, an `oldest_membership`
|
||||||
|
* transport selection will be used instead.
|
||||||
* @throws can throw if it exceeds a configured maximum retry.
|
* @throws can throw if it exceeds a configured maximum retry.
|
||||||
*/
|
*/
|
||||||
join(fociPreferred: Focus[], fociActive?: Focus, onError?: (error: unknown) => void): void;
|
join(fociPreferred: Transport[], multiSfuFocus?: Transport, onError?: (error: unknown) => void): void;
|
||||||
/**
|
/**
|
||||||
* Send all necessary events to make this user leave the RTC session.
|
* Send all necessary events to make this user leave the RTC session.
|
||||||
* @param timeout the maximum duration in ms until the promise is forced to resolve.
|
* @param timeout the maximum duration in ms until the promise is forced to resolve.
|
||||||
@@ -95,11 +97,6 @@ export interface IMembershipManager
|
|||||||
* Call this if the MatrixRTC session members have changed.
|
* Call this if the MatrixRTC session members have changed.
|
||||||
*/
|
*/
|
||||||
onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void>;
|
onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void>;
|
||||||
/**
|
|
||||||
* The used active focus in the currently joined session.
|
|
||||||
* @returns the used active focus in the currently joined session or undefined if not joined.
|
|
||||||
*/
|
|
||||||
getActiveFocus(): Focus | undefined;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the intent of a membership on the call (e.g. user is now providing a video feed)
|
* Update the intent of a membership on the call (e.g. user is now providing a video feed)
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2023 New Vector Ltd
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { type Focus } from "./focus.ts";
|
|
||||||
|
|
||||||
export interface LivekitFocusConfig extends Focus {
|
|
||||||
type: "livekit";
|
|
||||||
livekit_service_url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isLivekitFocusConfig = (object: any): object is LivekitFocusConfig =>
|
|
||||||
object.type === "livekit" && "livekit_service_url" in object;
|
|
||||||
|
|
||||||
export interface LivekitFocus extends LivekitFocusConfig {
|
|
||||||
livekit_alias: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isLivekitFocus = (object: any): object is LivekitFocus =>
|
|
||||||
isLivekitFocusConfig(object) && "livekit_alias" in object;
|
|
||||||
|
|
||||||
export interface LivekitFocusActive extends Focus {
|
|
||||||
type: "livekit";
|
|
||||||
focus_selection: "oldest_membership";
|
|
||||||
}
|
|
||||||
export const isLivekitFocusActive = (object: any): object is LivekitFocusActive =>
|
|
||||||
object.type === "livekit" && "focus_selection" in object;
|
|
||||||
46
src/matrixrtc/LivekitTransport.ts
Normal file
46
src/matrixrtc/LivekitTransport.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type Transport } from "./types.ts";
|
||||||
|
|
||||||
|
export interface LivekitTransportConfig extends Transport {
|
||||||
|
type: "livekit";
|
||||||
|
livekit_service_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isLivekitTransportConfig = (object: any): object is LivekitTransportConfig =>
|
||||||
|
object.type === "livekit" && "livekit_service_url" in object;
|
||||||
|
|
||||||
|
export interface LivekitTransport extends LivekitTransportConfig {
|
||||||
|
livekit_alias: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isLivekitTransport = (object: any): object is LivekitTransport =>
|
||||||
|
isLivekitTransportConfig(object) && "livekit_alias" in object;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated, this is just needed for the old focus active / focus fields of a call membership.
|
||||||
|
* Not needed for new implementations.
|
||||||
|
*/
|
||||||
|
export interface LivekitFocusSelection extends Transport {
|
||||||
|
type: "livekit";
|
||||||
|
focus_selection: "oldest_membership" | "multi_sfu";
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @deprecated see LivekitFocusSelection
|
||||||
|
*/
|
||||||
|
export const isLivekitFocusSelection = (object: any): object is LivekitFocusSelection =>
|
||||||
|
object.type === "livekit" && "focus_selection" in object;
|
||||||
@@ -24,17 +24,17 @@ import { KnownMembership } from "../@types/membership.ts";
|
|||||||
import { type ISendEventResponse } from "../@types/requests.ts";
|
import { type ISendEventResponse } from "../@types/requests.ts";
|
||||||
import { CallMembership } from "./CallMembership.ts";
|
import { CallMembership } from "./CallMembership.ts";
|
||||||
import { RoomStateEvent } from "../models/room-state.ts";
|
import { RoomStateEvent } from "../models/room-state.ts";
|
||||||
import { type Focus } from "./focus.ts";
|
|
||||||
import { MembershipManager } from "./MembershipManager.ts";
|
import { MembershipManager } from "./MembershipManager.ts";
|
||||||
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
|
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
|
||||||
import { deepCompare, logDurationSync } from "../utils.ts";
|
import { deepCompare, logDurationSync } from "../utils.ts";
|
||||||
import {
|
import type {
|
||||||
type Statistics,
|
Statistics,
|
||||||
type RTCNotificationType,
|
RTCNotificationType,
|
||||||
type Status,
|
Status,
|
||||||
type IRTCNotificationContent,
|
IRTCNotificationContent,
|
||||||
type ICallNotifyContent,
|
ICallNotifyContent,
|
||||||
type RTCCallIntent,
|
RTCCallIntent,
|
||||||
|
Transport,
|
||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
|
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
|
||||||
import {
|
import {
|
||||||
@@ -103,10 +103,17 @@ export interface SessionConfig {
|
|||||||
/**
|
/**
|
||||||
* The session description is used to identify a session. Used in the state event.
|
* The session description is used to identify a session. Used in the state event.
|
||||||
*/
|
*/
|
||||||
export interface SessionDescription {
|
export interface SlotDescription {
|
||||||
id: string;
|
id: string;
|
||||||
application: string;
|
application: string;
|
||||||
}
|
}
|
||||||
|
export function slotIdToDescription(slotId: string): SlotDescription {
|
||||||
|
const [application, id] = slotId.split("#");
|
||||||
|
return { application, id };
|
||||||
|
}
|
||||||
|
export function slotDescriptionToId(slotDescription: SlotDescription): string {
|
||||||
|
return `${slotDescription.application}#${slotDescription.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
// The names follow these principles:
|
// The names follow these principles:
|
||||||
// - we use the technical term delay if the option is related to delayed events.
|
// - we use the technical term delay if the option is related to delayed events.
|
||||||
@@ -185,6 +192,7 @@ export interface MembershipConfig {
|
|||||||
* but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
|
* but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
|
||||||
*/
|
*/
|
||||||
delayedLeaveEventRestartLocalTimeoutMs?: number;
|
delayedLeaveEventRestartLocalTimeoutMs?: number;
|
||||||
|
useRtcMemberFormat?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EncryptionConfig {
|
export interface EncryptionConfig {
|
||||||
@@ -240,8 +248,6 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
> {
|
> {
|
||||||
private membershipManager?: IMembershipManager;
|
private membershipManager?: IMembershipManager;
|
||||||
private encryptionManager?: IEncryptionManager;
|
private encryptionManager?: IEncryptionManager;
|
||||||
// The session Id of the call, this is the call_id of the call Member event.
|
|
||||||
private _callId: string | undefined;
|
|
||||||
private joinConfig?: SessionConfig;
|
private joinConfig?: SessionConfig;
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
@@ -279,33 +285,53 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
*
|
*
|
||||||
* It can be undefined since the callId is only known once the first membership joins.
|
* It can be undefined since the callId is only known once the first membership joins.
|
||||||
* The callId is the property that, per definition, groups memberships into one call.
|
* The callId is the property that, per definition, groups memberships into one call.
|
||||||
|
* @deprecated use `slotId` instead.
|
||||||
*/
|
*/
|
||||||
public get callId(): string | undefined {
|
public get callId(): string | undefined {
|
||||||
return this._callId;
|
return this.slotDescription?.id;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* The slotId of the call.
|
||||||
|
* `{application}#{appSpecificId}`
|
||||||
|
* It can be undefined since the slotId is only known once the first membership joins.
|
||||||
|
* The slotId is the property that, per definition, groups memberships into one call.
|
||||||
|
*/
|
||||||
|
public get slotId(): string | undefined {
|
||||||
|
return slotDescriptionToId(this.slotDescription);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all the call memberships for a room that match the provided `sessionDescription`,
|
* Returns all the call memberships for a room that match the provided `sessionDescription`,
|
||||||
* oldest first.
|
* oldest first.
|
||||||
*
|
*
|
||||||
* @deprecated Use `MatrixRTCSession.sessionMembershipsForRoom` instead.
|
* @deprecated Use `MatrixRTCSession.sessionMembershipsForSlot` instead.
|
||||||
*/
|
*/
|
||||||
public static callMembershipsForRoom(
|
public static callMembershipsForRoom(
|
||||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
|
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
|
||||||
): CallMembership[] {
|
): CallMembership[] {
|
||||||
return MatrixRTCSession.sessionMembershipsForRoom(room, {
|
return MatrixRTCSession.sessionMembershipsForSlot(room, {
|
||||||
id: "",
|
id: "",
|
||||||
application: "m.call",
|
application: "m.call",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all the call memberships for a room that match the provided `sessionDescription`,
|
* @deprecated use `MatrixRTCSession.slotMembershipsForRoom` instead.
|
||||||
* oldest first.
|
|
||||||
*/
|
*/
|
||||||
public static sessionMembershipsForRoom(
|
public static sessionMembershipsForRoom(
|
||||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
|
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
|
||||||
sessionDescription: SessionDescription,
|
sessionDescription: SlotDescription,
|
||||||
|
): CallMembership[] {
|
||||||
|
return this.sessionMembershipsForSlot(room, sessionDescription);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all the call memberships for a room that match the provided `sessionDescription`,
|
||||||
|
* oldest first.
|
||||||
|
*/
|
||||||
|
public static sessionMembershipsForSlot(
|
||||||
|
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
|
||||||
|
slotDescription: SlotDescription,
|
||||||
): CallMembership[] {
|
): CallMembership[] {
|
||||||
const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`);
|
const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`);
|
||||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||||
@@ -335,12 +361,16 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
if (membershipContents.length === 0) continue;
|
if (membershipContents.length === 0) continue;
|
||||||
|
|
||||||
for (const membershipData of membershipContents) {
|
for (const membershipData of membershipContents) {
|
||||||
|
if (!("application" in membershipData)) {
|
||||||
|
// This is a left membership event, ignore it here to not log warnings.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const membership = new CallMembership(memberEvent, membershipData);
|
const membership = new CallMembership(memberEvent, membershipData);
|
||||||
|
|
||||||
if (!deepCompare(membership.sessionDescription, sessionDescription)) {
|
if (!deepCompare(membership.slotDescription, slotDescription)) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Ignoring membership of user ${membership.sender} for a different session: ${JSON.stringify(membership.sessionDescription)}`,
|
`Ignoring membership of user ${membership.sender} for a different slot: ${JSON.stringify(membership.slotDescription)}`,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -379,26 +409,29 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
* This method is an alias for `MatrixRTCSession.sessionForRoom` with
|
* This method is an alias for `MatrixRTCSession.sessionForRoom` with
|
||||||
* sessionDescription `{ id: "", application: "m.call" }`.
|
* sessionDescription `{ id: "", application: "m.call" }`.
|
||||||
*
|
*
|
||||||
* @deprecated Use `MatrixRTCSession.sessionForRoom` with sessionDescription `{ id: "", application: "m.call" }` instead.
|
* @deprecated Use `MatrixRTCSession.sessionForSlot` with sessionDescription `{ id: "", application: "m.call" }` instead.
|
||||||
*/
|
*/
|
||||||
public static roomSessionForRoom(client: MatrixClient, room: Room): MatrixRTCSession {
|
public static roomSessionForRoom(client: MatrixClient, room: Room): MatrixRTCSession {
|
||||||
const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, { id: "", application: "m.call" });
|
const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(room, { id: "", application: "m.call" });
|
||||||
return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" });
|
return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use `MatrixRTCSession.sessionForSlot` instead.
|
||||||
|
*/
|
||||||
|
public static sessionForRoom(client: MatrixClient, room: Room, slotDescription: SlotDescription): MatrixRTCSession {
|
||||||
|
return this.sessionForSlot(client, room, slotDescription);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the MatrixRTC session for the room.
|
* Return the MatrixRTC session for the room.
|
||||||
* This returned session can be used to find out if there are active sessions
|
* This returned session can be used to find out if there are active sessions
|
||||||
* for the requested room and `sessionDescription`.
|
* for the requested room and `slotDescription`.
|
||||||
*/
|
*/
|
||||||
public static sessionForRoom(
|
public static sessionForSlot(client: MatrixClient, room: Room, slotDescription: SlotDescription): MatrixRTCSession {
|
||||||
client: MatrixClient,
|
const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(room, slotDescription);
|
||||||
room: Room,
|
|
||||||
sessionDescription: SessionDescription,
|
|
||||||
): MatrixRTCSession {
|
|
||||||
const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, sessionDescription);
|
|
||||||
|
|
||||||
return new MatrixRTCSession(client, room, callMemberships, sessionDescription);
|
return new MatrixRTCSession(client, room, callMemberships, slotDescription);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -444,14 +477,14 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
>,
|
>,
|
||||||
public memberships: CallMembership[],
|
public memberships: CallMembership[],
|
||||||
/**
|
/**
|
||||||
* The session description is used to define the exact session this object is tracking.
|
* The slot description is a virtual address where participants are allowed to meet.
|
||||||
* A session is distinct from another session if one of those properties differ: `roomSubset.roomId`, `sessionDescription.application`, `sessionDescription.id`.
|
* This session will only manage memberships that match this slot description.
|
||||||
|
* Sessions are distinct if any of those properties are distinct: `roomSubset.roomId`, `slotDescription.application`, `slotDescription.id`.
|
||||||
*/
|
*/
|
||||||
public readonly sessionDescription: SessionDescription,
|
public readonly slotDescription: SlotDescription,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`);
|
this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`);
|
||||||
this._callId = memberships[0]?.sessionDescription.id;
|
|
||||||
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||||
// TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager
|
// TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager
|
||||||
roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||||
@@ -490,14 +523,18 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
* This will not subscribe to updates: remember to call subscribe() separately if
|
* This will not subscribe to updates: remember to call subscribe() separately if
|
||||||
* desired.
|
* desired.
|
||||||
* This method will return immediately and the session will be joined in the background.
|
* This method will return immediately and the session will be joined in the background.
|
||||||
*
|
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
|
||||||
* @param fociActive - The object representing the active focus. (This depends on the focus type.)
|
* If multiSfuFocus is set, this is only needed if this client wants to publish to multiple transports simultaneously.
|
||||||
* @param fociPreferred - The list of preferred foci this member proposes to use/knows/has access to.
|
* @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the
|
||||||
* For the livekit case this is a list of foci generated from the homeserver well-known, the current rtc session,
|
* membership manager will operate in a multi-SFU connection mode. If `undefined`, an `oldest_membership`
|
||||||
* or optionally other room members homeserver well known.
|
* transport selection will be used instead.
|
||||||
* @param joinConfig - Additional configuration for the joined session.
|
* @param joinConfig - Additional configuration for the joined session.
|
||||||
*/
|
*/
|
||||||
public joinRoomSession(fociPreferred: Focus[], fociActive?: Focus, joinConfig?: JoinSessionConfig): void {
|
public joinRoomSession(
|
||||||
|
fociPreferred: Transport[],
|
||||||
|
multiSfuFocus?: Transport,
|
||||||
|
joinConfig?: JoinSessionConfig,
|
||||||
|
): void {
|
||||||
if (this.isJoined()) {
|
if (this.isJoined()) {
|
||||||
this.logger.info(`Already joined to session in room ${this.roomSubset.roomId}: ignoring join call`);
|
this.logger.info(`Already joined to session in room ${this.roomSubset.roomId}: ignoring join call`);
|
||||||
return;
|
return;
|
||||||
@@ -508,8 +545,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
joinConfig,
|
joinConfig,
|
||||||
this.roomSubset,
|
this.roomSubset,
|
||||||
this.client,
|
this.client,
|
||||||
() => this.getOldestMembership(),
|
this.slotDescription,
|
||||||
this.sessionDescription,
|
|
||||||
this.logger,
|
this.logger,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -571,7 +607,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
this.pendingNotificationToSend = this.joinConfig?.notificationType;
|
this.pendingNotificationToSend = this.joinConfig?.notificationType;
|
||||||
|
|
||||||
// Join!
|
// Join!
|
||||||
this.membershipManager!.join(fociPreferred, fociActive, (e) => {
|
this.membershipManager!.join(fociPreferred, multiSfuFocus, (e) => {
|
||||||
this.logger.error("MembershipManager encountered an unrecoverable error: ", e);
|
this.logger.error("MembershipManager encountered an unrecoverable error: ", e);
|
||||||
this.emit(MatrixRTCSessionEvent.MembershipManagerError, e);
|
this.emit(MatrixRTCSessionEvent.MembershipManagerError, e);
|
||||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, this.isJoined());
|
this.emit(MatrixRTCSessionEvent.JoinStateChanged, this.isJoined());
|
||||||
@@ -606,16 +642,23 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
|
|
||||||
return await leavePromise;
|
return await leavePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the active focus from the current CallMemberState event
|
* This returns the focus in use by the oldest membership.
|
||||||
* @returns The focus that is currently in use to connect to this session. This is undefined
|
* Do not use since this might be just the focus for the oldest membership. others might use a different focus.
|
||||||
* if the client is not connected to this session.
|
* @deprecated use `member.getTransport(session.getOldestMembership())` instead for the specific member you want to get the focus for.
|
||||||
*/
|
*/
|
||||||
public getActiveFocus(): Focus | undefined {
|
public getFocusInUse(): Transport | undefined {
|
||||||
return this.membershipManager?.getActiveFocus();
|
const oldestMembership = this.getOldestMembership();
|
||||||
|
return oldestMembership?.getTransport(oldestMembership);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The used focusActive of the oldest membership (to find out the selection type multi-sfu or oldest membership active focus)
|
||||||
|
* @deprecated does not work with m.rtc.member. Do not rely on it.
|
||||||
|
*/
|
||||||
|
public getActiveFocus(): Transport | undefined {
|
||||||
|
return this.getOldestMembership()?.getFocusActive();
|
||||||
|
}
|
||||||
public getOldestMembership(): CallMembership | undefined {
|
public getOldestMembership(): CallMembership | undefined {
|
||||||
return this.memberships[0];
|
return this.memberships[0];
|
||||||
}
|
}
|
||||||
@@ -646,20 +689,6 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
await this.membershipManager?.updateCallIntent(callIntent);
|
await this.membershipManager?.updateCallIntent(callIntent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* This method is used when the user is not yet connected to the Session but wants to know what focus
|
|
||||||
* the users in the session are using to make a decision how it wants/should connect.
|
|
||||||
*
|
|
||||||
* See also `getActiveFocus`
|
|
||||||
* @returns The focus which should be used when joining this session.
|
|
||||||
*/
|
|
||||||
public getFocusInUse(): Focus | undefined {
|
|
||||||
const oldestMembership = this.getOldestMembership();
|
|
||||||
if (oldestMembership?.getFocusSelection() === "oldest_membership") {
|
|
||||||
return oldestMembership.getPreferredFoci()[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-emit an EncryptionKeyChanged event for each tracked encryption key. This can be used to export
|
* Re-emit an EncryptionKeyChanged event for each tracked encryption key. This can be used to export
|
||||||
* the keys.
|
* the keys.
|
||||||
@@ -777,9 +806,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
|||||||
*/
|
*/
|
||||||
private recalculateSessionMembers = (): void => {
|
private recalculateSessionMembers = (): void => {
|
||||||
const oldMemberships = this.memberships;
|
const oldMemberships = this.memberships;
|
||||||
this.memberships = MatrixRTCSession.sessionMembershipsForRoom(this.room, this.sessionDescription);
|
this.memberships = MatrixRTCSession.sessionMembershipsForSlot(this.room, this.slotDescription);
|
||||||
|
|
||||||
this._callId = this._callId ?? this.memberships[0]?.sessionDescription.id;
|
|
||||||
|
|
||||||
const changed =
|
const changed =
|
||||||
oldMemberships.length != this.memberships.length ||
|
oldMemberships.length != this.memberships.length ||
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
|||||||
import { type Room } from "../models/room.ts";
|
import { type Room } from "../models/room.ts";
|
||||||
import { type RoomState, RoomStateEvent } from "../models/room-state.ts";
|
import { type RoomState, RoomStateEvent } from "../models/room-state.ts";
|
||||||
import { type MatrixEvent } from "../models/event.ts";
|
import { type MatrixEvent } from "../models/event.ts";
|
||||||
import { MatrixRTCSession, type SessionDescription } from "./MatrixRTCSession.ts";
|
import { MatrixRTCSession, type SlotDescription } from "./MatrixRTCSession.ts";
|
||||||
import { EventType } from "../@types/event.ts";
|
import { EventType } from "../@types/event.ts";
|
||||||
|
|
||||||
export enum MatrixRTCSessionManagerEvents {
|
export enum MatrixRTCSessionManagerEvents {
|
||||||
@@ -56,7 +56,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
|||||||
public constructor(
|
public constructor(
|
||||||
rootLogger: Logger,
|
rootLogger: Logger,
|
||||||
private client: MatrixClient,
|
private client: MatrixClient,
|
||||||
private readonly sessionDescription: SessionDescription = { id: "", application: "m.call" }, // Default to the Matrix Call application
|
private readonly slotDescription: SlotDescription = { application: "m.call", id: "" }, // Default to the Matrix Call application
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
|
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
|
||||||
@@ -66,7 +66,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
|||||||
// We shouldn't need to null-check here, but matrix-client.spec.ts mocks getRooms
|
// We shouldn't need to null-check here, but matrix-client.spec.ts mocks getRooms
|
||||||
// returning nothing, and breaks tests if you change it to return an empty array :'(
|
// returning nothing, and breaks tests if you change it to return an empty array :'(
|
||||||
for (const room of this.client.getRooms() ?? []) {
|
for (const room of this.client.getRooms() ?? []) {
|
||||||
const session = MatrixRTCSession.sessionForRoom(this.client, room, this.sessionDescription);
|
const session = MatrixRTCSession.sessionForRoom(this.client, room, this.slotDescription);
|
||||||
if (session.memberships.length > 0) {
|
if (session.memberships.length > 0) {
|
||||||
this.roomSessions.set(room.roomId, session);
|
this.roomSessions.set(room.roomId, session);
|
||||||
}
|
}
|
||||||
@@ -102,7 +102,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
|||||||
if (!this.roomSessions.has(room.roomId)) {
|
if (!this.roomSessions.has(room.roomId)) {
|
||||||
this.roomSessions.set(
|
this.roomSessions.set(
|
||||||
room.roomId,
|
room.roomId,
|
||||||
MatrixRTCSession.sessionForRoom(this.client, room, this.sessionDescription),
|
MatrixRTCSession.sessionForRoom(this.client, room, this.slotDescription),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,20 +15,28 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
import { AbortError } from "p-retry";
|
import { AbortError } from "p-retry";
|
||||||
|
|
||||||
import { EventType } from "../@types/event.ts";
|
import { EventType, RelationType } from "../@types/event.ts";
|
||||||
import { UpdateDelayedEventAction } from "../@types/requests.ts";
|
import { UpdateDelayedEventAction } from "../@types/requests.ts";
|
||||||
import { type MatrixClient } from "../client.ts";
|
import type { MatrixClient } from "../client.ts";
|
||||||
import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
|
|
||||||
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
|
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
|
||||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||||
import { type Room } from "../models/room.ts";
|
import { type Room } from "../models/room.ts";
|
||||||
import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts";
|
import {
|
||||||
import { type Focus } from "./focus.ts";
|
type CallMembership,
|
||||||
import { isMyMembership, type RTCCallIntent, Status } from "./types.ts";
|
DEFAULT_EXPIRE_DURATION,
|
||||||
import { isLivekitFocusActive } from "./LivekitFocus.ts";
|
type RtcMembershipData,
|
||||||
import { type SessionDescription, type MembershipConfig, type SessionConfig } from "./MatrixRTCSession.ts";
|
type SessionMembershipData,
|
||||||
|
} from "./CallMembership.ts";
|
||||||
|
import { type Transport, isMyMembership, type RTCCallIntent, Status } from "./types.ts";
|
||||||
|
import {
|
||||||
|
type SlotDescription,
|
||||||
|
type MembershipConfig,
|
||||||
|
type SessionConfig,
|
||||||
|
slotDescriptionToId,
|
||||||
|
} from "./MatrixRTCSession.ts";
|
||||||
import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts";
|
import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts";
|
||||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||||
|
import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
|
||||||
import {
|
import {
|
||||||
MembershipManagerEvent,
|
MembershipManagerEvent,
|
||||||
type IMembershipManager,
|
type IMembershipManager,
|
||||||
@@ -36,12 +44,11 @@ import {
|
|||||||
} from "./IMembershipManager.ts";
|
} from "./IMembershipManager.ts";
|
||||||
|
|
||||||
/* MembershipActionTypes:
|
/* MembershipActionTypes:
|
||||||
|
|
||||||
On Join: ───────────────┐ ┌───────────────(1)───────────┐
|
On Join: ───────────────┐ ┌───────────────(1)───────────┐
|
||||||
▼ ▼ │
|
▼ ▼ │
|
||||||
┌────────────────┐ │
|
┌────────────────┐ │
|
||||||
│SendDelayedEvent│ ──────(2)───┐ │
|
│SendDelayedEvent│ ──────(2)───┐ │
|
||||||
└────────────────┘ │ │
|
└────────────────┘ │ │
|
||||||
│(3) │ │
|
│(3) │ │
|
||||||
▼ │ │
|
▼ │ │
|
||||||
┌─────────────┐ │ │
|
┌─────────────┐ │ │
|
||||||
@@ -52,9 +59,9 @@ On Join: ───────────────┐ ┌─────
|
|||||||
┌────────────┐ │ │ ┌───────────────────┐ │
|
┌────────────┐ │ │ ┌───────────────────┐ │
|
||||||
│UpdateExpiry│ (s) (s)|RestartDelayedEvent│ │
|
│UpdateExpiry│ (s) (s)|RestartDelayedEvent│ │
|
||||||
└────────────┘ │ │ └───────────────────┘ │
|
└────────────┘ │ │ └───────────────────┘ │
|
||||||
│ │ │ │ │ │
|
│ │ │ │ │ │
|
||||||
└─────┘ └──────┘ └───────┘
|
└─────┘ └──────┘ └───────┘
|
||||||
|
|
||||||
On Leave: ───────── STOP ALL ABOVE
|
On Leave: ───────── STOP ALL ABOVE
|
||||||
▼
|
▼
|
||||||
┌────────────────────────────────┐
|
┌────────────────────────────────┐
|
||||||
@@ -169,18 +176,21 @@ export class MembershipManager
|
|||||||
/**
|
/**
|
||||||
* Puts the MembershipManager in a state where it tries to be joined.
|
* Puts the MembershipManager in a state where it tries to be joined.
|
||||||
* It will send delayed events and membership events
|
* It will send delayed events and membership events
|
||||||
* @param fociPreferred
|
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
|
||||||
* @param focusActive
|
* If multiSfuFocus is set, this is only needed if this client wants to publish to multiple transports simultaneously.
|
||||||
|
* @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the
|
||||||
|
* membership manager will operate in a multi-SFU connection mode. If `undefined`, an `oldest_membership`
|
||||||
|
* transport selection will be used instead.
|
||||||
* @param onError This will be called once the membership manager encounters an unrecoverable error.
|
* @param onError This will be called once the membership manager encounters an unrecoverable error.
|
||||||
* This should bubble up the the frontend to communicate that the call does not work in the current environment.
|
* This should bubble up the the frontend to communicate that the call does not work in the current environment.
|
||||||
*/
|
*/
|
||||||
public join(fociPreferred: Focus[], focusActive?: Focus, onError?: (error: unknown) => void): void {
|
public join(fociPreferred: Transport[], multiSfuFocus?: Transport, onError?: (error: unknown) => void): void {
|
||||||
if (this.scheduler.running) {
|
if (this.scheduler.running) {
|
||||||
this.logger.error("MembershipManager is already running. Ignoring join request.");
|
this.logger.error("MembershipManager is already running. Ignoring join request.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.fociPreferred = fociPreferred;
|
this.fociPreferred = fociPreferred;
|
||||||
this.focusActive = focusActive;
|
this.rtcTransport = multiSfuFocus;
|
||||||
this.leavePromiseResolvers = undefined;
|
this.leavePromiseResolvers = undefined;
|
||||||
this.activated = true;
|
this.activated = true;
|
||||||
this.oldStatus = this.status;
|
this.oldStatus = this.status;
|
||||||
@@ -266,25 +276,6 @@ export class MembershipManager
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
public getActiveFocus(): Focus | undefined {
|
|
||||||
if (this.focusActive) {
|
|
||||||
// A livekit active focus
|
|
||||||
if (isLivekitFocusActive(this.focusActive)) {
|
|
||||||
if (this.focusActive.focus_selection === "oldest_membership") {
|
|
||||||
const oldestMembership = this.getOldestMembership();
|
|
||||||
return oldestMembership?.getPreferredFoci()[0];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.logger.warn("Unknown own ActiveFocus type. This makes it impossible to connect to an SFU.");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// We do not understand the membership format (could be legacy). We default to oldestMembership
|
|
||||||
// Once there are other methods this is a hard error!
|
|
||||||
const oldestMembership = this.getOldestMembership();
|
|
||||||
return oldestMembership?.getPreferredFoci()[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async updateCallIntent(callIntent: RTCCallIntent): Promise<void> {
|
public async updateCallIntent(callIntent: RTCCallIntent): Promise<void> {
|
||||||
if (!this.activated || !this.ownMembership) {
|
if (!this.activated || !this.ownMembership) {
|
||||||
throw Error("You cannot update your intent before joining the call");
|
throw Error("You cannot update your intent before joining the call");
|
||||||
@@ -302,7 +293,6 @@ export class MembershipManager
|
|||||||
* @param joinConfig
|
* @param joinConfig
|
||||||
* @param room
|
* @param room
|
||||||
* @param client
|
* @param client
|
||||||
* @param getOldestMembership
|
|
||||||
*/
|
*/
|
||||||
public constructor(
|
public constructor(
|
||||||
private joinConfig: (SessionConfig & MembershipConfig) | undefined,
|
private joinConfig: (SessionConfig & MembershipConfig) | undefined,
|
||||||
@@ -315,8 +305,7 @@ export class MembershipManager
|
|||||||
| "_unstable_sendDelayedStateEvent"
|
| "_unstable_sendDelayedStateEvent"
|
||||||
| "_unstable_updateDelayedEvent"
|
| "_unstable_updateDelayedEvent"
|
||||||
>,
|
>,
|
||||||
private getOldestMembership: () => CallMembership | undefined,
|
public readonly slotDescription: SlotDescription,
|
||||||
public readonly sessionDescription: SessionDescription,
|
|
||||||
parentLogger?: Logger,
|
parentLogger?: Logger,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
@@ -325,7 +314,9 @@ export class MembershipManager
|
|||||||
if (userId === null) throw Error("Missing userId in client");
|
if (userId === null) throw Error("Missing userId in client");
|
||||||
if (deviceId === null) throw Error("Missing deviceId in client");
|
if (deviceId === null) throw Error("Missing deviceId in client");
|
||||||
this.deviceId = deviceId;
|
this.deviceId = deviceId;
|
||||||
this.stateKey = this.makeMembershipStateKey(userId, deviceId);
|
// this needs to become a uuid so that consecutive join/leaves result in a key rotation.
|
||||||
|
// we keep it as a string for now for backwards compatibility.
|
||||||
|
this.memberId = this.makeMembershipStateKey(userId, deviceId);
|
||||||
this.state = MembershipManager.defaultState;
|
this.state = MembershipManager.defaultState;
|
||||||
this.callIntent = joinConfig?.callIntent;
|
this.callIntent = joinConfig?.callIntent;
|
||||||
this.scheduler = new ActionScheduler((type): Promise<ActionUpdate> => {
|
this.scheduler = new ActionScheduler((type): Promise<ActionUpdate> => {
|
||||||
@@ -371,9 +362,10 @@ export class MembershipManager
|
|||||||
}
|
}
|
||||||
// Membership Event static parameters:
|
// Membership Event static parameters:
|
||||||
private deviceId: string;
|
private deviceId: string;
|
||||||
private stateKey: string;
|
private memberId: string;
|
||||||
private fociPreferred?: Focus[];
|
/** @deprecated This will be removed in favor or rtcTransport becoming a list of actively used transports */
|
||||||
private focusActive?: Focus;
|
private fociPreferred?: Transport[];
|
||||||
|
private rtcTransport?: Transport;
|
||||||
|
|
||||||
// Config:
|
// Config:
|
||||||
private delayedLeaveEventDelayMsOverride?: number;
|
private delayedLeaveEventDelayMsOverride?: number;
|
||||||
@@ -406,6 +398,9 @@ export class MembershipManager
|
|||||||
private get delayedLeaveEventRestartLocalTimeoutMs(): number {
|
private get delayedLeaveEventRestartLocalTimeoutMs(): number {
|
||||||
return this.joinConfig?.delayedLeaveEventRestartLocalTimeoutMs ?? 2000;
|
return this.joinConfig?.delayedLeaveEventRestartLocalTimeoutMs ?? 2000;
|
||||||
}
|
}
|
||||||
|
private get useRtcMemberFormat(): boolean {
|
||||||
|
return this.joinConfig?.useRtcMemberFormat ?? false;
|
||||||
|
}
|
||||||
// LOOP HANDLER:
|
// LOOP HANDLER:
|
||||||
private async membershipLoopHandler(type: MembershipActionType): Promise<ActionUpdate> {
|
private async membershipLoopHandler(type: MembershipActionType): Promise<ActionUpdate> {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -472,9 +467,9 @@ export class MembershipManager
|
|||||||
{
|
{
|
||||||
delay: this.delayedLeaveEventDelayMs,
|
delay: this.delayedLeaveEventDelayMs,
|
||||||
},
|
},
|
||||||
EventType.GroupCallMemberPrefix,
|
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
|
||||||
{}, // leave event
|
{}, // leave event
|
||||||
this.stateKey,
|
this.memberId,
|
||||||
)
|
)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs;
|
this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs;
|
||||||
@@ -659,9 +654,9 @@ export class MembershipManager
|
|||||||
return await this.client
|
return await this.client
|
||||||
.sendStateEvent(
|
.sendStateEvent(
|
||||||
this.room.roomId,
|
this.room.roomId,
|
||||||
EventType.GroupCallMemberPrefix,
|
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
|
||||||
this.makeMyMembership(this.membershipEventExpiryMs),
|
this.makeMyMembership(this.membershipEventExpiryMs),
|
||||||
this.stateKey,
|
this.memberId,
|
||||||
)
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.setAndEmitProbablyLeft(false);
|
this.setAndEmitProbablyLeft(false);
|
||||||
@@ -705,9 +700,9 @@ export class MembershipManager
|
|||||||
return await this.client
|
return await this.client
|
||||||
.sendStateEvent(
|
.sendStateEvent(
|
||||||
this.room.roomId,
|
this.room.roomId,
|
||||||
EventType.GroupCallMemberPrefix,
|
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
|
||||||
this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration),
|
this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration),
|
||||||
this.stateKey,
|
this.memberId,
|
||||||
)
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// Success, we reset retries and schedule update.
|
// Success, we reset retries and schedule update.
|
||||||
@@ -731,7 +726,12 @@ export class MembershipManager
|
|||||||
}
|
}
|
||||||
private async sendFallbackLeaveEvent(): Promise<ActionUpdate> {
|
private async sendFallbackLeaveEvent(): Promise<ActionUpdate> {
|
||||||
return await this.client
|
return await this.client
|
||||||
.sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, {}, this.stateKey)
|
.sendStateEvent(
|
||||||
|
this.room.roomId,
|
||||||
|
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
|
||||||
|
{},
|
||||||
|
this.memberId,
|
||||||
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent);
|
this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent);
|
||||||
this.state.hasMemberStateEvent = false;
|
this.state.hasMemberStateEvent = false;
|
||||||
@@ -746,7 +746,7 @@ export class MembershipManager
|
|||||||
|
|
||||||
// HELPERS
|
// HELPERS
|
||||||
private makeMembershipStateKey(localUserId: string, localDeviceId: string): string {
|
private makeMembershipStateKey(localUserId: string, localDeviceId: string): string {
|
||||||
const stateKey = `${localUserId}_${localDeviceId}_${this.sessionDescription.application}${this.sessionDescription.id}`;
|
const stateKey = `${localUserId}_${localDeviceId}_${this.slotDescription.application}${this.slotDescription.id}`;
|
||||||
if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) {
|
if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) {
|
||||||
return stateKey;
|
return stateKey;
|
||||||
} else {
|
} else {
|
||||||
@@ -757,20 +757,45 @@ export class MembershipManager
|
|||||||
/**
|
/**
|
||||||
* Constructs our own membership
|
* Constructs our own membership
|
||||||
*/
|
*/
|
||||||
private makeMyMembership(expires: number): SessionMembershipData {
|
private makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
|
||||||
const hasPreviousEvent = !!this.ownMembership;
|
const ownMembership = this.ownMembership;
|
||||||
return {
|
if (this.useRtcMemberFormat) {
|
||||||
// TODO: use the new format for m.rtc.member events where call_id becomes session.id
|
const relationObject = ownMembership?.eventId
|
||||||
"application": this.sessionDescription.application,
|
? { "m.relation": { rel_type: RelationType.Reference, event_id: ownMembership?.eventId } }
|
||||||
"call_id": this.sessionDescription.id,
|
: {};
|
||||||
"scope": "m.room",
|
return {
|
||||||
"device_id": this.deviceId,
|
application: {
|
||||||
expires,
|
type: this.slotDescription.application,
|
||||||
"focus_active": { type: "livekit", focus_selection: "oldest_membership" },
|
...(this.callIntent ? { "m.call.intent": this.callIntent } : {}),
|
||||||
"foci_preferred": this.fociPreferred ?? [],
|
},
|
||||||
"m.call.intent": this.callIntent,
|
slot_id: slotDescriptionToId(this.slotDescription),
|
||||||
...(hasPreviousEvent ? { created_ts: this.ownMembership?.createdTs() } : undefined),
|
rtc_transports: this.rtcTransport ? [this.rtcTransport] : [],
|
||||||
};
|
member: { device_id: this.deviceId, user_id: this.client.getUserId()!, id: this.memberId },
|
||||||
|
versions: [],
|
||||||
|
...relationObject,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const focusObjects =
|
||||||
|
this.rtcTransport === undefined
|
||||||
|
? {
|
||||||
|
focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const,
|
||||||
|
foci_preferred: this.fociPreferred ?? [],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
focus_active: { type: "livekit", focus_selection: "multi_sfu" } as const,
|
||||||
|
foci_preferred: [this.rtcTransport, ...(this.fociPreferred ?? [])],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
"application": this.slotDescription.application,
|
||||||
|
"call_id": this.slotDescription.id,
|
||||||
|
"scope": "m.room",
|
||||||
|
"device_id": this.deviceId,
|
||||||
|
expires,
|
||||||
|
"m.call.intent": this.callIntent,
|
||||||
|
...focusObjects,
|
||||||
|
...(ownMembership !== undefined ? { created_ts: ownMembership.createdTs() } : undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error checks and handlers
|
// Error checks and handlers
|
||||||
|
|||||||
@@ -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,8 +15,7 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./CallMembership.ts";
|
export * from "./CallMembership.ts";
|
||||||
export type * from "./focus.ts";
|
export * from "./LivekitTransport.ts";
|
||||||
export * from "./LivekitFocus.ts";
|
|
||||||
export * from "./MatrixRTCSession.ts";
|
export * from "./MatrixRTCSession.ts";
|
||||||
export * from "./MatrixRTCSessionManager.ts";
|
export * from "./MatrixRTCSessionManager.ts";
|
||||||
export type * from "./types.ts";
|
export type * from "./types.ts";
|
||||||
|
|||||||
@@ -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