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:
@@ -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;
|
||||
|
||||
@@ -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<void> => {
|
||||
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<HTMLDivElement, LogoutDialogProps>(
|
||||
// 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);
|
||||
|
||||
@@ -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<void> => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user