Skip to content

Commit 41d70d0

Browse files
authored
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
1 parent a08a273 commit 41d70d0

File tree

7 files changed

+262
-27
lines changed

7 files changed

+262
-27
lines changed

spec/unit/matrixrtc/MatrixRTCSession.spec.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,27 @@ describe("MatrixRTCSession", () => {
222222
});
223223
});
224224

225+
describe("getConsensusCallIntent", () => {
226+
it.each([
227+
[undefined, undefined, undefined],
228+
["audio", undefined, "audio"],
229+
[undefined, "audio", "audio"],
230+
["audio", "audio", "audio"],
231+
["audio", "video", undefined],
232+
])("gets correct consensus for %s + %s = %s", (intentA, intentB, result) => {
233+
jest.useFakeTimers();
234+
jest.setSystemTime(4000);
235+
const mockRoom = makeMockRoom([
236+
Object.assign({}, membershipTemplate, { "m.call.intent": intentA }),
237+
Object.assign({}, membershipTemplate, { "m.call.intent": intentB }),
238+
]);
239+
240+
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
241+
expect(sess.getConsensusCallIntent()).toEqual(result);
242+
jest.useRealTimers();
243+
});
244+
});
245+
225246
describe("getsActiveFocus", () => {
226247
const firstPreferredFocus = {
227248
type: "livekit",
@@ -370,6 +391,79 @@ describe("MatrixRTCSession", () => {
370391
);
371392
});
372393

394+
it("sends a notification with a intent when starting a call and emits DidSendCallNotification", async () => {
395+
// Simulate a join, including the update to the room state
396+
// Ensure sendEvent returns event IDs so the DidSendCallNotification payload includes them
397+
sendEventMock
398+
.mockResolvedValueOnce({ event_id: "legacy-evt" })
399+
.mockResolvedValueOnce({ event_id: "new-evt" });
400+
const didSendEventFn = jest.fn();
401+
sess!.once(MatrixRTCSessionEvent.DidSendCallNotification, didSendEventFn);
402+
// Create an additional listener to create a promise that resolves after the emission.
403+
const didSendNotification = new Promise((resolve) => {
404+
sess!.once(MatrixRTCSessionEvent.DidSendCallNotification, resolve);
405+
});
406+
407+
sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring", callIntent: "audio" });
408+
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
409+
410+
mockRoomState(mockRoom, [
411+
{
412+
...membershipTemplate,
413+
"user_id": client.getUserId()!,
414+
// This is what triggers the intent type on the notification event.
415+
"m.call.intent": "audio",
416+
},
417+
]);
418+
419+
sess!.onRTCSessionMemberUpdate();
420+
const ownMembershipId = sess?.memberships[0].eventId;
421+
expect(sess!.getConsensusCallIntent()).toEqual("audio");
422+
423+
expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.RTCNotification, {
424+
"m.mentions": { user_ids: [], room: true },
425+
"notification_type": "ring",
426+
"m.call.intent": "audio",
427+
"m.relates_to": {
428+
event_id: ownMembershipId,
429+
rel_type: "m.reference",
430+
},
431+
"lifetime": 30000,
432+
"sender_ts": expect.any(Number),
433+
});
434+
435+
// Check if deprecated notify event is also sent.
436+
expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.CallNotify, {
437+
"application": "m.call",
438+
"m.mentions": { user_ids: [], room: true },
439+
"notify_type": "ring",
440+
"call_id": "",
441+
});
442+
await didSendNotification;
443+
// And ensure we emitted the DidSendCallNotification event with both payloads
444+
expect(didSendEventFn).toHaveBeenCalledWith(
445+
{
446+
"event_id": "new-evt",
447+
"lifetime": 30000,
448+
"m.mentions": { room: true, user_ids: [] },
449+
"m.relates_to": {
450+
event_id: expect.any(String),
451+
rel_type: "m.reference",
452+
},
453+
"notification_type": "ring",
454+
"m.call.intent": "audio",
455+
"sender_ts": expect.any(Number),
456+
},
457+
{
458+
"application": "m.call",
459+
"call_id": "",
460+
"event_id": "legacy-evt",
461+
"m.mentions": { room: true, user_ids: [] },
462+
"notify_type": "ring",
463+
},
464+
);
465+
});
466+
373467
it("doesn't send a notification when joining an existing call", async () => {
374468
// Add another member to the call so that it is considered an existing call
375469
mockRoomState(mockRoom, [membershipTemplate]);

spec/unit/matrixrtc/MembershipManager.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,43 @@ describe("MembershipManager", () => {
901901
}
902902
});
903903
});
904+
905+
describe("updateCallIntent()", () => {
906+
it("should fail if the user has not joined the call", async () => {
907+
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
908+
// After joining we want our own focus to be the one we select.
909+
try {
910+
await manager.updateCallIntent("video");
911+
throw Error("Should have thrown");
912+
} catch {}
913+
});
914+
915+
it("can adjust the intent", async () => {
916+
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
917+
manager.join([]);
918+
expect(manager.isActivated()).toEqual(true);
919+
const membership = mockCallMembership({ ...membershipTemplate, user_id: client.getUserId()! }, room.roomId);
920+
await manager.onRTCSessionMemberUpdate([membership]);
921+
await manager.updateCallIntent("video");
922+
expect(client.sendStateEvent).toHaveBeenCalledTimes(2);
923+
const eventContent = (client.sendStateEvent as Mock).mock.calls[0][2] as SessionMembershipData;
924+
expect(eventContent["created_ts"]).toEqual(membership.createdTs());
925+
expect(eventContent["m.call.intent"]).toEqual("video");
926+
});
927+
928+
it("does nothing if the intent doesn't change", async () => {
929+
const manager = new MembershipManager({ callIntent: "video" }, room, client, () => undefined, callSession);
930+
manager.join([]);
931+
expect(manager.isActivated()).toEqual(true);
932+
const membership = mockCallMembership(
933+
{ ...membershipTemplate, "user_id": client.getUserId()!, "m.call.intent": "video" },
934+
room.roomId,
935+
);
936+
await manager.onRTCSessionMemberUpdate([membership]);
937+
await manager.updateCallIntent("video");
938+
expect(client.sendStateEvent).toHaveBeenCalledTimes(0);
939+
});
940+
});
904941
});
905942

906943
it("Should prefix log with MembershipManager used", () => {

src/matrixrtc/CallMembership.ts

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { deepCompare } from "../utils.ts";
1919
import { type Focus } from "./focus.ts";
2020
import { isLivekitFocusActive } from "./LivekitFocus.ts";
2121
import { type SessionDescription } from "./MatrixRTCSession.ts";
22+
import { type RTCCallIntent } from "./types.ts";
2223

2324
/**
2425
* The default duration in milliseconds that a membership is considered valid for.
@@ -31,37 +32,37 @@ type CallScope = "m.room" | "m.user";
3132

3233
/**
3334
* MSC4143 (MatrixRTC) session membership data.
34-
* Represents an entry in the memberships section of an m.call.member event as it is on the wire.
35+
* Represents the `session` in the memberships section of an m.call.member event as it is on the wire.
3536
**/
3637
export type SessionMembershipData = {
3738
/**
3839
* The RTC application defines the type of the RTC session.
3940
*/
40-
application: string;
41+
"application": string;
4142

4243
/**
4344
* The id of this session.
4445
* A session can never span over multiple rooms so this id is to distinguish between
4546
* multiple session in one room. A room wide session that is not associated with a user,
4647
* and therefore immune to creation race conflicts, uses the `call_id: ""`.
4748
*/
48-
call_id: string;
49+
"call_id": string;
4950

5051
/**
5152
* The Matrix device ID of this session. A single user can have multiple sessions on different devices.
5253
*/
53-
device_id: string;
54+
"device_id": string;
5455

5556
/**
5657
* The focus selection system this user/membership is using.
5758
*/
58-
focus_active: Focus;
59+
"focus_active": Focus;
5960

6061
/**
6162
* A list of possible foci this uses knows about. One of them might be used based on the focus_active
6263
* selection system.
6364
*/
64-
foci_preferred: Focus[];
65+
"foci_preferred": Focus[];
6566

6667
/**
6768
* Optional field that contains the creation of the session. If it is undefined the creation
@@ -70,36 +71,52 @@ export type SessionMembershipData = {
7071
* - If it is undefined it can be interpreted as a "Join".
7172
* - If it is defined it can be interpreted as an "Update"
7273
*/
73-
created_ts?: number;
74+
"created_ts"?: number;
7475

7576
// Application specific data
7677

7778
/**
7879
* If the `application` = `"m.call"` this defines if it is a room or user owned call.
7980
* There can always be one room scroped call but multiple user owned calls (breakout sessions)
8081
*/
81-
scope?: CallScope;
82+
"scope"?: CallScope;
8283

8384
/**
8485
* Optionally we allow to define a delta to the `created_ts` that defines when the event is expired/invalid.
8586
* This should be set to multiple hours. The only reason it exist is to deal with failed delayed events.
8687
* (for example caused by a homeserver crashes)
8788
**/
88-
expires?: number;
89+
"expires"?: number;
90+
91+
/**
92+
* The intent of the call from the perspective of this user. This may be an audio call, video call or
93+
* something else.
94+
*/
95+
"m.call.intent"?: RTCCallIntent;
8996
};
9097

91-
const checkSessionsMembershipData = (data: any, errors: string[]): data is SessionMembershipData => {
98+
const checkSessionsMembershipData = (
99+
data: Partial<Record<keyof SessionMembershipData, any>>,
100+
errors: string[],
101+
): data is SessionMembershipData => {
92102
const prefix = "Malformed session membership event: ";
93103
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
94104
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
95105
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
96106
if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string");
97107
if (!Array.isArray(data.foci_preferred)) errors.push(prefix + "foci_preferred must be an array");
98108
// optional parameters
99-
if (data.created_ts && typeof data.created_ts !== "number") errors.push(prefix + "created_ts must be number");
109+
if (data.created_ts !== undefined && typeof data.created_ts !== "number") {
110+
errors.push(prefix + "created_ts must be number");
111+
}
100112

101113
// application specific data (we first need to check if they exist)
102-
if (data.scope && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
114+
if (data.scope !== undefined && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
115+
116+
if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") {
117+
errors.push(prefix + "m.call.intent must be a string");
118+
}
119+
103120
return errors.length === 0;
104121
};
105122

@@ -142,6 +159,10 @@ export class CallMembership {
142159
return this.membershipData.device_id;
143160
}
144161

162+
public get callIntent(): RTCCallIntent | undefined {
163+
return this.membershipData["m.call.intent"];
164+
}
165+
145166
public get sessionDescription(): SessionDescription {
146167
return {
147168
application: this.membershipData.application,

src/matrixrtc/IMembershipManager.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ limitations under the License.
1616

1717
import type { CallMembership } from "./CallMembership.ts";
1818
import type { Focus } from "./focus.ts";
19-
import type { Status } from "./types.ts";
19+
import type { RTCCallIntent, Status } from "./types.ts";
2020
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
2121

2222
export enum MembershipManagerEvent {
@@ -100,4 +100,10 @@ export interface IMembershipManager
100100
* @returns the used active focus in the currently joined session or undefined if not joined.
101101
*/
102102
getActiveFocus(): Focus | undefined;
103+
104+
/**
105+
* Update the intent of a membership on the call (e.g. user is now providing a video feed)
106+
* @param callIntent The new intent to set.
107+
*/
108+
updateCallIntent(callIntent: RTCCallIntent): Promise<void>;
103109
}

src/matrixrtc/MatrixRTCSession.ts

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
type Status,
3535
type IRTCNotificationContent,
3636
type ICallNotifyContent,
37+
type RTCCallIntent,
3738
} from "./types.ts";
3839
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
3940
import {
@@ -92,6 +93,11 @@ export interface SessionConfig {
9293
* @default `undefined` (no notification)
9394
*/
9495
notificationType?: RTCNotificationType;
96+
97+
/**
98+
* Determines the kind of call this will be.
99+
*/
100+
callIntent?: RTCCallIntent;
95101
}
96102

97103
/**
@@ -614,6 +620,32 @@ export class MatrixRTCSession extends TypedEventEmitter<
614620
return this.memberships[0];
615621
}
616622

623+
/**
624+
* Get the call intent for the current call, based on what members are advertising. If one or more
625+
* members disagree on the current call intent, or nobody specifies one then `undefined` is returned.
626+
*
627+
* If all members that specify a call intent agree, that value is returned.
628+
* @returns A call intent, or `undefined` if no consensus or not given.
629+
*/
630+
public getConsensusCallIntent(): RTCCallIntent | undefined {
631+
const getFirstCallIntent = this.memberships.find((m) => !!m.callIntent)?.callIntent;
632+
if (!getFirstCallIntent) {
633+
return undefined;
634+
}
635+
if (this.memberships.every((m) => !m.callIntent || m.callIntent === getFirstCallIntent)) {
636+
return getFirstCallIntent;
637+
}
638+
return undefined;
639+
}
640+
641+
public async updateCallIntent(callIntent: RTCCallIntent): Promise<void> {
642+
const myMembership = this.membershipManager?.ownMembership;
643+
if (!myMembership) {
644+
throw Error("Not connected yet");
645+
}
646+
await this.membershipManager?.updateCallIntent(callIntent);
647+
}
648+
617649
/**
618650
* This method is used when the user is not yet connected to the Session but wants to know what focus
619651
* 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<
665697
}
666698

667699
/**
668-
* Sends a notification corresponding to the configured notify type.
700+
* Sends notification events to indiciate the call has started.
701+
* Note: This does not return a promise, instead scheduling the notification events to be sent.
702+
* @param parentEventId Event id linking to your RTC call membership event.
703+
* @param notificationType The type of notification to send
704+
* @param callIntent The type of call this is (e.g. "audio").
669705
*/
670-
private sendCallNotify(parentEventId: string, notificationType: RTCNotificationType): void {
706+
private sendCallNotify(
707+
parentEventId: string,
708+
notificationType: RTCNotificationType,
709+
callIntent?: RTCCallIntent,
710+
): void {
671711
const sendLegacyNotificationEvent = async (): Promise<{
672712
response: ISendEventResponse;
673713
content: ICallNotifyContent;
@@ -695,6 +735,9 @@ export class MatrixRTCSession extends TypedEventEmitter<
695735
"sender_ts": Date.now(),
696736
"lifetime": 30_000, // 30 seconds
697737
};
738+
if (callIntent) {
739+
content["m.call.intent"] = callIntent;
740+
}
698741
const response = await this.client.sendEvent(this.roomSubset.roomId, EventType.RTCNotification, content);
699742
return { response, content };
700743
};
@@ -757,7 +800,11 @@ export class MatrixRTCSession extends TypedEventEmitter<
757800
// If we're the first member in the call, we're responsible for
758801
// sending the notification event
759802
if (ownMembership.eventId && this.joinConfig?.notificationType) {
760-
this.sendCallNotify(ownMembership.eventId, this.joinConfig.notificationType);
803+
this.sendCallNotify(
804+
ownMembership.eventId,
805+
this.joinConfig.notificationType,
806+
ownMembership.callIntent,
807+
);
761808
} else {
762809
this.logger.warn("Own membership eventId is undefined, cannot send call notification");
763810
}

0 commit comments

Comments
 (0)