Leave session when error occurs and show error screens in widget mode (#3021)

Co-authored-by: Hugh Nimmo-Smith <hughns@users.noreply.github.com>
This commit is contained in:
Robin
2025-02-26 17:20:30 +07:00
committed by GitHub
parent cd05df3e33
commit 2bb5b020e6
4 changed files with 133 additions and 106 deletions

View File

@@ -5,17 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { import { type FC, type FormEventHandler, useCallback, useState } from "react";
type FC,
type FormEventHandler,
type ReactNode,
useCallback,
useState,
} from "react";
import { type MatrixClient } from "matrix-js-sdk/src/client"; import { type MatrixClient } from "matrix-js-sdk/src/client";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { Button, Heading, Text } from "@vector-im/compound-web"; import { Button, Heading, Text } from "@vector-im/compound-web";
import { OfflineIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
@@ -28,15 +21,12 @@ import { FieldRow, InputField } from "../input/Input";
import { StarRatingInput } from "../input/StarRatingInput"; import { StarRatingInput } from "../input/StarRatingInput";
import { Link } from "../button/Link"; import { Link } from "../button/Link";
import { LinkButton } from "../button"; import { LinkButton } from "../button";
import { ErrorView } from "../ErrorView";
interface Props { interface Props {
client: MatrixClient; client: MatrixClient;
isPasswordlessUser: boolean; isPasswordlessUser: boolean;
confineToRoom: boolean; confineToRoom: boolean;
endedCallId: string; endedCallId: string;
leaveError?: Error;
reconnect: () => void;
} }
export const CallEndedView: FC<Props> = ({ export const CallEndedView: FC<Props> = ({
@@ -44,8 +34,6 @@ export const CallEndedView: FC<Props> = ({
isPasswordlessUser, isPasswordlessUser,
confineToRoom, confineToRoom,
endedCallId, endedCallId,
leaveError,
reconnect,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -143,61 +131,32 @@ export const CallEndedView: FC<Props> = ({
</div> </div>
); );
const renderBody = (): ReactNode => {
if (leaveError) {
return (
<>
<main className={styles.main}>
<ErrorView
Icon={OfflineIcon}
title={t("error.connection_lost")}
rageshake
>
<p>{t("error.connection_lost_description")}</p>
<Button onClick={reconnect}>
{t("call_ended_view.reconnect_button")}
</Button>
</ErrorView>
</main>
</>
);
} else {
return (
<>
<main className={styles.main}>
<Heading size="xl" weight="semibold" className={styles.headline}>
{surveySubmitted
? t("call_ended_view.headline", {
displayName,
})
: t("call_ended_view.headline", {
displayName,
}) +
"\n" +
t("call_ended_view.survey_prompt")}
</Heading>
{(!surveySubmitted || confineToRoom) &&
PosthogAnalytics.instance.isEnabled()
? qualitySurveyDialog
: createAccountDialog}
</main>
{!confineToRoom && (
<Text className={styles.footer}>
<Link to="/"> {t("call_ended_view.not_now_button")} </Link>
</Text>
)}
</>
);
}
};
return ( return (
<> <>
<Header> <Header>
<LeftNav>{!confineToRoom && <HeaderLogo />}</LeftNav> <LeftNav>{!confineToRoom && <HeaderLogo />}</LeftNav>
<RightNav /> <RightNav />
</Header> </Header>
<div className={styles.container}>{renderBody()}</div> <div className={styles.container}>
<main className={styles.main}>
<Heading size="xl" weight="semibold" className={styles.headline}>
{surveySubmitted
? t("call_ended_view.headline", { displayName })
: t("call_ended_view.headline", { displayName }) +
"\n" +
t("call_ended_view.survey_prompt")}
</Heading>
{(!surveySubmitted || confineToRoom) &&
PosthogAnalytics.instance.isEnabled()
? qualitySurveyDialog
: createAccountDialog}
</main>
{!confineToRoom && (
<Text className={styles.footer}>
<Link to="/"> {t("call_ended_view.not_now_button")} </Link>
</Text>
)}
</div>
</> </>
); );
}; };

View File

@@ -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, waitFor } from "@testing-library/react"; import { render, waitFor, screen } 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";
@@ -14,6 +14,7 @@ import { JoinRule, type RoomState } from "matrix-js-sdk/src/matrix";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { type RelationsContainer } from "matrix-js-sdk/src/models/relations-container"; import { type RelationsContainer } from "matrix-js-sdk/src/models/relations-container";
import { useState } from "react";
import { type MuteStates } from "./MuteStates"; import { type MuteStates } from "./MuteStates";
import { prefetchSounds } from "../soundUtils"; import { prefetchSounds } from "../soundUtils";
@@ -184,3 +185,28 @@ test("will play a leave sound synchronously in widget mode", async () => {
); );
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce(); expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce();
}); });
test("GroupCallView leaves the session when an error occurs", async () => {
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(() => {
const [error, setError] = useState<Error | null>(null);
if (error !== null) throw error;
return (
<div>
<button onClick={() => setError(new Error())}>Panic!</button>
</div>
);
});
const user = userEvent.setup();
const { rtcSession } = createGroupCallView(null);
await user.click(screen.getByRole("button", { name: "Panic!" }));
screen.getByText("error.generic");
expect(leaveRTCSession).toHaveBeenCalledWith(
rtcSession,
"error",
expect.any(Promise),
);
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());
});

View File

@@ -5,7 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type FC, useCallback, useEffect, useMemo, useState } from "react"; import {
type FC,
type ReactElement,
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { type MatrixClient } from "matrix-js-sdk/src/client"; import { type MatrixClient } from "matrix-js-sdk/src/client";
import { import {
Room, Room,
@@ -14,9 +22,14 @@ import {
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { JoinRule } from "matrix-js-sdk/src/matrix"; import { JoinRule } from "matrix-js-sdk/src/matrix";
import { WebBrowserIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import {
OfflineIcon,
WebBrowserIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ErrorBoundary } from "@sentry/react";
import { Button } from "@vector-im/compound-web";
import type { IWidgetApiRequest } from "matrix-widget-api"; import type { IWidgetApiRequest } from "matrix-widget-api";
import { import {
@@ -24,14 +37,14 @@ import {
type JoinCallData, type JoinCallData,
type WidgetHelpers, type WidgetHelpers,
} from "../widget"; } from "../widget";
import { FullScreenView } from "../FullScreenView"; import { ErrorPage, FullScreenView } from "../FullScreenView";
import { LobbyView } from "./LobbyView"; import { LobbyView } from "./LobbyView";
import { type MatrixInfo } from "./VideoPreview"; import { type MatrixInfo } from "./VideoPreview";
import { CallEndedView } from "./CallEndedView"; import { CallEndedView } from "./CallEndedView";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { useProfile } from "../profile/useProfile"; import { useProfile } from "../profile/useProfile";
import { findDeviceByName } from "../utils/media"; import { findDeviceByName } from "../utils/media";
import { ActiveCall } from "./InCallView"; import { ActiveCall, ConnectionLostError } from "./InCallView";
import { MUTE_PARTICIPANT_COUNT, type MuteStates } from "./MuteStates"; import { MUTE_PARTICIPANT_COUNT, type MuteStates } from "./MuteStates";
import { useMediaDevices } from "../livekit/MediaDevicesContext"; import { useMediaDevices } from "../livekit/MediaDevicesContext";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
@@ -55,6 +68,11 @@ declare global {
} }
} }
interface GroupCallErrorPageProps {
error: Error | unknown;
resetError: () => void;
}
interface Props { interface Props {
client: MatrixClient; client: MatrixClient;
isPasswordlessUser: boolean; isPasswordlessUser: boolean;
@@ -229,16 +247,14 @@ export const GroupCallView: FC<Props> = ({
]); ]);
const [left, setLeft] = useState(false); const [left, setLeft] = useState(false);
const [leaveError, setLeaveError] = useState<Error | undefined>(undefined);
const navigate = useNavigate(); const navigate = useNavigate();
const onLeave = useCallback( const onLeave = useCallback(
(leaveError?: Error): void => { (cause: "user" | "error" = "user"): void => {
const audioPromise = leaveSoundContext.current?.playSound("left"); const audioPromise = leaveSoundContext.current?.playSound("left");
// In embedded/widget mode the iFrame will be killed right after the call ended prohibiting the posthog event from getting sent, // 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. // therefore we want the event to be sent instantly without getting queued/batched.
const sendInstantly = !!widget; const sendInstantly = !!widget;
setLeaveError(leaveError);
setLeft(true); setLeft(true);
// we need to wait until the callEnded event is tracked on posthog. // we need to wait until the callEnded event is tracked on posthog.
// Otherwise the iFrame gets killed before the callEnded event got tracked. // Otherwise the iFrame gets killed before the callEnded event got tracked.
@@ -254,7 +270,7 @@ export const GroupCallView: FC<Props> = ({
leaveRTCSession( leaveRTCSession(
rtcSession, rtcSession,
leaveError === undefined ? "user" : "error", cause,
// Wait for the sound in widget mode (it's not long) // Wait for the sound in widget mode (it's not long)
Promise.all([audioPromise, posthogRequest]), Promise.all([audioPromise, posthogRequest]),
) )
@@ -303,14 +319,6 @@ export const GroupCallView: FC<Props> = ({
} }
}, [widget, isJoined, rtcSession]); }, [widget, isJoined, rtcSession]);
const onReconnect = useCallback(() => {
setLeft(false);
setLeaveError(undefined);
enterRTCSession(rtcSession, perParticipantE2EE).catch((e) => {
logger.error("Error re-entering RTC session on reconnect", e);
});
}, [rtcSession, perParticipantE2EE]);
const joinRule = useJoinRule(rtcSession.room); const joinRule = useJoinRule(rtcSession.room);
const [shareModalOpen, setInviteModalOpen] = useState(false); const [shareModalOpen, setInviteModalOpen] = useState(false);
@@ -327,6 +335,43 @@ export const GroupCallView: FC<Props> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const errorPage = useMemo(() => {
function GroupCallErrorPage({
error,
resetError,
}: GroupCallErrorPageProps): ReactElement {
useEffect(() => {
if (rtcSession.isJoined()) onLeave("error");
}, [error]);
const onReconnect = useCallback(() => {
setLeft(false);
resetError();
enterRTCSession(rtcSession, perParticipantE2EE).catch((e) => {
logger.error("Error re-entering RTC session on reconnect", e);
});
}, [resetError]);
return error instanceof ConnectionLostError ? (
<FullScreenView>
<ErrorView
Icon={OfflineIcon}
title={t("error.connection_lost")}
rageshake
>
<p>{t("error.connection_lost_description")}</p>
<Button onClick={onReconnect}>
{t("call_ended_view.reconnect_button")}
</Button>
</ErrorView>
</FullScreenView>
) : (
<ErrorPage error={error} />
);
}
return GroupCallErrorPage;
}, [onLeave, rtcSession, perParticipantE2EE, t]);
if (!isE2EESupportedBrowser() && e2eeSystem.kind !== E2eeType.NONE) { if (!isE2EESupportedBrowser() && e2eeSystem.kind !== E2eeType.NONE) {
// If we have a encryption system but the browser does not support it. // If we have a encryption system but the browser does not support it.
return ( return (
@@ -361,8 +406,9 @@ export const GroupCallView: FC<Props> = ({
</> </>
); );
let body: ReactNode;
if (isJoined) { if (isJoined) {
return ( body = (
<> <>
{shareModal} {shareModal}
<ActiveCall <ActiveCall
@@ -390,36 +436,32 @@ export const GroupCallView: FC<Props> = ({
// submitting anything. // submitting anything.
if ( if (
isPasswordlessUser || isPasswordlessUser ||
(PosthogAnalytics.instance.isEnabled() && widget === null) || (PosthogAnalytics.instance.isEnabled() && widget === null)
leaveError
) { ) {
return ( body = (
<> <CallEndedView
<CallEndedView endedCallId={rtcSession.room.roomId}
endedCallId={rtcSession.room.roomId} client={client}
client={client} isPasswordlessUser={isPasswordlessUser}
isPasswordlessUser={isPasswordlessUser} confineToRoom={confineToRoom}
confineToRoom={confineToRoom} />
leaveError={leaveError}
reconnect={onReconnect}
/>
;
</>
); );
} else { } else {
// If the user is a regular user, we'll have sent them back to the homepage, // If the user is a regular user, we'll have sent them back to the homepage,
// so just sit here & do nothing: otherwise we would (briefly) mount the // so just sit here & do nothing: otherwise we would (briefly) mount the
// LobbyView again which would open capture devices again. // LobbyView again which would open capture devices again.
return null; body = null;
} }
} else if (left && widget !== null) { } else if (left && widget !== null) {
// Left in widget mode: // Left in widget mode:
if (!returnToLobby) { if (!returnToLobby) {
return null; body = null;
} }
} else if (preload || skipLobby) { } else if (preload || skipLobby) {
return null; body = null;
} else {
body = lobbyView;
} }
return lobbyView; return <ErrorBoundary fallback={errorPage}>{body}</ErrorBoundary>;
}; };

View File

@@ -102,6 +102,8 @@ const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
const maxTapDurationMs = 400; const maxTapDurationMs = 400;
export class ConnectionLostError extends Error {}
export interface ActiveCallProps export interface ActiveCallProps
extends Omit<InCallViewProps, "vm" | "livekitRoom" | "connState"> { extends Omit<InCallViewProps, "vm" | "livekitRoom" | "connState"> {
e2eeSystem: EncryptionSystem; e2eeSystem: EncryptionSystem;
@@ -173,7 +175,8 @@ export interface InCallViewProps {
livekitRoom: Room; livekitRoom: Room;
muteStates: MuteStates; muteStates: MuteStates;
participantCount: number; participantCount: number;
onLeave: (error?: Error) => void; /** Function to call when the user explicitly ends the call */
onLeave: () => void;
hideHeader: boolean; hideHeader: boolean;
otelGroupCallMembership?: OTelGroupCallMembership; otelGroupCallMembership?: OTelGroupCallMembership;
connState: ECConnectionState; connState: ECConnectionState;
@@ -198,13 +201,10 @@ export const InCallView: FC<InCallViewProps> = ({
useWakeLock(); useWakeLock();
useEffect(() => { // annoyingly we don't get the disconnection reason this way,
if (connState === ConnectionState.Disconnected) { // only by listening for the emitted event
// annoyingly we don't get the disconnection reason this way, if (connState === ConnectionState.Disconnected)
// only by listening for the emitted event throw new ConnectionLostError();
onLeave(new Error("Disconnected from call server"));
}
}, [connState, onLeave]);
const containerRef1 = useRef<HTMLDivElement | null>(null); const containerRef1 = useRef<HTMLDivElement | null>(null);
const [containerRef2, bounds] = useMeasure(); const [containerRef2, bounds] = useMeasure();