soapbox/src/components/site-error-boundary.tsx

200 wiersze
6.9 KiB
TypeScript

import React, { type ErrorInfo, useRef, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { FormattedMessage } from 'react-intl';
import { NODE_ENV } from 'soapbox/build-config';
import { HStack, Text, Stack, Textarea } from 'soapbox/components/ui';
import { useSoapboxConfig } from 'soapbox/hooks';
import { captureSentryException } from 'soapbox/sentry';
import KVStore from 'soapbox/storage/kv-store';
import sourceCode from 'soapbox/utils/code';
import { unregisterSW } from 'soapbox/utils/sw';
import SentryFeedbackForm from './sentry-feedback-form';
import SiteLogo from './site-logo';
interface ISiteErrorBoundary {
children: React.ReactNode;
}
/** Application-level error boundary. Fills the whole screen. */
const SiteErrorBoundary: React.FC<ISiteErrorBoundary> = ({ children }) => {
const { links, sentryDsn } = useSoapboxConfig();
const textarea = useRef<HTMLTextAreaElement>(null);
const [error, setError] = useState<unknown>();
const [componentStack, setComponentStack] = useState<string>();
const [browser, setBrowser] = useState<Bowser.Parser.Parser>();
const [sentryEventId, setSentryEventId] = useState<string>();
const sentryEnabled = Boolean(sentryDsn);
const isProduction = NODE_ENV === 'production';
const errorText = String(error) + componentStack;
const clearCookies: React.MouseEventHandler = (e) => {
localStorage.clear();
sessionStorage.clear();
KVStore.clear();
if ('serviceWorker' in navigator) {
e.preventDefault();
unregisterSW().then(goHome).catch(goHome);
}
};
const handleCopy: React.MouseEventHandler = () => {
if (!textarea.current) return;
textarea.current.select();
textarea.current.setSelectionRange(0, 99999);
document.execCommand('copy');
};
function handleError(error: Error, info: ErrorInfo) {
setError(error);
setComponentStack(info.componentStack);
captureSentryException(error, {
tags: {
// Allow page crashes to be easily searched in Sentry.
ErrorBoundary: 'yes',
},
})
.then((eventId) => setSentryEventId(eventId))
.catch(console.error);
import('bowser')
.then(({ default: Bowser }) => setBrowser(Bowser.getParser(window.navigator.userAgent)))
.catch(() => {});
}
function goHome() {
location.href = '/';
}
const fallback = (
<div className='flex h-screen flex-col bg-white pb-12 pt-16 dark:bg-primary-900'>
<main className='mx-auto flex w-full max-w-7xl grow flex-col justify-center px-4 sm:px-6 lg:px-8'>
<div className='flex shrink-0 justify-center'>
<a href='/' className='inline-flex'>
<SiteLogo alt='Logo' className='h-12 w-auto cursor-pointer' />
</a>
</div>
<div className='py-8'>
<div className='mx-auto max-w-xl space-y-2 text-center'>
<h1 className='text-3xl font-extrabold tracking-tight text-gray-900 sm:text-4xl dark:text-gray-500'>
<FormattedMessage id='alert.unexpected.message' defaultMessage='Something went wrong.' />
</h1>
<p className='text-lg text-gray-700 dark:text-gray-600'>
<FormattedMessage
id='alert.unexpected.body'
defaultMessage="We're sorry for the interruption. If the problem persists, please reach out to our support team. You may also try to {clearCookies} (this will log you out)."
values={{
clearCookies: (
<a href='/' onClick={clearCookies} className='text-primary-600 hover:underline dark:text-accent-blue'>
<FormattedMessage
id='alert.unexpected.clear_cookies'
defaultMessage='clear cookies and browser data'
/>
</a>
),
}}
/>
</p>
<Text theme='muted'>
<Text weight='medium' tag='span' theme='muted'>{sourceCode.displayName}:</Text>
{' '}{sourceCode.version}
</Text>
<div className='mt-10'>
<a href='/' className='text-base font-medium text-primary-600 hover:underline dark:text-accent-blue'>
<FormattedMessage id='alert.unexpected.return_home' defaultMessage='Return Home' />
{' '}
<span className='inline-block rtl:rotate-180' aria-hidden='true'>&rarr;</span>
</a>
</div>
</div>
<div className='mx-auto max-w-lg space-y-4 py-16'>
{(isProduction) ? (
(sentryEnabled && sentryEventId) && (
<SentryFeedbackForm eventId={sentryEventId} />
)
) : (
<>
{errorText && (
<Textarea
ref={textarea}
value={errorText}
onClick={handleCopy}
isCodeEditor
rows={12}
readOnly
/>
)}
{browser && (
<Stack>
<Text weight='semibold'><FormattedMessage id='alert.unexpected.browser' defaultMessage='Browser' /></Text>
<Text theme='muted'>{browser.getBrowserName()} {browser.getBrowserVersion()}</Text>
</Stack>
)}
</>
)}
</div>
</div>
</main>
<footer className='mx-auto w-full max-w-7xl shrink-0 px-4 sm:px-6 lg:px-8'>
<HStack justifyContent='center' space={4} element='nav'>
{links.get('status') && (
<SiteErrorBoundaryLink href={links.get('status')!}>
<FormattedMessage id='alert.unexpected.links.status' defaultMessage='Status' />
</SiteErrorBoundaryLink>
)}
{links.get('help') && (
<SiteErrorBoundaryLink href={links.get('help')!}>
<FormattedMessage id='alert.unexpected.links.help' defaultMessage='Help Center' />
</SiteErrorBoundaryLink>
)}
{links.get('support') && (
<SiteErrorBoundaryLink href={links.get('support')!}>
<FormattedMessage id='alert.unexpected.links.support' defaultMessage='Support' />
</SiteErrorBoundaryLink>
)}
</HStack>
</footer>
</div>
);
return (
<ErrorBoundary fallback={fallback} onError={handleError}>
{children}
</ErrorBoundary>
);
};
interface ISiteErrorBoundaryLink {
href: string;
children: React.ReactNode;
}
function SiteErrorBoundaryLink({ href, children }: ISiteErrorBoundaryLink) {
return (
<>
<span className='inline-block border-l border-gray-300' aria-hidden='true' />
<a href={href} className='text-sm font-medium text-gray-700 hover:underline dark:text-gray-600'>
{children}
</a>
</>
);
}
export default SiteErrorBoundary;