mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-06-06 15:49:01 +02:00
Merge pull request #3300 from overleaf/msm-chat-react-store
React chat store GitOrigin-RevId: 204009eb5798b02a41e621b33b05ef0cb9d10b15
This commit is contained in:
@@ -1,12 +1,4 @@
|
||||
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"
|
||||
)
|
||||
chat(reset-unread-messages="resetUnreadMessages")
|
||||
|
||||
@@ -5,19 +5,24 @@ import MessageInput from './message-input'
|
||||
import InfiniteScroll from './infinite-scroll'
|
||||
import Icon from '../../../shared/components/icon'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useChatStore } from '../store/chat-store-effect'
|
||||
import withErrorBoundary from '../../../infrastructure/error-boundary'
|
||||
|
||||
function ChatPane({
|
||||
atEnd,
|
||||
loading,
|
||||
loadMoreMessages,
|
||||
messages,
|
||||
resetUnreadMessages,
|
||||
sendMessage,
|
||||
userId
|
||||
}) {
|
||||
function ChatPane({ resetUnreadMessages }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
atEnd,
|
||||
loading,
|
||||
loadMoreMessages,
|
||||
messages,
|
||||
sendMessage,
|
||||
userId
|
||||
} = useChatStore()
|
||||
|
||||
useEffect(() => {
|
||||
loadMoreMessages()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const shouldDisplayPlaceholder = !loading && messages.length === 0
|
||||
@@ -80,13 +85,7 @@ function Placeholder() {
|
||||
}
|
||||
|
||||
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
|
||||
resetUnreadMessages: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
export default ChatPane
|
||||
export default withErrorBoundary(ChatPane)
|
||||
|
||||
@@ -59,9 +59,8 @@ function MessageList({ messages, resetUnreadMessages, userId }) {
|
||||
}
|
||||
|
||||
MessageList.propTypes = {
|
||||
messages: PropTypes.arrayOf(
|
||||
PropTypes.shape({ timestamp: PropTypes.instanceOf(Date) })
|
||||
).isRequired,
|
||||
messages: PropTypes.arrayOf(PropTypes.shape({ timestamp: PropTypes.number }))
|
||||
.isRequired,
|
||||
resetUnreadMessages: PropTypes.func.isRequired,
|
||||
userId: PropTypes.string.isRequired
|
||||
}
|
||||
|
||||
@@ -1,42 +1,11 @@
|
||||
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() {
|
||||
App.controller('ReactChatController', function($scope, ide) {
|
||||
$scope.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))
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ChatStore } from './chat-store'
|
||||
|
||||
let chatStore
|
||||
|
||||
export function resetChatStore() {
|
||||
chatStore = undefined
|
||||
}
|
||||
|
||||
export function useChatStore() {
|
||||
if (!chatStore) {
|
||||
chatStore = new ChatStore()
|
||||
}
|
||||
|
||||
function getStateFromStore() {
|
||||
return {
|
||||
userId: window.user.id,
|
||||
atEnd: chatStore.atEnd,
|
||||
loading: chatStore.loading,
|
||||
messages: chatStore.messages,
|
||||
loadMoreMessages: () => chatStore.loadMoreMessages(),
|
||||
sendMessage: message => chatStore.sendMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
const [storeState, setStoreState] = useState(getStateFromStore())
|
||||
|
||||
useEffect(() => {
|
||||
function handleStoreUpdated() {
|
||||
setStoreState(getStateFromStore())
|
||||
}
|
||||
chatStore.on('updated', handleStoreUpdated)
|
||||
return () => chatStore.off('updated', handleStoreUpdated)
|
||||
}, [])
|
||||
|
||||
return storeState
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import EventEmitter from '../../../utils/EventEmitter'
|
||||
import { appendMessage, prependMessages } from './message-list-appender'
|
||||
import { getJSON, postJSON } from '../../../infrastructure/fetch-json'
|
||||
|
||||
export const MESSAGE_LIMIT = 50
|
||||
|
||||
export class ChatStore {
|
||||
constructor() {
|
||||
this.messages = []
|
||||
this.loading = false
|
||||
this.atEnd = false
|
||||
|
||||
this._nextBeforeTimestamp = null
|
||||
this._justSent = false
|
||||
|
||||
this._emitter = new EventEmitter()
|
||||
|
||||
window._ide.socket.on('new-chat-message', message => {
|
||||
const messageIsFromSelf =
|
||||
message && message.user && message.user.id === window.user.id
|
||||
if (!messageIsFromSelf || !this._justSent) {
|
||||
this.messages = appendMessage(this.messages, message)
|
||||
this._emitter.emit('updated')
|
||||
this._emitter.emit('message-received', message)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('Chat.MessageReceived', { detail: { message } })
|
||||
)
|
||||
}
|
||||
this._justSent = false
|
||||
})
|
||||
}
|
||||
|
||||
on(event, fn) {
|
||||
this._emitter.on(event, fn)
|
||||
}
|
||||
|
||||
off(event, fn) {
|
||||
this._emitter.off(event, fn)
|
||||
}
|
||||
|
||||
loadMoreMessages() {
|
||||
if (this.atEnd) {
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
this._emitter.emit('updated')
|
||||
|
||||
let url = `/project/${window.project_id}/messages?limit=${MESSAGE_LIMIT}`
|
||||
if (this._nextBeforeTimestamp) {
|
||||
url += `&before=${this._nextBeforeTimestamp}`
|
||||
}
|
||||
|
||||
return getJSON(url).then(response => {
|
||||
const messages = response || []
|
||||
this.loading = false
|
||||
if (messages.length < MESSAGE_LIMIT) {
|
||||
this.atEnd = true
|
||||
}
|
||||
messages.reverse()
|
||||
this.messages = prependMessages(this.messages, messages)
|
||||
this._nextBeforeTimestamp = this.messages[0]
|
||||
? this.messages[0].timestamp
|
||||
: undefined
|
||||
this._emitter.emit('updated')
|
||||
})
|
||||
}
|
||||
|
||||
sendMessage(message) {
|
||||
if (!message) {
|
||||
return
|
||||
}
|
||||
const body = {
|
||||
content: message,
|
||||
_csrf: window.csrfToken
|
||||
}
|
||||
this._justSent = true
|
||||
this.messages = appendMessage(this.messages, {
|
||||
user: window.user,
|
||||
content: message,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
const url = `/project/${window.project_id}/messages`
|
||||
this._emitter.emit('updated')
|
||||
return postJSON(url, { body })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
const TIMESTAMP_GROUP_SIZE = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
export function appendMessage(messageList, message) {
|
||||
const lastMessage = messageList[messageList.length - 1]
|
||||
|
||||
const shouldGroup =
|
||||
lastMessage &&
|
||||
message &&
|
||||
message.user &&
|
||||
message.user.id &&
|
||||
message.user.id === lastMessage.user.id &&
|
||||
message.timestamp - lastMessage.timestamp < TIMESTAMP_GROUP_SIZE
|
||||
|
||||
if (shouldGroup) {
|
||||
return messageList.slice(0, messageList.length - 1).concat({
|
||||
...lastMessage,
|
||||
timestamp: message.timestamp,
|
||||
contents: lastMessage.contents.concat(message.content)
|
||||
})
|
||||
} else {
|
||||
return messageList.slice(0).concat({
|
||||
user: message.user,
|
||||
timestamp: message.timestamp,
|
||||
contents: [message.content]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function prependMessages(messageList, messages) {
|
||||
const listCopy = messageList.slice(0)
|
||||
messages
|
||||
.slice(0)
|
||||
.reverse()
|
||||
.forEach(message => {
|
||||
const firstMessage = listCopy[0]
|
||||
const shouldGroup =
|
||||
firstMessage &&
|
||||
message &&
|
||||
message.user &&
|
||||
message.user.id === firstMessage.user.id &&
|
||||
firstMessage.timestamp - message.timestamp < TIMESTAMP_GROUP_SIZE
|
||||
|
||||
if (shouldGroup) {
|
||||
firstMessage.timestamp = message.timestamp
|
||||
firstMessage.contents = [message.content].concat(firstMessage.contents)
|
||||
} else {
|
||||
listCopy.unshift({
|
||||
user: message.user,
|
||||
timestamp: message.timestamp,
|
||||
contents: [message.content]
|
||||
})
|
||||
}
|
||||
})
|
||||
return listCopy
|
||||
}
|
||||
@@ -25,19 +25,26 @@ export default App.controller('ChatButtonController', function($scope, ide) {
|
||||
|
||||
$scope.$on('chat:resetUnreadMessages', e => $scope.resetUnreadMessages())
|
||||
|
||||
$scope.$on('chat:newMessage', function(e, message) {
|
||||
function handleNewMessage(message) {
|
||||
if (message != null) {
|
||||
if (
|
||||
__guard__(message != null ? message.user : undefined, x => x.id) !==
|
||||
ide.$scope.user.id
|
||||
) {
|
||||
if (!$scope.ui.chatOpen) {
|
||||
$scope.unreadMessages += 1
|
||||
$scope.$applyAsync(() => {
|
||||
$scope.unreadMessages += 1
|
||||
})
|
||||
}
|
||||
return flashTitle()
|
||||
flashTitle()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
$scope.$on('chat:newMessage', (e, message) => handleNewMessage(message))
|
||||
window.addEventListener('Chat.MessageReceived', ({ detail: { message } }) =>
|
||||
handleNewMessage(message)
|
||||
)
|
||||
|
||||
let focussed = true
|
||||
let newMessageNotificationTimeout = null
|
||||
|
||||
@@ -206,6 +206,7 @@
|
||||
"mini-css-extract-plugin": "^0.8.0",
|
||||
"mkdirp": "0.5.1",
|
||||
"mock-fs": "^4.11.0",
|
||||
"node-fetch": "^2.6.1",
|
||||
"nodemon": "^1.14.3",
|
||||
"optimize-css-assets-webpack-plugin": "^5.0.3",
|
||||
"postcss-loader": "^3.0.0",
|
||||
|
||||
+4
@@ -23,3 +23,7 @@ moment.updateLocale('en', {
|
||||
sameElse: 'ddd, Do MMM YY'
|
||||
}
|
||||
})
|
||||
|
||||
// node-fetch doesn't accept relative URL's: https://github.com/node-fetch/node-fetch/blob/master/docs/v2-LIMITS.md#known-differences
|
||||
const fetch = require('node-fetch')
|
||||
global.fetch = (url, ...options) => fetch('http://localhost' + url, ...options)
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import React from 'react'
|
||||
import { expect } from 'chai'
|
||||
import { screen, render } from '@testing-library/react'
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
waitForElementToBeRemoved
|
||||
} from '@testing-library/react'
|
||||
import fetchMock from 'fetch-mock'
|
||||
|
||||
import ChatPane from '../../../../../frontend/js/features/chat/components/chat-pane'
|
||||
import {
|
||||
stubGlobalUser,
|
||||
stubChatStore,
|
||||
stubMathJax,
|
||||
stubUIConfig,
|
||||
tearDownGlobalUserStub,
|
||||
tearDownChatStore,
|
||||
tearDownMathJaxStubs,
|
||||
tearDownUIConfigStubs
|
||||
} from './stubs'
|
||||
@@ -19,105 +24,60 @@ describe('<ChatPane />', function() {
|
||||
email: 'fake@example.com'
|
||||
}
|
||||
|
||||
function createMessages() {
|
||||
return [
|
||||
{
|
||||
contents: ['a message'],
|
||||
user: currentUser,
|
||||
timestamp: new Date()
|
||||
},
|
||||
{
|
||||
contents: ['another message'],
|
||||
user: currentUser,
|
||||
timestamp: new Date()
|
||||
}
|
||||
]
|
||||
}
|
||||
const testMessages = [
|
||||
{
|
||||
content: 'a message',
|
||||
user: currentUser,
|
||||
timestamp: new Date().getTime()
|
||||
},
|
||||
{
|
||||
content: 'another message',
|
||||
user: currentUser,
|
||||
timestamp: new Date().getTime()
|
||||
}
|
||||
]
|
||||
|
||||
before(function() {
|
||||
stubGlobalUser(currentUser) // required by ColorManager
|
||||
beforeEach(function() {
|
||||
stubChatStore({ user: currentUser })
|
||||
stubUIConfig()
|
||||
stubMathJax()
|
||||
fetchMock.reset()
|
||||
})
|
||||
|
||||
after(function() {
|
||||
tearDownGlobalUserStub()
|
||||
afterEach(function() {
|
||||
tearDownChatStore()
|
||||
tearDownUIConfigStubs()
|
||||
tearDownMathJaxStubs()
|
||||
fetchMock.reset()
|
||||
})
|
||||
|
||||
it('renders multiple messages', function() {
|
||||
render(
|
||||
<ChatPane
|
||||
loadMoreMessages={() => {}}
|
||||
sendMessage={() => {}}
|
||||
userId={currentUser.id}
|
||||
messages={createMessages()}
|
||||
resetUnreadMessages={() => {}}
|
||||
/>
|
||||
)
|
||||
it('renders multiple messages', async function() {
|
||||
fetchMock.get(/messages/, testMessages)
|
||||
render(<ChatPane resetUnreadMessages={() => {}} />)
|
||||
|
||||
screen.getByText('a message')
|
||||
screen.getByText('another message')
|
||||
await screen.findByText('a message')
|
||||
await screen.findByText('another message')
|
||||
})
|
||||
|
||||
describe('loading spinner', function() {
|
||||
it('is rendered while the messages is loading', function() {
|
||||
render(
|
||||
<ChatPane
|
||||
loading
|
||||
loadMoreMessages={() => {}}
|
||||
sendMessage={() => {}}
|
||||
userId={currentUser.id}
|
||||
messages={createMessages()}
|
||||
resetUnreadMessages={() => {}}
|
||||
/>
|
||||
)
|
||||
screen.getByText('Loading…')
|
||||
})
|
||||
|
||||
it('is not rendered when the messages are not loading', function() {
|
||||
render(
|
||||
<ChatPane
|
||||
loading={false}
|
||||
loadMoreMessages={() => {}}
|
||||
sendMessage={() => {}}
|
||||
userId={currentUser.id}
|
||||
messages={createMessages()}
|
||||
resetUnreadMessages={() => {}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
expect(screen.queryByText('Loading…')).to.not.exist
|
||||
it('A loading spinner is rendered while the messages are loading, then disappears', async function() {
|
||||
fetchMock.get(/messages/, [])
|
||||
render(<ChatPane resetUnreadMessages={() => {}} />)
|
||||
await waitForElementToBeRemoved(() => screen.getByText('Loading…'))
|
||||
})
|
||||
|
||||
describe('"send your first message" placeholder', function() {
|
||||
it('is rendered when there are no messages ', function() {
|
||||
render(
|
||||
<ChatPane
|
||||
loadMoreMessages={() => {}}
|
||||
sendMessage={() => {}}
|
||||
userId={currentUser.id}
|
||||
messages={[]}
|
||||
resetUnreadMessages={() => {}}
|
||||
/>
|
||||
)
|
||||
screen.getByText('Send your first message to your collaborators')
|
||||
it('is rendered when there are no messages ', async function() {
|
||||
fetchMock.get(/messages/, [])
|
||||
render(<ChatPane resetUnreadMessages={() => {}} />)
|
||||
await screen.findByText('Send your first message to your collaborators')
|
||||
})
|
||||
|
||||
it('is not rendered when there are some messages', function() {
|
||||
render(
|
||||
<ChatPane
|
||||
loading={false}
|
||||
loadMoreMessages={() => {}}
|
||||
sendMessage={() => {}}
|
||||
userId={currentUser.id}
|
||||
messages={createMessages()}
|
||||
resetUnreadMessages={() => {}}
|
||||
/>
|
||||
)
|
||||
it('is not rendered when messages are displayed', function() {
|
||||
fetchMock.get(/messages/, testMessages)
|
||||
render(<ChatPane resetUnreadMessages={() => {}} />)
|
||||
expect(
|
||||
screen.queryByText('Send your first message to your collaborators')
|
||||
).to.not.exist
|
||||
})
|
||||
expect(screen.queryByText('Send your first message to your collaborators'))
|
||||
.to.not.exist
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,10 +5,10 @@ import { screen, render, fireEvent } from '@testing-library/react'
|
||||
|
||||
import MessageList from '../../../../../frontend/js/features/chat/components/message-list'
|
||||
import {
|
||||
stubGlobalUser,
|
||||
stubChatStore,
|
||||
stubMathJax,
|
||||
stubUIConfig,
|
||||
tearDownGlobalUserStub,
|
||||
tearDownChatStore,
|
||||
tearDownMathJaxStubs,
|
||||
tearDownUIConfigStubs
|
||||
} from './stubs'
|
||||
@@ -25,24 +25,24 @@ describe('<MessageList />', function() {
|
||||
{
|
||||
contents: ['a message'],
|
||||
user: currentUser,
|
||||
timestamp: new Date()
|
||||
timestamp: new Date().getTime()
|
||||
},
|
||||
{
|
||||
contents: ['another message'],
|
||||
user: currentUser,
|
||||
timestamp: new Date()
|
||||
timestamp: new Date().getTime()
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
before(function() {
|
||||
stubGlobalUser(currentUser) // required by ColorManager
|
||||
stubChatStore({ user: currentUser }) // required by ColorManager
|
||||
stubUIConfig()
|
||||
stubMathJax()
|
||||
})
|
||||
|
||||
after(function() {
|
||||
tearDownGlobalUserStub()
|
||||
tearDownChatStore()
|
||||
tearDownUIConfigStubs()
|
||||
tearDownMathJaxStubs()
|
||||
})
|
||||
@@ -62,8 +62,8 @@ describe('<MessageList />', function() {
|
||||
|
||||
it('renders a single timestamp for all messages within 5 minutes', function() {
|
||||
const msgs = createMessages()
|
||||
msgs[0].timestamp = new Date(2019, 6, 3, 4, 23)
|
||||
msgs[1].timestamp = new Date(2019, 6, 3, 4, 27)
|
||||
msgs[0].timestamp = new Date(2019, 6, 3, 4, 23).getTime()
|
||||
msgs[1].timestamp = new Date(2019, 6, 3, 4, 27).getTime()
|
||||
|
||||
render(
|
||||
<MessageList
|
||||
@@ -79,8 +79,8 @@ describe('<MessageList />', function() {
|
||||
|
||||
it('renders a timestamp for each messages separated for more than 5 minutes', function() {
|
||||
const msgs = createMessages()
|
||||
msgs[0].timestamp = new Date(2019, 6, 3, 4, 23)
|
||||
msgs[1].timestamp = new Date(2019, 6, 3, 4, 31)
|
||||
msgs[0].timestamp = new Date(2019, 6, 3, 4, 23).getTime()
|
||||
msgs[1].timestamp = new Date(2019, 6, 3, 4, 31).getTime()
|
||||
|
||||
render(
|
||||
<MessageList
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { expect } from 'chai'
|
||||
import React from 'react'
|
||||
import { screen, render } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
import Message from '../../../../../frontend/js/features/chat/components/message'
|
||||
import {
|
||||
stubGlobalUser,
|
||||
stubMathJax,
|
||||
stubUIConfig,
|
||||
tearDownGlobalUserStub,
|
||||
tearDownMathJaxStubs,
|
||||
tearDownUIConfigStubs
|
||||
} from './stubs'
|
||||
@@ -19,14 +17,14 @@ describe('<Message />', function() {
|
||||
email: 'fake@example.com'
|
||||
}
|
||||
|
||||
before(function() {
|
||||
stubGlobalUser(currentUser) // required by ColorManager
|
||||
beforeEach(function() {
|
||||
window.user = currentUser
|
||||
stubUIConfig()
|
||||
stubMathJax()
|
||||
})
|
||||
|
||||
after(function() {
|
||||
tearDownGlobalUserStub()
|
||||
afterEach(function() {
|
||||
delete window.user
|
||||
tearDownUIConfigStubs()
|
||||
tearDownMathJaxStubs()
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import sinon from 'sinon'
|
||||
import { resetChatStore } from '../../../../../frontend/js/features/chat/store/chat-store-effect'
|
||||
|
||||
export function stubUIConfig() {
|
||||
window.uiConfig = {
|
||||
@@ -26,10 +27,15 @@ export function tearDownMathJaxStubs() {
|
||||
delete window.MathJax
|
||||
}
|
||||
|
||||
export function stubGlobalUser(user) {
|
||||
export function stubChatStore({ user }) {
|
||||
window._ide = { socket: { on: sinon.stub(), off: sinon.stub() } }
|
||||
window.dispatchEvent = sinon.stub()
|
||||
window.user = user
|
||||
resetChatStore()
|
||||
}
|
||||
|
||||
export function tearDownGlobalUserStub() {
|
||||
export function tearDownChatStore() {
|
||||
delete window._ide
|
||||
delete window.dispatchEvent
|
||||
delete window.user
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import {
|
||||
ChatStore,
|
||||
MESSAGE_LIMIT
|
||||
} from '../../../../../frontend/js/features/chat/store/chat-store'
|
||||
|
||||
describe('ChatStore', function() {
|
||||
let store, socket, mockSocketMessage
|
||||
|
||||
const user = {
|
||||
id: '123abc'
|
||||
}
|
||||
|
||||
const testMessage = {
|
||||
content: 'hello',
|
||||
timestamp: new Date().getTime(),
|
||||
user
|
||||
}
|
||||
|
||||
beforeEach(function() {
|
||||
fetchMock.reset()
|
||||
|
||||
window.user = user
|
||||
window.project_id = 'project-123'
|
||||
window.csrfToken = 'csrf_tok'
|
||||
|
||||
socket = { on: sinon.stub() }
|
||||
window._ide = { socket }
|
||||
mockSocketMessage = message => socket.on.getCall(0).args[1](message)
|
||||
|
||||
window.dispatchEvent = sinon.stub()
|
||||
|
||||
store = new ChatStore()
|
||||
})
|
||||
|
||||
afterEach(function() {
|
||||
fetchMock.restore()
|
||||
delete window._ide
|
||||
delete window.csrfToken
|
||||
delete window.user
|
||||
delete window.project_id
|
||||
})
|
||||
|
||||
describe('new message events', function() {
|
||||
it('subscribes to the socket for new message events', function() {
|
||||
expect(socket.on).to.be.calledWith('new-chat-message')
|
||||
})
|
||||
|
||||
it('notifies an update event after new messages are received', function() {
|
||||
const subscriber = sinon.stub()
|
||||
store.on('updated', subscriber)
|
||||
mockSocketMessage(testMessage)
|
||||
expect(subscriber).to.be.calledOnce
|
||||
})
|
||||
|
||||
it('can unsubscribe from events', function() {
|
||||
const subscriber = sinon.stub()
|
||||
store.on('updated', subscriber)
|
||||
store.off('updated', subscriber)
|
||||
mockSocketMessage(testMessage)
|
||||
expect(subscriber).not.to.be.called
|
||||
})
|
||||
|
||||
it('when the message is from other user, it is added to the messages list', function() {
|
||||
mockSocketMessage({ ...testMessage, id: 'other_user' })
|
||||
expect(store.messages[store.messages.length - 1]).to.deep.equal({
|
||||
user: testMessage.user,
|
||||
timestamp: testMessage.timestamp,
|
||||
contents: [testMessage.content]
|
||||
})
|
||||
})
|
||||
|
||||
describe('messages sent by the user', function() {
|
||||
beforeEach(function() {
|
||||
fetchMock.post(/messages/, 204)
|
||||
})
|
||||
|
||||
it('are not added to the message list', async function() {
|
||||
await store.sendMessage(testMessage.content)
|
||||
const originalMessageList = store.messages.slice(0)
|
||||
mockSocketMessage(testMessage)
|
||||
expect(originalMessageList).to.deep.equal(store.messages)
|
||||
|
||||
// next message by a different user is added normally
|
||||
const otherMessage = {
|
||||
...testMessage,
|
||||
user: { id: 'other_user' },
|
||||
content: 'other'
|
||||
}
|
||||
mockSocketMessage(otherMessage)
|
||||
expect(store.messages.length).to.equal(originalMessageList.length + 1)
|
||||
expect(store.messages[store.messages.length - 1]).to.deep.equal({
|
||||
user: otherMessage.user,
|
||||
timestamp: otherMessage.timestamp,
|
||||
contents: [otherMessage.content]
|
||||
})
|
||||
})
|
||||
|
||||
it("don't notify an update event after new messages are received", async function() {
|
||||
await store.sendMessage(testMessage.content)
|
||||
|
||||
const subscriber = sinon.stub()
|
||||
store.on('updated', subscriber)
|
||||
mockSocketMessage(testMessage)
|
||||
|
||||
expect(subscriber).not.to.be.called
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadMoreMessages()', function() {
|
||||
it('aborts the request when the entire message list is loaded', async function() {
|
||||
store.atEnd = true
|
||||
await store.loadMoreMessages()
|
||||
expect(fetchMock.calls().length).to.equal(0)
|
||||
expect(store.loading).to.equal(false)
|
||||
})
|
||||
|
||||
it('updates the list of messages', async function() {
|
||||
const originalMessageList = store.messages.slice(0)
|
||||
fetchMock.get(/messages/, [testMessage])
|
||||
await store.loadMoreMessages()
|
||||
expect(store.messages.length).to.equal(originalMessageList.length + 1)
|
||||
expect(store.messages[store.messages.length - 1]).to.deep.equal({
|
||||
user: testMessage.user,
|
||||
timestamp: testMessage.timestamp,
|
||||
contents: [testMessage.content]
|
||||
})
|
||||
})
|
||||
|
||||
it('notifies an update event for when the loading starts, and a second one once data is available', async function() {
|
||||
const subscriber = sinon.stub()
|
||||
store.on('updated', subscriber)
|
||||
fetchMock.get(/messages/, [testMessage])
|
||||
await store.loadMoreMessages()
|
||||
expect(subscriber).to.be.calledTwice
|
||||
})
|
||||
|
||||
it('marks `atEnd` flag to true when there are no more messages to retrieve', async function() {
|
||||
expect(store.atEnd).to.equal(false)
|
||||
fetchMock.get(/messages/, [testMessage])
|
||||
await store.loadMoreMessages()
|
||||
expect(store.atEnd).to.equal(true)
|
||||
})
|
||||
|
||||
it('marks `atEnd` flag to false when there are still messages to retrieve', async function() {
|
||||
const messages = []
|
||||
for (let i = 0; i < MESSAGE_LIMIT; i++) {
|
||||
messages.push({ ...testMessage, content: `message #${i}` })
|
||||
}
|
||||
expect(store.atEnd).to.equal(false)
|
||||
fetchMock.get(/messages/, messages)
|
||||
await store.loadMoreMessages()
|
||||
expect(store.atEnd).to.equal(false)
|
||||
})
|
||||
|
||||
it('subsequent requests for new messages start at the timestamp of the latest message', async function() {
|
||||
const messages = []
|
||||
for (let i = 0; i < MESSAGE_LIMIT - 1; i++) {
|
||||
// sending enough messages so it doesn't mark `atEnd === true`
|
||||
messages.push({ ...testMessage, content: `message #${i}` })
|
||||
}
|
||||
|
||||
const timestamp = new Date().getTime()
|
||||
messages.push({ ...testMessage, timestamp })
|
||||
|
||||
fetchMock.get(/messages/, messages)
|
||||
await store.loadMoreMessages()
|
||||
|
||||
fetchMock.get(/messages/, [])
|
||||
await store.loadMoreMessages()
|
||||
|
||||
expect(fetchMock.calls().length).to.equal(2)
|
||||
const url = fetchMock.lastCall()[0]
|
||||
expect(url).to.match(new RegExp(`&before=${timestamp}`))
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendMessage()', function() {
|
||||
beforeEach(function() {
|
||||
fetchMock.post(/messages/, 204)
|
||||
})
|
||||
|
||||
it('appends the message to the list', async function() {
|
||||
const originalMessageList = store.messages.slice(0)
|
||||
await store.sendMessage('a message')
|
||||
expect(store.messages.length).to.equal(originalMessageList.length + 1)
|
||||
const lastMessage = store.messages[store.messages.length - 1]
|
||||
expect(lastMessage.contents).to.deep.equal(['a message'])
|
||||
expect(lastMessage.user).to.deep.equal(user)
|
||||
expect(lastMessage.timestamp).to.be.greaterThan(0)
|
||||
})
|
||||
|
||||
it('notifies an update event', async function() {
|
||||
const subscriber = sinon.stub()
|
||||
store.on('updated', subscriber)
|
||||
await store.sendMessage('a message')
|
||||
expect(subscriber).to.be.calledOnce
|
||||
})
|
||||
|
||||
it('sends an http POST request to the server', async function() {
|
||||
await store.sendMessage('a message')
|
||||
expect(fetchMock.calls().length).to.equal(1)
|
||||
const body = fetchMock.lastCall()[1].body
|
||||
expect(JSON.parse(body)).to.deep.equal({
|
||||
content: 'a message',
|
||||
_csrf: 'csrf_tok'
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores empty messages', async function() {
|
||||
const subscriber = sinon.stub()
|
||||
store.on('updated', subscriber)
|
||||
await store.sendMessage('')
|
||||
await store.sendMessage(null)
|
||||
expect(subscriber).not.to.be.called
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,220 @@
|
||||
import { expect } from 'chai'
|
||||
import {
|
||||
appendMessage,
|
||||
prependMessages
|
||||
} from '../../../../../frontend/js/features/chat/store/message-list-appender'
|
||||
|
||||
const testUser = {
|
||||
id: '123abc'
|
||||
}
|
||||
|
||||
const otherUser = {
|
||||
id: '234other'
|
||||
}
|
||||
|
||||
function createTestMessageList() {
|
||||
return [
|
||||
{
|
||||
contents: ['hello', 'world'],
|
||||
timestamp: new Date().getTime(),
|
||||
user: otherUser
|
||||
},
|
||||
{
|
||||
contents: ['foo'],
|
||||
timestamp: new Date().getTime(),
|
||||
user: testUser
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('prependMessages()', function() {
|
||||
function createTestMessages() {
|
||||
const message1 = {
|
||||
content: 'hello',
|
||||
timestamp: new Date().getTime(),
|
||||
user: testUser
|
||||
}
|
||||
const message2 = { ...message1 }
|
||||
return [message1, message2]
|
||||
}
|
||||
|
||||
it('to an empty list', function() {
|
||||
const messages = createTestMessages()
|
||||
expect(prependMessages([], messages)).to.deep.equal([
|
||||
{
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content, messages[1].content]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
describe('when the messages to prepend are from the same user', function() {
|
||||
let list, messages
|
||||
|
||||
beforeEach(function() {
|
||||
list = createTestMessageList()
|
||||
messages = createTestMessages()
|
||||
messages[0].user = testUser // makes all the messages have the same author
|
||||
})
|
||||
|
||||
it('when the prepended messages are close in time, contents should be merged into the same message', function() {
|
||||
const result = prependMessages(createTestMessageList(), messages)
|
||||
expect(result.length).to.equal(list.length + 1)
|
||||
expect(result[0]).to.deep.equal({
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content, messages[1].content]
|
||||
})
|
||||
})
|
||||
|
||||
it('when the prepended messages are separated in time, each message is prepended', function() {
|
||||
messages[0].timestamp = messages[1].timestamp - 6 * 60 * 1000 // 6 minutes before the next message
|
||||
const result = prependMessages(createTestMessageList(), messages)
|
||||
expect(result.length).to.equal(list.length + 2)
|
||||
expect(result[0]).to.deep.equal({
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content]
|
||||
})
|
||||
expect(result[1]).to.deep.equal({
|
||||
timestamp: messages[1].timestamp,
|
||||
user: messages[1].user,
|
||||
contents: [messages[1].content]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the messages to prepend are from different users', function() {
|
||||
let list, messages
|
||||
|
||||
beforeEach(function() {
|
||||
list = createTestMessageList()
|
||||
messages = createTestMessages()
|
||||
})
|
||||
|
||||
it('should prepend separate messages to the list', function() {
|
||||
messages[0].user = otherUser
|
||||
const result = prependMessages(createTestMessageList(), messages)
|
||||
expect(result.length).to.equal(list.length + 2)
|
||||
expect(result[0]).to.deep.equal({
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content]
|
||||
})
|
||||
expect(result[1]).to.deep.equal({
|
||||
timestamp: messages[1].timestamp,
|
||||
user: messages[1].user,
|
||||
contents: [messages[1].content]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should merge the prepended messages into the first existing one when user is same user and are close in time', function() {
|
||||
const list = createTestMessageList()
|
||||
const messages = createTestMessages()
|
||||
messages[0].user = messages[1].user = list[0].user
|
||||
|
||||
const result = prependMessages(createTestMessageList(), messages)
|
||||
expect(result.length).to.equal(list.length)
|
||||
expect(result[0]).to.deep.equal({
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content, messages[1].content, ...list[0].contents]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendMessage()', function() {
|
||||
function createTestMessage() {
|
||||
return {
|
||||
content: 'hi!',
|
||||
timestamp: new Date().getTime(),
|
||||
user: testUser
|
||||
}
|
||||
}
|
||||
|
||||
it('to an empty list', function() {
|
||||
const testMessage = createTestMessage()
|
||||
expect(appendMessage([], testMessage)).to.deep.equal([
|
||||
{
|
||||
timestamp: testMessage.timestamp,
|
||||
user: testMessage.user,
|
||||
contents: [testMessage.content]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
describe('messages appended shortly after the last message on the list', function() {
|
||||
let list, message
|
||||
|
||||
beforeEach(function() {
|
||||
list = createTestMessageList()
|
||||
message = createTestMessage()
|
||||
message.timestamp = list[1].timestamp + 6 * 1000 // 6 seconds after the last message in the list
|
||||
})
|
||||
|
||||
describe('when the author is the same as the last message', function() {
|
||||
it('should append the content to the last message', function() {
|
||||
const result = appendMessage(list, message)
|
||||
expect(result.length).to.equal(list.length)
|
||||
expect(result[1].contents).to.deep.equal(
|
||||
list[1].contents.concat(message.content)
|
||||
)
|
||||
})
|
||||
|
||||
it('should update the last message timestamp', function() {
|
||||
const result = appendMessage(list, message)
|
||||
expect(result[1].timestamp).to.equal(message.timestamp)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the author is different than the last message', function() {
|
||||
beforeEach(function() {
|
||||
message.user = otherUser
|
||||
})
|
||||
|
||||
it('should append the new message to the list', function() {
|
||||
const result = appendMessage(list, message)
|
||||
expect(result.length).to.equal(list.length + 1)
|
||||
expect(result[2]).to.deep.equal({
|
||||
timestamp: message.timestamp,
|
||||
user: message.user,
|
||||
contents: [message.content]
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('messages appended later after the last message on the list', function() {
|
||||
let list, message
|
||||
|
||||
beforeEach(function() {
|
||||
list = createTestMessageList()
|
||||
message = createTestMessage()
|
||||
message.timestamp = list[1].timestamp + 6 * 60 * 1000 // 6 minutes after the last message in the list
|
||||
})
|
||||
|
||||
it('when the author is the same as the last message, should be appended as new message', function() {
|
||||
const result = appendMessage(list, message)
|
||||
expect(result.length).to.equal(3)
|
||||
expect(result[2]).to.deep.equal({
|
||||
timestamp: message.timestamp,
|
||||
user: message.user,
|
||||
contents: [message.content]
|
||||
})
|
||||
})
|
||||
|
||||
it('when the author is the different than the last message, should be appended as new message', function() {
|
||||
message.user = otherUser
|
||||
|
||||
const result = appendMessage(list, message)
|
||||
expect(result.length).to.equal(3)
|
||||
expect(result[2]).to.deep.equal({
|
||||
timestamp: message.timestamp,
|
||||
user: message.user,
|
||||
contents: [message.content]
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user