From e475f56af59ce952453ced57b530bf5da0232f3d Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 25 Aug 2025 13:49:01 +0200 Subject: [PATCH 01/15] waitForNotificationAnswer Signed-off-by: Timo K --- src/UrlParams.ts | 12 ++ src/room/GroupCallView.tsx | 1 - src/room/InCallView.tsx | 3 +- src/state/CallViewModel.test.ts | 214 +++++++++++++++++++++++++++++++- src/state/CallViewModel.ts | 192 ++++++++++++++++++++++------ yarn.lock | 9 +- 6 files changed, 384 insertions(+), 47 deletions(-) diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 30019d36..f2ee5d4d 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -216,6 +216,17 @@ export interface UrlConfiguration { * This is one part to make the call matrixRTC session behave like a telephone call. */ autoLeaveWhenOthersLeft: boolean; + + /** + * If the client should show behave like it is awaiting an answer if a notification was sent. + * This is a no-op if not combined with sendNotificationType. + * + * This entails: + * - show ui that it is awaiting an answer + * - play a sound that indicates that it is awaiting an answer + * - auto-dismiss the call widget once the notification lifetime expires on the receivers side. + */ + awaitingAnswer: boolean; } // If you need to add a new flag to this interface, prefer a name that describes @@ -442,6 +453,7 @@ export const getUrlParams = ( "ring", "notification", ]), + awaitingAnswer: parser.getFlag("showAwaitingAnswerFeedback"), autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"), }; diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index ea57bd10..63fc942f 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -453,7 +453,6 @@ export const GroupCallView: FC = ({ matrixInfo={matrixInfo} rtcSession={rtcSession as MatrixRTCSession} matrixRoom={room} - participantCount={participantCount} onLeave={onLeave} header={header} muteStates={muteStates} diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 4e3229a5..16e03987 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -216,7 +216,6 @@ export interface InCallViewProps { matrixRoom: MatrixRoom; livekitRoom: LivekitRoom; muteStates: MuteStates; - participantCount: number; /** Function to call when the user explicitly ends the call */ onLeave: () => void; header: HeaderStyle; @@ -233,7 +232,6 @@ export const InCallView: FC = ({ matrixRoom, livekitRoom, muteStates, - participantCount, onLeave, header: headerStyle, connState, @@ -312,6 +310,7 @@ export const InCallView: FC = ({ () => void toggleRaisedHand(), ); + const participantCount = useBehavior(vm.participantCount$); const reconnecting = useBehavior(vm.reconnecting$); const windowMode = useBehavior(vm.windowMode$); const layout = useBehavior(vm.layout$); diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 30fbad36..a4f5d2a3 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { test, vi, onTestFinished, it } from "vitest"; +import { test, vi, onTestFinished, it, describe } from "vitest"; import EventEmitter from "events"; import { BehaviorSubject, @@ -32,6 +32,9 @@ import { Status, type CallMembership, type MatrixRTCSession, + type IRTCNotificationContent, + type ICallNotifyContent, + MatrixRTCSessionEvent, } from "matrix-js-sdk/lib/matrixrtc"; import { deepCompare } from "matrix-js-sdk/lib/utils"; @@ -1228,6 +1231,215 @@ test("autoLeaveWhenOthersLeft$ doesn't emits when autoLeaveWhenOthersLeft option }); }); +describe("waitForNotificationAnswer$", () => { + test("unknown -> ringing -> timeout when notified and nobody joins", () => { + withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + // No one ever joins (only local user) + withCallViewModel( + { + remoteParticipants$: scope.behavior(hot("a", { a: [] }), []), + rtcMembers$: scope.behavior(hot("a", { a: [localRtcMember] }), []), + connectionState$: of(ConnectionState.Connected), + speaking: new Map(), + mediaDevices: mockMediaDevices({}), + }, + (vm, rtcSession) => { + // Fire a call notification at 10ms with lifetime 30ms + schedule(" 10ms r", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + { lifetime: 30 } as unknown as IRTCNotificationContent, + {} as unknown as ICallNotifyContent, + ); + }, + }); + + expectObservable(vm.waitForNotificationAnswer$).toBe( + "a 9ms b 29ms c", + { a: "unknown", b: "ringing", c: "timeout" }, + ); + }, + { + waitForNotificationAnswer: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + test("ringing -> success if someone joins before timeout", () => { + withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + // Someone joins at 20ms (both LiveKit participant and MatrixRTC member) + const remote$ = scope.behavior( + hot("a--b", { a: [], b: [aliceParticipant] }), + [], + ); + const rtc$ = scope.behavior( + hot("a--b", { + a: [localRtcMember], + b: [localRtcMember, aliceRtcMember], + }), + [], + ); + + withCallViewModel( + { + remoteParticipants$: remote$, + rtcMembers$: rtc$, + connectionState$: of(ConnectionState.Connected), + speaking: new Map(), + mediaDevices: mockMediaDevices({}), + }, + (vm, rtcSession) => { + // Notify at 5ms so we enter ringing, then success at 20ms + schedule(" 5ms r", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + { lifetime: 100 } as unknown as IRTCNotificationContent, + {} as unknown as ICallNotifyContent, + ); + }, + }); + + expectObservable(vm.waitForNotificationAnswer$).toBe("a 2ms c", { + a: "unknown", + c: "success", + }); + }, + { + waitForNotificationAnswer: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + test("success when someone joins before we notify", () => { + withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + // Join at 10ms, notify later at 20ms (state should stay success) + const remote$ = scope.behavior( + hot("a-b", { a: [], b: [aliceParticipant] }), + [], + ); + const rtc$ = scope.behavior( + hot("a-b", { + a: [localRtcMember], + b: [localRtcMember, aliceRtcMember], + }), + [], + ); + + withCallViewModel( + { + remoteParticipants$: remote$, + rtcMembers$: rtc$, + connectionState$: of(ConnectionState.Connected), + speaking: new Map(), + mediaDevices: mockMediaDevices({}), + }, + (vm, rtcSession) => { + schedule(" 20ms r", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + { lifetime: 50 } as unknown as IRTCNotificationContent, + {} as unknown as ICallNotifyContent, + ); + }, + }); + expectObservable(vm.waitForNotificationAnswer$).toBe("a 1ms b", { + a: "unknown", + b: "success", + }); + }, + { + waitForNotificationAnswer: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + test("notify without lifetime -> immediate timeout", () => { + withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + withCallViewModel( + { + remoteParticipants$: scope.behavior(hot("a", { a: [] }), []), + rtcMembers$: scope.behavior(hot("a", { a: [localRtcMember] }), []), + connectionState$: of(ConnectionState.Connected), + speaking: new Map(), + mediaDevices: mockMediaDevices({}), + }, + (vm, rtcSession) => { + schedule(" 10ms r", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + { lifetime: 0 } as unknown as IRTCNotificationContent, // no lifetime + {} as unknown as ICallNotifyContent, + ); + }, + }); + expectObservable(vm.waitForNotificationAnswer$).toBe("a 9ms b", { + a: "unknown", + b: "timeout", + }); + }, + { + waitForNotificationAnswer: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + test("stays null when waitForNotificationAnswer=false", () => { + withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + const remote$ = scope.behavior( + hot("a--b", { a: [], b: [aliceParticipant] }), + [], + ); + const rtc$ = scope.behavior( + hot("a--b", { + a: [localRtcMember], + b: [localRtcMember, aliceRtcMember], + }), + [], + ); + + withCallViewModel( + { + remoteParticipants$: remote$, + rtcMembers$: rtc$, + connectionState$: of(ConnectionState.Connected), + speaking: new Map(), + mediaDevices: mockMediaDevices({}), + }, + (vm, rtcSession) => { + schedule(" 5ms r", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + { lifetime: 30 } as unknown as IRTCNotificationContent, + {} as unknown as ICallNotifyContent, + ); + }, + }); + expectObservable(vm.waitForNotificationAnswer$).toBe("(n)", { + n: null, + }); + }, + { + waitForNotificationAnswer: false, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); +}); + test("audio output changes when toggling earpiece mode", () => { withTestScheduler(({ schedule, expectObservable }) => { getUrlParams.mockReturnValue({ controlledAudioDevices: true }); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index d7bf1812..f22269c0 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -53,6 +53,8 @@ import { import { logger } from "matrix-js-sdk/lib/logger"; import { type CallMembership, + type ICallNotifyContent, + type IRTCNotificationContent, type MatrixRTCSession, MatrixRTCSessionEvent, MembershipManagerEvent, @@ -110,7 +112,13 @@ import { type Behavior } from "./Behavior"; export interface CallViewModelOptions { encryptionSystem: EncryptionSystem; autoLeaveWhenOthersLeft?: boolean; + /** + * If the call is started in a way where we want it to behave like a telephone usecase + * If we sent a notification event, we want the ui to show a ringing state + */ + waitForNotificationAnswer?: boolean; } + // How long we wait after a focus switch before showing the real participant // list again const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000; @@ -427,7 +435,14 @@ export class CallViewModel extends ViewModel { MembershipManagerEvent.StatusChanged, ).pipe( startWith(null), - map(() => this.matrixRTCSession.membershipStatus === Status.Connected), + map( + () => + ( + this.matrixRTCSession as unknown as { + membershipStatus?: Status; + } + ).membershipStatus === Status.Connected, + ), ), // Also watch out for warnings that we've likely hit a timeout and our // delayed leave event is being sent (this condition is here because it @@ -438,7 +453,11 @@ export class CallViewModel extends ViewModel { MembershipManagerEvent.ProbablyLeft, ).pipe( startWith(null), - map(() => this.matrixRTCSession.probablyLeft !== true), + map( + () => + (this.matrixRTCSession as unknown as { probablyLeft?: boolean }) + .probablyLeft !== true, + ), ), ), ); @@ -825,49 +844,68 @@ export class CallViewModel extends ViewModel { * - Each participant has a corresponding MatrixRTC membership state event * - There can be multiple participants for one matrix user. */ - public readonly participantChanges$ = this.userMedia$.pipe( - map((mediaItems) => mediaItems.map((m) => m.id)), - scan( - (prev, ids) => { - const left = prev.ids.filter((id) => !ids.includes(id)); - const joined = ids.filter((id) => !prev.ids.includes(id)); - return { ids, joined, left }; - }, - { ids: [], joined: [], left: [] }, + public readonly participantChanges$ = this.scope.behavior( + this.userMedia$.pipe( + map((mediaItems) => mediaItems.map((m) => m.id)), + scan( + (prev, ids) => { + const left = prev.ids.filter((id) => !ids.includes(id)); + const joined = ids.filter((id) => !prev.ids.includes(id)); + return { ids, joined, left }; + }, + { ids: [], joined: [], left: [] }, + ), ), ); + /** + * The number of participants currently in the call. + * + * - Each participant has one livekit connection + * - Each participant has a corresponding MatrixRTC membership state event + * - There can be multiple participants for one matrix user. + */ + public readonly participantCount$ = this.scope.behavior( + this.participantChanges$.pipe(map(({ ids }) => ids.length)), + ); + /** * This observable tracks the matrix users that are currently in the call. * There can be just one matrix user with multiple participants (see also participantChanges$) */ - public readonly matrixUserChanges$ = this.userMedia$.pipe( - map( - (mediaItems) => - new Set( - mediaItems - .map((m) => m.vm.member?.userId) - .filter((id) => id !== undefined), - ), - ), - scan< - Set, - { - userIds: Set; - joinedUserIds: Set; - leftUserIds: Set; - } - >( - (prevState, userIds) => { - const left = new Set( - [...prevState.userIds].filter((id) => !userIds.has(id)), - ); - const joined = new Set( - [...userIds].filter((id) => !prevState.userIds.has(id)), - ); - return { userIds: userIds, joinedUserIds: joined, leftUserIds: left }; - }, - { userIds: new Set(), joinedUserIds: new Set(), leftUserIds: new Set() }, + public readonly matrixUserChanges$ = this.scope.behavior( + this.userMedia$.pipe( + map( + (mediaItems) => + new Set( + mediaItems + .map((m) => m.vm.member?.userId) + .filter((id) => id !== undefined), + ), + ), + scan< + Set, + { + userIds: Set; + joinedUserIds: Set; + leftUserIds: Set; + } + >( + (prevState, userIds) => { + const left = new Set( + [...prevState.userIds].filter((id) => !userIds.has(id)), + ); + const joined = new Set( + [...userIds].filter((id) => !prevState.userIds.has(id)), + ); + return { userIds: userIds, joinedUserIds: joined, leftUserIds: left }; + }, + { + userIds: new Set(), + joinedUserIds: new Set(), + leftUserIds: new Set(), + }, + ), ), ); @@ -891,6 +929,84 @@ export class CallViewModel extends ViewModel { map(() => {}), ); + /** + * "unknown": We don't know if the RTC session decides to send a notify event yet. + * It will only be known once we sent our own membership and know we were the first one to join. + * "ringing": The notification event was sent. + * "ringEnded": The notification events lifetime has timed out -> ringing stopped on all receiving clients. + */ + private readonly notificationEventIsRingingOthers$: Observable< + "unknown" | "ringing" | "ringEnded" | null + > = fromEvent<[IRTCNotificationContent, ICallNotifyContent]>( + this.matrixRTCSession, + MatrixRTCSessionEvent.DidSendCallNotification, + ).pipe( + switchMap(([notificationEvent]) => { + // event.lifetime is expected to be in ms + const lifetime = notificationEvent?.lifetime ?? 0; + if (lifetime > 0) { + // Emit true immediately, then false after lifetime ms + return concat( + of<"ringing" | null>("ringing"), + timer(lifetime).pipe(map((): "ringEnded" | null => "ringEnded")), + ); + } + // If no lifetime, just emit true once + return of(null); + }), + startWith("unknown" as "unknown" | null), + ); + + /** + * If some other matrix user has joined the call. It can start with true if there are already multiple matrix users. + */ + private readonly someoneElseJoined$ = this.matrixUserChanges$.pipe( + scan( + (someoneJoined, { joinedUserIds }) => + someoneJoined || [...joinedUserIds].some((id) => id !== this.userId), + false, + ), + startWith(this.matrixUserChanges$.value.userIds.size > 1), + ); + + /** + * The current waiting for answer state of the call. + * - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening). + * - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership. + * Then we can conclude if we were the first one to join or not. + * - "timeout": No-one picked up in the defined time this call should be ringing on others devices. + * The call failed. If desired this can be used as a trigger to exit the call. + * - "success": Someone else joined. The call is in a normal state. Stop audiovisual feedback. + * - null: EC is configured to never show any waiting for answer state. + */ + public readonly waitForNotificationAnswer$: Behavior< + "unknown" | "ringing" | "timeout" | "success" | null + > = this.scope.behavior( + combineLatest([ + this.notificationEventIsRingingOthers$, + this.someoneElseJoined$, + ]).pipe( + map(([isRingingOthers, someoneJoined]) => { + // Never enter waiting for answer state if the app is not configured with waitingForAnswer. + if (!this.options.waitForNotificationAnswer) return null; + // As soon as someone joins, we can consider the call "wait for answer" successful + if (someoneJoined) return "success"; + + switch (isRingingOthers) { + case "unknown": + return "unknown"; + case "ringing": + return "ringing"; + case "ringEnded": + return "timeout"; + default: + return "timeout"; + } + }), + distinctUntilChanged(), + ), + ); + /** * List of MediaItems that we want to display, that are of type ScreenShare */ diff --git a/yarn.lock b/yarn.lock index 0d2cf54a..15ed17d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10278,9 +10278,9 @@ __metadata: languageName: node linkType: hard -"matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop": - version: 37.13.0 - resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=3a33c658bbcb8ce8791ec066db899f2571f5c52f" +"matrix-js-sdk@portal:/Users/timo/Projects/matrix-js-sdk::locator=element-call%40workspace%3A.": + version: 0.0.0-use.local + resolution: "matrix-js-sdk@portal:/Users/timo/Projects/matrix-js-sdk::locator=element-call%40workspace%3A." dependencies: "@babel/runtime": "npm:^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.1.0" @@ -10296,9 +10296,8 @@ __metadata: sdp-transform: "npm:^2.14.1" unhomoglyph: "npm:^1.0.6" uuid: "npm:11" - checksum: 10c0/1db0d39cfbe4f1c69c8acda0ea7580a4819fc47a7d4bff057382e33e72d9a610f8c03043a6c00bc647dfdc2815aa643c69d25022fb759342a92b77e1841524f1 languageName: node - linkType: hard + linkType: soft "matrix-widget-api@npm:^1.10.0, matrix-widget-api@npm:^1.13.0": version: 1.13.1 From acd26c61eff41c1fe17fb76fff1c7a2bf9a512c3 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 25 Aug 2025 14:02:47 +0200 Subject: [PATCH 02/15] use custom js-sdk from PR: https://github.com/matrix-org/matrix-js-sdk/pull/4976 Signed-off-by: Timo K --- package.json | 2 +- yarn.lock | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 1df79c26..68d364a8 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "livekit-client": "^2.13.0", "lodash-es": "^4.17.21", "loglevel": "^1.9.1", - "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#head=develop", + "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#a9dc1c3a38668c7561ce0eac4a7935498dd0199c", "matrix-widget-api": "^1.13.0", "normalize.css": "^8.0.1", "observable-hooks": "^4.2.3", diff --git a/yarn.lock b/yarn.lock index 15ed17d5..7e6352c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7537,7 +7537,7 @@ __metadata: livekit-client: "npm:^2.13.0" lodash-es: "npm:^4.17.21" loglevel: "npm:^1.9.1" - matrix-js-sdk: "github:matrix-org/matrix-js-sdk#head=develop" + matrix-js-sdk: "github:matrix-org/matrix-js-sdk#a9dc1c3a38668c7561ce0eac4a7935498dd0199c" matrix-widget-api: "npm:^1.13.0" normalize.css: "npm:^8.0.1" observable-hooks: "npm:^4.2.3" @@ -10278,9 +10278,9 @@ __metadata: languageName: node linkType: hard -"matrix-js-sdk@portal:/Users/timo/Projects/matrix-js-sdk::locator=element-call%40workspace%3A.": - version: 0.0.0-use.local - resolution: "matrix-js-sdk@portal:/Users/timo/Projects/matrix-js-sdk::locator=element-call%40workspace%3A." +"matrix-js-sdk@github:matrix-org/matrix-js-sdk#a9dc1c3a38668c7561ce0eac4a7935498dd0199c": + version: 37.13.0 + resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=a9dc1c3a38668c7561ce0eac4a7935498dd0199c" dependencies: "@babel/runtime": "npm:^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.1.0" @@ -10296,8 +10296,9 @@ __metadata: sdp-transform: "npm:^2.14.1" unhomoglyph: "npm:^1.0.6" uuid: "npm:11" + checksum: 10c0/1f97dd28046ff0ef0700a1c354611dd9eded5d3df5e987af1f12f60f9aebeb0bb932d1cd8685942bd1dd1d673fbf28df5ce1799a35a95d1a06b9f500a3787013 languageName: node - linkType: soft + linkType: hard "matrix-widget-api@npm:^1.10.0, matrix-widget-api@npm:^1.13.0": version: 1.13.1 From fb8de061f320d78806c7e78fc240089a4b9a8c34 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 25 Aug 2025 14:31:14 +0200 Subject: [PATCH 03/15] rename to callPickupState Signed-off-by: Timo K --- src/UrlParams.ts | 9 ++++++--- src/state/CallViewModel.test.ts | 31 ++++++++++++++++--------------- src/state/CallViewModel.ts | 8 ++++---- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/UrlParams.ts b/src/UrlParams.ts index f2ee5d4d..a26045b5 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -218,7 +218,7 @@ export interface UrlConfiguration { autoLeaveWhenOthersLeft: boolean; /** - * If the client should show behave like it is awaiting an answer if a notification was sent. + * If the client should behave like it is awaiting an answer if a notification was sent (wait for call pick up). * This is a no-op if not combined with sendNotificationType. * * This entails: @@ -226,7 +226,7 @@ export interface UrlConfiguration { * - play a sound that indicates that it is awaiting an answer * - auto-dismiss the call widget once the notification lifetime expires on the receivers side. */ - awaitingAnswer: boolean; + shouldWaitForCallPickup: boolean; } // If you need to add a new flag to this interface, prefer a name that describes @@ -358,6 +358,7 @@ export const getUrlParams = ( returnToLobby: false, sendNotificationType: "notification" as RTCNotificationType, autoLeaveWhenOthersLeft: false, + shouldWaitForCallPickup: false, }; switch (intent) { case UserIntent.StartNewCall: @@ -377,6 +378,7 @@ export const getUrlParams = ( ...inAppDefault, skipLobby: true, autoLeaveWhenOthersLeft: true, + shouldWaitForCallPickup: true, }; break; case UserIntent.JoinExistingCallDM: @@ -402,6 +404,7 @@ export const getUrlParams = ( returnToLobby: false, sendNotificationType: undefined, autoLeaveWhenOthersLeft: false, + shouldWaitForCallPickup: false, }; } @@ -453,7 +456,7 @@ export const getUrlParams = ( "ring", "notification", ]), - awaitingAnswer: parser.getFlag("showAwaitingAnswerFeedback"), + shouldWaitForCallPickup: parser.getFlag("shouldWaitForCallPickup"), autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"), }; diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index a4f5d2a3..02297dd4 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -1231,7 +1231,7 @@ test("autoLeaveWhenOthersLeft$ doesn't emits when autoLeaveWhenOthersLeft option }); }); -describe("waitForNotificationAnswer$", () => { +describe("shouldWaitForCallPickup$", () => { test("unknown -> ringing -> timeout when notified and nobody joins", () => { withTestScheduler(({ hot, schedule, expectObservable, scope }) => { // No one ever joins (only local user) @@ -1255,13 +1255,14 @@ describe("waitForNotificationAnswer$", () => { }, }); - expectObservable(vm.waitForNotificationAnswer$).toBe( - "a 9ms b 29ms c", - { a: "unknown", b: "ringing", c: "timeout" }, - ); + expectObservable(vm.callPickupState$).toBe("a 9ms b 29ms c", { + a: "unknown", + b: "ringing", + c: "timeout", + }); }, { - waitForNotificationAnswer: true, + shouldWaitForCallPickup: true, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); @@ -1303,13 +1304,13 @@ describe("waitForNotificationAnswer$", () => { }, }); - expectObservable(vm.waitForNotificationAnswer$).toBe("a 2ms c", { + expectObservable(vm.callPickupState$).toBe("a 2ms c", { a: "unknown", c: "success", }); }, { - waitForNotificationAnswer: true, + shouldWaitForCallPickup: true, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); @@ -1349,13 +1350,13 @@ describe("waitForNotificationAnswer$", () => { ); }, }); - expectObservable(vm.waitForNotificationAnswer$).toBe("a 1ms b", { + expectObservable(vm.callPickupState$).toBe("a 1ms b", { a: "unknown", b: "success", }); }, { - waitForNotificationAnswer: true, + shouldWaitForCallPickup: true, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); @@ -1382,20 +1383,20 @@ describe("waitForNotificationAnswer$", () => { ); }, }); - expectObservable(vm.waitForNotificationAnswer$).toBe("a 9ms b", { + expectObservable(vm.callPickupState$).toBe("a 9ms b", { a: "unknown", b: "timeout", }); }, { - waitForNotificationAnswer: true, + shouldWaitForCallPickup: true, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); }); }); - test("stays null when waitForNotificationAnswer=false", () => { + test("stays null when shouldWaitForCallPickup=false", () => { withTestScheduler(({ hot, schedule, expectObservable, scope }) => { const remote$ = scope.behavior( hot("a--b", { a: [], b: [aliceParticipant] }), @@ -1427,12 +1428,12 @@ describe("waitForNotificationAnswer$", () => { ); }, }); - expectObservable(vm.waitForNotificationAnswer$).toBe("(n)", { + expectObservable(vm.callPickupState$).toBe("(n)", { n: null, }); }, { - waitForNotificationAnswer: false, + shouldWaitForCallPickup: false, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index f22269c0..709d27fa 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -116,7 +116,7 @@ export interface CallViewModelOptions { * If the call is started in a way where we want it to behave like a telephone usecase * If we sent a notification event, we want the ui to show a ringing state */ - waitForNotificationAnswer?: boolean; + shouldWaitForCallPickup?: boolean; } // How long we wait after a focus switch before showing the real participant @@ -970,7 +970,7 @@ export class CallViewModel extends ViewModel { ); /** - * The current waiting for answer state of the call. + * The current call pickup state of the call. * - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening). * - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership. * Then we can conclude if we were the first one to join or not. @@ -979,7 +979,7 @@ export class CallViewModel extends ViewModel { * - "success": Someone else joined. The call is in a normal state. Stop audiovisual feedback. * - null: EC is configured to never show any waiting for answer state. */ - public readonly waitForNotificationAnswer$: Behavior< + public readonly callPickupState$: Behavior< "unknown" | "ringing" | "timeout" | "success" | null > = this.scope.behavior( combineLatest([ @@ -988,7 +988,7 @@ export class CallViewModel extends ViewModel { ]).pipe( map(([isRingingOthers, someoneJoined]) => { // Never enter waiting for answer state if the app is not configured with waitingForAnswer. - if (!this.options.waitForNotificationAnswer) return null; + if (!this.options.shouldWaitForCallPickup) return null; // As soon as someone joins, we can consider the call "wait for answer" successful if (someoneJoined) return "success"; From 04c03b1e72742fafbfde5fe4020f1082dcd55c1a Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 26 Aug 2025 17:41:58 +0200 Subject: [PATCH 04/15] bump js-sdk to develop branch Signed-off-by: Timo K --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 68d364a8..1df79c26 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "livekit-client": "^2.13.0", "lodash-es": "^4.17.21", "loglevel": "^1.9.1", - "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#a9dc1c3a38668c7561ce0eac4a7935498dd0199c", + "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#head=develop", "matrix-widget-api": "^1.13.0", "normalize.css": "^8.0.1", "observable-hooks": "^4.2.3", diff --git a/yarn.lock b/yarn.lock index 7e6352c4..723e3486 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7537,7 +7537,7 @@ __metadata: livekit-client: "npm:^2.13.0" lodash-es: "npm:^4.17.21" loglevel: "npm:^1.9.1" - matrix-js-sdk: "github:matrix-org/matrix-js-sdk#a9dc1c3a38668c7561ce0eac4a7935498dd0199c" + matrix-js-sdk: "github:matrix-org/matrix-js-sdk#head=develop" matrix-widget-api: "npm:^1.13.0" normalize.css: "npm:^8.0.1" observable-hooks: "npm:^4.2.3" @@ -10278,9 +10278,9 @@ __metadata: languageName: node linkType: hard -"matrix-js-sdk@github:matrix-org/matrix-js-sdk#a9dc1c3a38668c7561ce0eac4a7935498dd0199c": +"matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop": version: 37.13.0 - resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=a9dc1c3a38668c7561ce0eac4a7935498dd0199c" + resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=c4c7f945141e142e6f846b243c33c4af97a9a44b" dependencies: "@babel/runtime": "npm:^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.1.0" @@ -10296,7 +10296,7 @@ __metadata: sdp-transform: "npm:^2.14.1" unhomoglyph: "npm:^1.0.6" uuid: "npm:11" - checksum: 10c0/1f97dd28046ff0ef0700a1c354611dd9eded5d3df5e987af1f12f60f9aebeb0bb932d1cd8685942bd1dd1d673fbf28df5ce1799a35a95d1a06b9f500a3787013 + checksum: 10c0/caa4b8a6d924ac36a21773dc2c8be6cb6b658a9feaabccdb24426719c563ac2cfe4778abb86f0889854ae36fc7ba02a6ed39acdbc0b73fdc31ce9a9789e7f36a languageName: node linkType: hard From 36be15436ac4d42d6502b74445494a02e55ffc25 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 26 Aug 2025 17:58:46 +0200 Subject: [PATCH 05/15] fix linter Signed-off-by: Timo K --- src/state/CallViewModel.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 02297dd4..2bb810bb 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -1249,8 +1249,10 @@ describe("shouldWaitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 30 } as unknown as IRTCNotificationContent, - {} as unknown as ICallNotifyContent, + { lifetime: 30 } as unknown as { + event_id: string; + } & IRTCNotificationContent, + {} as unknown as { event_id: string } & ICallNotifyContent, ); }, }); From 03527e9d7a71a9be8ae8754bd6b1b4722b33f335 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 26 Aug 2025 18:09:50 +0200 Subject: [PATCH 06/15] fix participant count test Signed-off-by: Timo K --- src/room/InCallView.test.tsx | 1 - .../__snapshots__/InCallView.test.tsx.snap | 28 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index ec057e94..eb192f66 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -177,7 +177,6 @@ function createInCallView(): RenderResult & { }} matrixRoom={room} livekitRoom={livekitRoom} - participantCount={0} onLeave={function (): void { throw new Error("Function not implemented."); }} diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index 7d6ab966..bfbef499 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -49,6 +49,34 @@ exports[`InCallView > rendering > renders 1`] = ` +
+ + + + + + + 2 + +
Date: Tue, 26 Aug 2025 18:10:08 +0200 Subject: [PATCH 07/15] dont use deprecated `defer` Signed-off-by: Timo K --- src/livekit/useECConnectionState.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/livekit/useECConnectionState.test.tsx b/src/livekit/useECConnectionState.test.tsx index 72324884..e71ceeae 100644 --- a/src/livekit/useECConnectionState.test.tsx +++ b/src/livekit/useECConnectionState.test.tsx @@ -93,8 +93,8 @@ describe("Leaking connection prevention", () => { test("Should cancel pending connections when the component is unmounted", async () => { const connectCall = vi.fn(); - const pendingConnection = defer(); - // let pendingDisconnection = defer() + const pendingConnection = Promise.withResolvers(); + // let pendingDisconnection = Promise.withResolvers() const disconnectMock = vi.fn(); const mockRoom = { @@ -141,8 +141,8 @@ describe("Leaking connection prevention", () => { test("Should cancel about to open but not yet opened connection", async () => { const createTracksCall = vi.fn(); - const pendingCreateTrack = defer(); - // let pendingDisconnection = defer() + const pendingCreateTrack = Promise.withResolvers(); + // let pendingDisconnection = Promise.withResolvers() const disconnectMock = vi.fn(); const connectMock = vi.fn(); From f410a119344e66409b8d258233c602734bc823c1 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 26 Aug 2025 18:13:04 +0200 Subject: [PATCH 08/15] unused import Signed-off-by: Timo K --- src/livekit/useECConnectionState.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/livekit/useECConnectionState.test.tsx b/src/livekit/useECConnectionState.test.tsx index e71ceeae..883cb68c 100644 --- a/src/livekit/useECConnectionState.test.tsx +++ b/src/livekit/useECConnectionState.test.tsx @@ -15,7 +15,7 @@ import { import userEvent from "@testing-library/user-event"; import { render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; -import { defer, sleep } from "matrix-js-sdk/lib/utils"; +import { sleep } from "matrix-js-sdk/lib/utils"; import { useECConnectionState } from "./useECConnectionState"; import { type SFUConfig } from "./openIDSFU"; From c15551c9f565f39b22240d54e769f80375294d38 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 26 Aug 2025 18:29:51 +0200 Subject: [PATCH 09/15] types Signed-off-by: Timo K --- src/state/CallViewModel.test.ts | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 2bb810bb..b774700c 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -1300,8 +1300,12 @@ describe("shouldWaitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 100 } as unknown as IRTCNotificationContent, - {} as unknown as ICallNotifyContent, + { lifetime: 100 } as unknown as { + event_id: string; + } & IRTCNotificationContent, + {} as unknown as { + event_id: string; + } & ICallNotifyContent, ); }, }); @@ -1347,8 +1351,12 @@ describe("shouldWaitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 50 } as unknown as IRTCNotificationContent, - {} as unknown as ICallNotifyContent, + { lifetime: 50 } as unknown as { + event_id: string; + } & IRTCNotificationContent, + {} as unknown as { + event_id: string; + } & ICallNotifyContent, ); }, }); @@ -1380,8 +1388,12 @@ describe("shouldWaitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 0 } as unknown as IRTCNotificationContent, // no lifetime - {} as unknown as ICallNotifyContent, + { lifetime: 0 } as unknown as { + event_id: string; + } & IRTCNotificationContent, // no lifetime + {} as unknown as { + event_id: string; + } & ICallNotifyContent, ); }, }); @@ -1425,8 +1437,12 @@ describe("shouldWaitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 30 } as unknown as IRTCNotificationContent, - {} as unknown as ICallNotifyContent, + { lifetime: 30 } as unknown as { + event_id: string; + } & IRTCNotificationContent, + {} as unknown as { + event_id: string; + } & ICallNotifyContent, ); }, }); From 07522d67044d7aad81755a5dffef862a588f2c79 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 3 Sep 2025 13:03:48 +0200 Subject: [PATCH 10/15] Remove unnecessary casts --- src/state/CallViewModel.ts | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 709d27fa..cc600154 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -435,14 +435,7 @@ export class CallViewModel extends ViewModel { MembershipManagerEvent.StatusChanged, ).pipe( startWith(null), - map( - () => - ( - this.matrixRTCSession as unknown as { - membershipStatus?: Status; - } - ).membershipStatus === Status.Connected, - ), + map(() => this.matrixRTCSession.membershipStatus === Status.Connected), ), // Also watch out for warnings that we've likely hit a timeout and our // delayed leave event is being sent (this condition is here because it @@ -453,11 +446,7 @@ export class CallViewModel extends ViewModel { MembershipManagerEvent.ProbablyLeft, ).pipe( startWith(null), - map( - () => - (this.matrixRTCSession as unknown as { probablyLeft?: boolean }) - .probablyLeft !== true, - ), + map(() => this.matrixRTCSession.probablyLeft !== true), ), ), ); From 880e07c07f9edbec225a0629529983d85a76e32b Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 3 Sep 2025 16:50:43 +0200 Subject: [PATCH 11/15] Tidy some things up, refactor dialing/ringing behaviors --- src/room/CallEventAudioRenderer.test.tsx | 10 +- src/room/CallEventAudioRenderer.tsx | 49 +--- src/room/InCallView.tsx | 2 +- src/state/CallViewModel.test.ts | 122 ++++------ src/state/CallViewModel.ts | 286 ++++++++++------------- 5 files changed, 179 insertions(+), 290 deletions(-) diff --git a/src/room/CallEventAudioRenderer.test.tsx b/src/room/CallEventAudioRenderer.test.tsx index 1c515175..f51e602d 100644 --- a/src/room/CallEventAudioRenderer.test.tsx +++ b/src/room/CallEventAudioRenderer.test.tsx @@ -19,10 +19,7 @@ import { act } from "react"; import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc"; import { mockRtcMembership } from "../utils/test"; -import { - CallEventAudioRenderer, - MAX_PARTICIPANT_COUNT_FOR_SOUND, -} from "./CallEventAudioRenderer"; +import { CallEventAudioRenderer } from "./CallEventAudioRenderer"; import { useAudioContext } from "../useAudioContext"; import { prefetchSounds } from "../soundUtils"; import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel"; @@ -33,6 +30,7 @@ import { local, localRtcMember, } from "../utils/test-fixtures"; +import { MAX_PARTICIPANT_COUNT_FOR_SOUND } from "../state/CallViewModel"; vitest.mock("../useAudioContext"); vitest.mock("../soundUtils"); @@ -172,7 +170,7 @@ test("should not play a sound when a hand raise is retracted", () => { }, }); }); - expect(playSound).toHaveBeenCalledTimes(2); + expect(playSound).toHaveBeenCalledTimes(1); act(() => { handRaisedSubject$.next({ ["foo"]: { @@ -182,5 +180,5 @@ test("should not play a sound when a hand raise is retracted", () => { }, }); }); - expect(playSound).toHaveBeenCalledTimes(2); + expect(playSound).toHaveBeenCalledTimes(1); }); diff --git a/src/room/CallEventAudioRenderer.tsx b/src/room/CallEventAudioRenderer.tsx index a39da82a..797501b6 100644 --- a/src/room/CallEventAudioRenderer.tsx +++ b/src/room/CallEventAudioRenderer.tsx @@ -6,7 +6,6 @@ Please see LICENSE in the repository root for full details. */ import { type ReactNode, useEffect } from "react"; -import { filter, interval, throttle } from "rxjs"; import { type CallViewModel } from "../state/CallViewModel"; import joinCallSoundMp3 from "../sound/join_call.mp3"; @@ -21,11 +20,6 @@ import { useAudioContext } from "../useAudioContext"; import { prefetchSounds } from "../soundUtils"; import { useLatest } from "../useLatest"; -// Do not play any sounds if the participant count has exceeded this -// number. -export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8; -export const THROTTLE_SOUND_EFFECT_MS = 500; - export const callEventAudioSounds = prefetchSounds({ join: { mp3: joinCallSoundMp3, @@ -60,37 +54,18 @@ export function CallEventAudioRenderer({ const audioEngineRef = useLatest(audioEngineCtx); useEffect(() => { - const joinSub = vm.participantChanges$ - .pipe( - filter( - ({ joined, ids }) => - ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && joined.length > 0, - ), - throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)), - ) - .subscribe(() => { - void audioEngineRef.current?.playSound("join"); - }); - - const leftSub = vm.participantChanges$ - .pipe( - filter( - ({ ids, left }) => - ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && left.length > 0, - ), - throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)), - ) - .subscribe(() => { - void audioEngineRef.current?.playSound("left"); - }); - - const handRaisedSub = vm.newHandRaised$.subscribe(() => { - void audioEngineRef.current?.playSound("raiseHand"); - }); - - const screenshareSub = vm.newScreenShare$.subscribe(() => { - void audioEngineRef.current?.playSound("screenshareStarted"); - }); + const joinSub = vm.joinSoundEffect$.subscribe( + () => void audioEngineRef.current?.playSound("join"), + ); + const leftSub = vm.leaveSoundEffect$.subscribe( + () => void audioEngineRef.current?.playSound("left"), + ); + const handRaisedSub = vm.newHandRaised$.subscribe( + () => void audioEngineRef.current?.playSound("raiseHand"), + ); + const screenshareSub = vm.newScreenShare$.subscribe( + () => void audioEngineRef.current?.playSound("screenshareStarted"), + ); return (): void => { joinSub.unsubscribe(); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 16e03987..034be16d 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -321,7 +321,7 @@ export const InCallView: FC = ({ const showFooter = useBehavior(vm.showFooter$); const earpieceMode = useBehavior(vm.earpieceMode$); const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); - useSubscription(vm.autoLeaveWhenOthersLeft$, onLeave); + useSubscription(vm.autoLeave$, onLeave); // Ideally we could detect taps by listening for click events and checking // that the pointerType of the event is "touch", but this isn't yet supported diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index b774700c..a26fdc59 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -311,7 +311,7 @@ function withCallViewModel( const roomEventSelectorSpy = vi .spyOn(ComponentsCore, "roomEventSelector") - .mockImplementation((room, eventType) => of()); + .mockImplementation((_room, _eventType) => of()); const livekitRoom = mockLivekitRoom( { localParticipant }, @@ -1071,9 +1071,9 @@ it("should rank raised hands above video feeds and below speakers and presenters }); function nooneEverThere$( - hot: (marbles: string, values: Record) => Observable, -): Observable { - return hot("a-b-c-d", { + behavior: (marbles: string, values: Record) => Behavior, +): Behavior { + return behavior("a-b-c-d", { a: [], // Start empty b: [], // Alice joins c: [], // Alice still there @@ -1082,12 +1082,12 @@ function nooneEverThere$( } function participantJoinLeave$( - hot: ( + behavior: ( marbles: string, values: Record, - ) => Observable, -): Observable { - return hot("a-b-c-d", { + ) => Behavior, +): Behavior { + return behavior("a-b-c-d", { a: [], // Start empty b: [aliceParticipant], // Alice joins c: [aliceParticipant], // Alice still there @@ -1096,12 +1096,12 @@ function participantJoinLeave$( } function rtcMemberJoinLeave$( - hot: ( + behavior: ( marbles: string, values: Record, - ) => Observable, -): Observable { - return hot("a-b-c-d", { + ) => Behavior, +): Behavior { + return behavior("a-b-c-d", { a: [localRtcMember], // Start empty b: [localRtcMember, aliceRtcMember], // Alice joins c: [localRtcMember, aliceRtcMember], // Alice still there @@ -1109,47 +1109,15 @@ function rtcMemberJoinLeave$( }); } -test("allOthersLeft$ emits only when someone joined and then all others left", () => { - withTestScheduler(({ hot, expectObservable, scope }) => { - // Test scenario 1: No one ever joins - should only emit initial false and never emit again - withCallViewModel( - { remoteParticipants$: scope.behavior(nooneEverThere$(hot), []) }, - (vm) => { - expectObservable(vm.allOthersLeft$).toBe("n------", { n: false }); - }, - ); - }); -}); - -test("allOthersLeft$ emits true when someone joined and then all others left", () => { - withTestScheduler(({ hot, expectObservable, scope }) => { +test("autoLeave$ emits only when autoLeaveWhenOthersLeft option is enabled", () => { + withTestScheduler(({ behavior, expectObservable }) => { withCallViewModel( { - remoteParticipants$: scope.behavior(participantJoinLeave$(hot), []), - rtcMembers$: scope.behavior(rtcMemberJoinLeave$(hot), []), + remoteParticipants$: participantJoinLeave$(behavior), + rtcMembers$: rtcMemberJoinLeave$(behavior), }, (vm) => { - expectObservable(vm.allOthersLeft$).toBe( - "n-----u", // false initially, then at frame 6: true then false emissions in same frame - { n: false, u: true }, // map(() => {}) - ); - }, - ); - }); -}); - -test("autoLeaveWhenOthersLeft$ emits only when autoLeaveWhenOthersLeft option is enabled", () => { - withTestScheduler(({ hot, expectObservable, scope }) => { - withCallViewModel( - { - remoteParticipants$: scope.behavior(participantJoinLeave$(hot), []), - rtcMembers$: scope.behavior(rtcMemberJoinLeave$(hot), []), - }, - (vm) => { - expectObservable(vm.autoLeaveWhenOthersLeft$).toBe( - "------e", // false initially, then at frame 6: true then false emissions in same frame - { e: undefined }, - ); + expectObservable(vm.autoLeave$).toBe("------(e|)", { e: undefined }); }, { autoLeaveWhenOthersLeft: true, @@ -1159,15 +1127,15 @@ test("autoLeaveWhenOthersLeft$ emits only when autoLeaveWhenOthersLeft option is }); }); -test("autoLeaveWhenOthersLeft$ never emits autoLeaveWhenOthersLeft option is enabled but no-one is there", () => { - withTestScheduler(({ hot, expectObservable, scope }) => { +test("autoLeave$ never emits autoLeaveWhenOthersLeft option is enabled but no-one is there", () => { + withTestScheduler(({ behavior, expectObservable }) => { withCallViewModel( { - remoteParticipants$: scope.behavior(nooneEverThere$(hot), []), - rtcMembers$: scope.behavior(nooneEverThere$(hot), []), + remoteParticipants$: nooneEverThere$(behavior), + rtcMembers$: nooneEverThere$(behavior), }, (vm) => { - expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------"); + expectObservable(vm.autoLeave$).toBe("-"); }, { autoLeaveWhenOthersLeft: true, @@ -1177,15 +1145,15 @@ test("autoLeaveWhenOthersLeft$ never emits autoLeaveWhenOthersLeft option is ena }); }); -test("autoLeaveWhenOthersLeft$ doesn't emit when autoLeaveWhenOthersLeft option is disabled and all others left", () => { - withTestScheduler(({ hot, expectObservable, scope }) => { +test("autoLeave$ doesn't emit when autoLeaveWhenOthersLeft option is disabled and all others left", () => { + withTestScheduler(({ behavior, expectObservable }) => { withCallViewModel( { - remoteParticipants$: scope.behavior(participantJoinLeave$(hot), []), - rtcMembers$: scope.behavior(rtcMemberJoinLeave$(hot), []), + remoteParticipants$: participantJoinLeave$(behavior), + rtcMembers$: rtcMemberJoinLeave$(behavior), }, (vm) => { - expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------"); + expectObservable(vm.autoLeave$).toBe("-"); }, { autoLeaveWhenOthersLeft: false, @@ -1195,31 +1163,25 @@ test("autoLeaveWhenOthersLeft$ doesn't emit when autoLeaveWhenOthersLeft option }); }); -test("autoLeaveWhenOthersLeft$ doesn't emits when autoLeaveWhenOthersLeft option is enabled and all others left", () => { - withTestScheduler(({ hot, expectObservable, scope }) => { +test("autoLeave$ emits when autoLeaveWhenOthersLeft option is enabled and all others left", () => { + withTestScheduler(({ behavior, expectObservable }) => { withCallViewModel( { - remoteParticipants$: scope.behavior( - hot("a-b-c-d", { - a: [], // Alone - b: [aliceParticipant], // Alice joins - c: [aliceParticipant], - d: [], // Local joins with a second device - }), - [], //Alice leaves - ), - rtcMembers$: scope.behavior( - hot("a-b-c-d", { - a: [localRtcMember], // Start empty - b: [localRtcMember, aliceRtcMember], // Alice joins - c: [localRtcMember, aliceRtcMember, localRtcMemberDevice2], // Alice still there - d: [localRtcMember, localRtcMemberDevice2], // The second Alice leaves - }), - [], - ), + remoteParticipants$: behavior("a-b-c-d", { + a: [], // Alone + b: [aliceParticipant], // Alice joins + c: [aliceParticipant], + d: [], // Local joins with a second device + }), + rtcMembers$: behavior("a-b-c-d", { + a: [localRtcMember], // Start empty + b: [localRtcMember, aliceRtcMember], // Alice joins + c: [localRtcMember, aliceRtcMember, localRtcMemberDevice2], // Alice still there + d: [localRtcMember, localRtcMemberDevice2], // The second Alice leaves + }), }, (vm) => { - expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("------e", { + expectObservable(vm.autoLeave$).toBe("------(e|)", { e: undefined, }); }, diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index cc600154..d4b9537e 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -27,6 +27,7 @@ import { import { BehaviorSubject, EMPTY, + NEVER, type Observable, Subject, combineLatest, @@ -35,10 +36,12 @@ import { filter, forkJoin, fromEvent, + ignoreElements, map, merge, mergeMap, of, + pairwise, race, scan, skip, @@ -47,13 +50,14 @@ import { switchMap, switchScan, take, + takeUntil, + throttleTime, timer, withLatestFrom, } from "rxjs"; import { logger } from "matrix-js-sdk/lib/logger"; import { type CallMembership, - type ICallNotifyContent, type IRTCNotificationContent, type MatrixRTCSession, MatrixRTCSessionEvent, @@ -107,7 +111,7 @@ import { observeSpeaker$ } from "./observeSpeaker"; import { shallowEquals } from "../utils/array"; import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname"; import { type MediaDevices } from "./MediaDevices"; -import { type Behavior } from "./Behavior"; +import { constant, type Behavior } from "./Behavior"; export interface CallViewModelOptions { encryptionSystem: EncryptionSystem; @@ -123,6 +127,11 @@ export interface CallViewModelOptions { // list again const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000; +// Do not play any sounds if the participant count has exceeded this +// number. +export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8; +export const THROTTLE_SOUND_EFFECT_MS = 500; + // This is the number of participants that we think constitutes a "small" call // on mobile. No spotlight tile should be shown below this threshold. const smallMobileCallThreshold = 3; @@ -563,6 +572,17 @@ export class CallViewModel extends ViewModel { ) .pipe(pauseWhen(this.pretendToBeDisconnected$)); + private readonly memberships$ = this.scope.behavior( + fromEvent( + this.matrixRTCSession, + MatrixRTCSessionEvent.MembershipsChanged, + ).pipe( + startWith(null), + pauseWhen(this.pretendToBeDisconnected$), + map(() => this.matrixRTCSession.memberships), + ), + ); + /** * Displaynames for each member of the call. This will disambiguate * any displaynames that clashes with another member. Only members @@ -572,18 +592,17 @@ export class CallViewModel extends ViewModel { // than on Chrome/Firefox). This means it is important that we multicast the result so that we // don't do this work more times than we need to. This is achieved by converting to a behavior: public readonly memberDisplaynames$ = this.scope.behavior( - merge( - // Handle call membership changes. - fromEvent( - this.matrixRTCSession, - MatrixRTCSessionEvent.MembershipsChanged, - ), - // Handle room membership changes (and displayname updates) - fromEvent(this.matrixRoom, RoomStateEvent.Members), - ).pipe( - startWith(null), - map(() => { - const memberships = this.matrixRTCSession.memberships; + // React to call memberships and also display name updates + // (calculateDisplayName implicitly depends on the room member data) + combineLatest( + [ + this.memberships$, + fromEvent(this.matrixRoom, RoomStateEvent.Members).pipe( + startWith(null), + pauseWhen(this.pretendToBeDisconnected$), + ), + ], + (memberships, _members) => { const displaynameMap = new Map(); const room = this.matrixRoom; @@ -605,8 +624,7 @@ export class CallViewModel extends ViewModel { ); } return displaynameMap; - }), - pauseWhen(this.pretendToBeDisconnected$), + }, ), ); @@ -636,13 +654,7 @@ export class CallViewModel extends ViewModel { this.remoteParticipants$, observeParticipantMedia(this.livekitRoom.localParticipant), duplicateTiles.value$, - // Also react to changes in the MatrixRTC session list. - // The session list will also be update if a room membership changes. - // No additional RoomState event listener needs to be set up. - fromEvent( - this.matrixRTCSession, - MatrixRTCSessionEvent.MembershipsChanged, - ).pipe(startWith(null), pauseWhen(this.pretendToBeDisconnected$)), + this.memberships$, showNonMemberTiles.value$, ]).pipe( scan( @@ -652,7 +664,7 @@ export class CallViewModel extends ViewModel { remoteParticipants, { participant: localParticipant }, duplicateTiles, - _membershipsChanged, + memberships, showNonMemberTiles, ], ) => { @@ -660,7 +672,7 @@ export class CallViewModel extends ViewModel { function* (this: CallViewModel): Iterable<[string, MediaItem]> { const room = this.matrixRoom; // m.rtc.members are the basis for calculating what is visible in the call - for (const rtcMember of this.matrixRTCSession.memberships) { + for (const rtcMember of memberships) { const { member, id: livekitParticipantId } = getRoomMemberFromRtcMember(rtcMember, room); const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`; @@ -826,175 +838,117 @@ export class CallViewModel extends ViewModel { ), ); - /** - * This observable tracks the currently connected participants. - * - * - Each participant has one livekit connection - * - Each participant has a corresponding MatrixRTC membership state event - * - There can be multiple participants for one matrix user. - */ - public readonly participantChanges$ = this.scope.behavior( - this.userMedia$.pipe( - map((mediaItems) => mediaItems.map((m) => m.id)), - scan( - (prev, ids) => { - const left = prev.ids.filter((id) => !ids.includes(id)); - const joined = ids.filter((id) => !prev.ids.includes(id)); - return { ids, joined, left }; - }, - { ids: [], joined: [], left: [] }, - ), + public readonly joinSoundEffect$ = this.userMedia$.pipe( + pairwise(), + filter( + ([prev, current]) => + current.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && + current.length > prev.length, ), + map(() => {}), + throttleTime(THROTTLE_SOUND_EFFECT_MS), + ); + + public readonly leaveSoundEffect$ = this.userMedia$.pipe( + pairwise(), + filter( + ([prev, current]) => + current.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && + current.length < prev.length, + ), + map(() => {}), + throttleTime(THROTTLE_SOUND_EFFECT_MS), ); /** * The number of participants currently in the call. * - * - Each participant has one livekit connection * - Each participant has a corresponding MatrixRTC membership state event - * - There can be multiple participants for one matrix user. + * - There can be multiple participants for one Matrix user if they join from + * multiple devices. */ public readonly participantCount$ = this.scope.behavior( - this.participantChanges$.pipe(map(({ ids }) => ids.length)), + this.memberships$.pipe(map((ms) => ms.length)), ); - /** - * This observable tracks the matrix users that are currently in the call. - * There can be just one matrix user with multiple participants (see also participantChanges$) - */ - public readonly matrixUserChanges$ = this.scope.behavior( - this.userMedia$.pipe( - map( - (mediaItems) => - new Set( - mediaItems - .map((m) => m.vm.member?.userId) - .filter((id) => id !== undefined), - ), - ), - scan< - Set, - { - userIds: Set; - joinedUserIds: Set; - leftUserIds: Set; - } - >( - (prevState, userIds) => { - const left = new Set( - [...prevState.userIds].filter((id) => !userIds.has(id)), - ); - const joined = new Set( - [...userIds].filter((id) => !prevState.userIds.has(id)), - ); - return { userIds: userIds, joinedUserIds: joined, leftUserIds: left }; - }, - { - userIds: new Set(), - joinedUserIds: new Set(), - leftUserIds: new Set(), - }, - ), + private readonly allOthersLeft$ = this.memberships$.pipe( + pairwise(), + filter( + ([prev, current]) => + current.every((m) => m.sender === this.userId) && + prev.some((m) => m.sender !== this.userId), ), - ); - - public readonly allOthersLeft$ = this.matrixUserChanges$.pipe( - map(({ userIds, leftUserIds }) => { - if (!this.userId) { - logger.warn("Could not access user ID to compute allOthersLeft"); - return false; - } - return ( - userIds.size === 1 && userIds.has(this.userId) && leftUserIds.size > 0 - ); - }), - startWith(false), - distinctUntilChanged(), - ); - - public readonly autoLeaveWhenOthersLeft$ = this.allOthersLeft$.pipe( - distinctUntilChanged(), - filter((leave) => (leave && this.options.autoLeaveWhenOthersLeft) ?? false), map(() => {}), + take(1), ); - /** - * "unknown": We don't know if the RTC session decides to send a notify event yet. - * It will only be known once we sent our own membership and know we were the first one to join. - * "ringing": The notification event was sent. - * "ringEnded": The notification events lifetime has timed out -> ringing stopped on all receiving clients. - */ - private readonly notificationEventIsRingingOthers$: Observable< - "unknown" | "ringing" | "ringEnded" | null - > = fromEvent<[IRTCNotificationContent, ICallNotifyContent]>( - this.matrixRTCSession, - MatrixRTCSessionEvent.DidSendCallNotification, - ).pipe( - switchMap(([notificationEvent]) => { - // event.lifetime is expected to be in ms - const lifetime = notificationEvent?.lifetime ?? 0; - if (lifetime > 0) { - // Emit true immediately, then false after lifetime ms - return concat( - of<"ringing" | null>("ringing"), - timer(lifetime).pipe(map((): "ringEnded" | null => "ringEnded")), - ); - } - // If no lifetime, just emit true once - return of(null); - }), - startWith("unknown" as "unknown" | null), - ); + public readonly autoLeave$ = this.options.autoLeaveWhenOthersLeft + ? this.allOthersLeft$ + : NEVER; /** - * If some other matrix user has joined the call. It can start with true if there are already multiple matrix users. + * Emits whenever the RTC session tells us that it intends to ring for a given + * duration. */ - private readonly someoneElseJoined$ = this.matrixUserChanges$.pipe( - scan( - (someoneJoined, { joinedUserIds }) => - someoneJoined || [...joinedUserIds].some((id) => id !== this.userId), - false, - ), - startWith(this.matrixUserChanges$.value.userIds.size > 1), + private readonly beginRingingForMs$ = ( + fromEvent( + this.matrixRTCSession, + MatrixRTCSessionEvent.DidSendCallNotification, + ) as Observable<[IRTCNotificationContent]> + ) + // event.lifetime is expected to be in ms + .pipe(map(([notificationEvent]) => notificationEvent?.lifetime ?? 0)); + + /** + * Whether some Matrix user other than ourself is joined to the call. + */ + private readonly someoneElseJoined$ = this.memberships$.pipe( + map((ms) => ms.some((m) => m.sender !== this.userId)), ); /** * The current call pickup state of the call. - * - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening). * - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership. * Then we can conclude if we were the first one to join or not. + * - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening). * - "timeout": No-one picked up in the defined time this call should be ringing on others devices. * The call failed. If desired this can be used as a trigger to exit the call. - * - "success": Someone else joined. The call is in a normal state. Stop audiovisual feedback. + * - "success": Someone else joined. The call is in a normal state. No audiovisual feedback. * - null: EC is configured to never show any waiting for answer state. */ - public readonly callPickupState$: Behavior< - "unknown" | "ringing" | "timeout" | "success" | null - > = this.scope.behavior( - combineLatest([ - this.notificationEventIsRingingOthers$, - this.someoneElseJoined$, - ]).pipe( - map(([isRingingOthers, someoneJoined]) => { - // Never enter waiting for answer state if the app is not configured with waitingForAnswer. - if (!this.options.shouldWaitForCallPickup) return null; - // As soon as someone joins, we can consider the call "wait for answer" successful - if (someoneJoined) return "success"; - - switch (isRingingOthers) { - case "unknown": - return "unknown"; - case "ringing": - return "ringing"; - case "ringEnded": - return "timeout"; - default: - return "timeout"; - } - }), - distinctUntilChanged(), - ), - ); + public readonly callPickupState$ = this.options.shouldWaitForCallPickup + ? this.scope.behavior<"unknown" | "ringing" | "timeout" | "success">( + concat( + concat( + // We don't know if the RTC session decides to send a notify event + // yet. It will only be known once we sent our own membership and + // know we were the first one to join. + of("unknown" as const), + // Once we get the signal to begin ringing: + this.beginRingingForMs$.pipe( + take(1), + switchMap((lifetime) => + lifetime === 0 + ? // If no lifetime, skip the ring state + EMPTY + : // Ring until lifetime ms have passed + timer(lifetime).pipe( + ignoreElements(), + startWith("ringing" as const), + ), + ), + ), + // The notification lifetime has timed out, meaning ringing has + // likely stopped on all receiving clients. + of("timeout" as const), + NEVER, + ).pipe( + takeUntil(this.someoneElseJoined$.pipe(filter((joined) => joined))), + ), + of("success" as const), + ), + ) + : constant(null); /** * List of MediaItems that we want to display, that are of type ScreenShare From b8acdc3cec7aa06799bbea037ec8c9fef8c42a65 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 3 Sep 2025 16:51:35 +0200 Subject: [PATCH 12/15] Tidy/correct the dialing/ringing tests --- src/state/CallViewModel.test.ts | 108 +++++++++++--------------------- 1 file changed, 35 insertions(+), 73 deletions(-) diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index a26fdc59..75b5e5c4 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -1195,16 +1195,10 @@ test("autoLeave$ emits when autoLeaveWhenOthersLeft option is enabled and all ot describe("shouldWaitForCallPickup$", () => { test("unknown -> ringing -> timeout when notified and nobody joins", () => { - withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + withTestScheduler(({ schedule, expectObservable }) => { // No one ever joins (only local user) withCallViewModel( - { - remoteParticipants$: scope.behavior(hot("a", { a: [] }), []), - rtcMembers$: scope.behavior(hot("a", { a: [localRtcMember] }), []), - connectionState$: of(ConnectionState.Connected), - speaking: new Map(), - mediaDevices: mockMediaDevices({}), - }, + { remoteParticipants$: constant([]) }, (vm, rtcSession) => { // Fire a call notification at 10ms with lifetime 30ms schedule(" 10ms r", { @@ -1234,27 +1228,18 @@ describe("shouldWaitForCallPickup$", () => { }); test("ringing -> success if someone joins before timeout", () => { - withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { // Someone joins at 20ms (both LiveKit participant and MatrixRTC member) - const remote$ = scope.behavior( - hot("a--b", { a: [], b: [aliceParticipant] }), - [], - ); - const rtc$ = scope.behavior( - hot("a--b", { - a: [localRtcMember], - b: [localRtcMember, aliceRtcMember], - }), - [], - ); - withCallViewModel( { - remoteParticipants$: remote$, - rtcMembers$: rtc$, - connectionState$: of(ConnectionState.Connected), - speaking: new Map(), - mediaDevices: mockMediaDevices({}), + remoteParticipants$: behavior("a 19ms b", { + a: [], + b: [aliceParticipant], + }), + rtcMembers$: behavior("a 19ms b", { + a: [localRtcMember], + b: [localRtcMember, aliceRtcMember], + }), }, (vm, rtcSession) => { // Notify at 5ms so we enter ringing, then success at 20ms @@ -1272,8 +1257,9 @@ describe("shouldWaitForCallPickup$", () => { }, }); - expectObservable(vm.callPickupState$).toBe("a 2ms c", { + expectObservable(vm.callPickupState$).toBe("a 4ms b 14ms c", { a: "unknown", + b: "ringing", c: "success", }); }, @@ -1286,27 +1272,18 @@ describe("shouldWaitForCallPickup$", () => { }); test("success when someone joins before we notify", () => { - withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { // Join at 10ms, notify later at 20ms (state should stay success) - const remote$ = scope.behavior( - hot("a-b", { a: [], b: [aliceParticipant] }), - [], - ); - const rtc$ = scope.behavior( - hot("a-b", { - a: [localRtcMember], - b: [localRtcMember, aliceRtcMember], - }), - [], - ); - withCallViewModel( { - remoteParticipants$: remote$, - rtcMembers$: rtc$, - connectionState$: of(ConnectionState.Connected), - speaking: new Map(), - mediaDevices: mockMediaDevices({}), + remoteParticipants$: behavior("a 9ms b", { + a: [], + b: [aliceParticipant], + }), + rtcMembers$: behavior("a 9ms b", { + a: [localRtcMember], + b: [localRtcMember, aliceRtcMember], + }), }, (vm, rtcSession) => { schedule(" 20ms r", { @@ -1322,7 +1299,7 @@ describe("shouldWaitForCallPickup$", () => { ); }, }); - expectObservable(vm.callPickupState$).toBe("a 1ms b", { + expectObservable(vm.callPickupState$).toBe("a 9ms b", { a: "unknown", b: "success", }); @@ -1336,21 +1313,15 @@ describe("shouldWaitForCallPickup$", () => { }); test("notify without lifetime -> immediate timeout", () => { - withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + withTestScheduler(({ schedule, expectObservable }) => { withCallViewModel( - { - remoteParticipants$: scope.behavior(hot("a", { a: [] }), []), - rtcMembers$: scope.behavior(hot("a", { a: [localRtcMember] }), []), - connectionState$: of(ConnectionState.Connected), - speaking: new Map(), - mediaDevices: mockMediaDevices({}), - }, + {}, (vm, rtcSession) => { schedule(" 10ms r", { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 0 } as unknown as { + {} as unknown as { event_id: string; } & IRTCNotificationContent, // no lifetime {} as unknown as { @@ -1373,26 +1344,17 @@ describe("shouldWaitForCallPickup$", () => { }); test("stays null when shouldWaitForCallPickup=false", () => { - withTestScheduler(({ hot, schedule, expectObservable, scope }) => { - const remote$ = scope.behavior( - hot("a--b", { a: [], b: [aliceParticipant] }), - [], - ); - const rtc$ = scope.behavior( - hot("a--b", { - a: [localRtcMember], - b: [localRtcMember, aliceRtcMember], - }), - [], - ); - + withTestScheduler(({ behavior, schedule, expectObservable }) => { withCallViewModel( { - remoteParticipants$: remote$, - rtcMembers$: rtc$, - connectionState$: of(ConnectionState.Connected), - speaking: new Map(), - mediaDevices: mockMediaDevices({}), + remoteParticipants$: behavior("a--b", { + a: [], + b: [aliceParticipant], + }), + rtcMembers$: behavior("a--b", { + a: [localRtcMember], + b: [localRtcMember, aliceRtcMember], + }), }, (vm, rtcSession) => { schedule(" 5ms r", { From 1428df8567a0a3ce354c8371aeb7b342e3960b86 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 3 Sep 2025 16:52:40 +0200 Subject: [PATCH 13/15] Tentatively rename URL parameter to waitForCallPickup --- src/UrlParams.ts | 10 +++++----- src/state/CallViewModel.test.ts | 14 +++++++------- src/state/CallViewModel.ts | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/UrlParams.ts b/src/UrlParams.ts index a26045b5..5f43b17f 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -226,7 +226,7 @@ export interface UrlConfiguration { * - play a sound that indicates that it is awaiting an answer * - auto-dismiss the call widget once the notification lifetime expires on the receivers side. */ - shouldWaitForCallPickup: boolean; + waitForCallPickup: boolean; } // If you need to add a new flag to this interface, prefer a name that describes @@ -358,7 +358,7 @@ export const getUrlParams = ( returnToLobby: false, sendNotificationType: "notification" as RTCNotificationType, autoLeaveWhenOthersLeft: false, - shouldWaitForCallPickup: false, + waitForCallPickup: false, }; switch (intent) { case UserIntent.StartNewCall: @@ -378,7 +378,7 @@ export const getUrlParams = ( ...inAppDefault, skipLobby: true, autoLeaveWhenOthersLeft: true, - shouldWaitForCallPickup: true, + waitForCallPickup: true, }; break; case UserIntent.JoinExistingCallDM: @@ -404,7 +404,7 @@ export const getUrlParams = ( returnToLobby: false, sendNotificationType: undefined, autoLeaveWhenOthersLeft: false, - shouldWaitForCallPickup: false, + waitForCallPickup: false, }; } @@ -456,7 +456,7 @@ export const getUrlParams = ( "ring", "notification", ]), - shouldWaitForCallPickup: parser.getFlag("shouldWaitForCallPickup"), + waitForCallPickup: parser.getFlag("waitForCallPickup"), autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"), }; diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 75b5e5c4..c30624ae 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -1193,7 +1193,7 @@ test("autoLeave$ emits when autoLeaveWhenOthersLeft option is enabled and all ot }); }); -describe("shouldWaitForCallPickup$", () => { +describe("waitForCallPickup$", () => { test("unknown -> ringing -> timeout when notified and nobody joins", () => { withTestScheduler(({ schedule, expectObservable }) => { // No one ever joins (only local user) @@ -1220,7 +1220,7 @@ describe("shouldWaitForCallPickup$", () => { }); }, { - shouldWaitForCallPickup: true, + waitForCallPickup: true, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); @@ -1264,7 +1264,7 @@ describe("shouldWaitForCallPickup$", () => { }); }, { - shouldWaitForCallPickup: true, + waitForCallPickup: true, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); @@ -1305,7 +1305,7 @@ describe("shouldWaitForCallPickup$", () => { }); }, { - shouldWaitForCallPickup: true, + waitForCallPickup: true, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); @@ -1336,14 +1336,14 @@ describe("shouldWaitForCallPickup$", () => { }); }, { - shouldWaitForCallPickup: true, + waitForCallPickup: true, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); }); }); - test("stays null when shouldWaitForCallPickup=false", () => { + test("stays null when waitForCallPickup=false", () => { withTestScheduler(({ behavior, schedule, expectObservable }) => { withCallViewModel( { @@ -1375,7 +1375,7 @@ describe("shouldWaitForCallPickup$", () => { }); }, { - shouldWaitForCallPickup: false, + waitForCallPickup: false, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index d4b9537e..9112954f 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -120,7 +120,7 @@ export interface CallViewModelOptions { * If the call is started in a way where we want it to behave like a telephone usecase * If we sent a notification event, we want the ui to show a ringing state */ - shouldWaitForCallPickup?: boolean; + waitForCallPickup?: boolean; } // How long we wait after a focus switch before showing the real participant @@ -916,7 +916,7 @@ export class CallViewModel extends ViewModel { * - "success": Someone else joined. The call is in a normal state. No audiovisual feedback. * - null: EC is configured to never show any waiting for answer state. */ - public readonly callPickupState$ = this.options.shouldWaitForCallPickup + public readonly callPickupState$ = this.options.waitForCallPickup ? this.scope.behavior<"unknown" | "ringing" | "timeout" | "success">( concat( concat( From 1e8b87408cc6060d3fd4450dab74a5efc2e9f91c Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 3 Sep 2025 16:59:37 +0200 Subject: [PATCH 14/15] Document recently added URL parameters --- docs/url-params.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/url-params.md b/docs/url-params.md index 3fac185a..b2af8416 100644 --- a/docs/url-params.md +++ b/docs/url-params.md @@ -70,6 +70,8 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st | `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. | | `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the user’s default homeserver. | | `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. | +| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. | +| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. | ### Widget-only parameters From c599d6eae997f61c1cf73924651b9039d0f16338 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 5 Sep 2025 14:06:28 +0200 Subject: [PATCH 15/15] Make the CallEventAudioRenderer test stricter --- src/room/CallEventAudioRenderer.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/room/CallEventAudioRenderer.test.tsx b/src/room/CallEventAudioRenderer.test.tsx index f51e602d..894d1d24 100644 --- a/src/room/CallEventAudioRenderer.test.tsx +++ b/src/room/CallEventAudioRenderer.test.tsx @@ -156,6 +156,7 @@ test("should not play a sound when a hand raise is retracted", () => { ]); render(); + playSound.mockClear(); act(() => { handRaisedSubject$.next({ ["foo"]: { @@ -170,7 +171,7 @@ test("should not play a sound when a hand raise is retracted", () => { }, }); }); - expect(playSound).toHaveBeenCalledTimes(1); + expect(playSound).toHaveBeenCalledExactlyOnceWith("raiseHand"); act(() => { handRaisedSubject$.next({ ["foo"]: { @@ -180,5 +181,5 @@ test("should not play a sound when a hand raise is retracted", () => { }, }); }); - expect(playSound).toHaveBeenCalledTimes(1); + expect(playSound).toHaveBeenCalledExactlyOnceWith("raiseHand"); });