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 { 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<Props> = ({
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<Props> = ({
);
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<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]);
const joinRule = useJoinRule(room);

View File

@@ -151,7 +151,7 @@ export const ActiveCall: FC<ActiveCallProps> = (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<InCallViewProps> = ({
<EndCallButton
key="end_call"
onClick={function (): void {
vm.leave();
vm.hangup();
}}
onTouchEnd={onControlsTouchEnd}
data-testid="incall_leave"

View File

@@ -57,6 +57,7 @@ import {
switchScan,
take,
takeUntil,
tap,
throttleTime,
timer,
} from "rxjs";
@@ -117,16 +118,16 @@ import { constant, type Behavior } from "./Behavior";
import {
enterRTCSession,
getLivekitAlias,
leaveRTCSession,
makeFocus,
} from "../rtcSessionHelpers";
import { E2eeType } from "../e2ee/e2eeType";
import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider";
import { Connection, PublishConnection } from "./Connection";
import { type MuteStates } from "./MuteStates";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { getUrlParams } from "../UrlParams";
import { type ProcessorState } from "../livekit/TrackProcessorContext";
import { ElementWidgetActions, widget } from "../widget";
import { IWidgetApiRequest } from "matrix-widget-api";
export interface CallViewModelOptions {
encryptionSystem: EncryptionSystem;
@@ -554,19 +555,6 @@ export class CallViewModel extends ViewModel {
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(
switchMap(() => this.remoteConnections$),
startWith(new Map<string, Connection>()),
@@ -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<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
@@ -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