Merge pull request #4242 from element-hq/delayed-leave-timings

Adapt delayed leave timings when delegation is available
This commit is contained in:
Robin
2026-09-09 08:19:58 +02:00
committed by GitHub
18 changed files with 570 additions and 527 deletions
-2
View File
@@ -9,8 +9,6 @@
"matrix_rtc_session": { "matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000, "wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000, "membership_event_expiry_ms": 180000000,
"delayed_leave_event_delay_ms": 18000,
"delayed_leave_event_restart_ms": 4000,
"network_error_retry_ms": 100 "network_error_retry_ms": 100
} }
} }
-2
View File
@@ -13,8 +13,6 @@
"matrix_rtc_session": { "matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000, "wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000, "membership_event_expiry_ms": 180000000,
"delayed_leave_event_delay_ms": 18000,
"delayed_leave_event_restart_ms": 4000,
"network_error_retry_ms": 100 "network_error_retry_ms": 100
} }
} }
-2
View File
@@ -9,8 +9,6 @@
"matrix_rtc_session": { "matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000, "wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000, "membership_event_expiry_ms": 180000000,
"delayed_leave_event_delay_ms": 18000,
"delayed_leave_event_restart_ms": 4000,
"network_error_retry_ms": 100 "network_error_retry_ms": 100
}, },
"posthog": { "posthog": {
-2
View File
@@ -9,8 +9,6 @@
"matrix_rtc_session": { "matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000, "wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000, "membership_event_expiry_ms": 180000000,
"delayed_leave_event_delay_ms": 18000,
"delayed_leave_event_restart_ms": 4000,
"network_error_retry_ms": 100 "network_error_retry_ms": 100
} }
} }
+55 -33
View File
@@ -24,6 +24,35 @@ export enum MatrixRTCMode {
Matrix_2_0 = "matrix_2_0", Matrix_2_0 = "matrix_2_0",
} }
export interface DelayedLeaveTimings {
/**
* The delay (in milliseconds) with which delayed leave events are sent.
*
* If the server receives no keep-alives from the client for any longer than
* this duration, it will send the leave event, automatically removing the
* user from the call.
*/
delay_ms?: number;
/**
* How frequently (in milliseconds) the client sends keep-alives to the server
* to restart the timer for a delayed leave event. Should be less than
* {@link DelayedLeaveTimings.delay_ms}.
*/
restart_ms?: number;
/**
* The time (in milliseconds) after which we consider a delayed event restart HTTP request to have failed.
* Setting this to a lower value will result in more frequent retries, but then we will also give up earlier.
*
* In the presence of network packet loss (hurting TCP connections), the custom delayedEventRestartLocalTimeoutMs
* helps by keeping more delayed event reset candidates in flight,
* improving the chances of a successful reset. (its is equivalent to the js-sdk `localTimeout` configuration,
* but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
*/
restart_timeout_ms?: number;
}
export interface ConfigOptions { export interface ConfigOptions {
/** /**
* The Posthog endpoint to which analytics data will be sent. * The Posthog endpoint to which analytics data will be sent.
@@ -184,29 +213,6 @@ export interface ConfigOptions {
*/ */
wait_for_key_rotation_ms?: number; wait_for_key_rotation_ms?: number;
/**
* The duration (in milliseconds) after the most recent keep-alive (delayed leave event restart)
* that the server waits before sending the leave MatrixRTC membership event.
*/
delayed_leave_event_delay_ms?: number;
/**
* The time (in milliseconds) after which we consider a delayed event restart http request to have failed.
* Setting this to a lower value will result in more frequent retries but also a higher chance of failiour.
*
* In the presence of network packet loss (hurting TCP connections), the custom delayedEventRestartLocalTimeoutMs
* helps by keeping more delayed event reset candidates in flight,
* improving the chances of a successful reset. (its is equivalent to the js-sdk `localTimeout` configuration,
* but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
*/
delayed_leave_event_restart_local_timeout_ms?: number;
/**
* The time interval (in milliseconds) at which the client sends membership keep-alive
* messages to the server by restarting the timer for the delayed leave event.
*/
delayed_leave_event_restart_ms?: number;
/** /**
* How long we wait before retrying after a network error on any of the requests. * How long we wait before retrying after a network error on any of the requests.
*/ */
@@ -231,9 +237,28 @@ export interface ConfigOptions {
* Defaults to the js-sdk default (undefined). Which means that rotation will always happen. * Defaults to the js-sdk default (undefined). Which means that rotation will always happen.
*/ */
key_rotation_participant_limit?: number; key_rotation_participant_limit?: number;
/**
* Timing options for delayed leave events, which are used to remove a user
* from a call when they lose connection.
*/
delayed_leave?: DelayedLeaveTimings;
/**
* Timing options for delayed leave events, in cases where the ability to
* send the event can be delegated to the SFU.
*
* We recommend setting {@link DelayedLeaveTimings.delay_ms} >>
* {@link sync_disconnect_grace_period_ms} here.
*/
delegated_delayed_leave?: DelayedLeaveTimings;
}; };
} }
export interface ResolvedDelayedLeaveTimings extends DelayedLeaveTimings {
delay_ms: number; // Required
}
// Overrides members from ConfigOptions that are always provided by the // Overrides members from ConfigOptions that are always provided by the
// default config and are therefore non-optional. // default config and are therefore non-optional.
export interface ResolvedConfigOptions extends ConfigOptions { export interface ResolvedConfigOptions extends ConfigOptions {
@@ -257,19 +282,15 @@ export interface ResolvedConfigOptions extends ConfigOptions {
> >
>; >;
}; };
matrix_rtc_session: { matrix_rtc_session: ConfigOptions["matrix_rtc_session"] & {
wait_for_key_rotation_ms?: number;
delayed_leave_event_delay_ms: number;
delayed_leave_event_restart_local_timeout_ms?: number;
delayed_leave_event_restart_ms?: number;
network_error_retry_ms: number; network_error_retry_ms: number;
membership_event_expiry_ms?: number; delayed_leave: ResolvedDelayedLeaveTimings;
key_rotation_participant_limit?: number; delegated_delayed_leave: ResolvedDelayedLeaveTimings;
}; };
} }
export const DEFAULT_CONFIG: ResolvedConfigOptions = { export const DEFAULT_CONFIG: ResolvedConfigOptions = {
sync_disconnect_grace_period_ms: 10000, sync_disconnect_grace_period_ms: 10_000,
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf", ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
media_quality: { media_quality: {
video_codec: "vp8", video_codec: "vp8",
@@ -285,7 +306,8 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
}, },
}, },
matrix_rtc_session: { matrix_rtc_session: {
delayed_leave_event_delay_ms: 10000, network_error_retry_ms: 1_000,
network_error_retry_ms: 1000, delayed_leave: { delay_ms: 18_000, restart_ms: 4_000 },
delegated_delayed_leave: { delay_ms: 3_600_000, restart_ms: 300_000 },
}, },
}; };
+4 -4
View File
@@ -148,7 +148,7 @@ describe("getSFUConfigWithOpenID", () => {
// Verify, that the request contains the expected delay parameters // Verify, that the request contains the expected delay parameters
if ( if (
body.delay_id === "mock_delay_id" && body.delay_id === "mock_delay_id" &&
body.delay_timeout === 10000 && body.delay_timeout === 3600000 &&
body.delay_cs_api_url === "https://homeserverserver.org/cs_api" body.delay_cs_api_url === "https://homeserverserver.org/cs_api"
) { ) {
return { return {
@@ -229,7 +229,7 @@ describe("getSFUConfigWithOpenID", () => {
expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token"); expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token");
expect(calls[0][1]).toStrictEqual({ expect(calls[0][1]).toStrictEqual({
// check if it uses correct delayID! // check if it uses correct delayID!
body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":10000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}', body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":3600000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -239,7 +239,7 @@ describe("getSFUConfigWithOpenID", () => {
expect(calls[1][0]).toStrictEqual("https://sfu.example.org/sfu/get"); expect(calls[1][0]).toStrictEqual("https://sfu.example.org/sfu/get");
expect(calls[1][1]).toStrictEqual({ expect(calls[1][1]).toStrictEqual({
body: '{"room":"!example_room_id","device_id":"DEVICE","delay_id":"mock_delay_id","delay_timeout":10000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}', body: '{"room":"!example_room_id","device_id":"DEVICE","delay_id":"mock_delay_id","delay_timeout":3600000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
@@ -284,7 +284,7 @@ describe("getSFUConfigWithOpenID", () => {
expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token"); expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token");
expect(calls[0][1]).toStrictEqual({ expect(calls[0][1]).toStrictEqual({
// check if it uses correct delayID! // check if it uses correct delayID!
body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":10000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}', body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":3600000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
+15 -19
View File
@@ -19,7 +19,7 @@ import {
} from "../utils/errors"; } from "../utils/errors";
import { doNetworkOperationWithRetry } from "../utils/matrix"; import { doNetworkOperationWithRetry } from "../utils/matrix";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { JwtEndpointVersion } from "../state/CallViewModel/localMember/LocalTransport"; import { MatrixRTCMode } from "../config/ConfigOptions";
/** /**
* Configuration and access tokens provided by the SFU on successful authentication. * Configuration and access tokens provided by the SFU on successful authentication.
@@ -80,11 +80,10 @@ export type OpenIDClientParts = Pick<
* @param serviceUrl The URL of the livekit SFU service * @param serviceUrl The URL of the livekit SFU service
* @param roomId The room id used in the jwt request. This is NOT the livekit_alias. The jwt service will provide the alias. It maps matrix room ids <-> Livekit aliases. * @param roomId The room id used in the jwt request. This is NOT the livekit_alias. The jwt service will provide the alias. It maps matrix room ids <-> Livekit aliases.
* @param opts Additional options to modify which endpoint with which data will be used to acquire the jwt token. * @param opts Additional options to modify which endpoint with which data will be used to acquire the jwt token.
* @param opts.forceJwtEndpoint This will use the old jwt endpoint which will create the rtc backend identity based on string concatenation * @param opts.matrixRTCMode Determines which version of the JWT endpoint to use, which affects whether the
* instead of a hash. * RTC backend identity is based on string concatenation (legacy) or a hash (Matrix 2.0).
* This function by default uses whatever is possible with the current jwt service installed next to the SFU. * This function by default uses whatever is possible with the current jwt service installed next to the SFU.
* For remote connections this does not matter, since we will not publish there we can rely on the newest option. * For remote connections this does not matter, since we will not publish there we can rely on the newest option.
* For our own connection we can only use the hashed version if we also send the new matrix2.0 sticky events.
* @param opts.delayEndpointBaseUrl The URL of the matrix homeserver. * @param opts.delayEndpointBaseUrl The URL of the matrix homeserver.
* @param opts.delayId The delay id used for the jwt service to manage. * @param opts.delayId The delay id used for the jwt service to manage.
* @param logger optional logger. * @param logger optional logger.
@@ -97,7 +96,7 @@ export async function getSFUConfigWithOpenID(
serviceUrl: string, serviceUrl: string,
roomId: string, roomId: string,
opts?: { opts?: {
forceJwtEndpoint?: JwtEndpointVersion; matrixRTCMode?: MatrixRTCMode;
delayEndpointBaseUrl?: string; delayEndpointBaseUrl?: string;
delayId?: string; delayId?: string;
}, },
@@ -116,19 +115,18 @@ export async function getSFUConfigWithOpenID(
logger?.debug("Got openID token", openIdToken); logger?.debug("Got openID token", openIdToken);
let sfuConfig: { url: string; jwt: string } | undefined; let sfuConfig: { url: string; jwt: string } | undefined;
const tryBothJwtEndpoints = opts?.forceJwtEndpoint === undefined; // This is for SFUs where we do not publish. const tryBothJwtEndpoints = opts?.matrixRTCMode === undefined; // This is for SFUs where we do not publish.
const forceMatrix2Jwt = const forceMatrix2Jwt = opts?.matrixRTCMode === MatrixRTCMode.Matrix_2_0;
opts?.forceJwtEndpoint === JwtEndpointVersion.Matrix_2_0;
// We want to start using the new endpoint (with optional delay delegation) // We want to start using the new endpoint (with optional delay delegation)
// if we can use both or if we are forced to use the new one. // if we can use both or if we are forced to use the new one.
if (tryBothJwtEndpoints || forceMatrix2Jwt) { if (tryBothJwtEndpoints || forceMatrix2Jwt) {
try { try {
logger?.info( logger?.info(
`Trying to get JWT with delegation for focus ${serviceUrl}...`, `Trying to get JWT via default endpoint for focus ${serviceUrl}...`,
); );
const sfuConfig = await getLiveKitJWTWithDelayDelegation( const sfuConfig = await getLiveKitJWT(
membership, membership,
serviceUrl, serviceUrl,
roomId, roomId,
@@ -154,7 +152,7 @@ export async function getSFUConfigWithOpenID(
logger?.info( logger?.info(
`Trying to get JWT with legacy endpoint for focus ${serviceUrl}...`, `Trying to get JWT with legacy endpoint for focus ${serviceUrl}...`,
); );
sfuConfig = await getLiveKitJWT( sfuConfig = await getLiveKitJWTLegacy(
membership.deviceId, membership.deviceId,
serviceUrl, serviceUrl,
roomId, roomId,
@@ -188,7 +186,7 @@ function extractFullConfigFromToken(sfuConfig: {
}; };
} }
async function getLiveKitJWT( async function getLiveKitJWTLegacy(
deviceId: string, deviceId: string,
livekitServiceURL: string, livekitServiceURL: string,
matrixRoomId: string, matrixRoomId: string,
@@ -204,11 +202,10 @@ async function getLiveKitJWT(
let bodyDalayParts: IDelayParams = {}; let bodyDalayParts: IDelayParams = {};
// Also check for empty string // Also check for empty string
if (delayId && delayEndpointBaseUrl) { if (delayId && delayEndpointBaseUrl) {
const delayTimeoutMs =
Config.get().matrix_rtc_session?.delayed_leave_event_delay_ms;
bodyDalayParts = { bodyDalayParts = {
delay_id: delayId, delay_id: delayId,
delay_timeout: delayTimeoutMs, delay_timeout:
Config.get().matrix_rtc_session.delegated_delayed_leave.delay_ms,
delay_cs_api_url: delayEndpointBaseUrl, delay_cs_api_url: delayEndpointBaseUrl,
}; };
} }
@@ -264,7 +261,7 @@ class NotSupportedError extends Error {
} }
} }
export async function getLiveKitJWTWithDelayDelegation( export async function getLiveKitJWT(
membership: CallMembershipIdentityParts, membership: CallMembershipIdentityParts,
livekitServiceURL: string, livekitServiceURL: string,
matrixRoomId: string, matrixRoomId: string,
@@ -288,11 +285,10 @@ export async function getLiveKitJWTWithDelayDelegation(
let bodyDalayParts = {}; let bodyDalayParts = {};
// Also check for empty string // Also check for empty string
if (delayId && delayEndpointBaseUrl) { if (delayId && delayEndpointBaseUrl) {
const delayTimeoutMs =
Config.get().matrix_rtc_session?.delayed_leave_event_delay_ms;
bodyDalayParts = { bodyDalayParts = {
delay_id: delayId, delay_id: delayId,
delay_timeout: delayTimeoutMs, delay_timeout:
Config.get().matrix_rtc_session.delegated_delayed_leave.delay_ms,
delay_cs_api_url: delayEndpointBaseUrl, delay_cs_api_url: delayEndpointBaseUrl,
}; };
} }
+3 -1
View File
@@ -135,7 +135,9 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
encryptionSystem: props.e2eeSystem, encryptionSystem: props.e2eeSystem,
autoLeaveWhenOthersLeft, autoLeaveWhenOthersLeft,
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring", waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
matrixRTCMode$: matrixRTCModeSetting.value$, // We merely sample the current mode here, so the user would need to
// manually rejoin to switch to a different one.
matrixRTCMode: matrixRTCModeSetting.value$.value,
}, },
reactionsReader.raisedHands$, reactionsReader.raisedHands$,
reactionsReader.reactions$, reactionsReader.reactions$,
+42 -55
View File
@@ -54,7 +54,6 @@ import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembership
import { import {
createToggle$, createToggle$,
filterBehavior, filterBehavior,
generateItem,
generateItems, generateItems,
pauseWhen, pauseWhen,
} from "../../utils/observable"; } from "../../utils/observable";
@@ -64,7 +63,10 @@ import {
showReactions, showReactions,
} from "../../settings/settings"; } from "../../settings/settings";
import { Config } from "../../config/Config"; import { Config } from "../../config/Config";
import { MatrixRTCMode } from "../../config/ConfigOptions"; import {
MatrixRTCMode,
type ResolvedDelayedLeaveTimings,
} from "../../config/ConfigOptions";
import { isFirefox, platform } from "../../Platform"; import { isFirefox, platform } from "../../Platform";
import { setPipEnabled$ } from "../../controls"; import { setPipEnabled$ } from "../../controls";
import { TileStore } from "../TileStore"; import { TileStore } from "../TileStore";
@@ -110,7 +112,6 @@ import {
} from "./localMember/LocalMember.ts"; } from "./localMember/LocalMember.ts";
import { import {
createLocalTransport$, createLocalTransport$,
JwtEndpointVersion,
type LocalTransport, type LocalTransport,
} from "./localMember/LocalTransport.ts"; } from "./localMember/LocalTransport.ts";
import { import {
@@ -188,7 +189,7 @@ export interface CallViewModelOptions {
/** Optional value overriding the connection factory, for testing purposes. */ /** Optional value overriding the connection factory, for testing purposes. */
connectionFactory?: ConnectionFactory; connectionFactory?: ConnectionFactory;
/** The version & compatibility mode of MatrixRTC that we should use. */ /** The version & compatibility mode of MatrixRTC that we should use. */
matrixRTCMode$?: Behavior<MatrixRTCMode>; matrixRTCMode?: MatrixRTCMode;
/** Optional behavior overriding for the screensharing, for testing */ /** Optional behavior overriding for the screensharing, for testing */
toggleScreensharing?: () => void; toggleScreensharing?: () => void;
} }
@@ -450,10 +451,8 @@ export function createCallViewModel$(
const configMatrixRTCMode = Config.get().matrix_rtc_mode as const configMatrixRTCMode = Config.get().matrix_rtc_mode as
| MatrixRTCMode | MatrixRTCMode
| undefined; | undefined;
const matrixRTCMode$ = const matrixRTCMode =
configMatrixRTCMode !== undefined configMatrixRTCMode ?? options.matrixRTCMode ?? MatrixRTCMode.Compatibility;
? constant(configMatrixRTCMode)
: (options.matrixRTCMode$ ?? constant(MatrixRTCMode.Compatibility));
// Each hbar seperates a block of input variables required for the CallViewModel to function. // Each hbar seperates a block of input variables required for the CallViewModel to function.
// The outputs of this block is written under the hbar. // The outputs of this block is written under the hbar.
@@ -487,38 +486,16 @@ export function createCallViewModel$(
memberId: uuidv4(), memberId: uuidv4(),
}; };
const localTransport$ = scope.behavior( const localTransport =
matrixRTCMode$.pipe( options.localTransport ??
generateItem( createLocalTransport$({
"CallViewModel localTransport$", scope: scope,
// Re-create LocalTransport whenever the mode changes memberships$: memberships$,
(mode) => ({ keys: [mode], data: undefined }), ownMembershipIdentity,
(scope, _data$, mode) => client,
options.localTransport ?? roomId: matrixRoom.roomId,
createLocalTransport$({ matrixRTCMode,
scope: scope, });
memberships$: memberships$,
ownMembershipIdentity,
client,
delayId$: scope.behavior(
(
fromEvent(
matrixRTCSession,
MembershipManagerEvent.DelayIdChanged,
// The type of reemitted event includes the original emitted as the second arg.
) as Observable<[string | undefined, IMembershipManager]>
).pipe(map(([delayId]) => delayId ?? null)),
matrixRTCSession.delayId ?? null,
),
roomId: matrixRoom.roomId,
forceJwtEndpoint:
mode === MatrixRTCMode.Matrix_2_0
? JwtEndpointVersion.Matrix_2_0
: JwtEndpointVersion.Legacy,
}),
),
),
);
const connectionFactory = const connectionFactory =
options.connectionFactory ?? options.connectionFactory ??
@@ -536,8 +513,7 @@ export function createCallViewModel$(
scope: scope, scope: scope,
connectionFactory: connectionFactory, connectionFactory: connectionFactory,
localTransport$: scope.behavior( localTransport$: scope.behavior(
localTransport$.pipe( localTransport.active$.pipe(
switchMap((t) => t.active$),
catchError((e: unknown) => { catchError((e: unknown) => {
logger.info( logger.info(
"could not pass local transport to createConnectionManager$. localTransport$ threw an error", "could not pass local transport to createConnectionManager$. localTransport$ threw an error",
@@ -562,16 +538,6 @@ export function createCallViewModel$(
localUser: { userId, deviceId }, localUser: { userId, deviceId },
}); });
const connectOptions$ = scope.behavior(
matrixRTCMode$.pipe(
map((mode) => ({
encryptMedia: livekitKeyProvider !== undefined,
// TODO. This might need to get called again on each change of matrixRTCMode...
matrixRTCMode: mode,
})),
),
);
const localMembership = createLocalMembership$({ const localMembership = createLocalMembership$({
scope, scope,
homeserverConnected: createHomeserverConnected$( homeserverConnected: createHomeserverConnected$(
@@ -580,12 +546,19 @@ export function createCallViewModel$(
matrixRTCSession, matrixRTCSession,
), ),
muteStates, muteStates,
joinMatrixRTC: (transport: LivekitTransportConfig) => { joinMatrixRTC: (
transport: LivekitTransportConfig,
delayedLeaveTimings: ResolvedDelayedLeaveTimings,
) => {
return enterRTCSession( return enterRTCSession(
matrixRTCSession, matrixRTCSession,
ownMembershipIdentity, ownMembershipIdentity,
transport, transport,
connectOptions$.value, {
encryptMedia: livekitKeyProvider !== undefined,
matrixRTCMode,
delayedLeaveTimings,
},
); );
}, },
createPublisherFactory: (connection: Connection) => { createPublisherFactory: (connection: Connection) => {
@@ -600,9 +573,23 @@ export function createCallViewModel$(
); );
}, },
connectionManager, connectionManager,
client,
matrixRTCSession, matrixRTCSession,
localTransport$, localTransport,
roomId: matrixRoom.roomId, roomId: matrixRoom.roomId,
baseUrl: client.baseUrl,
ownMembershipIdentity,
delayId$: scope.behavior(
(
fromEvent(
matrixRTCSession,
MembershipManagerEvent.DelayIdChanged,
// The type of reemitted event includes the original emitted as the second arg.
) as Observable<[string | undefined, IMembershipManager]>
).pipe(map(([delayId]) => delayId ?? null)),
matrixRTCSession.delayId ?? null,
),
matrixRTCMode,
logger: logger.getChild(`[${Date.now()}]`), logger: logger.getChild(`[${Date.now()}]`),
}); });
@@ -238,7 +238,7 @@ export function withCallViewModel(mode: MatrixRTCMode) {
); );
}, },
}, },
matrixRTCMode$: constant(mode), matrixRTCMode: mode,
...options, ...options,
}, },
raisedHands$, raisedHands$,
@@ -19,13 +19,18 @@ import {
beforeAll, beforeAll,
afterAll, afterAll,
beforeEach, beforeEach,
afterEach,
} from "vitest"; } from "vitest";
import { BehaviorSubject, map, of } from "rxjs"; import { BehaviorSubject, map, of } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { type LocalParticipant, type LocalTrack } from "livekit-client"; import { type LocalParticipant, type LocalTrack } from "livekit-client";
import fetchMock from "fetch-mock";
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics"; import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics";
import { MatrixRTCMode } from "../../../config/ConfigOptions"; import {
MatrixRTCMode,
type ResolvedDelayedLeaveTimings,
} from "../../../config/ConfigOptions";
import { type HomeserverDisconnectReason } from "./HomeserverConnected"; import { type HomeserverDisconnectReason } from "./HomeserverConnected";
import { import {
flushPromises, flushPromises,
@@ -35,6 +40,7 @@ import {
mockMuteStates, mockMuteStates,
withTestScheduler, withTestScheduler,
ownMemberMock, ownMemberMock,
testScope,
} from "../../../utils/test"; } from "../../../utils/test";
import { import {
TransportState, TransportState,
@@ -58,6 +64,7 @@ import {
type LocalTransport, type LocalTransport,
type LocalTransportWithSFUConfig, type LocalTransportWithSFUConfig,
} from "./LocalTransport"; } from "./LocalTransport";
import * as openIDSFU from "../../../livekit/openIDSFU";
initializeWidget(); initializeWidget();
@@ -95,112 +102,112 @@ describe("watchScreenShareToggle", () => {
}); });
}); });
describe("LocalMembership", () => { const timings: ResolvedDelayedLeaveTimings = {
describe("enterRTCSession", () => { delay_ms: 10000,
it("It joins the correct Session", () => { restart_ms: 4000,
mockConfig({ restart_timeout_ms: 1000,
livekit: { livekit_service_url: "http://my-default-service-url.com" }, };
});
const mockedSession = vi.mocked({ const delegatedTimings: ResolvedDelayedLeaveTimings = {
room: { delay_ms: timings.delay_ms * 10,
roomId: "roomId", restart_ms: timings.restart_ms! * 10,
client: { restart_timeout_ms: timings.restart_timeout_ms! * 10,
getDomain: vi.fn().mockReturnValue("example.org"), };
getOpenIdToken: vi.fn().mockResolvedValue({
access_token: "ACCCESS_TOKEN",
token_type: "Bearer",
matrix_server_name: "localhost",
expires_in: 10000,
}),
},
},
memberships: [],
joinRTCSession: vi.fn(),
}) as unknown as MatrixRTCSession;
enterRTCSession( const mockedClient = {
mockedSession, getDomain: vi.fn().mockReturnValue("example.org"),
ownMemberMock, getDeviceId: vi.fn().mockReturnValue("AAAA"),
{ getOpenIdToken: vi.fn().mockResolvedValue({
livekit_alias: "roomId", access_token: "ACCCESS_TOKEN",
livekit_service_url: "http://my-livekit-service-url.com", token_type: "Bearer",
type: "livekit", matrix_server_name: "localhost",
}, expires_in: 10000,
{ }),
encryptMedia: true, };
matrixRTCMode: MATRIX_RTC_MODE,
},
);
expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith( describe("enterRTCSession", () => {
{ const transport: LivekitTransportConfig = {
deviceId: "DEVICE", livekit_alias: "roomId",
memberId: "@alice:example.org:DEVICE", livekit_service_url: "http://my-livekit-service-url.com",
userId: "@alice:example.org", type: "livekit",
}, };
[],
{
livekit_alias: "roomId",
livekit_service_url: "http://my-livekit-service-url.com",
type: "livekit",
},
expect.objectContaining({ manageMediaKeys: true }),
);
});
it("passes keyRotationParticipantLimit from config to joinRTCSession", () => { const options = {
mockConfig({ encryptMedia: true,
livekit: { livekit_service_url: "http://my-default-service-url.com" }, matrixRTCMode: MATRIX_RTC_MODE,
matrix_rtc_session: { delayedLeaveTimings: timings,
delayed_leave_event_delay_ms: 0, };
network_error_retry_ms: 0,
key_rotation_participant_limit: 50,
},
});
const mockedSession = vi.mocked({ const mockedSession = vi.mocked({
room: { room: {
roomId: "roomId", roomId: "roomId",
client: { client: mockedClient,
getDomain: vi.fn().mockReturnValue("example.org"), },
getOpenIdToken: vi.fn().mockResolvedValue({ memberships: [],
access_token: "ACCCESS_TOKEN", joinRTCSession: vi.fn(),
token_type: "Bearer", }) as unknown as MatrixRTCSession;
matrix_server_name: "localhost",
expires_in: 10000,
}),
},
},
memberships: [],
joinRTCSession: vi.fn(),
}) as unknown as MatrixRTCSession;
enterRTCSession( beforeEach(() =>
mockedSession, mockConfig({
ownMemberMock, livekit: { livekit_service_url: "http://my-default-service-url.com" },
{ }),
livekit_alias: "roomId", );
livekit_service_url: "http://my-livekit-service-url.com",
type: "livekit",
},
{
encryptMedia: true,
matrixRTCMode: MATRIX_RTC_MODE,
},
);
expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith( it("It joins the correct Session", () => {
expect.any(Object), enterRTCSession(mockedSession, ownMemberMock, transport, options);
[],
expect.any(Object), expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith(
expect.objectContaining({ {
keyRotationParticipantLimit: 50, deviceId: "DEVICE",
}), memberId: "@alice:example.org:DEVICE",
); userId: "@alice:example.org",
}); },
[],
transport,
expect.objectContaining({ manageMediaKeys: true }),
);
}); });
it("passes keyRotationParticipantLimit from config to joinRTCSession", () => {
mockConfig({
livekit: { livekit_service_url: "http://my-default-service-url.com" },
matrix_rtc_session: {
network_error_retry_ms: 0,
key_rotation_participant_limit: 50,
delayed_leave: timings,
delegated_delayed_leave: timings,
},
});
enterRTCSession(mockedSession, ownMemberMock, transport, options);
expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith(
expect.any(Object),
[],
expect.any(Object),
expect.objectContaining({
keyRotationParticipantLimit: 50,
}),
);
});
it("uses the specified delayed leave timings", () => {
enterRTCSession(mockedSession, ownMemberMock, transport, options);
expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.objectContaining({
delayedLeaveEventRestartMs: timings.restart_ms,
delayedLeaveEventDelayMs: timings.delay_ms,
delayedLeaveEventRestartLocalTimeoutMs: timings.restart_timeout_ms,
}),
);
});
});
describe("LocalMembership", () => {
const defaultCreateLocalMemberValues = { const defaultCreateLocalMemberValues = {
options: constant({ options: constant({
encryptMedia: false, encryptMedia: false,
@@ -226,8 +233,30 @@ describe("LocalMembership", () => {
rtsSession$: constant(RTCMemberStatus.Connected), rtsSession$: constant(RTCMemberStatus.Connected),
}, },
roomId: "!test-room-id:example.org", roomId: "!test-room-id:example.org",
baseUrl: "https://matrix.example.org",
ownMembershipIdentity: ownMemberMock,
client: mockedClient,
delayId$: constant(null),
matrixRTCMode: MATRIX_RTC_MODE,
}; };
beforeEach(() => {
mockConfig({
livekit: { livekit_service_url: "http://my-default-service-url.com" },
matrix_rtc_session: {
network_error_retry_ms: 1000,
delayed_leave: timings,
delegated_delayed_leave: delegatedTimings,
},
});
fetchMock.catch(404);
});
afterEach(async () => {
void (await fetchMock.flush());
fetchMock.reset();
});
it("throws error on missing RTC config error", () => { it("throws error on missing RTC config error", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => { withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
const localTransport$ = scope.behavior<null | LivekitTransportConfig>( const localTransport$ = scope.behavior<null | LivekitTransportConfig>(
@@ -254,9 +283,8 @@ describe("LocalMembership", () => {
scope, scope,
...defaultCreateLocalMemberValues, ...defaultCreateLocalMemberValues,
connectionManager: mockConnectionManager, connectionManager: mockConnectionManager,
localTransport$: behavior("a", { a: aLocalTransport }), localTransport: aLocalTransport,
}); });
localMembership.requestJoinAndPublish();
expectObservable(localMembership.localMemberState$).toBe("ne", { expectObservable(localMembership.localMemberState$).toBe("ne", {
n: TransportState.Waiting, n: TransportState.Waiting,
@@ -299,9 +327,8 @@ describe("LocalMembership", () => {
scope, scope,
...defaultCreateLocalMemberValues, ...defaultCreateLocalMemberValues,
connectionManager: mockConnectionManager, connectionManager: mockConnectionManager,
localTransport$: behavior("a", { a: aLocalTransport }), localTransport: aLocalTransport,
}); });
localMembership.requestJoinAndPublish();
expectObservable(localMembership.localMemberState$).toBe("n-e", { expectObservable(localMembership.localMemberState$).toBe("n-e", {
n: TransportState.Waiting, n: TransportState.Waiting,
@@ -317,8 +344,8 @@ describe("LocalMembership", () => {
const scope = new ObservableScope(); const scope = new ObservableScope();
const aLocalTransport: LocalTransport = { const aLocalTransport: LocalTransport = {
advertised$: new BehaviorSubject(aTransport), advertised$: constant(aTransport),
active$: new BehaviorSubject(aTransportWithSFUConfig), active$: constant(aTransportWithSFUConfig),
}; };
const mockConnectionManager = { const mockConnectionManager = {
@@ -336,7 +363,7 @@ describe("LocalMembership", () => {
leaveRoomSession: vi.fn(), leaveRoomSession: vi.fn(),
}, },
connectionManager: mockConnectionManager, connectionManager: mockConnectionManager,
localTransport$: new BehaviorSubject(aLocalTransport), localTransport: aLocalTransport,
}); });
const expextedLog = const expextedLog =
"'not connected yet' while updating the call intent (this is expected on startup)"; "'not connected yet' while updating the call intent (this is expected on startup)";
@@ -397,12 +424,90 @@ describe("LocalMembership", () => {
livekitRoom: mockLivekitRoom({}), livekitRoom: mockLivekitRoom({}),
} as unknown as Connection; } as unknown as Connection;
const authCallSpy = vi
.spyOn(openIDSFU, "getSFUConfigWithOpenID")
.mockImplementation(() => mockedClient.getOpenIdToken());
afterEach(() => authCallSpy.mockClear());
it.each([
["no", null, timings],
[
"homeserver",
"https://matrix.example.org/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave",
delegatedTimings,
],
["transport", "/a/delegate_delayed_leave", delegatedTimings],
])(
"joins session with %s delegation support",
async (_serviceName, delegationUrl, delayedLeaveTimings) => {
const scope = testScope();
const joinMatrixRTC = vi.fn();
const delayId$ = new BehaviorSubject<string | null>(null);
if (delegationUrl !== null)
fetchMock.post(delegationUrl, () => ({ status: 401, body: {} }));
const localMembership = createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionManager: {
connectionManagerData$: constant(
new Epoch(new ConnectionManagerData()),
),
},
joinMatrixRTC,
localTransport: {
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
},
delayId$,
});
localMembership.requestJoinAndPublish();
void (await fetchMock.flush());
await flushPromises();
// Joins with timings appropriate for the level of delegation support
expect(joinMatrixRTC).toHaveBeenCalledWith(
aTransport,
delayedLeaveTimings,
);
expect(authCallSpy).not.toHaveBeenCalled();
delayId$.next("leave1");
await flushPromises();
if (delegationUrl === null) {
expect(authCallSpy).not.toHaveBeenCalled();
} else {
// Delegation is supported in this test case, so go on to check that
// LocalMember actually performs delegation
const expectDelegation = (delayId: string) =>
expect(authCallSpy).toHaveBeenLastCalledWith(
mockedClient,
ownMemberMock,
"a",
"!test-room-id:example.org",
{
matrixRTCMode: MATRIX_RTC_MODE,
delayEndpointBaseUrl: "https://matrix.example.org",
delayId,
},
expect.anything(),
);
expectDelegation("leave1");
delayId$.next("leave2"); // Can change delegated leaves
await flushPromises();
expectDelegation("leave2");
}
},
);
it("recreates publisher if new connection is used, always unpublish and end tracks", async () => { it("recreates publisher if new connection is used, always unpublish and end tracks", async () => {
const scope = new ObservableScope(); const scope = new ObservableScope();
const activeTransport$ = new BehaviorSubject(aTransportWithSFUConfig); const activeTransport$ = new BehaviorSubject(aTransportWithSFUConfig);
const aLocalTransport: LocalTransport = { const aLocalTransport: LocalTransport = {
advertised$: new BehaviorSubject(aTransport), advertised$: constant(aTransport),
active$: activeTransport$, active$: activeTransport$,
}; };
@@ -440,7 +545,7 @@ describe("LocalMembership", () => {
connectionManager: { connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)), connectionManagerData$: constant(new Epoch(connectionManagerData)),
}, },
localTransport$: new BehaviorSubject(aLocalTransport), localTransport: aLocalTransport,
}); });
await flushPromises(); await flushPromises();
activeTransport$.next({ activeTransport$.next({
@@ -471,7 +576,7 @@ describe("LocalMembership", () => {
const publishers: Publisher[] = []; const publishers: Publisher[] = [];
const tracks$ = new BehaviorSubject<LocalTrack[]>([]); const tracks$ = new BehaviorSubject<LocalTrack[]>([]);
const publishing$ = new BehaviorSubject<boolean>(false); const publishing$ = constant<boolean>(false);
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation( defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
() => { () => {
const p = { const p = {
@@ -495,8 +600,8 @@ describe("LocalMembership", () => {
>; >;
const aLocalTransport: LocalTransport = { const aLocalTransport: LocalTransport = {
advertised$: new BehaviorSubject(aTransport), advertised$: constant(aTransport),
active$: new BehaviorSubject(aTransportWithSFUConfig), active$: constant(aTransportWithSFUConfig),
}; };
const connectionManagerData = new ConnectionManagerData(); const connectionManagerData = new ConnectionManagerData();
@@ -508,7 +613,7 @@ describe("LocalMembership", () => {
connectionManager: { connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)), connectionManagerData$: constant(new Epoch(connectionManagerData)),
}, },
localTransport$: new BehaviorSubject(aLocalTransport), localTransport: aLocalTransport,
}); });
await flushPromises(); await flushPromises();
expect(publisherFactory).toHaveBeenCalledOnce(); expect(publisherFactory).toHaveBeenCalledOnce();
@@ -536,7 +641,7 @@ describe("LocalMembership", () => {
new BehaviorSubject<null | LocalTransportWithSFUConfig>(null); new BehaviorSubject<null | LocalTransportWithSFUConfig>(null);
const aLocalTransport: LocalTransport = { const aLocalTransport: LocalTransport = {
advertised$: new BehaviorSubject(aTransport), advertised$: constant(aTransport),
active$: activeTransport$, active$: activeTransport$,
}; };
@@ -579,7 +684,7 @@ describe("LocalMembership", () => {
connectionManager: { connectionManager: {
connectionManagerData$, connectionManagerData$,
}, },
localTransport$: new BehaviorSubject(aLocalTransport), localTransport: aLocalTransport,
}); });
await flushPromises(); await flushPromises();
@@ -714,10 +819,10 @@ describe("LocalMembership", () => {
connectionManager: { connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)), connectionManagerData$: constant(new Epoch(connectionManagerData)),
}, },
localTransport$: new BehaviorSubject({ localTransport: {
advertised$: new BehaviorSubject(aTransport), advertised$: constant(aTransport),
active$: new BehaviorSubject(aTransportWithSFUConfig), active$: constant(aTransportWithSFUConfig),
}), },
}); });
await flushPromises(); await flushPromises();
@@ -754,10 +859,10 @@ describe("LocalMembership", () => {
connectionManager: { connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)), connectionManagerData$: constant(new Epoch(connectionManagerData)),
}, },
localTransport$: new BehaviorSubject({ localTransport: {
advertised$: new BehaviorSubject(aTransport), advertised$: constant(aTransport),
active$: new BehaviorSubject(aTransportWithSFUConfig), active$: constant(aTransportWithSFUConfig),
}), },
}); });
await flushPromises(); await flushPromises();
@@ -796,18 +901,19 @@ describe("LocalMembership", () => {
scope, scope,
...defaultCreateLocalMemberValues, ...defaultCreateLocalMemberValues,
homeserverConnected: { homeserverConnected: {
combined$: new BehaviorSubject< combined$: constant<[boolean, HomeserverDisconnectReason | null]>([
[boolean, HomeserverDisconnectReason | null] true,
>([true, null]), null,
]),
rtsSession$: constant(RTCMemberStatus.Connected), rtsSession$: constant(RTCMemberStatus.Connected),
}, },
connectionManager: { connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)), connectionManagerData$: constant(new Epoch(connectionManagerData)),
}, },
localTransport$: new BehaviorSubject({ localTransport: {
advertised$: new BehaviorSubject(aTransport), advertised$: constant(aTransport),
active$: new BehaviorSubject(aTransportWithSFUConfig), active$: constant(aTransportWithSFUConfig),
}), },
}); });
await flushPromises(); await flushPromises();
@@ -848,10 +954,10 @@ describe("LocalMembership", () => {
connectionManager: { connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)), connectionManagerData$: constant(new Epoch(connectionManagerData)),
}, },
localTransport$: new BehaviorSubject({ localTransport: {
advertised$: new BehaviorSubject(aTransport), advertised$: constant(aTransport),
active$: new BehaviorSubject(aTransportWithSFUConfig), active$: constant(aTransportWithSFUConfig),
}), },
}); });
await flushPromises(); await flushPromises();
@@ -917,10 +1023,10 @@ describe("LocalMembership", () => {
connectionManager: { connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)), connectionManagerData$: constant(new Epoch(connectionManagerData)),
}, },
localTransport$: new BehaviorSubject({ localTransport: {
advertised$: new BehaviorSubject(aTransport), advertised$: constant(aTransport),
active$: new BehaviorSubject(aTransportWithSFUConfig), active$: constant(aTransportWithSFUConfig),
}), },
}); });
return { scope, localMembership }; return { scope, localMembership };
}; };
@@ -15,6 +15,7 @@ import {
MediaDeviceFailure, MediaDeviceFailure,
} from "livekit-client"; } from "livekit-client";
import { observeParticipantEvents } from "@livekit/components-core"; import { observeParticipantEvents } from "@livekit/components-core";
import { type MatrixClient } from "matrix-js-sdk";
import { import {
Status as RTCSessionStatus, Status as RTCSessionStatus,
type LivekitTransport, type LivekitTransport,
@@ -62,7 +63,10 @@ import {
screenShareCodec, screenShareCodec,
parseResolution, parseResolution,
} from "../../../settings/settings.ts"; } from "../../../settings/settings.ts";
import { MatrixRTCMode } from "../../../config/ConfigOptions.ts"; import {
MatrixRTCMode,
type ResolvedDelayedLeaveTimings,
} from "../../../config/ConfigOptions.ts";
import { Config } from "../../../config/Config.ts"; import { Config } from "../../../config/Config.ts";
import { import {
ConnectionState, ConnectionState,
@@ -72,6 +76,8 @@ import {
import { type HomeserverConnected } from "./HomeserverConnected.ts"; import { type HomeserverConnected } from "./HomeserverConnected.ts";
import { type LocalTransport } from "./LocalTransport.ts"; import { type LocalTransport } from "./LocalTransport.ts";
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts"; import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
import { or$ } from "../../../utils/observable.ts";
import { getSFUConfigWithOpenID } from "../../../livekit/openIDSFU.ts";
export enum TransportState { export enum TransportState {
/** Not even a transport is available to the LocalMembership */ /** Not even a transport is available to the LocalMembership */
@@ -133,14 +139,22 @@ interface Props {
muteStates: MuteStates; muteStates: MuteStates;
connectionManager: IConnectionManager; connectionManager: IConnectionManager;
createPublisherFactory: (connection: Connection) => Publisher; createPublisherFactory: (connection: Connection) => Publisher;
joinMatrixRTC: (transport: LivekitTransportConfig) => void; joinMatrixRTC: (
transport: LivekitTransportConfig,
delayedLeaveTimings: ResolvedDelayedLeaveTimings,
) => void;
homeserverConnected: HomeserverConnected; homeserverConnected: HomeserverConnected;
roomId: string; roomId: string;
localTransport$: Behavior<LocalTransport>; ownMembershipIdentity: CallMembershipIdentityParts;
localTransport: LocalTransport;
client: Pick<MatrixClient, "getDeviceId" | "getOpenIdToken">;
matrixRTCSession: Pick< matrixRTCSession: Pick<
MatrixRTCSession, MatrixRTCSession,
"updateCallIntent" | "leaveRoomSession" "updateCallIntent" | "leaveRoomSession"
>; >;
baseUrl: string;
delayId$: Behavior<string | null>;
matrixRTCMode: MatrixRTCMode;
logger: Logger; logger: Logger;
} }
@@ -155,10 +169,12 @@ interface Props {
* @param props.createPublisherFactory Factory to create a publisher once we have a connection. * @param props.createPublisherFactory Factory to create a publisher once we have a connection.
* @param props.joinMatrixRTC Callback to join the matrix RTC session once we have a transport. * @param props.joinMatrixRTC Callback to join the matrix RTC session once we have a transport.
* @param props.homeserverConnected The homeserver connected state. * @param props.homeserverConnected The homeserver connected state.
* @param props.localTransport$ The transport to advertise in our membership. * @param props.localTransport The transport to advertise in our membership.
* @param props.logger The logger to use. * @param props.logger The logger to use.
* @param props.muteStates The mute states for video and audio. * @param props.muteStates The mute states for video and audio.
* @param props.matrixRTCSession The matrix RTC session to join. * @param props.matrixRTCSession The matrix RTC session to join.
* @param props.baseUrl Base URL of the homeserver.
* @param props.delayId$ ID of the delayed leave event to delegate to the SFU.
* @param props.roomId The room ID used as the call identifier in analytics events. * @param props.roomId The room ID used as the call identifier in analytics events.
* @returns * @returns
* - publisher: The handle to create tracks and publish them to the room. * - publisher: The handle to create tracks and publish them to the room.
@@ -170,14 +186,19 @@ interface Props {
export const createLocalMembership$ = ({ export const createLocalMembership$ = ({
scope, scope,
connectionManager, connectionManager,
localTransport$, localTransport,
homeserverConnected, homeserverConnected,
createPublisherFactory, createPublisherFactory,
joinMatrixRTC, joinMatrixRTC,
logger: parentLogger, logger: parentLogger,
muteStates, muteStates,
client,
matrixRTCSession, matrixRTCSession,
baseUrl,
roomId, roomId,
ownMembershipIdentity,
delayId$,
matrixRTCMode,
}: Props): { }: Props): {
/** /**
* This request to start audio and video tracks. * This request to start audio and video tracks.
@@ -236,25 +257,69 @@ export const createLocalMembership$ = ({
return of(null); return of(null);
}; };
// This is the transport that we will advertise in our membership. async function checkDelegationSupport(
const advertisedTransport$ = localTransport$.pipe( endpointUrl: string,
switchMap((lt) => lt.advertised$), serviceName: string,
): Promise<boolean> {
logger.info(`Checking whether ${serviceName} supports delegation…`);
try {
// Bluntly hit the endpoint without auth to check for a 404. Unfortunately
// we can't wrap this in a retry loop, as many servers don't just disable
// delegation support, but in fact are from a time before the endpoint
// existed at all, therefore we can hit CORS errors which would just gum
// up the retry loop. (May be revisited after Matrix 2.0.)
const res = await fetch(endpointUrl, { method: "POST" });
if (res.status === 404) {
logger.warn(`${serviceName} does not support delegation`);
return false;
} else {
logger.info(`${serviceName} supports delegation`);
return true;
}
} catch (e) {
logger.warn(
`Failed to determine whether ${serviceName} supports delegation, assuming no support`,
e,
);
return false;
}
}
const homeserverSupportsDelegation = checkDelegationSupport(
baseUrl +
"/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave",
"homeserver",
);
// The transport that we will advertise in our membership, paired with info as
// to whether delayed event delegation is supported
const joinParams$ = localTransport.advertised$.pipe(
catchError(handleTransportError), catchError(handleTransportError),
distinctUntilChanged(areLivekitTransportsEqual), distinctUntilChanged(areLivekitTransportsEqual),
switchMap((transport) => {
if (transport === null) return of(null);
const transportSupportsDelegation = checkDelegationSupport(
transport.livekit_service_url + "/delegate_delayed_leave",
`transport ${transport.livekit_service_url}`,
);
return or$(
from(homeserverSupportsDelegation),
from(transportSupportsDelegation),
).pipe(
map((delegationSupported) => ({ transport, delegationSupported })),
startWith(null),
);
}),
); );
// Unwrap the local transport and set the state of the LocalMembership to error in case the transport is an error. // Unwrap the local transport and set the state of the LocalMembership to error in case the transport is an error.
const activeTransport$ = scope.behavior( const activeTransport$ = scope.behavior(
localTransport$.pipe( combineLatest([localTransport.active$, localTransport.advertised$]).pipe(
switchMap((lt) => { map(([active, advertised]) => {
return combineLatest([lt.active$, lt.advertised$]).pipe( // Our policy is to not publish to another transport if our prefered transport is miss-configured
map(([active, advertised]) => { if (advertised == null) return null;
// Our policy is to not publish to another transport if our prefered transport is miss-configured
if (advertised == null) return null;
return active?.transport ?? null; return active?.transport ?? null;
}),
);
}), }),
catchError(handleTransportError), catchError(handleTransportError),
distinctUntilChanged(areLivekitTransportsEqual), distinctUntilChanged(areLivekitTransportsEqual),
@@ -617,16 +682,20 @@ export const createLocalMembership$ = ({
// Keep matrix rtc session in sync with advertisedTransport$, connectRequested$ // Keep matrix rtc session in sync with advertisedTransport$, connectRequested$
scope.reconcile( scope.reconcile(
scope.behavior( scope.behavior(combineLatest([joinParams$, joinAndPublishRequested$])),
combineLatest([advertisedTransport$, joinAndPublishRequested$]), async ([joinParams, shouldConnect]) => {
), if (!joinParams) return;
async ([transport, shouldConnect]) => {
if (!transport) return;
// if shouldConnect=false we will do the disconnect as the cleanup from the previous reconcile iteration. // if shouldConnect=false we will do the disconnect as the cleanup from the previous reconcile iteration.
if (!shouldConnect) return; if (!shouldConnect) return;
const sessionConfig = Config.get().matrix_rtc_session;
try { try {
joinMatrixRTC(transport); joinMatrixRTC(
joinParams.transport,
joinParams.delegationSupported
? sessionConfig.delegated_delayed_leave
: sessionConfig.delayed_leave,
);
} catch (error) { } catch (error) {
logger.error("Error entering RTC session", error); logger.error("Error entering RTC session", error);
if (error instanceof Error) if (error instanceof Error)
@@ -653,6 +722,34 @@ export const createLocalMembership$ = ({
), ),
); );
// Delegate delayed leaves to the SFU
scope.reconcile(
scope.behavior(combineLatest([joinParams$, delayId$])),
async ([joinParams, delayId]) => {
if (joinParams?.delegationSupported && delayId !== null) {
try {
// This will technically cause the service to issue a new JWT token,
// but it's safe to discard. We're only interested in triggering
// delegation.
await getSFUConfigWithOpenID(
client,
ownMembershipIdentity,
joinParams.transport.livekit_service_url,
roomId,
{ matrixRTCMode, delayEndpointBaseUrl: baseUrl, delayId },
logger,
);
} catch (e) {
// TODO: Surface this to the user as a service interruption?
logger.error(
`Failed to delegate leave to ${joinParams.transport.livekit_service_url}`,
e,
);
}
}
},
);
// Pause upstream of all local media tracks when we're disconnected from // Pause upstream of all local media tracks when we're disconnected from
// MatrixRTC, because it can be an unpleasant surprise for the app to say // MatrixRTC, because it can be an unpleasant surprise for the app to say
// 'reconnecting' and yet still be transmitting your media to others. // 'reconnecting' and yet still be transmitting your media to others.
@@ -864,6 +961,7 @@ export function observeSharingScreen$(p: Participant): Observable<boolean> {
interface EnterRTCSessionOptions { interface EnterRTCSessionOptions {
encryptMedia: boolean; encryptMedia: boolean;
matrixRTCMode: MatrixRTCMode; matrixRTCMode: MatrixRTCMode;
delayedLeaveTimings: ResolvedDelayedLeaveTimings;
} }
/** /**
@@ -876,6 +974,7 @@ interface EnterRTCSessionOptions {
* @param rtcSession - The MatrixRTCSession to join. * @param rtcSession - The MatrixRTCSession to join.
* @param ownMembershipIdentity - Options for entering the RTC session. * @param ownMembershipIdentity - Options for entering the RTC session.
* @param transport - The LivekitTransport to use for this session. * @param transport - The LivekitTransport to use for this session.
* @param delayedLeaveTimings - The preferred timings for delayed leave events.
* @param options - `encryptMedia`: Whether to encrypt media `matrixRTCMode`: The Matrix RTC mode to use. * @param options - `encryptMedia`: Whether to encrypt media `matrixRTCMode`: The Matrix RTC mode to use.
* @throws If the widget could not send ElementWidgetActions.JoinCall action. * @throws If the widget could not send ElementWidgetActions.JoinCall action.
*/ */
@@ -884,9 +983,8 @@ export function enterRTCSession(
rtcSession: MatrixRTCSession, rtcSession: MatrixRTCSession,
ownMembershipIdentity: CallMembershipIdentityParts, ownMembershipIdentity: CallMembershipIdentityParts,
transport: LivekitTransportConfig, transport: LivekitTransportConfig,
options: EnterRTCSessionOptions, { encryptMedia, matrixRTCMode, delayedLeaveTimings }: EnterRTCSessionOptions,
): void { ): void {
const { encryptMedia, matrixRTCMode } = options;
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date()); PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId); PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
@@ -894,7 +992,11 @@ export function enterRTCSession(
// have started tracking by the time calls start getting created. // have started tracking by the time calls start getting created.
// groupCallOTelMembership?.onJoinCall(); // groupCallOTelMembership?.onJoinCall();
const { matrix_rtc_session: matrixRtcSessionConfig } = Config.get(); const {
sync_disconnect_grace_period_ms: gracePeriod,
matrix_rtc_session: sessionConfig,
} = Config.get();
const retryInterval = sessionConfig.network_error_retry_ms;
const { sendNotificationType: notificationType, callIntent } = getUrlParams(); const { sendNotificationType: notificationType, callIntent } = getUrlParams();
const multiSFU = const multiSFU =
matrixRTCMode === MatrixRTCMode.Compatibility || matrixRTCMode === MatrixRTCMode.Compatibility ||
@@ -912,16 +1014,11 @@ export function enterRTCSession(
}; };
} }
// Calculates `maximumNetworkErrorRetryCount`. The connection is failed if EITHER: // Set maximumNetworkErrorRetryCount such that we will consider the client
// - The /sync loop is unresponsive for > `gracePeriod` ms, or // disconnected as soon as either it fails to sync for longer than the grace
// - A delayed leave event is emitted (after `leaveDelay` ms period). // period, or it is likely that a delayed leave event has been sent.
// Note: Use leaveDelay >> gracePeriod for delegated leave events.
const gracePeriod = Config.get().sync_disconnect_grace_period_ms;
const leaveDelay = matrixRtcSessionConfig?.delayed_leave_event_delay_ms;
const retryInterval = matrixRtcSessionConfig?.network_error_retry_ms;
// Math.min is used to account for the respective worst case: /sync not available or leave event emitted. // Math.min is used to account for the respective worst case: /sync not available or leave event emitted.
const maxWaitTime = Math.min(gracePeriod, leaveDelay); const maxWaitTime = Math.min(gracePeriod, delayedLeaveTimings.delay_ms);
const maximumNetworkErrorRetryCount = const maximumNetworkErrorRetryCount =
Math.ceil(maxWaitTime / retryInterval) + 1; Math.ceil(maxWaitTime / retryInterval) + 1;
@@ -936,18 +1033,14 @@ export function enterRTCSession(
notificationType, notificationType,
callIntent, callIntent,
manageMediaKeys: encryptMedia, manageMediaKeys: encryptMedia,
delayedLeaveEventRestartMs: delayedLeaveEventRestartMs: delayedLeaveTimings.restart_ms,
matrixRtcSessionConfig?.delayed_leave_event_restart_ms, delayedLeaveEventDelayMs: delayedLeaveTimings.delay_ms,
delayedLeaveEventDelayMs:
matrixRtcSessionConfig?.delayed_leave_event_delay_ms,
delayedLeaveEventRestartLocalTimeoutMs: delayedLeaveEventRestartLocalTimeoutMs:
matrixRtcSessionConfig?.delayed_leave_event_restart_local_timeout_ms, delayedLeaveTimings.restart_timeout_ms,
networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms, networkErrorRetryMs: sessionConfig.network_error_retry_ms,
makeKeyDelay: matrixRtcSessionConfig?.wait_for_key_rotation_ms, makeKeyDelay: sessionConfig.wait_for_key_rotation_ms,
membershipEventExpiryMs: membershipEventExpiryMs: sessionConfig.membership_event_expiry_ms,
matrixRtcSessionConfig?.membership_event_expiry_ms, keyRotationParticipantLimit: sessionConfig.key_rotation_participant_limit,
keyRotationParticipantLimit:
matrixRtcSessionConfig?.key_rotation_participant_limit,
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0, unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
maximumNetworkErrorRetryCount: maximumNetworkErrorRetryCount, maximumNetworkErrorRetryCount: maximumNetworkErrorRetryCount,
}, },
@@ -14,11 +14,8 @@ import {
type MockedObject, type MockedObject,
vi, vi,
} from "vitest"; } from "vitest";
import { import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
type CallMembership, import { lastValueFrom } from "rxjs";
type LivekitTransportConfig,
} from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, filter, lastValueFrom } from "rxjs";
import fetchMock from "fetch-mock"; import fetchMock from "fetch-mock";
import { import {
@@ -27,11 +24,7 @@ import {
ownMemberMock, ownMemberMock,
testScope, testScope,
} from "../../../utils/test"; } from "../../../utils/test";
import { import { createLocalTransport$ } from "./LocalTransport";
createLocalTransport$,
JwtEndpointVersion,
type LocalTransportWithSFUConfig,
} from "./LocalTransport";
import { constant } from "../../Behavior"; import { constant } from "../../Behavior";
import { Epoch, ObservableScope } from "../../ObservableScope"; import { Epoch, ObservableScope } from "../../ObservableScope";
import { import {
@@ -41,6 +34,7 @@ import {
import * as openIDSFU from "../../../livekit/openIDSFU"; import * as openIDSFU from "../../../livekit/openIDSFU";
import { customLivekitUrl } from "../../../settings/settings"; import { customLivekitUrl } from "../../../settings/settings";
import { testJWTToken } from "../../../utils/test-fixtures"; import { testJWTToken } from "../../../utils/test-fixtures";
import { MatrixRTCMode } from "../../../config/ConfigOptions";
describe("LocalTransport", () => { describe("LocalTransport", () => {
const openIdResponse: openIDSFU.SFUConfig = { const openIdResponse: openIDSFU.SFUConfig = {
@@ -61,14 +55,12 @@ describe("LocalTransport", () => {
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]), _unstable_getRTCTransports: async () => Promise.resolve([]),
getDomain: () => "example.org", getDomain: () => "example.org",
baseUrl: "example.org",
// These won't be called in this error path but satisfy the type // These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(), getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(), getDeviceId: vi.fn(),
}, },
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
forceJwtEndpoint: JwtEndpointVersion.Legacy, matrixRTCMode: MatrixRTCMode.Compatibility,
delayId$: constant("delay_id_mock"),
}); });
await flushPromises(); await flushPromises();
@@ -100,7 +92,6 @@ describe("LocalTransport", () => {
roomId: "!example_room_id", roomId: "!example_room_id",
memberships$: constant(new Epoch<CallMembership[]>([])), memberships$: constant(new Epoch<CallMembership[]>([])),
client: { client: {
baseUrl: "https://example.org",
getDomain: () => "example.org", getDomain: () => "example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]), _unstable_getRTCTransports: async () => Promise.resolve([]),
@@ -108,8 +99,7 @@ describe("LocalTransport", () => {
getDeviceId: vi.fn(), getDeviceId: vi.fn(),
}, },
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
forceJwtEndpoint: JwtEndpointVersion.Legacy, matrixRTCMode: MatrixRTCMode.Compatibility,
delayId$: constant("delay_id_mock"),
}); });
active$.subscribe( active$.subscribe(
(o) => observations.push(o), (o) => observations.push(o),
@@ -147,11 +137,9 @@ describe("LocalTransport", () => {
getDomain: () => "example.org", getDomain: () => "example.org",
getOpenIdToken: vi.fn(), getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(), getDeviceId: vi.fn(),
baseUrl: "https://example.org",
}, },
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
forceJwtEndpoint: JwtEndpointVersion.Legacy, matrixRTCMode: MatrixRTCMode.Compatibility,
delayId$: constant("delay_id_mock"),
}); });
openIdResolver.resolve?.({ openIdResolver.resolve?.({
@@ -194,11 +182,9 @@ describe("LocalTransport", () => {
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
scope: testScope(), scope: testScope(),
roomId: "!example_room_id", roomId: "!example_room_id",
forceJwtEndpoint: JwtEndpointVersion.Legacy, matrixRTCMode: MatrixRTCMode.Compatibility,
delayId$: constant(null),
memberships$: constant(new Epoch<CallMembership[]>([])), memberships$: constant(new Epoch<CallMembership[]>([])),
client: { client: {
baseUrl: "https://example.org",
getDomain: vi.fn().mockReturnValue("example.org"), getDomain: vi.fn().mockReturnValue("example.org"),
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]), _unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
@@ -306,12 +292,10 @@ describe("LocalTransport", () => {
scope: testScope(), scope: testScope(),
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
roomId: "!example_room_id", roomId: "!example_room_id",
forceJwtEndpoint: JwtEndpointVersion.Legacy, matrixRTCMode: MatrixRTCMode.Compatibility,
delayId$: constant(null),
memberships$: constant(new Epoch<CallMembership[]>([])), memberships$: constant(new Epoch<CallMembership[]>([])),
client: { client: {
getDomain: () => "example.org", getDomain: () => "example.org",
baseUrl: "https://example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]), _unstable_getRTCTransports: async () => Promise.resolve([]),
// These won't be called in this error path but satisfy the type // These won't be called in this error path but satisfy the type
@@ -329,84 +313,4 @@ describe("LocalTransport", () => {
); );
}); });
}); });
it("should not update advertised/active transport on delayID changes, but delay Id delegation should be called", async () => {
// For simplicity, we'll just use the config livekit
customLivekitUrl.setValue("https://lk.example.org");
const authCallSpy = vi
.spyOn(openIDSFU, "getSFUConfigWithOpenID")
.mockResolvedValue(openIdResponse);
const delayId$ = new BehaviorSubject<string | null>(null);
const { advertised$, active$ } = createLocalTransport$({
scope: testScope(),
ownMembershipIdentity: ownMemberMock,
roomId: "!example_room_id",
// We want multi-sdu
forceJwtEndpoint: JwtEndpointVersion.Legacy,
delayId$: delayId$,
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
getDomain: () => "example.org",
baseUrl: "https://example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
});
const advertisedValues: LivekitTransportConfig[] = [];
const activeValues: LocalTransportWithSFUConfig[] = [];
advertised$
.pipe(filter((v) => v !== null))
.subscribe((t) => advertisedValues.push(t));
active$
.pipe(filter((v) => v !== null))
.subscribe((t) => activeValues.push(t));
await flushPromises();
// we have now an active and an advertised
expect(advertisedValues.length).toEqual(1);
expect(activeValues.length).toEqual(1);
expect(advertisedValues[0]!.livekit_service_url).toEqual(
"https://lk.example.org",
);
expect(activeValues[0]!.transport.livekit_service_url).toEqual(
"https://lk.example.org",
);
expect(authCallSpy).toHaveBeenCalledTimes(2);
// Now emits 3 new delays id
delayId$.next("delay_id_1");
await flushPromises();
delayId$.next("delay_id_2");
await flushPromises();
delayId$.next("delay_id_3");
await flushPromises();
// No new emissions should've happened, it is the same transport.
expect(advertisedValues.length).toEqual(1);
expect(activeValues.length).toEqual(1);
// Still we should have updated the delayID to auth
expect(authCallSpy).toHaveBeenCalledTimes(
4 * 2 /* 2 calls for each delayId ?? why */,
);
expect(authCallSpy).toHaveBeenLastCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.objectContaining({
delayId: "delay_id_3",
}),
expect.anything(),
);
});
}); });
@@ -10,14 +10,7 @@ import {
type LivekitTransportConfig, type LivekitTransportConfig,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
import { import { distinctUntilChanged, from, map, of, switchMap } from "rxjs";
combineLatest,
distinctUntilChanged,
from,
map,
of,
switchMap,
} from "rxjs";
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger"; import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
@@ -37,6 +30,7 @@ import {
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts"; import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
import { customLivekitUrl } from "../../../settings/settings.ts"; import { customLivekitUrl } from "../../../settings/settings.ts";
import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts"; import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts";
import { type MatrixRTCMode } from "../../../config/ConfigOptions.ts";
/* /*
* It figures out “which LiveKit focus URL/alias the local user should use,” * It figures out “which LiveKit focus URL/alias the local user should use,”
@@ -46,20 +40,11 @@ interface Props {
scope: ObservableScope; scope: ObservableScope;
ownMembershipIdentity: CallMembershipIdentityParts; ownMembershipIdentity: CallMembershipIdentityParts;
memberships$: Behavior<Epoch<CallMembership[]>>; memberships$: Behavior<Epoch<CallMembership[]>>;
client: Pick< client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports"> &
MatrixClient,
"getDomain" | "baseUrl" | "_unstable_getRTCTransports"
> &
OpenIDClientParts; OpenIDClientParts;
// Used by the jwt service to create the livekit room and compute the livekit alias. // Used by the jwt service to create the livekit room and compute the livekit alias.
roomId: string; roomId: string;
forceJwtEndpoint: JwtEndpointVersion; matrixRTCMode: MatrixRTCMode;
delayId$: Behavior<string | null>;
}
export enum JwtEndpointVersion {
Legacy = "legacy",
Matrix_2_0 = "matrix_2_0",
} }
// TODO livekit_alias-cleanup // TODO livekit_alias-cleanup
@@ -122,8 +107,7 @@ export const createLocalTransport$ = ({
ownMembershipIdentity, ownMembershipIdentity,
client, client,
roomId, roomId,
forceJwtEndpoint, matrixRTCMode,
delayId$,
}: Props): LocalTransport => { }: Props): LocalTransport => {
const logger = rootLogger.getChild("[LocalTransport]"); const logger = rootLogger.getChild("[LocalTransport]");
@@ -138,40 +122,36 @@ export const createLocalTransport$ = ({
transportDiscovery.discoverPreferredTransport(), transportDiscovery.discoverPreferredTransport(),
); );
const preferredConfig$ = customLivekitUrl.value$ const preferredConfig$ = customLivekitUrl.value$.pipe(
.pipe( switchMap((customUrl) => {
switchMap((customUrl) => { if (customUrl) {
if (customUrl) { return of({
return of({ type: "livekit",
type: "livekit", livekit_service_url: customUrl,
livekit_service_url: customUrl, } as LivekitTransportConfig);
} as LivekitTransportConfig); } else {
} else { return discoveredTransport$;
return discoveredTransport$; }
} }),
}), map((config) => {
) if (!config) {
.pipe( // Bubbled up from the preferredConfig$ observable.
map((config) => { throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
if (!config) { }
// Bubbled up from the preferredConfig$ observable. return config;
throw new MatrixRTCTransportMissingError(client.getDomain() ?? ""); }),
} distinctUntilChanged(areLivekitTransportsEqual),
return config; );
}),
distinctUntilChanged(areLivekitTransportsEqual),
);
const preferredTransport$ = combineLatest([preferredConfig$, delayId$]).pipe( const preferredTransport$ = preferredConfig$.pipe(
switchMap(async ([transport, delayId]) => { switchMap(async (transport) => {
try { try {
return await doOpenIdAndJWTFromUrl( return await doOpenIdAndJWTFromUrl(
transport, transport,
forceJwtEndpoint, matrixRTCMode,
ownMembershipIdentity, ownMembershipIdentity,
roomId, roomId,
client, client,
delayId ?? undefined,
logger, logger,
); );
} catch (e) { } catch (e) {
@@ -193,21 +173,7 @@ export const createLocalTransport$ = ({
), ),
null, null,
), ),
active$: scope.behavior( active$: scope.behavior(preferredTransport$, null),
preferredTransport$.pipe(
// XXX: WORK AROUND due to a reconnection glitch.
// To remove when we have a proper way to refresh the delegation event ID without refreshing
// the whole credentials.
// We deliberately hide any changes to the SFU config because we
// do not want the app to reconnect whenever the JWT
// token changes due to us delegating a new delayed event. The
// initial SFU config for the transport is all the app needs.
distinctUntilChanged((prev, next) =>
areLivekitTransportsEqual(prev.transport, next.transport),
),
),
null,
),
}; };
}; };
@@ -219,25 +185,19 @@ export const createLocalTransport$ = ({
* use we don't want to risk any issues by re-using a token. * use we don't want to risk any issues by re-using a token.
* *
* @param transport The transport to authenticate with. * @param transport The transport to authenticate with.
* @param forceJwtEndpoint Whether to force the JWT endpoint to be used. * @param matrixRTCMode Whether to force the JWT endpoint to be used.
* @param membership The identity of the local member. * @param membership The identity of the local member.
* @param roomId The room ID to use for the JWT. * @param roomId The room ID to use for the JWT.
* @param client The client to use for the OpenID token. * @param client The client to use for the OpenID token.
* @param delayId The delayId to use for the JWT.
* *
* @throws FailToGetOpenIdToken, NoMatrix2AuthorizationService * @throws FailToGetOpenIdToken, NoMatrix2AuthorizationService
*/ */
async function doOpenIdAndJWTFromUrl( async function doOpenIdAndJWTFromUrl(
transport: LivekitTransportConfig, transport: LivekitTransportConfig,
forceJwtEndpoint: JwtEndpointVersion, matrixRTCMode: MatrixRTCMode,
membership: CallMembershipIdentityParts, membership: CallMembershipIdentityParts,
roomId: string, roomId: string,
client: Pick< client: Pick<MatrixClient, "_unstable_getRTCTransports"> & OpenIDClientParts,
MatrixClient,
"getDomain" | "baseUrl" | "_unstable_getRTCTransports"
> &
OpenIDClientParts,
delayId?: string,
logger?: Logger, logger?: Logger,
): Promise<LocalTransportWithSFUConfig> { ): Promise<LocalTransportWithSFUConfig> {
const sfuConfig = await getSFUConfigWithOpenID( const sfuConfig = await getSFUConfigWithOpenID(
@@ -245,11 +205,7 @@ async function doOpenIdAndJWTFromUrl(
membership, membership,
transport.livekit_service_url, transport.livekit_service_url,
roomId, roomId,
{ { matrixRTCMode },
forceJwtEndpoint: forceJwtEndpoint,
delayEndpointBaseUrl: client.baseUrl,
delayId,
},
logger, logger,
); );
return { return {
+22 -1
View File
@@ -9,9 +9,30 @@ import { expect, test } from "vitest";
import { type Observable, of, Subject, switchMap } from "rxjs"; import { type Observable, of, Subject, switchMap } from "rxjs";
import { withTestScheduler } from "./test"; import { withTestScheduler } from "./test";
import { filterBehavior, generateItems, pauseWhen } from "./observable"; import { or$, filterBehavior, generateItems, pauseWhen } from "./observable";
import { type Behavior } from "../state/Behavior"; import { type Behavior } from "../state/Behavior";
const yesNo = {
y: true,
n: false,
};
test("or$", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const input1Marbles = "ny--n--";
const input2Marbles = "n-y--n-";
const input3Marbles = "n--y--n";
const outputMarbles = "nyyyyyn";
expectObservable(
or$(
behavior(input1Marbles, yesNo),
behavior(input2Marbles, yesNo),
behavior(input3Marbles, yesNo),
),
).toBe(outputMarbles, yesNo);
});
});
test("pauseWhen", () => { test("pauseWhen", () => {
withTestScheduler(({ behavior, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const inputMarbles = " abcdefgh-i-jk-"; const inputMarbles = " abcdefgh-i-jk-";
+4 -38
View File
@@ -114,13 +114,11 @@ export function getValue<T>(state$: Observable<T>): T {
} }
/** /**
* Creates an Observable that has a value of true whenever all its inputs are * Creates an Observable that has a value of true whenever some of its inputs
* true. * are true.
*
* @public
*/ */
export function and$(...inputs: Observable<boolean>[]): Observable<boolean> { export function or$(...inputs: Observable<boolean>[]): Observable<boolean> {
return combineLatest(inputs, (...flags) => flags.every((flag) => flag)); return combineLatest(inputs, (...flags) => flags.some((flag) => flag));
} }
/** /**
@@ -229,38 +227,6 @@ export function filterBehavior<T, S extends T>(
); );
} }
/**
* Maps a changing input value to an item whose lifetime is tied to a certain
* computed key. The item may capture some dynamic data from the input.
*/
export function generateItem<
Input,
Keys extends [unknown, ...unknown[]],
Data,
Item,
>(
name: string,
generator: (input: Input) => { keys: readonly [...Keys]; data: Data },
factory: (
scope: ObservableScope,
data$: Behavior<Data>,
...keys: Keys
) => Item,
): OperatorFunction<Input, Item> {
return (input$) =>
input$.pipe(
generateItemsInternal(
name,
function* (input) {
yield generator(input);
},
factory,
(items) => items,
),
map(([item]) => item),
);
}
function generateItemsInternal< function generateItemsInternal<
Input, Input,
Keys extends [unknown, ...unknown[]], Keys extends [unknown, ...unknown[]],
+1 -1
View File
@@ -174,7 +174,7 @@ export function getBasicCallViewModelEnvironment(
setE2EEEnabled: async () => Promise.resolve(), setE2EEEnabled: async () => Promise.resolve(),
}), }),
connectionState$: constant(ConnectionState.Connected), connectionState$: constant(ConnectionState.Connected),
matrixRTCMode$: constant(MatrixRTCMode.Compatibility), matrixRTCMode: MatrixRTCMode.Compatibility,
...callViewModelOptions, ...callViewModelOptions,
}, },
handRaisedSubject$, handRaisedSubject$,
-2
View File
@@ -27,8 +27,6 @@ export default defineConfig((env) =>
data: { data: {
matrix_rtc_session: { matrix_rtc_session: {
wait_for_key_rotation_ms: 5000, wait_for_key_rotation_ms: 5000,
delayed_leave_event_restart_ms: 4000,
delayed_leave_event_delay_ms: 18000,
}, },
}, },
}, },