From ccfd32c9b2f7dda31173f999d35bb458cf17e2a8 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 16 Sep 2025 16:52:17 +0200 Subject: [PATCH] move leave logic into view model Signed-off-by: Timo K --- src/room/GroupCallView.test.tsx | 2 +- src/room/GroupCallView.tsx | 50 +++++------ src/room/InCallView.test.tsx | 3 +- src/room/InCallView.tsx | 35 +++----- src/state/CallViewModel.ts | 143 +++++++++++++++++++++++--------- 5 files changed, 136 insertions(+), 97 deletions(-) diff --git a/src/room/GroupCallView.test.tsx b/src/room/GroupCallView.test.tsx index 084c06ec..bf5d1fef 100644 --- a/src/room/GroupCallView.test.tsx +++ b/src/room/GroupCallView.test.tsx @@ -103,7 +103,7 @@ beforeEach(() => { }); // A trivial implementation of Active call to ensure we are testing GroupCallView exclusively here. (ActiveCall as MockedFunction).mockImplementation( - ({ onLeave }) => { + ({ onLeft: onLeave }) => { return (
diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 9d310864..ab083ffe 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -184,9 +184,6 @@ export const GroupCallView: FC = ({ } = useUrlParams(); const e2eeSystem = useRoomEncryptionSystem(room.roomId); const [useNewMembershipManager] = useSetting(useNewMembershipManagerSetting); - const [useExperimentalToDeviceTransport] = useSetting( - useExperimentalToDeviceTransportSetting, - ); // Save the password once we start the groupCallView useEffect(() => { @@ -223,12 +220,6 @@ export const GroupCallView: FC = ({ try { setJoined(true); // TODO-MULTI-SFU what to do with error handling now that we don't use this function? - // await enterRTCSession( - // rtcSession, - // perParticipantE2EE, - // useNewMembershipManager, - // useExperimentalToDeviceTransport, - // ); } catch (e) { if (e instanceof ElementCallError) { setExternalError(e); @@ -322,16 +313,16 @@ export const GroupCallView: FC = ({ const navigate = useNavigate(); - const onLeave = useCallback( - ( - cause: "user" | "error" = "user", - playSound: CallEventSounds = "left", - ): void => { + const onLeft = useCallback( + (reason: "timeout" | "user" | "allOthersLeft" | "decline"): void => { + let playSound: CallEventSounds = "left"; + if (reason === "timeout" || reason === "decline") playSound = reason; + + setLeft(true); const audioPromise = leaveSoundContext.current?.playSound(playSound); // In embedded/widget mode the iFrame will be killed right after the call ended prohibiting the posthog event from getting sent, // therefore we want the event to be sent instantly without getting queued/batched. const sendInstantly = !!widget; - setLeft(true); // we need to wait until the callEnded event is tracked on posthog. // Otherwise the iFrame gets killed before the callEnded event got tracked. const posthogRequest = new Promise((resolve) => { @@ -339,37 +330,33 @@ export const GroupCallView: FC = ({ room.roomId, rtcSession.memberships.length, sendInstantly, + rtcSession, ); window.setTimeout(resolve, 10); }); - // TODO-MULTI-SFU find a solution if this is supposed to happen here or in the view model. - leaveRTCSession( - rtcSession, - cause, - // Wait for the sound in widget mode (it's not long) - Promise.all([audioPromise, posthogRequest]), - ) - // Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts. - .then(async () => { + void Promise.all([audioPromise, posthogRequest]) + .then(() => { if ( !isPasswordlessUser && !confineToRoom && !PosthogAnalytics.instance.isEnabled() ) { - await navigate("/"); + void navigate("/"); } }) - .catch((e) => { - logger.error("Error leaving RTC session", e); - }); + .catch(() => + logger.error( + "could failed to play leave audio or send posthog leave event", + ), + ); }, [ leaveSoundContext, widget, - rtcSession, room.roomId, + rtcSession, isPasswordlessUser, confineToRoom, navigate, @@ -457,7 +444,7 @@ export const GroupCallView: FC = ({ matrixInfo={matrixInfo} rtcSession={rtcSession as MatrixRTCSession} matrixRoom={room} - onLeave={onLeave} + onLeft={onLeft} header={header} muteStates={muteStates} e2eeSystem={e2eeSystem} @@ -518,7 +505,8 @@ export const GroupCallView: FC = ({ }} onError={ (/**error*/) => { - if (rtcSession.isJoined()) onLeave("error"); + // TODO this should not be "user". It needs a new case + if (rtcSession.isJoined()) onLeft("user"); } } > diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index 15a896a4..6d2aaf0a 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -177,7 +177,8 @@ function createInCallView(): RenderResult & { }} matrixRoom={room} livekitRoom={livekitRoom} - onLeave={function (): void { + participantCount={0} + onLeft={function (): void { throw new Error("Function not implemented."); }} onShareClick={null} diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 1e92d110..c12b44c9 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -23,11 +23,7 @@ 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, - useSubscription, -} from "observable-hooks"; +import { useObservable, useObservableEagerState } from "observable-hooks"; import { logger } from "matrix-js-sdk/lib/logger"; import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; import { @@ -94,10 +90,7 @@ import { } from "../reactions/useReactionsSender"; import { ReactionsAudioRenderer } from "./ReactionAudioRenderer"; import { ReactionsOverlay } from "./ReactionsOverlay"; -import { - CallEventAudioRenderer, - type CallEventSounds, -} from "./CallEventAudioRenderer"; +import { CallEventAudioRenderer } from "./CallEventAudioRenderer"; import { debugTileLayout as debugTileLayoutSetting, useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting, @@ -129,6 +122,8 @@ const maxTapDurationMs = 400; export interface ActiveCallProps extends Omit { e2eeSystem: EncryptionSystem; + // TODO refactor those reasons into an enum + onLeft: (reason: "user" | "timeout" | "decline" | "allOthersLeft") => void; } export const ActiveCall: FC = (props) => { @@ -154,8 +149,11 @@ export const ActiveCall: FC = (props) => { reactionsReader.reactions$, ); setVm(vm); + + const sub = vm.left$.subscribe(props.onLeft); return (): void => { vm.destroy(); + sub.unsubscribe(); reactionsReader.destroy(); }; }, [ @@ -167,6 +165,7 @@ export const ActiveCall: FC = (props) => { autoLeaveWhenOthersLeft, sendNotificationType, waitForCallPickup, + props.onLeft, ]); if (vm === null) return null; @@ -185,8 +184,6 @@ export interface InCallViewProps { rtcSession: MatrixRTCSession; matrixRoom: MatrixRoom; muteStates: MuteStates; - /** Function to call when the user explicitly ends the call */ - onLeave: (cause: "user", soundFile?: CallEventSounds) => void; header: HeaderStyle; otelGroupCallMembership?: OTelGroupCallMembership; onShareClick: (() => void) | null; @@ -199,7 +196,7 @@ export const InCallView: FC = ({ rtcSession, matrixRoom, muteStates, - onLeave, + header: headerStyle, onShareClick, }) => { @@ -295,7 +292,6 @@ export const InCallView: FC = ({ const showFooter = useBehavior(vm.showFooter$); const earpieceMode = useBehavior(vm.earpieceMode$); const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); - useSubscription(vm.autoLeave$, () => onLeave("user")); // We need to set the proper timings on the animation based upon the sound length. const ringDuration = pickupPhaseAudio?.soundDuration["waiting"] ?? 1; @@ -316,16 +312,6 @@ export const InCallView: FC = ({ }; }, [pickupPhaseAudio?.soundDuration, ringDuration]); - // When we enter timeout or decline we will leave the call. - useEffect((): void | (() => void) => { - if (callPickupState === "timeout") { - onLeave("user", "timeout"); - } - if (callPickupState === "decline") { - onLeave("user", "decline"); - } - }, [callPickupState, onLeave, pickupPhaseAudio]); - // When waiting for pickup, loop a waiting sound useEffect((): void | (() => void) => { if (callPickupState !== "ringing" || !pickupPhaseAudio) return; @@ -343,6 +329,7 @@ export const InCallView: FC = ({ if (callPickupState !== "ringing") return null; // Use room state for other participants data (the one that we likely want to reach) + // TODO: this screams it wants to be a behavior in the vm. const roomOthers = [ ...matrixRoom.getMembersWithMembership("join"), ...matrixRoom.getMembersWithMembership("invite"), @@ -816,7 +803,7 @@ export const InCallView: FC = ({ c.connectionState$), + startWith(ConnectionState.Disconnected), + ), + ); + // TODO-MULTI-SFU make sure that we consider the room memberships here as well (so that here we only have valid memberships) // this also makes it possible to use this memberships$ list in all observables based on it. // there should be no other call to: this.matrixRTCSession.memberships! @@ -541,12 +550,19 @@ export class CallViewModel extends ViewModel { this.join$.next(); } - private readonly leave$ = new Subject(); + private readonly leave$ = new Subject< + "decline" | "timeout" | "user" | "allOthersLeft" + >(); public leave(): void { - this.leave$.next(); + this.leave$.next("user"); } + private readonly _left$ = new Subject< + "decline" | "timeout" | "user" | "allOthersLeft" + >(); + public left$ = this._left$.asObservable(); + private readonly connectionInstructions$ = this.join$.pipe( switchMap(() => this.remoteConnections$), startWith(new Map()), @@ -628,10 +644,9 @@ export class CallViewModel extends ViewModel { private readonly connected$ = this.scope.behavior( and$( this.matrixConnected$, - // TODO-MULTI-SFU - // this.livekitConnectionState$.pipe( - // map((state) => state === ConnectionState.Connected), - // ), + this.livekitConnectionState$.pipe( + map((state) => state === ConnectionState.Connected), + ), ), ); @@ -663,7 +678,6 @@ export class CallViewModel extends ViewModel { // in a split-brained state. private readonly pretendToBeDisconnected$ = this.reconnecting$; - private readonly participants$ = this.scope.behavior< { participant: LocalParticipant | RemoteParticipant; @@ -731,7 +745,6 @@ export class CallViewModel extends ViewModel { // Handle room membership changes (and displayname updates) fromEvent(this.matrixRoom, RoomStateEvent.Members), // TODO: do we need: pauseWhen(this.pretendToBeDisconnected$), - ).pipe( startWith(null), map(() => { @@ -759,7 +772,7 @@ export class CallViewModel extends ViewModel { ); } return displaynameMap; - }, + }), ), ); @@ -971,21 +984,6 @@ export class CallViewModel extends ViewModel { this.memberships$.pipe(map((ms) => ms.length)), ); - private readonly allOthersLeft$ = this.memberships$.pipe( - pairwise(), - filter( - ([prev, current]) => - current.every((m) => m.sender === this.userId) && - prev.some((m) => m.sender !== this.userId), - ), - map(() => {}), - take(1), - ); - - public readonly autoLeave$ = this.options.autoLeaveWhenOthersLeft - ? this.allOthersLeft$ - : NEVER; - private readonly didSendCallNotification$ = fromEvent( this.matrixRTCSession, MatrixRTCSessionEvent.DidSendCallNotification, @@ -994,6 +992,7 @@ export class CallViewModel extends ViewModel { MatrixRTCSessionEventHandlerMap[MatrixRTCSessionEvent.DidSendCallNotification] > >; + /** * Whenever the RTC session tells us that it intends to ring the remote * participant's devices, this emits an Observable tracking the current state of @@ -1109,6 +1108,56 @@ export class CallViewModel extends ViewModel { map(() => {}), throttleTime(THROTTLE_SOUND_EFFECT_MS), ); + /** + * 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, + { + userIds: Set; + joinedUserIds: Set; + leftUserIds: Set; + } + >( + (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() }, + ), + ); + + private 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), + ); + + public readonly autoLeave$ = this.options.autoLeaveWhenOthersLeft + ? this.allOthersLeft$ + : NEVER; /** * List of MediaItems that we want to display, that are of type ScreenShare @@ -1791,8 +1840,6 @@ export class CallViewModel extends ViewModel { ), filter((v) => v.playSounds), ); - // TODO-REBASE: expose connection state observable - public readonly livekitConnectionState$: Observable; public constructor( // A call is permanently tied to a single Matrix room @@ -1839,18 +1886,34 @@ export class CallViewModel extends ViewModel { ); }); - this.join$.pipe(this.scope.bind()).subscribe(() => { - // TODO-MULTI-SFU: this makes no sense what so ever!!! - // need to look into this again. - // leaveRTCSession( - // this.matrixRTCSession, - // "user", // TODO-MULTI-SFU ? - // // Wait for the sound in widget mode (it's not long) - // Promise.resolve(), // TODO-MULTI-SFU - // //Promise.all([audioPromise, posthogRequest]), - // ).catch((e) => { - // logger.error("Error leaving RTC session", e); - // }); + this.allOthersLeft$ + .pipe( + this.scope.bind(), + filter((l) => (l && this.options.autoLeaveWhenOthersLeft) ?? false), + distinctUntilChanged(), + ) + .subscribe(() => { + this.leave$.next("allOthersLeft"); + }); + + this.callPickupState$.pipe(this.scope.bind()).subscribe((state) => { + if (state === "timeout" || state === "decline") { + this.leave$.next(state); + } + }); + + this.leave$.pipe(this.scope.bind()).subscribe((reason) => { + const { confineToRoom } = getUrlParams(); + leaveRTCSession(this.matrixRTCSession, "user") + // Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts. + .then(() => { + if (!confineToRoom && !PosthogAnalytics.instance.isEnabled()) { + this._left$.next(reason); + } + }) + .catch((e) => { + logger.error("Error leaving RTC session", e); + }); }); // Pause upstream of all local media tracks when we're disconnected from