streetmix/streetmix

View on GitHub
client/src/ui/Toasts/ToastContainer.tsx

Summary

Maintainability
B
6 hrs
Test Coverage
import React, { useState } from 'react'
import { useTransition, animated } from '@react-spring/web'
import { useSelector, useDispatch } from '../../store/hooks'
import { destroyToast } from '../../store/slices/toasts'
import Toast from './Toast'
import ToastUndo from './ToastUndo'
import ToastSignIn from './ToastSignIn'
import ToastNoConnection from './ToastNoConnection'
import ToastWebMonetization from './ToastWebMonetization'
import ToastWebMonetizationSuccess from './ToastWebMonetizationSuccess'
import './ToastContainer.scss'

const TOAST_SPRING_CONFIG = {
  tension: 488,
  friction: 36,
  precision: 0.01
}
const TOAST_DISPLAY_TIMEOUT = 6000
const TOAST_MAX_TO_DISPLAY = 5

/**
 * Based on react-spring "Notification hub" example.
 * https://codesandbox.io/s/v1i1t
 */
function ToastContainer (): React.ReactElement {
  const config = TOAST_SPRING_CONFIG
  const [refMap] = useState(() => new WeakMap())
  const [cancelMap] = useState(() => new WeakMap())
  const toasts = useSelector((state) => state.toasts)
  const contentDirection = useSelector((state) => state.app.contentDirection)
  const showLifeBar = useSelector(
    (state) => state.flags.TOAST_LIFE_BAR?.value ?? false
  )
  const dispatch = useDispatch()

  const items =
    toasts.length >= TOAST_MAX_TO_DISPLAY
      ? toasts.slice(toasts.length - TOAST_MAX_TO_DISPLAY)
      : toasts

  const transitions = useTransition(items, {
    from: {
      opacity: 0,
      height: 0,
      x: contentDirection === 'rtl' ? -300 : 300,

      // `life` is just a variable name used by the react-spring example, it
      // has no special meaning. This is the only property that decreases
      // based on the `duration` property set in config
      life: '100%'
    },
    keys: (item) => item.timestamp,
    enter: (item) => async (next, cancel) => {
      cancelMap.set(item, cancel)

      await next({
        // Animate height to create space for the toast. Using
        // `getBoundingClientRect()` gets us more precise values (decimals)
        // and the +10 adds a margin between this and the next toast.
        height: refMap.get(item).getBoundingClientRect().height + 10
      })
      await next({
        opacity: 1,
        x: 0
      })
      await next({ life: '0%' })
    },
    leave: [
      // First animate transition out
      {
        opacity: 0,
        x: contentDirection === 'rtl' ? -300 : 300
      },
      {
        // Then animate height to zero so that subsequent messages "slide" upwards
        height: 0
      }
    ],
    onRest: (result, ctrl, item) => {
      dispatch(destroyToast(item.timestamp))
    },
    config: (item, index, phase) => (key) =>
      phase === 'enter' && key === 'life'
        ? { duration: item.duration ?? TOAST_DISPLAY_TIMEOUT }
        : config
  })

  return (
    <div className="toast-container">
      {transitions(({ life, ...style }, item) => {
        function setRef<T> (ref: T | null): void {
          ref !== null && refMap.set(item, ref)
        }

        function handleClose (event?: React.MouseEvent | Event): void {
          // Not all instances of this function is called by an event handler
          if (event !== undefined) {
            event.stopPropagation()
          }
          if (cancelMap.has(item) && life.get() !== '0%') {
            cancelMap.get(item)()
          }
        }

        let childComponent

        switch (item.component) {
          case 'TOAST_UNDO':
            childComponent = (
              <ToastUndo
                setRef={setRef}
                handleClose={handleClose}
                item={item}
              />
            )
            break
          case 'TOAST_SIGN_IN':
            childComponent = (
              <ToastSignIn
                setRef={setRef}
                handleClose={handleClose}
                item={item}
              />
            )
            break
          case 'TOAST_WEB_MONETIZATION':
            childComponent = (
              <ToastWebMonetization
                setRef={setRef}
                handleClose={handleClose}
                item={item}
              />
            )
            break
          case 'TOAST_WEB_MONETIZATION_SUCCESS':
            childComponent = (
              <ToastWebMonetizationSuccess
                setRef={setRef}
                handleClose={handleClose}
                item={item}
              />
            )
            break
          case 'TOAST_NO_CONNECTION':
            childComponent = (
              <ToastNoConnection
                setRef={setRef}
                handleClose={handleClose}
                item={item}
              />
            )
            break
          default:
            childComponent = (
              <Toast setRef={setRef} handleClose={handleClose} item={item} />
            )
            break
        }

        return (
          <animated.div style={style}>
            {childComponent}
            {/* Toast countdown debugger */}
            {showLifeBar && (
              <animated.div className="toast-lifebar" style={{ right: life }} />
            )}
          </animated.div>
        )
      })}
    </div>
  )
}

export default ToastContainer