Skip to content

Commit 832a5aa

Browse files
authored
Merge pull request #3011 from element-hq/robin/close-action
Send a 'close' action when the widget is ready to close
2 parents 5e83952 + 518c8ea commit 832a5aa

File tree

7 files changed

+114
-23
lines changed

7 files changed

+114
-23
lines changed

src/room/GroupCallView.test.tsx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
66
*/
77

88
import { beforeEach, expect, type MockedFunction, test, vitest } from "vitest";
9-
import { render } from "@testing-library/react";
9+
import { render, waitFor } from "@testing-library/react";
1010
import { type MatrixClient } from "matrix-js-sdk/src/client";
1111
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
1212
import { of } from "rxjs";
@@ -20,6 +20,7 @@ import { prefetchSounds } from "../soundUtils";
2020
import { useAudioContext } from "../useAudioContext";
2121
import { ActiveCall } from "./InCallView";
2222
import {
23+
flushPromises,
2324
mockMatrixRoom,
2425
mockMatrixRoomMember,
2526
mockRtcMembership,
@@ -51,13 +52,13 @@ const carol = mockMatrixRoomMember(localRtcMember);
5152
const roomMembers = new Map([carol].map((p) => [p.userId, p]));
5253

5354
const roomId = "!foo:bar";
54-
const soundPromise = Promise.resolve(true);
5555

5656
beforeEach(() => {
57+
vitest.clearAllMocks();
5758
(prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({
5859
sound: new ArrayBuffer(0),
5960
});
60-
playSound = vitest.fn().mockReturnValue(soundPromise);
61+
playSound = vitest.fn();
6162
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
6263
playSound,
6364
});
@@ -136,8 +137,15 @@ test("will play a leave sound asynchronously in SPA mode", async () => {
136137
const leaveButton = getByText("Leave");
137138
await user.click(leaveButton);
138139
expect(playSound).toHaveBeenCalledWith("left");
139-
expect(leaveRTCSession).toHaveBeenCalledWith(rtcSession, undefined);
140+
expect(leaveRTCSession).toHaveBeenCalledWith(
141+
rtcSession,
142+
"user",
143+
expect.any(Promise),
144+
);
140145
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce();
146+
// Ensure that the playSound promise resolves within this test to avoid
147+
// impacting the results of other tests
148+
await waitFor(() => expect(leaveRTCSession).toHaveResolved());
141149
});
142150

143151
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 () => {
148156
} as Partial<WidgetHelpers["api"]>,
149157
lazyActions: new LazyEventEmitter(),
150158
};
159+
let resolvePlaySound: () => void;
160+
playSound = vitest
161+
.fn()
162+
.mockReturnValue(
163+
new Promise<void>((resolve) => (resolvePlaySound = resolve)),
164+
);
165+
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
166+
playSound,
167+
});
168+
151169
const { getByText, rtcSession } = createGroupCallView(
152170
widget as WidgetHelpers,
153171
);
154172
const leaveButton = getByText("Leave");
155173
await user.click(leaveButton);
174+
await flushPromises();
175+
expect(leaveRTCSession).not.toHaveResolved();
176+
resolvePlaySound!();
177+
await flushPromises();
178+
156179
expect(playSound).toHaveBeenCalledWith("left");
157-
expect(leaveRTCSession).toHaveBeenCalledWith(rtcSession, soundPromise);
180+
expect(leaveRTCSession).toHaveBeenCalledWith(
181+
rtcSession,
182+
"user",
183+
expect.any(Promise),
184+
);
158185
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce();
159186
});

src/room/GroupCallView.tsx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -246,17 +246,23 @@ export const GroupCallView: FC<Props> = ({
246246
const sendInstantly = !!widget;
247247
setLeaveError(leaveError);
248248
setLeft(true);
249-
PosthogAnalytics.instance.eventCallEnded.track(
250-
rtcSession.room.roomId,
251-
rtcSession.memberships.length,
252-
sendInstantly,
253-
rtcSession,
254-
);
249+
// we need to wait until the callEnded event is tracked on posthog.
250+
// Otherwise the iFrame gets killed before the callEnded event got tracked.
251+
const posthogRequest = new Promise((resolve) => {
252+
PosthogAnalytics.instance.eventCallEnded.track(
253+
rtcSession.room.roomId,
254+
rtcSession.memberships.length,
255+
sendInstantly,
256+
rtcSession,
257+
);
258+
window.setTimeout(resolve, 10);
259+
});
255260

256261
leaveRTCSession(
257262
rtcSession,
263+
leaveError === undefined ? "user" : "error",
258264
// Wait for the sound in widget mode (it's not long)
259-
sendInstantly && audioPromise ? audioPromise : undefined,
265+
Promise.all([audioPromise, posthogRequest]),
260266
)
261267
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
262268
.then(async () => {
@@ -292,7 +298,7 @@ export const GroupCallView: FC<Props> = ({
292298
const onHangup = (ev: CustomEvent<IWidgetApiRequest>): void => {
293299
widget.api.transport.reply(ev.detail, {});
294300
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
295-
leaveRTCSession(rtcSession).catch((e) => {
301+
leaveRTCSession(rtcSession, "user").catch((e) => {
296302
logger.error("Failed to leave RTC session", e);
297303
});
298304
};

src/rtcSessionHelper.test.ts renamed to src/rtcSessionHelpers.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,20 @@ Please see LICENSE in the repository root for full details.
88
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
99
import { expect, test, vi } from "vitest";
1010
import { AutoDiscovery } from "matrix-js-sdk/src/autodiscovery";
11+
import EventEmitter from "events";
1112

12-
import { enterRTCSession } from "../src/rtcSessionHelpers";
13+
import { enterRTCSession, leaveRTCSession } from "../src/rtcSessionHelpers";
1314
import { mockConfig } from "./utils/test";
15+
import { ElementWidgetActions, widget } from "./widget";
16+
17+
const actualWidget = await vi.hoisted(async () => vi.importActual("./widget"));
18+
vi.mock("./widget", () => ({
19+
...actualWidget,
20+
widget: {
21+
api: { transport: { send: vi.fn(), reply: vi.fn(), stop: vi.fn() } },
22+
lazyActions: new EventEmitter(),
23+
},
24+
}));
1425

1526
test("It joins the correct Session", async () => {
1627
const focusFromOlderMembership = {
@@ -96,3 +107,33 @@ test("It joins the correct Session", async () => {
96107
},
97108
);
98109
});
110+
111+
test("leaveRTCSession closes the widget on a normal hangup", async () => {
112+
vi.clearAllMocks();
113+
const session = { leaveRoomSession: vi.fn() } as unknown as MatrixRTCSession;
114+
await leaveRTCSession(session, "user");
115+
expect(session.leaveRoomSession).toHaveBeenCalled();
116+
expect(widget!.api.transport.send).toHaveBeenCalledWith(
117+
ElementWidgetActions.HangupCall,
118+
expect.anything(),
119+
);
120+
expect(widget!.api.transport.send).toHaveBeenCalledWith(
121+
ElementWidgetActions.Close,
122+
expect.anything(),
123+
);
124+
});
125+
126+
test("leaveRTCSession doesn't close the widget on a fatal error", async () => {
127+
vi.clearAllMocks();
128+
const session = { leaveRoomSession: vi.fn() } as unknown as MatrixRTCSession;
129+
await leaveRTCSession(session, "error");
130+
expect(session.leaveRoomSession).toHaveBeenCalled();
131+
expect(widget!.api.transport.send).toHaveBeenCalledWith(
132+
ElementWidgetActions.HangupCall,
133+
expect.anything(),
134+
);
135+
expect(widget!.api.transport.send).not.toHaveBeenCalledWith(
136+
ElementWidgetActions.Close,
137+
expect.anything(),
138+
);
139+
});

src/rtcSessionHelpers.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -130,13 +130,9 @@ export async function enterRTCSession(
130130

131131
const widgetPostHangupProcedure = async (
132132
widget: WidgetHelpers,
133+
cause: "user" | "error",
133134
promiseBeforeHangup?: Promise<unknown>,
134135
): Promise<void> => {
135-
// we need to wait until the callEnded event is tracked on posthog.
136-
// Otherwise the iFrame gets killed before the callEnded event got tracked.
137-
await new Promise((resolve) => window.setTimeout(resolve, 10)); // 10ms
138-
PosthogAnalytics.instance.logout();
139-
140136
try {
141137
await widget.api.setAlwaysOnScreen(false);
142138
} catch (e) {
@@ -148,16 +144,32 @@ const widgetPostHangupProcedure = async (
148144
// We send the hangup event after the memberships have been updated
149145
// calling leaveRTCSession.
150146
// We need to wait because this makes the client hosting this widget killing the IFrame.
151-
await widget.api.transport.send(ElementWidgetActions.HangupCall, {});
147+
try {
148+
await widget.api.transport.send(ElementWidgetActions.HangupCall, {});
149+
} catch (e) {
150+
logger.error("Failed to send hangup action", e);
151+
}
152+
// On a normal user hangup we can shut down and close the widget. But if an
153+
// error occurs we should keep the widget open until the user reads it.
154+
if (cause === "user") {
155+
try {
156+
await widget.api.transport.send(ElementWidgetActions.Close, {});
157+
} catch (e) {
158+
logger.error("Failed to send close action", e);
159+
}
160+
widget.api.transport.stop();
161+
PosthogAnalytics.instance.logout();
162+
}
152163
};
153164

154165
export async function leaveRTCSession(
155166
rtcSession: MatrixRTCSession,
167+
cause: "user" | "error",
156168
promiseBeforeHangup?: Promise<unknown>,
157169
): Promise<void> {
158170
await rtcSession.leaveRoomSession();
159171
if (widget) {
160-
await widgetPostHangupProcedure(widget, promiseBeforeHangup);
172+
await widgetPostHangupProcedure(widget, cause, promiseBeforeHangup);
161173
} else {
162174
await promiseBeforeHangup;
163175
}

src/utils/test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ export function withFakeTimers(continuation: () => void): void {
5050
}
5151
}
5252

53+
export async function flushPromises(): Promise<void> {
54+
await new Promise<void>((resolve) => window.setTimeout(resolve));
55+
}
56+
5357
export interface OurRunHelpers extends RunHelpers {
5458
/**
5559
* Schedules a sequence of actions to happen, as described by a marble

src/widget.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ import { getUrlParams } from "./UrlParams";
2121
import { Config } from "./config/Config";
2222
import { ElementCallReactionEventType } from "./reactions";
2323

24-
// Subset of the actions in matrix-react-sdk
24+
// Subset of the actions in element-web
2525
export enum ElementWidgetActions {
2626
JoinCall = "io.element.join",
2727
HangupCall = "im.vector.hangup",
28+
Close = "io.element.close",
2829
TileLayout = "io.element.tile_layout",
2930
SpotlightLayout = "io.element.spotlight_layout",
3031
// This can be sent as from or to widget

tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"compilerOptions": {
33
"target": "es2022",
4-
"module": "es2020",
4+
"module": "es2022",
55
"jsx": "react-jsx",
66
"lib": ["es2022", "dom", "dom.iterable"],
77

0 commit comments

Comments
 (0)