1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-11-26 17:03:12 +03:00

Add call intent to RTC call notifications (#5010)

* Add media hint specifier

* Refactor to use m.call.intent and to apply to membership

* lint

* Add a mechanism to get the consensus of a call.

* Update tests

* Expose option to update the call intent.

* Better docs

* Add tests

* lint
This commit is contained in:
Will Hunt
2025-09-25 10:02:35 +01:00
committed by GitHub
parent a08a2737e1
commit 41d70d0b5d
7 changed files with 262 additions and 27 deletions

View File

@@ -222,6 +222,27 @@ describe("MatrixRTCSession", () => {
}); });
}); });
describe("getConsensusCallIntent", () => {
it.each([
[undefined, undefined, undefined],
["audio", undefined, "audio"],
[undefined, "audio", "audio"],
["audio", "audio", "audio"],
["audio", "video", undefined],
])("gets correct consensus for %s + %s = %s", (intentA, intentB, result) => {
jest.useFakeTimers();
jest.setSystemTime(4000);
const mockRoom = makeMockRoom([
Object.assign({}, membershipTemplate, { "m.call.intent": intentA }),
Object.assign({}, membershipTemplate, { "m.call.intent": intentB }),
]);
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess.getConsensusCallIntent()).toEqual(result);
jest.useRealTimers();
});
});
describe("getsActiveFocus", () => { describe("getsActiveFocus", () => {
const firstPreferredFocus = { const firstPreferredFocus = {
type: "livekit", type: "livekit",
@@ -370,6 +391,79 @@ describe("MatrixRTCSession", () => {
); );
}); });
it("sends a notification with a intent when starting a call and emits DidSendCallNotification", async () => {
// Simulate a join, including the update to the room state
// Ensure sendEvent returns event IDs so the DidSendCallNotification payload includes them
sendEventMock
.mockResolvedValueOnce({ event_id: "legacy-evt" })
.mockResolvedValueOnce({ event_id: "new-evt" });
const didSendEventFn = jest.fn();
sess!.once(MatrixRTCSessionEvent.DidSendCallNotification, didSendEventFn);
// Create an additional listener to create a promise that resolves after the emission.
const didSendNotification = new Promise((resolve) => {
sess!.once(MatrixRTCSessionEvent.DidSendCallNotification, resolve);
});
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring", callIntent: "audio" });
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
mockRoomState(mockRoom, [
{
...membershipTemplate,
"user_id": client.getUserId()!,
// This is what triggers the intent type on the notification event.
"m.call.intent": "audio",
},
]);
sess!.onRTCSessionMemberUpdate();
const ownMembershipId = sess?.memberships[0].eventId;
expect(sess!.getConsensusCallIntent()).toEqual("audio");
expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.RTCNotification, {
"m.mentions": { user_ids: [], room: true },
"notification_type": "ring",
"m.call.intent": "audio",
"m.relates_to": {
event_id: ownMembershipId,
rel_type: "m.reference",
},
"lifetime": 30000,
"sender_ts": expect.any(Number),
});
// Check if deprecated notify event is also sent.
expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.CallNotify, {
"application": "m.call",
"m.mentions": { user_ids: [], room: true },
"notify_type": "ring",
"call_id": "",
});
await didSendNotification;
// And ensure we emitted the DidSendCallNotification event with both payloads
expect(didSendEventFn).toHaveBeenCalledWith(
{
"event_id": "new-evt",
"lifetime": 30000,
"m.mentions": { room: true, user_ids: [] },
"m.relates_to": {
event_id: expect.any(String),
rel_type: "m.reference",
},
"notification_type": "ring",
"m.call.intent": "audio",
"sender_ts": expect.any(Number),
},
{
"application": "m.call",
"call_id": "",
"event_id": "legacy-evt",
"m.mentions": { room: true, user_ids: [] },
"notify_type": "ring",
},
);
});
it("doesn't send a notification when joining an existing call", async () => { it("doesn't send a notification when joining an existing call", async () => {
// Add another member to the call so that it is considered an existing call // Add another member to the call so that it is considered an existing call
mockRoomState(mockRoom, [membershipTemplate]); mockRoomState(mockRoom, [membershipTemplate]);

View File

@@ -901,6 +901,43 @@ describe("MembershipManager", () => {
} }
}); });
}); });
describe("updateCallIntent()", () => {
it("should fail if the user has not joined the call", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
// After joining we want our own focus to be the one we select.
try {
await manager.updateCallIntent("video");
throw Error("Should have thrown");
} catch {}
});
it("can adjust the intent", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([]);
expect(manager.isActivated()).toEqual(true);
const membership = mockCallMembership({ ...membershipTemplate, user_id: client.getUserId()! }, room.roomId);
await manager.onRTCSessionMemberUpdate([membership]);
await manager.updateCallIntent("video");
expect(client.sendStateEvent).toHaveBeenCalledTimes(2);
const eventContent = (client.sendStateEvent as Mock).mock.calls[0][2] as SessionMembershipData;
expect(eventContent["created_ts"]).toEqual(membership.createdTs());
expect(eventContent["m.call.intent"]).toEqual("video");
});
it("does nothing if the intent doesn't change", async () => {
const manager = new MembershipManager({ callIntent: "video" }, room, client, () => undefined, callSession);
manager.join([]);
expect(manager.isActivated()).toEqual(true);
const membership = mockCallMembership(
{ ...membershipTemplate, "user_id": client.getUserId()!, "m.call.intent": "video" },
room.roomId,
);
await manager.onRTCSessionMemberUpdate([membership]);
await manager.updateCallIntent("video");
expect(client.sendStateEvent).toHaveBeenCalledTimes(0);
});
});
}); });
it("Should prefix log with MembershipManager used", () => { it("Should prefix log with MembershipManager used", () => {

View File

@@ -19,6 +19,7 @@ import { deepCompare } from "../utils.ts";
import { type Focus } from "./focus.ts"; import { type Focus } from "./focus.ts";
import { isLivekitFocusActive } from "./LivekitFocus.ts"; import { isLivekitFocusActive } from "./LivekitFocus.ts";
import { type SessionDescription } from "./MatrixRTCSession.ts"; import { type SessionDescription } from "./MatrixRTCSession.ts";
import { type RTCCallIntent } from "./types.ts";
/** /**
* The default duration in milliseconds that a membership is considered valid for. * The default duration in milliseconds that a membership is considered valid for.
@@ -31,13 +32,13 @@ type CallScope = "m.room" | "m.user";
/** /**
* MSC4143 (MatrixRTC) session membership data. * MSC4143 (MatrixRTC) session membership data.
* Represents an entry in the memberships section of an m.call.member event as it is on the wire. * Represents the `session` in the memberships section of an m.call.member event as it is on the wire.
**/ **/
export type SessionMembershipData = { export type SessionMembershipData = {
/** /**
* The RTC application defines the type of the RTC session. * The RTC application defines the type of the RTC session.
*/ */
application: string; "application": string;
/** /**
* The id of this session. * The id of this session.
@@ -45,23 +46,23 @@ export type SessionMembershipData = {
* multiple session in one room. A room wide session that is not associated with a user, * multiple session in one room. A room wide session that is not associated with a user,
* and therefore immune to creation race conflicts, uses the `call_id: ""`. * and therefore immune to creation race conflicts, uses the `call_id: ""`.
*/ */
call_id: string; "call_id": string;
/** /**
* The Matrix device ID of this session. A single user can have multiple sessions on different devices. * The Matrix device ID of this session. A single user can have multiple sessions on different devices.
*/ */
device_id: string; "device_id": string;
/** /**
* The focus selection system this user/membership is using. * The focus selection system this user/membership is using.
*/ */
focus_active: Focus; "focus_active": Focus;
/** /**
* A list of possible foci this uses knows about. One of them might be used based on the focus_active * A list of possible foci this uses knows about. One of them might be used based on the focus_active
* selection system. * selection system.
*/ */
foci_preferred: Focus[]; "foci_preferred": Focus[];
/** /**
* Optional field that contains the creation of the session. If it is undefined the creation * Optional field that contains the creation of the session. If it is undefined the creation
@@ -70,7 +71,7 @@ export type SessionMembershipData = {
* - If it is undefined it can be interpreted as a "Join". * - If it is undefined it can be interpreted as a "Join".
* - If it is defined it can be interpreted as an "Update" * - If it is defined it can be interpreted as an "Update"
*/ */
created_ts?: number; "created_ts"?: number;
// Application specific data // Application specific data
@@ -78,17 +79,26 @@ export type SessionMembershipData = {
* If the `application` = `"m.call"` this defines if it is a room or user owned call. * If the `application` = `"m.call"` this defines if it is a room or user owned call.
* There can always be one room scroped call but multiple user owned calls (breakout sessions) * There can always be one room scroped call but multiple user owned calls (breakout sessions)
*/ */
scope?: CallScope; "scope"?: CallScope;
/** /**
* Optionally we allow to define a delta to the `created_ts` that defines when the event is expired/invalid. * Optionally we allow to define a delta to the `created_ts` that defines when the event is expired/invalid.
* This should be set to multiple hours. The only reason it exist is to deal with failed delayed events. * This should be set to multiple hours. The only reason it exist is to deal with failed delayed events.
* (for example caused by a homeserver crashes) * (for example caused by a homeserver crashes)
**/ **/
expires?: number; "expires"?: number;
/**
* The intent of the call from the perspective of this user. This may be an audio call, video call or
* something else.
*/
"m.call.intent"?: RTCCallIntent;
}; };
const checkSessionsMembershipData = (data: any, errors: string[]): data is SessionMembershipData => { const checkSessionsMembershipData = (
data: Partial<Record<keyof SessionMembershipData, any>>,
errors: string[],
): data is SessionMembershipData => {
const prefix = "Malformed session membership event: "; const prefix = "Malformed session membership event: ";
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string"); if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string"); if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
@@ -96,10 +106,17 @@ const checkSessionsMembershipData = (data: any, errors: string[]): data is Sessi
if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string"); if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string");
if (!Array.isArray(data.foci_preferred)) errors.push(prefix + "foci_preferred must be an array"); if (!Array.isArray(data.foci_preferred)) errors.push(prefix + "foci_preferred must be an array");
// optional parameters // optional parameters
if (data.created_ts && typeof data.created_ts !== "number") errors.push(prefix + "created_ts must be number"); if (data.created_ts !== undefined && typeof data.created_ts !== "number") {
errors.push(prefix + "created_ts must be number");
}
// application specific data (we first need to check if they exist) // 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"); if (data.scope !== undefined && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") {
errors.push(prefix + "m.call.intent must be a string");
}
return errors.length === 0; return errors.length === 0;
}; };
@@ -142,6 +159,10 @@ export class CallMembership {
return this.membershipData.device_id; return this.membershipData.device_id;
} }
public get callIntent(): RTCCallIntent | undefined {
return this.membershipData["m.call.intent"];
}
public get sessionDescription(): SessionDescription { public get sessionDescription(): SessionDescription {
return { return {
application: this.membershipData.application, application: this.membershipData.application,

View File

@@ -16,7 +16,7 @@ limitations under the License.
import type { CallMembership } from "./CallMembership.ts"; import type { CallMembership } from "./CallMembership.ts";
import type { Focus } from "./focus.ts"; import type { Focus } from "./focus.ts";
import type { Status } from "./types.ts"; import type { RTCCallIntent, Status } from "./types.ts";
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
export enum MembershipManagerEvent { export enum MembershipManagerEvent {
@@ -100,4 +100,10 @@ export interface IMembershipManager
* @returns the used active focus in the currently joined session or undefined if not joined. * @returns the used active focus in the currently joined session or undefined if not joined.
*/ */
getActiveFocus(): Focus | undefined; getActiveFocus(): Focus | undefined;
/**
* Update the intent of a membership on the call (e.g. user is now providing a video feed)
* @param callIntent The new intent to set.
*/
updateCallIntent(callIntent: RTCCallIntent): Promise<void>;
} }

View File

@@ -34,6 +34,7 @@ import {
type Status, type Status,
type IRTCNotificationContent, type IRTCNotificationContent,
type ICallNotifyContent, type ICallNotifyContent,
type RTCCallIntent,
} from "./types.ts"; } from "./types.ts";
import { RoomKeyTransport } from "./RoomKeyTransport.ts"; import { RoomKeyTransport } from "./RoomKeyTransport.ts";
import { import {
@@ -92,6 +93,11 @@ export interface SessionConfig {
* @default `undefined` (no notification) * @default `undefined` (no notification)
*/ */
notificationType?: RTCNotificationType; notificationType?: RTCNotificationType;
/**
* Determines the kind of call this will be.
*/
callIntent?: RTCCallIntent;
} }
/** /**
@@ -614,6 +620,32 @@ export class MatrixRTCSession extends TypedEventEmitter<
return this.memberships[0]; return this.memberships[0];
} }
/**
* Get the call intent for the current call, based on what members are advertising. If one or more
* members disagree on the current call intent, or nobody specifies one then `undefined` is returned.
*
* If all members that specify a call intent agree, that value is returned.
* @returns A call intent, or `undefined` if no consensus or not given.
*/
public getConsensusCallIntent(): RTCCallIntent | undefined {
const getFirstCallIntent = this.memberships.find((m) => !!m.callIntent)?.callIntent;
if (!getFirstCallIntent) {
return undefined;
}
if (this.memberships.every((m) => !m.callIntent || m.callIntent === getFirstCallIntent)) {
return getFirstCallIntent;
}
return undefined;
}
public async updateCallIntent(callIntent: RTCCallIntent): Promise<void> {
const myMembership = this.membershipManager?.ownMembership;
if (!myMembership) {
throw Error("Not connected yet");
}
await this.membershipManager?.updateCallIntent(callIntent);
}
/** /**
* This method is used when the user is not yet connected to the Session but wants to know what focus * This method is used when the user is not yet connected to the Session but wants to know what focus
* the users in the session are using to make a decision how it wants/should connect. * the users in the session are using to make a decision how it wants/should connect.
@@ -665,9 +697,17 @@ export class MatrixRTCSession extends TypedEventEmitter<
} }
/** /**
* Sends a notification corresponding to the configured notify type. * Sends notification events to indiciate the call has started.
* Note: This does not return a promise, instead scheduling the notification events to be sent.
* @param parentEventId Event id linking to your RTC call membership event.
* @param notificationType The type of notification to send
* @param callIntent The type of call this is (e.g. "audio").
*/ */
private sendCallNotify(parentEventId: string, notificationType: RTCNotificationType): void { private sendCallNotify(
parentEventId: string,
notificationType: RTCNotificationType,
callIntent?: RTCCallIntent,
): void {
const sendLegacyNotificationEvent = async (): Promise<{ const sendLegacyNotificationEvent = async (): Promise<{
response: ISendEventResponse; response: ISendEventResponse;
content: ICallNotifyContent; content: ICallNotifyContent;
@@ -695,6 +735,9 @@ export class MatrixRTCSession extends TypedEventEmitter<
"sender_ts": Date.now(), "sender_ts": Date.now(),
"lifetime": 30_000, // 30 seconds "lifetime": 30_000, // 30 seconds
}; };
if (callIntent) {
content["m.call.intent"] = callIntent;
}
const response = await this.client.sendEvent(this.roomSubset.roomId, EventType.RTCNotification, content); const response = await this.client.sendEvent(this.roomSubset.roomId, EventType.RTCNotification, content);
return { response, content }; return { response, content };
}; };
@@ -757,7 +800,11 @@ export class MatrixRTCSession extends TypedEventEmitter<
// If we're the first member in the call, we're responsible for // If we're the first member in the call, we're responsible for
// sending the notification event // sending the notification event
if (ownMembership.eventId && this.joinConfig?.notificationType) { if (ownMembership.eventId && this.joinConfig?.notificationType) {
this.sendCallNotify(ownMembership.eventId, this.joinConfig.notificationType); this.sendCallNotify(
ownMembership.eventId,
this.joinConfig.notificationType,
ownMembership.callIntent,
);
} else { } else {
this.logger.warn("Own membership eventId is undefined, cannot send call notification"); this.logger.warn("Own membership eventId is undefined, cannot send call notification");
} }

View File

@@ -24,9 +24,9 @@ import { type Logger, logger as rootLogger } from "../logger.ts";
import { type Room } from "../models/room.ts"; import { type Room } from "../models/room.ts";
import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts"; import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts";
import { type Focus } from "./focus.ts"; import { type Focus } from "./focus.ts";
import { isMyMembership, Status } from "./types.ts"; import { isMyMembership, type RTCCallIntent, Status } from "./types.ts";
import { isLivekitFocusActive } from "./LivekitFocus.ts"; import { isLivekitFocusActive } from "./LivekitFocus.ts";
import { type SessionDescription, type MembershipConfig } from "./MatrixRTCSession.ts"; import { type SessionDescription, type MembershipConfig, type SessionConfig } from "./MatrixRTCSession.ts";
import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts"; import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { import {
@@ -156,6 +156,7 @@ export class MembershipManager
{ {
private activated = false; private activated = false;
private logger: Logger; private logger: Logger;
private callIntent: RTCCallIntent | undefined;
public isActivated(): boolean { public isActivated(): boolean {
return this.activated; return this.activated;
@@ -230,7 +231,10 @@ export class MembershipManager
private leavePromiseResolvers?: PromiseWithResolvers<boolean>; private leavePromiseResolvers?: PromiseWithResolvers<boolean>;
public async onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void> { public onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void> {
if (!this.isActivated()) {
return Promise.resolve();
}
const userId = this.client.getUserId(); const userId = this.client.getUserId();
const deviceId = this.client.getDeviceId(); const deviceId = this.client.getDeviceId();
if (!userId || !deviceId) { if (!userId || !deviceId) {
@@ -239,7 +243,7 @@ export class MembershipManager
} }
this._ownMembership = memberships.find((m) => isMyMembership(m, userId, deviceId)); this._ownMembership = memberships.find((m) => isMyMembership(m, userId, deviceId));
if (this.isActivated() && !this._ownMembership) { if (!this._ownMembership) {
// If one of these actions are scheduled or are getting inserted in the next iteration, we should already // If one of these actions are scheduled or are getting inserted in the next iteration, we should already
// take care of our missing membership. // take care of our missing membership.
const sendingMembershipActions = [ const sendingMembershipActions = [
@@ -281,6 +285,18 @@ export class MembershipManager
} }
} }
public async updateCallIntent(callIntent: RTCCallIntent): Promise<void> {
if (!this.activated || !this.ownMembership) {
throw Error("You cannot update your intent before joining the call");
}
if (this.ownMembership.callIntent === callIntent) {
return; // No-op
}
this.callIntent = callIntent;
// Kick off a new membership event as a result.
await this.sendJoinEvent();
}
/** /**
* @throws if the client does not return user or device id. * @throws if the client does not return user or device id.
* @param joinConfig * @param joinConfig
@@ -289,7 +305,7 @@ export class MembershipManager
* @param getOldestMembership * @param getOldestMembership
*/ */
public constructor( public constructor(
private joinConfig: MembershipConfig | undefined, private joinConfig: (SessionConfig & MembershipConfig) | undefined,
private room: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion">, private room: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion">,
private client: Pick< private client: Pick<
MatrixClient, MatrixClient,
@@ -311,6 +327,7 @@ export class MembershipManager
this.deviceId = deviceId; this.deviceId = deviceId;
this.stateKey = this.makeMembershipStateKey(userId, deviceId); this.stateKey = this.makeMembershipStateKey(userId, deviceId);
this.state = MembershipManager.defaultState; this.state = MembershipManager.defaultState;
this.callIntent = joinConfig?.callIntent;
this.scheduler = new ActionScheduler((type): Promise<ActionUpdate> => { this.scheduler = new ActionScheduler((type): Promise<ActionUpdate> => {
if (this.oldStatus) { if (this.oldStatus) {
// we put this at the beginning of the actions scheduler loop handle callback since it is a loop this // we put this at the beginning of the actions scheduler loop handle callback since it is a loop this
@@ -741,15 +758,18 @@ export class MembershipManager
* Constructs our own membership * Constructs our own membership
*/ */
private makeMyMembership(expires: number): SessionMembershipData { private makeMyMembership(expires: number): SessionMembershipData {
const hasPreviousEvent = !!this.ownMembership;
return { return {
// TODO: use the new format for m.rtc.member events where call_id becomes session.id // TODO: use the new format for m.rtc.member events where call_id becomes session.id
application: this.sessionDescription.application, "application": this.sessionDescription.application,
call_id: this.sessionDescription.id, "call_id": this.sessionDescription.id,
scope: "m.room", "scope": "m.room",
device_id: this.deviceId, "device_id": this.deviceId,
expires, expires,
focus_active: { type: "livekit", focus_selection: "oldest_membership" }, "focus_active": { type: "livekit", focus_selection: "oldest_membership" },
foci_preferred: this.fociPreferred ?? [], "foci_preferred": this.fociPreferred ?? [],
"m.call.intent": this.callIntent,
...(hasPreviousEvent ? { created_ts: this.ownMembership?.createdTs() } : undefined),
}; };
} }

View File

@@ -96,10 +96,20 @@ export interface ICallNotifyContent {
} }
export type RTCNotificationType = "ring" | "notification"; export type RTCNotificationType = "ring" | "notification";
/**
* Represents the intention of the call from the perspective of the sending user.
* May be any string, although `"audio"` and `"video"` are commonly accepted values.
*/
export type RTCCallIntent = "audio" | "video" | string;
export interface IRTCNotificationContent extends RelationEvent { export interface IRTCNotificationContent extends RelationEvent {
"m.mentions": IMentions; "m.mentions": IMentions;
"decline_reason"?: string; "decline_reason"?: string;
"notification_type": RTCNotificationType; "notification_type": RTCNotificationType;
/**
* The initial intent of the calling user.
*/
"m.call.intent"?: RTCCallIntent;
"sender_ts": number; "sender_ts": number;
"lifetime": number; "lifetime": number;
} }