refactor: Centralize group call errors in custom GroupCallErrorBoundary
This commit is contained in:
@@ -5,16 +5,11 @@ 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";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -51,62 +46,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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
<TestComponent />
|
<TestComponent />
|
||||||
</ErrorBoundary>
|
</GroupCallErrorBoundary>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
await user.click(screen.getByRole("button", { name: "Connect" }));
|
await user.click(screen.getByRole("button", { name: "Connect" }));
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
170
src/room/GroupCallErrorBoundary.test.tsx
Normal file
170
src/room/GroupCallErrorBoundary.test.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/*
|
||||||
|
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 ReactElement, type ReactNode } from "react";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
|
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
|
||||||
|
import {
|
||||||
|
ConnectionLostError,
|
||||||
|
E2EENotSupportedError,
|
||||||
|
type ElementCallError,
|
||||||
|
InsufficientCapacityError,
|
||||||
|
MatrixRTCFocusMissingError,
|
||||||
|
UnknownCallError,
|
||||||
|
} from "../utils/errors.ts";
|
||||||
|
import { mockConfig } from "../utils/test.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}>
|
||||||
|
<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}>
|
||||||
|
<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("should have a reconnect button for ConnectionLostError", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const reconnectCallback = vi.fn();
|
||||||
|
|
||||||
|
const TestComponent = (): ReactNode => {
|
||||||
|
throw new ConnectionLostError();
|
||||||
|
};
|
||||||
|
|
||||||
|
const { asFragment } = render(
|
||||||
|
<BrowserRouter>
|
||||||
|
<GroupCallErrorBoundary
|
||||||
|
onError={vi.fn()}
|
||||||
|
recoveryActionHandler={reconnectCallback}
|
||||||
|
>
|
||||||
|
<TestComponent />
|
||||||
|
</GroupCallErrorBoundary>
|
||||||
|
</BrowserRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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" }));
|
||||||
|
|
||||||
|
expect(reconnectCallback).toHaveBeenCalledOnce();
|
||||||
|
expect(reconnectCallback).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()}>
|
||||||
|
<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();
|
||||||
|
});
|
||||||
|
});
|
||||||
137
src/room/GroupCallErrorBoundary.tsx
Normal file
137
src/room/GroupCallErrorBoundary.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/*
|
||||||
|
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";
|
||||||
|
|
||||||
|
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
|
||||||
|
|
||||||
|
export type RecoveryActionHandler = (action: CallErrorRecoveryAction) => void;
|
||||||
|
|
||||||
|
interface ErrorPageProps {
|
||||||
|
error: ElementCallError;
|
||||||
|
recoveryActionHandler?: RecoveryActionHandler;
|
||||||
|
resetError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ErrorPage: FC<ErrorPageProps> = ({
|
||||||
|
error,
|
||||||
|
recoveryActionHandler,
|
||||||
|
}: ErrorPageProps): ReactElement => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// let title: string;
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GroupCallErrorBoundary = ({
|
||||||
|
recoveryActionHandler,
|
||||||
|
onError,
|
||||||
|
children,
|
||||||
|
}: 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
|
||||||
|
error={callError}
|
||||||
|
resetError={resetError}
|
||||||
|
recoveryActionHandler={recoveryActionHandler}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[recoveryActionHandler],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
fallback={fallbackRenderer}
|
||||||
|
onError={(error) => onError?.(error)}
|
||||||
|
children={children}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 enterRTCError;
|
||||||
};
|
};
|
||||||
body = <ErrorComponent />;
|
body = <ErrorComponent />;
|
||||||
} else if (isJoined) {
|
} else if (isJoined) {
|
||||||
@@ -543,5 +477,27 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
body = lobbyView;
|
body = lobbyView;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ErrorBoundary fallback={errorPage}>{body}</ErrorBoundary>;
|
return (
|
||||||
|
<GroupCallErrorBoundary
|
||||||
|
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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
1051
src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap
Normal file
1051
src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user