From 7e4299d1a19675223dcde636a7b42dd834b8b6ed Mon Sep 17 00:00:00 2001 From: Benjamin Piouffle Date: Wed, 19 Jun 2024 18:15:56 +0200 Subject: [PATCH] feat: Transcript --- app/components/Statements/StatementsList.jsx | 15 +- app/components/StyledUtils/Text.jsx | 12 +- .../VideoDebate/ActionBubbleMenu.jsx | 45 +++- .../VideoDebate/CaptionsExtractor.jsx | 241 ++++++++++++++++++ app/components/VideoDebate/ColumnDebate.jsx | 12 +- app/components/VideoDebate/ColumnVideo.jsx | 20 +- app/i18n/en/videoDebate.json | 8 +- app/i18n/fr/videoDebate.json | 8 +- app/lib/cf_routes.js | 4 + app/lib/hooks/use-debounce.js | 21 ++ app/router.jsx | 6 +- app/state/video_debate/statements/reducer.js | 9 +- .../VideoDebate/action_bubble_menu.sass | 5 + package-lock.json | 68 +++++ package.json | 3 + 15 files changed, 448 insertions(+), 29 deletions(-) create mode 100644 app/components/VideoDebate/CaptionsExtractor.jsx create mode 100644 app/lib/hooks/use-debounce.js diff --git a/app/components/Statements/StatementsList.jsx b/app/components/Statements/StatementsList.jsx index 0b11cae19..63724e9e6 100644 --- a/app/components/Statements/StatementsList.jsx +++ b/app/components/Statements/StatementsList.jsx @@ -1,3 +1,4 @@ +import memoizeOne from 'memoize-one' import React from 'react' import FlipMove from 'react-flip-move' import { withNamespaces } from 'react-i18next' @@ -16,6 +17,8 @@ import { StatementForm } from './StatementForm' speakers: state.VideoDebate.video.data.speakers, statements: state.VideoDebate.statements.data, statementFormSpeakerId: statementFormValueSelector(state, 'speaker_id'), + statementFormText: statementFormValueSelector(state, 'text'), + statementFormTime: statementFormValueSelector(state, 'time'), offset: state.VideoDebate.video.offset, }), { closeStatementForm, postStatement, setScrollTo }, @@ -45,6 +48,12 @@ export default class StatementsList extends React.PureComponent { } } + getInitialValues = memoizeOne((speakerId, text, time) => ({ + speaker_id: speakerId, + text, + time, + })) + render() { const { speakers, statementFormSpeakerId, statements, offset } = this.props const speakerId = @@ -54,7 +63,11 @@ export default class StatementsList extends React.PureComponent { {statementFormSpeakerId !== undefined && ( this.props.closeStatementForm()} diff --git a/app/components/StyledUtils/Text.jsx b/app/components/StyledUtils/Text.jsx index 94815d65a..8f618c006 100644 --- a/app/components/StyledUtils/Text.jsx +++ b/app/components/StyledUtils/Text.jsx @@ -1,5 +1,14 @@ import styled from 'styled-components' -import { color, display, fontSize, fontStyle, fontWeight, space, textAlign } from 'styled-system' +import { + color, + display, + fontSize, + fontStyle, + fontWeight, + lineHeight, + space, + textAlign, +} from 'styled-system' export const Span = styled.span` ${color} @@ -25,4 +34,5 @@ export const P = styled.p` ${space} ${display} ${textAlign} + ${lineHeight} ` diff --git a/app/components/VideoDebate/ActionBubbleMenu.jsx b/app/components/VideoDebate/ActionBubbleMenu.jsx index bb7c1b36d..6405e4920 100644 --- a/app/components/VideoDebate/ActionBubbleMenu.jsx +++ b/app/components/VideoDebate/ActionBubbleMenu.jsx @@ -5,8 +5,9 @@ import { connect } from 'react-redux' import { withRouter } from 'react-router-dom' import { destroyStatementForm } from '../../state/video_debate/statements/effects' -import { changeStatementFormSpeaker } from '../../state/video_debate/statements/reducer' +import { changeStatementForm } from '../../state/video_debate/statements/reducer' import { hasStatementForm } from '../../state/video_debate/statements/selectors' +import { forcePosition, setPlaying } from '../../state/video_debate/video/reducer' import { withLoggedInUser } from '../LoggedInUser/UserProvider' import { Icon } from '../Utils/Icon' @@ -17,8 +18,10 @@ import { Icon } from '../Utils/Icon' hasStatementForm: hasStatementForm(state), }), { - changeStatementFormSpeaker, + changeStatementForm, destroyStatementForm, + forcePosition, + setPlaying, }, ) @withNamespaces('videoDebate') @@ -26,21 +29,34 @@ import { Icon } from '../Utils/Icon' @withLoggedInUser export default class ActionBubbleMenu extends React.PureComponent { render() { - const { t, hasStatementForm, isAuthenticated } = this.props - + const { t, hasStatementForm, isAuthenticated, hidden } = this.props return (
{isAuthenticated ? ( - this.onStatementBubbleClick()} - /> + + !hidden && this.onStatementBubbleClick()} + /> + {!isNaN(this.props.playSelectionPosition) && ( + { + this.props.setPlaying(true) + this.props.forcePosition(this.props.playSelectionPosition) + }} + /> + )} + ) : ( { + if ($isPlaying) { + if ($isCurrent) { + return css` + text-shadow: #9f9f9f 1px 1px 0px; + color: #000; + ` + } else if (!$isPast) { + return css` + color: #999; + ` + } + } + }} +` + +// A statement is displayed before each caption whe +const getStatementsAtPosition = (statements, caption, nextCaption) => { + const selected = [] + for (const statement of statements) { + if (statement.time >= caption.start) { + if (!nextCaption || statement.time < nextCaption.start) { + selected.push(statement) + } else { + break + } + } + } + + return selected +} + +const Arrow = styled.div` + visibility: hidden; + + &, + &::before { + position: absolute; + width: 8px; + height: 8px; + background: white; + } + + &::before { + visibility: visible; + content: ''; + transform: rotate(45deg); + } +` + +const StatementTooltip = styled.div` + background: white; + border: 1px solid #ccc; + border-radius: 8px; + padding: 8px; + font-size: 12px; + + &[data-popper-placement^='top'] ${Arrow} { + bottom: -4px; + } + &[data-popper-placement^='bottom'] ${Arrow} { + top: -4px; + } + &[data-popper-placement^='left'] ${Arrow} { + right: -4px; + } + &[data-popper-placement^='right'] ${Arrow} { + left: -4px; + } +` + +const StatementIconButton = styled(UnstyledButton)` + background: white; + border: 1px solid #ccc; + border-radius: 50%; + padding: 4px; + margin-right: 4px; + cursor: pointer; + width: 34px; + height: 34px; + &:hover { + background: #f9f9f9; + } +` + +const StatementIndicator = ({ statement }) => { + const [referenceElement, setReferenceElement] = React.useState(null) + const [popperElement, setPopperElement] = React.useState(null) + const [arrowElement, setArrowElement] = React.useState(null) + const [show, setShow] = React.useState(false) + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: 'top', + modifiers: [ + { name: 'arrow', options: { element: arrowElement } }, + { name: 'offset', options: { offset: [0, 8] } }, + { name: 'flip', options: { fallbackPlacements: ['bottom'] } }, + { name: 'preventOverflow', options: { padding: 8 } }, + ], + }) + + return ( + <> + setShow(true)} + onBlur={() => setShow(false)} + > + + + + {show && ( + + + + + )} + + ) +} + +const CaptionsExtractor = ({ t, videoId, playbackPosition, statements }) => { + const { data, loading, error } = useQuery(captionsQuery, { variables: { videoId } }) + const [selection, setSelection] = React.useState({ text: null }) + const textContainerRef = React.useRef() + + // Watch for selection changes + React.useEffect(() => { + const watchForSelectionChange = debounce(() => { + if (!textContainerRef.current) { + return + } + + const selection = document.getSelection() + if (selection.rangeCount > 0) { + const range = selection.getRangeAt(0) + if (textContainerRef.current.contains(range.commonAncestorContainer)) { + const firstNode = range.startContainer.parentNode + setSelection({ + text: selection.toString(), + start: parseFloat(firstNode.getAttribute('data-start')) || 0, + }) + } + } + }, 100) + + document.addEventListener('selectionchange', watchForSelectionChange) + return () => { + document.removeEventListener('selectionchange', watchForSelectionChange) + } + }, []) + + if (loading) { + return + } else if (error) { + // eslint-disable-next-line no-console + console.error(error) + return {t('captions.errorLoading')} + } else if (!data.video.captions?.length) { + return {t('captions.notFound')} + } + + return ( + +

+ {data.video.captions.map((caption, index) => { + const nextCaption = data.video.captions[index + 1] + const statementsAtPosition = getStatementsAtPosition(statements, caption, nextCaption) + return ( + + {statementsAtPosition.map((statement) => ( + + ))} + caption.start + caption.duration} + $isCurrent={ + playbackPosition >= caption.start && + playbackPosition <= caption.start + caption.duration + } + > + {caption.text} + {' '} + + ) + })} +

+ +
+ ) +} + +export default connect((state) => ({ + statements: state.VideoDebate.statements.data, + playbackPosition: state.VideoDebate.video.playback.position, +}))(withNamespaces('videoDebate')(CaptionsExtractor)) diff --git a/app/components/VideoDebate/ColumnDebate.jsx b/app/components/VideoDebate/ColumnDebate.jsx index 39cfb58fa..299166c08 100644 --- a/app/components/VideoDebate/ColumnDebate.jsx +++ b/app/components/VideoDebate/ColumnDebate.jsx @@ -10,16 +10,19 @@ import { isLoadingVideoDebate } from '../../state/video_debate/selectors' import { hasStatementForm } from '../../state/video_debate/statements/selectors' import { withLoggedInUser } from '../LoggedInUser/UserProvider' import StatementsList from '../Statements/StatementsList' +import Container from '../StyledUtils/Container' import DismissableMessage from '../Utils/DismissableMessage' import ExternalLinkNewTab from '../Utils/ExternalLinkNewTab' import { Icon } from '../Utils/Icon' import { LoadingFrame } from '../Utils/LoadingFrame' import Message from '../Utils/Message' import ActionBubbleMenu from './ActionBubbleMenu' +import CaptionsExtractor from './CaptionsExtractor' import VideoDebateHistory from './VideoDebateHistory' const TitleContainer = styled.div` padding: 1.5rem; + margin-top: 1rem; ` const TitleH1 = styled.h1` color: #0a0a0a; @@ -90,8 +93,13 @@ export class ColumnDebate extends React.PureComponent { if (view === 'history') { return - } - if (view === 'debate') { + } else if (view === 'captions') { + return ( + + + + ) + } else if (view === 'debate') { if (isLoading) { return } diff --git a/app/components/VideoDebate/ColumnVideo.jsx b/app/components/VideoDebate/ColumnVideo.jsx index 5f50e50db..5c921bcdd 100644 --- a/app/components/VideoDebate/ColumnVideo.jsx +++ b/app/components/VideoDebate/ColumnVideo.jsx @@ -1,4 +1,5 @@ import { Flex } from '@rebass/grid' +import { FileText } from '@styled-icons/feather' import classNames from 'classnames' import React from 'react' import { withNamespaces } from 'react-i18next' @@ -6,7 +7,7 @@ import { connect } from 'react-redux' import { Link } from 'react-router-dom' import { MIN_REPUTATION_ADD_SPEAKER } from '../../constants' -import { videoHistoryURL, videoURL } from '../../lib/cf_routes' +import { videoCaptionsUrl, videoHistoryURL, videoURL } from '../../lib/cf_routes' import { videoDebateOnlineUsersCount, videoDebateOnlineViewersCount, @@ -15,7 +16,6 @@ import { withLoggedInUser } from '../LoggedInUser/UserProvider' import AddSpeakerForm from '../Speakers/AddSpeakerForm' import { SpeakerPreview } from '../Speakers/SpeakerPreview' import { Icon, LoadingFrame } from '../Utils' -import ExternalLinkNewTab from '../Utils/ExternalLinkNewTab' import ReputationGuardTooltip from '../Utils/ReputationGuardTooltip' import Actions from './Actions' import Presence from './Presence' @@ -40,8 +40,7 @@ export class ColumnVideo extends React.PureComponent { const { video, view, t } = this.props const { url, title, speakers } = video - const isDebate = view === 'debate' - + const isDebate = !view || view === 'debate' return (
@@ -58,17 +57,18 @@ export class ColumnVideo extends React.PureComponent { {t('debate')} -
  • +
  • {t('history')}
  • -
  • - - - {t('chat')} - +
  • + + +   + {t('captions.title')} +
  • diff --git a/app/i18n/en/videoDebate.json b/app/i18n/en/videoDebate.json index 6af223aa6..bfbdfac8a 100644 --- a/app/i18n/en/videoDebate.json +++ b/app/i18n/en/videoDebate.json @@ -107,9 +107,15 @@ "help10": "- Is this a personal opinion?", "helpLink": "Read the contribution guidelines" }, + "captions": { + "errorLoading": "An error occurred while loading the captions", + "notFound": "No captions found", + "title": "Transcript" + }, "loading": { "statements": "Loading Statements", - "video": "Loading Video" + "video": "Loading Video", + "captions": "Loading Captions" }, "tips": { "firstStatement": "You can <1>add a first statement by clicking on the <3> icon next to the speaker's name", diff --git a/app/i18n/fr/videoDebate.json b/app/i18n/fr/videoDebate.json index b709e687f..8212f3987 100644 --- a/app/i18n/fr/videoDebate.json +++ b/app/i18n/fr/videoDebate.json @@ -107,9 +107,15 @@ "help10": "- les opinions personnelles.", "helpLink": "Voir le guide de contribution complet" }, + "captions": { + "errorLoading": "Erreur lors de la récupération des sous-titres", + "notFound": "Aucun sous-titre n'est disponible pour cette vidéo", + "title": "Transcription" + }, "loading": { "statements": "Chargement des textes", - "video": "Chargement de la video" + "video": "Chargement de la video", + "captions": "Chargement des sous-titres" }, "tips": { "firstStatement": "Vous pouvez <1>ajouter une citation en cliquant sur l'icone <3> à côté du nom de l'intervenant", diff --git a/app/lib/cf_routes.js b/app/lib/cf_routes.js index e2d1ad361..4ca6e6145 100644 --- a/app/lib/cf_routes.js +++ b/app/lib/cf_routes.js @@ -19,6 +19,10 @@ export const videoHistoryURL = (videoHashID) => { return `${videoURL(videoHashID)}/history` } +export const videoCaptionsUrl = (videoHashID) => { + return `${videoURL(videoHashID)}/captions` +} + export const statementURL = (videoHashID, statementID) => { return `${videoURL(videoHashID)}?statement=${statementID}` } diff --git a/app/lib/hooks/use-debounce.js b/app/lib/hooks/use-debounce.js new file mode 100644 index 000000000..b98ac5f96 --- /dev/null +++ b/app/lib/hooks/use-debounce.js @@ -0,0 +1,21 @@ +import { debounce } from 'lodash' +import { useCallback, useEffect } from 'react' + +export const useDebounce = (callback, delay) => { + // Memoize the debounced function + const debouncedCallback = useCallback( + debounce((...args) => { + callback(...args) + }, delay), + [callback, delay], + ) + + // Clean up the debounced function on unmount + useEffect(() => { + return () => { + debouncedCallback.cancel() + } + }, [debouncedCallback]) + + return debouncedCallback +} diff --git a/app/router.jsx b/app/router.jsx index ea3f68a68..d0554df63 100644 --- a/app/router.jsx +++ b/app/router.jsx @@ -56,7 +56,11 @@ const CFRouter = () => ( - + diff --git a/app/state/video_debate/statements/reducer.js b/app/state/video_debate/statements/reducer.js index be3d7256f..78d4475d1 100644 --- a/app/state/video_debate/statements/reducer.js +++ b/app/state/video_debate/statements/reducer.js @@ -1,5 +1,5 @@ import { List, Record } from 'immutable' -import { orderBy } from 'lodash' +import { isUndefined, omitBy, orderBy } from 'lodash' import { combineActions, createAction, handleActions } from 'redux-actions' import { change, destroy } from 'redux-form' @@ -21,6 +21,13 @@ export const decrementFormCount = createAction('STATEMENTS/DECREMENT_FORM_COUNT' export const STATEMENT_FORM_NAME = 'StatementForm' export const changeStatementFormSpeaker = ({ id }) => change(STATEMENT_FORM_NAME, 'speaker_id', id) export const closeStatementForm = () => destroy(STATEMENT_FORM_NAME) +export const changeStatementForm = (values) => { + return (dispatch) => { + Object.entries(omitBy(values, isUndefined)).forEach(([key, value]) => + dispatch(change(STATEMENT_FORM_NAME, key, value)), + ) + } +} const INITIAL_STATE = new Record({ isLoading: false, diff --git a/app/styles/_components/VideoDebate/action_bubble_menu.sass b/app/styles/_components/VideoDebate/action_bubble_menu.sass index c6de67976..2ae028aaa 100644 --- a/app/styles/_components/VideoDebate/action_bubble_menu.sass +++ b/app/styles/_components/VideoDebate/action_bubble_menu.sass @@ -11,6 +11,8 @@ $sub-bubble-icon-size: 24px right: $bubble-margin bottom: $bubble-margin position: fixed + opacity: 1 + transition: bottom 0.3s, opacity 0.3s z-index: 99 &:not(:hover) .action-bubble:not(:first-child) display: none @@ -19,6 +21,9 @@ $sub-bubble-icon-size: 24px display: block @media(pointer: none) display: block + &.hiddenBelow + bottom: -100px + opacity: 0 .action-bubble height: $main-bubble-height diff --git a/package-lock.json b/package-lock.json index 50f52e919..1652c903b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.2.1", "dependencies": { "@apollo/client": "^3.9.4", + "@popperjs/core": "2.11.8", "@rebass/grid": "~6.0.0-5", "@styled-icons/feather": "^10.47.0", "algoliasearch": "4.22.1", @@ -28,6 +29,7 @@ "is-promise": "~4.0.0", "isomorphic-fetch": "~3.0.0", "lodash": "^4.17.21", + "memoize-one": "6.0.0", "phoenix": "~1.7.10", "polished": "^4.3.1", "prop-types": "~15.8.1", @@ -41,6 +43,7 @@ "react-instantsearch-dom": "6.40.4", "react-markdown": "~8.0.7", "react-player": "~2.14.1", + "react-popper": "2.3.0", "react-redux": "~7.2.9", "react-resize-detector": "~9.1.1", "react-router": "5.3.4", @@ -3423,6 +3426,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@rebass/grid": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@rebass/grid/-/grid-6.0.0.tgz", @@ -17506,6 +17518,25 @@ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" }, + "node_modules/react-popper": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz", + "integrity": "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==", + "dependencies": { + "react-fast-compare": "^3.0.1", + "warning": "^4.0.2" + }, + "peerDependencies": { + "@popperjs/core": "^2.0.0", + "react": "^16.8.0 || ^17 || ^18", + "react-dom": "^16.8.0 || ^17 || ^18" + } + }, + "node_modules/react-popper/node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" + }, "node_modules/react-redux": { "version": "7.2.9", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", @@ -20469,6 +20500,14 @@ "makeerror": "1.0.12" } }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", @@ -23692,6 +23731,11 @@ } } }, + "@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==" + }, "@rebass/grid": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@rebass/grid/-/grid-6.0.0.tgz", @@ -34029,6 +34073,22 @@ } } }, + "react-popper": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz", + "integrity": "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==", + "requires": { + "react-fast-compare": "^3.0.1", + "warning": "^4.0.2" + }, + "dependencies": { + "react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" + } + } + }, "react-redux": { "version": "7.2.9", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", @@ -36278,6 +36338,14 @@ "makeerror": "1.0.12" } }, + "warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "requires": { + "loose-envify": "^1.0.0" + } + }, "watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", diff --git a/package.json b/package.json index 1e88174e9..7b8874513 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ }, "dependencies": { "@apollo/client": "^3.9.4", + "@popperjs/core": "2.11.8", "@rebass/grid": "~6.0.0-5", "@styled-icons/feather": "^10.47.0", "algoliasearch": "4.22.1", @@ -70,6 +71,7 @@ "is-promise": "~4.0.0", "isomorphic-fetch": "~3.0.0", "lodash": "^4.17.21", + "memoize-one": "6.0.0", "phoenix": "~1.7.10", "polished": "^4.3.1", "prop-types": "~15.8.1", @@ -83,6 +85,7 @@ "react-instantsearch-dom": "6.40.4", "react-markdown": "~8.0.7", "react-player": "~2.14.1", + "react-popper": "2.3.0", "react-redux": "~7.2.9", "react-resize-detector": "~9.1.1", "react-router": "5.3.4",