From f1b42a3d0d02ea5f1ffe055ce95a7d420415f7ca Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Mon, 12 Oct 2020 11:25:59 +0100 Subject: [PATCH] Merge pull request #3192 from overleaf/msm-chat-react Chat reactification GitOrigin-RevId: ee1268b412513a8656703257febad4975adb74e7 --- .../src/Features/Project/ProjectController.js | 3 +- services/web/app/views/project/editor.pug | 5 +- .../app/views/project/editor/chat-react.pug | 12 ++ .../frontend/extracted-translation-keys.json | 6 +- .../js/features/chat/components/chat-pane.js | 90 ++++++++++++++ .../chat/components/infinite-scroll.js | 117 ++++++++++++++++++ .../chat/components/message-content.js | 52 ++++++++ .../features/chat/components/message-input.js | 32 +++++ .../features/chat/components/message-list.js | 69 +++++++++++ .../js/features/chat/components/message.js | 72 +++++++++++ .../chat/controllers/chat-controller.js | 42 +++++++ services/web/frontend/js/ide/chat/index.js | 5 +- .../js/ide/chat/services/chatMessages.js | 13 +- .../frontend/stylesheets/_style_includes.less | 1 + .../components/infinite-scroll.less | 3 + services/web/package-lock.json | 47 +++++-- services/web/package.json | 1 + 17 files changed, 548 insertions(+), 22 deletions(-) create mode 100644 services/web/app/views/project/editor/chat-react.pug create mode 100644 services/web/frontend/js/features/chat/components/chat-pane.js create mode 100644 services/web/frontend/js/features/chat/components/infinite-scroll.js create mode 100644 services/web/frontend/js/features/chat/components/message-content.js create mode 100644 services/web/frontend/js/features/chat/components/message-input.js create mode 100644 services/web/frontend/js/features/chat/components/message-list.js create mode 100644 services/web/frontend/js/features/chat/components/message.js create mode 100644 services/web/frontend/js/features/chat/controllers/chat-controller.js create mode 100644 services/web/frontend/stylesheets/components/infinite-scroll.less diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 7bc657a558..731ec08a22 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -842,7 +842,8 @@ const ProjectController = { gaOptimize: enableOptimize, customOptimizeEvent: true, experimentId: Settings.experimentId, - showNewLogsUI: req.query && req.query.new_logs_ui === 'true' + showNewLogsUI: req.query && req.query.new_logs_ui === 'true', + showNewChatUI: req.query && req.query.new_chat_ui === 'true' }) timer.done() } diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug index 6ae08442d9..e9a8b8fc07 100644 --- a/services/web/app/views/project/editor.pug +++ b/services/web/app/views/project/editor.pug @@ -102,7 +102,10 @@ block content if !isRestrictedTokenMember .ui-layout-east - include ./editor/chat + if showNewChatUI + include ./editor/chat-react + else + include ./editor/chat include ./editor/hotkeys diff --git a/services/web/app/views/project/editor/chat-react.pug b/services/web/app/views/project/editor/chat-react.pug new file mode 100644 index 0000000000..ff227c26e2 --- /dev/null +++ b/services/web/app/views/project/editor/chat-react.pug @@ -0,0 +1,12 @@ +aside.chat( + ng-controller="ReactChatController" +) + chat( + at-end="atEnd" + loading="loading" + load-more-messages="loadMoreMessages" + messages="messages" + reset-unread-messages="resetUnreadMessages" + send-message="sendMessage" + user-id="userId" + ) diff --git a/services/web/frontend/extracted-translation-keys.json b/services/web/frontend/extracted-translation-keys.json index 1aa9cccd6b..12aa76d6e8 100644 --- a/services/web/frontend/extracted-translation-keys.json +++ b/services/web/frontend/extracted-translation-keys.json @@ -17,5 +17,9 @@ "fast", "stop_on_validation_error", "ignore_validation_errors", - "run_syntax_check_now" + "run_syntax_check_now", + "loading", + "no_messages", + "send_first_message", + "your_message" ] diff --git a/services/web/frontend/js/features/chat/components/chat-pane.js b/services/web/frontend/js/features/chat/components/chat-pane.js new file mode 100644 index 0000000000..ab8e21660f --- /dev/null +++ b/services/web/frontend/js/features/chat/components/chat-pane.js @@ -0,0 +1,90 @@ +import React, { useEffect } from 'react' +import PropTypes from 'prop-types' +import MessageList from './message-list' +import MessageInput from './message-input' +import InfiniteScroll from './infinite-scroll' +import Icon from '../../../shared/components/icon' +import { useTranslation } from 'react-i18next' + +function ChatPane({ + atEnd, + loading, + loadMoreMessages, + messages, + resetUnreadMessages, + sendMessage, + userId +}) { + useEffect(() => { + loadMoreMessages() + }, []) + + const shouldDisplayPlaceholder = !loading && messages.length === 0 + + const messageContentCount = messages.reduce( + (acc, { contents }) => acc + contents.length, + 0 + ) + + return ( + + ) +} + +function LoadingSpinner() { + const { t } = useTranslation() + return ( +
+ + {` ${t('loading')}...`} +
+ ) +} + +function Placeholder() { + const { t } = useTranslation() + return ( + <> +
{t('no_messages')}
+
+ {t('send_first_message')} +
+ +
+ + ) +} + +ChatPane.propTypes = { + atEnd: PropTypes.bool, + loading: PropTypes.bool, + loadMoreMessages: PropTypes.func.isRequired, + messages: PropTypes.array.isRequired, + resetUnreadMessages: PropTypes.func.isRequired, + sendMessage: PropTypes.func.isRequired, + userId: PropTypes.string.isRequired +} + +export default ChatPane diff --git a/services/web/frontend/js/features/chat/components/infinite-scroll.js b/services/web/frontend/js/features/chat/components/infinite-scroll.js new file mode 100644 index 0000000000..bc70025e66 --- /dev/null +++ b/services/web/frontend/js/features/chat/components/infinite-scroll.js @@ -0,0 +1,117 @@ +import React, { useRef, useEffect } from 'react' +import PropTypes from 'prop-types' + +const SCROLL_END_OFFSET = 30 + +function usePrevious(value) { + const ref = useRef() + useEffect(() => { + ref.current = value + }) + return ref.current +} + +function InfiniteScroll({ + atEnd, + children, + className = '', + fetchData, + itemCount, + isLoading +}) { + const root = useRef(null) + + const prevItemCount = usePrevious(itemCount) + + // we keep the value in a Ref instead of state so it can be safely used in effects + const scrollBottomRef = React.useRef(0) + function setScrollBottom(value) { + scrollBottomRef.current = value + } + + // position updates are not immediately applied. The DOM frequently can't calculate + // element bounds after react updates, so it needs some throttling + function scheduleScrollPositionUpdate(throttle) { + const timeoutHandler = setTimeout( + () => + (root.current.scrollTop = + root.current.scrollHeight - + root.current.clientHeight - + scrollBottomRef.current), + throttle + ) + return () => clearTimeout(timeoutHandler) + } + + // Repositions the scroll after new items are loaded + useEffect( + () => { + // the first render requires a longer throttling due to slower DOM updates + const scrollThrottle = prevItemCount === 0 ? 150 : 0 + return scheduleScrollPositionUpdate(scrollThrottle) + }, + [itemCount, prevItemCount] + ) + + // Repositions the scroll after a window resize + useEffect(() => { + let clearScrollPositionUpdate + const handleResize = () => { + clearScrollPositionUpdate = scheduleScrollPositionUpdate(400) + } + window.addEventListener('resize', handleResize) + return () => { + window.removeEventListener('resize', handleResize) + if (clearScrollPositionUpdate) { + clearScrollPositionUpdate() + } + } + }, []) + + function onScrollHandler(event) { + setScrollBottom( + root.current.scrollHeight - + root.current.scrollTop - + root.current.clientHeight + ) + if (event.target !== event.currentTarget) { + // Ignore scroll events on nested divs + // (this check won't be necessary in React 17: https://github.com/facebook/react/issues/15723 + return + } + if (shouldFetchData()) { + fetchData() + } + } + + function shouldFetchData() { + const containerIsLargerThanContent = + root.current.children[0].clientHeight < root.current.clientHeight + if (atEnd || isLoading || containerIsLargerThanContent) { + return false + } else { + return root.current.scrollTop < SCROLL_END_OFFSET + } + } + + return ( +
+ {children} +
+ ) +} + +InfiniteScroll.propTypes = { + atEnd: PropTypes.bool, + children: PropTypes.element.isRequired, + className: PropTypes.string, + fetchData: PropTypes.func.isRequired, + itemCount: PropTypes.number.isRequired, + isLoading: PropTypes.bool +} + +export default InfiniteScroll diff --git a/services/web/frontend/js/features/chat/components/message-content.js b/services/web/frontend/js/features/chat/components/message-content.js new file mode 100644 index 0000000000..9829d91bbf --- /dev/null +++ b/services/web/frontend/js/features/chat/components/message-content.js @@ -0,0 +1,52 @@ +import React, { useRef, useEffect } from 'react' +import PropTypes from 'prop-types' +import Linkify from 'react-linkify' + +function MessageContent({ content }) { + const root = useRef(null) + + useEffect(() => { + const MJHub = window.MathJax.Hub + const inlineMathConfig = MJHub.config && MJHub.config.tex2jax.inlineMath + const alreadyConfigured = inlineMathConfig.some( + c => c[0] === '$' && c[1] === '$' + ) + if (!alreadyConfigured) { + MJHub.Config({ + tex2jax: { + inlineMath: inlineMathConfig.concat([['$', '$']]) + } + }) + } + }, []) + + useEffect( + () => { + // adds attributes to all the links generated by , required due to https://github.com/tasti/react-linkify/issues/99 + root.current.getElementsByTagName('a').forEach(a => { + a.setAttribute('target', '_blank') + a.setAttribute('rel', 'noreferrer noopener') + }) + + // MathJax typesetting + const MJHub = window.MathJax.Hub + const timeoutHandler = setTimeout(() => { + MJHub.Queue(['Typeset', MJHub, root.current]) + }, 0) + return () => clearTimeout(timeoutHandler) + }, + [content] + ) + + return ( +

+ {content} +

+ ) +} + +MessageContent.propTypes = { + content: PropTypes.string.isRequired +} + +export default MessageContent diff --git a/services/web/frontend/js/features/chat/components/message-input.js b/services/web/frontend/js/features/chat/components/message-input.js new file mode 100644 index 0000000000..56ff069674 --- /dev/null +++ b/services/web/frontend/js/features/chat/components/message-input.js @@ -0,0 +1,32 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { useTranslation } from 'react-i18next' + +function MessageInput({ resetUnreadMessages, sendMessage }) { + const { t } = useTranslation() + + function handleKeyDown(event) { + if (event.key === 'Enter') { + event.preventDefault() + sendMessage(event.target.value) + event.target.value = '' // clears the textarea content + } + } + + return ( +
+