From 6cf020763e4c1ad23f0fda75ec9e7f7cc9d72d98 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 24 Sep 2025 21:26:16 -0400 Subject: [PATCH] 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). --- src/room/GroupCallView.tsx | 76 ++++++++++++++++++------------ src/room/InCallView.tsx | 4 +- src/state/CallViewModel.ts | 95 +++++++++++++++++++------------------- 3 files changed, 95 insertions(+), 80 deletions(-) diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 614c7b50..49d8b60b 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -41,7 +41,6 @@ import { ActiveCall } from "./InCallView"; import { type MuteStates } from "../state/MuteStates"; import { useMediaDevices } from "../MediaDevicesContext"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; -import { leaveRTCSession } from "../rtcSessionHelpers"; import { saveKeyForRoom, useRoomEncryptionSystem, @@ -50,7 +49,12 @@ import { useRoomAvatar } from "./useRoomAvatar"; import { useRoomName } from "./useRoomName"; import { useJoinRule } from "./useJoinRule"; 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 { useAudioContext } from "../useAudioContext"; import { @@ -322,37 +326,62 @@ export const GroupCallView: FC = ({ setJoined(false); setLeft(true); 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, - // therefore we want the event to be sent instantly without getting queued/batched. - 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. + // We need to wait until the callEnded event is tracked on PostHog, + // otherwise the iframe may get killed first. 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( room.roomId, rtcSession.memberships.length, sendInstantly, - 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); }); 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 ( !isPasswordlessUser && !confineToRoom && !PosthogAnalytics.instance.isEnabled() - ) { + ) 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, @@ -367,24 +396,11 @@ export const GroupCallView: FC = ({ ); useEffect(() => { - if (widget && joined) { + if (widget && joined) // set widget to sticky once joined. widget.api.setAlwaysOnScreen(true).catch((e) => { logger.error("Error calling setAlwaysOnScreen(true)", e); }); - - const onHangup = (ev: CustomEvent): 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]); const joinRule = useJoinRule(room); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 14f18fb7..20d53b3a 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -151,7 +151,7 @@ export const ActiveCall: FC = (props) => { ); setVm(vm); - const sub = vm.left$.subscribe(props.onLeft); + const sub = vm.leave$.subscribe(props.onLeft); return (): void => { vm.destroy(); sub.unsubscribe(); @@ -798,7 +798,7 @@ export const InCallView: FC = ({ (); - - 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( switchMap(() => this.remoteConnections$), startWith(new Map()), @@ -1154,7 +1142,7 @@ export class CallViewModel extends ViewModel { ); private readonly allOthersLeft$ = this.matrixUserChanges$.pipe( - map(({ userIds, leftUserIds }) => { + filter(({ userIds, leftUserIds }) => { if (!this.userId) { logger.warn("Could not access user ID to compute allOthersLeft"); return false; @@ -1163,12 +1151,40 @@ export class CallViewModel extends ViewModel { userIds.size === 1 && userIds.has(this.userId) && leftUserIds.size > 0 ); }), - startWith(false), + map(() => "allOthersLeft" as const), ); - public readonly autoLeave$ = this.options.autoLeaveWhenOthersLeft - ? this.allOthersLeft$ - : NEVER; + // Public for testing + public readonly autoLeave$ = merge( + this.options.autoLeaveWhenOthersLeft ? this.allOthersLeft$ : NEVER, + this.callPickupState$.pipe( + filter((state) => state === "timeout" || state === "decline"), + ), + ); + + private readonly userHangup$ = new Subject(); + public hangup(): void { + this.userHangup$.next(); + } + + private readonly widgetHangup$ = + widget === null + ? NEVER + : ( + fromEvent( + widget.lazyActions, + ElementWidgetActions.HangupCall, + ) as Observable<[CustomEvent]> + ).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 @@ -1929,34 +1945,17 @@ export class CallViewModel extends ViewModel { ); }); - this.allOthersLeft$ - .pipe( - this.scope.bind(), - filter((l) => (l && this.options.autoLeaveWhenOthersLeft) ?? false), - distinctUntilChanged(), - ) - .subscribe(() => { - this.leave$.next("allOthersLeft"); - }); - - 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); - }); + this.leave$.pipe(this.scope.bind()).subscribe(() => { + // Only sends Matrix leave event. The LiveKit session will disconnect once, uh... + // (TODO-MULTI-SFU does anything actually cause it to disconnect?) + void this.matrixRTCSession + .leaveRoomSession() + .catch((e) => logger.error("Error leaving RTC session", e)) + .then(async () => + widget?.api.transport + .send(ElementWidgetActions.HangupCall, {}) + .catch((e) => logger.error("Failed to send hangup action", e)), + ); }); // Pause upstream of all local media tracks when we're disconnected from