Hangup when last person in call (based on url params) (#3372)
* Introduce condigurable auto leave option * Read url params for auto leave * add tests * rename url param to `autoLeave` * lint Signed-off-by: Timo K <toger5@hotmail.de> * fix scope in CallViewModel tests Signed-off-by: Timo K <toger5@hotmail.de> * use auto leave in DM case Signed-off-by: Timo K <toger5@hotmail.de> * Make last once leave logic based on matrix user id (was participant id before) Signed-off-by: Timo K <toger5@hotmail.de> * add test for multi device auto leave Signed-off-by: Timo K <toger5@hotmail.de> --------- Signed-off-by: Timo K <toger5@hotmail.de>
This commit is contained in:
@@ -210,6 +210,12 @@ export interface UrlConfiguration {
|
|||||||
* Whether and what type of notification EC should send, when the user joins the call.
|
* Whether and what type of notification EC should send, when the user joins the call.
|
||||||
*/
|
*/
|
||||||
sendNotificationType?: RTCNotificationType;
|
sendNotificationType?: RTCNotificationType;
|
||||||
|
/**
|
||||||
|
* Whether the app should automatically leave the call when there
|
||||||
|
* is no one left in the call.
|
||||||
|
* This is one part to make the call matrixRTC session behave like a telephone call.
|
||||||
|
*/
|
||||||
|
autoLeaveWhenOthersLeft: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If you need to add a new flag to this interface, prefer a name that describes
|
// If you need to add a new flag to this interface, prefer a name that describes
|
||||||
@@ -277,10 +283,16 @@ class ParamParser {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the flag exists and is not "false".
|
||||||
|
*/
|
||||||
public getFlagParam(name: string, defaultValue = false): boolean {
|
public getFlagParam(name: string, defaultValue = false): boolean {
|
||||||
const param = this.getParam(name);
|
const param = this.getParam(name);
|
||||||
return param === null ? defaultValue : param !== "false";
|
return param === null ? defaultValue : param !== "false";
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Returns the value of the flag if it exists, or undefined if it does not.
|
||||||
|
*/
|
||||||
public getFlag(name: string): boolean | undefined {
|
public getFlag(name: string): boolean | undefined {
|
||||||
const param = this.getParam(name);
|
const param = this.getParam(name);
|
||||||
return param !== null ? param !== "false" : undefined;
|
return param !== null ? param !== "false" : undefined;
|
||||||
@@ -334,6 +346,7 @@ export const getUrlParams = (
|
|||||||
skipLobby: true,
|
skipLobby: true,
|
||||||
returnToLobby: false,
|
returnToLobby: false,
|
||||||
sendNotificationType: "notification" as RTCNotificationType,
|
sendNotificationType: "notification" as RTCNotificationType,
|
||||||
|
autoLeaveWhenOthersLeft: false,
|
||||||
};
|
};
|
||||||
switch (intent) {
|
switch (intent) {
|
||||||
case UserIntent.StartNewCall:
|
case UserIntent.StartNewCall:
|
||||||
@@ -352,14 +365,14 @@ export const getUrlParams = (
|
|||||||
intentPreset = {
|
intentPreset = {
|
||||||
...inAppDefault,
|
...inAppDefault,
|
||||||
skipLobby: true,
|
skipLobby: true,
|
||||||
// autoLeaveWhenOthersLeft: true, // TODO: add this once available
|
autoLeaveWhenOthersLeft: true,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
case UserIntent.JoinExistingCallDM:
|
case UserIntent.JoinExistingCallDM:
|
||||||
intentPreset = {
|
intentPreset = {
|
||||||
...inAppDefault,
|
...inAppDefault,
|
||||||
skipLobby: true,
|
skipLobby: true,
|
||||||
// autoLeaveWhenOthersLeft: true, // TODO: add this once available
|
autoLeaveWhenOthersLeft: true,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
// Non widget usecase defaults
|
// Non widget usecase defaults
|
||||||
@@ -377,6 +390,7 @@ export const getUrlParams = (
|
|||||||
skipLobby: false,
|
skipLobby: false,
|
||||||
returnToLobby: false,
|
returnToLobby: false,
|
||||||
sendNotificationType: undefined,
|
sendNotificationType: undefined,
|
||||||
|
autoLeaveWhenOthersLeft: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,12 +442,13 @@ export const getUrlParams = (
|
|||||||
"ring",
|
"ring",
|
||||||
"notification",
|
"notification",
|
||||||
]),
|
]),
|
||||||
|
autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...properties,
|
...properties,
|
||||||
...intentPreset,
|
...intentPreset,
|
||||||
...pickBy(configuration, (v) => v !== undefined),
|
...pickBy(configuration, (v?: unknown) => v !== undefined),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function CallEventAudioRenderer({
|
|||||||
const audioEngineRef = useLatest(audioEngineCtx);
|
const audioEngineRef = useLatest(audioEngineCtx);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const joinSub = vm.memberChanges$
|
const joinSub = vm.participantChanges$
|
||||||
.pipe(
|
.pipe(
|
||||||
filter(
|
filter(
|
||||||
({ joined, ids }) =>
|
({ joined, ids }) =>
|
||||||
@@ -72,7 +72,7 @@ export function CallEventAudioRenderer({
|
|||||||
void audioEngineRef.current?.playSound("join");
|
void audioEngineRef.current?.playSound("join");
|
||||||
});
|
});
|
||||||
|
|
||||||
const leftSub = vm.memberChanges$
|
const leftSub = vm.participantChanges$
|
||||||
.pipe(
|
.pipe(
|
||||||
filter(
|
filter(
|
||||||
({ ids, left }) =>
|
({ ids, left }) =>
|
||||||
|
|||||||
@@ -166,7 +166,11 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
const { displayName, avatarUrl } = useProfile(client);
|
const { displayName, avatarUrl } = useProfile(client);
|
||||||
const roomName = useRoomName(room);
|
const roomName = useRoomName(room);
|
||||||
const roomAvatar = useRoomAvatar(room);
|
const roomAvatar = useRoomAvatar(room);
|
||||||
const { perParticipantE2EE, returnToLobby } = useUrlParams();
|
const {
|
||||||
|
perParticipantE2EE,
|
||||||
|
returnToLobby,
|
||||||
|
password: passwordFromUrl,
|
||||||
|
} = useUrlParams();
|
||||||
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
|
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
|
||||||
const [useNewMembershipManager] = useSetting(useNewMembershipManagerSetting);
|
const [useNewMembershipManager] = useSetting(useNewMembershipManagerSetting);
|
||||||
const [useExperimentalToDeviceTransport] = useSetting(
|
const [useExperimentalToDeviceTransport] = useSetting(
|
||||||
@@ -174,7 +178,6 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Save the password once we start the groupCallView
|
// Save the password once we start the groupCallView
|
||||||
const { password: passwordFromUrl } = useUrlParams();
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (passwordFromUrl) saveKeyForRoom(room.roomId, passwordFromUrl);
|
if (passwordFromUrl) saveKeyForRoom(room.roomId, passwordFromUrl);
|
||||||
}, [passwordFromUrl, room.roomId]);
|
}, [passwordFromUrl, room.roomId]);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import useMeasure from "react-use-measure";
|
|||||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { BehaviorSubject, map } from "rxjs";
|
import { BehaviorSubject, map } from "rxjs";
|
||||||
import { useObservable } from "observable-hooks";
|
import { useObservable, useSubscription } from "observable-hooks";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
|
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
|
||||||
import {
|
import {
|
||||||
@@ -140,11 +140,11 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logger.info(
|
logger.info(
|
||||||
`[Lifecycle] InCallView Component mounted, livekitroom state ${livekitRoom?.state}`,
|
`[Lifecycle] InCallView Component mounted, livekit room state ${livekitRoom?.state}`,
|
||||||
);
|
);
|
||||||
return (): void => {
|
return (): void => {
|
||||||
logger.info(
|
logger.info(
|
||||||
`[Lifecycle] InCallView Component unmounted, livekitroom state ${livekitRoom?.state}`,
|
`[Lifecycle] InCallView Component unmounted, livekit room state ${livekitRoom?.state}`,
|
||||||
);
|
);
|
||||||
livekitRoom
|
livekitRoom
|
||||||
?.disconnect()
|
?.disconnect()
|
||||||
@@ -159,6 +159,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
};
|
};
|
||||||
}, [livekitRoom]);
|
}, [livekitRoom]);
|
||||||
|
|
||||||
|
const { autoLeaveWhenOthersLeft } = useUrlParams();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (livekitRoom !== undefined) {
|
if (livekitRoom !== undefined) {
|
||||||
const reactionsReader = new ReactionsReader(props.rtcSession);
|
const reactionsReader = new ReactionsReader(props.rtcSession);
|
||||||
@@ -166,7 +168,10 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
props.rtcSession,
|
props.rtcSession,
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
mediaDevices,
|
mediaDevices,
|
||||||
props.e2eeSystem,
|
{
|
||||||
|
encryptionSystem: props.e2eeSystem,
|
||||||
|
autoLeaveWhenOthersLeft,
|
||||||
|
},
|
||||||
connStateObservable$,
|
connStateObservable$,
|
||||||
reactionsReader.raisedHands$,
|
reactionsReader.raisedHands$,
|
||||||
reactionsReader.reactions$,
|
reactionsReader.reactions$,
|
||||||
@@ -183,6 +188,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
mediaDevices,
|
mediaDevices,
|
||||||
props.e2eeSystem,
|
props.e2eeSystem,
|
||||||
connStateObservable$,
|
connStateObservable$,
|
||||||
|
autoLeaveWhenOthersLeft,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (livekitRoom === undefined || vm === null) return null;
|
if (livekitRoom === undefined || vm === null) return null;
|
||||||
@@ -313,6 +319,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
const earpieceMode = useBehavior(vm.earpieceMode$);
|
const earpieceMode = useBehavior(vm.earpieceMode$);
|
||||||
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
||||||
const switchCamera = useSwitchCamera(vm.localVideo$);
|
const switchCamera = useSwitchCamera(vm.localVideo$);
|
||||||
|
useSubscription(vm.autoLeaveWhenOthersLeft$, 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
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ import {
|
|||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||||
|
|
||||||
import { CallViewModel, type Layout } from "./CallViewModel";
|
import {
|
||||||
|
CallViewModel,
|
||||||
|
type CallViewModelOptions,
|
||||||
|
type Layout,
|
||||||
|
} from "./CallViewModel";
|
||||||
import {
|
import {
|
||||||
mockLivekitRoom,
|
mockLivekitRoom,
|
||||||
mockLocalParticipant,
|
mockLocalParticipant,
|
||||||
@@ -71,6 +75,7 @@ import {
|
|||||||
local,
|
local,
|
||||||
localId,
|
localId,
|
||||||
localRtcMember,
|
localRtcMember,
|
||||||
|
localRtcMemberDevice2,
|
||||||
} from "../utils/test-fixtures";
|
} from "../utils/test-fixtures";
|
||||||
import { ObservableScope } from "./ObservableScope";
|
import { ObservableScope } from "./ObservableScope";
|
||||||
import { MediaDevices } from "./MediaDevices";
|
import { MediaDevices } from "./MediaDevices";
|
||||||
@@ -231,6 +236,10 @@ function withCallViewModel(
|
|||||||
vm: CallViewModel,
|
vm: CallViewModel,
|
||||||
subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> },
|
subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> },
|
||||||
) => void,
|
) => void,
|
||||||
|
options: CallViewModelOptions = {
|
||||||
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
|
autoLeaveWhenOthersLeft: false,
|
||||||
|
},
|
||||||
): void {
|
): void {
|
||||||
const room = mockMatrixRoom({
|
const room = mockMatrixRoom({
|
||||||
client: {
|
client: {
|
||||||
@@ -281,9 +290,7 @@ function withCallViewModel(
|
|||||||
rtcSession as unknown as MatrixRTCSession,
|
rtcSession as unknown as MatrixRTCSession,
|
||||||
liveKitRoom,
|
liveKitRoom,
|
||||||
mediaDevices,
|
mediaDevices,
|
||||||
{
|
options,
|
||||||
kind: E2eeType.PER_PARTICIPANT,
|
|
||||||
},
|
|
||||||
connectionState$,
|
connectionState$,
|
||||||
raisedHands$,
|
raisedHands$,
|
||||||
new BehaviorSubject({}),
|
new BehaviorSubject({}),
|
||||||
@@ -978,7 +985,7 @@ test("should strip RTL characters from displayname", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should rank raised hands above video feeds and below speakers and presenters", () => {
|
it("should rank raised hands above video feeds and below speakers and presenters", () => {
|
||||||
withTestScheduler(({ schedule, expectObservable }) => {
|
withTestScheduler(({ schedule, expectObservable, behavior }) => {
|
||||||
// There should always be one tile for each MatrixRTCSession
|
// There should always be one tile for each MatrixRTCSession
|
||||||
const expectedLayoutMarbles = "ab";
|
const expectedLayoutMarbles = "ab";
|
||||||
|
|
||||||
@@ -1037,6 +1044,176 @@ it("should rank raised hands above video feeds and below speakers and presenters
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function nooneEverThere$<T>(
|
||||||
|
hot: (marbles: string, values: Record<string, T[]>) => Observable<T[]>,
|
||||||
|
): Observable<T[]> {
|
||||||
|
return hot("a-b-c-d", {
|
||||||
|
a: [], // Start empty
|
||||||
|
b: [], // Alice joins
|
||||||
|
c: [], // Alice still there
|
||||||
|
d: [], // Alice leaves
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function participantJoinLeave$(
|
||||||
|
hot: (
|
||||||
|
marbles: string,
|
||||||
|
values: Record<string, RemoteParticipant[]>,
|
||||||
|
) => Observable<RemoteParticipant[]>,
|
||||||
|
): Observable<RemoteParticipant[]> {
|
||||||
|
return hot("a-b-c-d", {
|
||||||
|
a: [], // Start empty
|
||||||
|
b: [aliceParticipant], // Alice joins
|
||||||
|
c: [aliceParticipant], // Alice still there
|
||||||
|
d: [], // Alice leaves
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function rtcMemberJoinLeave$(
|
||||||
|
hot: (
|
||||||
|
marbles: string,
|
||||||
|
values: Record<string, CallMembership[]>,
|
||||||
|
) => Observable<CallMembership[]>,
|
||||||
|
): Observable<CallMembership[]> {
|
||||||
|
return hot("a-b-c-d", {
|
||||||
|
a: [], // Start empty
|
||||||
|
b: [aliceRtcMember], // Alice joins
|
||||||
|
c: [aliceRtcMember], // Alice still there
|
||||||
|
d: [], // Alice leaves
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
scope.behavior(nooneEverThere$(hot), []),
|
||||||
|
scope.behavior(nooneEverThere$(hot), []),
|
||||||
|
of(ConnectionState.Connected),
|
||||||
|
new Map(),
|
||||||
|
mockMediaDevices({}),
|
||||||
|
(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(
|
||||||
|
scope.behavior(participantJoinLeave$(hot), []),
|
||||||
|
scope.behavior(rtcMemberJoinLeave$(hot), []),
|
||||||
|
of(ConnectionState.Connected),
|
||||||
|
new Map(),
|
||||||
|
mockMediaDevices({}),
|
||||||
|
(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(
|
||||||
|
scope.behavior(participantJoinLeave$(hot), []),
|
||||||
|
scope.behavior(rtcMemberJoinLeave$(hot), []),
|
||||||
|
of(ConnectionState.Connected),
|
||||||
|
new Map(),
|
||||||
|
mockMediaDevices({}),
|
||||||
|
(vm) => {
|
||||||
|
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe(
|
||||||
|
"------e", // false initially, then at frame 6: true then false emissions in same frame
|
||||||
|
{ e: undefined },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
autoLeaveWhenOthersLeft: true,
|
||||||
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("autoLeaveWhenOthersLeft$ never emits autoLeaveWhenOthersLeft option is enabled but no-one is there", () => {
|
||||||
|
withTestScheduler(({ hot, expectObservable, scope }) => {
|
||||||
|
withCallViewModel(
|
||||||
|
scope.behavior(nooneEverThere$(hot), []),
|
||||||
|
scope.behavior(nooneEverThere$(hot), []),
|
||||||
|
of(ConnectionState.Connected),
|
||||||
|
new Map(),
|
||||||
|
mockMediaDevices({}),
|
||||||
|
(vm) => {
|
||||||
|
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------");
|
||||||
|
},
|
||||||
|
{
|
||||||
|
autoLeaveWhenOthersLeft: true,
|
||||||
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("autoLeaveWhenOthersLeft$ doesn't emit when autoLeaveWhenOthersLeft option is disabled and all others left", () => {
|
||||||
|
withTestScheduler(({ hot, expectObservable, scope }) => {
|
||||||
|
withCallViewModel(
|
||||||
|
scope.behavior(participantJoinLeave$(hot), []),
|
||||||
|
scope.behavior(rtcMemberJoinLeave$(hot), []),
|
||||||
|
of(ConnectionState.Connected),
|
||||||
|
new Map(),
|
||||||
|
mockMediaDevices({}),
|
||||||
|
(vm) => {
|
||||||
|
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------");
|
||||||
|
},
|
||||||
|
{
|
||||||
|
autoLeaveWhenOthersLeft: false,
|
||||||
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("autoLeaveWhenOthersLeft$ doesn't emits when autoLeaveWhenOthersLeft option is enabled and all others left", () => {
|
||||||
|
withTestScheduler(({ hot, expectObservable, scope }) => {
|
||||||
|
withCallViewModel(
|
||||||
|
scope.behavior(
|
||||||
|
hot("a-b-c-d", {
|
||||||
|
a: [], // Alone
|
||||||
|
b: [aliceParticipant], // Alice joins
|
||||||
|
c: [aliceParticipant],
|
||||||
|
d: [], // Local joins with a second device
|
||||||
|
}),
|
||||||
|
[], //Alice leaves
|
||||||
|
),
|
||||||
|
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
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
of(ConnectionState.Connected),
|
||||||
|
new Map(),
|
||||||
|
mockMediaDevices({}),
|
||||||
|
(vm) => {
|
||||||
|
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("------e", {
|
||||||
|
e: undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
autoLeaveWhenOthersLeft: true,
|
||||||
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("audio output changes when toggling earpiece mode", () => {
|
test("audio output changes when toggling earpiece mode", () => {
|
||||||
withTestScheduler(({ schedule, expectObservable }) => {
|
withTestScheduler(({ schedule, expectObservable }) => {
|
||||||
getUrlParams.mockReturnValue({ controlledAudioDevices: true });
|
getUrlParams.mockReturnValue({ controlledAudioDevices: true });
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname";
|
|||||||
import { type MediaDevices } from "./MediaDevices";
|
import { type MediaDevices } from "./MediaDevices";
|
||||||
import { type Behavior } from "./Behavior";
|
import { type Behavior } from "./Behavior";
|
||||||
|
|
||||||
|
export interface CallViewModelOptions {
|
||||||
|
encryptionSystem: EncryptionSystem;
|
||||||
|
autoLeaveWhenOthersLeft?: boolean;
|
||||||
|
}
|
||||||
// How long we wait after a focus switch before showing the real participant
|
// How long we wait after a focus switch before showing the real participant
|
||||||
// list again
|
// list again
|
||||||
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
|
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
|
||||||
@@ -473,49 +477,47 @@ export class CallViewModel extends ViewModel {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private readonly memberships$: Observable<CallMembership[]> = merge(
|
||||||
|
// Handle call membership changes.
|
||||||
|
fromEvent(this.matrixRTCSession, MatrixRTCSessionEvent.MembershipsChanged),
|
||||||
|
// Handle room membership changes (and displayname updates)
|
||||||
|
fromEvent(this.matrixRTCSession.room, RoomStateEvent.Members),
|
||||||
|
).pipe(
|
||||||
|
startWith(this.matrixRTCSession.memberships),
|
||||||
|
map(() => {
|
||||||
|
return 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
|
||||||
* joined to the call are considered here.
|
* joined to the call are considered here.
|
||||||
*/
|
*/
|
||||||
public readonly memberDisplaynames$ = this.scope.behavior(
|
public readonly memberDisplaynames$ = this.memberships$.pipe(
|
||||||
merge(
|
map((memberships) => {
|
||||||
// Handle call membership changes.
|
const displaynameMap = new Map<string, string>();
|
||||||
fromEvent(
|
const { room } = this.matrixRTCSession;
|
||||||
this.matrixRTCSession,
|
|
||||||
MatrixRTCSessionEvent.MembershipsChanged,
|
|
||||||
),
|
|
||||||
// Handle room membership changes (and displayname updates)
|
|
||||||
fromEvent(this.matrixRTCSession.room, RoomStateEvent.Members),
|
|
||||||
).pipe(
|
|
||||||
startWith(null),
|
|
||||||
map(() => {
|
|
||||||
const displaynameMap = new Map<string, string>();
|
|
||||||
const { room, memberships } = this.matrixRTCSession;
|
|
||||||
|
|
||||||
// We only consider RTC members for disambiguation as they are the only visible members.
|
// We only consider RTC members for disambiguation as they are the only visible members.
|
||||||
for (const rtcMember of memberships) {
|
for (const rtcMember of memberships) {
|
||||||
const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`;
|
const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`;
|
||||||
const { member } = getRoomMemberFromRtcMember(rtcMember, room);
|
const { member } = getRoomMemberFromRtcMember(rtcMember, room);
|
||||||
if (!member) {
|
if (!member) {
|
||||||
logger.error(
|
logger.error("Could not find member for media id:", matrixIdentifier);
|
||||||
"Could not find member for media id:",
|
continue;
|
||||||
matrixIdentifier,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const disambiguate = shouldDisambiguate(member, memberships, room);
|
|
||||||
displaynameMap.set(
|
|
||||||
matrixIdentifier,
|
|
||||||
calculateDisplayName(member, disambiguate),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return displaynameMap;
|
const disambiguate = shouldDisambiguate(member, memberships, room);
|
||||||
}),
|
displaynameMap.set(
|
||||||
// It turns out that doing the disambiguation above is rather expensive on Safari (10x slower
|
matrixIdentifier,
|
||||||
// than on Chrome/Firefox). This means it is important that we multicast the result so that we
|
calculateDisplayName(member, disambiguate),
|
||||||
// don't do this work more times than we need to. This is achieved by converting to a behavior:
|
);
|
||||||
),
|
}
|
||||||
|
return displaynameMap;
|
||||||
|
}),
|
||||||
|
// It turns out that doing the disambiguation above is rather expensive on Safari (10x slower
|
||||||
|
// 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 handsRaised$ = this.scope.behavior(this.handsRaisedSubject$);
|
public readonly handsRaised$ = this.scope.behavior(this.handsRaisedSubject$);
|
||||||
@@ -612,7 +614,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
indexedMediaId,
|
indexedMediaId,
|
||||||
member,
|
member,
|
||||||
participant,
|
participant,
|
||||||
this.encryptionSystem,
|
this.options.encryptionSystem,
|
||||||
this.livekitRoom,
|
this.livekitRoom,
|
||||||
this.memberDisplaynames$.pipe(
|
this.memberDisplaynames$.pipe(
|
||||||
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
|
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
|
||||||
@@ -635,7 +637,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
screenShareId,
|
screenShareId,
|
||||||
member,
|
member,
|
||||||
participant,
|
participant,
|
||||||
this.encryptionSystem,
|
this.options.encryptionSystem,
|
||||||
this.livekitRoom,
|
this.livekitRoom,
|
||||||
this.memberDisplaynames$.pipe(
|
this.memberDisplaynames$.pipe(
|
||||||
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
|
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
|
||||||
@@ -676,7 +678,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
nonMemberId,
|
nonMemberId,
|
||||||
undefined,
|
undefined,
|
||||||
participant,
|
participant,
|
||||||
this.encryptionSystem,
|
this.options.encryptionSystem,
|
||||||
this.livekitRoom,
|
this.livekitRoom,
|
||||||
this.memberDisplaynames$.pipe(
|
this.memberDisplaynames$.pipe(
|
||||||
map(
|
map(
|
||||||
@@ -726,18 +728,77 @@ export class CallViewModel extends ViewModel {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
public readonly memberChanges$ = this.userMedia$
|
/**
|
||||||
.pipe(map((mediaItems) => mediaItems.map((m) => m.id)))
|
* This observable tracks the currently connected participants.
|
||||||
.pipe(
|
*
|
||||||
scan<string[], { ids: string[]; joined: string[]; left: string[] }>(
|
* - Each participant has one livekit connection
|
||||||
(prev, ids) => {
|
* - Each participant has a corresponding MatrixRTC membership state event
|
||||||
const left = prev.ids.filter((id) => !ids.includes(id));
|
* - There can be multiple participants for one matrix user.
|
||||||
const joined = ids.filter((id) => !prev.ids.includes(id));
|
*/
|
||||||
return { ids, joined, left };
|
public readonly participantChanges$ = this.userMedia$.pipe(
|
||||||
},
|
map((mediaItems) => mediaItems.map((m) => m.id)),
|
||||||
{ ids: [], joined: [], left: [] },
|
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: [] },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<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 }) => {
|
||||||
|
const userId = this.matrixRTCSession.room.client.getUserId();
|
||||||
|
if (!userId) {
|
||||||
|
logger.warn("Could access client.getUserId to compute allOthersLeft");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return userIds.size === 1 && userIds.has(userId) && leftUserIds.size > 0;
|
||||||
|
}),
|
||||||
|
startWith(false),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
);
|
||||||
|
|
||||||
|
public readonly autoLeaveWhenOthersLeft$ = this.allOthersLeft$.pipe(
|
||||||
|
distinctUntilChanged(),
|
||||||
|
filter((leave) => (leave && this.options.autoLeaveWhenOthersLeft) ?? false),
|
||||||
|
map(() => {}),
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -1426,7 +1487,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
private readonly matrixRTCSession: MatrixRTCSession,
|
private readonly matrixRTCSession: MatrixRTCSession,
|
||||||
private readonly livekitRoom: LivekitRoom,
|
private readonly livekitRoom: LivekitRoom,
|
||||||
private readonly mediaDevices: MediaDevices,
|
private readonly mediaDevices: MediaDevices,
|
||||||
private readonly encryptionSystem: EncryptionSystem,
|
private readonly options: CallViewModelOptions,
|
||||||
private readonly connectionState$: Observable<ECConnectionState>,
|
private readonly connectionState$: Observable<ECConnectionState>,
|
||||||
private readonly handsRaisedSubject$: Observable<
|
private readonly handsRaisedSubject$: Observable<
|
||||||
Record<string, RaisedHandInfo>
|
Record<string, RaisedHandInfo>
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import {
|
|||||||
mockLocalParticipant,
|
mockLocalParticipant,
|
||||||
} from "./test";
|
} from "./test";
|
||||||
|
|
||||||
export const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC");
|
export const localRtcMember = mockRtcMembership("@carol:example.org", "1111");
|
||||||
|
export const localRtcMemberDevice2 = mockRtcMembership(
|
||||||
|
"@carol:example.org",
|
||||||
|
"2222",
|
||||||
|
);
|
||||||
export const local = mockMatrixRoomMember(localRtcMember);
|
export const local = mockMatrixRoomMember(localRtcMember);
|
||||||
export const localParticipant = mockLocalParticipant({ identity: "" });
|
export const localParticipant = mockLocalParticipant({ identity: "" });
|
||||||
export const localId = `${local.userId}:${localRtcMember.deviceId}`;
|
export const localId = `${local.userId}:${localRtcMember.deviceId}`;
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export function getBasicCallViewModelEnvironment(
|
|||||||
liveKitRoom,
|
liveKitRoom,
|
||||||
mockMediaDevices({}),
|
mockMediaDevices({}),
|
||||||
{
|
{
|
||||||
kind: E2eeType.PER_PARTICIPANT,
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
},
|
},
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
handRaisedSubject$,
|
handRaisedSubject$,
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export interface OurRunHelpers extends RunHelpers {
|
|||||||
values?: { [marble: string]: T },
|
values?: { [marble: string]: T },
|
||||||
error?: unknown,
|
error?: unknown,
|
||||||
): Behavior<T>;
|
): Behavior<T>;
|
||||||
|
scope: ObservableScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TestRunnerGlobal {
|
interface TestRunnerGlobal {
|
||||||
@@ -96,6 +97,7 @@ export function withTestScheduler(
|
|||||||
scheduler.run((helpers) =>
|
scheduler.run((helpers) =>
|
||||||
continuation({
|
continuation({
|
||||||
...helpers,
|
...helpers,
|
||||||
|
scope,
|
||||||
schedule(marbles, actions) {
|
schedule(marbles, actions) {
|
||||||
const actionsObservable$ = helpers
|
const actionsObservable$ = helpers
|
||||||
.cold(marbles)
|
.cold(marbles)
|
||||||
|
|||||||
Reference in New Issue
Block a user