Say "Server unavailable" for signal-only reconnects instead of muting the call

When the SFU drops the signalling WebSocket, livekit-client resumes it
while the peer connection stays up and media keeps flowing. EC treated
this as a full reconnect: playback volume zeroed on every remote tile,
remote video hidden, and the non-dismissable Reconnecting scrim shown,
so a 2 s blip silenced the whole call.

LocalMember now exposes signalReconnecting$ separately and no longer
counts SignalReconnecting as a disconnect, so reconnecting$ (and with it
pretendToBeDisconnected$, the hands/reactions pause and the reconnect
analytics) only fires for a real Reconnecting, a disconnect or a
homeserver problem. InCallView shows a scrimless, non-modal "Server
unavailable" toast for the signal-only case so the user still gets
told what is going on, per the review of the first attempt.
This commit is contained in:
Matthew Hodgson
2026-09-29 11:51:29 +01:00
parent fbce73ae55
commit f2eabed7ae
6 changed files with 99 additions and 11 deletions
+1
View File
@@ -117,6 +117,7 @@
"room_creation_restricted": "Failed to create call", "room_creation_restricted": "Failed to create call",
"room_creation_restricted_description": "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists.", "room_creation_restricted_description": "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists.",
"screen_share_failed": "Could not start screen sharing", "screen_share_failed": "Could not start screen sharing",
"server_unavailable": "Server unavailable",
"sticky_events_required": "Homeserver does not support Matrix 2.0 calls", "sticky_events_required": "Homeserver does not support Matrix 2.0 calls",
"sticky_events_required_description": "This deployment is configured to use Matrix 2.0 call mode, but the homeserver does not advertise support for sticky events (MSC4354). Ask your server admin to upgrade, or switch the deployment to a compatible mode.", "sticky_events_required_description": "This deployment is configured to use Matrix 2.0 call mode, but the homeserver does not advertise support for sticky events (MSC4354). Ask your server admin to upgrade, or switch the deployment to a compatible mode.",
"unexpected_ec_error": "An unexpected error occurred (<0>Error Code:</0> <1>{{ errorCode }}</1>). Please contact your server admin." "unexpected_ec_error": "An unexpected error occurred (<0>Error Code:</0> <1>{{ errorCode }}</1>). Please contact your server admin."
+13
View File
@@ -299,6 +299,7 @@ export const InCallView: FC<InCallViewProps> = ({
const audioParticipants = useBehavior(vm.livekitRoomItems$); const audioParticipants = useBehavior(vm.livekitRoomItems$);
const participantCount = useBehavior(vm.participantCount$); const participantCount = useBehavior(vm.participantCount$);
const reconnecting = useBehavior(vm.reconnecting$); const reconnecting = useBehavior(vm.reconnecting$);
const signalReconnecting = useBehavior(vm.signalReconnecting$);
const screenShareError = useBehavior(vm.screenShareError$); const screenShareError = useBehavior(vm.screenShareError$);
const layout = useBehavior(vm.layout$); const layout = useBehavior(vm.layout$);
const edgeToEdge = useBehavior(vm.edgeToEdge$); const edgeToEdge = useBehavior(vm.edgeToEdge$);
@@ -462,6 +463,17 @@ export const InCallView: FC<InCallViewProps> = ({
</> </>
); );
// Media is still flowing, so no scrim and nothing paused: just say so
const serverUnavailableToast = (
<Toast
onDismiss={onDismissReconnectingToast}
open={signalReconnecting && !reconnecting}
modal={false}
>
{t("error.server_unavailable")}
</Toast>
);
const earpieceOverlay = ( const earpieceOverlay = (
<EarpieceOverlay <EarpieceOverlay
show={earpieceMode && !reconnecting} show={earpieceMode && !reconnecting}
@@ -679,6 +691,7 @@ export const InCallView: FC<InCallViewProps> = ({
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} /> <ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
<RingingAudioRenderer vm={ringingVm} muted={muteAllAudio} /> <RingingAudioRenderer vm={ringingVm} muted={muteAllAudio} />
{reconnectingToast} {reconnectingToast}
{serverUnavailableToast}
{screenShareToast} {screenShareToast}
{earpieceOverlay} {earpieceOverlay}
<ReactionsOverlay vm={vm} /> <ReactionsOverlay vm={vm} />
+10 -10
View File
@@ -172,7 +172,7 @@ exports[`InCallView > rendering > renders 1`] = `
class="_settingsLogoContainer_20b7b4" class="_settingsLogoContainer_20b7b4"
> >
<button <button
aria-labelledby="_r_b_" aria-labelledby="_r_e_"
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4" class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
data-kind="secondary" data-kind="secondary"
data-testid="settings-bottom-left" data-testid="settings-bottom-left"
@@ -307,7 +307,7 @@ exports[`InCallView > rendering > renders 1`] = `
class="_buttons_20b7b4" class="_buttons_20b7b4"
> >
<button <button
aria-labelledby="_r_g_" aria-labelledby="_r_j_"
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53" class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary" data-kind="secondary"
data-size="lg" data-size="lg"
@@ -332,7 +332,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-busy="false" aria-busy="false"
aria-checked="false" aria-checked="false"
aria-disabled="true" aria-disabled="true"
aria-labelledby="_r_l_" aria-labelledby="_r_o_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
@@ -357,7 +357,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-busy="false" aria-busy="false"
aria-checked="false" aria-checked="false"
aria-disabled="true" aria-disabled="true"
aria-labelledby="_r_q_" aria-labelledby="_r_t_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
@@ -382,7 +382,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-disabled="false" aria-disabled="false"
aria-expanded="false" aria-expanded="false"
aria-haspopup="true" aria-haspopup="true"
aria-labelledby="_r_v_" aria-labelledby="_r_12_"
class="_button_1nw83_8 _raiseHand_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53" class="_button_1nw83_8 _raiseHand_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary" data-kind="secondary"
data-size="lg" data-size="lg"
@@ -405,7 +405,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg> </svg>
</button> </button>
<button <button
aria-labelledby="_r_17_" aria-labelledby="_r_1a_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
@@ -433,8 +433,8 @@ exports[`InCallView > rendering > renders 1`] = `
data-size="lg" data-size="lg"
> >
<input <input
aria-labelledby="_r_1d_" aria-labelledby="_r_1g_"
name="_r_1c_" name="_r_1f_"
type="radio" type="radio"
value="spotlight" value="spotlight"
/> />
@@ -453,9 +453,9 @@ exports[`InCallView > rendering > renders 1`] = `
/> />
</svg> </svg>
<input <input
aria-labelledby="_r_1i_" aria-labelledby="_r_1l_"
checked="" checked=""
name="_r_1c_" name="_r_1f_"
type="radio" type="radio"
value="grid" value="grid"
/> />
+6
View File
@@ -476,6 +476,11 @@ export interface CallViewModel {
* Whether the app is currently reconnecting to the LiveKit server and/or setting the matrix rtc room state. * Whether the app is currently reconnecting to the LiveKit server and/or setting the matrix rtc room state.
*/ */
reconnecting$: Behavior<boolean>; reconnecting$: Behavior<boolean>;
/**
* Whether only LiveKit's signalling connection is being re-established,
* with media still flowing.
*/
signalReconnecting$: Behavior<boolean>;
/** /**
* Shortcut for not requireing to parse and combine connectionState.matrix and connectionState.livekit * Shortcut for not requireing to parse and combine connectionState.matrix and connectionState.livekit
@@ -1912,6 +1917,7 @@ export function createCallViewModel$(
earpieceMode$: earpieceMode$, earpieceMode$: earpieceMode$,
audioOutputSwitcher$: audioOutputSwitcher$, audioOutputSwitcher$: audioOutputSwitcher$,
reconnecting$: localMembership.reconnecting$, reconnecting$: localMembership.reconnecting$,
signalReconnecting$: localMembership.signalReconnecting$,
livekitRoomItems$, livekitRoomItems$,
connected$: localMembership.connected$, connected$: localMembership.connected$,
screenShareError$: localMembership.screenShareError$, screenShareError$: localMembership.screenShareError$,
@@ -762,6 +762,55 @@ describe("LocalMembership", () => {
scope.end(); scope.end();
}); });
it("reports a signal-only reconnect without reconnecting", async () => {
const scope = new ObservableScope();
const trackSpy = vi.spyOn(
PosthogAnalytics.instance.eventCallReconnecting,
"track",
);
const connectionState$ = new BehaviorSubject<ConnectionState>(
ConnectionState.LivekitConnected,
);
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(
{
...connectionTransportAConnected,
state$: connectionState$,
} as unknown as Connection,
[],
);
const localMembership = createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
homeserverConnected: {
combined$: constant<[boolean, HomeserverDisconnectReason | null]>([
true,
null,
]),
rtsSession$: constant(RTCMemberStatus.Connected),
},
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport),
});
await flushPromises();
connectionState$.next(ConnectionState.LivekitSignalReconnecting);
expect(localMembership.signalReconnecting$.value).toBe(true);
expect(localMembership.reconnecting$.value).toBe(false);
expect(localMembership.connected$.value).toBe(true);
connectionState$.next(ConnectionState.LivekitConnected);
expect(localMembership.signalReconnecting$.value).toBe(false);
expect(trackSpy).not.toHaveBeenCalled();
scope.end();
});
it("fires one event per completed reconnection cycle", async () => { it("fires one event per completed reconnection cycle", async () => {
const scope = new ObservableScope(); const scope = new ObservableScope();
const trackSpy = vi.spyOn( const trackSpy = vi.spyOn(
@@ -241,6 +241,12 @@ export const createLocalMembership$ = ({
* Tracks the homserver and livekit connected state and based on that computes reconnecting. * Tracks the homserver and livekit connected state and based on that computes reconnecting.
*/ */
reconnecting$: Behavior<boolean>; reconnecting$: Behavior<boolean>;
/**
* Whether LiveKit is re-establishing only its signalling connection. The
* peer connection, and so the media, stays up: the user is told the server
* is unavailable but nothing is paused.
*/
signalReconnecting$: Behavior<boolean>;
/** Shorthand for homeserverConnected.rtcSession === Status.Disconnected /** Shorthand for homeserverConnected.rtcSession === Status.Disconnected
* Direct translation to the js-sdk membership manager connection `Status`. * Direct translation to the js-sdk membership manager connection `Status`.
*/ */
@@ -565,7 +571,13 @@ export const createLocalMembership$ = ({
combineLatest([ combineLatest([
homeserverConnected.combined$, homeserverConnected.combined$,
localConnectionState$.pipe( localConnectionState$.pipe(
map((state) => state === ConnectionState.LivekitConnected), // A signal-only reconnect keeps the peer connection (and so the
// media) up, so it is not a disconnect: see signalReconnecting$.
map(
(state) =>
state === ConnectionState.LivekitConnected ||
state === ConnectionState.LivekitSignalReconnecting,
),
), ),
]).pipe( ]).pipe(
map(([[hsConnected, hsReason], livekitConnected]) => { map(([[hsConnected, hsReason], livekitConnected]) => {
@@ -596,6 +608,12 @@ export const createLocalMembership$ = ({
false, false,
); );
const signalReconnecting$ = scope.behavior(
localConnectionState$.pipe(
map((state) => state === ConnectionState.LivekitSignalReconnecting),
),
);
let reconnectStart: { let reconnectStart: {
time: number; time: number;
reason: NonNullable<(typeof connectionDisconnectReason$)["value"]>; reason: NonNullable<(typeof connectionDisconnectReason$)["value"]>;
@@ -880,6 +898,7 @@ export const createLocalMembership$ = ({
localMemberState$, localMemberState$,
participant$, participant$,
reconnecting$, reconnecting$,
signalReconnecting$,
connected$: matrixAndLivekitConnected$, connected$: matrixAndLivekitConnected$,
disconnected$: scope.behavior( disconnected$: scope.behavior(
homeserverConnected.rtsSession$.pipe( homeserverConnected.rtsSession$.pipe(