Make UI react instantly to hanging up but also wait for leave sound

This ensures that we don't see a mistaken 'reconnecting' toast while we're hanging up (and also that the leave sound gets a chance to play in widgets once again).
This commit is contained in:
Robin
2025-09-24 21:26:16 -04:00
parent edd3eb8747
commit 6cf020763e
3 changed files with 95 additions and 80 deletions

View File

@@ -41,7 +41,6 @@ import { ActiveCall } from "./InCallView";
import { type MuteStates } from "../state/MuteStates"; import { type MuteStates } from "../state/MuteStates";
import { useMediaDevices } from "../MediaDevicesContext"; import { useMediaDevices } from "../MediaDevicesContext";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { leaveRTCSession } from "../rtcSessionHelpers";
import { import {
saveKeyForRoom, saveKeyForRoom,
useRoomEncryptionSystem, useRoomEncryptionSystem,
@@ -50,7 +49,12 @@ import { useRoomAvatar } from "./useRoomAvatar";
import { useRoomName } from "./useRoomName"; import { useRoomName } from "./useRoomName";
import { useJoinRule } from "./useJoinRule"; import { useJoinRule } from "./useJoinRule";
import { InviteModal } from "./InviteModal"; import { InviteModal } from "./InviteModal";
import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams"; import {
getUrlParams,
HeaderStyle,
type UrlParams,
useUrlParams,
} from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { useAudioContext } from "../useAudioContext"; import { useAudioContext } from "../useAudioContext";
import { import {
@@ -322,37 +326,62 @@ export const GroupCallView: FC<Props> = ({
setJoined(false); setJoined(false);
setLeft(true); setLeft(true);
const audioPromise = leaveSoundContext.current?.playSound(playSound); const audioPromise = leaveSoundContext.current?.playSound(playSound);
// In embedded/widget mode the iFrame will be killed right after the call ended prohibiting the posthog event from getting sent, // We need to wait until the callEnded event is tracked on PostHog,
// therefore we want the event to be sent instantly without getting queued/batched. // otherwise the iframe may get killed first.
const sendInstantly = !!widget;
// we need to wait until the callEnded event is tracked on posthog.
// Otherwise the iFrame gets killed before the callEnded event got tracked.
const posthogRequest = new Promise((resolve) => { const posthogRequest = new Promise((resolve) => {
// To increase the likelihood of the PostHog event being sent out in
// widget mode before the iframe is killed, we ask it to skip the
// usual queuing/batching of requests.
const sendInstantly = widget !== null;
PosthogAnalytics.instance.eventCallEnded.track( PosthogAnalytics.instance.eventCallEnded.track(
room.roomId, room.roomId,
rtcSession.memberships.length, rtcSession.memberships.length,
sendInstantly, sendInstantly,
rtcSession, rtcSession,
); );
// Unfortunately the PostHog library provides no way to await the
// tracking of an event, but we don't really want it to hold up the
// closing of the widget that long anyway, so giving it 10 ms will do.
window.setTimeout(resolve, 10); window.setTimeout(resolve, 10);
}); });
void Promise.all([audioPromise, posthogRequest]) void Promise.all([audioPromise, posthogRequest])
.then(() => { .catch((e) =>
logger.error(
"Failed to play leave audio and/or send PostHog leave event",
e,
),
)
.then(async () => {
if ( if (
!isPasswordlessUser && !isPasswordlessUser &&
!confineToRoom && !confineToRoom &&
!PosthogAnalytics.instance.isEnabled() !PosthogAnalytics.instance.isEnabled()
) { )
void navigate("/"); void navigate("/");
if (widget) {
// After this point the iframe could die at any moment!
try {
await widget.api.setAlwaysOnScreen(false);
} catch (e) {
logger.error(
"Failed to set call widget `alwaysOnScreen` to false",
e,
);
}
// On a normal user hangup we can shut down and close the widget. But if an
// error occurs we should keep the widget open until the user reads it.
if (reason === "user" && !getUrlParams().returnToLobby) {
try {
await widget.api.transport.send(ElementWidgetActions.Close, {});
} catch (e) {
logger.error("Failed to send close action", e);
}
widget.api.transport.stop();
}
} }
}) });
.catch(() =>
logger.error(
"could failed to play leave audio or send posthog leave event",
),
);
}, },
[ [
setJoined, setJoined,
@@ -367,24 +396,11 @@ export const GroupCallView: FC<Props> = ({
); );
useEffect(() => { useEffect(() => {
if (widget && joined) { if (widget && joined)
// set widget to sticky once joined. // set widget to sticky once joined.
widget.api.setAlwaysOnScreen(true).catch((e) => { widget.api.setAlwaysOnScreen(true).catch((e) => {
logger.error("Error calling setAlwaysOnScreen(true)", e); logger.error("Error calling setAlwaysOnScreen(true)", e);
}); });
const onHangup = (ev: CustomEvent<IWidgetApiRequest>): void => {
widget.api.transport.reply(ev.detail, {});
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
leaveRTCSession(rtcSession, "user").catch((e) => {
logger.error("Failed to leave RTC session", e);
});
};
widget.lazyActions.once(ElementWidgetActions.HangupCall, onHangup);
return (): void => {
widget.lazyActions.off(ElementWidgetActions.HangupCall, onHangup);
};
}
}, [widget, joined, rtcSession]); }, [widget, joined, rtcSession]);
const joinRule = useJoinRule(room); const joinRule = useJoinRule(room);

View File

@@ -151,7 +151,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
); );
setVm(vm); setVm(vm);
const sub = vm.left$.subscribe(props.onLeft); const sub = vm.leave$.subscribe(props.onLeft);
return (): void => { return (): void => {
vm.destroy(); vm.destroy();
sub.unsubscribe(); sub.unsubscribe();
@@ -798,7 +798,7 @@ export const InCallView: FC<InCallViewProps> = ({
<EndCallButton <EndCallButton
key="end_call" key="end_call"
onClick={function (): void { onClick={function (): void {
vm.leave(); vm.hangup();
}} }}
onTouchEnd={onControlsTouchEnd} onTouchEnd={onControlsTouchEnd}
data-testid="incall_leave" data-testid="incall_leave"

View File

@@ -57,6 +57,7 @@ import {
switchScan, switchScan,
take, take,
takeUntil, takeUntil,
tap,
throttleTime, throttleTime,
timer, timer,
} from "rxjs"; } from "rxjs";
@@ -117,16 +118,16 @@ import { constant, type Behavior } from "./Behavior";
import { import {
enterRTCSession, enterRTCSession,
getLivekitAlias, getLivekitAlias,
leaveRTCSession,
makeFocus, makeFocus,
} from "../rtcSessionHelpers"; } from "../rtcSessionHelpers";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider"; import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider";
import { Connection, PublishConnection } from "./Connection"; import { Connection, PublishConnection } from "./Connection";
import { type MuteStates } from "./MuteStates"; import { type MuteStates } from "./MuteStates";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { getUrlParams } from "../UrlParams"; import { getUrlParams } from "../UrlParams";
import { type ProcessorState } from "../livekit/TrackProcessorContext"; import { type ProcessorState } from "../livekit/TrackProcessorContext";
import { ElementWidgetActions, widget } from "../widget";
import { IWidgetApiRequest } from "matrix-widget-api";
export interface CallViewModelOptions { export interface CallViewModelOptions {
encryptionSystem: EncryptionSystem; encryptionSystem: EncryptionSystem;
@@ -554,19 +555,6 @@ export class CallViewModel extends ViewModel {
this.join$.next(); this.join$.next();
} }
private readonly leave$ = new Subject<
"decline" | "timeout" | "user" | "allOthersLeft"
>();
public leave(): void {
this.leave$.next("user");
}
private readonly _left$ = new Subject<
"decline" | "timeout" | "user" | "allOthersLeft"
>();
public left$ = this._left$.asObservable();
private readonly connectionInstructions$ = this.join$.pipe( private readonly connectionInstructions$ = this.join$.pipe(
switchMap(() => this.remoteConnections$), switchMap(() => this.remoteConnections$),
startWith(new Map<string, Connection>()), startWith(new Map<string, Connection>()),
@@ -1154,7 +1142,7 @@ export class CallViewModel extends ViewModel {
); );
private readonly allOthersLeft$ = this.matrixUserChanges$.pipe( private readonly allOthersLeft$ = this.matrixUserChanges$.pipe(
map(({ userIds, leftUserIds }) => { filter(({ userIds, leftUserIds }) => {
if (!this.userId) { if (!this.userId) {
logger.warn("Could not access user ID to compute allOthersLeft"); logger.warn("Could not access user ID to compute allOthersLeft");
return false; return false;
@@ -1163,12 +1151,40 @@ export class CallViewModel extends ViewModel {
userIds.size === 1 && userIds.has(this.userId) && leftUserIds.size > 0 userIds.size === 1 && userIds.has(this.userId) && leftUserIds.size > 0
); );
}), }),
startWith(false), map(() => "allOthersLeft" as const),
); );
public readonly autoLeave$ = this.options.autoLeaveWhenOthersLeft // Public for testing
? this.allOthersLeft$ public readonly autoLeave$ = merge(
: NEVER; this.options.autoLeaveWhenOthersLeft ? this.allOthersLeft$ : NEVER,
this.callPickupState$.pipe(
filter((state) => state === "timeout" || state === "decline"),
),
);
private readonly userHangup$ = new Subject<void>();
public hangup(): void {
this.userHangup$.next();
}
private readonly widgetHangup$ =
widget === null
? NEVER
: (
fromEvent(
widget.lazyActions,
ElementWidgetActions.HangupCall,
) as Observable<[CustomEvent<IWidgetApiRequest>]>
).pipe(tap(([ev]) => widget!.api.transport.reply(ev.detail, {})));
public readonly leave$: Observable<
"user" | "timeout" | "decline" | "allOthersLeft"
> = merge(
this.autoLeave$,
merge(this.userHangup$, this.widgetHangup$).pipe(
map(() => "user" as const),
),
);
/** /**
* List of MediaItems that we want to display, that are of type ScreenShare * List of MediaItems that we want to display, that are of type ScreenShare
@@ -1929,34 +1945,17 @@ export class CallViewModel extends ViewModel {
); );
}); });
this.allOthersLeft$ this.leave$.pipe(this.scope.bind()).subscribe(() => {
.pipe( // Only sends Matrix leave event. The LiveKit session will disconnect once, uh...
this.scope.bind(), // (TODO-MULTI-SFU does anything actually cause it to disconnect?)
filter((l) => (l && this.options.autoLeaveWhenOthersLeft) ?? false), void this.matrixRTCSession
distinctUntilChanged(), .leaveRoomSession()
) .catch((e) => logger.error("Error leaving RTC session", e))
.subscribe(() => { .then(async () =>
this.leave$.next("allOthersLeft"); widget?.api.transport
}); .send(ElementWidgetActions.HangupCall, {})
.catch((e) => logger.error("Failed to send hangup action", e)),
this.callPickupState$.pipe(this.scope.bind()).subscribe((state) => { );
if (state === "timeout" || state === "decline") {
this.leave$.next(state);
}
});
this.leave$.pipe(this.scope.bind()).subscribe((reason) => {
const { confineToRoom } = this.urlParams;
leaveRTCSession(this.matrixRTCSession, "user")
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
.then(() => {
if (!confineToRoom && !PosthogAnalytics.instance.isEnabled()) {
this._left$.next(reason);
}
})
.catch((e) => {
logger.error("Error leaving RTC session", e);
});
}); });
// Pause upstream of all local media tracks when we're disconnected from // Pause upstream of all local media tracks when we're disconnected from