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
cf0bf56541
commit
d15a3b336b
4 changed files with 80 additions and 21 deletions
|
|
@ -3,6 +3,7 @@ import { useAtomValue, useSetAtom, useStore } from 'jotai';
|
|||
import {
|
||||
CallEmbedContextProvider,
|
||||
CallEmbedRefContextProvider,
|
||||
useCallCloseEvent,
|
||||
useCallHangupEvent,
|
||||
useCallJoined,
|
||||
useCallThemeSync,
|
||||
|
|
@ -20,24 +21,23 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
|
|||
|
||||
useCallMemberSoundSync(embed);
|
||||
useCallThemeSync(embed);
|
||||
useCallHangupEvent(
|
||||
embed,
|
||||
useCallback(() => {
|
||||
if (store.get(callEmbedAtom) !== embed) return;
|
||||
setCallEmbed(undefined);
|
||||
}, [store, embed, setCallEmbed])
|
||||
);
|
||||
const clearIfCurrent = useCallback(() => {
|
||||
if (store.get(callEmbedAtom) !== embed) return;
|
||||
setCallEmbed(undefined);
|
||||
}, [store, embed, setCallEmbed]);
|
||||
useCallHangupEvent(embed, clearIfCurrent);
|
||||
// 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,
|
||||
// the embed lingers with joined=false forever, and the Android FGS (keyed
|
||||
// to callEmbedAtom presence) stays up as a ghost ongoing notification.
|
||||
// Widget failed to prepare (bad network, iframe load error). Clear the atom
|
||||
// so callEmbedAtom's setter can dispose the embed and the Android FGS
|
||||
// (keyed on the atom) stops.
|
||||
useEffect(() => {
|
||||
const unsubscribe = embed.onPreparingError(() => {
|
||||
if (store.get(callEmbedAtom) !== embed) return;
|
||||
setCallEmbed(undefined);
|
||||
});
|
||||
const unsubscribe = embed.onPreparingError(clearIfCurrent);
|
||||
return unsubscribe;
|
||||
}, [embed, store, setCallEmbed]);
|
||||
}, [embed, clearIfCurrent]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useSetAtom, useStore } from 'jotai';
|
||||
import { StatusDivider } from './components';
|
||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
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 = {
|
||||
enabled: boolean;
|
||||
onToggle: () => Promise<unknown>;
|
||||
|
|
@ -157,9 +165,30 @@ export function CallControl({
|
|||
}) {
|
||||
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
const store = useStore();
|
||||
|
||||
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 =
|
||||
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);
|
||||
};
|
||||
|
||||
export const useCallCloseEvent = (embed: CallEmbed, callback: () => void) => {
|
||||
useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, callback);
|
||||
};
|
||||
|
||||
export const useCallMemberSoundSync = (embed: CallEmbed) => {
|
||||
const callSession = useCallSession(embed.room);
|
||||
useCallMembersChange(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
//
|
||||
// Contract:
|
||||
// - 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,
|
||||
// dispose prev, then start new
|
||||
//
|
||||
|
|
@ -108,7 +111,17 @@ export const useSwitchOrStartDmCall = (): ((roomId: string) => Promise<void>) =>
|
|||
}
|
||||
|
||||
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;
|
||||
if (!container) {
|
||||
|
|
@ -116,8 +129,21 @@ export const useSwitchOrStartDmCall = (): ((roomId: string) => Promise<void>) =>
|
|||
}
|
||||
|
||||
if (prev) {
|
||||
await waitLeave(prev);
|
||||
prev.dispose();
|
||||
if (prev.roomId === roomId) {
|
||||
// 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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue