Harden DM call teardown with io.element.close listener, 8s hangup timeout, and same-room zombie escape so stale embeds stop blocking retries.
This commit is contained in:
parent
49f7e7417f
commit
91afffc11e
4 changed files with 80 additions and 21 deletions
|
|
@ -3,6 +3,7 @@ import { useAtomValue, useSetAtom, useStore } from 'jotai';
|
||||||
import {
|
import {
|
||||||
CallEmbedContextProvider,
|
CallEmbedContextProvider,
|
||||||
CallEmbedRefContextProvider,
|
CallEmbedRefContextProvider,
|
||||||
|
useCallCloseEvent,
|
||||||
useCallHangupEvent,
|
useCallHangupEvent,
|
||||||
useCallJoined,
|
useCallJoined,
|
||||||
useCallThemeSync,
|
useCallThemeSync,
|
||||||
|
|
@ -20,24 +21,23 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||||
|
|
||||||
useCallMemberSoundSync(embed);
|
useCallMemberSoundSync(embed);
|
||||||
useCallThemeSync(embed);
|
useCallThemeSync(embed);
|
||||||
useCallHangupEvent(
|
const clearIfCurrent = useCallback(() => {
|
||||||
embed,
|
if (store.get(callEmbedAtom) !== embed) return;
|
||||||
useCallback(() => {
|
setCallEmbed(undefined);
|
||||||
if (store.get(callEmbedAtom) !== embed) return;
|
}, [store, embed, setCallEmbed]);
|
||||||
setCallEmbed(undefined);
|
useCallHangupEvent(embed, clearIfCurrent);
|
||||||
}, [store, embed, setCallEmbed])
|
// EC emits Close (not Hangup) when LiveKit gives up without explicit user
|
||||||
);
|
// action — e.g. reconnect fail after a network blip. Without this listener
|
||||||
|
// the embed lingers as a zombie and the Android FGS stays up.
|
||||||
|
useCallCloseEvent(embed, clearIfCurrent);
|
||||||
|
|
||||||
// Widget failed to prepare (bad network, iframe load error). Without this,
|
// Widget failed to prepare (bad network, iframe load error). Clear the atom
|
||||||
// the embed lingers with joined=false forever, and the Android FGS (keyed
|
// so callEmbedAtom's setter can dispose the embed and the Android FGS
|
||||||
// to callEmbedAtom presence) stays up as a ghost ongoing notification.
|
// (keyed on the atom) stops.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = embed.onPreparingError(() => {
|
const unsubscribe = embed.onPreparingError(clearIfCurrent);
|
||||||
if (store.get(callEmbedAtom) !== embed) return;
|
|
||||||
setCallEmbed(undefined);
|
|
||||||
});
|
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [embed, store, setCallEmbed]);
|
}, [embed, clearIfCurrent]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
|
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom, useStore } from 'jotai';
|
||||||
import { StatusDivider } from './components';
|
import { StatusDivider } from './components';
|
||||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { callEmbedAtom } from '../../state/callEmbed';
|
import { callEmbedAtom } from '../../state/callEmbed';
|
||||||
|
|
||||||
|
// Fail-open window for widget hangup ack. If the widget is healthy, its
|
||||||
|
// im.vector.hangup / io.element.close action lands within a second and
|
||||||
|
// clearIfCurrent (CallEmbedProvider) drops the atom. If LiveKit is dead and
|
||||||
|
// the widget never replies, we fall through and force-clear locally — same
|
||||||
|
// pattern as element-web's performDisconnection TIMEOUT_MS (16s there, 8s
|
||||||
|
// here to match the mobile-ish feel of our DM flow and PEER_LEAVE_GRACE_MS).
|
||||||
|
const HANGUP_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
type MicrophoneButtonProps = {
|
type MicrophoneButtonProps = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
onToggle: () => Promise<unknown>;
|
onToggle: () => Promise<unknown>;
|
||||||
|
|
@ -157,9 +165,30 @@ export function CallControl({
|
||||||
}) {
|
}) {
|
||||||
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
|
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
|
||||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
const [hangupState, hangup] = useAsyncCallback(
|
const [hangupState, hangup] = useAsyncCallback(
|
||||||
useCallback(() => callEmbed.hangup(), [callEmbed])
|
useCallback(async () => {
|
||||||
|
// Transport.send itself can reject (widget dead / unsupported action).
|
||||||
|
// Swallow and still poll — even a dead widget may later re-emit via the
|
||||||
|
// Close path, and the force-clear fallback guarantees forward progress.
|
||||||
|
try {
|
||||||
|
await callEmbed.hangup();
|
||||||
|
} catch {
|
||||||
|
/* noop — fall through to poll + force-clear */
|
||||||
|
}
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < HANGUP_TIMEOUT_MS) {
|
||||||
|
if (store.get(callEmbedAtom) !== callEmbed) return;
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await new Promise((r) => {
|
||||||
|
setTimeout(r, 200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (store.get(callEmbedAtom) === callEmbed) {
|
||||||
|
setCallEmbed(undefined);
|
||||||
|
}
|
||||||
|
}, [callEmbed, store, setCallEmbed])
|
||||||
);
|
);
|
||||||
const exiting =
|
const exiting =
|
||||||
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
|
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,10 @@ export const useCallHangupEvent = (embed: CallEmbed, callback: () => void) => {
|
||||||
useClientWidgetApiEvent(embed.call, ElementWidgetActions.HangupCall, callback);
|
useClientWidgetApiEvent(embed.call, ElementWidgetActions.HangupCall, callback);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useCallCloseEvent = (embed: CallEmbed, callback: () => void) => {
|
||||||
|
useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, callback);
|
||||||
|
};
|
||||||
|
|
||||||
export const useCallMemberSoundSync = (embed: CallEmbed) => {
|
export const useCallMemberSoundSync = (embed: CallEmbed) => {
|
||||||
const callSession = useCallSession(embed.room);
|
const callSession = useCallSession(embed.room);
|
||||||
useCallMembersChange(
|
useCallMembersChange(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@
|
||||||
//
|
//
|
||||||
// Contract:
|
// Contract:
|
||||||
// - no prev embed → start a new DM call
|
// - no prev embed → start a new DM call
|
||||||
// - prev.roomId === arg → no-op (healthy same-room click)
|
// - prev.roomId === arg, healthy (joined + peer present) → no-op
|
||||||
|
// - prev.roomId === arg, zombie (never joined, peer gone, or stuck) →
|
||||||
|
// dispose prev directly and start fresh; waitLeave is skipped because
|
||||||
|
// the widget is likely unresponsive
|
||||||
// - prev.roomId !== arg → switch: hangup prev, wait for clean leave,
|
// - prev.roomId !== arg → switch: hangup prev, wait for clean leave,
|
||||||
// dispose prev, then start new
|
// dispose prev, then start new
|
||||||
//
|
//
|
||||||
|
|
@ -108,7 +111,17 @@ export const useSwitchOrStartDmCall = (): ((roomId: string) => Promise<void>) =>
|
||||||
}
|
}
|
||||||
|
|
||||||
const prev = store.get(callEmbedAtom);
|
const prev = store.get(callEmbedAtom);
|
||||||
if (prev?.roomId === roomId) return;
|
if (prev?.roomId === roomId) {
|
||||||
|
const selfUserId = mx.getSafeUserId();
|
||||||
|
const selfDeviceId = mx.getDeviceId();
|
||||||
|
const peerPresent = mx.matrixRTC
|
||||||
|
.getRoomSession(prev.room)
|
||||||
|
.memberships.some(
|
||||||
|
(m: CallMembership) =>
|
||||||
|
m.sender !== selfUserId || (!!selfDeviceId && m.deviceId !== selfDeviceId)
|
||||||
|
);
|
||||||
|
if (prev.joined && peerPresent) return;
|
||||||
|
}
|
||||||
|
|
||||||
const container = callEmbedRef.current;
|
const container = callEmbedRef.current;
|
||||||
if (!container) {
|
if (!container) {
|
||||||
|
|
@ -116,8 +129,21 @@ export const useSwitchOrStartDmCall = (): ((roomId: string) => Promise<void>) =>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prev) {
|
if (prev) {
|
||||||
await waitLeave(prev);
|
if (prev.roomId === roomId) {
|
||||||
prev.dispose();
|
// Zombie same-room embed (widget never joined, peer gone, or stuck
|
||||||
|
// after LiveKit reconnect fail). waitLeave would hang on the
|
||||||
|
// unresponsive widget. Best-effort widget hangup before direct
|
||||||
|
// dispose so the widget's MembershipManager gets a chance to send
|
||||||
|
// the server-side leave event; if it doesn't, the SDK's delayed
|
||||||
|
// leave fires a few seconds after dispose.
|
||||||
|
if (prev.joined) {
|
||||||
|
prev.hangup().catch(() => undefined);
|
||||||
|
}
|
||||||
|
prev.dispose();
|
||||||
|
} else {
|
||||||
|
await waitLeave(prev);
|
||||||
|
prev.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const embed = createCallEmbed(mx, room, true, theme.kind, container, callPref, true);
|
const embed = createCallEmbed(mx, room, true, theme.kind, container, callPref, true);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue