Merge pull request #3011 from element-hq/robin/close-action
Send a 'close' action when the widget is ready to close
This commit is contained in:
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { beforeEach, expect, type MockedFunction, test, vitest } from "vitest";
|
import { beforeEach, expect, type MockedFunction, test, vitest } from "vitest";
|
||||||
import { render } from "@testing-library/react";
|
import { render, waitFor } from "@testing-library/react";
|
||||||
import { type MatrixClient } from "matrix-js-sdk/src/client";
|
import { type MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
|
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
|
||||||
import { of } from "rxjs";
|
import { of } from "rxjs";
|
||||||
@@ -20,6 +20,7 @@ import { prefetchSounds } from "../soundUtils";
|
|||||||
import { useAudioContext } from "../useAudioContext";
|
import { useAudioContext } from "../useAudioContext";
|
||||||
import { ActiveCall } from "./InCallView";
|
import { ActiveCall } from "./InCallView";
|
||||||
import {
|
import {
|
||||||
|
flushPromises,
|
||||||
mockMatrixRoom,
|
mockMatrixRoom,
|
||||||
mockMatrixRoomMember,
|
mockMatrixRoomMember,
|
||||||
mockRtcMembership,
|
mockRtcMembership,
|
||||||
@@ -51,13 +52,13 @@ const carol = mockMatrixRoomMember(localRtcMember);
|
|||||||
const roomMembers = new Map([carol].map((p) => [p.userId, p]));
|
const roomMembers = new Map([carol].map((p) => [p.userId, p]));
|
||||||
|
|
||||||
const roomId = "!foo:bar";
|
const roomId = "!foo:bar";
|
||||||
const soundPromise = Promise.resolve(true);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vitest.clearAllMocks();
|
||||||
(prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({
|
(prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({
|
||||||
sound: new ArrayBuffer(0),
|
sound: new ArrayBuffer(0),
|
||||||
});
|
});
|
||||||
playSound = vitest.fn().mockReturnValue(soundPromise);
|
playSound = vitest.fn();
|
||||||
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
|
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
|
||||||
playSound,
|
playSound,
|
||||||
});
|
});
|
||||||
@@ -136,8 +137,15 @@ test("will play a leave sound asynchronously in SPA mode", async () => {
|
|||||||
const leaveButton = getByText("Leave");
|
const leaveButton = getByText("Leave");
|
||||||
await user.click(leaveButton);
|
await user.click(leaveButton);
|
||||||
expect(playSound).toHaveBeenCalledWith("left");
|
expect(playSound).toHaveBeenCalledWith("left");
|
||||||
expect(leaveRTCSession).toHaveBeenCalledWith(rtcSession, undefined);
|
expect(leaveRTCSession).toHaveBeenCalledWith(
|
||||||
|
rtcSession,
|
||||||
|
"user",
|
||||||
|
expect.any(Promise),
|
||||||
|
);
|
||||||
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce();
|
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce();
|
||||||
|
// Ensure that the playSound promise resolves within this test to avoid
|
||||||
|
// impacting the results of other tests
|
||||||
|
await waitFor(() => expect(leaveRTCSession).toHaveResolved());
|
||||||
});
|
});
|
||||||
|
|
||||||
test("will play a leave sound synchronously in widget mode", async () => {
|
test("will play a leave sound synchronously in widget mode", async () => {
|
||||||
@@ -148,12 +156,31 @@ test("will play a leave sound synchronously in widget mode", async () => {
|
|||||||
} as Partial<WidgetHelpers["api"]>,
|
} as Partial<WidgetHelpers["api"]>,
|
||||||
lazyActions: new LazyEventEmitter(),
|
lazyActions: new LazyEventEmitter(),
|
||||||
};
|
};
|
||||||
|
let resolvePlaySound: () => void;
|
||||||
|
playSound = vitest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue(
|
||||||
|
new Promise<void>((resolve) => (resolvePlaySound = resolve)),
|
||||||
|
);
|
||||||
|
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
|
||||||
|
playSound,
|
||||||
|
});
|
||||||
|
|
||||||
const { getByText, rtcSession } = createGroupCallView(
|
const { getByText, rtcSession } = createGroupCallView(
|
||||||
widget as WidgetHelpers,
|
widget as WidgetHelpers,
|
||||||
);
|
);
|
||||||
const leaveButton = getByText("Leave");
|
const leaveButton = getByText("Leave");
|
||||||
await user.click(leaveButton);
|
await user.click(leaveButton);
|
||||||
|
await flushPromises();
|
||||||
|
expect(leaveRTCSession).not.toHaveResolved();
|
||||||
|
resolvePlaySound!();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
expect(playSound).toHaveBeenCalledWith("left");
|
expect(playSound).toHaveBeenCalledWith("left");
|
||||||
expect(leaveRTCSession).toHaveBeenCalledWith(rtcSession, soundPromise);
|
expect(leaveRTCSession).toHaveBeenCalledWith(
|
||||||
|
rtcSession,
|
||||||
|
"user",
|
||||||
|
expect.any(Promise),
|
||||||
|
);
|
||||||
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce();
|
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -246,17 +246,23 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
const sendInstantly = !!widget;
|
const sendInstantly = !!widget;
|
||||||
setLeaveError(leaveError);
|
setLeaveError(leaveError);
|
||||||
setLeft(true);
|
setLeft(true);
|
||||||
PosthogAnalytics.instance.eventCallEnded.track(
|
// we need to wait until the callEnded event is tracked on posthog.
|
||||||
rtcSession.room.roomId,
|
// Otherwise the iFrame gets killed before the callEnded event got tracked.
|
||||||
rtcSession.memberships.length,
|
const posthogRequest = new Promise((resolve) => {
|
||||||
sendInstantly,
|
PosthogAnalytics.instance.eventCallEnded.track(
|
||||||
rtcSession,
|
rtcSession.room.roomId,
|
||||||
);
|
rtcSession.memberships.length,
|
||||||
|
sendInstantly,
|
||||||
|
rtcSession,
|
||||||
|
);
|
||||||
|
window.setTimeout(resolve, 10);
|
||||||
|
});
|
||||||
|
|
||||||
leaveRTCSession(
|
leaveRTCSession(
|
||||||
rtcSession,
|
rtcSession,
|
||||||
|
leaveError === undefined ? "user" : "error",
|
||||||
// Wait for the sound in widget mode (it's not long)
|
// Wait for the sound in widget mode (it's not long)
|
||||||
sendInstantly && audioPromise ? audioPromise : undefined,
|
Promise.all([audioPromise, posthogRequest]),
|
||||||
)
|
)
|
||||||
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
|
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -292,7 +298,7 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
const onHangup = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
const onHangup = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||||
widget.api.transport.reply(ev.detail, {});
|
widget.api.transport.reply(ev.detail, {});
|
||||||
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
|
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
|
||||||
leaveRTCSession(rtcSession).catch((e) => {
|
leaveRTCSession(rtcSession, "user").catch((e) => {
|
||||||
logger.error("Failed to leave RTC session", e);
|
logger.error("Failed to leave RTC session", e);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,9 +8,20 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||||
import { expect, test, vi } from "vitest";
|
import { expect, test, vi } from "vitest";
|
||||||
import { AutoDiscovery } from "matrix-js-sdk/src/autodiscovery";
|
import { AutoDiscovery } from "matrix-js-sdk/src/autodiscovery";
|
||||||
|
import EventEmitter from "events";
|
||||||
|
|
||||||
import { enterRTCSession } from "../src/rtcSessionHelpers";
|
import { enterRTCSession, leaveRTCSession } from "../src/rtcSessionHelpers";
|
||||||
import { mockConfig } from "./utils/test";
|
import { mockConfig } from "./utils/test";
|
||||||
|
import { ElementWidgetActions, widget } from "./widget";
|
||||||
|
|
||||||
|
const actualWidget = await vi.hoisted(async () => vi.importActual("./widget"));
|
||||||
|
vi.mock("./widget", () => ({
|
||||||
|
...actualWidget,
|
||||||
|
widget: {
|
||||||
|
api: { transport: { send: vi.fn(), reply: vi.fn(), stop: vi.fn() } },
|
||||||
|
lazyActions: new EventEmitter(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
test("It joins the correct Session", async () => {
|
test("It joins the correct Session", async () => {
|
||||||
const focusFromOlderMembership = {
|
const focusFromOlderMembership = {
|
||||||
@@ -96,3 +107,33 @@ test("It joins the correct Session", async () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("leaveRTCSession closes the widget on a normal hangup", async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
const session = { leaveRoomSession: vi.fn() } as unknown as MatrixRTCSession;
|
||||||
|
await leaveRTCSession(session, "user");
|
||||||
|
expect(session.leaveRoomSession).toHaveBeenCalled();
|
||||||
|
expect(widget!.api.transport.send).toHaveBeenCalledWith(
|
||||||
|
ElementWidgetActions.HangupCall,
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(widget!.api.transport.send).toHaveBeenCalledWith(
|
||||||
|
ElementWidgetActions.Close,
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaveRTCSession doesn't close the widget on a fatal error", async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
const session = { leaveRoomSession: vi.fn() } as unknown as MatrixRTCSession;
|
||||||
|
await leaveRTCSession(session, "error");
|
||||||
|
expect(session.leaveRoomSession).toHaveBeenCalled();
|
||||||
|
expect(widget!.api.transport.send).toHaveBeenCalledWith(
|
||||||
|
ElementWidgetActions.HangupCall,
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(widget!.api.transport.send).not.toHaveBeenCalledWith(
|
||||||
|
ElementWidgetActions.Close,
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -130,13 +130,9 @@ export async function enterRTCSession(
|
|||||||
|
|
||||||
const widgetPostHangupProcedure = async (
|
const widgetPostHangupProcedure = async (
|
||||||
widget: WidgetHelpers,
|
widget: WidgetHelpers,
|
||||||
|
cause: "user" | "error",
|
||||||
promiseBeforeHangup?: Promise<unknown>,
|
promiseBeforeHangup?: Promise<unknown>,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
// we need to wait until the callEnded event is tracked on posthog.
|
|
||||||
// Otherwise the iFrame gets killed before the callEnded event got tracked.
|
|
||||||
await new Promise((resolve) => window.setTimeout(resolve, 10)); // 10ms
|
|
||||||
PosthogAnalytics.instance.logout();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await widget.api.setAlwaysOnScreen(false);
|
await widget.api.setAlwaysOnScreen(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -148,16 +144,32 @@ const widgetPostHangupProcedure = async (
|
|||||||
// We send the hangup event after the memberships have been updated
|
// We send the hangup event after the memberships have been updated
|
||||||
// calling leaveRTCSession.
|
// calling leaveRTCSession.
|
||||||
// We need to wait because this makes the client hosting this widget killing the IFrame.
|
// We need to wait because this makes the client hosting this widget killing the IFrame.
|
||||||
await widget.api.transport.send(ElementWidgetActions.HangupCall, {});
|
try {
|
||||||
|
await widget.api.transport.send(ElementWidgetActions.HangupCall, {});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to send hangup action", 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 (cause === "user") {
|
||||||
|
try {
|
||||||
|
await widget.api.transport.send(ElementWidgetActions.Close, {});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to send close action", e);
|
||||||
|
}
|
||||||
|
widget.api.transport.stop();
|
||||||
|
PosthogAnalytics.instance.logout();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function leaveRTCSession(
|
export async function leaveRTCSession(
|
||||||
rtcSession: MatrixRTCSession,
|
rtcSession: MatrixRTCSession,
|
||||||
|
cause: "user" | "error",
|
||||||
promiseBeforeHangup?: Promise<unknown>,
|
promiseBeforeHangup?: Promise<unknown>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await rtcSession.leaveRoomSession();
|
await rtcSession.leaveRoomSession();
|
||||||
if (widget) {
|
if (widget) {
|
||||||
await widgetPostHangupProcedure(widget, promiseBeforeHangup);
|
await widgetPostHangupProcedure(widget, cause, promiseBeforeHangup);
|
||||||
} else {
|
} else {
|
||||||
await promiseBeforeHangup;
|
await promiseBeforeHangup;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ export function withFakeTimers(continuation: () => void): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function flushPromises(): Promise<void> {
|
||||||
|
await new Promise<void>((resolve) => window.setTimeout(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
export interface OurRunHelpers extends RunHelpers {
|
export interface OurRunHelpers extends RunHelpers {
|
||||||
/**
|
/**
|
||||||
* Schedules a sequence of actions to happen, as described by a marble
|
* Schedules a sequence of actions to happen, as described by a marble
|
||||||
|
|||||||
@@ -21,10 +21,11 @@ import { getUrlParams } from "./UrlParams";
|
|||||||
import { Config } from "./config/Config";
|
import { Config } from "./config/Config";
|
||||||
import { ElementCallReactionEventType } from "./reactions";
|
import { ElementCallReactionEventType } from "./reactions";
|
||||||
|
|
||||||
// Subset of the actions in matrix-react-sdk
|
// Subset of the actions in element-web
|
||||||
export enum ElementWidgetActions {
|
export enum ElementWidgetActions {
|
||||||
JoinCall = "io.element.join",
|
JoinCall = "io.element.join",
|
||||||
HangupCall = "im.vector.hangup",
|
HangupCall = "im.vector.hangup",
|
||||||
|
Close = "io.element.close",
|
||||||
TileLayout = "io.element.tile_layout",
|
TileLayout = "io.element.tile_layout",
|
||||||
SpotlightLayout = "io.element.spotlight_layout",
|
SpotlightLayout = "io.element.spotlight_layout",
|
||||||
// This can be sent as from or to widget
|
// This can be sent as from or to widget
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2022",
|
"target": "es2022",
|
||||||
"module": "es2020",
|
"module": "es2022",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"lib": ["es2022", "dom", "dom.iterable"],
|
"lib": ["es2022", "dom", "dom.iterable"],
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user