diff --git a/_locales/en/messages.json b/_locales/en/messages.json index f405b875af..6afb7c1257 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -5678,6 +5678,10 @@ } } }, + "ErrorBoundaryNotification__text": { + "message": "Failed to display the message due to an internal error. Click to submit a debug log.", + "description": "An error notification displayed when message fails to render due to an internal error" + }, "GroupDescription__read-more": { "message": "read more", "description": "Button text when the group description is too long" diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index dc7f0379a9..0a9c6c3da4 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -2425,6 +2425,63 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', } } +.module-error-boundary-notification { + text-align: center; + cursor: pointer; + + &:focus { + @include keyboard-mode { + outline: 0; + } + } + + &:focus &__message { + @include keyboard-mode { + opacity: 1; + } + } + + &__message { + opacity: 0.8; + } + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-05; + } + + &__icon-container { + margin-left: auto; + margin-right: auto; + display: inline-flex; + flex-direction: row; + align-items: center; + margin-bottom: 8px; + } + + &__icon { + height: 20px; + width: 20px; + display: inline-block; + opacity: 0.6; + + @include light-theme { + @include color-svg( + '../images/icons/v2/error-solid-24.svg', + $color-gray-60 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/error-solid-24.svg', + $color-gray-05 + ); + } + } +} + .module-notification--with-click-handler { cursor: pointer; } diff --git a/ts/components/conversation/ErrorBoundary.stories.tsx b/ts/components/conversation/ErrorBoundary.stories.tsx new file mode 100644 index 0000000000..5aca72bca7 --- /dev/null +++ b/ts/components/conversation/ErrorBoundary.stories.tsx @@ -0,0 +1,26 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { setup as setupI18n } from '../../../js/modules/i18n'; +import enMessages from '../../../_locales/en/messages.json'; +import { ErrorBoundary } from './ErrorBoundary'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf('Components/Conversation/ErrorBoundary', module); + +const Fail: React.FC> = () => { + throw new Error('Failed'); +}; + +story.add('Error state', () => { + return ( + + + + ); +}); diff --git a/ts/components/conversation/ErrorBoundary.tsx b/ts/components/conversation/ErrorBoundary.tsx new file mode 100644 index 0000000000..9fdca7809f --- /dev/null +++ b/ts/components/conversation/ErrorBoundary.tsx @@ -0,0 +1,84 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { ReactNode } from 'react'; + +import { LocalizerType } from '../../types/Util'; +import * as Errors from '../../types/errors'; + +export type Props = { + i18n: LocalizerType; + children: ReactNode; + + showDebugLog(): void; +}; + +export type State = { + error?: Error; +}; + +const CSS_MODULE = 'module-error-boundary-notification'; + +export class ErrorBoundary extends React.PureComponent { + constructor(props: Props) { + super(props); + + this.state = { error: undefined }; + } + + public static getDerivedStateFromError(error: Error): State { + window.log.error( + 'ErrorBoundary: captured rendering error', + Errors.toLogFormat(error) + ); + return { error }; + } + + public render(): ReactNode { + const { error } = this.state; + const { i18n, children } = this.props; + + if (!error) { + return children; + } + + return ( +
+
+
+
+
+ {i18n('ErrorBoundaryNotification__text')} +
+
+ ); + } + + private onClick(event: React.MouseEvent): void { + event.stopPropagation(); + event.preventDefault(); + + this.onAction(); + } + + private onKeyDown(event: React.KeyboardEvent): void { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + event.stopPropagation(); + event.preventDefault(); + + this.onAction(); + } + + private onAction(): void { + const { showDebugLog } = this.props; + showDebugLog(); + } +} diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index 8485b3fac1..ab0b284cc4 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -21,6 +21,7 @@ import { assert } from '../../util/assert'; import { missingCaseError } from '../../util/missingCaseError'; import { PropsActions as MessageActionsType } from './Message'; +import { ErrorBoundary } from './ErrorBoundary'; import { PropsActions as SafetyNumberActionsType } from './SafetyNumberNotification'; import { Intl } from '../Intl'; import { TimelineWarning } from './TimelineWarning'; @@ -653,6 +654,7 @@ export class Timeline extends React.PureComponent { }: RowRendererParamsType): JSX.Element => { const { id, + i18n, haveOldest, items, renderItem, @@ -727,7 +729,9 @@ export class Timeline extends React.PureComponent { style={styleWithWidth} role="row" > - {renderItem(messageId, id, this.resizeMessage, this.props)} + window.showDebugLog()}> + {renderItem(messageId, id, this.resizeMessage, this.props)} +
); }