digitalfabrik/integreat-app

View on GitHub
web/src/components/RemoteContent.tsx

Summary

Maintainability
A
0 mins
Test Coverage
A
98%
import Dompurify from 'dompurify'
import React, { ReactElement, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled, { css } from 'styled-components'

import { ExternalSourcePermissions } from 'shared'

import { ExternalLinkIcon } from '../assets'
import buildConfig from '../constants/buildConfig'
import dimensions from '../constants/dimensions'
import { helpers } from '../constants/theme'
import useLocalStorage from '../hooks/useLocalStorage'
import useWindowDimensions from '../hooks/useWindowDimensions'
import {
  LOCAL_STORAGE_ITEM_EXTERNAL_SOURCES,
  handleAllowedIframeSources,
  hideIframe,
  preserveIFrameSourcesFromContent,
} from '../utils/iframes'

const SandBox = styled.div<{ $centered: boolean; $smallText: boolean }>`
  font-family: ${props => props.theme.fonts.web.contentFont};
  font-size: ${props => (props.$smallText ? helpers.adaptiveFontSize : props.theme.fonts.contentFontSize)};
  line-height: ${props => props.theme.fonts.contentLineHeight};
  display: flow-root; /* clearfix for the img floats */

  ${props => (props.$centered ? 'text-align: center;' : '')}
  ${props => (props.$centered ? 'list-style-position: inside;' : '')}
  img {
    max-width: 100%;
    max-height: 100%;
    object-fit: contain;

    &.alignright {
      float: inline-end;
    }

    &.alignleft {
      float: inline-start;
    }

    &.aligncenter {
      display: block;
      margin: auto;
    }
  }

  figure {
    margin-inline-start: 0;
    text-align: center;
    margin: 15px auto;

    @media only screen and (width <= 640px) {
      width: 100% !important;
    }
  }

  figcaption {
    font-size: ${props => props.theme.fonts.hintFontSize};
    font-style: italic;
    padding: 0 15px;
  }

  table {
    display: block;
    width: 100% !important;
    height: auto !important; /* need important because of badly formatted remote content */
    overflow: auto;
  }

  tbody,
  thead {
    display: table; /* little bit hacky, but works in all browsers, even IE11 :O */
    width: 100%;
    box-sizing: border-box;
    border-collapse: collapse;
  }

  tbody,
  thead,
  th,
  td {
    border: 1px solid ${props => props.theme.colors.backgroundAccentColor};
  }

  a {
    overflow-wrap: break-word;
  }

  details > * {
    padding: 0 25px;
  }

  details > summary {
    padding: 0;
  }

  pre {
    overflow-x: auto;
  }

  .link-external::after {
    content: '';
    display: inline-block;
    background-image: url('${ExternalLinkIcon}');
    width: ${props => props.theme.fonts.contentFontSize};
    height: ${props => props.theme.fonts.contentFontSize};
    ${props =>
      props.$smallText &&
      css`
        ${helpers.adaptiveHeight}
        ${helpers.adaptiveWidth}
      `};
    background-size: contain;
    background-repeat: no-repeat;
    vertical-align: middle;
    margin: 0 4px;
  }

  iframe {
    border: none;
    border-bottom: 1px solid ${props => props.theme.colors.borderColor};

    @media ${dimensions.smallViewport} {
      max-width: 100%;
    }
  }

  .iframe-container {
    display: flex;
    padding: 4px;
    flex-direction: column;
    border: 1px solid ${props => props.theme.colors.borderColor};
    border-radius: 4px;
    box-shadow:
      0 1px 3px rgb(0 0 0 / 10%),
      0 1px 2px rgb(0 0 0 / 15%);
  }

  .iframe-info-text {
    display: flex;
    padding: 12px;
    justify-content: space-between;
    font-size: ${props => props.theme.fonts.decorativeFontSizeSmall};
  }

  .iframe-info-text > input {
    margin-inline-start: 12px;
    cursor: pointer;
  }

  .iframe-info-text > label {
    cursor: pointer;
  }

  .iframe-source {
    display: contents;
    font-weight: bold;
  }

  #opt-in-settings-link {
    margin-inline-start: 12px;
    padding: 0;
    cursor: pointer;
    align-self: center;
  }
`

type RemoteContentProps = {
  html: string
  onInternalLinkClick: (url: string) => void
  centered?: boolean
  smallText?: boolean
}

const HIJACK = new RegExp(buildConfig().internalLinksHijackPattern)
const DOMPURIFY_TAG_IFRAME = 'iframe'
const DOMPURIFY_ATTRIBUTE_FULLSCREEN = 'allowfullscreen'
const DOMPURIFY_ATTRIBUTE_TARGET = 'target'

export type IframeSources = Record<number, string>
export const IFRAME_BLANK_SOURCE = 'about:blank'

const RemoteContent = ({
  html,
  onInternalLinkClick,
  centered = false,
  smallText = false,
}: RemoteContentProps): ReactElement => {
  const sandBoxRef = React.createRef<HTMLDivElement>()
  const { value: externalSourcePermissions, updateLocalStorageItem } = useLocalStorage<ExternalSourcePermissions>({
    key: LOCAL_STORAGE_ITEM_EXTERNAL_SOURCES,
    initialValue: {},
  })

  const [contentIframeSources, setContentIframeSources] = useState<IframeSources>({})
  const { viewportSmall, width: deviceWidth } = useWindowDimensions()
  const { t } = useTranslation()

  const handleClick = useCallback(
    (event: MouseEvent): void => {
      event.preventDefault()
      const target = event.currentTarget

      if (target instanceof HTMLAnchorElement) {
        const href = target.href
        const url = new URL(decodeURIComponent(href))
        onInternalLinkClick(decodeURIComponent(`${url.pathname}${url.search}${url.hash}`))
      }
    },
    [onInternalLinkClick],
  )

  const onUpdateLocalStorage = useCallback(
    (source: string): void => updateLocalStorageItem({ ...externalSourcePermissions, [source]: true }),
    [externalSourcePermissions, updateLocalStorageItem],
  )

  useEffect(() => {
    if (!sandBoxRef.current) {
      return
    }
    const currentSandBoxRef = sandBoxRef.current
    const collection = currentSandBoxRef.getElementsByTagName('a')
    Array.from(collection).forEach(node => {
      if (HIJACK.test(node.href)) {
        node.addEventListener('click', handleClick)
      }
    })

    const iframes = currentSandBoxRef.getElementsByTagName('iframe')
    Array.from(iframes).forEach((iframe: HTMLIFrameElement, index: number) => {
      if (iframe.src !== IFRAME_BLANK_SOURCE) {
        preserveIFrameSourcesFromContent(index, iframe.src, setContentIframeSources, contentIframeSources)
      }

      const storedIframeSource = contentIframeSources[index]
      const supportedSource = storedIframeSource
        ? buildConfig().supportedIframeSources.find(src => storedIframeSource.includes(src))
        : undefined

      hideIframe(iframe)
      if (supportedSource && storedIframeSource) {
        handleAllowedIframeSources(
          iframe,
          externalSourcePermissions,
          storedIframeSource,
          t,
          onUpdateLocalStorage,
          index,
          supportedSource,
          viewportSmall,
          deviceWidth,
        )
      }
    })
  }, [
    t,
    html,
    handleClick,
    sandBoxRef,
    externalSourcePermissions,
    contentIframeSources,
    onUpdateLocalStorage,
    viewportSmall,
    deviceWidth,
  ])

  const dangerouslySetInnerHTML = {
    __html: Dompurify.sanitize(html, {
      ADD_TAGS: [DOMPURIFY_TAG_IFRAME],
      ADD_ATTR: [DOMPURIFY_ATTRIBUTE_FULLSCREEN, DOMPURIFY_ATTRIBUTE_TARGET],
    }),
  }

  return (
    <SandBox
      dir='auto'
      $centered={centered}
      dangerouslySetInnerHTML={dangerouslySetInnerHTML}
      ref={sandBoxRef}
      $smallText={smallText}
    />
  )
}

export default RemoteContent