Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion config/config.devenv.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"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,
"use_key_delay_ms": 1000,
"key_rotation_grace_period_ms": 10000
}
}
4 changes: 3 additions & 1 deletion config/config.sample.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"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,
"use_key_delay_ms": 1000,
"key_rotation_grace_period_ms": 10000
}
}
17 changes: 17 additions & 0 deletions src/config/ConfigOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,23 @@ export interface ConfigOptions {
* This is what goes into the m.rtc.member event expiry field and is typically set to a number of hours.
*/
membership_event_expiry_ms?: number;

/**
* The delay (in milliseconds) between sending a new encryption key and
* starting to encrypt media with it. This gives other participants time
* to receive the key before media is encrypted with it.
* SDK default: 1000
*/
use_key_delay_ms?: number;

/**
* The grace period (in milliseconds) after a key rotation during which
* new joiners can reuse the existing key instead of triggering another
* rotation. Higher values reduce redundant rotations when multiple
* participants join in quick succession.
* SDK default: 10000
*/
key_rotation_grace_period_ms?: number;
};
}

Expand Down
16 changes: 14 additions & 2 deletions src/e2ee/matrixKeyProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const logger = rootLogger.getChild("[MatrixKeyProvider]");

export class MatrixKeyProvider extends BaseKeyProvider {
private rtcSession?: MatrixRTCSession;
private joinedAt?: number;

public constructor() {
super({ ratchetWindowSize: 10, keyringSize: 256 });
Expand All @@ -30,6 +31,8 @@ export class MatrixKeyProvider extends BaseKeyProvider {
}

this.rtcSession = rtcSession;
this.joinedAt = Date.now();
logger.info(`setRTCSession called, recording join timestamp`);

this.rtcSession.on(
MatrixRTCSessionEvent.EncryptionKeyChanged,
Expand All @@ -47,21 +50,30 @@ export class MatrixKeyProvider extends BaseKeyProvider {
membershipParts: CallMembershipIdentityParts,
rtcBackendIdentity: string,
): void => {
const keyReceivedAt = Date.now();
const timeSinceJoin = this.joinedAt
? keyReceivedAt - this.joinedAt
: undefined;
logger.info(
`Key received ${timeSinceJoin !== undefined ? `${timeSinceJoin}ms after join` : "(no join timestamp)"} for ${membershipParts.userId}:${membershipParts.deviceId} index=${encryptionKeyIndex}`,
);

crypto.subtle
.importKey("raw", encryptionKey, "HKDF", false, [
"deriveBits",
"deriveKey",
])
.then(
(keyMaterial) => {
const importDuration = Date.now() - keyReceivedAt;
this.onSetEncryptionKey(
keyMaterial,
rtcBackendIdentity,
encryptionKeyIndex,
);

logger.debug(
`Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${rtcBackendIdentity} (before hash: ${membershipParts.userId}:${membershipParts.deviceId}) encryptionKeyIndex=${encryptionKeyIndex}`,
logger.info(
`Key imported in ${importDuration}ms and sent to livekit room=${this.rtcSession?.room.roomId} participantId=${rtcBackendIdentity} (before hash: ${membershipParts.userId}:${membershipParts.deviceId}) encryptionKeyIndex=${encryptionKeyIndex}`,
);
},
(e) => {
Expand Down
52 changes: 52 additions & 0 deletions src/e2ee/usePrewarmOlmSessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
Copyright 2026 New Vector Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

import { useEffect } from "react";
import { type MatrixClient, type Room } from "matrix-js-sdk";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";

const logger = rootLogger.getChild("[OlmPrewarm]");

/**
* Pre-warms Olm sessions with current call participants while the user is in
* the lobby. This avoids a `/keys/claim` round-trip when actually joining the
* call and distributing E2EE media encryption keys.
*
* Internally calls `CryptoApi.prepareToEncrypt(room)` which triggers
* `KeyClaimManager.ensureSessionsForUsers()` for all room members, establishing
* Olm sessions with any devices we haven't communicated with before.
*
* @param client - The Matrix client.
* @param room - The room the call is in.
* @param memberships - Current call memberships (used to trigger re-warming
* when new participants join while the user is still in the lobby).
* @param enabled - Pass `false` to disable (e.g. when the user has already
* joined the call or E2EE is not in use).
*/
export function usePrewarmOlmSessions(
client: MatrixClient,
room: Room,
memberships: CallMembership[],
enabled: boolean,
): void {
useEffect(() => {
if (!enabled) return;
if (memberships.length === 0) return;

const crypto = client.getCrypto();
if (!crypto) {
logger.debug("No crypto available, skipping Olm session pre-warming");
return;
}

logger.info(
`Pre-warming Olm sessions for room ${room.roomId} (${memberships.length} call members)`,
);
Comment on lines +47 to +49
Copy link
Copy Markdown
Contributor

@toger5 toger5 Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The log is wrong. Or at least misleading. we are preparing for all room members. not just the N call members with this right?

crypto.prepareToEncrypt(room);
}, [client, room, memberships, enabled]);
}
3 changes: 1 addition & 2 deletions src/livekit/MatrixAudioRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,9 @@ export function LivekitRoomAudioRenderer({
.filter((ref) => ref.publication.kind === Track.Kind.Audio)
// Only keep tracks from participants that are in the validIdentities list
.filter((ref) => {
if (ref.participant.isLocal) return false;
const isValid = validIdentities.includes(ref.participant.identity);
if (!isValid) {
// TODO make sure to also skip the warn logging for the local identity
// Log that there is an invalid identity, that means that someone is publishing audio that is not expected to be in the call.
prefixedLogger.warn(
Comment on lines 80 to 83
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we remove this?

`Audio track ${ref.participant.identity} from ${url} has no matching matrix call member`,
`current members: ${validIdentities.join()}`,
Expand Down
10 changes: 10 additions & 0 deletions src/room/GroupCallView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
useUrlParams,
} from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType";
import { usePrewarmOlmSessions } from "../e2ee/usePrewarmOlmSessions";
import { useAudioContext } from "../useAudioContext";
import {
callEventAudioSounds,
Expand Down Expand Up @@ -206,6 +207,15 @@ export const GroupCallView: FC<Props> = ({
};
}, [client, displayName, avatarUrl, roomName, room, roomAvatar, e2eeSystem]);

// Pre-warm Olm sessions with call participants while in the lobby so that
// E2EE key exchange is faster when the user actually joins.
usePrewarmOlmSessions(
client,
room,
memberships,
!joined && e2eeSystem.kind === E2eeType.PER_PARTICIPANT,
);

// Count each member only once, regardless of how many devices they use
const participantCount = useMemo(
() => new Set<string>(memberships.map((m) => m.userId!)).size,
Expand Down
45 changes: 43 additions & 2 deletions src/state/CallViewModel/localMember/HomeserverConnected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,17 @@ import {
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
import { fromEvent, startWith, map, tap, type Observable } from "rxjs";
import {
fromEvent,
startWith,
map,
tap,
switchMap,
timer,
of,
distinctUntilChanged,
type Observable,
} from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";

import { type ObservableScope } from "../../ObservableScope";
Expand Down Expand Up @@ -45,11 +55,28 @@ export function createHomeserverConnected$(
matrixRTCSession: NodeStyleEventEmitter &
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
): HomeserverConnected {
const syncing$ = (
const syncingRaw$ = (
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
).pipe(
startWith([client.getSyncState()]),
map(([state]) => state === SyncState.Syncing),
tap((v) => logger.info(`syncing$ (raw): ${v}`)),
);

// Sync errors are transient — the SDK auto-retries and /sync abort
// recoveries can take several seconds. Debounce the false→true transition
// with an 8s grace period so brief sync interruptions don't cascade.
const syncing$ = syncingRaw$.pipe(
switchMap((syncing) =>
syncing
? of(true)
: timer(8000).pipe(
map(() => false),
startWith(true),
),
),
distinctUntilChanged(),
tap((v) => logger.info(`syncing$ (debounced): ${v}`)),
);

const rtsSession$ = scope.behavior<Status>(
Expand All @@ -61,6 +88,7 @@ export function createHomeserverConnected$(

const membershipConnected$ = rtsSession$.pipe(
map((status) => status === Status.Connected),
tap((v) => logger.info(`membershipConnected$: ${v}`)),
);

// This is basically notProbablyLeft$
Expand All @@ -77,10 +105,23 @@ export function createHomeserverConnected$(
).pipe(
startWith(null),
map(() => matrixRTCSession.probablyLeft !== true),
tap((v) => logger.info(`certainlyConnected$ (notProbablyLeft): ${v}`)),
);

const combined$ = scope.behavior(
and$(syncing$, membershipConnected$, certainlyConnected$).pipe(
// Don't immediately report disconnection for brief hiccups.
// Stay "connected" during a 2s grace period so that momentary sync
// errors or delayed-leave-event timeouts don't flap the UI.
switchMap((connected) =>
connected
? of(true)
: timer(2000).pipe(
map(() => false),
startWith(true),
),
),
distinctUntilChanged(),
tap((connected) => {
logger.info(`Homeserver connected update: ${connected}`);
}),
Expand Down
17 changes: 15 additions & 2 deletions src/state/CallViewModel/localMember/LocalMember.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
startWith,
switchMap,
tap,
timer,
} from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { deepCompare } from "matrix-js-sdk/lib/utils";
Expand Down Expand Up @@ -238,6 +239,7 @@ export const createLocalMembership$ = ({

return connectionData.getConnectionForTransport(localTransport);
}),
distinctUntilChanged(),
tap((connection) => {
logger.info(
`Local connection updated: ${connection?.transport?.livekit_service_url}`,
Expand Down Expand Up @@ -393,6 +395,7 @@ export const createLocalMembership$ = ({

const localConnectionState$ = localConnection$.pipe(
switchMap((connection) => (connection ? connection.state$ : of(null))),
tap((state) => logger.debug(`localConnectionState$: ${state}`)),
);

const mediaState$: Behavior<LocalMemberMediaState> = scope.behavior(
Expand Down Expand Up @@ -488,11 +491,18 @@ export const createLocalMembership$ = ({

/**
* Whether we should tell the user that we're reconnecting to the call.
* Debounced so that brief (<1.5s) disconnections don't flash the UI.
*/
const reconnecting$ = scope.behavior(
matrixAndLivekitConnected$.pipe(
pairwise(),
map(([prev, current]) => prev === true && current === false),
switchMap((connected) =>
connected
? of(false)
: timer(1500).pipe(
map(() => true),
startWith(false),
),
),
),
false,
);
Expand Down Expand Up @@ -768,6 +778,9 @@ export function enterRTCSession(
matrixRtcSessionConfig?.delayed_leave_event_restart_local_timeout_ms,
networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms,
makeKeyDelay: matrixRtcSessionConfig?.wait_for_key_rotation_ms,
useKeyDelay: matrixRtcSessionConfig?.use_key_delay_ms,
keyRotationGracePeriodMs:
matrixRtcSessionConfig?.key_rotation_grace_period_ms,
membershipEventExpiryMs:
matrixRtcSessionConfig?.membership_event_expiry_ms,
useExperimentalToDeviceTransport: true,
Expand Down
Loading