Skip to content

Commit a75952c

Browse files
committed
Send a 'close' action when the widget is ready to close
By keeping 'hangup' and 'close' as separate actions, we can allow Element Call widgets to stay on an error screen after the user has been disconnected without the widget completely disappearing from the host's UI. We don't have to request any additional capabilities to use a custom widget action like this one.
1 parent 0f5dc33 commit a75952c

File tree

7 files changed

+105
-22
lines changed

7 files changed

+105
-22
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: 10 additions & 6 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) {
@@ -149,15 +145,23 @@ const widgetPostHangupProcedure = async (
149145
// calling leaveRTCSession.
150146
// We need to wait because this makes the client hosting this widget killing the IFrame.
151147
await widget.api.transport.send(ElementWidgetActions.HangupCall, {});
148+
// On a normal user hangup we can shut down and close the widget. But if an
149+
// error occurs we should keep the widget open until the user reads it.
150+
if (cause === "user") {
151+
await widget.api.transport.send(ElementWidgetActions.Close, {});
152+
widget.api.transport.stop();
153+
PosthogAnalytics.instance.logout();
154+
}
152155
};
153156

154157
export async function leaveRTCSession(
155158
rtcSession: MatrixRTCSession,
159+
cause: "user" | "error",
156160
promiseBeforeHangup?: Promise<unknown>,
157161
): Promise<void> {
158162
await rtcSession.leaveRoomSession();
159163
if (widget) {
160-
await widgetPostHangupProcedure(widget, promiseBeforeHangup);
164+
await widgetPostHangupProcedure(widget, cause, promiseBeforeHangup);
161165
} else {
162166
await promiseBeforeHangup;
163167
}

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)