Merge pull request #3192 from overleaf/msm-chat-react

Chat reactification

GitOrigin-RevId: ee1268b412513a8656703257febad4975adb74e7
This commit is contained in:
Alasdair Smith 2020-10-12 11:25:59 +01:00 committed by Copybot
parent 3ca5c4b26a
commit f1b42a3d0d
17 changed files with 548 additions and 22 deletions

View file

@ -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()
}

View file

@ -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

View file

@ -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"
)

View file

@ -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"
]

View file

@ -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 (
<aside className="chat">
<InfiniteScroll
atEnd={atEnd}
className="messages"
fetchData={loadMoreMessages}
isLoading={loading}
itemCount={messageContentCount}
>
<div>
{loading && <LoadingSpinner />}
{shouldDisplayPlaceholder && <Placeholder />}
<MessageList
messages={messages}
userId={userId}
resetUnreadMessages={resetUnreadMessages}
/>
</div>
</InfiniteScroll>
<MessageInput
resetUnreadMessages={resetUnreadMessages}
sendMessage={sendMessage}
/>
</aside>
)
}
function LoadingSpinner() {
const { t } = useTranslation()
return (
<div className="loading">
<Icon type="fw" modifier="refresh" spin />
{` ${t('loading')}...`}
</div>
)
}
function Placeholder() {
const { t } = useTranslation()
return (
<>
<div className="no-messages text-center small">{t('no_messages')}</div>
<div className="first-message text-center">
{t('send_first_message')}
<br />
<Icon type="arrow-down" />
</div>
</>
)
}
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

View file

@ -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 (
<div
ref={root}
onScroll={onScrollHandler}
className={`infinite-scroll ${className}`}
>
{children}
</div>
)
}
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

View file

@ -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 <Linkify/>, 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 (
<p ref={root}>
<Linkify>{content}</Linkify>
</p>
)
}
MessageContent.propTypes = {
content: PropTypes.string.isRequired
}
export default MessageContent

View file

@ -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 (
<div className="new-message">
<textarea
placeholder={`${t('your_message')}...`}
onKeyDown={handleKeyDown}
onClick={resetUnreadMessages}
/>
</div>
)
}
MessageInput.propTypes = {
resetUnreadMessages: PropTypes.func.isRequired,
sendMessage: PropTypes.func.isRequired
}
export default MessageInput

View file

@ -0,0 +1,69 @@
import React from 'react'
import PropTypes from 'prop-types'
import moment from 'moment'
import Message from './message'
const FIVE_MINUTES = 5 * 60 * 1000
function formatTimestamp(date) {
if (!date) {
return 'N/A'
} else {
return `${moment(date).format('h:mm a')} ${moment(date).calendar()}`
}
}
function indexFromEnd(list, index) {
return list.length - index - 1
}
function MessageList({ messages, resetUnreadMessages, userId }) {
function shouldRenderDate(messageIndex) {
if (messageIndex === 0) {
return true
} else {
const message = messages[messageIndex]
const previousMessage = messages[messageIndex - 1]
return message.timestamp - previousMessage.timestamp > FIVE_MINUTES
}
}
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<ul
className="list-unstyled"
onClick={resetUnreadMessages}
onKeyDown={resetUnreadMessages}
>
{messages.map((message, index) => (
// new messages are added to the beginning of the list, so we use a reversed index
<li key={indexFromEnd(messages, index)} className="message">
{shouldRenderDate(index) && (
<div className="date">
<time
dateTime={
message.timestamp
? moment(message.timestamp).format()
: undefined
}
>
{formatTimestamp(message.timestamp)}
</time>
</div>
)}
<Message message={message} userId={userId} />
</li>
))}
</ul>
)
}
MessageList.propTypes = {
messages: PropTypes.arrayOf(
PropTypes.shape({ timestamp: PropTypes.instanceOf(Date) })
).isRequired,
resetUnreadMessages: PropTypes.func.isRequired,
userId: PropTypes.string.isRequired
}
export default MessageList

View file

@ -0,0 +1,72 @@
import React from 'react'
import PropTypes from 'prop-types'
import ColorManager from '../../../ide/colors/ColorManager'
import MessageContent from './message-content'
function Message({ message, userId }) {
const {
chatMessageBorderSaturation,
chatMessageBorderLightness,
chatMessageBgSaturation,
chatMessageBgLightness
} = window.uiConfig
function hue(user) {
return user ? ColorManager.getHueForUserId(user.id) : 0
}
function getMessageStyle(user) {
return {
borderColor: `hsl(${hue(
user
)}, ${chatMessageBorderSaturation}, ${chatMessageBorderLightness})`,
backgroundColor: `hsl(${hue(
user
)}, ${chatMessageBgSaturation}, ${chatMessageBgLightness})`
}
}
function getArrowStyle(user) {
return {
borderColor: `hsl(${hue(
user
)}, ${chatMessageBorderSaturation}, ${chatMessageBorderLightness})`
}
}
const isMessageFromSelf = message.user ? message.user.id === userId : false
return (
<div className="message-wrapper">
{!isMessageFromSelf && (
<div className="name">
<span>{message.user.first_name || message.user.email}</span>
</div>
)}
<div className="message" style={getMessageStyle(message.user)}>
{!isMessageFromSelf && (
<div className="arrow" style={getArrowStyle(message.user)} />
)}
<div className="message-content">
{message.contents.map((content, index) => (
<MessageContent key={index} content={content} />
))}
</div>
</div>
</div>
)
}
Message.propTypes = {
message: PropTypes.shape({
contents: PropTypes.arrayOf(PropTypes.string).isRequired,
user: PropTypes.shape({
id: PropTypes.string,
email: PropTypes.string,
first_name: PropTypes.string
})
}),
userId: PropTypes.string.isRequired
}
export default Message

View file

@ -0,0 +1,42 @@
import App from '../../../base'
import { react2angular } from 'react2angular'
import ChatPane from '../components/chat-pane'
App.controller('ReactChatController', function($scope, chatMessages, ide) {
ide.$scope.$on('chat:more-messages-loaded', onMoreMessagesLoaded)
function onMoreMessagesLoaded(e, chat) {
ide.$scope.$applyAsync(() => {
$scope.atEnd = chatMessages.state.atEnd
$scope.loading = chat.state.loading
$scope.messages = chat.state.messages.slice(0) // passing a new reference to trigger a prop update on react
})
}
ide.$scope.$on('chat:more-messages-loading', onMoreMessagesLoading)
function onMoreMessagesLoading(e, chat) {
ide.$scope.$applyAsync(() => {
$scope.loading = true
})
}
function sendMessage(message) {
if (message) {
chatMessages.sendMessage(message)
ide.$scope.$broadcast('chat:newMessage', message)
}
}
function resetUnreadMessages() {
ide.$scope.$broadcast('chat:resetUnreadMessages')
}
$scope.atEnd = chatMessages.state.atEnd
$scope.loading = chatMessages.state.loading
$scope.loadMoreMessages = chatMessages.loadMoreMessages
$scope.messages = chatMessages.state.messages
$scope.resetUnreadMessages = resetUnreadMessages
$scope.sendMessage = sendMessage
$scope.userId = ide.$scope.user.id
})
App.component('chat', react2angular(ChatPane))

View file

@ -1,9 +1,6 @@
/* eslint-disable
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
import './controllers/ChatButtonController'
import './controllers/ChatController'
import './controllers/ChatMessageController'
import '../../directives/mathjax'
import '../../filters/wrapLongWords'
import '../../features/chat/controllers/chat-controller'

View file

@ -73,6 +73,7 @@ export default App.factory('chatMessages', function($http, ide) {
url += `&before=${chat.state.nextBeforeTimestamp}`
}
chat.state.loading = true
ide.$scope.$broadcast('chat:more-messages-loading', chat)
return $http.get(url).then(function(response) {
const messages = response.data != null ? response.data : []
chat.state.loading = false
@ -94,15 +95,16 @@ export default App.factory('chatMessages', function($http, ide) {
)
)
}
return (chat.state.errored = true)
chat.state.errored = true
} else {
messages.reverse()
prependMessages(messages)
return (chat.state.nextBeforeTimestamp =
chat.state.nextBeforeTimestamp =
chat.state.messages[0] != null
? chat.state.messages[0].timestamp
: undefined)
: undefined
}
ide.$scope.$broadcast('chat:more-messages-loaded', chat)
})
}
@ -143,14 +145,15 @@ export default App.factory('chatMessages', function($http, ide) {
message.timestamp - lastMessage.timestamp < TIMESTAMP_GROUP_SIZE
if (shouldGroup) {
lastMessage.timestamp = message.timestamp
return lastMessage.contents.push(message.content)
lastMessage.contents.push(message.content)
} else {
return chat.state.messages.push({
chat.state.messages.push({
user: message.user,
timestamp: message.timestamp,
contents: [message.content]
})
}
ide.$scope.$broadcast('chat:more-messages-loaded', chat)
}
return chat

View file

@ -54,6 +54,7 @@
@import 'components/input-suggestions.less';
@import 'components/nvd3.less';
@import 'components/nvd3_override.less';
@import 'components/infinite-scroll.less';
// Components w/ JavaScript
@import 'components/modals.less';

View file

@ -0,0 +1,3 @@
.infinite-scroll {
overflow-y: auto;
}

View file

@ -9791,7 +9791,7 @@
"append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY="
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="
},
"aproba": {
"version": "1.2.0",
@ -12252,7 +12252,7 @@
"buffer-shims": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz",
"integrity": "sha1-mXjOMXOIxkmth5MCjDR37wRKi1E="
"integrity": "sha512-Zy8ZXMyxIT6RMTeY7OP/bDndfj6bwCan7SS98CEndS6deHwWPpseeHlwarNcBim+etXnF9HBc1non5JgDaJU1g=="
},
"buffer-xor": {
"version": "1.0.3",
@ -12305,7 +12305,7 @@
"busboy": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.14.tgz",
"integrity": "sha1-bCpiLvz0fFe7vh4qnDetNseSVFM=",
"integrity": "sha512-InWFDomvlkEj+xWLBfU3AvnbVYqeTWmQopiW0tWWEy5yehYm2YkGEc59sUmw/4ty5Zj/b0WHGs1LgecuBSBGrg==",
"requires": {
"dicer": "0.2.5",
"readable-stream": "1.1.x"
@ -12314,7 +12314,7 @@
"readable-stream": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
"integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=",
"integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==",
"requires": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.1",
@ -14969,7 +14969,7 @@
"dicer": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/dicer/-/dicer-0.2.5.tgz",
"integrity": "sha1-WZbAhrszIYyBLAkL3cCc0S+stw8=",
"integrity": "sha512-FDvbtnq7dzlPz0wyYlOExifDEZcu8h+rErEXgfxqmLfRfC/kJidEFh4+effJRO3P0xmfqyPbSMG0LveNRfTKVg==",
"requires": {
"readable-stream": "1.1.x",
"streamsearch": "0.1.2"
@ -14978,7 +14978,7 @@
"readable-stream": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
"integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=",
"integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==",
"requires": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.1",
@ -22659,6 +22659,14 @@
"integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=",
"dev": true
},
"linkify-it": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz",
"integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==",
"requires": {
"uc.micro": "^1.0.1"
}
},
"load-json-file": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz",
@ -23523,7 +23531,7 @@
"microtime-nodejs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/microtime-nodejs/-/microtime-nodejs-1.0.0.tgz",
"integrity": "sha1-iFlASvLipGKhXJzWvyxORo2r2+g="
"integrity": "sha512-SthP/4JW6HUIZfgM0nadNtwKm/WMH0+z1i4RsPDnud+UasjoABzSkCk3eMhIRzipgwPhkdAYpTI69X4II4j1pA=="
},
"miller-rabin": {
"version": "4.0.1",
@ -23856,7 +23864,7 @@
},
"mkdirp": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
"resolved": false,
"integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==",
"requires": {
"minimist": "0.0.8"
@ -29780,6 +29788,15 @@
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
},
"react-linkify": {
"version": "1.0.0-alpha",
"resolved": "https://registry.npmjs.org/react-linkify/-/react-linkify-1.0.0-alpha.tgz",
"integrity": "sha512-7gcIUvJkAXXttt1fmBK9cwn+1jTa4hbKLGCZ9J1U6EOkyb2/+LKL1Z28d9rtDLMnpvImlNlLPdTPooorl5cpmg==",
"requires": {
"linkify-it": "^2.0.3",
"tlds": "^1.199.0"
}
},
"react-overlays": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-0.9.1.tgz",
@ -31415,7 +31432,7 @@
"resolve-from": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz",
"integrity": "sha1-lICrIOlP+h2egKgEx+oUdhGWa1c="
"integrity": "sha512-qpFcKaXsq8+oRoLilkwyc7zHGF5i9Q2/25NIgLQQ/+VVv9rU4qvr6nXVAw1DsnXJyQkZsR4Ytfbtg5ehfcUssQ=="
},
"resolve-url": {
"version": "0.2.1",
@ -33315,7 +33332,7 @@
"streamsearch": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz",
"integrity": "sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo="
"integrity": "sha512-jos8u++JKm0ARcSUTAZXOVC0mSox7Bhn6sBgty73P1f3JGf7yG2clTbBNHUdde/kdvP2FESam+vM6l8jBrNxHA=="
},
"strict-uri-encode": {
"version": "1.1.0",
@ -34924,6 +34941,11 @@
"integrity": "sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g=",
"dev": true
},
"tlds": {
"version": "1.210.0",
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.210.0.tgz",
"integrity": "sha512-5bzt4JE+NlnwiKpVW9yzWxuc44m+t2opmPG+eSKDp5V5qdyGvjMngKgBb5ZK8GiheQMbRTCKpRwFJeIEO6pV7Q=="
},
"tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
@ -35316,6 +35338,11 @@
}
}
},
"uc.micro": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA=="
},
"uglify-js": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.9.1.tgz",

View file

@ -125,6 +125,7 @@
"react-dom": "^16.13.1",
"react-error-boundary": "^2.3.1",
"react-i18next": "^11.7.1",
"react-linkify": "^1.0.0-alpha",
"react2angular": "^4.0.6",
"redis-sharelatex": "^1.0.13",
"request": "^2.88.2",