1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-08-07 23:02:56 +03:00

Make the js-sdk compatible with MSC preferred foci and active focus. (#4195)

* Refactor to preferred and active foci.

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

* make the sdk compatible with MSC4143 but still be backwards compatible

* comment fixes

* also fallback to legacy if the current member event is legacy

* use XOR types

* use EitherAnd

* make livekit Foucs types simpler

* review

* fix tests

* test work

* more review + more tests

* remove unnecassary await that is in conflict with the comment

* make joinRoomSession sync

* Update src/matrixrtc/MatrixRTCSession.ts

Co-authored-by: Andrew Ferrazzutti <af_0_af@hotmail.com>

* review

* fix

* test

* review

* review

* comment clarification

* typo

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Andrew Ferrazzutti <af_0_af@hotmail.com>
This commit is contained in:
Timo
2024-06-17 15:02:29 +02:00
committed by GitHub
parent 7ecaa53e34
commit d754392410
10 changed files with 652 additions and 237 deletions

View File

@@ -15,16 +15,7 @@ limitations under the License.
*/ */
import { MatrixEvent } from "../../../src"; import { MatrixEvent } from "../../../src";
import { CallMembership, CallMembershipData } from "../../../src/matrixrtc/CallMembership"; import { CallMembership, CallMembershipDataLegacy, SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
const membershipTemplate: CallMembershipData = {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: "AAAAAAA",
expires: 5000,
membershipID: "bloop",
};
function makeMockEvent(originTs = 0): MatrixEvent { function makeMockEvent(originTs = 0): MatrixEvent {
return { return {
@@ -34,6 +25,16 @@ function makeMockEvent(originTs = 0): MatrixEvent {
} }
describe("CallMembership", () => { describe("CallMembership", () => {
describe("CallMembershipDataLegacy", () => {
const membershipTemplate: CallMembershipDataLegacy = {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: "AAAAAAA",
expires: 5000,
membershipID: "bloop",
foci_active: [{ type: "livekit" }],
};
it("rejects membership with no expiry and no expires_ts", () => { it("rejects membership with no expiry and no expires_ts", () => {
expect(() => { expect(() => {
new CallMembership( new CallMembership(
@@ -55,10 +56,10 @@ describe("CallMembership", () => {
}).toThrow(); }).toThrow();
}); });
it("rejects membership with no scope", () => { it("allow membership with no scope", () => {
expect(() => { expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined })); new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
}).toThrow(); }).not.toThrow();
}); });
it("rejects with malformatted expires_ts", () => { it("rejects with malformatted expires_ts", () => {
expect(() => { expect(() => {
@@ -92,7 +93,7 @@ describe("CallMembership", () => {
it("computes absolute expiry time based on expires_ts", () => { it("computes absolute expiry time based on expires_ts", () => {
const membership = new CallMembership( const membership = new CallMembership(
makeMockEvent(1000), makeMockEvent(1000),
Object.assign({}, membershipTemplate, { expires: undefined, expires_ts: 6000 }), Object.assign({}, membershipTemplate, { expires_ts: 6000 }),
); );
expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000); expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000);
}); });
@@ -111,19 +112,88 @@ describe("CallMembership", () => {
expect(membership.isExpired()).toEqual(true); expect(membership.isExpired()).toEqual(true);
}); });
it("returns active 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, fakeEvent,
Object.assign({}, membershipTemplate, { foci_active: [mockFocus] }), Object.assign({}, membershipTemplate, { foci_active: [mockFocus] }),
); );
expect(membership.getActiveFoci()).toEqual([mockFocus]); expect(membership.getPreferredFoci()).toEqual([mockFocus]);
});
});
describe("SessionMembershipData", () => {
const membershipTemplate: SessionMembershipData = {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: "AAAAAAA",
focus_active: { type: "livekit" },
foci_preferred: [{ type: "livekit" }],
};
it("rejects membership with no device_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined }));
}).toThrow();
});
it("rejects membership with no call_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined }));
}).toThrow();
});
it("allow membership with no scope", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
}).not.toThrow();
});
it("uses event timestamp if no created_ts", () => {
const membership = new CallMembership(makeMockEvent(12345), membershipTemplate);
expect(membership.createdTs()).toEqual(12345);
});
it("uses created_ts if present", () => {
const membership = new CallMembership(
makeMockEvent(12345),
Object.assign({}, membershipTemplate, { created_ts: 67890 }),
);
expect(membership.createdTs()).toEqual(67890);
});
it("considers memberships unexpired if local age low enough", () => {
const fakeEvent = makeMockEvent(1000);
fakeEvent.getLocalAge = jest.fn().mockReturnValue(3000);
const membership = new CallMembership(fakeEvent, membershipTemplate);
expect(membership.isExpired()).toEqual(false);
});
it("returns preferred foci", () => {
const fakeEvent = makeMockEvent();
const mockFocus = { type: "this_is_a_mock_focus" };
const membership = new CallMembership(
fakeEvent,
Object.assign({}, membershipTemplate, { foci_preferred: [mockFocus] }),
);
expect(membership.getPreferredFoci()).toEqual([mockFocus]);
});
}); });
describe("expiry calculation", () => { describe("expiry calculation", () => {
let fakeEvent: MatrixEvent; let fakeEvent: MatrixEvent;
let membership: CallMembership; let membership: CallMembership;
const membershipTemplate: CallMembershipDataLegacy = {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: "AAAAAAA",
expires: 5000,
membershipID: "bloop",
foci_active: [{ type: "livekit" }],
};
beforeEach(() => { beforeEach(() => {
// server origin timestamp for this event is 1000 // server origin timestamp for this event is 1000

View File

@@ -0,0 +1,60 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { isLivekitFocus, isLivekitFocusActive, isLivekitFocusConfig } from "../../../src/matrixrtc/LivekitFocus";
describe("LivekitFocus", () => {
it("isLivekitFocus", () => {
expect(
isLivekitFocus({
type: "livekit",
livekit_service_url: "http://test.com",
livekit_alias: "test",
}),
).toBeTruthy();
expect(isLivekitFocus({ type: "livekit" })).toBeFalsy();
expect(
isLivekitFocus({ type: "not-livekit", livekit_service_url: "http://test.com", livekit_alias: "test" }),
).toBeFalsy();
expect(
isLivekitFocus({ type: "livekit", other_service_url: "http://test.com", livekit_alias: "test" }),
).toBeFalsy();
expect(
isLivekitFocus({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }),
).toBeFalsy();
});
it("isLivekitFocusActive", () => {
expect(
isLivekitFocusActive({
type: "livekit",
focus_selection: "oldest_membership",
}),
).toBeTruthy();
expect(isLivekitFocusActive({ type: "livekit" })).toBeFalsy();
expect(isLivekitFocusActive({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy();
});
it("isLivekitFocusConfig", () => {
expect(
isLivekitFocusConfig({
type: "livekit",
livekit_service_url: "http://test.com",
}),
).toBeTruthy();
expect(isLivekitFocusConfig({ type: "livekit" })).toBeFalsy();
expect(isLivekitFocusConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy();
expect(isLivekitFocusConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy();
});
});

View File

@@ -29,6 +29,7 @@ const membershipTemplate: CallMembershipData = {
device_id: "AAAAAAA", device_id: "AAAAAAA",
expires: 60 * 60 * 1000, expires: 60 * 60 * 1000,
membershipID: "bloop", membershipID: "bloop",
foci_active: [{ type: "livekit", livekit_service_url: "https://lk.url" }],
}; };
const mockFocus = { type: "mock" }; const mockFocus = { type: "mock" };
@@ -198,6 +199,64 @@ describe("MatrixRTCSession", () => {
}); });
}); });
describe("getsActiveFocus", () => {
const activeFociConfig = { type: "livekit", livekit_service_url: "https://active.url" };
it("gets the correct active focus with oldest_membership", () => {
const mockRoom = makeMockRoom([
Object.assign({}, membershipTemplate, {
device_id: "foo",
created_ts: 500,
foci_active: [activeFociConfig],
}),
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], {
type: "livekit",
focus_selection: "oldest_membership",
});
expect(sess.getActiveFocus()).toBe(activeFociConfig);
});
it("does not provide focus if the selction method is unknown", () => {
const mockRoom = makeMockRoom([
Object.assign({}, membershipTemplate, {
device_id: "foo",
created_ts: 500,
foci_active: [activeFociConfig],
}),
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], {
type: "livekit",
focus_selection: "unknown",
});
expect(sess.getActiveFocus()).toBe(undefined);
});
it("gets the correct active focus legacy", () => {
const mockRoom = makeMockRoom([
Object.assign({}, membershipTemplate, {
device_id: "foo",
created_ts: 500,
foci_active: [activeFociConfig],
}),
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }]);
expect(sess.getActiveFocus()).toBe(activeFociConfig);
});
});
describe("joining", () => { describe("joining", () => {
let mockRoom: Room; let mockRoom: Room;
let sendStateEventMock: jest.Mock; let sendStateEventMock: jest.Mock;
@@ -223,13 +282,13 @@ describe("MatrixRTCSession", () => {
}); });
it("shows joined once join is called", () => { it("shows joined once join is called", () => {
sess!.joinRoomSession([mockFocus]); sess!.joinRoomSession([mockFocus], mockFocus);
expect(sess!.isJoined()).toEqual(true); expect(sess!.isJoined()).toEqual(true);
}); });
it("sends a membership event when joining a call", () => { it("sends a membership event when joining a call", () => {
jest.useFakeTimers(); jest.useFakeTimers();
sess!.joinRoomSession([mockFocus]); sess!.joinRoomSession([mockFocus], mockFocus);
expect(client.sendStateEvent).toHaveBeenCalledWith( expect(client.sendStateEvent).toHaveBeenCalledWith(
mockRoom!.roomId, mockRoom!.roomId,
EventType.GroupCallMemberPrefix, EventType.GroupCallMemberPrefix,
@@ -242,7 +301,8 @@ describe("MatrixRTCSession", () => {
device_id: "AAAAAAA", device_id: "AAAAAAA",
expires: 3600000, expires: 3600000,
expires_ts: Date.now() + 3600000, expires_ts: Date.now() + 3600000,
foci_active: [{ type: "mock" }], foci_active: [mockFocus],
membershipID: expect.stringMatching(".*"), membershipID: expect.stringMatching(".*"),
}, },
], ],
@@ -253,11 +313,11 @@ describe("MatrixRTCSession", () => {
}); });
it("does nothing if join called when already joined", () => { it("does nothing if join called when already joined", () => {
sess!.joinRoomSession([mockFocus]); sess!.joinRoomSession([mockFocus], mockFocus);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1); expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
sess!.joinRoomSession([mockFocus]); sess!.joinRoomSession([mockFocus], mockFocus);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1); expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
}); });
@@ -274,7 +334,7 @@ describe("MatrixRTCSession", () => {
const sendStateEventMock = jest.fn().mockImplementation(resolveFn); const sendStateEventMock = jest.fn().mockImplementation(resolveFn);
client.sendStateEvent = sendStateEventMock; client.sendStateEvent = sendStateEventMock;
sess!.joinRoomSession([mockFocus]); sess!.joinRoomSession([mockFocus], mockFocus);
const eventContent = await eventSentPromise; const eventContent = await eventSentPromise;
@@ -308,7 +368,7 @@ describe("MatrixRTCSession", () => {
device_id: "AAAAAAA", device_id: "AAAAAAA",
expires: 3600000 * 2, expires: 3600000 * 2,
expires_ts: 1000 + 3600000 * 2, expires_ts: 1000 + 3600000 * 2,
foci_active: [{ type: "mock" }], foci_active: [mockFocus],
created_ts: 1000, created_ts: 1000,
membershipID: expect.stringMatching(".*"), membershipID: expect.stringMatching(".*"),
}, },
@@ -322,7 +382,7 @@ describe("MatrixRTCSession", () => {
}); });
it("creates a key when joining", () => { it("creates a key when joining", () => {
sess!.joinRoomSession([mockFocus], true); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
const keys = sess?.getKeysForParticipant("@alice:example.org", "AAAAAAA"); const keys = sess?.getKeysForParticipant("@alice:example.org", "AAAAAAA");
expect(keys).toHaveLength(1); expect(keys).toHaveLength(1);
@@ -336,7 +396,7 @@ describe("MatrixRTCSession", () => {
sendEventMock.mockImplementation(resolve); sendEventMock.mockImplementation(resolve);
}); });
sess!.joinRoomSession([mockFocus], true); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
await eventSentPromise; await eventSentPromise;
@@ -372,7 +432,7 @@ describe("MatrixRTCSession", () => {
}); });
}); });
sess!.joinRoomSession([mockFocus], true); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
jest.advanceTimersByTime(10000); jest.advanceTimersByTime(10000);
await eventSentPromise; await eventSentPromise;
@@ -394,7 +454,7 @@ describe("MatrixRTCSession", () => {
throw e; throw e;
}); });
sess!.joinRoomSession([mockFocus], true); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
expect(client.cancelPendingEvent).toHaveBeenCalledWith(eventSentinel); expect(client.cancelPendingEvent).toHaveBeenCalledWith(eventSentinel);
}); });
@@ -409,7 +469,7 @@ describe("MatrixRTCSession", () => {
sendEventMock.mockImplementation(resolve); sendEventMock.mockImplementation(resolve);
}); });
sess.joinRoomSession([mockFocus], true); sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
await keysSentPromise1; await keysSentPromise1;
sendEventMock.mockClear(); sendEventMock.mockClear();
@@ -462,7 +522,7 @@ describe("MatrixRTCSession", () => {
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
}); });
sess.joinRoomSession([mockFocus], true); sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
const firstKeysPayload = await keysSentPromise1; const firstKeysPayload = await keysSentPromise1;
expect(firstKeysPayload.keys).toHaveLength(1); expect(firstKeysPayload.keys).toHaveLength(1);
@@ -499,7 +559,7 @@ describe("MatrixRTCSession", () => {
sendEventMock.mockImplementation(resolve); sendEventMock.mockImplementation(resolve);
}); });
sess.joinRoomSession([mockFocus], true); sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
await keysSentPromise1; await keysSentPromise1;
sendEventMock.mockClear(); sendEventMock.mockClear();
@@ -595,7 +655,7 @@ describe("MatrixRTCSession", () => {
jest.advanceTimersByTime(10000); jest.advanceTimersByTime(10000);
sess.joinRoomSession([mockFocus]); sess.joinRoomSession([mockFocus], mockFocus);
expect(client.sendStateEvent).toHaveBeenCalledWith( expect(client.sendStateEvent).toHaveBeenCalledWith(
mockRoomNoExpired!.roomId, mockRoomNoExpired!.roomId,
@@ -631,7 +691,7 @@ describe("MatrixRTCSession", () => {
]); ]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess.joinRoomSession([mockFocus]); sess.joinRoomSession([mockFocus], mockFocus);
expect(client.sendStateEvent).toHaveBeenCalledWith( expect(client.sendStateEvent).toHaveBeenCalledWith(
mockRoom!.roomId, mockRoom!.roomId,
@@ -645,6 +705,7 @@ describe("MatrixRTCSession", () => {
device_id: "OTHERDEVICE", device_id: "OTHERDEVICE",
expires: 3600000, expires: 3600000,
created_ts: 1000, created_ts: 1000,
foci_active: [{ type: "livekit", livekit_service_url: "https://lk.url" }],
membershipID: expect.stringMatching(".*"), membershipID: expect.stringMatching(".*"),
}, },
{ {

View File

@@ -35,6 +35,7 @@ const membershipTemplate: CallMembershipData = {
device_id: "AAAAAAA", device_id: "AAAAAAA",
expires: 60 * 60 * 1000, expires: 60 * 60 * 1000,
membershipID: "bloop", membershipID: "bloop",
foci_active: [{ type: "test" }],
}; };
describe("MatrixRTCSessionManager", () => { describe("MatrixRTCSessionManager", () => {

View File

@@ -57,6 +57,7 @@ import {
} from "../webrtc/callEventTypes"; } from "../webrtc/callEventTypes";
import { EncryptionKeysEventContent, ICallNotifyContent } from "../matrixrtc/types"; import { EncryptionKeysEventContent, ICallNotifyContent } from "../matrixrtc/types";
import { M_POLL_END, M_POLL_START, PollEndEventContent, PollStartEventContent } from "./polls"; import { M_POLL_END, M_POLL_START, PollEndEventContent, PollStartEventContent } from "./polls";
import { SessionMembershipData } from "../matrixrtc/CallMembership";
export enum EventType { export enum EventType {
// Room state events // Room state events
@@ -356,7 +357,10 @@ export interface StateEvents {
// MSC3401 // MSC3401
[EventType.GroupCallPrefix]: IGroupCallRoomState; [EventType.GroupCallPrefix]: IGroupCallRoomState;
[EventType.GroupCallMemberPrefix]: XOR<IGroupCallRoomMemberState, ExperimentalGroupCallRoomMemberState>; [EventType.GroupCallMemberPrefix]: XOR<
XOR<IGroupCallRoomMemberState, ExperimentalGroupCallRoomMemberState>,
XOR<SessionMembershipData, {}>
>;
// MSC3089 // MSC3089
[UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent; [UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent;

View File

@@ -14,52 +14,114 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { EitherAnd } from "matrix-events-sdk/lib/types";
import { MatrixEvent } from "../matrix"; import { MatrixEvent } from "../matrix";
import { deepCompare } from "../utils"; import { deepCompare } from "../utils";
import { Focus } from "./focus"; import { Focus } from "./focus";
import { isLivekitFocusActive } from "./LivekitFocus";
type CallScope = "m.room" | "m.user"; type CallScope = "m.room" | "m.user";
// Represents an entry in the memberships section of an m.call.member event as it is on the wire // Represents an entry in the memberships section of an m.call.member event as it is on the wire
export interface CallMembershipData {
application?: string; // There are two different data interfaces. One for the Legacy types and one compliant with MSC4143
// MSC4143 (MatrixRTC) session membership data
export type SessionMembershipData = {
application: string;
call_id: string;
device_id: string;
focus_active: Focus;
foci_preferred: Focus[];
created_ts?: number;
// Application specific data
scope?: CallScope;
};
export const isSessionMembershipData = (data: CallMembershipData): data is SessionMembershipData =>
"focus_active" in data;
const checkSessionsMembershipData = (data: any, 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.call_id !== "string") errors.push(prefix + "call_id must be string");
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string");
if (!Array.isArray(data.foci_preferred)) errors.push(prefix + "foci_preferred must be an array");
// optional parameters
if (data.created_ts && typeof data.created_ts !== "number") errors.push(prefix + "created_ts must be number");
// application specific data (we first need to check if they exist)
if (data.scope && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
return errors.length === 0;
};
// Legacy session membership data
export type CallMembershipDataLegacy = {
application: string;
call_id: string; call_id: string;
scope: CallScope; scope: CallScope;
device_id: string; device_id: string;
created_ts?: number;
expires?: number;
expires_ts?: number;
foci_active?: Focus[];
membershipID: string; membershipID: string;
created_ts?: number;
foci_active?: Focus[];
} & EitherAnd<{ expires: number }, { expires_ts: number }>;
export const isLegacyCallMembershipData = (data: CallMembershipData): data is CallMembershipDataLegacy =>
"membershipID" in data;
const checkCallMembershipDataLegacy = (data: any, errors: string[]): data is CallMembershipDataLegacy => {
const prefix = "Malformed legacy rtc membership event: ";
if (!("expires" in data || "expires_ts" in data)) {
errors.push(prefix + "expires_ts or expires must be present");
} }
if ("expires" in data) {
if (typeof data.expires !== "number") {
errors.push(prefix + "expires must be numeric");
}
}
if ("expires_ts" in data) {
if (typeof data.expires_ts !== "number") {
errors.push(prefix + "expires_ts must be numeric");
}
}
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.application !== "string") errors.push(prefix + "application must be a string");
if (typeof data.membershipID !== "string") errors.push(prefix + "membershipID must be a string");
// optional elements
if (data.created_ts && typeof data.created_ts !== "number") errors.push(prefix + "created_ts must be number");
// application specific data (we first need to check if they exist)
if (data.scope && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
return errors.length === 0;
};
export type CallMembershipData = CallMembershipDataLegacy | SessionMembershipData;
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.data, b.data); return deepCompare(a.membershipData, b.membershipData);
} }
private membershipData: CallMembershipData;
public constructor( public constructor(
private parentEvent: MatrixEvent, private parentEvent: MatrixEvent,
private data: CallMembershipData, data: any,
) { ) {
if (!(data.expires || data.expires_ts)) { const sessionErrors: string[] = [];
throw new Error("Malformed membership: expires_ts or expires must be present"); const legacyErrors: string[] = [];
if (!checkSessionsMembershipData(data, sessionErrors) && !checkCallMembershipDataLegacy(data, legacyErrors)) {
throw Error(
`unknown CallMembership data. Does not match legacy call.member (${legacyErrors.join(" & ")}) events nor MSC4143 (${sessionErrors.join(" & ")})`,
);
} else {
this.membershipData = data;
} }
if (data.expires) {
if (typeof data.expires !== "number") {
throw new Error("Malformed membership: expires must be numeric");
}
}
if (data.expires_ts) {
if (typeof data.expires_ts !== "number") {
throw new Error("Malformed membership: expires_ts must be numeric");
}
}
if (typeof data.device_id !== "string") throw new Error("Malformed membership event: device_id must be string");
if (typeof data.call_id !== "string") throw new Error("Malformed membership event: call_id must be string");
if (typeof data.scope !== "string") throw new Error("Malformed membership event: scope must be string");
if (!parentEvent.getSender()) throw new Error("Invalid parent event: sender is null");
} }
public get sender(): string | undefined { public get sender(): string | undefined {
@@ -67,62 +129,89 @@ export class CallMembership {
} }
public get callId(): string { public get callId(): string {
return this.data.call_id; return this.membershipData.call_id;
} }
public get deviceId(): string { public get deviceId(): string {
return this.data.device_id; return this.membershipData.device_id;
} }
public get application(): string | undefined { public get application(): string | undefined {
return this.data.application; return this.membershipData.application;
} }
public get scope(): CallScope { public get scope(): CallScope | undefined {
return this.data.scope; return this.membershipData.scope;
} }
public get membershipID(): string { public get membershipID(): string {
return this.data.membershipID; if (isLegacyCallMembershipData(this.membershipData)) return this.membershipData.membershipID;
// the createdTs behaves equivalent to the membershipID.
// we only need the field for the legacy member envents where we needed to update them
// synapse ignores sending state events if they have the same content.
else return this.createdTs().toString();
} }
public createdTs(): number { public createdTs(): number {
return this.data.created_ts ?? this.parentEvent.getTs(); return this.membershipData.created_ts ?? this.parentEvent.getTs();
} }
public getAbsoluteExpiry(): number { public getAbsoluteExpiry(): number | undefined {
if (this.data.expires) { if (!isLegacyCallMembershipData(this.membershipData)) return undefined;
return this.createdTs() + this.data.expires; if ("expires" in this.membershipData) {
// we know createdTs exists since we already do the isLegacyCallMembershipData check
return this.createdTs() + this.membershipData.expires;
} else { } else {
// We know it exists because we checked for this in the constructor. // We know it exists because we checked for this in the constructor.
return this.data.expires_ts!; return this.membershipData.expires_ts;
} }
} }
// gets the expiry time of the event, converted into the device's local time // gets the expiry time of the event, converted into the device's local time
public getLocalExpiry(): number { public getLocalExpiry(): number | undefined {
if (this.data.expires) { if (!isLegacyCallMembershipData(this.membershipData)) return undefined;
if ("expires" in this.membershipData) {
// we know createdTs exists since we already do the isLegacyCallMembershipData check
const relativeCreationTime = this.parentEvent.getTs() - this.createdTs(); const relativeCreationTime = this.parentEvent.getTs() - this.createdTs();
const localCreationTs = this.parentEvent.localTimestamp - relativeCreationTime; const localCreationTs = this.parentEvent.localTimestamp - relativeCreationTime;
return localCreationTs + this.data.expires; return localCreationTs + this.membershipData.expires;
} else { } else {
// With expires_ts we cannot convert to local time. // With expires_ts we cannot convert to local time.
// TODO: Check the server timestamp and compute a diff to local time. // TODO: Check the server timestamp and compute a diff to local time.
return this.data.expires_ts!; return this.membershipData.expires_ts;
} }
} }
public getMsUntilExpiry(): number { public getMsUntilExpiry(): number | undefined {
return this.getLocalExpiry() - Date.now(); if (isLegacyCallMembershipData(this.membershipData)) return this.getLocalExpiry()! - Date.now();
} }
public isExpired(): boolean { public isExpired(): boolean {
return this.getMsUntilExpiry() <= 0; if (isLegacyCallMembershipData(this.membershipData)) return this.getMsUntilExpiry()! <= 0;
// MSC4143 events expire by being updated. So if the event exists, its not expired.
return false;
} }
public getActiveFoci(): Focus[] { public getPreferredFoci(): Focus[] {
return this.data.foci_active ?? []; // To support both, the new and the old MatrixRTC memberships have two cases based
// on the availablitiy of `foci_preferred`
if (isLegacyCallMembershipData(this.membershipData)) return this.membershipData.foci_active ?? [];
// MSC4143 style membership
return this.membershipData.foci_preferred;
}
public getFocusSelection(): string | undefined {
if (isLegacyCallMembershipData(this.membershipData)) {
return "oldest_membership";
} else {
const focusActive = this.membershipData.focus_active;
if (isLivekitFocusActive(focusActive)) {
return focusActive.focus_selection;
}
}
} }
} }

View File

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

View File

@@ -20,7 +20,13 @@ import { EventTimeline } from "../models/event-timeline";
import { Room } from "../models/room"; import { Room } from "../models/room";
import { MatrixClient } from "../client"; import { MatrixClient } from "../client";
import { EventType } from "../@types/event"; import { EventType } from "../@types/event";
import { CallMembership, CallMembershipData } from "./CallMembership"; import {
CallMembership,
CallMembershipData,
CallMembershipDataLegacy,
SessionMembershipData,
isLegacyCallMembershipData,
} from "./CallMembership";
import { RoomStateEvent } from "../models/room-state"; import { RoomStateEvent } from "../models/room-state";
import { Focus } from "./focus"; import { Focus } from "./focus";
import { randomString, secureRandomBase64Url } from "../randomstring"; import { randomString, secureRandomBase64Url } from "../randomstring";
@@ -29,6 +35,8 @@ import { decodeBase64, encodeUnpaddedBase64 } from "../base64";
import { KnownMembership } from "../@types/membership"; import { KnownMembership } from "../@types/membership";
import { MatrixError } from "../http-api/errors"; import { MatrixError } from "../http-api/errors";
import { MatrixEvent } from "../models/event"; import { MatrixEvent } from "../models/event";
import { isLivekitFocusActive } from "./LivekitFocus";
import { ExperimentalGroupCallRoomMemberState } from "../webrtc/groupCall";
const MEMBERSHIP_EXPIRY_TIME = 60 * 60 * 1000; const MEMBERSHIP_EXPIRY_TIME = 60 * 60 * 1000;
const MEMBER_EVENT_CHECK_PERIOD = 2 * 60 * 1000; // How often we check to see if we need to re-send our member event const MEMBER_EVENT_CHECK_PERIOD = 2 * 60 * 1000; // How often we check to see if we need to re-send our member event
@@ -57,7 +65,7 @@ export enum MatrixRTCSessionEvent {
MembershipsChanged = "memberships_changed", MembershipsChanged = "memberships_changed",
// We joined or left the session: our own local idea of whether we are joined, // We joined or left the session: our own local idea of whether we are joined,
// separate from MembershipsChanged, ie. independent of whether our member event // separate from MembershipsChanged, ie. independent of whether our member event
// has succesfully gone through. // has successfully gone through.
JoinStateChanged = "join_state_changed", JoinStateChanged = "join_state_changed",
// The key used to encrypt media has changed // The key used to encrypt media has changed
EncryptionKeyChanged = "encryption_key_changed", EncryptionKeyChanged = "encryption_key_changed",
@@ -75,7 +83,20 @@ export type MatrixRTCSessionEventHandlerMap = {
participantId: string, participantId: string,
) => void; ) => void;
}; };
export interface JoinSessionConfig {
/** If true, generate and share a media key for this participant,
* and emit MatrixRTCSessionEvent.EncryptionKeyChanged when
* media keys for other participants become available.
*/
manageMediaKeys?: boolean;
/** Lets you configure how the events for the session are formatted.
* - legacy: use one event with a membership array.
* - MSC4143: use one event per membership (with only one membership per event)
* More details can be found in MSC4143 and by checking the types:
* `CallMembershipDataLegacy` and `SessionMembershipData`
*/
useLegacyMemberEvents?: boolean;
}
/** /**
* A MatrixRTCSession manages the membership & properties of a MatrixRTC session. * A MatrixRTCSession manages the membership & properties of a MatrixRTC session.
* This class doesn't deal with media at all, just membership & properties of a session. * This class doesn't deal with media at all, just membership & properties of a session.
@@ -102,12 +123,16 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
private makeNewKeyTimeout?: ReturnType<typeof setTimeout>; private makeNewKeyTimeout?: ReturnType<typeof setTimeout>;
private setNewKeyTimeouts = new Set<ReturnType<typeof setTimeout>>(); private setNewKeyTimeouts = new Set<ReturnType<typeof setTimeout>>();
private activeFoci: Focus[] | undefined; // This is a Focus with the specified fields for an ActiveFocus (e.g. LivekitFocusActive for type="livekit")
private ownFocusActive?: Focus;
// This is a Foci array that contains the Focus objects this user is aware of and proposes to use.
private ownFociPreferred?: Focus[];
private updateCallMembershipRunning = false; private updateCallMembershipRunning = false;
private needCallMembershipUpdate = false; private needCallMembershipUpdate = false;
private manageMediaKeys = false; private manageMediaKeys = false;
private useLegacyMemberEvents = true;
// userId:deviceId => array of keys // userId:deviceId => array of keys
private encryptionKeys = new Map<string, Array<Uint8Array>>(); private encryptionKeys = new Map<string, Array<Uint8Array>>();
private lastEncryptionKeyUpdateRequest?: number; private lastEncryptionKeyUpdateRequest?: number;
@@ -134,21 +159,33 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
const callMemberships: CallMembership[] = []; const callMemberships: CallMembership[] = [];
for (const memberEvent of callMemberEvents) { for (const memberEvent of callMemberEvents) {
const eventMemberships: CallMembershipData[] = memberEvent.getContent()["memberships"]; const content = memberEvent.getContent();
if (eventMemberships === undefined) { let membershipContents: any[] = [];
continue; // We first decide if its a MSC4143 event (per device state key)
} if ("memberships" in content) {
if (!Array.isArray(eventMemberships)) { // we have a legacy (one event for all devices) event
if (!Array.isArray(content["memberships"])) {
logger.warn(`Malformed member event from ${memberEvent.getSender()}: memberships is not an array`); logger.warn(`Malformed member event from ${memberEvent.getSender()}: memberships is not an array`);
continue; continue;
} }
membershipContents = content["memberships"];
} else {
// We have a MSC4143 event membership event
if (Object.keys(content).length !== 0) {
// We checked for empty content to not try to construct CallMembership's with {}.
membershipContents.push(content);
}
}
if (membershipContents.length === 0) {
continue;
}
for (const membershipData of eventMemberships) { for (const membershipData of membershipContents) {
try { try {
const membership = new CallMembership(memberEvent, membershipData); const membership = new CallMembership(memberEvent, membershipData);
if (membership.callId !== "" || membership.scope !== "m.room") { if (membership.callId !== "" || membership.scope !== "m.room") {
// for now, just ignore anything that isn't the a room scope call // for now, just ignore anything that isn't a room scope call
logger.info(`Ignoring user-scoped call`); logger.info(`Ignoring user-scoped call`);
continue; continue;
} }
@@ -202,6 +239,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
/* /*
* Returns true if we intend to be participating in the MatrixRTC session. * Returns true if we intend to be participating in the MatrixRTC session.
* This is determined by checking if the relativeExpiry has been set.
*/ */
public isJoined(): boolean { public isJoined(): boolean {
return this.relativeExpiry !== undefined; return this.relativeExpiry !== undefined;
@@ -232,30 +270,34 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
* 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 activeFoci - The list of foci to set as currently active in the call member event * @param fociActive - The object representing the active focus. (This depends on the focus type.)
* @param manageMediaKeys - If true, generate and share a a media key for this participant, * @param fociPreferred - The list of preferred foci this member proposes to use/knows/has access to.
* and emit MatrixRTCSessionEvent.EncryptionKeyChanged when * For the livekit case this is a list of foci generated from the homeserver well-known, the current rtc session,
* media keys for other participants become available. * or optionally other room members homeserver well known.
* @param joinConfig - Additional configuration for the joined session.
*/ */
public joinRoomSession(activeFoci: Focus[], manageMediaKeys?: boolean): void { public joinRoomSession(fociPreferred: Focus[], fociActive?: Focus, joinConfig?: JoinSessionConfig): void {
if (this.isJoined()) { if (this.isJoined()) {
logger.info(`Already joined to session in room ${this.room.roomId}: ignoring join call`); logger.info(`Already joined to session in room ${this.room.roomId}: ignoring join call`);
return; return;
} }
logger.info(`Joining call session in room ${this.room.roomId} with manageMediaKeys=${manageMediaKeys}`); this.ownFocusActive = fociActive;
this.activeFoci = activeFoci; this.ownFociPreferred = fociPreferred;
this.relativeExpiry = MEMBERSHIP_EXPIRY_TIME; this.relativeExpiry = MEMBERSHIP_EXPIRY_TIME;
this.manageMediaKeys = manageMediaKeys ?? false; this.manageMediaKeys = joinConfig?.manageMediaKeys ?? this.manageMediaKeys;
this.useLegacyMemberEvents = joinConfig?.useLegacyMemberEvents ?? this.useLegacyMemberEvents;
this.membershipId = randomString(5); this.membershipId = randomString(5);
this.emit(MatrixRTCSessionEvent.JoinStateChanged, true);
if (manageMediaKeys) { logger.info(`Joining call session in room ${this.room.roomId} with manageMediaKeys=${this.manageMediaKeys}`);
if (joinConfig?.manageMediaKeys) {
this.makeNewSenderKey(); this.makeNewSenderKey();
this.requestKeyEventSend(); this.requestKeyEventSend();
} }
// We don't wait for this, mostly because it may fail and schedule a retry, so this // We don't wait for this, mostly because it may fail and schedule a retry, so this
// function returning doesn't really mean anything at all. // function returning doesn't really mean anything at all.
this.triggerCallMembershipEventUpdate(); this.triggerCallMembershipEventUpdate();
this.emit(MatrixRTCSessionEvent.JoinStateChanged, true);
} }
/** /**
@@ -295,7 +337,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
logger.info(`Leaving call session in room ${this.room.roomId}`); logger.info(`Leaving call session in room ${this.room.roomId}`);
this.relativeExpiry = undefined; this.relativeExpiry = undefined;
this.activeFoci = undefined; this.ownFocusActive = undefined;
this.manageMediaKeys = false; this.manageMediaKeys = false;
this.membershipId = undefined; this.membershipId = undefined;
this.emit(MatrixRTCSessionEvent.JoinStateChanged, false); this.emit(MatrixRTCSessionEvent.JoinStateChanged, false);
@@ -315,6 +357,21 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
}); });
} }
public getActiveFocus(): Focus | undefined {
if (this.ownFocusActive && isLivekitFocusActive(this.ownFocusActive)) {
// A livekit active focus
if (this.ownFocusActive.focus_selection === "oldest_membership") {
const oldestMembership = this.getOldestMembership();
return oldestMembership?.getPreferredFoci()[0];
}
}
if (!this.ownFocusActive) {
// we use the legacy call.member events so default to oldest member
const oldestMembership = this.getOldestMembership();
return oldestMembership?.getPreferredFoci()[0];
}
}
public getKeysForParticipant(userId: string, deviceId: string): Array<Uint8Array> | undefined { public getKeysForParticipant(userId: string, deviceId: string): Array<Uint8Array> | undefined {
return this.encryptionKeys.get(getParticipantId(userId, deviceId)); return this.encryptionKeys.get(getParticipantId(userId, deviceId));
} }
@@ -344,7 +401,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
* @param userId - The user ID of the participant * @param userId - The user ID of the participant
* @param deviceId - Device ID of the participant * @param deviceId - Device ID of the participant
* @param encryptionKeyIndex - The index of the key to set * @param encryptionKeyIndex - The index of the key to set
* @param encryptionKeyString - The string represenation of the key to set in base64 * @param encryptionKeyString - The string representation of the key to set in base64
* @param delayBeforeuse - If true, delay before emitting a key changed event. Useful when setting * @param delayBeforeuse - If true, delay before emitting a key changed event. Useful when setting
* encryption keys for the local participant to allow time for the key to * encryption keys for the local participant to allow time for the key to
* be distributed. * be distributed.
@@ -379,7 +436,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
/** /**
* Generate a new sender key and add it at the next available index * Generate a new sender key and add it at the next available index
* @param delayBeforeUse - If true, wait for a short period before settign the key for the * @param delayBeforeUse - If true, wait for a short period before setting the key for the
* media encryptor to use. If false, set the key immediately. * media encryptor to use. If false, set the key immediately.
*/ */
private makeNewSenderKey(delayBeforeUse = false): void { private makeNewSenderKey(delayBeforeUse = false): void {
@@ -488,7 +545,9 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
let soonestExpiry; let soonestExpiry;
for (const membership of this.memberships) { for (const membership of this.memberships) {
const thisExpiry = membership.getMsUntilExpiry(); const thisExpiry = membership.getMsUntilExpiry();
if (soonestExpiry === undefined || thisExpiry < soonestExpiry) { // If getMsUntilExpiry is undefined we have a MSC4143 (MatrixRTC) compliant event - it never expires
// but will be reliably resent on disconnect.
if (thisExpiry !== undefined && (soonestExpiry === undefined || thisExpiry < soonestExpiry)) {
soonestExpiry = thisExpiry; soonestExpiry = thisExpiry;
} }
} }
@@ -502,6 +561,13 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
return this.memberships[0]; return this.memberships[0];
} }
public getFocusInUse(): Focus | undefined {
const oldestMembership = this.getOldestMembership();
if (oldestMembership?.getFocusSelection() === "oldest_membership") {
return oldestMembership.getPreferredFoci()[0];
}
}
public onCallEncryption = (event: MatrixEvent): void => { public onCallEncryption = (event: MatrixEvent): void => {
const userId = event.getSender(); const userId = event.getSender();
const content = event.getContent<EncryptionKeysEventContent>(); const content = event.getContent<EncryptionKeysEventContent>();
@@ -613,30 +679,41 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
* Constructs our own membership * Constructs our own membership
* @param prevMembership - The previous value of our call membership, if any * @param prevMembership - The previous value of our call membership, if any
*/ */
private makeMyMembership(prevMembership?: CallMembership): CallMembershipData { private makeMyMembershipLegacy(deviceId: string, prevMembership?: CallMembership): CallMembershipDataLegacy {
if (this.relativeExpiry === undefined) { if (this.relativeExpiry === undefined) {
throw new Error("Tried to create our own membership event when we're not joined!"); throw new Error("Tried to create our own membership event when we're not joined!");
} }
if (this.membershipId === undefined) { if (this.membershipId === undefined) {
throw new Error("Tried to create our own membership event when we have no membership ID!"); throw new Error("Tried to create our own membership event when we have no membership ID!");
} }
const createdTs = prevMembership?.createdTs();
const m: CallMembershipData = { return {
call_id: "", call_id: "",
scope: "m.room", scope: "m.room",
application: "m.call", application: "m.call",
device_id: this.client.getDeviceId()!, device_id: deviceId,
expires: this.relativeExpiry, expires: this.relativeExpiry,
foci_active: this.activeFoci,
membershipID: this.membershipId,
};
if (prevMembership) m.created_ts = prevMembership.createdTs();
if (m.created_ts) m.expires_ts = m.created_ts + (m.expires ?? 0);
// TODO: Date.now() should be the origin_server_ts (now). // TODO: Date.now() should be the origin_server_ts (now).
else m.expires_ts = Date.now() + (m.expires ?? 0); expires_ts: this.relativeExpiry + (createdTs ?? Date.now()),
// we use the fociPreferred since this is the list of foci.
return m; // it is named wrong in the Legacy events.
foci_active: this.ownFociPreferred,
membershipID: this.membershipId,
...(createdTs ? { created_ts: createdTs } : {}),
};
}
/**
* Constructs our own membership
*/
private makeMyMembership(deviceId: string): SessionMembershipData {
return {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: deviceId,
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
foci_preferred: this.ownFociPreferred ?? [],
};
} }
/** /**
@@ -646,36 +723,41 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
myPrevMembershipData?: CallMembershipData, myPrevMembershipData?: CallMembershipData,
myPrevMembership?: CallMembership, myPrevMembership?: CallMembership,
): boolean { ): boolean {
// work out if we need to update our membership event if (myPrevMembership && myPrevMembership.getMsUntilExpiry() === undefined) return false;
let needsUpdate = false;
// Need to update if there's a membership for us but we're not joined (valid or otherwise) // Need to update if there's a membership for us but we're not joined (valid or otherwise)
if (!this.isJoined() && myPrevMembershipData) needsUpdate = true; if (!this.isJoined()) return !!myPrevMembershipData;
if (this.isJoined()) {
// ...or if we are joined, but there's no valid membership event // ...or if we are joined, but there's no valid membership event
if (!myPrevMembership) { if (!myPrevMembership) return true;
needsUpdate = true;
} else if (myPrevMembership.getMsUntilExpiry() < MEMBERSHIP_EXPIRY_TIME / 2) { const expiryTime = myPrevMembership.getMsUntilExpiry();
if (expiryTime !== undefined && expiryTime < MEMBERSHIP_EXPIRY_TIME / 2) {
// ...or if the expiry time needs bumping // ...or if the expiry time needs bumping
needsUpdate = true;
this.relativeExpiry! += MEMBERSHIP_EXPIRY_TIME; this.relativeExpiry! += MEMBERSHIP_EXPIRY_TIME;
} return true;
} }
return needsUpdate; return false;
} }
private makeNewMembership(deviceId: string): SessionMembershipData | {} {
// If we're joined, add our own
if (this.isJoined()) {
return this.makeMyMembership(deviceId);
}
return {};
}
/** /**
* Makes a new membership list given the old list alonng with this user's previous membership event * Makes a new membership list given the old list alonng with this user's previous membership event
* (if any) and this device's previous membership (if any) * (if any) and this device's previous membership (if any)
*/ */
private makeNewMemberships( private makeNewLegacyMemberships(
oldMemberships: CallMembershipData[], oldMemberships: CallMembershipData[],
localDeviceId: string,
myCallMemberEvent?: MatrixEvent, myCallMemberEvent?: MatrixEvent,
myPrevMembership?: CallMembership, myPrevMembership?: CallMembership,
): CallMembershipData[] { ): ExperimentalGroupCallRoomMemberState {
const localDeviceId = this.client.getDeviceId();
if (!localDeviceId) throw new Error("Local device ID is null!");
const filterExpired = (m: CallMembershipData): boolean => { const filterExpired = (m: CallMembershipData): boolean => {
let membershipObj; let membershipObj;
try { try {
@@ -704,10 +786,10 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
// If we're joined, add our own // If we're joined, add our own
if (this.isJoined()) { if (this.isJoined()) {
newMemberships.push(this.makeMyMembership(myPrevMembership)); newMemberships.push(this.makeMyMembershipLegacy(localDeviceId, myPrevMembership));
} }
return newMemberships; return { memberships: newMemberships };
} }
private triggerCallMembershipEventUpdate = async (): Promise<void> => { private triggerCallMembershipEventUpdate = async (): Promise<void> => {
@@ -742,13 +824,23 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
if (!localUserId || !localDeviceId) throw new Error("User ID or device ID was null!"); if (!localUserId || !localDeviceId) throw new Error("User ID or device ID was null!");
const myCallMemberEvent = roomState.getStateEvents(EventType.GroupCallMemberPrefix, localUserId) ?? undefined; const myCallMemberEvent = roomState.getStateEvents(EventType.GroupCallMemberPrefix, localUserId) ?? undefined;
const content = myCallMemberEvent?.getContent<Record<any, unknown>>() ?? {}; const content = myCallMemberEvent?.getContent() ?? {};
const memberships: CallMembershipData[] = Array.isArray(content["memberships"]) ? content["memberships"] : []; const legacy = "memberships" in content || this.useLegacyMemberEvents;
let newContent: {} | ExperimentalGroupCallRoomMemberState | SessionMembershipData = {};
if (legacy) {
let myPrevMembership: CallMembership | undefined;
// We know its CallMembershipDataLegacy
const memberships: CallMembershipDataLegacy[] = Array.isArray(content["memberships"])
? content["memberships"]
: [];
const myPrevMembershipData = memberships.find((m) => m.device_id === localDeviceId); const myPrevMembershipData = memberships.find((m) => m.device_id === localDeviceId);
let myPrevMembership;
try { try {
if (myCallMemberEvent && myPrevMembershipData && myPrevMembershipData.membershipID === this.membershipId) { if (
myCallMemberEvent &&
myPrevMembershipData &&
isLegacyCallMembershipData(myPrevMembershipData) &&
myPrevMembershipData.membershipID === this.membershipId
) {
myPrevMembership = new CallMembership(myCallMemberEvent, myPrevMembershipData); myPrevMembership = new CallMembership(myCallMemberEvent, myPrevMembershipData);
} }
} catch (e) { } catch (e) {
@@ -756,32 +848,30 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
// wasn't valid // wasn't valid
logger.warn("Our previous call membership was invalid - this shouldn't happen.", e); logger.warn("Our previous call membership was invalid - this shouldn't happen.", e);
} }
if (myPrevMembership) { if (myPrevMembership) {
logger.debug(`${myPrevMembership.getMsUntilExpiry()} until our membership expires`); logger.debug(`${myPrevMembership.getMsUntilExpiry()} until our membership expires`);
} }
if (!this.membershipEventNeedsUpdate(myPrevMembershipData, myPrevMembership)) { if (!this.membershipEventNeedsUpdate(myPrevMembershipData, myPrevMembership)) {
// nothing to do - reschedule the check again // nothing to do - reschedule the check again
this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, MEMBER_EVENT_CHECK_PERIOD); this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, MEMBER_EVENT_CHECK_PERIOD);
return; return;
} }
newContent = this.makeNewLegacyMemberships(memberships, localDeviceId, myCallMemberEvent, myPrevMembership);
const newContent = { } else {
memberships: this.makeNewMemberships(memberships, myCallMemberEvent, myPrevMembership), newContent = this.makeNewMembership(localDeviceId);
}; }
try { try {
await this.client.sendStateEvent( await this.client.sendStateEvent(
this.room.roomId, this.room.roomId,
EventType.GroupCallMemberPrefix, EventType.GroupCallMemberPrefix,
newContent, newContent,
localUserId, this.useLegacyMemberEvents ? localUserId : `${localUserId}_${localDeviceId}`,
); );
logger.info(`Sent updated call member event.`); logger.info(`Sent updated call member event.`);
// check periodically to see if we need to refresh our member event // check periodically to see if we need to refresh our member event
if (this.isJoined()) { if (this.isJoined() && legacy) {
this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, MEMBER_EVENT_CHECK_PERIOD); this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, MEMBER_EVENT_CHECK_PERIOD);
} }
} catch (e) { } catch (e) {

View File

@@ -73,9 +73,9 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
} }
this.roomSessions.clear(); this.roomSessions.clear();
this.client.removeListener(ClientEvent.Room, this.onRoom); this.client.off(ClientEvent.Room, this.onRoom);
this.client.removeListener(RoomEvent.Timeline, this.onTimeline); this.client.off(RoomEvent.Timeline, this.onTimeline);
this.client.removeListener(RoomStateEvent.Events, this.onRoomState); this.client.off(RoomStateEvent.Events, this.onRoomState);
} }
/** /**

View File

@@ -21,4 +21,5 @@ limitations under the License.
*/ */
export interface Focus { export interface Focus {
type: string; type: string;
[key: string]: unknown;
} }