From 871458a773dad270180c2b5660889f08db7fff11 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Wed, 9 Sep 2026 11:29:50 +0100 Subject: [PATCH] Don't show the reconnecting overlay for signal-only reconnects A LiveKit signalReconnecting state means the signalling WebSocket dropped but the peer connection (and so the media) is still up. Treating it as disconnected muted every remote participant, hid their video and showed the non-dismissable reconnecting overlay for what is usually a 2s blip. Only a full Reconnecting (or homeserver disconnect) does that now. --- .../localMember/LocalMember.test.ts | 49 +++++++++++++++++++ .../CallViewModel/localMember/LocalMember.ts | 8 ++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/state/CallViewModel/localMember/LocalMember.test.ts b/src/state/CallViewModel/localMember/LocalMember.test.ts index e7c5b1e45..2fc59c48f 100644 --- a/src/state/CallViewModel/localMember/LocalMember.test.ts +++ b/src/state/CallViewModel/localMember/LocalMember.test.ts @@ -930,6 +930,55 @@ describe("LocalMembership", () => { scope.end(); }); + it("ignores signal-only reconnects", async () => { + const scope = new ObservableScope(); + const trackSpy = vi.spyOn( + PosthogAnalytics.instance.eventCallReconnecting, + "track", + ); + + const connectionState$ = new BehaviorSubject( + ConnectionState.LivekitConnected, + ); + const mutableConnection = { + ...connectionTransportAConnected, + state$: connectionState$, + } as unknown as Connection; + + const connectionManagerData = new ConnectionManagerData(); + connectionManagerData.add(mutableConnection, []); + + const localMembership = createLocalMembership$({ + scope, + ...defaultCreateLocalMemberValues, + homeserverConnected: { + combined$: constant<[boolean, HomeserverDisconnectReason | null]>([ + true, + null, + ]), + rtsSession$: constant(RTCMemberStatus.Connected), + }, + connectionManager: { + connectionManagerData$: constant(new Epoch(connectionManagerData)), + }, + localTransport: { + advertised$: constant(aTransport), + active$: constant(aTransportWithSFUConfig), + }, + }); + + await flushPromises(); + + connectionState$.next(ConnectionState.LivekitSignalReconnecting); + expect(localMembership.reconnecting$.value).toBe(false); + expect(localMembership.connected$.value).toBe(true); + connectionState$.next(ConnectionState.LivekitConnected); + + expect(trackSpy).not.toHaveBeenCalled(); + + scope.end(); + }); + it("fires one event per completed reconnection cycle", async () => { const scope = new ObservableScope(); const trackSpy = vi.spyOn( diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index 7805805b8..d150ab151 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -582,7 +582,13 @@ export const createLocalMembership$ = ({ combineLatest([ homeserverConnected.combined$, localConnectionState$.pipe( - map((state) => state === ConnectionState.LivekitConnected), + // A signal-only reconnect keeps the peer connection (and so the + // media) up, so we don't tell the user we're reconnecting for it. + map( + (state) => + state === ConnectionState.LivekitConnected || + state === ConnectionState.LivekitSignalReconnecting, + ), ), ]).pipe( map(([[hsConnected, hsReason], livekitConnected]) => {