move leave logic into view model

Signed-off-by: Timo K <toger5@hotmail.de>
This commit is contained in:
Timo K
2025-09-16 16:52:17 +02:00
parent 38d78ddce4
commit ccfd32c9b2
5 changed files with 136 additions and 97 deletions

View File

@@ -103,7 +103,7 @@ beforeEach(() => {
}); });
// A trivial implementation of Active call to ensure we are testing GroupCallView exclusively here. // A trivial implementation of Active call to ensure we are testing GroupCallView exclusively here.
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation( (ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(
({ onLeave }) => { ({ onLeft: onLeave }) => {
return ( return (
<div> <div>
<button onClick={() => onLeave("user")}>Leave</button> <button onClick={() => onLeave("user")}>Leave</button>

View File

@@ -184,9 +184,6 @@ export const GroupCallView: FC<Props> = ({
} = useUrlParams(); } = useUrlParams();
const e2eeSystem = useRoomEncryptionSystem(room.roomId); const e2eeSystem = useRoomEncryptionSystem(room.roomId);
const [useNewMembershipManager] = useSetting(useNewMembershipManagerSetting); const [useNewMembershipManager] = useSetting(useNewMembershipManagerSetting);
const [useExperimentalToDeviceTransport] = useSetting(
useExperimentalToDeviceTransportSetting,
);
// Save the password once we start the groupCallView // Save the password once we start the groupCallView
useEffect(() => { useEffect(() => {
@@ -223,12 +220,6 @@ export const GroupCallView: FC<Props> = ({
try { try {
setJoined(true); setJoined(true);
// TODO-MULTI-SFU what to do with error handling now that we don't use this function? // 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) { } catch (e) {
if (e instanceof ElementCallError) { if (e instanceof ElementCallError) {
setExternalError(e); setExternalError(e);
@@ -322,16 +313,16 @@ export const GroupCallView: FC<Props> = ({
const navigate = useNavigate(); const navigate = useNavigate();
const onLeave = useCallback( const onLeft = useCallback(
( (reason: "timeout" | "user" | "allOthersLeft" | "decline"): void => {
cause: "user" | "error" = "user", let playSound: CallEventSounds = "left";
playSound: CallEventSounds = "left", if (reason === "timeout" || reason === "decline") playSound = reason;
): void => {
setLeft(true);
const audioPromise = leaveSoundContext.current?.playSound(playSound); 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, // 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. // therefore we want the event to be sent instantly without getting queued/batched.
const sendInstantly = !!widget; const sendInstantly = !!widget;
setLeft(true);
// we need to wait until the callEnded event is tracked on posthog. // we need to wait until the callEnded event is tracked on posthog.
// Otherwise the iFrame gets killed before the callEnded event got tracked. // Otherwise the iFrame gets killed before the callEnded event got tracked.
const posthogRequest = new Promise((resolve) => { const posthogRequest = new Promise((resolve) => {
@@ -339,37 +330,33 @@ export const GroupCallView: FC<Props> = ({
room.roomId, room.roomId,
rtcSession.memberships.length, rtcSession.memberships.length,
sendInstantly, sendInstantly,
rtcSession, rtcSession,
); );
window.setTimeout(resolve, 10); window.setTimeout(resolve, 10);
}); });
// TODO-MULTI-SFU find a solution if this is supposed to happen here or in the view model. void Promise.all([audioPromise, posthogRequest])
leaveRTCSession( .then(() => {
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 () => {
if ( if (
!isPasswordlessUser && !isPasswordlessUser &&
!confineToRoom && !confineToRoom &&
!PosthogAnalytics.instance.isEnabled() !PosthogAnalytics.instance.isEnabled()
) { ) {
await navigate("/"); void navigate("/");
} }
}) })
.catch((e) => { .catch(() =>
logger.error("Error leaving RTC session", e); logger.error(
}); "could failed to play leave audio or send posthog leave event",
),
);
}, },
[ [
leaveSoundContext, leaveSoundContext,
widget, widget,
rtcSession,
room.roomId, room.roomId,
rtcSession,
isPasswordlessUser, isPasswordlessUser,
confineToRoom, confineToRoom,
navigate, navigate,
@@ -457,7 +444,7 @@ export const GroupCallView: FC<Props> = ({
matrixInfo={matrixInfo} matrixInfo={matrixInfo}
rtcSession={rtcSession as MatrixRTCSession} rtcSession={rtcSession as MatrixRTCSession}
matrixRoom={room} matrixRoom={room}
onLeave={onLeave} onLeft={onLeft}
header={header} header={header}
muteStates={muteStates} muteStates={muteStates}
e2eeSystem={e2eeSystem} e2eeSystem={e2eeSystem}
@@ -518,7 +505,8 @@ export const GroupCallView: FC<Props> = ({
}} }}
onError={ onError={
(/**error*/) => { (/**error*/) => {
if (rtcSession.isJoined()) onLeave("error"); // TODO this should not be "user". It needs a new case
if (rtcSession.isJoined()) onLeft("user");
} }
} }
> >

View File

@@ -177,7 +177,8 @@ function createInCallView(): RenderResult & {
}} }}
matrixRoom={room} matrixRoom={room}
livekitRoom={livekitRoom} livekitRoom={livekitRoom}
onLeave={function (): void { participantCount={0}
onLeft={function (): void {
throw new Error("Function not implemented."); throw new Error("Function not implemented.");
}} }}
onShareClick={null} onShareClick={null}

View File

@@ -23,11 +23,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 { import { useObservable, useObservableEagerState } from "observable-hooks";
useObservable,
useObservableEagerState,
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 {
@@ -94,10 +90,7 @@ import {
} from "../reactions/useReactionsSender"; } from "../reactions/useReactionsSender";
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer"; import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
import { ReactionsOverlay } from "./ReactionsOverlay"; import { ReactionsOverlay } from "./ReactionsOverlay";
import { import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
CallEventAudioRenderer,
type CallEventSounds,
} from "./CallEventAudioRenderer";
import { import {
debugTileLayout as debugTileLayoutSetting, debugTileLayout as debugTileLayoutSetting,
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting, useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
@@ -129,6 +122,8 @@ const maxTapDurationMs = 400;
export interface ActiveCallProps export interface ActiveCallProps
extends Omit<InCallViewProps, "vm" | "livekitRoom" | "connState"> { extends Omit<InCallViewProps, "vm" | "livekitRoom" | "connState"> {
e2eeSystem: EncryptionSystem; e2eeSystem: EncryptionSystem;
// TODO refactor those reasons into an enum
onLeft: (reason: "user" | "timeout" | "decline" | "allOthersLeft") => void;
} }
export const ActiveCall: FC<ActiveCallProps> = (props) => { export const ActiveCall: FC<ActiveCallProps> = (props) => {
@@ -154,8 +149,11 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
reactionsReader.reactions$, reactionsReader.reactions$,
); );
setVm(vm); setVm(vm);
const sub = vm.left$.subscribe(props.onLeft);
return (): void => { return (): void => {
vm.destroy(); vm.destroy();
sub.unsubscribe();
reactionsReader.destroy(); reactionsReader.destroy();
}; };
}, [ }, [
@@ -167,6 +165,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
autoLeaveWhenOthersLeft, autoLeaveWhenOthersLeft,
sendNotificationType, sendNotificationType,
waitForCallPickup, waitForCallPickup,
props.onLeft,
]); ]);
if (vm === null) return null; if (vm === null) return null;
@@ -185,8 +184,6 @@ export interface InCallViewProps {
rtcSession: MatrixRTCSession; rtcSession: MatrixRTCSession;
matrixRoom: MatrixRoom; matrixRoom: MatrixRoom;
muteStates: MuteStates; muteStates: MuteStates;
/** Function to call when the user explicitly ends the call */
onLeave: (cause: "user", soundFile?: CallEventSounds) => void;
header: HeaderStyle; header: HeaderStyle;
otelGroupCallMembership?: OTelGroupCallMembership; otelGroupCallMembership?: OTelGroupCallMembership;
onShareClick: (() => void) | null; onShareClick: (() => void) | null;
@@ -199,7 +196,7 @@ export const InCallView: FC<InCallViewProps> = ({
rtcSession, rtcSession,
matrixRoom, matrixRoom,
muteStates, muteStates,
onLeave,
header: headerStyle, header: headerStyle,
onShareClick, onShareClick,
}) => { }) => {
@@ -295,7 +292,6 @@ export const InCallView: FC<InCallViewProps> = ({
const showFooter = useBehavior(vm.showFooter$); const showFooter = useBehavior(vm.showFooter$);
const earpieceMode = useBehavior(vm.earpieceMode$); const earpieceMode = useBehavior(vm.earpieceMode$);
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); 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. // We need to set the proper timings on the animation based upon the sound length.
const ringDuration = pickupPhaseAudio?.soundDuration["waiting"] ?? 1; const ringDuration = pickupPhaseAudio?.soundDuration["waiting"] ?? 1;
@@ -316,16 +312,6 @@ export const InCallView: FC<InCallViewProps> = ({
}; };
}, [pickupPhaseAudio?.soundDuration, ringDuration]); }, [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 // When waiting for pickup, loop a waiting sound
useEffect((): void | (() => void) => { useEffect((): void | (() => void) => {
if (callPickupState !== "ringing" || !pickupPhaseAudio) return; if (callPickupState !== "ringing" || !pickupPhaseAudio) return;
@@ -343,6 +329,7 @@ export const InCallView: FC<InCallViewProps> = ({
if (callPickupState !== "ringing") return null; if (callPickupState !== "ringing") return null;
// Use room state for other participants data (the one that we likely want to reach) // 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 = [ const roomOthers = [
...matrixRoom.getMembersWithMembership("join"), ...matrixRoom.getMembersWithMembership("join"),
...matrixRoom.getMembersWithMembership("invite"), ...matrixRoom.getMembersWithMembership("invite"),
@@ -816,7 +803,7 @@ export const InCallView: FC<InCallViewProps> = ({
<EndCallButton <EndCallButton
key="end_call" key="end_call"
onClick={function (): void { onClick={function (): void {
onLeave("user"); vm.leave();
}} }}
onTouchEnd={onControlsTouchEnd} onTouchEnd={onControlsTouchEnd}
data-testid="incall_leave" data-testid="incall_leave"

View File

@@ -112,17 +112,19 @@ import { observeSpeaker$ } from "./observeSpeaker";
import { shallowEquals } from "../utils/array"; import { shallowEquals } from "../utils/array";
import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname"; import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname";
import { type MediaDevices } from "./MediaDevices"; import { type MediaDevices } from "./MediaDevices";
import { type Behavior } from "./Behavior"; import { constant, type Behavior } from "./Behavior";
import { import {
enterRTCSession, enterRTCSession,
getLivekitAlias, getLivekitAlias,
leaveRTCSession,
makeFocus, makeFocus,
} from "../rtcSessionHelpers"; } from "../rtcSessionHelpers";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider"; import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider";
import { type ECConnectionState } from "../livekit/useECConnectionState";
import { Connection, PublishConnection } from "./Connection"; import { Connection, PublishConnection } from "./Connection";
import { type MuteStates } from "./MuteStates"; import { type MuteStates } from "./MuteStates";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { getUrlParams } from "../UrlParams";
export interface CallViewModelOptions { export interface CallViewModelOptions {
encryptionSystem: EncryptionSystem; encryptionSystem: EncryptionSystem;
@@ -461,6 +463,13 @@ export class CallViewModel extends ViewModel {
), ),
); );
public readonly livekitConnectionState$ = this.scope.behavior(
combineLatest([this.localConnection]).pipe(
switchMap(([c]) => 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) // 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. // 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! // there should be no other call to: this.matrixRTCSession.memberships!
@@ -541,12 +550,19 @@ export class CallViewModel extends ViewModel {
this.join$.next(); this.join$.next();
} }
private readonly leave$ = new Subject<void>(); private readonly leave$ = new Subject<
"decline" | "timeout" | "user" | "allOthersLeft"
>();
public leave(): void { 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( private readonly connectionInstructions$ = this.join$.pipe(
switchMap(() => this.remoteConnections$), switchMap(() => this.remoteConnections$),
startWith(new Map<string, Connection>()), startWith(new Map<string, Connection>()),
@@ -628,10 +644,9 @@ export class CallViewModel extends ViewModel {
private readonly connected$ = this.scope.behavior( private readonly connected$ = this.scope.behavior(
and$( and$(
this.matrixConnected$, this.matrixConnected$,
// TODO-MULTI-SFU this.livekitConnectionState$.pipe(
// this.livekitConnectionState$.pipe( map((state) => state === ConnectionState.Connected),
// map((state) => state === ConnectionState.Connected), ),
// ),
), ),
); );
@@ -663,7 +678,6 @@ export class CallViewModel extends ViewModel {
// in a split-brained state. // in a split-brained state.
private readonly pretendToBeDisconnected$ = this.reconnecting$; private readonly pretendToBeDisconnected$ = this.reconnecting$;
private readonly participants$ = this.scope.behavior< private readonly participants$ = this.scope.behavior<
{ {
participant: LocalParticipant | RemoteParticipant; participant: LocalParticipant | RemoteParticipant;
@@ -731,7 +745,6 @@ export class CallViewModel extends ViewModel {
// Handle room membership changes (and displayname updates) // Handle room membership changes (and displayname updates)
fromEvent(this.matrixRoom, RoomStateEvent.Members), fromEvent(this.matrixRoom, RoomStateEvent.Members),
// TODO: do we need: pauseWhen(this.pretendToBeDisconnected$), // TODO: do we need: pauseWhen(this.pretendToBeDisconnected$),
).pipe( ).pipe(
startWith(null), startWith(null),
map(() => { map(() => {
@@ -759,7 +772,7 @@ export class CallViewModel extends ViewModel {
); );
} }
return displaynameMap; return displaynameMap;
}, }),
), ),
); );
@@ -971,21 +984,6 @@ export class CallViewModel extends ViewModel {
this.memberships$.pipe(map((ms) => ms.length)), 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( private readonly didSendCallNotification$ = fromEvent(
this.matrixRTCSession, this.matrixRTCSession,
MatrixRTCSessionEvent.DidSendCallNotification, MatrixRTCSessionEvent.DidSendCallNotification,
@@ -994,6 +992,7 @@ export class CallViewModel extends ViewModel {
MatrixRTCSessionEventHandlerMap[MatrixRTCSessionEvent.DidSendCallNotification] MatrixRTCSessionEventHandlerMap[MatrixRTCSessionEvent.DidSendCallNotification]
> >
>; >;
/** /**
* Whenever the RTC session tells us that it intends to ring the remote * 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 * participant's devices, this emits an Observable tracking the current state of
@@ -1109,6 +1108,56 @@ export class CallViewModel extends ViewModel {
map(() => {}), map(() => {}),
throttleTime(THROTTLE_SOUND_EFFECT_MS), 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<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() },
),
);
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 * 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), filter((v) => v.playSounds),
); );
// TODO-REBASE: expose connection state observable
public readonly livekitConnectionState$: Observable<ECConnectionState>;
public constructor( public constructor(
// A call is permanently tied to a single Matrix room // 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(() => { this.allOthersLeft$
// TODO-MULTI-SFU: this makes no sense what so ever!!! .pipe(
// need to look into this again. this.scope.bind(),
// leaveRTCSession( filter((l) => (l && this.options.autoLeaveWhenOthersLeft) ?? false),
// this.matrixRTCSession, distinctUntilChanged(),
// "user", // TODO-MULTI-SFU ? )
// // Wait for the sound in widget mode (it's not long) .subscribe(() => {
// Promise.resolve(), // TODO-MULTI-SFU this.leave$.next("allOthersLeft");
// //Promise.all([audioPromise, posthogRequest]), });
// ).catch((e) => {
// logger.error("Error leaving RTC session", e); 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 // Pause upstream of all local media tracks when we're disconnected from