Skip to content

Commit 2bb5b02

Browse files
robintownhughns
andauthored
Leave session when error occurs and show error screens in widget mode (#3021)
Co-authored-by: Hugh Nimmo-Smith <hughns@users.noreply.github.com>
1 parent cd05df3 commit 2bb5b02

File tree

4 files changed

+133
-106
lines changed

4 files changed

+133
-106
lines changed

src/room/CallEndedView.tsx

Lines changed: 21 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
55
Please see LICENSE in the repository root for full details.
66
*/
77

8-
import {
9-
type FC,
10-
type FormEventHandler,
11-
type ReactNode,
12-
useCallback,
13-
useState,
14-
} from "react";
8+
import { type FC, type FormEventHandler, useCallback, useState } from "react";
159
import { type MatrixClient } from "matrix-js-sdk/src/client";
1610
import { Trans, useTranslation } from "react-i18next";
1711
import { Button, Heading, Text } from "@vector-im/compound-web";
18-
import { OfflineIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
1912
import { useNavigate } from "react-router-dom";
2013
import { logger } from "matrix-js-sdk/src/logger";
2114

@@ -28,24 +21,19 @@ import { FieldRow, InputField } from "../input/Input";
2821
import { StarRatingInput } from "../input/StarRatingInput";
2922
import { Link } from "../button/Link";
3023
import { LinkButton } from "../button";
31-
import { ErrorView } from "../ErrorView";
3224

3325
interface Props {
3426
client: MatrixClient;
3527
isPasswordlessUser: boolean;
3628
confineToRoom: boolean;
3729
endedCallId: string;
38-
leaveError?: Error;
39-
reconnect: () => void;
4030
}
4131

4232
export const CallEndedView: FC<Props> = ({
4333
client,
4434
isPasswordlessUser,
4535
confineToRoom,
4636
endedCallId,
47-
leaveError,
48-
reconnect,
4937
}) => {
5038
const { t } = useTranslation();
5139
const navigate = useNavigate();
@@ -143,61 +131,32 @@ export const CallEndedView: FC<Props> = ({
143131
</div>
144132
);
145133

146-
const renderBody = (): ReactNode => {
147-
if (leaveError) {
148-
return (
149-
<>
150-
<main className={styles.main}>
151-
<ErrorView
152-
Icon={OfflineIcon}
153-
title={t("error.connection_lost")}
154-
rageshake
155-
>
156-
<p>{t("error.connection_lost_description")}</p>
157-
<Button onClick={reconnect}>
158-
{t("call_ended_view.reconnect_button")}
159-
</Button>
160-
</ErrorView>
161-
</main>
162-
</>
163-
);
164-
} else {
165-
return (
166-
<>
167-
<main className={styles.main}>
168-
<Heading size="xl" weight="semibold" className={styles.headline}>
169-
{surveySubmitted
170-
? t("call_ended_view.headline", {
171-
displayName,
172-
})
173-
: t("call_ended_view.headline", {
174-
displayName,
175-
}) +
176-
"\n" +
177-
t("call_ended_view.survey_prompt")}
178-
</Heading>
179-
{(!surveySubmitted || confineToRoom) &&
180-
PosthogAnalytics.instance.isEnabled()
181-
? qualitySurveyDialog
182-
: createAccountDialog}
183-
</main>
184-
{!confineToRoom && (
185-
<Text className={styles.footer}>
186-
<Link to="/"> {t("call_ended_view.not_now_button")} </Link>
187-
</Text>
188-
)}
189-
</>
190-
);
191-
}
192-
};
193-
194134
return (
195135
<>
196136
<Header>
197137
<LeftNav>{!confineToRoom && <HeaderLogo />}</LeftNav>
198138
<RightNav />
199139
</Header>
200-
<div className={styles.container}>{renderBody()}</div>
140+
<div className={styles.container}>
141+
<main className={styles.main}>
142+
<Heading size="xl" weight="semibold" className={styles.headline}>
143+
{surveySubmitted
144+
? t("call_ended_view.headline", { displayName })
145+
: t("call_ended_view.headline", { displayName }) +
146+
"\n" +
147+
t("call_ended_view.survey_prompt")}
148+
</Heading>
149+
{(!surveySubmitted || confineToRoom) &&
150+
PosthogAnalytics.instance.isEnabled()
151+
? qualitySurveyDialog
152+
: createAccountDialog}
153+
</main>
154+
{!confineToRoom && (
155+
<Text className={styles.footer}>
156+
<Link to="/"> {t("call_ended_view.not_now_button")} </Link>
157+
</Text>
158+
)}
159+
</div>
201160
</>
202161
);
203162
};

src/room/GroupCallView.test.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ 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, waitFor } from "@testing-library/react";
9+
import { render, waitFor, screen } 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";
1313
import { JoinRule, type RoomState } from "matrix-js-sdk/src/matrix";
1414
import { BrowserRouter } from "react-router-dom";
1515
import userEvent from "@testing-library/user-event";
1616
import { type RelationsContainer } from "matrix-js-sdk/src/models/relations-container";
17+
import { useState } from "react";
1718

1819
import { type MuteStates } from "./MuteStates";
1920
import { prefetchSounds } from "../soundUtils";
@@ -184,3 +185,28 @@ test("will play a leave sound synchronously in widget mode", async () => {
184185
);
185186
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce();
186187
});
188+
189+
test("GroupCallView leaves the session when an error occurs", async () => {
190+
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(() => {
191+
const [error, setError] = useState<Error | null>(null);
192+
if (error !== null) throw error;
193+
return (
194+
<div>
195+
<button onClick={() => setError(new Error())}>Panic!</button>
196+
</div>
197+
);
198+
});
199+
const user = userEvent.setup();
200+
const { rtcSession } = createGroupCallView(null);
201+
await user.click(screen.getByRole("button", { name: "Panic!" }));
202+
screen.getByText("error.generic");
203+
expect(leaveRTCSession).toHaveBeenCalledWith(
204+
rtcSession,
205+
"error",
206+
expect.any(Promise),
207+
);
208+
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce();
209+
// Ensure that the playSound promise resolves within this test to avoid
210+
// impacting the results of other tests
211+
await waitFor(() => expect(leaveRTCSession).toHaveResolved());
212+
});

src/room/GroupCallView.tsx

Lines changed: 77 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
55
Please see LICENSE in the repository root for full details.
66
*/
77

8-
import { type FC, useCallback, useEffect, useMemo, useState } from "react";
8+
import {
9+
type FC,
10+
type ReactElement,
11+
type ReactNode,
12+
useCallback,
13+
useEffect,
14+
useMemo,
15+
useState,
16+
} from "react";
917
import { type MatrixClient } from "matrix-js-sdk/src/client";
1018
import {
1119
Room,
@@ -14,24 +22,29 @@ import {
1422
import { logger } from "matrix-js-sdk/src/logger";
1523
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
1624
import { JoinRule } from "matrix-js-sdk/src/matrix";
17-
import { WebBrowserIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
25+
import {
26+
OfflineIcon,
27+
WebBrowserIcon,
28+
} from "@vector-im/compound-design-tokens/assets/web/icons";
1829
import { useTranslation } from "react-i18next";
1930
import { useNavigate } from "react-router-dom";
31+
import { ErrorBoundary } from "@sentry/react";
32+
import { Button } from "@vector-im/compound-web";
2033

2134
import type { IWidgetApiRequest } from "matrix-widget-api";
2235
import {
2336
ElementWidgetActions,
2437
type JoinCallData,
2538
type WidgetHelpers,
2639
} from "../widget";
27-
import { FullScreenView } from "../FullScreenView";
40+
import { ErrorPage, FullScreenView } from "../FullScreenView";
2841
import { LobbyView } from "./LobbyView";
2942
import { type MatrixInfo } from "./VideoPreview";
3043
import { CallEndedView } from "./CallEndedView";
3144
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
3245
import { useProfile } from "../profile/useProfile";
3346
import { findDeviceByName } from "../utils/media";
34-
import { ActiveCall } from "./InCallView";
47+
import { ActiveCall, ConnectionLostError } from "./InCallView";
3548
import { MUTE_PARTICIPANT_COUNT, type MuteStates } from "./MuteStates";
3649
import { useMediaDevices } from "../livekit/MediaDevicesContext";
3750
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
@@ -55,6 +68,11 @@ declare global {
5568
}
5669
}
5770

71+
interface GroupCallErrorPageProps {
72+
error: Error | unknown;
73+
resetError: () => void;
74+
}
75+
5876
interface Props {
5977
client: MatrixClient;
6078
isPasswordlessUser: boolean;
@@ -229,16 +247,14 @@ export const GroupCallView: FC<Props> = ({
229247
]);
230248

231249
const [left, setLeft] = useState(false);
232-
const [leaveError, setLeaveError] = useState<Error | undefined>(undefined);
233250
const navigate = useNavigate();
234251

235252
const onLeave = useCallback(
236-
(leaveError?: Error): void => {
253+
(cause: "user" | "error" = "user"): void => {
237254
const audioPromise = leaveSoundContext.current?.playSound("left");
238255
// In embedded/widget mode the iFrame will be killed right after the call ended prohibiting the posthog event from getting sent,
239256
// therefore we want the event to be sent instantly without getting queued/batched.
240257
const sendInstantly = !!widget;
241-
setLeaveError(leaveError);
242258
setLeft(true);
243259
// we need to wait until the callEnded event is tracked on posthog.
244260
// Otherwise the iFrame gets killed before the callEnded event got tracked.
@@ -254,7 +270,7 @@ export const GroupCallView: FC<Props> = ({
254270

255271
leaveRTCSession(
256272
rtcSession,
257-
leaveError === undefined ? "user" : "error",
273+
cause,
258274
// Wait for the sound in widget mode (it's not long)
259275
Promise.all([audioPromise, posthogRequest]),
260276
)
@@ -303,14 +319,6 @@ export const GroupCallView: FC<Props> = ({
303319
}
304320
}, [widget, isJoined, rtcSession]);
305321

306-
const onReconnect = useCallback(() => {
307-
setLeft(false);
308-
setLeaveError(undefined);
309-
enterRTCSession(rtcSession, perParticipantE2EE).catch((e) => {
310-
logger.error("Error re-entering RTC session on reconnect", e);
311-
});
312-
}, [rtcSession, perParticipantE2EE]);
313-
314322
const joinRule = useJoinRule(rtcSession.room);
315323

316324
const [shareModalOpen, setInviteModalOpen] = useState(false);
@@ -327,6 +335,43 @@ export const GroupCallView: FC<Props> = ({
327335

328336
const { t } = useTranslation();
329337

338+
const errorPage = useMemo(() => {
339+
function GroupCallErrorPage({
340+
error,
341+
resetError,
342+
}: GroupCallErrorPageProps): ReactElement {
343+
useEffect(() => {
344+
if (rtcSession.isJoined()) onLeave("error");
345+
}, [error]);
346+
347+
const onReconnect = useCallback(() => {
348+
setLeft(false);
349+
resetError();
350+
enterRTCSession(rtcSession, perParticipantE2EE).catch((e) => {
351+
logger.error("Error re-entering RTC session on reconnect", e);
352+
});
353+
}, [resetError]);
354+
355+
return error instanceof ConnectionLostError ? (
356+
<FullScreenView>
357+
<ErrorView
358+
Icon={OfflineIcon}
359+
title={t("error.connection_lost")}
360+
rageshake
361+
>
362+
<p>{t("error.connection_lost_description")}</p>
363+
<Button onClick={onReconnect}>
364+
{t("call_ended_view.reconnect_button")}
365+
</Button>
366+
</ErrorView>
367+
</FullScreenView>
368+
) : (
369+
<ErrorPage error={error} />
370+
);
371+
}
372+
return GroupCallErrorPage;
373+
}, [onLeave, rtcSession, perParticipantE2EE, t]);
374+
330375
if (!isE2EESupportedBrowser() && e2eeSystem.kind !== E2eeType.NONE) {
331376
// If we have a encryption system but the browser does not support it.
332377
return (
@@ -361,8 +406,9 @@ export const GroupCallView: FC<Props> = ({
361406
</>
362407
);
363408

409+
let body: ReactNode;
364410
if (isJoined) {
365-
return (
411+
body = (
366412
<>
367413
{shareModal}
368414
<ActiveCall
@@ -390,36 +436,32 @@ export const GroupCallView: FC<Props> = ({
390436
// submitting anything.
391437
if (
392438
isPasswordlessUser ||
393-
(PosthogAnalytics.instance.isEnabled() && widget === null) ||
394-
leaveError
439+
(PosthogAnalytics.instance.isEnabled() && widget === null)
395440
) {
396-
return (
397-
<>
398-
<CallEndedView
399-
endedCallId={rtcSession.room.roomId}
400-
client={client}
401-
isPasswordlessUser={isPasswordlessUser}
402-
confineToRoom={confineToRoom}
403-
leaveError={leaveError}
404-
reconnect={onReconnect}
405-
/>
406-
;
407-
</>
441+
body = (
442+
<CallEndedView
443+
endedCallId={rtcSession.room.roomId}
444+
client={client}
445+
isPasswordlessUser={isPasswordlessUser}
446+
confineToRoom={confineToRoom}
447+
/>
408448
);
409449
} else {
410450
// If the user is a regular user, we'll have sent them back to the homepage,
411451
// so just sit here & do nothing: otherwise we would (briefly) mount the
412452
// LobbyView again which would open capture devices again.
413-
return null;
453+
body = null;
414454
}
415455
} else if (left && widget !== null) {
416456
// Left in widget mode:
417457
if (!returnToLobby) {
418-
return null;
458+
body = null;
419459
}
420460
} else if (preload || skipLobby) {
421-
return null;
461+
body = null;
462+
} else {
463+
body = lobbyView;
422464
}
423465

424-
return lobbyView;
466+
return <ErrorBoundary fallback={errorPage}>{body}</ErrorBoundary>;
425467
};

0 commit comments

Comments
 (0)