diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 5f8e7c28..a7d7d4dc 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 } 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 { @@ -140,11 +144,11 @@ export const ActiveCall: FC = (props) => { useEffect(() => { logger.info( - `[Lifecycle] InCallView Component mounted, livekitroom state ${livekitRoom?.state}`, + `[Lifecycle] InCallView Component mounted, livekit room state ${livekitRoom?.state}`, ); return (): void => { logger.info( - `[Lifecycle] InCallView Component unmounted, livekitroom state ${livekitRoom?.state}`, + `[Lifecycle] InCallView Component unmounted, livekit room state ${livekitRoom?.state}`, ); livekitRoom ?.disconnect() @@ -166,7 +170,10 @@ export const ActiveCall: FC = (props) => { props.rtcSession, livekitRoom, mediaDevices, - props.e2eeSystem, + { + encryptionSystem: props.e2eeSystem, + autoLeaveWhenOthersLeft: undefined, + }, connStateObservable$, reactionsReader.raisedHands$, reactionsReader.reactions$, @@ -313,6 +320,7 @@ export const InCallView: FC = ({ const earpieceMode = useBehavior(vm.earpieceMode$); const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); const switchCamera = useSwitchCamera(vm.localVideo$); + useSubscription(vm.autoLeaveWhenOthersLeft$, 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.ts b/src/state/CallViewModel.ts index bd2a7607..956f055c 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -96,6 +96,10 @@ import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname"; import { type MediaDevices } from "./MediaDevices"; import { type Behavior } from "./Behavior"; +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; @@ -473,49 +477,47 @@ export class CallViewModel extends ViewModel { ), ); + 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(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.scope.behavior( - 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), - map(() => { - const displaynameMap = new Map(); - const { room, memberships } = this.matrixRTCSession; + public readonly memberDisplaynames$ = this.memberships$.pipe( + map((memberships) => { + const displaynameMap = new Map(); + const { room } = this.matrixRTCSession; - // We only consider RTC members for disambiguation as they are the only visible members. - for (const rtcMember of memberships) { - const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`; - const { member } = getRoomMemberFromRtcMember(rtcMember, room); - if (!member) { - logger.error( - "Could not find member for media id:", - matrixIdentifier, - ); - continue; - } - const disambiguate = shouldDisambiguate(member, memberships, room); - displaynameMap.set( - matrixIdentifier, - calculateDisplayName(member, disambiguate), - ); + // We only consider RTC members for disambiguation as they are the only visible members. + for (const rtcMember of memberships) { + const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`; + const { member } = getRoomMemberFromRtcMember(rtcMember, room); + if (!member) { + logger.error("Could not find member for media id:", matrixIdentifier); + continue; } - 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: - ), + const disambiguate = shouldDisambiguate(member, memberships, room); + displaynameMap.set( + matrixIdentifier, + calculateDisplayName(member, disambiguate), + ); + } + 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$); @@ -612,7 +614,7 @@ export class CallViewModel extends ViewModel { indexedMediaId, member, participant, - this.encryptionSystem, + this.options.encryptionSystem, this.livekitRoom, this.memberDisplaynames$.pipe( map((m) => m.get(matrixIdentifier) ?? "[👻]"), @@ -635,7 +637,7 @@ export class CallViewModel extends ViewModel { screenShareId, member, participant, - this.encryptionSystem, + this.options.encryptionSystem, this.livekitRoom, this.memberDisplaynames$.pipe( map((m) => m.get(matrixIdentifier) ?? "[👻]"), @@ -676,7 +678,7 @@ export class CallViewModel extends ViewModel { nonMemberId, undefined, participant, - this.encryptionSystem, + this.options.encryptionSystem, this.livekitRoom, this.memberDisplaynames$.pipe( map( @@ -726,18 +728,29 @@ 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 === 0 && left.length > 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 @@ -1426,7 +1439,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