Prevent showing calling view when disconnected from Livekit. (#3491)

* Refactor disconnection handling

* Use "unknown"

* Update signature

* Add tests

* Expose livekitConnectionState directly

* fix whoopsie
This commit is contained in:
Will Hunt
2025-09-16 14:16:11 +01:00
committed by GitHub
parent 5811794f31
commit 2374a3fd33
4 changed files with 70 additions and 21 deletions

View File

@@ -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 MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container"; 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 { of } from "rxjs";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
@@ -180,7 +180,6 @@ function createInCallView(): RenderResult & {
onLeave={function (): void { onLeave={function (): void {
throw new Error("Function not implemented."); throw new Error("Function not implemented.");
}} }}
connState={ConnectionState.Connected}
onShareClick={null} onShareClick={null}
/> />
</RoomContext> </RoomContext>

View File

@@ -25,7 +25,11 @@ 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 { useObservable, useSubscription } from "observable-hooks"; import {
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 {
@@ -63,7 +67,6 @@ import { type MuteStates } from "./MuteStates";
import { type MatrixInfo } from "./VideoPreview"; import { type MatrixInfo } from "./VideoPreview";
import { InviteButton } from "../button/InviteButton"; import { InviteButton } from "../button/InviteButton";
import { LayoutToggle } from "./LayoutToggle"; import { LayoutToggle } from "./LayoutToggle";
import { type ECConnectionState } from "../livekit/useECConnectionState";
import { useOpenIDSFU } from "../livekit/openIDSFU"; import { useOpenIDSFU } from "../livekit/openIDSFU";
import { import {
CallViewModel, CallViewModel,
@@ -212,12 +215,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
return ( return (
<RoomContext value={livekitRoom}> <RoomContext value={livekitRoom}>
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}> <ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
<InCallView <InCallView {...props} vm={vm} livekitRoom={livekitRoom} />
{...props}
vm={vm}
livekitRoom={livekitRoom}
connState={connState}
/>
</ReactionsSenderProvider> </ReactionsSenderProvider>
</RoomContext> </RoomContext>
); );
@@ -235,7 +233,6 @@ export interface InCallViewProps {
onLeave: (cause: "user", soundFile?: CallEventSounds) => void; onLeave: (cause: "user", soundFile?: CallEventSounds) => void;
header: HeaderStyle; header: HeaderStyle;
otelGroupCallMembership?: OTelGroupCallMembership; otelGroupCallMembership?: OTelGroupCallMembership;
connState: ECConnectionState;
onShareClick: (() => void) | null; onShareClick: (() => void) | null;
} }
@@ -249,7 +246,6 @@ export const InCallView: FC<InCallViewProps> = ({
muteStates, muteStates,
onLeave, onLeave,
header: headerStyle, header: headerStyle,
connState,
onShareClick, onShareClick,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -257,10 +253,11 @@ export const InCallView: FC<InCallViewProps> = ({
useReactionsSender(); useReactionsSender();
useWakeLock(); useWakeLock();
const connectionState = useObservableEagerState(vm.livekitConnectionState$);
// annoyingly we don't get the disconnection reason this way, // annoyingly we don't get the disconnection reason this way,
// only by listening for the emitted event // only by listening for the emitted event
if (connState === ConnectionState.Disconnected) if (connectionState === ConnectionState.Disconnected)
throw new ConnectionLostError(); throw new ConnectionLostError();
const containerRef1 = useRef<HTMLDivElement | null>(null); const containerRef1 = useRef<HTMLDivElement | null>(null);

View File

@@ -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", () => { test("success when someone joins before we notify", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => { withTestScheduler(({ behavior, schedule, expectObservable }) => {
// Join at 10ms, notify later at 20ms (state should stay success) // Join at 10ms, notify later at 20ms (state should stay success)

View File

@@ -947,6 +947,7 @@ export class CallViewModel extends ViewModel {
* The current call pickup state of the call. * 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. * - "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. * 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). * - "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. * - "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. * 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< ? this.scope.behavior<
"unknown" | "ringing" | "timeout" | "decline" | "success" "unknown" | "ringing" | "timeout" | "decline" | "success"
>( >(
this.someoneElseJoined$.pipe( combineLatest([
switchMap((someoneElseJoined) => this.livekitConnectionState$,
someoneElseJoined this.someoneElseJoined$,
? of("success" as const) ]).pipe(
: // Show the ringing state of the most recent ringing attempt. switchMap(([livekitConnectionState, someoneElseJoined]) => {
this.ring$.pipe(switchAll()), 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 // 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 // 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 // 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 livekitRoom: LivekitRoom,
private readonly mediaDevices: MediaDevices, private readonly mediaDevices: MediaDevices,
private readonly options: CallViewModelOptions, private readonly options: CallViewModelOptions,
private readonly livekitConnectionState$: Observable<ECConnectionState>, public readonly livekitConnectionState$: Observable<ECConnectionState>,
private readonly handsRaisedSubject$: Observable< private readonly handsRaisedSubject$: Observable<
Record<string, RaisedHandInfo> Record<string, RaisedHandInfo>
>, >,