about summary refs log tree commit diff
path: root/app/javascript
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2022-10-22 23:18:32 +0200
committerClaire <claire.github-309c@sitedethib.com>2022-10-28 19:24:02 +0200
commit1315c149c0c6159cf7b7091b31accedb714a648f (patch)
treea35f4151a97956042143d547b1b9ec66ba2aa751 /app/javascript
parent92385da9c3d3c88108db530dd468c1933d40540f (diff)
[Glitch] Add error boundary around routes in web UI
Port a43a8237681187f6e56524aa3effcfc998a877de to glitch-soc

Co-authored-by: Yamagishi Kazutoshi <ykzts@desire.sh>
Signed-off-by: Claire <claire.github-309c@sitedethib.com>
Diffstat (limited to 'app/javascript')
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/bundle_column_error.js160
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js6
-rw-r--r--app/javascript/flavours/glitch/features/ui/util/react_router_helpers.js34
-rw-r--r--app/javascript/flavours/glitch/styles/components/columns.scss41
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss9
-rw-r--r--app/javascript/flavours/glitch/styles/components/single_column.scss3
6 files changed, 220 insertions, 33 deletions
diff --git a/app/javascript/flavours/glitch/features/ui/components/bundle_column_error.js b/app/javascript/flavours/glitch/features/ui/components/bundle_column_error.js
index 382481905..7cbe1413d 100644
--- a/app/javascript/flavours/glitch/features/ui/components/bundle_column_error.js
+++ b/app/javascript/flavours/glitch/features/ui/components/bundle_column_error.js
@@ -1,45 +1,155 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { defineMessages, injectIntl } from 'react-intl';
-
+import { injectIntl, FormattedMessage } from 'react-intl';
 import Column from 'flavours/glitch/components/column';
-import ColumnHeader from 'flavours/glitch/components/column_header';
-import IconButton from 'flavours/glitch/components/icon_button';
+import Button from 'flavours/glitch/components/button';
 import { Helmet } from 'react-helmet';
+import { Link } from 'react-router-dom';
+import classNames from 'classnames';
+import { autoPlayGif } from 'flavours/glitch/initial_state';
+
+class GIF extends React.PureComponent {
+
+  static propTypes = {
+    src: PropTypes.string.isRequired,
+    staticSrc: PropTypes.string.isRequired,
+    className: PropTypes.string,
+    animate: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    animate: autoPlayGif,
+  };
+
+  state = {
+    hovering: false,
+  };
+
+  handleMouseEnter = () => {
+    const { animate } = this.props;
+
+    if (!animate) {
+      this.setState({ hovering: true });
+    }
+  }
+
+  handleMouseLeave = () => {
+    const { animate } = this.props;
+
+    if (!animate) {
+      this.setState({ hovering: false });
+    }
+  }
+
+  render () {
+    const { src, staticSrc, className, animate } = this.props;
+    const { hovering } = this.state;
+
+    return (
+      <img
+        className={className}
+        src={(hovering || animate) ? src : staticSrc}
+        alt=''
+        role='presentation'
+        onMouseEnter={this.handleMouseEnter}
+        onMouseLeave={this.handleMouseLeave}
+      />
+    );
+  }
+
+}
+
+class CopyButton extends React.PureComponent {
+
+  static propTypes = {
+    children: PropTypes.node.isRequired,
+    value: PropTypes.string.isRequired,
+  };
+
+  state = {
+    copied: false,
+  };
+
+  handleClick = () => {
+    const { value } = this.props;
+    navigator.clipboard.writeText(value);
+    this.setState({ copied: true });
+    this.timeout = setTimeout(() => this.setState({ copied: false }), 700);
+  }
+
+  componentWillUnmount () {
+    if (this.timeout) clearTimeout(this.timeout);
+  }
+
+  render () {
+    const { children } = this.props;
+    const { copied } = this.state;
+
+    return (
+      <Button onClick={this.handleClick} className={copied ? 'copied' : 'copyable'}>{copied ? <FormattedMessage id='copypaste.copied' defaultMessage='Copied' /> : children}</Button>
+    );
+  }
 
-const messages = defineMessages({
-  title: { id: 'bundle_column_error.title', defaultMessage: 'Network error' },
-  body: { id: 'bundle_column_error.body', defaultMessage: 'Something went wrong while loading this component.' },
-  retry: { id: 'bundle_column_error.retry', defaultMessage: 'Try again' },
-});
+}
 
-class BundleColumnError extends React.Component {
+export default @injectIntl
+class BundleColumnError extends React.PureComponent {
 
   static propTypes = {
-    onRetry: PropTypes.func.isRequired,
+    errorType: PropTypes.oneOf(['routing', 'network', 'error']),
+    onRetry: PropTypes.func,
     intl: PropTypes.object.isRequired,
     multiColumn: PropTypes.bool,
-  }
+    stacktrace: PropTypes.string,
+  };
+
+  static defaultProps = {
+    errorType: 'routing',
+  };
 
   handleRetry = () => {
-    this.props.onRetry();
+    const { onRetry } = this.props;
+
+    if (onRetry) {
+      onRetry();
+    }
   }
 
   render () {
-    const { multiColumn, intl: { formatMessage } } = this.props;
+    const { errorType, multiColumn, stacktrace } = this.props;
 
-    return (
-      <Column bindToDocument={!multiColumn} label={formatMessage(messages.title)}>
-        <ColumnHeader
-          icon='exclamation-circle'
-          title={formatMessage(messages.title)}
-          showBackButton
-          multiColumn={multiColumn}
-        />
+    let title, body;
 
+    switch(errorType) {
+    case 'routing':
+      title = <FormattedMessage id='bundle_column_error.routing.title' defaultMessage='404' />;
+      body = <FormattedMessage id='bundle_column_error.routing.body' defaultMessage='The requested page could not be found. Are you sure the URL in the address bar is correct?' />;
+      break;
+    case 'network':
+      title = <FormattedMessage id='bundle_column_error.network.title' defaultMessage='Network error' />;
+      body = <FormattedMessage id='bundle_column_error.network.body' defaultMessage='There was an error when trying to load this page. This could be due to a temporary problem with your internet connection or this server.' />;
+      break;
+    case 'error':
+      title = <FormattedMessage id='bundle_column_error.error.title' defaultMessage='Oh, no!' />;
+      body = <FormattedMessage id='bundle_column_error.error.body' defaultMessage='The requested page could not be rendered. It could be due to a bug in our code, or a browser compatibility issue.' />;
+      break;
+    }
+
+    return (
+      <Column bindToDocument={!multiColumn}>
         <div className='error-column'>
-          <IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} />
-          {formatMessage(messages.body)}
+          <GIF src='/oops.gif' staticSrc='/oops.png' className='error-column__image' />
+
+          <div className='error-column__message'>
+            <h1>{title}</h1>
+            <p>{body}</p>
+
+            <div className='error-column__message__actions'>
+              {errorType === 'network' && <Button onClick={this.handleRetry}><FormattedMessage id='bundle_column_error.retry' defaultMessage='Try again' /></Button>}
+              {errorType === 'error' && <CopyButton value={stacktrace}><FormattedMessage id='bundle_column_error.copy_stacktrace' defaultMessage='Copy error report' /></CopyButton>}
+              <Link to='/' className={classNames('button', { 'button-tertiary': errorType !== 'routing' })}><FormattedMessage id='bundle_column_error.return' defaultMessage='Go back home' /></Link>
+            </div>
+          </div>
         </div>
 
         <Helmet>
@@ -50,5 +160,3 @@ class BundleColumnError extends React.Component {
   }
 
 }
-
-export default injectIntl(BundleColumnError);
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index 5c567be42..9ca946142 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import LoadingBarContainer from './containers/loading_bar_container';
 import ModalContainer from './containers/modal_container';
 import { connect } from 'react-redux';
-import { Redirect, withRouter } from 'react-router-dom';
+import { Redirect, Route, withRouter } from 'react-router-dom';
 import { layoutFromWindow } from 'flavours/glitch/is_mobile';
 import { debounce } from 'lodash';
 import { uploadCompose, resetCompose, changeComposeSpoilerness } from 'flavours/glitch/actions/compose';
@@ -15,6 +15,7 @@ import { clearHeight } from 'flavours/glitch/actions/height_cache';
 import { changeLayout } from 'flavours/glitch/actions/app';
 import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
 import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
+import BundleColumnError from './components/bundle_column_error';
 import UploadArea from './components/upload_area';
 import PermaLink from 'flavours/glitch/components/permalink';
 import ColumnsAreaContainer from './containers/columns_area_container';
@@ -39,7 +40,6 @@ import {
   HashtagTimeline,
   Notifications,
   FollowRequests,
-  GenericNotFound,
   FavouritedStatuses,
   BookmarkedStatuses,
   ListTimeline,
@@ -233,7 +233,7 @@ class SwitchingColumnsArea extends React.PureComponent {
           <WrappedRoute path='/lists' component={Lists} content={children} />
           <WrappedRoute path='/getting-started-misc' component={GettingStartedMisc} content={children} />
 
-          <WrappedRoute component={GenericNotFound} content={children} />
+          <Route component={BundleColumnError} />
         </WrappedSwitch>
       </ColumnsAreaContainer>
     );
diff --git a/app/javascript/flavours/glitch/features/ui/util/react_router_helpers.js b/app/javascript/flavours/glitch/features/ui/util/react_router_helpers.js
index 60a81a581..8946c8252 100644
--- a/app/javascript/flavours/glitch/features/ui/util/react_router_helpers.js
+++ b/app/javascript/flavours/glitch/features/ui/util/react_router_helpers.js
@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { Switch, Route } from 'react-router-dom';
-
+import StackTrace from 'stacktrace-js';
 import ColumnLoading from 'flavours/glitch/features/ui/components/column_loading';
 import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
 import BundleContainer from 'flavours/glitch/features/ui/containers/bundle_container';
@@ -42,8 +42,38 @@ export class WrappedRoute extends React.Component {
     componentParams: {},
   };
 
+  static getDerivedStateFromError () {
+    return {
+      hasError: true,
+    };
+  };
+
+  state = {
+    hasError: false,
+    stacktrace: '',
+  };
+
+  componentDidCatch (error) {
+    StackTrace.fromError(error).then(stackframes => {
+      this.setState({ stacktrace: error.toString() + '\n' + stackframes.map(frame => frame.toString()).join('\n') });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
   renderComponent = ({ match }) => {
     const { component, content, multiColumn, componentParams } = this.props;
+    const { hasError, stacktrace } = this.state;
+
+    if (hasError) {
+      return (
+        <BundleColumnError
+          stacktrace={stacktrace}
+          multiColumn={multiColumn}
+          errorType='error'
+        />
+      );
+    }
 
     return (
       <BundleContainer fetchComponent={component} loading={this.renderLoading} error={this.renderError}>
@@ -59,7 +89,7 @@ export class WrappedRoute extends React.Component {
   }
 
   renderError = (props) => {
-    return <BundleColumnError {...props} />;
+    return <BundleColumnError {...props} errorType='network' />;
   }
 
   render () {
diff --git a/app/javascript/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss
index 75891ad2e..5de8547e9 100644
--- a/app/javascript/flavours/glitch/styles/components/columns.scss
+++ b/app/javascript/flavours/glitch/styles/components/columns.scss
@@ -627,7 +627,6 @@ $ui-header-height: 55px;
 }
 
 .empty-column-indicator,
-.error-column,
 .follow_requests-unlocked_explanation {
   color: $dark-text-color;
   background: $ui-base-color;
@@ -664,7 +663,47 @@ $ui-header-height: 55px;
 }
 
 .error-column {
+  padding: 20px;
+  background: $ui-base-color;
+  border-radius: 4px;
+  display: flex;
+  flex: 1 1 auto;
+  align-items: center;
+  justify-content: center;
   flex-direction: column;
+  cursor: default;
+
+  &__image {
+    max-width: 350px;
+    margin-top: -50px;
+  }
+
+  &__message {
+    text-align: center;
+    color: $darker-text-color;
+    font-size: 15px;
+    line-height: 22px;
+
+    h1 {
+      font-size: 28px;
+      line-height: 33px;
+      font-weight: 700;
+      margin-bottom: 15px;
+      color: $primary-text-color;
+    }
+
+    p {
+      max-width: 48ch;
+    }
+
+    &__actions {
+      margin-top: 30px;
+      display: flex;
+      gap: 10px;
+      align-items: center;
+      justify-content: center;
+    }
+  }
 }
 
 // more fixes for the navbar-under mode
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index 81d90f442..8877626b8 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -189,6 +189,15 @@
     color: $highlight-text-color;
   }
 
+  &.copyable {
+    transition: background 300ms linear;
+  }
+
+  &.copied {
+    background: $valid-value-color;
+    transition: none;
+  }
+
   &::-moz-focus-inner {
     border: 0;
   }
diff --git a/app/javascript/flavours/glitch/styles/components/single_column.scss b/app/javascript/flavours/glitch/styles/components/single_column.scss
index 3befca567..1725a5480 100644
--- a/app/javascript/flavours/glitch/styles/components/single_column.scss
+++ b/app/javascript/flavours/glitch/styles/components/single_column.scss
@@ -322,7 +322,8 @@
 
   .column-header,
   .column-back-button,
-  .scrollable {
+  .scrollable,
+  .error-column {
     border-radius: 0 !important;
   }
 }