mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #3192 from overleaf/msm-chat-react
Chat reactification GitOrigin-RevId: ee1268b412513a8656703257febad4975adb74e7
This commit is contained in:
parent
3ca5c4b26a
commit
f1b42a3d0d
17 changed files with 548 additions and 22 deletions
|
@ -842,7 +842,8 @@ const ProjectController = {
|
||||||
gaOptimize: enableOptimize,
|
gaOptimize: enableOptimize,
|
||||||
customOptimizeEvent: true,
|
customOptimizeEvent: true,
|
||||||
experimentId: Settings.experimentId,
|
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()
|
timer.done()
|
||||||
}
|
}
|
||||||
|
|
|
@ -102,7 +102,10 @@ block content
|
||||||
|
|
||||||
if !isRestrictedTokenMember
|
if !isRestrictedTokenMember
|
||||||
.ui-layout-east
|
.ui-layout-east
|
||||||
include ./editor/chat
|
if showNewChatUI
|
||||||
|
include ./editor/chat-react
|
||||||
|
else
|
||||||
|
include ./editor/chat
|
||||||
|
|
||||||
include ./editor/hotkeys
|
include ./editor/hotkeys
|
||||||
|
|
||||||
|
|
12
services/web/app/views/project/editor/chat-react.pug
Normal file
12
services/web/app/views/project/editor/chat-react.pug
Normal 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"
|
||||||
|
)
|
|
@ -17,5 +17,9 @@
|
||||||
"fast",
|
"fast",
|
||||||
"stop_on_validation_error",
|
"stop_on_validation_error",
|
||||||
"ignore_validation_errors",
|
"ignore_validation_errors",
|
||||||
"run_syntax_check_now"
|
"run_syntax_check_now",
|
||||||
|
"loading",
|
||||||
|
"no_messages",
|
||||||
|
"send_first_message",
|
||||||
|
"your_message"
|
||||||
]
|
]
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
72
services/web/frontend/js/features/chat/components/message.js
Normal file
72
services/web/frontend/js/features/chat/components/message.js
Normal 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
|
|
@ -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))
|
|
@ -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/ChatButtonController'
|
||||||
import './controllers/ChatController'
|
import './controllers/ChatController'
|
||||||
import './controllers/ChatMessageController'
|
import './controllers/ChatMessageController'
|
||||||
import '../../directives/mathjax'
|
import '../../directives/mathjax'
|
||||||
import '../../filters/wrapLongWords'
|
import '../../filters/wrapLongWords'
|
||||||
|
import '../../features/chat/controllers/chat-controller'
|
||||||
|
|
|
@ -73,6 +73,7 @@ export default App.factory('chatMessages', function($http, ide) {
|
||||||
url += `&before=${chat.state.nextBeforeTimestamp}`
|
url += `&before=${chat.state.nextBeforeTimestamp}`
|
||||||
}
|
}
|
||||||
chat.state.loading = true
|
chat.state.loading = true
|
||||||
|
ide.$scope.$broadcast('chat:more-messages-loading', chat)
|
||||||
return $http.get(url).then(function(response) {
|
return $http.get(url).then(function(response) {
|
||||||
const messages = response.data != null ? response.data : []
|
const messages = response.data != null ? response.data : []
|
||||||
chat.state.loading = false
|
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 {
|
} else {
|
||||||
messages.reverse()
|
messages.reverse()
|
||||||
prependMessages(messages)
|
prependMessages(messages)
|
||||||
return (chat.state.nextBeforeTimestamp =
|
chat.state.nextBeforeTimestamp =
|
||||||
chat.state.messages[0] != null
|
chat.state.messages[0] != null
|
||||||
? chat.state.messages[0].timestamp
|
? 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
|
message.timestamp - lastMessage.timestamp < TIMESTAMP_GROUP_SIZE
|
||||||
if (shouldGroup) {
|
if (shouldGroup) {
|
||||||
lastMessage.timestamp = message.timestamp
|
lastMessage.timestamp = message.timestamp
|
||||||
return lastMessage.contents.push(message.content)
|
lastMessage.contents.push(message.content)
|
||||||
} else {
|
} else {
|
||||||
return chat.state.messages.push({
|
chat.state.messages.push({
|
||||||
user: message.user,
|
user: message.user,
|
||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
contents: [message.content]
|
contents: [message.content]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
ide.$scope.$broadcast('chat:more-messages-loaded', chat)
|
||||||
}
|
}
|
||||||
|
|
||||||
return chat
|
return chat
|
||||||
|
|
|
@ -54,6 +54,7 @@
|
||||||
@import 'components/input-suggestions.less';
|
@import 'components/input-suggestions.less';
|
||||||
@import 'components/nvd3.less';
|
@import 'components/nvd3.less';
|
||||||
@import 'components/nvd3_override.less';
|
@import 'components/nvd3_override.less';
|
||||||
|
@import 'components/infinite-scroll.less';
|
||||||
|
|
||||||
// Components w/ JavaScript
|
// Components w/ JavaScript
|
||||||
@import 'components/modals.less';
|
@import 'components/modals.less';
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
.infinite-scroll {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
47
services/web/package-lock.json
generated
47
services/web/package-lock.json
generated
|
@ -9791,7 +9791,7 @@
|
||||||
"append-field": {
|
"append-field": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||||
"integrity": "sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY="
|
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="
|
||||||
},
|
},
|
||||||
"aproba": {
|
"aproba": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
|
@ -12252,7 +12252,7 @@
|
||||||
"buffer-shims": {
|
"buffer-shims": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz",
|
||||||
"integrity": "sha1-mXjOMXOIxkmth5MCjDR37wRKi1E="
|
"integrity": "sha512-Zy8ZXMyxIT6RMTeY7OP/bDndfj6bwCan7SS98CEndS6deHwWPpseeHlwarNcBim+etXnF9HBc1non5JgDaJU1g=="
|
||||||
},
|
},
|
||||||
"buffer-xor": {
|
"buffer-xor": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
|
@ -12305,7 +12305,7 @@
|
||||||
"busboy": {
|
"busboy": {
|
||||||
"version": "0.2.14",
|
"version": "0.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.14.tgz",
|
||||||
"integrity": "sha1-bCpiLvz0fFe7vh4qnDetNseSVFM=",
|
"integrity": "sha512-InWFDomvlkEj+xWLBfU3AvnbVYqeTWmQopiW0tWWEy5yehYm2YkGEc59sUmw/4ty5Zj/b0WHGs1LgecuBSBGrg==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"dicer": "0.2.5",
|
"dicer": "0.2.5",
|
||||||
"readable-stream": "1.1.x"
|
"readable-stream": "1.1.x"
|
||||||
|
@ -12314,7 +12314,7 @@
|
||||||
"readable-stream": {
|
"readable-stream": {
|
||||||
"version": "1.1.14",
|
"version": "1.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
|
||||||
"integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=",
|
"integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"core-util-is": "~1.0.0",
|
"core-util-is": "~1.0.0",
|
||||||
"inherits": "~2.0.1",
|
"inherits": "~2.0.1",
|
||||||
|
@ -14969,7 +14969,7 @@
|
||||||
"dicer": {
|
"dicer": {
|
||||||
"version": "0.2.5",
|
"version": "0.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/dicer/-/dicer-0.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/dicer/-/dicer-0.2.5.tgz",
|
||||||
"integrity": "sha1-WZbAhrszIYyBLAkL3cCc0S+stw8=",
|
"integrity": "sha512-FDvbtnq7dzlPz0wyYlOExifDEZcu8h+rErEXgfxqmLfRfC/kJidEFh4+effJRO3P0xmfqyPbSMG0LveNRfTKVg==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"readable-stream": "1.1.x",
|
"readable-stream": "1.1.x",
|
||||||
"streamsearch": "0.1.2"
|
"streamsearch": "0.1.2"
|
||||||
|
@ -14978,7 +14978,7 @@
|
||||||
"readable-stream": {
|
"readable-stream": {
|
||||||
"version": "1.1.14",
|
"version": "1.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
|
||||||
"integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=",
|
"integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"core-util-is": "~1.0.0",
|
"core-util-is": "~1.0.0",
|
||||||
"inherits": "~2.0.1",
|
"inherits": "~2.0.1",
|
||||||
|
@ -22659,6 +22659,14 @@
|
||||||
"integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=",
|
"integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=",
|
||||||
"dev": true
|
"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": {
|
"load-json-file": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz",
|
||||||
|
@ -23523,7 +23531,7 @@
|
||||||
"microtime-nodejs": {
|
"microtime-nodejs": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/microtime-nodejs/-/microtime-nodejs-1.0.0.tgz",
|
"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": {
|
"miller-rabin": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
|
@ -23856,7 +23864,7 @@
|
||||||
},
|
},
|
||||||
"mkdirp": {
|
"mkdirp": {
|
||||||
"version": "0.5.1",
|
"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==",
|
"integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"minimist": "0.0.8"
|
"minimist": "0.0.8"
|
||||||
|
@ -29780,6 +29788,15 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
||||||
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
|
"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": {
|
"react-overlays": {
|
||||||
"version": "0.9.1",
|
"version": "0.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-0.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-0.9.1.tgz",
|
||||||
|
@ -31415,7 +31432,7 @@
|
||||||
"resolve-from": {
|
"resolve-from": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz",
|
"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": {
|
"resolve-url": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
|
@ -33315,7 +33332,7 @@
|
||||||
"streamsearch": {
|
"streamsearch": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz",
|
||||||
"integrity": "sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo="
|
"integrity": "sha512-jos8u++JKm0ARcSUTAZXOVC0mSox7Bhn6sBgty73P1f3JGf7yG2clTbBNHUdde/kdvP2FESam+vM6l8jBrNxHA=="
|
||||||
},
|
},
|
||||||
"strict-uri-encode": {
|
"strict-uri-encode": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
|
@ -34924,6 +34941,11 @@
|
||||||
"integrity": "sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g=",
|
"integrity": "sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g=",
|
||||||
"dev": true
|
"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": {
|
"tmp": {
|
||||||
"version": "0.0.33",
|
"version": "0.0.33",
|
||||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
|
"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": {
|
"uglify-js": {
|
||||||
"version": "3.9.1",
|
"version": "3.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.9.1.tgz",
|
||||||
|
|
|
@ -125,6 +125,7 @@
|
||||||
"react-dom": "^16.13.1",
|
"react-dom": "^16.13.1",
|
||||||
"react-error-boundary": "^2.3.1",
|
"react-error-boundary": "^2.3.1",
|
||||||
"react-i18next": "^11.7.1",
|
"react-i18next": "^11.7.1",
|
||||||
|
"react-linkify": "^1.0.0-alpha",
|
||||||
"react2angular": "^4.0.6",
|
"react2angular": "^4.0.6",
|
||||||
"redis-sharelatex": "^1.0.13",
|
"redis-sharelatex": "^1.0.13",
|
||||||
"request": "^2.88.2",
|
"request": "^2.88.2",
|
||||||
|
|
Loading…
Reference in a new issue