From 80ade28eedcef4baf285987bfa57c95dcc9c46df Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 6 Nov 2026 15:46:27 +0100 Subject: [PATCH] Stabilise m.call.intent --- package.json | 2 +- pnpm-lock.yaml | 18 ++++----- sdk/src/RTCParticipation.ts | 1 + sdk/src/api.ts | 12 ++++-- sdk/src/matrixrtc/joinJsSdkSession.test.ts | 14 ++++--- sdk/src/matrixrtc/joinJsSdkSession.ts | 8 ++-- src/UrlConfiguration.ts | 18 +++++---- src/UrlParams.test.ts | 16 +++++--- src/room/InCallView.tsx | 4 +- src/room/RingingAudioRenderer.test.tsx | 3 +- .../AndroidControlledAudioOutput.test.ts | 30 ++++++++------ src/state/AndroidControlledAudioOutput.ts | 9 +++-- .../CallNotificationLifecycle.test.ts | 40 +++++++++++++++---- .../CallNotificationLifecycle.ts | 7 +++- src/state/CallViewModel/CallViewModel.test.ts | 5 ++- src/state/IOSControlledAudioOutput.test.ts | 14 ++++--- src/state/IOSControlledAudioOutput.ts | 9 +++-- src/state/MediaDevices.test.ts | 8 +++- src/state/initialMuteState.test.ts | 26 +++++++----- src/state/initialMuteState.ts | 9 +++-- src/tile/GridTile.test.tsx | 7 +++- src/tile/RingingStatus.tsx | 3 +- src/tile/SpotlightTile.test.tsx | 3 +- 23 files changed, 169 insertions(+), 97 deletions(-) diff --git a/package.json b/package.json index d867be176..0b2f3509a 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ "livekit-client": "^2.18.1", "lodash-es": "^4.17.21", "loglevel": "^1.9.1", - "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01", + "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#4cb91f3155c31f982cbb7375ca34a14fbecc7848", "matrix-widget-api": "^1.18.0", "node-stdlib-browser": "^1.3.1", "normalize.css": "^8.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32c4d95e0..5c95b7355 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,8 +191,8 @@ importers: specifier: ^1.9.1 version: 1.9.2 matrix-js-sdk: - specifier: github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01 - version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01 + specifier: github:matrix-org/matrix-js-sdk#4cb91f3155c31f982cbb7375ca34a14fbecc7848 + version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4cb91f3155c31f982cbb7375ca34a14fbecc7848 matrix-widget-api: specifier: ^1.18.0 version: 1.20.0 @@ -4734,8 +4734,8 @@ packages: matrix-events-sdk@0.0.1: resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==} - matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01: - resolution: {gitHosted: true, integrity: sha512-ZAhn/zB44MgrXqUzi9ffSFcmZY3+2nx7bEgfEJR7chYDFyT/XmlmfCJx7Ar4rOTAYZJRVnMxrEwiEHGDapUi0Q==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01} + matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4cb91f3155c31f982cbb7375ca34a14fbecc7848: + resolution: {gitHosted: true, integrity: sha512-3OCLpmzR0zO9TsTdUcxhN8dhy8ZpoZqGzbAQFESaDU60nJXrJRUKiKJAB61KzimVdy2xNscLl4M4NGdqX3aNuw==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4cb91f3155c31f982cbb7375ca34a14fbecc7848} version: 45.0.0 engines: {node: '>=22.0.0'} @@ -4946,8 +4946,8 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - p-retry@8.0.1: - resolution: {integrity: sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==} + p-retry@9.0.0: + resolution: {integrity: sha512-HuEats8gYn/UUYa5uyveyQGv75mQmwdFtuMgnZlS12dj/7bdnJSO6keaTjgcZUhvkANTj5EO3lACUmV3xmgj0Q==} engines: {node: '>=22'} p-try@2.2.0: @@ -10409,7 +10409,7 @@ snapshots: matrix-events-sdk@0.0.1: {} - matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01: + matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4cb91f3155c31f982cbb7375ca34a14fbecc7848: dependencies: '@babel/runtime': 8.0.8 '@matrix-org/matrix-sdk-crypto-wasm': 18.9.0 @@ -10419,7 +10419,7 @@ snapshots: loglevel: 1.9.2 matrix-events-sdk: 0.0.1 matrix-widget-api: 1.20.0 - p-retry: 8.0.1 + p-retry: 9.0.0 sdp-transform: 3.0.0 unhomoglyph: 1.0.6 @@ -10741,7 +10741,7 @@ snapshots: dependencies: p-limit: 3.1.0 - p-retry@8.0.1: + p-retry@9.0.0: dependencies: is-network-error: 1.3.2 diff --git a/sdk/src/RTCParticipation.ts b/sdk/src/RTCParticipation.ts index a88fa55b3..e591b6210 100644 --- a/sdk/src/RTCParticipation.ts +++ b/sdk/src/RTCParticipation.ts @@ -184,6 +184,7 @@ export function createRTCParticipation( encryptMedia: encryptionSystem.kind !== E2eeType.NONE, matrixRTCMode, sendNotificationType: options.sendNotificationType, + callIntent: options.callIntent, applicationData: options.applicationData, timings, delayedLeave, diff --git a/sdk/src/api.ts b/sdk/src/api.ts index d9a7c0b75..526cf6cf1 100644 --- a/sdk/src/api.ts +++ b/sdk/src/api.ts @@ -12,6 +12,7 @@ Please see LICENSE in the repository root for full details. */ import { + type RTCCallIntent, type RTCNotificationType, type Transport, } from "matrix-js-sdk/lib/matrixrtc"; @@ -103,11 +104,14 @@ export interface RTCParticipationOptions { * to a notification (ringing, timeouts, declines) is the application's job. */ sendNotificationType?: RTCNotificationType; + /** + * The kind of call the local member intends to take part in: `"m.audio"` + * or `"m.video"`. Carried by the membership as a field of its own. + */ + callIntent?: RTCCallIntent; /** * Whatever the application wants to say about itself in the membership, - * under namespaced keys. Opaque to the SDK, apart from `m.call.intent` - * (`"audio"` or `"video"`), which the membership format carries as a - * field of its own. + * under namespaced keys. Opaque to the SDK. */ applicationData?: Record; /** Timings the host has configured; the defaults otherwise. */ @@ -287,7 +291,7 @@ export interface RTCMember { memberId: string; /** Which transport this member is on; undefined when the membership has none. */ transport$: Behavior; - /** What the member's application says about itself, e.g. `m.call.intent`. */ + /** What the member's application says about itself, e.g. `intent`. */ applicationData$: Behavior>; displayName$: Behavior; diff --git a/sdk/src/matrixrtc/joinJsSdkSession.test.ts b/sdk/src/matrixrtc/joinJsSdkSession.test.ts index 4d9213097..7bb584a53 100644 --- a/sdk/src/matrixrtc/joinJsSdkSession.test.ts +++ b/sdk/src/matrixrtc/joinJsSdkSession.test.ts @@ -60,7 +60,7 @@ describe("joinJsSdkSession", () => { expect(s.join).toHaveBeenCalledWith(ownMemberMock, [transportWithAlias], { notificationType: "ring", callIntent: undefined, - applicationData: {}, + applicationData: undefined, manageMediaKeys: true, delayedLeaveEventRestartMs: 300_000, delayedLeaveEventDelayMs: 3_600_000, @@ -95,13 +95,14 @@ describe("joinJsSdkSession", () => { ); }); - it("lifts the call intent out of the application data and sends sticky events in Matrix 2.0 mode", () => { + it("passes the call intent and application data through and sends sticky events in Matrix 2.0 mode", () => { const s = session(); joinJsSdkSession("!room:example.org", s, ownMemberMock, transport, { encryptMedia: true, matrixRTCMode: MatrixRTCMode.Matrix_2_0, - applicationData: { "m.call.intent": "audio", custom: 1 }, + callIntent: "m.audio", + applicationData: { custom: 1 }, timings: defaultSessionTimings, delayedLeave: defaultSessionTimings.delayedLeave, }); @@ -110,7 +111,7 @@ describe("joinJsSdkSession", () => { ownMemberMock, [transportWithAlias], expect.objectContaining({ - callIntent: "audio", + callIntent: "m.audio", applicationData: { custom: 1 }, unstableSendStickyEvents: true, }), @@ -124,7 +125,8 @@ describe("joinJsSdkSession", () => { joinJsSdkSession("!room:example.org", s, ownMemberMock, transport, { encryptMedia: true, matrixRTCMode: MatrixRTCMode.Matrix_2_0, - applicationData: { "m.call.intent": "audio", custom: 1 }, + callIntent: "m.audio", + applicationData: { custom: 1 }, timings: defaultSessionTimings, delayedLeave: defaultSessionTimings.delayedLeave, }); @@ -133,7 +135,7 @@ describe("joinJsSdkSession", () => { ownMemberMock, [transport], expect.objectContaining({ - callIntent: "audio", + callIntent: "m.audio", applicationData: { custom: 1 }, unstableSendStickyEvents: true, }), diff --git a/sdk/src/matrixrtc/joinJsSdkSession.ts b/sdk/src/matrixrtc/joinJsSdkSession.ts index 5c6d14049..d2dd6e9be 100644 --- a/sdk/src/matrixrtc/joinJsSdkSession.ts +++ b/sdk/src/matrixrtc/joinJsSdkSession.ts @@ -23,6 +23,7 @@ interface Options { encryptMedia: boolean; matrixRTCMode: MatrixRTCMode; sendNotificationType?: RTCNotificationType; + callIntent?: RTCCallIntent; applicationData?: Record; timings: SessionTimings; /** The delayed leave timings to use: delegated to the SFU or not. */ @@ -43,15 +44,12 @@ export function joinJsSdkSession( encryptMedia, matrixRTCMode, sendNotificationType, + callIntent, applicationData, timings, delayedLeave, }: Options, ): void { - // The membership format carries the intent as a field of its own - const { "m.call.intent": intent, ...rest } = applicationData ?? {}; - const callIntent = - typeof intent === "string" ? (intent as RTCCallIntent) : undefined; // Give up on the network as soon as either the sync has been down for the // grace period or the delayed leave has probably been sent, whichever is // sooner @@ -78,7 +76,7 @@ export function joinJsSdkSession( { notificationType: sendNotificationType, callIntent, - applicationData: rest, + applicationData, manageMediaKeys: encryptMedia, delayedLeaveEventRestartMs: delayedLeave.restart_ms, delayedLeaveEventDelayMs: delayedLeave.delay_ms, diff --git a/src/UrlConfiguration.ts b/src/UrlConfiguration.ts index 2840bad1a..4d7847095 100644 --- a/src/UrlConfiguration.ts +++ b/src/UrlConfiguration.ts @@ -18,6 +18,8 @@ Please see LICENSE in the repository root for full details. */ import { + RTC_CALL_INTENT_AUDIO, + RTC_CALL_INTENT_VIDEO, type RTCCallIntent, type RTCNotificationType, } from "matrix-js-sdk/lib/matrixrtc"; @@ -179,40 +181,40 @@ export function configurationForIntent(intent: UserIntent): UrlConfiguration { switch (intent) { case UserIntent.StartNewCall: preset.skipLobby = false; - preset.callIntent = "video"; + preset.callIntent = RTC_CALL_INTENT_VIDEO; break; case UserIntent.JoinExistingCall: // On desktop this will be overridden based on which button was used to join the call preset.skipLobby = false; - preset.callIntent = "video"; + preset.callIntent = RTC_CALL_INTENT_VIDEO; break; case UserIntent.StartNewCallVoice: preset.skipLobby = false; - preset.callIntent = "audio"; + preset.callIntent = RTC_CALL_INTENT_AUDIO; break; case UserIntent.JoinExistingCallVoice: // On desktop this will be overridden based on which button was used to join the call preset.skipLobby = false; - preset.callIntent = "audio"; + preset.callIntent = RTC_CALL_INTENT_AUDIO; break; case UserIntent.StartNewCallDMVoice: - preset.callIntent = "audio"; + preset.callIntent = RTC_CALL_INTENT_AUDIO; // Fall through case UserIntent.StartNewCallDM: preset.skipLobby = true; preset.sendNotificationType = "ring"; preset.autoLeaveWhenOthersLeft = true; preset.waitForCallPickup = true; - preset.callIntent = preset.callIntent ?? "video"; + preset.callIntent = preset.callIntent ?? RTC_CALL_INTENT_VIDEO; break; case UserIntent.JoinExistingCallDMVoice: - preset.callIntent = "audio"; + preset.callIntent = RTC_CALL_INTENT_AUDIO; // Fall through case UserIntent.JoinExistingCallDM: // On desktop this will be overridden based on which button was used to join the call preset.skipLobby = true; preset.autoLeaveWhenOthersLeft = true; - preset.callIntent = preset.callIntent ?? "video"; + preset.callIntent = preset.callIntent ?? RTC_CALL_INTENT_VIDEO; break; // Non widget usecase defaults default: diff --git a/src/UrlParams.test.ts b/src/UrlParams.test.ts index c1f1aeeb0..ee29b4c27 100644 --- a/src/UrlParams.test.ts +++ b/src/UrlParams.test.ts @@ -21,6 +21,10 @@ import { UserIntent, } from "../src/UrlParams"; import { mockConfig } from "./utils/test"; +import { + RTC_CALL_INTENT_AUDIO, + RTC_CALL_INTENT_VIDEO, +} from "matrix-js-sdk/lib/matrixrtc"; const ROOM_NAME = "roomNameHere"; const ROOM_ID = "!d45f138fsd"; @@ -275,7 +279,7 @@ describe("UrlParams", () => { ).toMatchObject({ ...startNewCallDefaults("desktop"), skipLobby: false, - callIntent: "video", + callIntent: RTC_CALL_INTENT_VIDEO, }); }); @@ -322,7 +326,7 @@ describe("UrlParams", () => { ).toMatchObject({ ...startNewCallDefaults("desktop"), skipLobby: false, - callIntent: "audio", + callIntent: RTC_CALL_INTENT_AUDIO, }); }); @@ -333,7 +337,7 @@ describe("UrlParams", () => { ), ).toMatchObject({ ...joinExistingCallDefaults("desktop"), - callIntent: "audio", + callIntent: RTC_CALL_INTENT_AUDIO, }); }); @@ -348,7 +352,7 @@ describe("UrlParams", () => { sendNotificationType: "ring", autoLeaveWhenOthersLeft: true, waitForCallPickup: true, - callIntent: "audio", + callIntent: RTC_CALL_INTENT_AUDIO, }); }); @@ -363,7 +367,7 @@ describe("UrlParams", () => { skipLobby: true, autoLeaveWhenOthersLeft: true, waitForCallPickup: false, - callIntent: "video", + callIntent: RTC_CALL_INTENT_VIDEO, }); }); @@ -377,7 +381,7 @@ describe("UrlParams", () => { skipLobby: true, autoLeaveWhenOthersLeft: true, waitForCallPickup: false, - callIntent: "audio", + callIntent: RTC_CALL_INTENT_AUDIO, }); }); }); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 3459d5565..148548ad2 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -825,9 +825,7 @@ export function rtcParticipationOptions( const session = config.matrix_rtc_session; return { sendNotificationType: urlParams.sendNotificationType, - applicationData: urlParams.callIntent - ? { "m.call.intent": urlParams.callIntent } - : undefined, + callIntent: urlParams.callIntent, timings: { syncDisconnectGracePeriodMs: config.sync_disconnect_grace_period_ms, networkErrorRetryMs: session.network_error_retry_ms, diff --git a/src/room/RingingAudioRenderer.test.tsx b/src/room/RingingAudioRenderer.test.tsx index 4d95ffacb..8ef3b863e 100644 --- a/src/room/RingingAudioRenderer.test.tsx +++ b/src/room/RingingAudioRenderer.test.tsx @@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details. import { expect, type MockedFunction, test, vi } from "vitest"; import { act, render } from "@testing-library/react"; import { BehaviorSubject } from "rxjs"; +import { RTC_CALL_INTENT_AUDIO } from "matrix-js-sdk/lib/matrixrtc"; import { useAudioContext } from "../useAudioContext"; import { createRingingMedia } from "../state/media/RingingMediaViewModel"; @@ -39,7 +40,7 @@ test("ringtone plays on loop while ringing", () => { userId: alice.userId, displayName$: constant("Alice"), mxcAvatarUrl$: constant(undefined), - intent: "audio", + intent: RTC_CALL_INTENT_AUDIO, pickupState$, }); diff --git a/src/state/AndroidControlledAudioOutput.test.ts b/src/state/AndroidControlledAudioOutput.test.ts index 31ccf2c17..cf1aa6d33 100644 --- a/src/state/AndroidControlledAudioOutput.test.ts +++ b/src/state/AndroidControlledAudioOutput.test.ts @@ -7,7 +7,11 @@ Please see LICENSE in the repository root for full details. import { it, vi, expect, beforeEach, afterEach, describe } from "vitest"; import { firstValueFrom, of, Subject, take, toArray } from "rxjs"; -import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc"; +import { + RTC_CALL_INTENT_AUDIO, + RTC_CALL_INTENT_VIDEO, + type RTCCallIntent, +} from "matrix-js-sdk/lib/matrixrtc"; import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput.ts"; import type { Controls, OutputDevice } from "../controls"; @@ -117,7 +121,7 @@ describe("Default selection", () => { const controlledAudioOutput = new AndroidControlledAudioOutput( of(BASE_DEVICE_LIST), testScope, - "video", + RTC_CALL_INTENT_VIDEO, mockControls, ); @@ -142,7 +146,7 @@ describe("Default selection", () => { const controlledAudioOutput = new AndroidControlledAudioOutput( of(BASE_DEVICE_LIST), testScope, - "audio", + RTC_CALL_INTENT_AUDIO, mockControls, ); @@ -163,7 +167,7 @@ describe("Default selection", () => { }); }); - ["audio", "video"].forEach((callIntent) => { + [RTC_CALL_INTENT_AUDIO, RTC_CALL_INTENT_VIDEO].forEach((callIntent) => { it(`Default to BT headset for ${callIntent} calls if present`, async () => { const controlledAudioOutput = new AndroidControlledAudioOutput( of(BT_HEADSET_BASE_DEVICE_LIST), @@ -190,7 +194,7 @@ describe("Default selection", () => { }); }); - ["audio", "video"].forEach((callIntent) => { + [RTC_CALL_INTENT_AUDIO, RTC_CALL_INTENT_VIDEO].forEach((callIntent) => { it(`Default to wired headset for ${callIntent} calls if present`, async () => { const controlledAudioOutput = new AndroidControlledAudioOutput( of(WIRED_HEADSET_BASE_DEVICE_LIST), @@ -300,7 +304,7 @@ describe("Test select a device", () => { b: BT_HEADSET_BASE_DEVICE_LIST, }), testScope, - "audio", + RTC_CALL_INTENT_AUDIO, mockControls, ); @@ -340,7 +344,7 @@ describe("Test select a device", () => { c: BASE_DEVICE_LIST, }), testScope, - "audio", + RTC_CALL_INTENT_AUDIO, mockControls, ); @@ -393,7 +397,7 @@ describe("Available device changes", () => { }); it("When a BT headset is added, control should switch to use it", () => { - createAudioControlledOutput("video"); + createAudioControlledOutput(RTC_CALL_INTENT_VIDEO); // Emit the base device list, the speaker should be selected availableSource$.next(BASE_DEVICE_LIST); @@ -419,7 +423,9 @@ describe("Available device changes", () => { // Android does not set `isExternalHeadset` to true for wired headphones, so we can't test this case.' it.skip("When a wired headset is added, control should switch to use it", async () => { - const controlledAudioOutput = createAudioControlledOutput("video"); + const controlledAudioOutput = createAudioControlledOutput( + RTC_CALL_INTENT_VIDEO, + ); // Emit the base device list, the speaker should be selected availableSource$.next(BASE_DEVICE_LIST); @@ -446,7 +452,7 @@ describe("Available device changes", () => { }); it("When the active bt headset is removed on audio call, control should switch to earpiece", () => { - createAudioControlledOutput("audio"); + createAudioControlledOutput(RTC_CALL_INTENT_AUDIO); // Emit the BT headset device list, the BT headset should be selected availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST); @@ -472,7 +478,7 @@ describe("Available device changes", () => { }); it("When the active bt headset is removed on video call, control should switch to speaker", () => { - createAudioControlledOutput("video"); + createAudioControlledOutput(RTC_CALL_INTENT_VIDEO); availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST); @@ -497,7 +503,7 @@ describe("Available device changes", () => { }); it("Do not repeatidly set the same device", () => { - createAudioControlledOutput("video"); + createAudioControlledOutput(RTC_CALL_INTENT_VIDEO); availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST); availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST); diff --git a/src/state/AndroidControlledAudioOutput.ts b/src/state/AndroidControlledAudioOutput.ts index 65ce20a05..57b7cb399 100644 --- a/src/state/AndroidControlledAudioOutput.ts +++ b/src/state/AndroidControlledAudioOutput.ts @@ -22,7 +22,10 @@ import { type SelectedAudioOutputDevice, } from "./MediaDevices.ts"; import type { ObservableScope } from "./ObservableScope.ts"; -import type { RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc"; +import { + RTC_CALL_INTENT_AUDIO, + type RTCCallIntent, +} from "matrix-js-sdk/lib/matrixrtc"; import { type Controls, type OutputDevice } from "../controls.ts"; import { type Behavior } from "./Behavior.ts"; @@ -102,7 +105,7 @@ export class AndroidControlledAudioOutput implements MediaDevice< * @constructor * @param controlledDevices$ - The list of available output devices coming from the hosting application, ordered by preference order (most preferred first). * @param scope - The ObservableScope to create the Behaviors in. - * @param initialIntent - The initial call intent (e.g. "audio" or "video") that can be used to determine the default audio routing (e.g. default to earpiece for audio calls and speaker for video calls). + * @param initialIntent - The initial call intent (e.g. "m.audio" or "m.video") that can be used to determine the default audio routing (e.g. default to earpiece for audio calls and speaker for video calls). * @param controls - The controls provided by the hosting application to control the audio routing and notify of user actions. */ public constructor( @@ -316,7 +319,7 @@ export class AndroidControlledAudioOutput implements MediaDevice< this.logger.debug( `Android routing logic intent: ${this.initialIntent} finding best default...`, ); - if (this.initialIntent === "audio") { + if (this.initialIntent === RTC_CALL_INTENT_AUDIO) { const systemProposed = available[0]; // If no headset is connected, android will route to the speaker by default, // but for a voice call we want to route to the earpiece instead, diff --git a/src/state/CallViewModel/CallNotificationLifecycle.test.ts b/src/state/CallViewModel/CallNotificationLifecycle.test.ts index a624f5fef..12a964ca1 100644 --- a/src/state/CallViewModel/CallNotificationLifecycle.test.ts +++ b/src/state/CallViewModel/CallNotificationLifecycle.test.ts @@ -12,7 +12,11 @@ import { MatrixEvent, type Room, } from "matrix-js-sdk"; -import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc"; +import { + RTC_CALL_INTENT_AUDIO, + RTC_CALL_INTENT_VIDEO, + type RTCCallIntent, +} from "matrix-js-sdk/lib/matrixrtc"; import { map, mergeMap, NEVER, type Observable, startWith } from "rxjs"; import { withTestScheduler } from "../../utils/test"; @@ -33,10 +37,12 @@ function mockRingEvent( eventId: string, lifetimeMs: number | undefined, sender = local.userId, + legacyIntent?: string, ): CallNotificationWrapper { return { event_id: eventId, ...(lifetimeMs === undefined ? {} : { lifetime: lifetimeMs }), + ...(legacyIntent === undefined ? {} : { "m.call.intent": legacyIntent }), notification_type: "ring", sender, } as unknown as CallNotificationWrapper; @@ -128,7 +134,25 @@ test("ring attempt times out after nobody joins", () => { }); expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", { - a: { intent: "audio", recipient: alice.userId }, + a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId }, + A: { outcome: "timeout" }, + }); + }); +}); + +test("ring attempt maps the legacy intent on the notification to its stable value", () => { + withTestScheduler(({ scope, expectObservable, hot }) => { + const { ringAttempts$ } = createCallNotificationLifecycle$({ + scope, + ...defaultProps, + memberUserIds$: constant([]), + sentCallNotification$: hot("-a", { + a: mockRingEvent("$notif1", 30, local.userId, "video"), + }), + }); + + expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", { + a: { intent: RTC_CALL_INTENT_VIDEO, recipient: alice.userId }, A: { outcome: "timeout" }, }); }); @@ -148,7 +172,7 @@ test("ring attempt is accepted once recipient joins", () => { }); expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-aA", { - a: { intent: "audio", recipient: alice.userId }, + a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId }, A: { outcome: "accept" }, }); }); @@ -166,7 +190,7 @@ test("ring attempt is immediately accepted if recipient is already joined", () = }); expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-(aA)", { - a: { intent: "audio", recipient: alice.userId }, + a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId }, A: { outcome: "accept" }, }); }); @@ -201,7 +225,7 @@ test("ring attempt can be declined", () => { }); expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-aA", { - a: { intent: "audio", recipient: alice.userId }, + a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId }, A: { outcome: "decline" }, }); }); @@ -236,7 +260,7 @@ test("ring attempt times out if recipient declines too late", () => { }); expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", { - a: { intent: "audio", recipient: alice.userId }, + a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId }, A: { outcome: "timeout" }, }); }); @@ -271,7 +295,7 @@ test("decline event relating to wrong event is ignored (times out)", () => { }); expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", { - a: { intent: "audio", recipient: alice.userId }, + a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId }, A: { outcome: "timeout" }, }); }); @@ -306,7 +330,7 @@ test("decline event from wrong sender is ignored (times out)", () => { }); expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", { - a: { intent: "audio", recipient: alice.userId }, + a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId }, A: { outcome: "timeout" }, }); }); diff --git a/src/state/CallViewModel/CallNotificationLifecycle.ts b/src/state/CallViewModel/CallNotificationLifecycle.ts index ec165996e..6370adb6e 100644 --- a/src/state/CallViewModel/CallNotificationLifecycle.ts +++ b/src/state/CallViewModel/CallNotificationLifecycle.ts @@ -9,7 +9,9 @@ import { type IRTCNotificationContent, type MatrixRTCSession, MatrixRTCSessionEvent, + RTC_CALL_INTENT_AUDIO, type RTCCallIntent, + toStableCallIntent, } from "matrix-js-sdk/lib/matrixrtc"; import { filter, @@ -155,7 +157,10 @@ export function createCallNotificationLifecycle$({ ); return of({ - intent: notificationEvent["m.call.intent"] ?? "audio", + // The notification still carries the legacy intent values + intent: + toStableCallIntent(notificationEvent["m.call.intent"]) ?? + RTC_CALL_INTENT_AUDIO, recipient, outcome$: race(timeout$, accept$, decline$).pipe( take(1), diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index 84efc79c7..77787bef1 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -21,6 +21,7 @@ import { type CallMembership, type IRTCNotificationContent, MatrixRTCSessionEvent, + RTC_CALL_INTENT_AUDIO, } from "matrix-js-sdk/lib/matrixrtc"; import { deepCompare } from "matrix-js-sdk/lib/utils"; @@ -1557,7 +1558,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => { b: expect.objectContaining({ type: "ringing", userId: alice.userId, - intent: "audio", + intent: RTC_CALL_INTENT_AUDIO, }), }); // Layout should show placeholder media for the participant we're @@ -1603,7 +1604,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => { b: expect.objectContaining({ type: "ringing", userId: alice.userId, - intent: "audio", + intent: RTC_CALL_INTENT_AUDIO, }), }); // Layout should show placeholder media for the participant we're diff --git a/src/state/IOSControlledAudioOutput.test.ts b/src/state/IOSControlledAudioOutput.test.ts index a1e53c5d6..d6d7dc7cf 100644 --- a/src/state/IOSControlledAudioOutput.test.ts +++ b/src/state/IOSControlledAudioOutput.test.ts @@ -21,6 +21,10 @@ import { EARPIECE_CONFIG_ID, IOSControlledAudioOutput, } from "./IOSControlledAudioOutput"; +import { + RTC_CALL_INTENT_AUDIO, + RTC_CALL_INTENT_VIDEO, +} from "matrix-js-sdk/lib/matrixrtc"; // `vi.mock` calls are hoisted above all imports, so the static imports below // already see these mocks. Force the iOS platform so that the virtual earpiece @@ -81,7 +85,7 @@ describe("Default selection", () => { const output = new IOSControlledAudioOutput( constant(false), testScope, - "audio", + RTC_CALL_INTENT_AUDIO, ); const selected = latestSelection(output); @@ -100,7 +104,7 @@ describe("Default selection", () => { const output = new IOSControlledAudioOutput( constant(false), testScope, - "video", + RTC_CALL_INTENT_VIDEO, ); const selected = latestSelection(output); @@ -113,7 +117,7 @@ describe("Default selection", () => { const output = new IOSControlledAudioOutput( constant(false), testScope, - "audio", + RTC_CALL_INTENT_AUDIO, ); const selected = latestSelection(output); @@ -130,7 +134,7 @@ describe("Explicit selection", () => { const output = new IOSControlledAudioOutput( constant(false), testScope, - "audio", + RTC_CALL_INTENT_AUDIO, ); const selected = latestSelection(output); @@ -150,7 +154,7 @@ describe("Explicit selection", () => { const output = new IOSControlledAudioOutput( constant(false), testScope, - "audio", + RTC_CALL_INTENT_AUDIO, ); const selected = latestSelection(output); diff --git a/src/state/IOSControlledAudioOutput.ts b/src/state/IOSControlledAudioOutput.ts index c366c4fef..7581658f9 100644 --- a/src/state/IOSControlledAudioOutput.ts +++ b/src/state/IOSControlledAudioOutput.ts @@ -8,7 +8,10 @@ Please see LICENSE in the repository root for full details. import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { combineLatest, merge, startWith, Subject, tap } from "rxjs"; -import type { RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc"; +import { + RTC_CALL_INTENT_AUDIO, + type RTCCallIntent, +} from "matrix-js-sdk/lib/matrixrtc"; import { availableOutputDevices$ as controlledAvailableOutputDevices$, outputDevice$ as controlledOutputSelection$, @@ -111,7 +114,7 @@ export class IOSControlledAudioOutput implements MediaDevice< * Chooses the default output device when no explicit selection (from the user * or the hosting application) has been made yet. * - * For voice calls (`initialIntent === "audio"`) we want to start on the + * For voice calls (`initialIntent === "m.audio"`) we want to start on the * earpiece rather than the speaker, like a regular phone call. We only * override when the device that would otherwise be the default is the * speaker: if the host already routed to a headset (e.g. Bluetooth) — which @@ -122,7 +125,7 @@ export class IOSControlledAudioOutput implements MediaDevice< available: Map, ): string | undefined { const firstId = available.keys().next().value; - if (this.initialIntent === "audio") { + if (this.initialIntent === RTC_CALL_INTENT_AUDIO) { const firstLabel = firstId !== undefined ? available.get(firstId) : undefined; if (firstLabel?.type === "speaker") { diff --git a/src/state/MediaDevices.test.ts b/src/state/MediaDevices.test.ts index 69c23765c..efe284075 100644 --- a/src/state/MediaDevices.test.ts +++ b/src/state/MediaDevices.test.ts @@ -35,6 +35,10 @@ import { AudioOutput, MediaDevices } from "./MediaDevices"; import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput"; import { IOSControlledAudioOutput } from "./IOSControlledAudioOutput"; import { ObservableScope } from "./ObservableScope"; +import { + RTC_CALL_INTENT_AUDIO, + RTC_CALL_INTENT_VIDEO, +} from "matrix-js-sdk/lib/matrixrtc"; // Which audio output implementation is used is decided by what the app hosting // Element Call told it, rather than being discovered from the environment. @@ -52,7 +56,7 @@ describe("MediaDevices audio output", () => { const devices = new MediaDevices(new ObservableScope(), { controlledAudioDevices: true, - callIntent: "audio", + callIntent: RTC_CALL_INTENT_AUDIO, }); expect(devices.audioOutput).toBeInstanceOf(AndroidControlledAudioOutput); @@ -63,7 +67,7 @@ describe("MediaDevices audio output", () => { const devices = new MediaDevices(new ObservableScope(), { controlledAudioDevices: true, - callIntent: "video", + callIntent: RTC_CALL_INTENT_VIDEO, }); expect(devices.audioOutput).toBeInstanceOf(IOSControlledAudioOutput); diff --git a/src/state/initialMuteState.test.ts b/src/state/initialMuteState.test.ts index 020a80b8c..3668dbe89 100644 --- a/src/state/initialMuteState.test.ts +++ b/src/state/initialMuteState.test.ts @@ -6,7 +6,11 @@ Please see LICENSE in the repository root for full details. */ import { test, expect } from "vitest"; -import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc"; +import { + RTC_CALL_INTENT_AUDIO, + RTC_CALL_INTENT_VIDEO, + type RTCCallIntent, +} from "matrix-js-sdk/lib/matrixrtc"; import { calculateInitialMuteState } from "./initialMuteState"; @@ -14,10 +18,10 @@ test.each<{ callIntent: RTCCallIntent; allowJoinUnmutedViaIntent: boolean; }>([ - { callIntent: "audio", allowJoinUnmutedViaIntent: false }, - { callIntent: "audio", allowJoinUnmutedViaIntent: true }, - { callIntent: "video", allowJoinUnmutedViaIntent: false }, - { callIntent: "video", allowJoinUnmutedViaIntent: true }, + { callIntent: RTC_CALL_INTENT_AUDIO, allowJoinUnmutedViaIntent: false }, + { callIntent: RTC_CALL_INTENT_AUDIO, allowJoinUnmutedViaIntent: true }, + { callIntent: RTC_CALL_INTENT_VIDEO, allowJoinUnmutedViaIntent: false }, + { callIntent: RTC_CALL_INTENT_VIDEO, allowJoinUnmutedViaIntent: true }, { callIntent: "unknown", allowJoinUnmutedViaIntent: false }, { callIntent: "unknown", allowJoinUnmutedViaIntent: true }, ])( @@ -29,15 +33,15 @@ test.each<{ allowJoinUnmutedViaIntent, ); expect(audioEnabled).toBe(true); - expect(videoEnabled).toBe(callIntent !== "audio"); + expect(videoEnabled).toBe(callIntent !== RTC_CALL_INTENT_AUDIO); }, ); test.each<{ callIntent: RTCCallIntent; }>([ - { callIntent: "audio" }, - { callIntent: "video" }, + { callIntent: RTC_CALL_INTENT_AUDIO }, + { callIntent: RTC_CALL_INTENT_VIDEO }, { callIntent: "unknown" }, ])( "Should always mute on start if skipping lobby and the host does not vouch for the intent (callIntent: $callIntent)", @@ -55,8 +59,8 @@ test.each<{ test.each<{ callIntent: RTCCallIntent; }>([ - { callIntent: "audio" }, - { callIntent: "video" }, + { callIntent: RTC_CALL_INTENT_AUDIO }, + { callIntent: RTC_CALL_INTENT_VIDEO }, { callIntent: "unknown" }, ])( "Can start unmuted if skipping lobby and the host vouches for the intent (callIntent: $callIntent)", @@ -67,6 +71,6 @@ test.each<{ true, ); expect(audioEnabled).toBe(true); - expect(videoEnabled).toBe(callIntent !== "audio"); + expect(videoEnabled).toBe(callIntent !== RTC_CALL_INTENT_AUDIO); }, ); diff --git a/src/state/initialMuteState.ts b/src/state/initialMuteState.ts index 51342e895..9dffbab6c 100644 --- a/src/state/initialMuteState.ts +++ b/src/state/initialMuteState.ts @@ -6,7 +6,10 @@ Please see LICENSE in the repository root for full details. */ import { logger } from "matrix-js-sdk/lib/logger"; -import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc"; +import { + RTC_CALL_INTENT_AUDIO, + type RTCCallIntent, +} from "matrix-js-sdk/lib/matrixrtc"; /** * Calculates the initial mute state for media devices based on configuration. @@ -40,10 +43,10 @@ export function calculateInitialMuteState( // A host that vouches for the intent is a trusted environment, so it allows // unmuted by default. Same for when showing a lobby, as users can adjust - // their settings there. Additionally, if the call intent is "audio", we + // their settings there. Additionally, if the call intent is "m.audio", we // disable video by default. return { audioEnabled: true, - videoEnabled: callIntent != "audio", + videoEnabled: callIntent != RTC_CALL_INTENT_AUDIO, }; } diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx index f7867c247..93f0fdaf5 100644 --- a/src/tile/GridTile.test.tsx +++ b/src/tile/GridTile.test.tsx @@ -8,7 +8,10 @@ Please see LICENSE in the repository root for full details. import { test, expect } from "vitest"; import { act, render, screen } from "@testing-library/react"; import { axe } from "vitest-axe"; -import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; +import { + RTC_CALL_INTENT_AUDIO, + type MatrixRTCSession, +} from "matrix-js-sdk/lib/matrixrtc"; import { BehaviorSubject } from "rxjs"; import { GridTile } from "./GridTile"; @@ -122,7 +125,7 @@ test("GridTile displays ringing media", async () => { const vm = createRingingMedia({ pickupState$, id: "test", - intent: "audio", + intent: RTC_CALL_INTENT_AUDIO, userId: "@alice:example.org", displayName$: constant("Alice"), mxcAvatarUrl$: constant(undefined), diff --git a/src/tile/RingingStatus.tsx b/src/tile/RingingStatus.tsx index 384d3c65b..bedc39265 100644 --- a/src/tile/RingingStatus.tsx +++ b/src/tile/RingingStatus.tsx @@ -12,6 +12,7 @@ import { EndCallIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import { useTranslation } from "react-i18next"; +import { RTC_CALL_INTENT_VIDEO } from "matrix-js-sdk/lib/matrixrtc"; import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel"; import { useBehavior } from "../useBehavior"; @@ -25,7 +26,7 @@ export const RingingStatus: FC = ({ vm }) => { const pickupState = useBehavior(vm.pickupState$); const Icon = pickupState === "ringing" - ? vm.intent === "video" + ? vm.intent === RTC_CALL_INTENT_VIDEO ? VideoCallSolidIcon : VoiceCallSolidIcon : EndCallIcon; diff --git a/src/tile/SpotlightTile.test.tsx b/src/tile/SpotlightTile.test.tsx index 3adc62da2..e61ccf400 100644 --- a/src/tile/SpotlightTile.test.tsx +++ b/src/tile/SpotlightTile.test.tsx @@ -11,6 +11,7 @@ import { axe } from "vitest-axe"; import userEvent from "@testing-library/user-event"; import { TooltipProvider } from "@vector-im/compound-web"; import { BehaviorSubject } from "rxjs"; +import { RTC_CALL_INTENT_AUDIO } from "matrix-js-sdk/lib/matrixrtc"; import { SpotlightTile } from "./SpotlightTile"; import { @@ -176,7 +177,7 @@ test("SpotlightTile displays ringing media", async () => { const vm = createRingingMedia({ pickupState$, id: "test", - intent: "audio", + intent: RTC_CALL_INTENT_AUDIO, userId: "@alice:example.org", displayName$: constant("Alice"), mxcAvatarUrl$: constant(undefined),