From 070a1ea01272792df4547311ca99fd78c8c394a6 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 19 Sep 2026 01:04:26 -0400 Subject: [PATCH] fix(calls): hang up explicitly when answering another call while in one (#195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answering from the in-call banner is the only call-to-call switch path; it started the new embed and let the atom dispose the old one, which just removed the iframe — our m.call.member in the old room lingered ~17 s (until the delayed leave expired), so everyone there still saw us in the call. hangupAndWait moves out of LogoutDialog into plugins/call/hangup.ts and handleAnswer now hangs up, waits for our membership to clear (bounded 4 s), disposes the old embed itself (its HangupCall echo would otherwise land after startCall and clear the NEW embed from the atom — seen in testing), then joins the new call. Measured headless: old membership gone in 1 s, new call live with both participants. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/components/CallEmbedProvider.tsx | 18 ++++++++++-- src/app/components/LogoutDialog.tsx | 26 ++---------------- src/app/plugins/call/hangup.ts | 35 ++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 27 deletions(-) create mode 100644 src/app/plugins/call/hangup.ts diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 67da794f0..54955b60a 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -39,6 +39,7 @@ import { import { callChatAtom, callEmbedAtom } from '../state/callEmbed'; import { toastQueueAtom } from '../state/toast'; import { CallEmbed, useCallControlState } from '../plugins/call'; +import { hangupCallAndWait } from '../plugins/call/hangup'; import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; import { useMatrixClient } from '../hooks/useMatrixClient'; @@ -421,6 +422,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) const [callInfo, setCallInfo] = useState(); const dm = callInfo ? directs.has(callInfo.room.roomId) : false; const startCall = useCallStart(dm); + const setCallEmbed = useSetAtom(callEmbedAtom); const { microphone, sound } = useCallPreferences(); const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin'); @@ -622,16 +624,26 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) ); const handleAnswer = useCallback( - (room: Room, video: boolean) => { + async (room: Room, video: boolean) => { + setCallInfo(undefined); + // [Gitea #195] Answering from the in-call banner is the one call-to-call + // switch path. Disposing the current embed alone leaves our ghost + // `m.call.member` in the old room for ~17 s (until the delayed leave + // expires) — hang up explicitly and wait for the membership to clear. + if (callEmbed?.joined) { + await hangupCallAndWait(mx, callEmbed); + // Dispose it ourselves now: its HangupCall echo would otherwise land + // after startCall() and clear the NEW embed from the atom. + setCallEmbed(undefined); + } // Honour cameraOnJoin and the persisted mic/sound preferences instead of // forcing camera+mic+sound on — every other join path does this, and // Answer was skipping it, publishing the camera with no prescreen. // (PTT's forceAudioOff is applied downstream inside useCallStart.) startCall(room, { microphone, video: cameraOnJoin && video, sound }); - setCallInfo(undefined); navigateRoom(room.roomId); }, - [startCall, navigateRoom, microphone, sound, cameraOnJoin], + [startCall, navigateRoom, microphone, sound, cameraOnJoin, callEmbed, mx, setCallEmbed], ); if (!callInfo) return null; diff --git a/src/app/components/LogoutDialog.tsx b/src/app/components/LogoutDialog.tsx index bcf8bce8e..4d92ab3e9 100644 --- a/src/app/components/LogoutDialog.tsx +++ b/src/app/components/LogoutDialog.tsx @@ -1,11 +1,10 @@ import React, { forwardRef, useCallback } from 'react'; import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds'; import { useAtom } from 'jotai'; -import { MatrixClient } from 'matrix-js-sdk'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { logoutClient } from '../../client/initMatrix'; import { callEmbedAtom } from '../state/callEmbed'; -import { CallEmbed } from '../plugins/call'; +import { hangupCallAndWait } from '../plugins/call/hangup'; import { useMatrixClient } from '../hooks/useMatrixClient'; import { useModalStyle } from '../hooks/useModalStyle'; import { useCrossSigningActive } from '../hooks/useCrossSigning'; @@ -20,27 +19,6 @@ import { * membership has actually been removed from the room, so the leave reaches * the homeserver before the client is stopped and the token is revoked. */ -const hangupAndWait = async (mx: MatrixClient, embed: CallEmbed): Promise => { - const myUserId = mx.getUserId(); - const myDeviceId = mx.getDeviceId(); - const stillIn = (): boolean => - mx.matrixRTC - .getRoomSession(embed.room) - .memberships.some((m) => m.sender === myUserId && m.deviceId === myDeviceId); - try { - await embed.hangup(); - } catch { - // widget already gone — fall through to the wait/timeout - } - const deadline = Date.now() + 4000; - while (stillIn() && Date.now() < deadline) { - // eslint-disable-next-line no-await-in-loop - await new Promise((r) => { - setTimeout(r, 150); - }); - } -}; - type LogoutDialogProps = { handleClose: () => void; }; @@ -64,7 +42,7 @@ export const LogoutDialog = forwardRef( // membership (expires: 4 h) stays behind as a ghost participant and // everyone else sees you "in call" until it times out. if (callEmbed && callEmbed.joined && !callEmbed.disposed) { - await hangupAndWait(mx, callEmbed); + await hangupCallAndWait(mx, callEmbed); setCallEmbed(undefined); } await logoutClient(mx); diff --git a/src/app/plugins/call/hangup.ts b/src/app/plugins/call/hangup.ts new file mode 100644 index 000000000..33eb189b6 --- /dev/null +++ b/src/app/plugins/call/hangup.ts @@ -0,0 +1,35 @@ +import { MatrixClient } from 'matrix-js-sdk'; +import { CallEmbed } from './CallEmbed'; + +/** + * Ask EC to hang up and wait (bounded) until our own RTC membership has left + * the room's session. Used wherever the embed is about to be torn down for a + * reason other than the user pressing End — logout (#29) and answering another + * call while in one (#195) — because disposing the iframe alone leaves a ghost + * `m.call.member` for everyone else until the delayed leave event expires + * (~17–30 s measured). + */ +export const hangupCallAndWait = async ( + mx: MatrixClient, + embed: CallEmbed, + timeoutMs = 4000, +): Promise => { + const myUserId = mx.getUserId(); + const myDeviceId = mx.getDeviceId(); + const stillIn = (): boolean => + mx.matrixRTC + .getRoomSession(embed.room) + .memberships.some((m) => m.sender === myUserId && m.deviceId === myDeviceId); + try { + await embed.hangup(); + } catch { + // widget already gone — fall through to the wait/timeout + } + const deadline = Date.now() + timeoutMs; + while (stillIn() && Date.now() < deadline) { + // eslint-disable-next-line no-await-in-loop + await new Promise((r) => { + setTimeout(r, 150); + }); + } +};