fix(calls): hang up explicitly when answering another call while in one (#195)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-19 01:04:26 -04:00
co-authored by Claude Opus 5
parent bbcbdad55a
commit 070a1ea012
3 changed files with 52 additions and 27 deletions
+15 -3
View File
@@ -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<IncomingCallInfo>();
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;