Skip to content

Introduce configurable auto leave option #3372

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: livekit
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/UrlParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -329,6 +337,7 @@ export const getUrlParams = (
rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"),
sentryDsn: parser.getParam("sentryDsn"),
sentryEnvironment: parser.getParam("sentryEnvironment"),
autoLeaveWhenOthersLeft: parser.getFlagParam("autoLeave"),
};
};

Expand Down
7 changes: 5 additions & 2 deletions src/room/GroupCallView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,15 +166,18 @@ export const GroupCallView: FC<Props> = ({
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(
useExperimentalToDeviceTransportSetting,
);

// Save the password once we start the groupCallView
const { password: passwordFromUrl } = useUrlParams();
useEffect(() => {
if (passwordFromUrl) saveKeyForRoom(room.roomId, passwordFromUrl);
}, [passwordFromUrl, room.roomId]);
Expand Down
16 changes: 14 additions & 2 deletions src/room/InCallView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -158,14 +162,19 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
};
}, [livekitRoom]);

const { autoLeaveWhenOthersLeft } = useUrlParams();

useEffect(() => {
if (livekitRoom !== undefined) {
const reactionsReader = new ReactionsReader(props.rtcSession);
const vm = new CallViewModel(
props.rtcSession,
livekitRoom,
mediaDevices,
props.e2eeSystem,
{
encryptionSystem: props.e2eeSystem,
autoLeaveWhenOthersLeft,
},
connStateObservable$,
reactionsReader.raisedHands$,
reactionsReader.reactions$,
Expand All @@ -182,6 +191,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
mediaDevices,
props.e2eeSystem,
connStateObservable$,
autoLeaveWhenOthersLeft,
]);

if (livekitRoom === undefined || vm === null) return null;
Expand Down Expand Up @@ -302,6 +312,8 @@ export const InCallView: FC<InCallViewProps> = ({
() => void toggleRaisedHand(),
);

useSubscription(vm.autoLeaveWhenOthersLeft$, onLeave);

const windowMode = useObservableEagerState(vm.windowMode$);
const layout = useObservableEagerState(vm.layout$);
const tileStoreGeneration = useObservableEagerState(vm.tileStoreGeneration$);
Expand Down
145 changes: 141 additions & 4 deletions src/state/CallViewModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -221,6 +225,10 @@ function withCallViewModel(
vm: CallViewModel,
subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> },
) => void,
options: CallViewModelOptions = {
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
autoLeaveWhenOthersLeft: false,
},
): void {
const room = mockMatrixRoom({
client: {
Expand Down Expand Up @@ -271,9 +279,7 @@ function withCallViewModel(
rtcSession as unknown as MatrixRTCSession,
liveKitRoom,
mediaDevices,
{
kind: E2eeType.PER_PARTICIPANT,
},
options,
connectionState$,
raisedHands$,
new BehaviorSubject({}),
Expand Down Expand Up @@ -1015,6 +1021,137 @@ 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 }) => {
// 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 });
Expand Down
71 changes: 47 additions & 24 deletions src/state/CallViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<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(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<string, string>();
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) {
Expand Down Expand Up @@ -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) ?? "[👻]"),
Expand All @@ -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) ?? "[👻]"),
Expand Down Expand Up @@ -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) ?? "[👻]"),
Expand Down Expand Up @@ -686,18 +696,31 @@ export class CallViewModel extends ViewModel {
),
);

public readonly memberChanges$ = this.userMedia$
.pipe(map((mediaItems) => mediaItems.map((m) => m.id)))
.pipe(
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: [] },
),
);
public readonly memberChanges$ = 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: [] },
),
);

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
Expand Down Expand Up @@ -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<ECConnectionState>,
private readonly handsRaisedSubject$: Observable<
Record<string, RaisedHandInfo>
Expand Down
Loading