Merge pull request #3300 from overleaf/msm-chat-react-store

React chat store

GitOrigin-RevId: 204009eb5798b02a41e621b33b05ef0cb9d10b15
This commit is contained in:
Miguel Serrano
2020-11-24 12:58:08 +01:00
committed by Copybot
parent cb9b44141c
commit 3b1c4e19a4
16 changed files with 726 additions and 171 deletions
@@ -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
+1
View File
@@ -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
View File
@@ -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]
})
})
})
})