diff --git a/services/web/app/views/project/editor/chat-react.pug b/services/web/app/views/project/editor/chat-react.pug
index ff227c26e2..9ccc31362e 100644
--- a/services/web/app/views/project/editor/chat-react.pug
+++ b/services/web/app/views/project/editor/chat-react.pug
@@ -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")
diff --git a/services/web/frontend/js/features/chat/components/chat-pane.js b/services/web/frontend/js/features/chat/components/chat-pane.js
index 421b758b5a..361bef3ebc 100644
--- a/services/web/frontend/js/features/chat/components/chat-pane.js
+++ b/services/web/frontend/js/features/chat/components/chat-pane.js
@@ -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)
diff --git a/services/web/frontend/js/features/chat/components/message-list.js b/services/web/frontend/js/features/chat/components/message-list.js
index 5de2dd131a..310d7fd34a 100644
--- a/services/web/frontend/js/features/chat/components/message-list.js
+++ b/services/web/frontend/js/features/chat/components/message-list.js
@@ -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
}
diff --git a/services/web/frontend/js/features/chat/controllers/chat-controller.js b/services/web/frontend/js/features/chat/controllers/chat-controller.js
index db981a8360..4170ce3c5e 100644
--- a/services/web/frontend/js/features/chat/controllers/chat-controller.js
+++ b/services/web/frontend/js/features/chat/controllers/chat-controller.js
@@ -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))
diff --git a/services/web/frontend/js/features/chat/store/chat-store-effect.js b/services/web/frontend/js/features/chat/store/chat-store-effect.js
new file mode 100644
index 0000000000..3a03391999
--- /dev/null
+++ b/services/web/frontend/js/features/chat/store/chat-store-effect.js
@@ -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
+}
diff --git a/services/web/frontend/js/features/chat/store/chat-store.js b/services/web/frontend/js/features/chat/store/chat-store.js
new file mode 100644
index 0000000000..c20538da23
--- /dev/null
+++ b/services/web/frontend/js/features/chat/store/chat-store.js
@@ -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 })
+ }
+}
diff --git a/services/web/frontend/js/features/chat/store/message-list-appender.js b/services/web/frontend/js/features/chat/store/message-list-appender.js
new file mode 100644
index 0000000000..b3600f4ff4
--- /dev/null
+++ b/services/web/frontend/js/features/chat/store/message-list-appender.js
@@ -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
+}
diff --git a/services/web/frontend/js/ide/chat/controllers/ChatButtonController.js b/services/web/frontend/js/ide/chat/controllers/ChatButtonController.js
index 1669f2f0fd..6071427801 100644
--- a/services/web/frontend/js/ide/chat/controllers/ChatButtonController.js
+++ b/services/web/frontend/js/ide/chat/controllers/ChatButtonController.js
@@ -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
diff --git a/services/web/package.json b/services/web/package.json
index 82ba36ee97..454f9486f5 100644
--- a/services/web/package.json
+++ b/services/web/package.json
@@ -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",
diff --git a/services/web/test/frontend/bootstrap.js b/services/web/test/frontend/bootstrap.js
index 763bb9a595..19b3f88c07 100644
--- a/services/web/test/frontend/bootstrap.js
+++ b/services/web/test/frontend/bootstrap.js
@@ -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)
diff --git a/services/web/test/frontend/features/chat/components/chat-pane.test.js b/services/web/test/frontend/features/chat/components/chat-pane.test.js
index 73f257478b..dd4cc51fc3 100644
--- a/services/web/test/frontend/features/chat/components/chat-pane.test.js
+++ b/services/web/test/frontend/features/chat/components/chat-pane.test.js
@@ -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('', 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(
- {}}
- sendMessage={() => {}}
- userId={currentUser.id}
- messages={createMessages()}
- resetUnreadMessages={() => {}}
- />
- )
+ it('renders multiple messages', async function() {
+ fetchMock.get(/messages/, testMessages)
+ render( {}} />)
- 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(
- {}}
- sendMessage={() => {}}
- userId={currentUser.id}
- messages={createMessages()}
- resetUnreadMessages={() => {}}
- />
- )
- screen.getByText('Loading…')
- })
-
- it('is not rendered when the messages are not loading', function() {
- render(
- {}}
- 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( {}} />)
+ await waitForElementToBeRemoved(() => screen.getByText('Loading…'))
})
describe('"send your first message" placeholder', function() {
- it('is rendered when there are no messages ', function() {
- render(
- {}}
- 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( {}} />)
+ await screen.findByText('Send your first message to your collaborators')
})
- it('is not rendered when there are some messages', function() {
- render(
- {}}
- sendMessage={() => {}}
- userId={currentUser.id}
- messages={createMessages()}
- resetUnreadMessages={() => {}}
- />
- )
+ it('is not rendered when messages are displayed', function() {
+ fetchMock.get(/messages/, testMessages)
+ render( {}} />)
+ 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
})
})
diff --git a/services/web/test/frontend/features/chat/components/message-list.test.js b/services/web/test/frontend/features/chat/components/message-list.test.js
index a0a8f217cf..0efc5aef9d 100644
--- a/services/web/test/frontend/features/chat/components/message-list.test.js
+++ b/services/web/test/frontend/features/chat/components/message-list.test.js
@@ -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('', 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('', 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(
', 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(
', 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()
})
diff --git a/services/web/test/frontend/features/chat/components/stubs.js b/services/web/test/frontend/features/chat/components/stubs.js
index fe22cb2b0f..04e94c5683 100644
--- a/services/web/test/frontend/features/chat/components/stubs.js
+++ b/services/web/test/frontend/features/chat/components/stubs.js
@@ -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
}
diff --git a/services/web/test/frontend/features/chat/store/chat-store.test.js b/services/web/test/frontend/features/chat/store/chat-store.test.js
new file mode 100644
index 0000000000..192c1f2fc6
--- /dev/null
+++ b/services/web/test/frontend/features/chat/store/chat-store.test.js
@@ -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
+ })
+ })
+})
diff --git a/services/web/test/frontend/features/chat/store/message-list-appender.test.js b/services/web/test/frontend/features/chat/store/message-list-appender.test.js
new file mode 100644
index 0000000000..b8abff5c5e
--- /dev/null
+++ b/services/web/test/frontend/features/chat/store/message-list-appender.test.js
@@ -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]
+ })
+ })
+ })
+})