From 38e60208ef802a92fb8307a8f71a049e942b7908 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 26 Aug 2025 13:54:25 +0200 Subject: [PATCH 1/7] Update dependency livekit-client to v2.15.6 (#3468) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 0d2cf54a..94f8feaf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10046,8 +10046,8 @@ __metadata: linkType: hard "livekit-client@npm:^2.13.0": - version: 2.15.5 - resolution: "livekit-client@npm:2.15.5" + version: 2.15.6 + resolution: "livekit-client@npm:2.15.6" dependencies: "@livekit/mutex": "npm:1.1.1" "@livekit/protocol": "npm:1.39.3" @@ -10060,7 +10060,7 @@ __metadata: webrtc-adapter: "npm:^9.0.1" peerDependencies: "@types/dom-mediacapture-record": ^1 - checksum: 10c0/52a70bdd39d802737ed7c25ae5d06daf9921156c4fc74f918009e86204430b2d200b66c55cefab949be4e5411cbc4d25eac92976f62f96b7226057a5b0706baa + checksum: 10c0/f1ab6cdf2b85647036e9de906734c1394dac497da0bd879a29d0c587c437ada262021478fcef24df99b3489a39d97fe67ab33de0785ed0a63335da2fef577192 languageName: node linkType: hard From a775bcd62a434cad4d20cdcb9f8a1339090d0806 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Thu, 28 Aug 2025 10:09:49 -0400 Subject: [PATCH 2/7] Migrate from js-sdk's `defer` in test See: matrix-org/matrix-js-sdk#4959 --- src/livekit/useECConnectionState.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/livekit/useECConnectionState.test.tsx b/src/livekit/useECConnectionState.test.tsx index 72324884..8abbf438 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"; @@ -93,7 +93,7 @@ describe("Leaking connection prevention", () => { test("Should cancel pending connections when the component is unmounted", async () => { const connectCall = vi.fn(); - const pendingConnection = defer(); + const pendingConnection = Promise.withResolvers(); // let pendingDisconnection = defer() const disconnectMock = vi.fn(); @@ -141,7 +141,7 @@ describe("Leaking connection prevention", () => { test("Should cancel about to open but not yet opened connection", async () => { const createTracksCall = vi.fn(); - const pendingCreateTrack = defer(); + const pendingCreateTrack = Promise.withResolvers(); // let pendingDisconnection = defer() const disconnectMock = vi.fn(); const connectMock = vi.fn(); From 07522d67044d7aad81755a5dffef862a588f2c79 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 3 Sep 2025 13:03:48 +0200 Subject: [PATCH 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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