Tidy some things up, refactor dialing/ringing behaviors

This commit is contained in:
Robin
2025-09-03 16:50:43 +02:00
parent 07522d6704
commit 880e07c07f
5 changed files with 179 additions and 290 deletions

View File

@@ -19,10 +19,7 @@ import { act } from "react";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc"; import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { mockRtcMembership } from "../utils/test"; import { mockRtcMembership } from "../utils/test";
import { import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
CallEventAudioRenderer,
MAX_PARTICIPANT_COUNT_FOR_SOUND,
} from "./CallEventAudioRenderer";
import { useAudioContext } from "../useAudioContext"; import { useAudioContext } from "../useAudioContext";
import { prefetchSounds } from "../soundUtils"; import { prefetchSounds } from "../soundUtils";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel"; import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
@@ -33,6 +30,7 @@ import {
local, local,
localRtcMember, localRtcMember,
} from "../utils/test-fixtures"; } from "../utils/test-fixtures";
import { MAX_PARTICIPANT_COUNT_FOR_SOUND } from "../state/CallViewModel";
vitest.mock("../useAudioContext"); vitest.mock("../useAudioContext");
vitest.mock("../soundUtils"); 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(() => { act(() => {
handRaisedSubject$.next({ handRaisedSubject$.next({
["foo"]: { ["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);
}); });

View File

@@ -6,7 +6,6 @@ Please see LICENSE in the repository root for full details.
*/ */
import { type ReactNode, useEffect } from "react"; import { type ReactNode, useEffect } from "react";
import { filter, interval, throttle } from "rxjs";
import { type CallViewModel } from "../state/CallViewModel"; import { type CallViewModel } from "../state/CallViewModel";
import joinCallSoundMp3 from "../sound/join_call.mp3"; import joinCallSoundMp3 from "../sound/join_call.mp3";
@@ -21,11 +20,6 @@ import { useAudioContext } from "../useAudioContext";
import { prefetchSounds } from "../soundUtils"; import { prefetchSounds } from "../soundUtils";
import { useLatest } from "../useLatest"; 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({ export const callEventAudioSounds = prefetchSounds({
join: { join: {
mp3: joinCallSoundMp3, mp3: joinCallSoundMp3,
@@ -60,37 +54,18 @@ export function CallEventAudioRenderer({
const audioEngineRef = useLatest(audioEngineCtx); const audioEngineRef = useLatest(audioEngineCtx);
useEffect(() => { useEffect(() => {
const joinSub = vm.participantChanges$ const joinSub = vm.joinSoundEffect$.subscribe(
.pipe( () => void audioEngineRef.current?.playSound("join"),
filter( );
({ joined, ids }) => const leftSub = vm.leaveSoundEffect$.subscribe(
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && joined.length > 0, () => void audioEngineRef.current?.playSound("left"),
), );
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)), const handRaisedSub = vm.newHandRaised$.subscribe(
) () => void audioEngineRef.current?.playSound("raiseHand"),
.subscribe(() => { );
void audioEngineRef.current?.playSound("join"); const screenshareSub = vm.newScreenShare$.subscribe(
}); () => void audioEngineRef.current?.playSound("screenshareStarted"),
);
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");
});
return (): void => { return (): void => {
joinSub.unsubscribe(); joinSub.unsubscribe();

View File

@@ -321,7 +321,7 @@ export const InCallView: FC<InCallViewProps> = ({
const showFooter = useBehavior(vm.showFooter$); const showFooter = useBehavior(vm.showFooter$);
const earpieceMode = useBehavior(vm.earpieceMode$); const earpieceMode = useBehavior(vm.earpieceMode$);
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); 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 // 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 // that the pointerType of the event is "touch", but this isn't yet supported

View File

@@ -311,7 +311,7 @@ function withCallViewModel(
const roomEventSelectorSpy = vi const roomEventSelectorSpy = vi
.spyOn(ComponentsCore, "roomEventSelector") .spyOn(ComponentsCore, "roomEventSelector")
.mockImplementation((room, eventType) => of()); .mockImplementation((_room, _eventType) => of());
const livekitRoom = mockLivekitRoom( const livekitRoom = mockLivekitRoom(
{ localParticipant }, { localParticipant },
@@ -1071,9 +1071,9 @@ it("should rank raised hands above video feeds and below speakers and presenters
}); });
function nooneEverThere$<T>( function nooneEverThere$<T>(
hot: (marbles: string, values: Record<string, T[]>) => Observable<T[]>, behavior: (marbles: string, values: Record<string, T[]>) => Behavior<T[]>,
): Observable<T[]> { ): Behavior<T[]> {
return hot("a-b-c-d", { return behavior("a-b-c-d", {
a: [], // Start empty a: [], // Start empty
b: [], // Alice joins b: [], // Alice joins
c: [], // Alice still there c: [], // Alice still there
@@ -1082,12 +1082,12 @@ function nooneEverThere$<T>(
} }
function participantJoinLeave$( function participantJoinLeave$(
hot: ( behavior: (
marbles: string, marbles: string,
values: Record<string, RemoteParticipant[]>, values: Record<string, RemoteParticipant[]>,
) => Observable<RemoteParticipant[]>, ) => Behavior<RemoteParticipant[]>,
): Observable<RemoteParticipant[]> { ): Behavior<RemoteParticipant[]> {
return hot("a-b-c-d", { return behavior("a-b-c-d", {
a: [], // Start empty a: [], // Start empty
b: [aliceParticipant], // Alice joins b: [aliceParticipant], // Alice joins
c: [aliceParticipant], // Alice still there c: [aliceParticipant], // Alice still there
@@ -1096,12 +1096,12 @@ function participantJoinLeave$(
} }
function rtcMemberJoinLeave$( function rtcMemberJoinLeave$(
hot: ( behavior: (
marbles: string, marbles: string,
values: Record<string, CallMembership[]>, values: Record<string, CallMembership[]>,
) => Observable<CallMembership[]>, ) => Behavior<CallMembership[]>,
): Observable<CallMembership[]> { ): Behavior<CallMembership[]> {
return hot("a-b-c-d", { return behavior("a-b-c-d", {
a: [localRtcMember], // Start empty a: [localRtcMember], // Start empty
b: [localRtcMember, aliceRtcMember], // Alice joins b: [localRtcMember, aliceRtcMember], // Alice joins
c: [localRtcMember, aliceRtcMember], // Alice still there c: [localRtcMember, aliceRtcMember], // Alice still there
@@ -1109,47 +1109,15 @@ function rtcMemberJoinLeave$(
}); });
} }
test("allOthersLeft$ emits only when someone joined and then all others left", () => { test("autoLeave$ emits only when autoLeaveWhenOthersLeft option is enabled", () => {
withTestScheduler(({ hot, expectObservable, scope }) => { withTestScheduler(({ behavior, expectObservable }) => {
// 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 }) => {
withCallViewModel( withCallViewModel(
{ {
remoteParticipants$: scope.behavior(participantJoinLeave$(hot), []), remoteParticipants$: participantJoinLeave$(behavior),
rtcMembers$: scope.behavior(rtcMemberJoinLeave$(hot), []), rtcMembers$: rtcMemberJoinLeave$(behavior),
}, },
(vm) => { (vm) => {
expectObservable(vm.allOthersLeft$).toBe( expectObservable(vm.autoLeave$).toBe("------(e|)", { e: undefined });
"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 },
);
}, },
{ {
autoLeaveWhenOthersLeft: true, 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", () => { test("autoLeave$ never emits autoLeaveWhenOthersLeft option is enabled but no-one is there", () => {
withTestScheduler(({ hot, expectObservable, scope }) => { withTestScheduler(({ behavior, expectObservable }) => {
withCallViewModel( withCallViewModel(
{ {
remoteParticipants$: scope.behavior(nooneEverThere$(hot), []), remoteParticipants$: nooneEverThere$(behavior),
rtcMembers$: scope.behavior(nooneEverThere$(hot), []), rtcMembers$: nooneEverThere$(behavior),
}, },
(vm) => { (vm) => {
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------"); expectObservable(vm.autoLeave$).toBe("-");
}, },
{ {
autoLeaveWhenOthersLeft: true, 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", () => { test("autoLeave$ doesn't emit when autoLeaveWhenOthersLeft option is disabled and all others left", () => {
withTestScheduler(({ hot, expectObservable, scope }) => { withTestScheduler(({ behavior, expectObservable }) => {
withCallViewModel( withCallViewModel(
{ {
remoteParticipants$: scope.behavior(participantJoinLeave$(hot), []), remoteParticipants$: participantJoinLeave$(behavior),
rtcMembers$: scope.behavior(rtcMemberJoinLeave$(hot), []), rtcMembers$: rtcMemberJoinLeave$(behavior),
}, },
(vm) => { (vm) => {
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------"); expectObservable(vm.autoLeave$).toBe("-");
}, },
{ {
autoLeaveWhenOthersLeft: false, 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", () => { test("autoLeave$ emits when autoLeaveWhenOthersLeft option is enabled and all others left", () => {
withTestScheduler(({ hot, expectObservable, scope }) => { withTestScheduler(({ behavior, expectObservable }) => {
withCallViewModel( withCallViewModel(
{ {
remoteParticipants$: scope.behavior( remoteParticipants$: behavior("a-b-c-d", {
hot("a-b-c-d", { a: [], // Alone
a: [], // Alone b: [aliceParticipant], // Alice joins
b: [aliceParticipant], // Alice joins c: [aliceParticipant],
c: [aliceParticipant], d: [], // Local joins with a second device
d: [], // Local joins with a second device }),
}), rtcMembers$: behavior("a-b-c-d", {
[], //Alice leaves a: [localRtcMember], // Start empty
), b: [localRtcMember, aliceRtcMember], // Alice joins
rtcMembers$: scope.behavior( c: [localRtcMember, aliceRtcMember, localRtcMemberDevice2], // Alice still there
hot("a-b-c-d", { d: [localRtcMember, localRtcMemberDevice2], // The second Alice leaves
a: [localRtcMember], // Start empty }),
b: [localRtcMember, aliceRtcMember], // Alice joins
c: [localRtcMember, aliceRtcMember, localRtcMemberDevice2], // Alice still there
d: [localRtcMember, localRtcMemberDevice2], // The second Alice leaves
}),
[],
),
}, },
(vm) => { (vm) => {
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("------e", { expectObservable(vm.autoLeave$).toBe("------(e|)", {
e: undefined, e: undefined,
}); });
}, },

View File

@@ -27,6 +27,7 @@ import {
import { import {
BehaviorSubject, BehaviorSubject,
EMPTY, EMPTY,
NEVER,
type Observable, type Observable,
Subject, Subject,
combineLatest, combineLatest,
@@ -35,10 +36,12 @@ import {
filter, filter,
forkJoin, forkJoin,
fromEvent, fromEvent,
ignoreElements,
map, map,
merge, merge,
mergeMap, mergeMap,
of, of,
pairwise,
race, race,
scan, scan,
skip, skip,
@@ -47,13 +50,14 @@ import {
switchMap, switchMap,
switchScan, switchScan,
take, take,
takeUntil,
throttleTime,
timer, timer,
withLatestFrom, withLatestFrom,
} from "rxjs"; } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { import {
type CallMembership, type CallMembership,
type ICallNotifyContent,
type IRTCNotificationContent, type IRTCNotificationContent,
type MatrixRTCSession, type MatrixRTCSession,
MatrixRTCSessionEvent, MatrixRTCSessionEvent,
@@ -107,7 +111,7 @@ import { observeSpeaker$ } from "./observeSpeaker";
import { shallowEquals } from "../utils/array"; import { shallowEquals } from "../utils/array";
import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname"; import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname";
import { type MediaDevices } from "./MediaDevices"; import { type MediaDevices } from "./MediaDevices";
import { type Behavior } from "./Behavior"; import { constant, type Behavior } from "./Behavior";
export interface CallViewModelOptions { export interface CallViewModelOptions {
encryptionSystem: EncryptionSystem; encryptionSystem: EncryptionSystem;
@@ -123,6 +127,11 @@ export interface CallViewModelOptions {
// list again // list again
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000; 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 // This is the number of participants that we think constitutes a "small" call
// on mobile. No spotlight tile should be shown below this threshold. // on mobile. No spotlight tile should be shown below this threshold.
const smallMobileCallThreshold = 3; const smallMobileCallThreshold = 3;
@@ -563,6 +572,17 @@ export class CallViewModel extends ViewModel {
) )
.pipe(pauseWhen(this.pretendToBeDisconnected$)); .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 * Displaynames for each member of the call. This will disambiguate
* any displaynames that clashes with another member. Only members * 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 // 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: // 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( public readonly memberDisplaynames$ = this.scope.behavior(
merge( // React to call memberships and also display name updates
// Handle call membership changes. // (calculateDisplayName implicitly depends on the room member data)
fromEvent( combineLatest(
this.matrixRTCSession, [
MatrixRTCSessionEvent.MembershipsChanged, this.memberships$,
), fromEvent(this.matrixRoom, RoomStateEvent.Members).pipe(
// Handle room membership changes (and displayname updates) startWith(null),
fromEvent(this.matrixRoom, RoomStateEvent.Members), pauseWhen(this.pretendToBeDisconnected$),
).pipe( ),
startWith(null), ],
map(() => { (memberships, _members) => {
const memberships = this.matrixRTCSession.memberships;
const displaynameMap = new Map<string, string>(); const displaynameMap = new Map<string, string>();
const room = this.matrixRoom; const room = this.matrixRoom;
@@ -605,8 +624,7 @@ export class CallViewModel extends ViewModel {
); );
} }
return displaynameMap; return displaynameMap;
}), },
pauseWhen(this.pretendToBeDisconnected$),
), ),
); );
@@ -636,13 +654,7 @@ export class CallViewModel extends ViewModel {
this.remoteParticipants$, this.remoteParticipants$,
observeParticipantMedia(this.livekitRoom.localParticipant), observeParticipantMedia(this.livekitRoom.localParticipant),
duplicateTiles.value$, duplicateTiles.value$,
// Also react to changes in the MatrixRTC session list. this.memberships$,
// 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$)),
showNonMemberTiles.value$, showNonMemberTiles.value$,
]).pipe( ]).pipe(
scan( scan(
@@ -652,7 +664,7 @@ export class CallViewModel extends ViewModel {
remoteParticipants, remoteParticipants,
{ participant: localParticipant }, { participant: localParticipant },
duplicateTiles, duplicateTiles,
_membershipsChanged, memberships,
showNonMemberTiles, showNonMemberTiles,
], ],
) => { ) => {
@@ -660,7 +672,7 @@ export class CallViewModel extends ViewModel {
function* (this: CallViewModel): Iterable<[string, MediaItem]> { function* (this: CallViewModel): Iterable<[string, MediaItem]> {
const room = this.matrixRoom; const room = this.matrixRoom;
// m.rtc.members are the basis for calculating what is visible in the call // 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 } = const { member, id: livekitParticipantId } =
getRoomMemberFromRtcMember(rtcMember, room); getRoomMemberFromRtcMember(rtcMember, room);
const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`; const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`;
@@ -826,175 +838,117 @@ export class CallViewModel extends ViewModel {
), ),
); );
/** public readonly joinSoundEffect$ = this.userMedia$.pipe(
* This observable tracks the currently connected participants. pairwise(),
* filter(
* - Each participant has one livekit connection ([prev, current]) =>
* - Each participant has a corresponding MatrixRTC membership state event current.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND &&
* - There can be multiple participants for one matrix user. current.length > prev.length,
*/
public readonly participantChanges$ = this.scope.behavior(
this.userMedia$.pipe(
map((mediaItems) => mediaItems.map((m) => m.id)),
scan<string[], { ids: string[]; joined: string[]; left: string[] }>(
(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: [] },
),
), ),
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. * The number of participants currently in the call.
* *
* - Each participant has one livekit connection
* - Each participant has a corresponding MatrixRTC membership state event * - 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( public readonly participantCount$ = this.scope.behavior(
this.participantChanges$.pipe(map(({ ids }) => ids.length)), this.memberships$.pipe(map((ms) => ms.length)),
); );
/** private readonly allOthersLeft$ = this.memberships$.pipe(
* This observable tracks the matrix users that are currently in the call. pairwise(),
* There can be just one matrix user with multiple participants (see also participantChanges$) filter(
*/ ([prev, current]) =>
public readonly matrixUserChanges$ = this.scope.behavior( current.every((m) => m.sender === this.userId) &&
this.userMedia$.pipe( prev.some((m) => m.sender !== this.userId),
map(
(mediaItems) =>
new Set(
mediaItems
.map((m) => m.vm.member?.userId)
.filter((id) => id !== undefined),
),
),
scan<
Set<string>,
{
userIds: Set<string>;
joinedUserIds: Set<string>;
leftUserIds: Set<string>;
}
>(
(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 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(() => {}), map(() => {}),
take(1),
); );
/** public readonly autoLeave$ = this.options.autoLeaveWhenOthersLeft
* "unknown": We don't know if the RTC session decides to send a notify event yet. ? this.allOthersLeft$
* It will only be known once we sent our own membership and know we were the first one to join. : NEVER;
* "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. * Emits whenever the RTC session tells us that it intends to ring for a given
* duration.
*/ */
private readonly someoneElseJoined$ = this.matrixUserChanges$.pipe( private readonly beginRingingForMs$ = (
scan( fromEvent(
(someoneJoined, { joinedUserIds }) => this.matrixRTCSession,
someoneJoined || [...joinedUserIds].some((id) => id !== this.userId), MatrixRTCSessionEvent.DidSendCallNotification,
false, ) as Observable<[IRTCNotificationContent]>
), )
startWith(this.matrixUserChanges$.value.userIds.size > 1), // 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. * 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. * - "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. * 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. * - "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. * 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. * - null: EC is configured to never show any waiting for answer state.
*/ */
public readonly callPickupState$: Behavior< public readonly callPickupState$ = this.options.shouldWaitForCallPickup
"unknown" | "ringing" | "timeout" | "success" | null ? this.scope.behavior<"unknown" | "ringing" | "timeout" | "success">(
> = this.scope.behavior( concat(
combineLatest([ concat(
this.notificationEventIsRingingOthers$, // We don't know if the RTC session decides to send a notify event
this.someoneElseJoined$, // yet. It will only be known once we sent our own membership and
]).pipe( // know we were the first one to join.
map(([isRingingOthers, someoneJoined]) => { of("unknown" as const),
// Never enter waiting for answer state if the app is not configured with waitingForAnswer. // Once we get the signal to begin ringing:
if (!this.options.shouldWaitForCallPickup) return null; this.beginRingingForMs$.pipe(
// As soon as someone joins, we can consider the call "wait for answer" successful take(1),
if (someoneJoined) return "success"; switchMap((lifetime) =>
lifetime === 0
switch (isRingingOthers) { ? // If no lifetime, skip the ring state
case "unknown": EMPTY
return "unknown"; : // Ring until lifetime ms have passed
case "ringing": timer(lifetime).pipe(
return "ringing"; ignoreElements(),
case "ringEnded": startWith("ringing" as const),
return "timeout"; ),
default: ),
return "timeout"; ),
} // The notification lifetime has timed out, meaning ringing has
}), // likely stopped on all receiving clients.
distinctUntilChanged(), 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 * List of MediaItems that we want to display, that are of type ScreenShare