diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 9f89fd471..8ff4ef369 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -71,6 +71,14 @@ export interface UrlParams { * is window.controls.onBackButtonPressed. */ header: HeaderStyle; + /** + * If we want the app to automatically leave the call when all other participants + * leave the call. + * + * This is used in settings where EC should behave more like a phone call other than a conference. + * Like in a DM. + */ + autoLeaveWhenOthersLeft: boolean; /** * Whether the controls should be shown. For screen recording no controls can be desired. */ @@ -329,6 +337,7 @@ export const getUrlParams = ( rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"), sentryDsn: parser.getParam("sentryDsn"), sentryEnvironment: parser.getParam("sentryEnvironment"), + autoLeaveWhenOthersLeft: parser.getFlagParam("autoLeave"), }; }; diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 5b9b1f02a..70eab6087 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -166,7 +166,11 @@ export const GroupCallView: FC = ({ const { displayName, avatarUrl } = useProfile(client); const roomName = useRoomName(room); const roomAvatar = useRoomAvatar(room); - const { perParticipantE2EE, returnToLobby } = useUrlParams(); + const { + perParticipantE2EE, + returnToLobby, + password: passwordFromUrl, + } = useUrlParams(); const e2eeSystem = useRoomEncryptionSystem(room.roomId); const [useNewMembershipManager] = useSetting(useNewMembershipManagerSetting); const [useExperimentalToDeviceTransport] = useSetting( @@ -174,7 +178,6 @@ export const GroupCallView: FC = ({ ); // Save the password once we start the groupCallView - const { password: passwordFromUrl } = useUrlParams(); useEffect(() => { if (passwordFromUrl) saveKeyForRoom(room.roomId, passwordFromUrl); }, [passwordFromUrl, room.roomId]); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 452e8572c..6157360cf 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -25,7 +25,11 @@ import useMeasure from "react-use-measure"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import classNames from "classnames"; import { BehaviorSubject, map } from "rxjs"; -import { useObservable, useObservableEagerState } from "observable-hooks"; +import { + useObservable, + useObservableEagerState, + useSubscription, +} from "observable-hooks"; import { logger } from "matrix-js-sdk/lib/logger"; import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; import { @@ -158,6 +162,8 @@ export const ActiveCall: FC = (props) => { }; }, [livekitRoom]); + const { autoLeaveWhenOthersLeft } = useUrlParams(); + useEffect(() => { if (livekitRoom !== undefined) { const reactionsReader = new ReactionsReader(props.rtcSession); @@ -165,7 +171,10 @@ export const ActiveCall: FC = (props) => { props.rtcSession, livekitRoom, mediaDevices, - props.e2eeSystem, + { + encryptionSystem: props.e2eeSystem, + autoLeaveWhenOthersLeft, + }, connStateObservable$, reactionsReader.raisedHands$, reactionsReader.reactions$, @@ -182,6 +191,7 @@ export const ActiveCall: FC = (props) => { mediaDevices, props.e2eeSystem, connStateObservable$, + autoLeaveWhenOthersLeft, ]); if (livekitRoom === undefined || vm === null) return null; @@ -302,6 +312,8 @@ export const InCallView: FC = ({ () => void toggleRaisedHand(), ); + useSubscription(vm.autoLeaveWhenOthersLeft$, onLeave); + const windowMode = useObservableEagerState(vm.windowMode$); const layout = useObservableEagerState(vm.layout$); const tileStoreGeneration = useObservableEagerState(vm.tileStoreGeneration$); diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 42b040792..4d78e438f 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -32,7 +32,11 @@ import { } from "matrix-js-sdk/lib/matrixrtc"; import { deepCompare } from "matrix-js-sdk/lib/utils"; -import { CallViewModel, type Layout } from "./CallViewModel"; +import { + CallViewModel, + type CallViewModelOptions, + type Layout, +} from "./CallViewModel"; import { mockLivekitRoom, mockLocalParticipant, @@ -221,6 +225,10 @@ function withCallViewModel( vm: CallViewModel, subjects: { raisedHands$: BehaviorSubject> }, ) => void, + options: CallViewModelOptions = { + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + autoLeaveWhenOthersLeft: false, + }, ): void { const room = mockMatrixRoom({ client: { @@ -271,9 +279,7 @@ function withCallViewModel( rtcSession as unknown as MatrixRTCSession, liveKitRoom, mediaDevices, - { - kind: E2eeType.PER_PARTICIPANT, - }, + options, connectionState$, raisedHands$, new BehaviorSubject({}), @@ -1015,6 +1021,137 @@ 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", { + a: [], // Start empty + b: [], // Alice joins + c: [], // Alice still there + d: [], // Alice leaves + }); +} + +function participantJoinLeave$( + hot: ( + marbles: string, + values: Record, + ) => Observable, +): Observable { + 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, + ) => Observable, +): Observable { + 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 }) => { + // Test scenario 1: No one ever joins - should only emit initial false and never emit again + withCallViewModel( + nooneEverThere$(hot), + 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 }) => { + withCallViewModel( + participantJoinLeave$(hot), + 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 }) => { + withCallViewModel( + participantJoinLeave$(hot), + 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 noone is there", () => { + withTestScheduler(({ hot, expectObservable }) => { + withCallViewModel( + nooneEverThere$(hot), + nooneEverThere$(hot), + of(ConnectionState.Connected), + new Map(), + mockMediaDevices({}), + (vm) => { + expectObservable(vm.autoLeaveWhenOthersLeft$).toBe( + "-------", // false initially, then at frame 6: true then false emissions in same frame + ); + }, + { + autoLeaveWhenOthersLeft: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); +}); + +test("autoLeaveWhenOthersLeft$ emits when autoLeaveWhenOthersLeft option is enabled and all others left", () => { + withTestScheduler(({ hot, expectObservable }) => { + withCallViewModel( + participantJoinLeave$(hot), + rtcMemberJoinLeave$(hot), + of(ConnectionState.Connected), + new Map(), + mockMediaDevices({}), + (vm) => { + expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------"); + }, + ); + }); +}); + test("audio output changes when toggling earpiece mode", () => { withTestScheduler(({ schedule, expectObservable }) => { getUrlParams.mockReturnValue({ controlledAudioDevices: true }); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index fc1222c44..9915e60a8 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -95,6 +95,10 @@ import { shallowEquals } from "../utils/array"; import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname"; import { type MediaDevices } from "./MediaDevices"; +export interface CallViewModelOptions { + encryptionSystem: EncryptionSystem; + autoLeaveWhenOthersLeft?: boolean; +} // How long we wait after a focus switch before showing the real participant // list again const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000; @@ -461,21 +465,27 @@ export class CallViewModel extends ViewModel { }, ); - /** - * Displaynames for each member of the call. This will disambiguate - * any displaynames that clashes with another member. Only members - * joined to the call are considered here. - */ - public readonly memberDisplaynames$ = merge( + private readonly memberships$: Observable = 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(null), + startWith(this.matrixRTCSession.memberships), map(() => { + return this.matrixRTCSession.memberships; + }), + ); + + /** + * Displaynames for each member of the call. This will disambiguate + * any displaynames that clashes with another member. Only members + * joined to the call are considered here. + */ + public readonly memberDisplaynames$ = this.memberships$.pipe( + map((memberships) => { const displaynameMap = new Map(); - const { room, memberships } = this.matrixRTCSession; + const { room } = this.matrixRTCSession; // We only consider RTC members for disambiguation as they are the only visible members. for (const rtcMember of memberships) { @@ -577,7 +587,7 @@ export class CallViewModel extends ViewModel { indexedMediaId, member, participant, - this.encryptionSystem, + this.options.encryptionSystem, this.livekitRoom, this.memberDisplaynames$.pipe( map((m) => m.get(matrixIdentifier) ?? "[👻]"), @@ -600,7 +610,7 @@ export class CallViewModel extends ViewModel { screenShareId, member, participant, - this.encryptionSystem, + this.options.encryptionSystem, this.livekitRoom, this.memberDisplaynames$.pipe( map((m) => m.get(matrixIdentifier) ?? "[👻]"), @@ -641,7 +651,7 @@ export class CallViewModel extends ViewModel { nonMemberId, undefined, participant, - this.encryptionSystem, + this.options.encryptionSystem, this.livekitRoom, this.memberDisplaynames$.pipe( map((m) => m.get(participant.identity) ?? "[👻]"), @@ -686,18 +696,31 @@ export class CallViewModel extends ViewModel { ), ); - public readonly memberChanges$ = this.userMedia$ - .pipe(map((mediaItems) => mediaItems.map((m) => m.id))) - .pipe( - 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 memberChanges$ = 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 allOthersLeft$ = this.memberChanges$.pipe( + map( + ({ ids, left }) => + ids.length === 1 && ids.includes("local:0") && left.length > 0, + ), + startWith(false), + distinctUntilChanged(), + ); + + public readonly autoLeaveWhenOthersLeft$ = this.allOthersLeft$.pipe( + filter((leave) => (leave && this.options.autoLeaveWhenOthersLeft) ?? false), + map(() => {}), + ); /** * List of MediaItems that we want to display, that are of type ScreenShare @@ -1383,7 +1406,7 @@ export class CallViewModel extends ViewModel { private readonly matrixRTCSession: MatrixRTCSession, private readonly livekitRoom: LivekitRoom, private readonly mediaDevices: MediaDevices, - private readonly encryptionSystem: EncryptionSystem, + private readonly options: CallViewModelOptions, private readonly connectionState$: Observable, private readonly handsRaisedSubject$: Observable< Record diff --git a/src/utils/test-viewmodel.ts b/src/utils/test-viewmodel.ts index 179c38b12..4781bf3d8 100644 --- a/src/utils/test-viewmodel.ts +++ b/src/utils/test-viewmodel.ts @@ -139,7 +139,7 @@ export function getBasicCallViewModelEnvironment( liveKitRoom, mockMediaDevices({}), { - kind: E2eeType.PER_PARTICIPANT, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, of(ConnectionState.Connected), handRaisedSubject$,