Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 2 commits into from
Feb 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 21 additions & 62 deletions src/room/CallEndedView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

import {
type FC,
type FormEventHandler,
type ReactNode,
useCallback,
useState,
} from "react";
import { type FC, type FormEventHandler, useCallback, useState } from "react";
import { type MatrixClient } from "matrix-js-sdk/src/client";
import { Trans, useTranslation } from "react-i18next";
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 { logger } from "matrix-js-sdk/src/logger";

Expand All @@ -28,24 +21,19 @@ import { FieldRow, InputField } from "../input/Input";
import { StarRatingInput } from "../input/StarRatingInput";
import { Link } from "../button/Link";
import { LinkButton } from "../button";
import { ErrorView } from "../ErrorView";

interface Props {
client: MatrixClient;
isPasswordlessUser: boolean;
confineToRoom: boolean;
endedCallId: string;
leaveError?: Error;
reconnect: () => void;
}

export const CallEndedView: FC<Props> = ({
client,
isPasswordlessUser,
confineToRoom,
endedCallId,
leaveError,
reconnect,
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
Expand Down Expand Up @@ -143,61 +131,32 @@ export const CallEndedView: FC<Props> = ({
</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 (
<>
<Header>
<LeftNav>{!confineToRoom && <HeaderLogo />}</LeftNav>
<RightNav />
</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>
</>
);
};
28 changes: 27 additions & 1 deletion src/room/GroupCallView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ Please see LICENSE in the repository root for full details.
*/

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 MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { of } from "rxjs";
import { JoinRule, type RoomState } from "matrix-js-sdk/src/matrix";
import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { type RelationsContainer } from "matrix-js-sdk/src/models/relations-container";
import { useState } from "react";

import { type MuteStates } from "./MuteStates";
import { prefetchSounds } from "../soundUtils";
Expand Down Expand Up @@ -184,3 +185,28 @@ test("will play a leave sound synchronously in widget mode", async () => {
);
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());
});
112 changes: 77 additions & 35 deletions src/room/GroupCallView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

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 {
Room,
Expand All @@ -14,24 +22,29 @@ import {
import { logger } from "matrix-js-sdk/src/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
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 { 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 {
ElementWidgetActions,
type JoinCallData,
type WidgetHelpers,
} from "../widget";
import { FullScreenView } from "../FullScreenView";
import { ErrorPage, FullScreenView } from "../FullScreenView";
import { LobbyView } from "./LobbyView";
import { type MatrixInfo } from "./VideoPreview";
import { CallEndedView } from "./CallEndedView";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { useProfile } from "../profile/useProfile";
import { findDeviceByName } from "../utils/media";
import { ActiveCall } from "./InCallView";
import { ActiveCall, ConnectionLostError } from "./InCallView";
import { MUTE_PARTICIPANT_COUNT, type MuteStates } from "./MuteStates";
import { useMediaDevices } from "../livekit/MediaDevicesContext";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
Expand All @@ -55,6 +68,11 @@ declare global {
}
}

interface GroupCallErrorPageProps {
error: Error | unknown;
resetError: () => void;
}

interface Props {
client: MatrixClient;
isPasswordlessUser: boolean;
Expand Down Expand Up @@ -229,16 +247,14 @@ export const GroupCallView: FC<Props> = ({
]);

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

const onLeave = useCallback(
(leaveError?: Error): void => {
(cause: "user" | "error" = "user"): void => {
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,
// therefore we want the event to be sent instantly without getting queued/batched.
const sendInstantly = !!widget;
setLeaveError(leaveError);
setLeft(true);
// we need to wait until the callEnded event is tracked on posthog.
// Otherwise the iFrame gets killed before the callEnded event got tracked.
Expand All @@ -254,7 +270,7 @@ export const GroupCallView: FC<Props> = ({

leaveRTCSession(
rtcSession,
leaveError === undefined ? "user" : "error",
cause,
// Wait for the sound in widget mode (it's not long)
Promise.all([audioPromise, posthogRequest]),
)
Expand Down Expand Up @@ -303,14 +319,6 @@ export const GroupCallView: FC<Props> = ({
}
}, [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 [shareModalOpen, setInviteModalOpen] = useState(false);
Expand All @@ -327,6 +335,43 @@ export const GroupCallView: FC<Props> = ({

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 we have a encryption system but the browser does not support it.
return (
Expand Down Expand Up @@ -361,8 +406,9 @@ export const GroupCallView: FC<Props> = ({
</>
);

let body: ReactNode;
if (isJoined) {
return (
body = (
<>
{shareModal}
<ActiveCall
Expand Down Expand Up @@ -390,36 +436,32 @@ export const GroupCallView: FC<Props> = ({
// submitting anything.
if (
isPasswordlessUser ||
(PosthogAnalytics.instance.isEnabled() && widget === null) ||
leaveError
(PosthogAnalytics.instance.isEnabled() && widget === null)
) {
return (
<>
<CallEndedView
endedCallId={rtcSession.room.roomId}
client={client}
isPasswordlessUser={isPasswordlessUser}
confineToRoom={confineToRoom}
leaveError={leaveError}
reconnect={onReconnect}
/>
;
</>
body = (
<CallEndedView
endedCallId={rtcSession.room.roomId}
client={client}
isPasswordlessUser={isPasswordlessUser}
confineToRoom={confineToRoom}
/>
);
} else {
// 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
// LobbyView again which would open capture devices again.
return null;
body = null;
}
} else if (left && widget !== null) {
// Left in widget mode:
if (!returnToLobby) {
return null;
body = null;
}
} else if (preload || skipLobby) {
return null;
body = null;
} else {
body = lobbyView;
}

return lobbyView;
return <ErrorBoundary fallback={errorPage}>{body}</ErrorBoundary>;
};
Loading