Backport: close button instead of go to home when in widget mode (#3087)

* Merge pull request #3058 from element-hq/valere/refactor_error_handling

refactor: Centralize group call errors in custom GroupCallErrorBoundary

* Merge pull request #3063 from element-hq/valere/fix_add_close_button_in_widget_mode

Error: use a close button instead of go to home when in widget mode
This commit is contained in:
Valere Fedronic
2025-03-13 10:47:39 +01:00
committed by GitHub
parent ca3f1d994d
commit d532f478ef
14 changed files with 1626 additions and 179 deletions

View File

@@ -72,7 +72,11 @@ export const App: FC = () => {
<Suspense fallback={null}> <Suspense fallback={null}>
<ClientProvider> <ClientProvider>
<MediaDevicesProvider> <MediaDevicesProvider>
<Sentry.ErrorBoundary fallback={ErrorPage}> <Sentry.ErrorBoundary
fallback={(error) => (
<ErrorPage error={error} widget={widget} />
)}
>
<DisconnectedBanner /> <DisconnectedBanner />
<Routes> <Routes>
<SentryRoute path="/" element={<HomePage />} /> <SentryRoute path="/" element={<HomePage />} />

View File

@@ -351,7 +351,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
}, [initClientState, onSync]); }, [initClientState, onSync]);
if (alreadyOpenedErr) { if (alreadyOpenedErr) {
return <ErrorPage error={alreadyOpenedErr} />; return <ErrorPage widget={widget} error={alreadyOpenedErr} />;
} }
return ( return (

View File

@@ -12,13 +12,16 @@ import {
type FC, type FC,
type ReactNode, type ReactNode,
type SVGAttributes, type SVGAttributes,
type ReactElement,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/src/logger";
import { RageshakeButton } from "./settings/RageshakeButton"; import { RageshakeButton } from "./settings/RageshakeButton";
import styles from "./ErrorView.module.css"; import styles from "./ErrorView.module.css";
import { useUrlParams } from "./UrlParams"; import { useUrlParams } from "./UrlParams";
import { LinkButton } from "./button"; import { LinkButton } from "./button";
import { ElementWidgetActions, type WidgetHelpers } from "./widget.ts";
interface Props { interface Props {
Icon: ComponentType<SVGAttributes<SVGElement>>; Icon: ComponentType<SVGAttributes<SVGElement>>;
@@ -35,6 +38,7 @@ interface Props {
*/ */
fatal?: boolean; fatal?: boolean;
children: ReactNode; children: ReactNode;
widget: WidgetHelpers | null;
} }
export const ErrorView: FC<Props> = ({ export const ErrorView: FC<Props> = ({
@@ -43,6 +47,7 @@ export const ErrorView: FC<Props> = ({
rageshake, rageshake,
fatal, fatal,
children, children,
widget,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { confineToRoom } = useUrlParams(); const { confineToRoom } = useUrlParams();
@@ -51,6 +56,46 @@ export const ErrorView: FC<Props> = ({
window.location.href = "/"; window.location.href = "/";
}, []); }, []);
const CloseWidgetButton: FC<{ widget: WidgetHelpers }> = ({
widget,
}): ReactElement => {
// in widget mode we don't want to show the return home button but a close button
const closeWidget = (): void => {
widget.api.transport
.send(ElementWidgetActions.Close, {})
.catch((e) => {
// What to do here?
logger.error("Failed to send close action", e);
})
.finally(() => {
widget.api.transport.stop();
});
};
return (
<Button kind="primary" onClick={closeWidget}>
{t("action.close")}
</Button>
);
};
// Whether the error is considered fatal or pathname is `/` then reload the all app.
// If not then navigate to home page.
const ReturnToHomeButton = (): ReactElement => {
if (fatal || location.pathname === "/") {
return (
<Button kind="tertiary" className={styles.homeLink} onClick={onReload}>
{t("return_home_button")}
</Button>
);
} else {
return (
<LinkButton kind="tertiary" className={styles.homeLink} to="/">
{t("return_home_button")}
</LinkButton>
);
}
};
return ( return (
<div className={styles.error}> <div className={styles.error}>
<BigIcon className={styles.icon}> <BigIcon className={styles.icon}>
@@ -63,20 +108,11 @@ export const ErrorView: FC<Props> = ({
{rageshake && ( {rageshake && (
<RageshakeButton description={`***Error View***: ${title}`} /> <RageshakeButton description={`***Error View***: ${title}`} />
)} )}
{!confineToRoom && {widget ? (
(fatal || location.pathname === "/" ? ( <CloseWidgetButton widget={widget} />
<Button ) : (
kind="tertiary" !confineToRoom && <ReturnToHomeButton />
className={styles.homeLink} )}
onClick={onReload}
>
{t("return_home_button")}
</Button>
) : (
<LinkButton kind="tertiary" className={styles.homeLink} to="/">
{t("return_home_button")}
</LinkButton>
))}
</div> </div>
); );
}; };

View File

@@ -17,6 +17,7 @@ import styles from "./FullScreenView.module.css";
import { useUrlParams } from "./UrlParams"; import { useUrlParams } from "./UrlParams";
import { RichError } from "./RichError"; import { RichError } from "./RichError";
import { ErrorView } from "./ErrorView"; import { ErrorView } from "./ErrorView";
import { type WidgetHelpers } from "./widget.ts";
interface FullScreenViewProps { interface FullScreenViewProps {
className?: string; className?: string;
@@ -47,11 +48,12 @@ export const FullScreenView: FC<FullScreenViewProps> = ({
interface ErrorPageProps { interface ErrorPageProps {
error: Error | unknown; error: Error | unknown;
widget: WidgetHelpers | null;
} }
// Due to this component being used as the crash fallback for Sentry, which has // Due to this component being used as the crash fallback for Sentry, which has
// weird type requirements, we can't just give this a type of FC<ErrorPageProps> // weird type requirements, we can't just give this a type of FC<ErrorPageProps>
export const ErrorPage = ({ error }: ErrorPageProps): ReactElement => { export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => {
const { t } = useTranslation(); const { t } = useTranslation();
useEffect(() => { useEffect(() => {
logger.error(error); logger.error(error);
@@ -63,7 +65,13 @@ export const ErrorPage = ({ error }: ErrorPageProps): ReactElement => {
{error instanceof RichError ? ( {error instanceof RichError ? (
error.richMessage error.richMessage
) : ( ) : (
<ErrorView Icon={ErrorIcon} title={t("error.generic")} rageshake fatal> <ErrorView
widget={widget}
Icon={ErrorIcon}
title={t("error.generic")}
rageshake
fatal
>
<p>{t("error.generic_description")}</p> <p>{t("error.generic_description")}</p>
</ErrorView> </ErrorView>
)} )}

View File

@@ -5,16 +5,12 @@ 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 { Trans, useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
ErrorIcon,
HostIcon,
PopOutIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import type { ComponentType, FC, ReactNode, SVGAttributes } from "react"; import type { FC, ReactNode } from "react";
import { ErrorView } from "./ErrorView"; import { ErrorView } from "./ErrorView";
import { type ElementCallError, ErrorCategory } from "./utils/errors.ts"; import { widget } from "./widget.ts";
/** /**
* An error consisting of a terse message to be logged to the console and a * An error consisting of a terse message to be logged to the console and a
@@ -36,7 +32,11 @@ const OpenElsewhere: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<ErrorView Icon={PopOutIcon} title={t("error.open_elsewhere")}> <ErrorView
widget={widget}
Icon={PopOutIcon}
title={t("error.open_elsewhere")}
>
<p> <p>
{t("error.open_elsewhere_description", { {t("error.open_elsewhere_description", {
brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call", brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
@@ -51,62 +51,3 @@ export class OpenElsewhereError extends RichError {
super("App opened in another tab", <OpenElsewhere />); super("App opened in another tab", <OpenElsewhere />);
} }
} }
const InsufficientCapacity: FC = () => {
const { t } = useTranslation();
return (
<ErrorView Icon={HostIcon} title={t("error.insufficient_capacity")}>
<p>{t("error.insufficient_capacity_description")}</p>
</ErrorView>
);
};
export class InsufficientCapacityError extends RichError {
public constructor() {
super("Insufficient server capacity", <InsufficientCapacity />);
}
}
type ECErrorProps = {
error: ElementCallError;
};
const GenericECError: FC<{ error: ElementCallError }> = ({
error,
}: ECErrorProps) => {
const { t } = useTranslation();
let title: string;
let icon: ComponentType<SVGAttributes<SVGElement>>;
switch (error.category) {
case ErrorCategory.CONFIGURATION_ISSUE:
title = t("error.call_is_not_supported");
icon = HostIcon;
break;
default:
title = t("error.generic");
icon = ErrorIcon;
}
return (
<ErrorView Icon={icon} title={title}>
<p>
{error.localisedMessage ?? (
<Trans
i18nKey="error.unexpected_ec_error"
components={[<b />, <code />]}
values={{ errorCode: error.code }}
/>
)}
</p>
</ErrorView>
);
};
export class ElementCallRichError extends RichError {
public ecError: ElementCallError;
public constructor(ecError: ElementCallError) {
super(ecError.message, <GenericECError error={ecError} />);
this.ecError = ecError;
}
}

View File

@@ -13,6 +13,7 @@ import { ErrorPage, LoadingPage } from "../FullScreenView";
import { UnauthenticatedView } from "./UnauthenticatedView"; import { UnauthenticatedView } from "./UnauthenticatedView";
import { RegisteredView } from "./RegisteredView"; import { RegisteredView } from "./RegisteredView";
import { usePageTitle } from "../usePageTitle"; import { usePageTitle } from "../usePageTitle";
import { widget } from "../widget.ts";
export const HomePage: FC = () => { export const HomePage: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -23,7 +24,7 @@ export const HomePage: FC = () => {
if (!clientState) { if (!clientState) {
return <LoadingPage />; return <LoadingPage />;
} else if (clientState.state === "error") { } else if (clientState.state === "error") {
return <ErrorPage error={clientState.error} />; return <ErrorPage widget={widget} error={clientState.error} />;
} else { } else {
return clientState.authenticated ? ( return clientState.authenticated ? (
<RegisteredView client={clientState.authenticated.client} /> <RegisteredView client={clientState.authenticated.client} />

View File

@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import { type FC, useCallback, useState } from "react"; import { type FC, useCallback, useState } from "react";
import { test } from "vitest"; import { test, vi } from "vitest";
import { import {
ConnectionError, ConnectionError,
ConnectionErrorReason, ConnectionErrorReason,
@@ -14,12 +14,11 @@ import {
} from "livekit-client"; } from "livekit-client";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "@sentry/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { ErrorPage } from "../FullScreenView";
import { useECConnectionState } from "./useECConnectionState"; import { useECConnectionState } from "./useECConnectionState";
import { type SFUConfig } from "./openIDSFU"; import { type SFUConfig } from "./openIDSFU";
import { GroupCallErrorBoundary } from "../room/GroupCallErrorBoundary.tsx";
test.each<[string, ConnectionError]>([ test.each<[string, ConnectionError]>([
[ [
@@ -61,9 +60,9 @@ test.each<[string, ConnectionError]>([
const user = userEvent.setup(); const user = userEvent.setup();
render( render(
<MemoryRouter> <MemoryRouter>
<ErrorBoundary fallback={ErrorPage}> <GroupCallErrorBoundary recoveryActionHandler={vi.fn()} widget={null}>
<TestComponent /> <TestComponent />
</ErrorBoundary> </GroupCallErrorBoundary>
</MemoryRouter>, </MemoryRouter>,
); );
await user.click(screen.getByRole("button", { name: "Connect" })); await user.click(screen.getByRole("button", { name: "Connect" }));

View File

@@ -20,7 +20,11 @@ import * as Sentry from "@sentry/react";
import { type SFUConfig, sfuConfigEquals } from "./openIDSFU"; import { type SFUConfig, sfuConfigEquals } from "./openIDSFU";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { InsufficientCapacityError, RichError } from "../RichError"; import {
ElementCallError,
InsufficientCapacityError,
UnknownCallError,
} from "../utils/errors.ts";
declare global { declare global {
interface Window { interface Window {
@@ -188,7 +192,7 @@ export function useECConnectionState(
const [isSwitchingFocus, setSwitchingFocus] = useState(false); const [isSwitchingFocus, setSwitchingFocus] = useState(false);
const [isInDoConnect, setIsInDoConnect] = useState(false); const [isInDoConnect, setIsInDoConnect] = useState(false);
const [error, setError] = useState<RichError | null>(null); const [error, setError] = useState<ElementCallError | null>(null);
if (error !== null) throw error; if (error !== null) throw error;
const onConnStateChanged = useCallback((state: ConnectionState) => { const onConnStateChanged = useCallback((state: ConnectionState) => {
@@ -271,9 +275,11 @@ export function useECConnectionState(
initialAudioOptions, initialAudioOptions,
) )
.catch((e) => { .catch((e) => {
if (e instanceof RichError) if (e instanceof ElementCallError) {
setError(e); // Bubble up any error screens to React setError(e); // Bubble up any error screens to React
else logger.error("Failed to connect to SFU", e); } else if (e instanceof Error) {
setError(new UnknownCallError(e));
} else logger.error("Failed to connect to SFU", e);
}) })
.finally(() => setIsInDoConnect(false)); .finally(() => setIsInDoConnect(false));
} }

View File

@@ -0,0 +1,253 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import {
type FC,
type ReactElement,
type ReactNode,
useCallback,
useState,
} from "react";
import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import {
type CallErrorRecoveryAction,
GroupCallErrorBoundary,
} from "./GroupCallErrorBoundary.tsx";
import {
ConnectionLostError,
E2EENotSupportedError,
type ElementCallError,
InsufficientCapacityError,
MatrixRTCFocusMissingError,
UnknownCallError,
} from "../utils/errors.ts";
import { mockConfig } from "../utils/test.ts";
import { ElementWidgetActions, type WidgetHelpers } from "../widget.ts";
test.each([
{
error: new MatrixRTCFocusMissingError("example.com"),
expectedTitle: "Call is not supported",
},
{
error: new ConnectionLostError(),
expectedTitle: "Connection lost",
expectedDescription: "You were disconnected from the call.",
},
{
error: new E2EENotSupportedError(),
expectedTitle: "Incompatible browser",
expectedDescription:
"Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.",
},
{
error: new InsufficientCapacityError(),
expectedTitle: "Insufficient capacity",
expectedDescription:
"The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
},
])(
"should report correct error for $expectedTitle",
async ({ error, expectedTitle, expectedDescription }) => {
const TestComponent = (): ReactNode => {
throw error;
};
const onErrorMock = vi.fn();
const { asFragment } = render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={onErrorMock}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
await screen.findByText(expectedTitle);
if (expectedDescription) {
expect(screen.queryByText(expectedDescription)).toBeInTheDocument();
}
expect(onErrorMock).toHaveBeenCalledWith(error);
expect(asFragment()).toMatchSnapshot();
},
);
test("should render the error page with link back to home", async () => {
const error = new MatrixRTCFocusMissingError("example.com");
const TestComponent = (): ReactNode => {
throw error;
};
const onErrorMock = vi.fn();
const { asFragment } = render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={onErrorMock}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
await screen.findByText("Call is not supported");
expect(screen.getByText(/Domain: example\.com/i)).toBeInTheDocument();
expect(
screen.getByText(/Error Code: MISSING_MATRIX_RTC_FOCUS/i),
).toBeInTheDocument();
await screen.findByRole("button", { name: "Return to home screen" });
expect(onErrorMock).toHaveBeenCalledOnce();
expect(onErrorMock).toHaveBeenCalledWith(error);
expect(asFragment()).toMatchSnapshot();
});
test("ConnectionLostError: Action handling should reset error state", async () => {
const user = userEvent.setup();
const TestComponent: FC<{ fail: boolean }> = ({ fail }): ReactNode => {
if (fail) {
throw new ConnectionLostError();
}
return <div>HELLO</div>;
};
const reconnectCallbackSpy = vi.fn();
const WrapComponent = (): ReactNode => {
const [failState, setFailState] = useState(true);
const reconnectCallback = useCallback(
(action: CallErrorRecoveryAction) => {
reconnectCallbackSpy(action);
setFailState(false);
},
[setFailState],
);
return (
<BrowserRouter>
<GroupCallErrorBoundary
recoveryActionHandler={reconnectCallback}
widget={null}
>
<TestComponent fail={failState} />
</GroupCallErrorBoundary>
</BrowserRouter>
);
};
const { asFragment } = render(<WrapComponent />);
// Should fail first
await screen.findByText("Connection lost");
await screen.findByRole("button", { name: "Reconnect" });
await screen.findByRole("button", { name: "Return to home screen" });
expect(asFragment()).toMatchSnapshot();
await user.click(screen.getByRole("button", { name: "Reconnect" }));
// reconnect should have reset the error, thus rendering should be ok
await screen.findByText("HELLO");
expect(reconnectCallbackSpy).toHaveBeenCalledOnce();
expect(reconnectCallbackSpy).toHaveBeenCalledWith("reconnect");
});
describe("Rageshake button", () => {
function setupTest(testError: ElementCallError): void {
mockConfig({
rageshake: {
submit_url: "https://rageshake.example.com.localhost",
},
});
const TestComponent = (): ReactElement => {
throw testError;
};
render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
}
test("should show send rageshake button for unknown errors", () => {
setupTest(new UnknownCallError(new Error("FOO")));
expect(
screen.queryByRole("button", { name: "Send debug logs" }),
).toBeInTheDocument();
});
test("should not show send rageshake button for call errors", () => {
setupTest(new E2EENotSupportedError());
expect(
screen.queryByRole("button", { name: "Send debug logs" }),
).not.toBeInTheDocument();
});
});
test("should have a close button in widget mode", async () => {
const error = new MatrixRTCFocusMissingError("example.com");
const TestComponent = (): ReactNode => {
throw error;
};
const mockWidget = {
api: {
transport: { send: vi.fn().mockResolvedValue(undefined), stop: vi.fn() },
},
} as unknown as WidgetHelpers;
const user = userEvent.setup();
const onErrorMock = vi.fn();
const { asFragment } = render(
<BrowserRouter>
<GroupCallErrorBoundary
widget={mockWidget}
onError={onErrorMock}
recoveryActionHandler={vi.fn()}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
await screen.findByText("Call is not supported");
await screen.findByRole("button", { name: "Close" });
expect(asFragment()).toMatchSnapshot();
await user.click(screen.getByRole("button", { name: "Close" }));
expect(mockWidget.api.transport.send).toHaveBeenCalledWith(
ElementWidgetActions.Close,
expect.anything(),
);
expect(mockWidget.api.transport.stop).toHaveBeenCalled();
});

View File

@@ -0,0 +1,146 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { ErrorBoundary, type FallbackRender } from "@sentry/react";
import {
type ComponentType,
type FC,
type ReactElement,
type ReactNode,
type SVGAttributes,
useCallback,
} from "react";
import { Trans, useTranslation } from "react-i18next";
import {
ErrorIcon,
HostIcon,
OfflineIcon,
WebBrowserIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import {
ConnectionLostError,
ElementCallError,
ErrorCategory,
ErrorCode,
UnknownCallError,
} from "../utils/errors.ts";
import { FullScreenView } from "../FullScreenView.tsx";
import { ErrorView } from "../ErrorView.tsx";
import { type WidgetHelpers } from "../widget.ts";
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
export type RecoveryActionHandler = (action: CallErrorRecoveryAction) => void;
interface ErrorPageProps {
error: ElementCallError;
recoveryActionHandler: RecoveryActionHandler;
resetError: () => void;
widget: WidgetHelpers | null;
}
const ErrorPage: FC<ErrorPageProps> = ({
error,
recoveryActionHandler,
widget,
}: ErrorPageProps): ReactElement => {
const { t } = useTranslation();
let icon: ComponentType<SVGAttributes<SVGElement>>;
switch (error.category) {
case ErrorCategory.CONFIGURATION_ISSUE:
icon = HostIcon;
break;
case ErrorCategory.NETWORK_CONNECTIVITY:
icon = OfflineIcon;
break;
case ErrorCategory.CLIENT_CONFIGURATION:
icon = WebBrowserIcon;
break;
default:
icon = ErrorIcon;
}
const actions: { label: string; onClick: () => void }[] = [];
if (error instanceof ConnectionLostError) {
actions.push({
label: t("call_ended_view.reconnect_button"),
onClick: () => recoveryActionHandler("reconnect"),
});
}
return (
<FullScreenView>
<ErrorView
Icon={icon}
title={error.localisedTitle}
rageshake={error.code == ErrorCode.UNKNOWN_ERROR}
widget={widget}
>
<p>
{error.localisedMessage ?? (
<Trans
i18nKey="error.unexpected_ec_error"
components={[<b />, <code />]}
values={{ errorCode: error.code }}
/>
)}
</p>
{actions &&
actions.map((action, index) => (
<button onClick={action.onClick} key={`action${index}`}>
{action.label}
</button>
))}
</ErrorView>
</FullScreenView>
);
};
interface BoundaryProps {
children: ReactNode | (() => ReactNode);
recoveryActionHandler: RecoveryActionHandler;
onError?: (error: unknown) => void;
widget: WidgetHelpers | null;
}
export const GroupCallErrorBoundary = ({
recoveryActionHandler,
onError,
children,
widget,
}: BoundaryProps): ReactElement => {
const fallbackRenderer: FallbackRender = useCallback(
({ error, resetError }): ReactElement => {
const callError =
error instanceof ElementCallError
? error
: new UnknownCallError(error instanceof Error ? error : new Error());
return (
<ErrorPage
widget={widget ?? null}
error={callError}
resetError={resetError}
recoveryActionHandler={(action: CallErrorRecoveryAction) => {
resetError();
recoveryActionHandler(action);
}}
/>
);
},
[recoveryActionHandler, widget],
);
return (
<ErrorBoundary
fallback={fallbackRenderer}
onError={(error) => onError?.(error)}
children={children}
/>
);
};

View File

@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
import { import {
type FC, type FC,
type ReactElement,
type ReactNode, type ReactNode,
useCallback, useCallback,
useEffect, useEffect,
@@ -25,14 +24,7 @@ import {
type MatrixRTCSession, type MatrixRTCSession,
} from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { JoinRule, type Room } from "matrix-js-sdk/src/matrix"; import { JoinRule, type Room } from "matrix-js-sdk/src/matrix";
import {
OfflineIcon,
WebBrowserIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
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 {
@@ -40,7 +32,6 @@ import {
type JoinCallData, type JoinCallData,
type WidgetHelpers, type WidgetHelpers,
} from "../widget"; } from "../widget";
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";
@@ -63,15 +54,14 @@ import { useAudioContext } from "../useAudioContext";
import { callEventAudioSounds } from "./CallEventAudioRenderer"; import { callEventAudioSounds } from "./CallEventAudioRenderer";
import { useLatest } from "../useLatest"; import { useLatest } from "../useLatest";
import { usePageTitle } from "../usePageTitle"; import { usePageTitle } from "../usePageTitle";
import { ErrorView } from "../ErrorView";
import { import {
ConnectionLostError, E2EENotSupportedError,
ElementCallError, ElementCallError,
ErrorCategory,
ErrorCode, ErrorCode,
RTCSessionError, RTCSessionError,
UnknownCallError,
} from "../utils/errors.ts"; } from "../utils/errors.ts";
import { ElementCallRichError } from "../RichError.tsx"; import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
import { import {
useNewMembershipManagerSetting as useNewMembershipManagerSetting, useNewMembershipManagerSetting as useNewMembershipManagerSetting,
useSetting, useSetting,
@@ -84,11 +74,6 @@ declare global {
} }
} }
interface GroupCallErrorPageProps {
error: Error | unknown;
resetError: () => void;
}
interface Props { interface Props {
client: MatrixClient; client: MatrixClient;
isPasswordlessUser: boolean; isPasswordlessUser: boolean;
@@ -205,10 +190,8 @@ export const GroupCallView: FC<Props> = ({
setError(e); setError(e);
} else { } else {
logger.error(`Unknown Error while entering RTC session`, e); logger.error(`Unknown Error while entering RTC session`, e);
const error = new ElementCallError( const error = new UnknownCallError(
e instanceof Error ? e.message : "Unknown error", e instanceof Error ? e : new Error("Unknown error", { cause: e }),
ErrorCode.UNKNOWN_ERROR,
ErrorCategory.UNKNOWN,
); );
setError(error); setError(error);
} }
@@ -398,58 +381,9 @@ export const GroupCallView: FC<Props> = ({
); );
const onShareClick = joinRule === JoinRule.Public ? onShareClickFn : null; const onShareClick = joinRule === JoinRule.Public ? onShareClickFn : null;
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();
enterRTCSessionOrError(
rtcSession,
perParticipantE2EE,
useNewMembershipManager,
).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;
}, [t, rtcSession, onLeave, perParticipantE2EE, useNewMembershipManager]);
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 ( throw new E2EENotSupportedError();
<FullScreenView>
<ErrorView Icon={WebBrowserIcon} title={t("error.e2ee_unsupported")}>
<p>{t("error.e2ee_unsupported_description")}</p>
</ErrorView>
</FullScreenView>
);
} }
const shareModal = ( const shareModal = (
@@ -484,9 +418,9 @@ export const GroupCallView: FC<Props> = ({
let body: ReactNode; let body: ReactNode;
if (error) { if (error) {
// If an ElementCallError was recorded, then create a component that will fail to render and throw // If an ElementCallError was recorded, then create a component that will fail to render and throw
// an ElementCallRichError error. This will then be handled by the ErrorBoundary component. // the error. This will then be handled by the ErrorBoundary component.
const ErrorComponent = (): ReactNode => { const ErrorComponent = (): ReactNode => {
throw new ElementCallRichError(error); throw error;
}; };
body = <ErrorComponent />; body = <ErrorComponent />;
} else if (isJoined) { } else if (isJoined) {
@@ -543,5 +477,28 @@ export const GroupCallView: FC<Props> = ({
body = lobbyView; body = lobbyView;
} }
return <ErrorBoundary fallback={errorPage}>{body}</ErrorBoundary>; return (
<GroupCallErrorBoundary
widget={widget}
recoveryActionHandler={(action) => {
if (action == "reconnect") {
setLeft(false);
enterRTCSessionOrError(
rtcSession,
perParticipantE2EE,
useNewMembershipManager,
).catch((e) => {
logger.error("Error re-entering RTC session", e);
});
}
}}
onError={
(/**error*/) => {
if (rtcSession.isJoined()) onLeave("error");
}
}
>
{body}
</GroupCallErrorBoundary>
);
}; };

View File

@@ -182,6 +182,7 @@ export const RoomPage: FC = () => {
<ErrorView <ErrorView
Icon={UnknownSolidIcon} Icon={UnknownSolidIcon}
title={t("error.call_not_found")} title={t("error.call_not_found")}
widget={widget}
> >
<Trans i18nKey="error.call_not_found_description"> <Trans i18nKey="error.call_not_found_description">
<p> <p>
@@ -199,6 +200,7 @@ export const RoomPage: FC = () => {
<ErrorView <ErrorView
Icon={groupCallState.error.icon} Icon={groupCallState.error.icon}
title={groupCallState.error.message} title={groupCallState.error.message}
widget={widget}
> >
<p>{groupCallState.error.messageBody}</p> <p>{groupCallState.error.messageBody}</p>
{groupCallState.error.reason && ( {groupCallState.error.reason && (
@@ -212,7 +214,7 @@ export const RoomPage: FC = () => {
</FullScreenView> </FullScreenView>
); );
} else { } else {
return <ErrorPage error={groupCallState.error} />; return <ErrorPage widget={widget} error={groupCallState.error} />;
} }
default: default:
return <> </>; return <> </>;
@@ -223,7 +225,7 @@ export const RoomPage: FC = () => {
if (loading || isRegistering) { if (loading || isRegistering) {
content = <LoadingPage />; content = <LoadingPage />;
} else if (error) { } else if (error) {
content = <ErrorPage error={error} />; content = <ErrorPage widget={widget} error={error} />;
} else if (!client) { } else if (!client) {
content = <RoomAuthView />; content = <RoomAuthView />;
} else if (!roomIdOrAlias) { } else if (!roomIdOrAlias) {

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,9 @@ export enum ErrorCode {
MISSING_MATRIX_RTC_FOCUS = "MISSING_MATRIX_RTC_FOCUS", MISSING_MATRIX_RTC_FOCUS = "MISSING_MATRIX_RTC_FOCUS",
CONNECTION_LOST_ERROR = "CONNECTION_LOST_ERROR", CONNECTION_LOST_ERROR = "CONNECTION_LOST_ERROR",
MEMBERSHIP_MANAGER_UNRECOVERABLE = "MEMBERSHIP_MANAGER_UNRECOVERABLE", MEMBERSHIP_MANAGER_UNRECOVERABLE = "MEMBERSHIP_MANAGER_UNRECOVERABLE",
/** LiveKit indicates that the server has hit its track limits */
INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR",
E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED",
UNKNOWN_ERROR = "UNKNOWN_ERROR", UNKNOWN_ERROR = "UNKNOWN_ERROR",
} }
@@ -22,6 +25,7 @@ export enum ErrorCategory {
CONFIGURATION_ISSUE = "CONFIGURATION_ISSUE", CONFIGURATION_ISSUE = "CONFIGURATION_ISSUE",
NETWORK_CONNECTIVITY = "NETWORK_CONNECTIVITY", NETWORK_CONNECTIVITY = "NETWORK_CONNECTIVITY",
RTC_SESSION_FAILURE = "RTC_SESSION_FAILURE", RTC_SESSION_FAILURE = "RTC_SESSION_FAILURE",
CLIENT_CONFIGURATION = "CLIENT_CONFIGURATION",
UNKNOWN = "UNKNOWN", UNKNOWN = "UNKNOWN",
// SYSTEM_FAILURE / FEDERATION_FAILURE .. // SYSTEM_FAILURE / FEDERATION_FAILURE ..
} }
@@ -33,14 +37,17 @@ export class ElementCallError extends Error {
public code: ErrorCode; public code: ErrorCode;
public category: ErrorCategory; public category: ErrorCategory;
public localisedMessage?: string; public localisedMessage?: string;
public localisedTitle: string;
public constructor( protected constructor(
name: string, localisedTitle: string,
code: ErrorCode, code: ErrorCode,
category: ErrorCategory, category: ErrorCategory,
localisedMessage?: string, localisedMessage: string,
cause?: Error,
) { ) {
super(name); super(localisedTitle, { cause });
this.localisedTitle = localisedTitle;
this.localisedMessage = localisedMessage; this.localisedMessage = localisedMessage;
this.category = category; this.category = category;
this.code = code; this.code = code;
@@ -52,7 +59,7 @@ export class MatrixRTCFocusMissingError extends ElementCallError {
public constructor(domain: string) { public constructor(domain: string) {
super( super(
"MatrixRTCFocusMissingError", t("error.call_is_not_supported"),
ErrorCode.MISSING_MATRIX_RTC_FOCUS, ErrorCode.MISSING_MATRIX_RTC_FOCUS,
ErrorCategory.CONFIGURATION_ISSUE, ErrorCategory.CONFIGURATION_ISSUE,
t("error.matrix_rtc_focus_missing", { t("error.matrix_rtc_focus_missing", {
@@ -68,9 +75,10 @@ export class MatrixRTCFocusMissingError extends ElementCallError {
export class ConnectionLostError extends ElementCallError { export class ConnectionLostError extends ElementCallError {
public constructor() { public constructor() {
super( super(
"Connection lost", t("error.connection_lost"),
ErrorCode.CONNECTION_LOST_ERROR, ErrorCode.CONNECTION_LOST_ERROR,
ErrorCategory.NETWORK_CONNECTIVITY, ErrorCategory.NETWORK_CONNECTIVITY,
t("error.connection_lost_description"),
); );
} }
} }
@@ -80,3 +88,38 @@ export class RTCSessionError extends ElementCallError {
super("RTCSession Error", code, ErrorCategory.RTC_SESSION_FAILURE, message); super("RTCSession Error", code, ErrorCategory.RTC_SESSION_FAILURE, message);
} }
} }
export class E2EENotSupportedError extends ElementCallError {
public constructor() {
super(
t("error.e2ee_unsupported"),
ErrorCode.E2EE_NOT_SUPPORTED,
ErrorCategory.CLIENT_CONFIGURATION,
t("error.e2ee_unsupported_description"),
);
}
}
export class UnknownCallError extends ElementCallError {
public constructor(error: Error) {
super(
t("error.generic"),
ErrorCode.UNKNOWN_ERROR,
ErrorCategory.UNKNOWN,
error.message,
// Properly set it as a cause for a better reporting on sentry
error,
);
}
}
export class InsufficientCapacityError extends ElementCallError {
public constructor() {
super(
t("error.insufficient_capacity"),
ErrorCode.INSUFFICIENT_CAPACITY_ERROR,
ErrorCategory.UNKNOWN,
t("error.insufficient_capacity_description"),
);
}
}