From 61203b8facc0c1ff3202497e99acfdef0526d536 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Tue, 16 Sep 2025 14:16:11 +0100 Subject: [PATCH] Prevent showing calling view when disconnected from Livekit. (#3491) * Refactor disconnection handling * Use "unknown" * Update signature * Add tests * Expose livekitConnectionState directly * fix whoopsie --- src/room/InCallView.test.tsx | 3 +-- src/room/InCallView.tsx | 19 ++++++-------- src/state/CallViewModel.test.ts | 45 +++++++++++++++++++++++++++++++++ src/state/CallViewModel.ts | 24 ++++++++++++------ 4 files changed, 70 insertions(+), 21 deletions(-) diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index eb192f66..f20ffada 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -17,7 +17,7 @@ import { act, render, type RenderResult } from "@testing-library/react"; import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container"; -import { ConnectionState, type LocalParticipant } from "livekit-client"; +import { type LocalParticipant } from "livekit-client"; import { of } from "rxjs"; import { BrowserRouter } from "react-router-dom"; import { TooltipProvider } from "@vector-im/compound-web"; @@ -180,7 +180,6 @@ function createInCallView(): RenderResult & { onLeave={function (): void { throw new Error("Function not implemented."); }} - connState={ConnectionState.Connected} onShareClick={null} /> diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 3d7044be..350af973 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, useSubscription } 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 { @@ -63,7 +67,6 @@ import { type MuteStates } from "./MuteStates"; import { type MatrixInfo } from "./VideoPreview"; import { InviteButton } from "../button/InviteButton"; import { LayoutToggle } from "./LayoutToggle"; -import { type ECConnectionState } from "../livekit/useECConnectionState"; import { useOpenIDSFU } from "../livekit/openIDSFU"; import { CallViewModel, @@ -212,12 +215,7 @@ export const ActiveCall: FC = (props) => { return ( - + ); @@ -235,7 +233,6 @@ export interface InCallViewProps { onLeave: (cause: "user", soundFile?: CallEventSounds) => void; header: HeaderStyle; otelGroupCallMembership?: OTelGroupCallMembership; - connState: ECConnectionState; onShareClick: (() => void) | null; } @@ -249,7 +246,6 @@ export const InCallView: FC = ({ muteStates, onLeave, header: headerStyle, - connState, onShareClick, }) => { const { t } = useTranslation(); @@ -257,10 +253,11 @@ export const InCallView: FC = ({ useReactionsSender(); useWakeLock(); + const connectionState = useObservableEagerState(vm.livekitConnectionState$); // annoyingly we don't get the disconnection reason this way, // only by listening for the emitted event - if (connState === ConnectionState.Disconnected) + if (connectionState === ConnectionState.Disconnected) throw new ConnectionLostError(); const containerRef1 = useRef(null); diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 4b5e603f..b6935a9b 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -1291,6 +1291,51 @@ describe("waitForCallPickup$", () => { }); }); + test("ringing -> unknown if we get disconnected", () => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { + const connectionState$ = new BehaviorSubject(ConnectionState.Connected); + // Someone joins at 20ms (both LiveKit participant and MatrixRTC member) + withCallViewModel( + { + remoteParticipants$: behavior("a 19ms b", { + a: [], + b: [aliceParticipant], + }), + rtcMembers$: behavior("a 19ms b", { + a: [localRtcMember], + b: [localRtcMember, aliceRtcMember], + }), + connectionState$, + }, + (vm, rtcSession) => { + // Notify at 5ms so we enter ringing, then get disconnected 5ms later + schedule(" 5ms r 5ms d", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + mockRingEvent("$notif2", 100), + mockLegacyRingEvent, + ); + }, + d: () => { + connectionState$.next(ConnectionState.Disconnected); + }, + }); + + expectObservable(vm.callPickupState$).toBe("a 4ms b 5ms c", { + a: "unknown", + b: "ringing", + c: "unknown", + }); + }, + { + waitForCallPickup: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + test("success when someone joins before we notify", () => { withTestScheduler(({ behavior, schedule, expectObservable }) => { // Join at 10ms, notify later at 20ms (state should stay success) diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index ca797aa8..8289369f 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -947,6 +947,7 @@ export class CallViewModel extends ViewModel { * The current call pickup state of the call. * - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership. * Then we can conclude if we were the first one to join or not. + * This may also be set if we are disconnected. * - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening). * - "timeout": No-one picked up in the defined time this call should be ringing on others devices. * The call failed. If desired this can be used as a trigger to exit the call. @@ -959,13 +960,20 @@ export class CallViewModel extends ViewModel { ? this.scope.behavior< "unknown" | "ringing" | "timeout" | "decline" | "success" >( - this.someoneElseJoined$.pipe( - switchMap((someoneElseJoined) => - someoneElseJoined - ? of("success" as const) - : // Show the ringing state of the most recent ringing attempt. - this.ring$.pipe(switchAll()), - ), + combineLatest([ + this.livekitConnectionState$, + this.someoneElseJoined$, + ]).pipe( + switchMap(([livekitConnectionState, someoneElseJoined]) => { + if (livekitConnectionState === ConnectionState.Disconnected) { + // Do not ring until we're connected. + return of("unknown" as const); + } else if (someoneElseJoined) { + return of("success" as const); + } + // Show the ringing state of the most recent ringing attempt. + return this.ring$.pipe(switchAll()); + }), // The state starts as 'unknown' because we don't know if the RTC // session will actually send a notify event yet. It will only be // known once we send our own membership and see that we were the @@ -1682,7 +1690,7 @@ export class CallViewModel extends ViewModel { private readonly livekitRoom: LivekitRoom, private readonly mediaDevices: MediaDevices, private readonly options: CallViewModelOptions, - private readonly livekitConnectionState$: Observable, + public readonly livekitConnectionState$: Observable, private readonly handsRaisedSubject$: Observable< Record >,