Make error screens more visually consistent (#2951)

This commit is contained in:
Robin
2025-01-17 04:35:39 -05:00
committed by GitHub
parent c218dc2f36
commit cda802a2e9
31 changed files with 334 additions and 175 deletions

View File

@@ -16,7 +16,7 @@ import { LoginPage } from "./auth/LoginPage";
import { RegisterPage } from "./auth/RegisterPage";
import { RoomPage } from "./room/RoomPage";
import { ClientProvider } from "./ClientContext";
import { CrashView, LoadingView } from "./FullScreenView";
import { ErrorPage, LoadingPage } from "./FullScreenView";
import { DisconnectedBanner } from "./DisconnectedBanner";
import { Initializer } from "./initializer";
import { MediaDevicesProvider } from "./livekit/MediaDevicesContext";
@@ -61,8 +61,6 @@ export const App: FC = () => {
.catch(logger.error);
});
const errorPage = <CrashView />;
return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
@@ -74,7 +72,7 @@ export const App: FC = () => {
<Suspense fallback={null}>
<ClientProvider>
<MediaDevicesProvider>
<Sentry.ErrorBoundary fallback={errorPage}>
<Sentry.ErrorBoundary fallback={ErrorPage}>
<DisconnectedBanner />
<Routes>
<SentryRoute path="/" element={<HomePage />} />
@@ -90,7 +88,7 @@ export const App: FC = () => {
</ClientProvider>
</Suspense>
) : (
<LoadingView />
<LoadingPage />
)}
</TooltipProvider>
</ThemeProvider>

View File

@@ -18,19 +18,18 @@ import {
} from "react";
import { useNavigate } from "react-router-dom";
import { logger } from "matrix-js-sdk/src/logger";
import { useTranslation } from "react-i18next";
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/src/sync";
import { ClientEvent, type MatrixClient } from "matrix-js-sdk/src/client";
import type { WidgetApi } from "matrix-widget-api";
import { ErrorView } from "./FullScreenView";
import { ErrorPage } from "./FullScreenView";
import { widget } from "./widget";
import {
PosthogAnalytics,
RegistrationType,
} from "./analytics/PosthogAnalytics";
import { translatedError } from "./TranslatedError";
import { useEventTarget } from "./useEvents";
import { OpenElsewhereError } from "./RichError";
declare global {
interface Window {
@@ -233,8 +232,6 @@ export const ClientProvider: FC<Props> = ({ children }) => {
PosthogAnalytics.instance.setRegistrationType(RegistrationType.Guest);
}, [navigate, initClientState?.client]);
const { t } = useTranslation();
// To protect against multiple sessions writing to the same storage
// simultaneously, we send a broadcast message that shuts down all other
// running instances of the app. This isn't necessary if the app is running in
@@ -251,8 +248,8 @@ export const ClientProvider: FC<Props> = ({ children }) => {
"message",
useCallback(() => {
initClientState?.client.stopClient();
setAlreadyOpenedErr(translatedError("application_opened_another_tab", t));
}, [initClientState?.client, setAlreadyOpenedErr, t]),
setAlreadyOpenedErr(new OpenElsewhereError());
}, [initClientState?.client, setAlreadyOpenedErr]),
);
const [isDisconnected, setIsDisconnected] = useState(false);
@@ -354,7 +351,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
}, [initClientState, onSync]);
if (alreadyOpenedErr) {
return <ErrorView error={alreadyOpenedErr} />;
return <ErrorPage error={alreadyOpenedErr} />;
}
return (

21
src/ErrorView.module.css Normal file
View File

@@ -0,0 +1,21 @@
.error {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--cpd-space-2x);
max-inline-size: 480px;
}
.icon {
margin-block-end: var(--cpd-space-4x);
}
.error > h1 {
margin: 0;
}
.error > p {
font: var(--cpd-font-body-lg-regular);
color: var(--cpd-color-text-secondary);
text-align: center;
}

82
src/ErrorView.tsx Normal file
View File

@@ -0,0 +1,82 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { BigIcon, Button, Heading } from "@vector-im/compound-web";
import {
useCallback,
type ComponentType,
type FC,
type ReactNode,
type SVGAttributes,
} from "react";
import { useTranslation } from "react-i18next";
import { RageshakeButton } from "./settings/RageshakeButton";
import styles from "./ErrorView.module.css";
import { useUrlParams } from "./UrlParams";
import { LinkButton } from "./button";
interface Props {
Icon: ComponentType<SVGAttributes<SVGElement>>;
title: string;
/**
* Show an option to submit a rageshake.
* @default false
*/
rageshake?: boolean;
/**
* Whether the error is considered fatal, i.e. non-recoverable. Causes the app
* to fully reload when clicking 'return to home'.
* @default false
*/
fatal?: boolean;
children: ReactNode;
}
export const ErrorView: FC<Props> = ({
Icon,
title,
rageshake,
fatal,
children,
}) => {
const { t } = useTranslation();
const { confineToRoom } = useUrlParams();
const onReload = useCallback(() => {
window.location.href = "/";
}, []);
return (
<div className={styles.error}>
<BigIcon className={styles.icon}>
<Icon />
</BigIcon>
<Heading as="h1" weight="semibold" size="md">
{title}
</Heading>
{children}
{rageshake && (
<RageshakeButton description={`***Error View***: ${title}`} />
)}
{!confineToRoom &&
(fatal || location.pathname === "/" ? (
<Button
kind="tertiary"
className={styles.homeLink}
onClick={onReload}
>
{t("return_home_button")}
</Button>
) : (
<LinkButton kind="tertiary" className={styles.homeLink} to="/">
{t("return_home_button")}
</LinkButton>
))}
</div>
);
};

View File

@@ -5,21 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { type FC, type ReactNode, useCallback, useEffect } from "react";
import { useLocation } from "react-router-dom";
import { type FC, type ReactElement, type ReactNode, useEffect } from "react";
import classNames from "classnames";
import { Trans, useTranslation } from "react-i18next";
import { useTranslation } from "react-i18next";
import * as Sentry from "@sentry/react";
import { logger } from "matrix-js-sdk/src/logger";
import { Button } from "@vector-im/compound-web";
import { ErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
import { LinkButton } from "./button";
import styles from "./FullScreenView.module.css";
import { TranslatedError } from "./TranslatedError";
import { Config } from "./config/Config";
import { RageshakeButton } from "./settings/RageshakeButton";
import { useUrlParams } from "./UrlParams";
import { RichError } from "./RichError";
import { ErrorView } from "./ErrorView";
interface FullScreenViewProps {
className?: string;
@@ -44,74 +41,33 @@ export const FullScreenView: FC<FullScreenViewProps> = ({
);
};
interface ErrorViewProps {
error: Error;
interface ErrorPageProps {
error: Error | unknown;
}
export const ErrorView: FC<ErrorViewProps> = ({ error }) => {
const location = useLocation();
const { confineToRoom } = useUrlParams();
// 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>
export const ErrorPage = ({ error }: ErrorPageProps): ReactElement => {
const { t } = useTranslation();
useEffect(() => {
logger.error(error);
Sentry.captureException(error);
}, [error]);
const onReload = useCallback(() => {
window.location.href = "/";
}, []);
return (
<FullScreenView>
<h1>{t("common.error")}</h1>
<p>
{error instanceof TranslatedError
? error.translatedMessage
: error.message}
</p>
<RageshakeButton description={`***Error View***: ${error.message}`} />
{!confineToRoom &&
(location.pathname === "/" ? (
<Button className={styles.homeLink} onClick={onReload}>
{t("return_home_button")}
</Button>
) : (
<LinkButton className={styles.homeLink} to="/">
{t("return_home_button")}
</LinkButton>
))}
</FullScreenView>
);
};
export const CrashView: FC = () => {
const { t } = useTranslation();
const onReload = useCallback(() => {
window.location.href = "/";
}, []);
return (
<FullScreenView>
<Trans i18nKey="full_screen_view_h1">
<h1>Oops, something's gone wrong.</h1>
</Trans>
{Config.get().rageshake?.submit_url && (
<Trans i18nKey="full_screen_view_description">
<p>Submitting debug logs will help us track down the problem.</p>
</Trans>
{error instanceof RichError ? (
error.richMessage
) : (
<ErrorView Icon={ErrorIcon} title={t("error.generic")} rageshake fatal>
<p>{t("error.generic_description")}</p>
</ErrorView>
)}
<RageshakeButton description="***Soft Crash***" />
<Button className={styles.wideButton} onClick={onReload}>
{t("return_home_button")}
</Button>
</FullScreenView>
);
};
export const LoadingView: FC = () => {
export const LoadingPage: FC = () => {
const { t } = useTranslation();
return (

48
src/RichError.tsx Normal file
View File

@@ -0,0 +1,48 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { type FC, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { ErrorView } from "./ErrorView";
/**
* An error consisting of a terse message to be logged to the console and a
* richer message to be shown to the user, as a full-screen page.
*/
export class RichError extends Error {
public constructor(
message: string,
/**
* The pretty, more helpful message to be shown on the error screen.
*/
public readonly richMessage: ReactNode,
) {
super(message);
}
}
const OpenElsewhere: FC = () => {
const { t } = useTranslation();
return (
<ErrorView Icon={PopOutIcon} title={t("error.open_elsewhere")}>
<p>
{t("error.open_elsewhere_description", {
brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
})}
</p>
</ErrorView>
);
};
export class OpenElsewhereError extends RichError {
public constructor() {
super("App opened in another tab", <OpenElsewhere />);
}
}

View File

@@ -9,6 +9,9 @@ import type { DefaultNamespace, ParseKeys, TFunction, TOptions } from "i18next";
/**
* An error with messages in both English and the user's preferred language.
* Use this for errors that need to be displayed inline within another
* component. For errors that could be given their own screen, prefer
* {@link RichError}.
*/
// Abstract to force consumers to use the function below rather than calling the
// constructor directly

View File

@@ -26,7 +26,7 @@ import { useClientLegacy } from "../ClientContext";
import { useInteractiveRegistration } from "./useInteractiveRegistration";
import styles from "./LoginPage.module.css";
import Logo from "../icons/LogoLarge.svg?react";
import { LoadingView } from "../FullScreenView";
import { LoadingPage } from "../FullScreenView";
import { useRecaptcha } from "./useRecaptcha";
import { usePageTitle } from "../usePageTitle";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
@@ -148,7 +148,7 @@ export const RegisterPage: FC = () => {
}, [loading, navigate, authenticated, passwordlessUser, registering]);
if (loading) {
return <LoadingView />;
return <LoadingPage />;
} else {
PosthogAnalytics.instance.eventSignup.cacheSignupStart(new Date());
}

View File

@@ -87,7 +87,7 @@ export function ReactionPopupMenu({
<Alert
className={styles.alert}
type="critical"
title={t("common.something_went_wrong")}
title={t("error.generic")}
>
{errorText}
</Alert>

View File

@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { type FC } from "react";
import { useClientState } from "../ClientContext";
import { ErrorView, LoadingView } from "../FullScreenView";
import { ErrorPage, LoadingPage } from "../FullScreenView";
import { UnauthenticatedView } from "./UnauthenticatedView";
import { RegisteredView } from "./RegisteredView";
import { usePageTitle } from "../usePageTitle";
@@ -21,9 +21,9 @@ export const HomePage: FC = () => {
const clientState = useClientState();
if (!clientState) {
return <LoadingView />;
return <LoadingPage />;
} else if (clientState.state === "error") {
return <ErrorView error={clientState.error} />;
return <ErrorPage error={clientState.error} />;
} else {
return clientState.authenticated ? (
<RegisteredView client={clientState.authenticated.client} />

View File

@@ -15,6 +15,7 @@ import {
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";
@@ -25,9 +26,9 @@ import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { FieldRow, InputField } from "../input/Input";
import { StarRatingInput } from "../input/StarRatingInput";
import { RageshakeButton } from "../settings/RageshakeButton";
import { Link } from "../button/Link";
import { LinkButton } from "../button";
import { ErrorView } from "../ErrorView";
interface Props {
client: MatrixClient;
@@ -147,25 +148,17 @@ export const CallEndedView: FC<Props> = ({
return (
<>
<main className={styles.main}>
<Heading size="xl" weight="semibold" className={styles.headline}>
<Trans i18nKey="call_ended_view.body">
You were disconnected from the call
</Trans>
</Heading>
<div className={styles.disconnectedButtons}>
<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>
<div className={styles.rageshakeButton}>
<RageshakeButton description="***Call disconnected***" />
</div>
</div>
</ErrorView>
</main>
{!confineToRoom && (
<Text className={styles.footer}>
<Link to="/"> {t("return_home_button")} </Link>
</Text>
)}
</>
);
} else {

View File

@@ -21,7 +21,7 @@ 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 { Heading, Text } from "@vector-im/compound-web";
import { WebBrowserIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@@ -54,11 +54,11 @@ import { useJoinRule } from "./useJoinRule";
import { InviteModal } from "./InviteModal";
import { useUrlParams } from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType";
import { Link } from "../button/Link";
import { useAudioContext } from "../useAudioContext";
import { callEventAudioSounds } from "./CallEventAudioRenderer";
import { useLatest } from "../useLatest";
import { usePageTitle } from "../usePageTitle";
import { ErrorView } from "../ErrorView";
declare global {
interface Window {
@@ -331,9 +331,9 @@ export const GroupCallView: FC<Props> = ({
// If we have a encryption system but the browser does not support it.
return (
<FullScreenView>
<Heading>{t("browser_media_e2ee_unsupported_heading")}</Heading>
<Text>{t("browser_media_e2ee_unsupported")}</Text>
<Link to="/">{t("common.home")}</Link>
<ErrorView Icon={WebBrowserIcon} title={t("error.e2ee_unsupported")}>
<p>{t("error.e2ee_unsupported_description")}</p>
</ErrorView>
</FullScreenView>
);
}

View File

@@ -14,13 +14,15 @@ import {
type JSX,
} from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { useTranslation } from "react-i18next";
import { CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { Trans, useTranslation } from "react-i18next";
import {
CheckIcon,
UnknownSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { type MatrixError } from "matrix-js-sdk/src/http-api";
import { Heading, Text } from "@vector-im/compound-web";
import { useClientLegacy } from "../ClientContext";
import { ErrorView, FullScreenView, LoadingView } from "../FullScreenView";
import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView";
import { RoomAuthView } from "./RoomAuthView";
import { GroupCallView } from "./GroupCallView";
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
@@ -37,6 +39,7 @@ import { useMuteStates } from "./MuteStates";
import { useOptInAnalytics } from "../settings/settings";
import { Config } from "../config/Config";
import { Link } from "../button/Link";
import { ErrorView } from "../ErrorView";
export const RoomPage: FC = () => {
const {
@@ -171,29 +174,40 @@ export const RoomPage: FC = () => {
if ((groupCallState.error as MatrixError).errcode === "M_NOT_FOUND") {
return (
<FullScreenView>
<Heading>{t("group_call_loader.failed_heading")}</Heading>
<Text>{t("group_call_loader.failed_text")}</Text>
{/* XXX: A 'create it for me' button would be the obvious UX here. Two screens already have
dupes of this flow, let's make a common component and put it here. */}
<Link to="/">{t("common.home")}</Link>
<ErrorView
Icon={UnknownSolidIcon}
title={t("error.call_not_found")}
>
<Trans i18nKey="error.call_not_found_description">
<p>
That link doesn't appear to belong to any existing call.
Check that you have the right link, or{" "}
<Link to="/">create a new one</Link>.
</p>
</Trans>
</ErrorView>
</FullScreenView>
);
} else if (groupCallState.error instanceof CallTerminatedMessage) {
return (
<FullScreenView>
<Heading>{groupCallState.error.message}</Heading>
<Text>{groupCallState.error.messageBody}</Text>
{groupCallState.error.reason && (
<>
{t("group_call_loader.reason")}:
<Text size="sm">"{groupCallState.error.reason}"</Text>
</>
)}
<Link to="/">{t("common.home")}</Link>
<ErrorView
Icon={groupCallState.error.icon}
title={groupCallState.error.message}
>
<p>{groupCallState.error.messageBody}</p>
{groupCallState.error.reason && (
<p>
{t("group_call_loader.reason", {
reason: groupCallState.error.reason,
})}
</p>
)}
</ErrorView>
</FullScreenView>
);
} else {
return <ErrorView error={groupCallState.error} />;
return <ErrorPage error={groupCallState.error} />;
}
default:
return <> </>;
@@ -202,9 +216,9 @@ export const RoomPage: FC = () => {
let content: ReactNode;
if (loading || isRegistering) {
content = <LoadingView />;
content = <LoadingPage />;
} else if (error) {
content = <ErrorView error={error} />;
content = <ErrorPage error={error} />;
} else if (!client) {
content = <RoomAuthView />;
} else if (!roomIdOrAlias) {

View File

@@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { useState, useEffect, useRef, useCallback } from "react";
import {
useState,
useEffect,
useRef,
useCallback,
type ComponentType,
type SVGAttributes,
} from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { EventType } from "matrix-js-sdk/src/@types/event";
import {
@@ -19,6 +26,11 @@ import { RoomEvent, type Room } from "matrix-js-sdk/src/models/room";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { JoinRule, MatrixError } from "matrix-js-sdk/src/matrix";
import { useTranslation } from "react-i18next";
import {
AdminIcon,
CloseIcon,
EndCallIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { widget } from "../widget";
@@ -92,27 +104,25 @@ async function joinRoomAfterInvite(
export class CallTerminatedMessage extends Error {
/**
* @param messageBody The message explaining the kind of termination (kick, ban, knock reject, etc.) (translated)
*/
public messageBody: string;
/**
* @param reason The user provided reason for the termination (kick/ban)
*/
public reason?: string;
/**
*
* @param messageTitle The title of the call ended screen message (translated)
* @param messageBody The message explaining the kind of termination (kick, ban, knock reject, etc.) (translated)
* @param reason The user provided reason for the termination (kick/ban)
*/
public constructor(
/**
* The icon to display with the message.
*/
public readonly icon: ComponentType<SVGAttributes<SVGElement>>,
messageTitle: string,
messageBody: string,
reason?: string,
/**
* The message explaining the kind of termination (kick, ban, knock reject,
* etc.) (translated)
*/
public readonly messageBody: string,
/**
* The user-provided reason for the termination (kick/ban)
*/
public readonly reason?: string,
) {
super(messageTitle);
this.messageBody = messageBody;
this.reason = reason;
}
}
@@ -128,6 +138,7 @@ export const useLoadGroupCall = (
const bannedError = useCallback(
(): CallTerminatedMessage =>
new CallTerminatedMessage(
AdminIcon,
t("group_call_loader.banned_heading"),
t("group_call_loader.banned_body"),
leaveReason(),
@@ -137,6 +148,7 @@ export const useLoadGroupCall = (
const knockRejectError = useCallback(
(): CallTerminatedMessage =>
new CallTerminatedMessage(
CloseIcon,
t("group_call_loader.knock_reject_heading"),
t("group_call_loader.knock_reject_body"),
leaveReason(),
@@ -146,6 +158,7 @@ export const useLoadGroupCall = (
const removeNoticeError = useCallback(
(): CallTerminatedMessage =>
new CallTerminatedMessage(
EndCallIcon,
t("group_call_loader.call_ended_heading"),
t("group_call_loader.call_ended_body"),
leaveReason(),