mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Fix: Chat messages deduplication (#12667)
* Prevent the duplicated message IDs by filtering them out * fix: Prevent the duplicated message IDs by filtering them out GitOrigin-RevId: 6d6c2821e6e29c9949fd323fa3a507016b51aff4
This commit is contained in:
parent
5f5e07a334
commit
df300b5f24
4 changed files with 198 additions and 32 deletions
|
@ -38,7 +38,11 @@ export function chatReducer(state, action) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
messages: prependMessages(state.messages, action.messages),
|
...prependMessages(
|
||||||
|
state.messages,
|
||||||
|
action.messages,
|
||||||
|
state.uniqueMessageIds
|
||||||
|
),
|
||||||
lastTimestamp: action.messages[0] ? action.messages[0].timestamp : null,
|
lastTimestamp: action.messages[0] ? action.messages[0].timestamp : null,
|
||||||
atEnd: action.messages.length < PAGE_SIZE,
|
atEnd: action.messages.length < PAGE_SIZE,
|
||||||
}
|
}
|
||||||
|
@ -46,22 +50,30 @@ export function chatReducer(state, action) {
|
||||||
case 'SEND_MESSAGE':
|
case 'SEND_MESSAGE':
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
messages: appendMessage(state.messages, {
|
...appendMessage(
|
||||||
// Messages are sent optimistically, so don't have an id (used for
|
state.messages,
|
||||||
// React keys). The uuid is valid for this session, and ensures all
|
{
|
||||||
// messages have an id. It will be overwritten by the actual ids on
|
// Messages are sent optimistically, so don't have an id (used for
|
||||||
// refresh
|
// React keys). The uuid is valid for this session, and ensures all
|
||||||
id: uuid(),
|
// messages have an id. It will be overwritten by the actual ids on
|
||||||
user: action.user,
|
// refresh
|
||||||
content: action.content,
|
id: uuid(),
|
||||||
timestamp: Date.now(),
|
user: action.user,
|
||||||
}),
|
content: action.content,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
state.uniqueMessageIds
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'RECEIVE_MESSAGE':
|
case 'RECEIVE_MESSAGE':
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
messages: appendMessage(state.messages, action.message),
|
...appendMessage(
|
||||||
|
state.messages,
|
||||||
|
action.message,
|
||||||
|
state.uniqueMessageIds
|
||||||
|
),
|
||||||
unreadMessageCount: state.unreadMessageCount + 1,
|
unreadMessageCount: state.unreadMessageCount + 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -94,6 +106,7 @@ const initialState = {
|
||||||
atEnd: false,
|
atEnd: false,
|
||||||
unreadMessageCount: 0,
|
unreadMessageCount: 0,
|
||||||
error: null,
|
error: null,
|
||||||
|
uniqueMessageIds: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChatContext = createContext()
|
export const ChatContext = createContext()
|
||||||
|
|
|
@ -1,6 +1,14 @@
|
||||||
const TIMESTAMP_GROUP_SIZE = 5 * 60 * 1000 // 5 minutes
|
const TIMESTAMP_GROUP_SIZE = 5 * 60 * 1000 // 5 minutes
|
||||||
|
|
||||||
export function appendMessage(messageList, message) {
|
export function appendMessage(messageList, message, uniqueMessageIds) {
|
||||||
|
if (uniqueMessageIds.includes(message.id)) {
|
||||||
|
return { messages: messageList, uniqueMessageIds }
|
||||||
|
}
|
||||||
|
|
||||||
|
uniqueMessageIds = uniqueMessageIds.slice(0)
|
||||||
|
|
||||||
|
uniqueMessageIds.push(message.id)
|
||||||
|
|
||||||
const lastMessage = messageList[messageList.length - 1]
|
const lastMessage = messageList[messageList.length - 1]
|
||||||
|
|
||||||
const shouldGroup =
|
const shouldGroup =
|
||||||
|
@ -12,7 +20,7 @@ export function appendMessage(messageList, message) {
|
||||||
message.timestamp - lastMessage.timestamp < TIMESTAMP_GROUP_SIZE
|
message.timestamp - lastMessage.timestamp < TIMESTAMP_GROUP_SIZE
|
||||||
|
|
||||||
if (shouldGroup) {
|
if (shouldGroup) {
|
||||||
return messageList.slice(0, messageList.length - 1).concat({
|
messageList = messageList.slice(0, messageList.length - 1).concat({
|
||||||
...lastMessage,
|
...lastMessage,
|
||||||
// the `id` is updated to the latest received content when a new
|
// the `id` is updated to the latest received content when a new
|
||||||
// message is appended or prepended
|
// message is appended or prepended
|
||||||
|
@ -21,21 +29,30 @@ export function appendMessage(messageList, message) {
|
||||||
contents: lastMessage.contents.concat(message.content),
|
contents: lastMessage.contents.concat(message.content),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
return messageList.slice(0).concat({
|
messageList = messageList.slice(0).concat({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
user: message.user,
|
user: message.user,
|
||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
contents: [message.content],
|
contents: [message.content],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { messages: messageList, uniqueMessageIds }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prependMessages(messageList, messages) {
|
export function prependMessages(messageList, messages, uniqueMessageIds) {
|
||||||
const listCopy = messageList.slice(0)
|
const listCopy = messageList.slice(0)
|
||||||
|
|
||||||
|
uniqueMessageIds = uniqueMessageIds.slice(0)
|
||||||
|
|
||||||
messages
|
messages
|
||||||
.slice(0)
|
.slice(0)
|
||||||
.reverse()
|
.reverse()
|
||||||
.forEach(message => {
|
.forEach(message => {
|
||||||
|
if (uniqueMessageIds.includes(message.id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uniqueMessageIds.push(message.id)
|
||||||
const firstMessage = listCopy[0]
|
const firstMessage = listCopy[0]
|
||||||
const shouldGroup =
|
const shouldGroup =
|
||||||
firstMessage &&
|
firstMessage &&
|
||||||
|
@ -57,5 +74,6 @@ export function prependMessages(messageList, messages) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return listCopy
|
|
||||||
|
return { messages: listCopy, uniqueMessageIds }
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,6 +50,10 @@ describe('ChatContext', function () {
|
||||||
fetchMock.post('express:/project/:projectId/messages', 200)
|
fetchMock.post('express:/project/:projectId/messages', 200)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
fetchMock.reset()
|
||||||
|
})
|
||||||
|
|
||||||
it('subscribes when mounted', function () {
|
it('subscribes when mounted', function () {
|
||||||
const socket = new EventEmitter()
|
const socket = new EventEmitter()
|
||||||
renderChatContextHook({ socket })
|
renderChatContextHook({ socket })
|
||||||
|
@ -99,6 +103,108 @@ describe('ChatContext', function () {
|
||||||
expect(message.contents).to.deep.equal(['new message'])
|
expect(message.contents).to.deep.equal(['new message'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('deduplicate messages from preloading', async function () {
|
||||||
|
// Mock socket: we only need to emit events, not mock actual connections
|
||||||
|
const socket = new EventEmitter()
|
||||||
|
const { result, waitForNextUpdate } = renderChatContextHook({
|
||||||
|
socket,
|
||||||
|
})
|
||||||
|
|
||||||
|
fetchMock.get(
|
||||||
|
'express:/project/:projectId/messages',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'msg_1',
|
||||||
|
content: 'new message',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
user: {
|
||||||
|
id: 'another_fake_user',
|
||||||
|
first_name: 'another_fake_user_first_name',
|
||||||
|
email: 'another_fake@example.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ overwriteRoutes: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mock message being received from another user
|
||||||
|
socket.emit('new-chat-message', {
|
||||||
|
id: 'msg_1',
|
||||||
|
content: 'new message',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
user: {
|
||||||
|
id: 'another_fake_user',
|
||||||
|
first_name: 'another_fake_user_first_name',
|
||||||
|
email: 'another_fake@example.com',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if received the message ID
|
||||||
|
expect(result.current.messages).to.have.length(1)
|
||||||
|
|
||||||
|
// Wait until initial messages have loaded
|
||||||
|
result.current.loadInitialMessages()
|
||||||
|
await waitForNextUpdate()
|
||||||
|
|
||||||
|
// Check if there are no message duplication
|
||||||
|
expect(result.current.messages).to.have.length(1)
|
||||||
|
|
||||||
|
const message = result.current.messages[0]
|
||||||
|
expect(message.id).to.equal('msg_1')
|
||||||
|
expect(message.contents).to.deep.equal(['new message'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicate messages from websocket', async function () {
|
||||||
|
// Mock socket: we only need to emit events, not mock actual connections
|
||||||
|
const socket = new EventEmitter()
|
||||||
|
const { result, waitForNextUpdate } = renderChatContextHook({
|
||||||
|
socket,
|
||||||
|
})
|
||||||
|
|
||||||
|
fetchMock.get(
|
||||||
|
'express:/project/:projectId/messages',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'msg_1',
|
||||||
|
content: 'new message',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
user: {
|
||||||
|
id: 'another_fake_user',
|
||||||
|
first_name: 'another_fake_user_first_name',
|
||||||
|
email: 'another_fake@example.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ overwriteRoutes: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wait until initial messages have loaded
|
||||||
|
result.current.loadInitialMessages()
|
||||||
|
await waitForNextUpdate()
|
||||||
|
|
||||||
|
// Check if received the message ID
|
||||||
|
expect(result.current.messages).to.have.length(1)
|
||||||
|
|
||||||
|
// Mock message being received from another user
|
||||||
|
socket.emit('new-chat-message', {
|
||||||
|
id: 'msg_1',
|
||||||
|
content: 'new message',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
user: {
|
||||||
|
id: 'another_fake_user',
|
||||||
|
first_name: 'another_fake_user_first_name',
|
||||||
|
email: 'another_fake@example.com',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if there are no message duplication
|
||||||
|
expect(result.current.messages).to.have.length(1)
|
||||||
|
|
||||||
|
const message = result.current.messages[0]
|
||||||
|
expect(message.id).to.equal('msg_1')
|
||||||
|
expect(message.contents).to.deep.equal(['new message'])
|
||||||
|
})
|
||||||
|
|
||||||
it("doesn't add received messages from the current user if a message was just sent", async function () {
|
it("doesn't add received messages from the current user if a message was just sent", async function () {
|
||||||
const socket = new EventEmitter()
|
const socket = new EventEmitter()
|
||||||
const { result, waitForNextUpdate } = renderChatContextHook({
|
const { result, waitForNextUpdate } = renderChatContextHook({
|
||||||
|
|
|
@ -43,7 +43,11 @@ describe('prependMessages()', function () {
|
||||||
|
|
||||||
it('to an empty list', function () {
|
it('to an empty list', function () {
|
||||||
const messages = createTestMessages()
|
const messages = createTestMessages()
|
||||||
expect(prependMessages([], messages)).to.deep.equal([
|
const uniqueMessageIds = []
|
||||||
|
|
||||||
|
expect(
|
||||||
|
prependMessages([], messages, uniqueMessageIds).messages
|
||||||
|
).to.deep.equal([
|
||||||
{
|
{
|
||||||
id: messages[0].id,
|
id: messages[0].id,
|
||||||
timestamp: messages[0].timestamp,
|
timestamp: messages[0].timestamp,
|
||||||
|
@ -54,16 +58,21 @@ describe('prependMessages()', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('when the messages to prepend are from the same user', function () {
|
describe('when the messages to prepend are from the same user', function () {
|
||||||
let list, messages
|
let list, messages, uniqueMessageIds
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
list = createTestMessageList()
|
list = createTestMessageList()
|
||||||
messages = createTestMessages()
|
messages = createTestMessages()
|
||||||
messages[0].user = testUser // makes all the messages have the same author
|
messages[0].user = testUser // makes all the messages have the same author
|
||||||
|
uniqueMessageIds = []
|
||||||
})
|
})
|
||||||
|
|
||||||
it('when the prepended messages are close in time, contents should be merged into the same message', function () {
|
it('when the prepended messages are close in time, contents should be merged into the same message', function () {
|
||||||
const result = prependMessages(createTestMessageList(), messages)
|
const result = prependMessages(
|
||||||
|
createTestMessageList(),
|
||||||
|
messages,
|
||||||
|
uniqueMessageIds
|
||||||
|
).messages
|
||||||
expect(result.length).to.equal(list.length + 1)
|
expect(result.length).to.equal(list.length + 1)
|
||||||
expect(result[0]).to.deep.equal({
|
expect(result[0]).to.deep.equal({
|
||||||
id: messages[0].id,
|
id: messages[0].id,
|
||||||
|
@ -75,7 +84,11 @@ describe('prependMessages()', function () {
|
||||||
|
|
||||||
it('when the prepended messages are separated in time, each message is prepended', function () {
|
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
|
messages[0].timestamp = messages[1].timestamp - 6 * 60 * 1000 // 6 minutes before the next message
|
||||||
const result = prependMessages(createTestMessageList(), messages)
|
const result = prependMessages(
|
||||||
|
createTestMessageList(),
|
||||||
|
messages,
|
||||||
|
uniqueMessageIds
|
||||||
|
).messages
|
||||||
expect(result.length).to.equal(list.length + 2)
|
expect(result.length).to.equal(list.length + 2)
|
||||||
expect(result[0]).to.deep.equal({
|
expect(result[0]).to.deep.equal({
|
||||||
id: messages[0].id,
|
id: messages[0].id,
|
||||||
|
@ -93,16 +106,21 @@ describe('prependMessages()', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('when the messages to prepend are from different users', function () {
|
describe('when the messages to prepend are from different users', function () {
|
||||||
let list, messages
|
let list, messages, uniqueMessageIds
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
list = createTestMessageList()
|
list = createTestMessageList()
|
||||||
messages = createTestMessages()
|
messages = createTestMessages()
|
||||||
|
uniqueMessageIds = []
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should prepend separate messages to the list', function () {
|
it('should prepend separate messages to the list', function () {
|
||||||
messages[0].user = otherUser
|
messages[0].user = otherUser
|
||||||
const result = prependMessages(createTestMessageList(), messages)
|
const result = prependMessages(
|
||||||
|
createTestMessageList(),
|
||||||
|
messages,
|
||||||
|
uniqueMessageIds
|
||||||
|
).messages
|
||||||
expect(result.length).to.equal(list.length + 2)
|
expect(result.length).to.equal(list.length + 2)
|
||||||
expect(result[0]).to.deep.equal({
|
expect(result[0]).to.deep.equal({
|
||||||
id: messages[0].id,
|
id: messages[0].id,
|
||||||
|
@ -123,8 +141,13 @@ describe('prependMessages()', function () {
|
||||||
const list = createTestMessageList()
|
const list = createTestMessageList()
|
||||||
const messages = createTestMessages()
|
const messages = createTestMessages()
|
||||||
messages[0].user = messages[1].user = list[0].user
|
messages[0].user = messages[1].user = list[0].user
|
||||||
|
const uniqueMessageIds = []
|
||||||
|
|
||||||
const result = prependMessages(createTestMessageList(), messages)
|
const result = prependMessages(
|
||||||
|
createTestMessageList(),
|
||||||
|
messages,
|
||||||
|
uniqueMessageIds
|
||||||
|
).messages
|
||||||
expect(result.length).to.equal(list.length)
|
expect(result.length).to.equal(list.length)
|
||||||
expect(result[0]).to.deep.equal({
|
expect(result[0]).to.deep.equal({
|
||||||
id: messages[0].id,
|
id: messages[0].id,
|
||||||
|
@ -147,7 +170,11 @@ describe('appendMessage()', function () {
|
||||||
|
|
||||||
it('to an empty list', function () {
|
it('to an empty list', function () {
|
||||||
const testMessage = createTestMessage()
|
const testMessage = createTestMessage()
|
||||||
expect(appendMessage([], testMessage)).to.deep.equal([
|
const uniqueMessageIds = []
|
||||||
|
|
||||||
|
expect(
|
||||||
|
appendMessage([], testMessage, uniqueMessageIds).messages
|
||||||
|
).to.deep.equal([
|
||||||
{
|
{
|
||||||
id: 'appended_message',
|
id: 'appended_message',
|
||||||
timestamp: testMessage.timestamp,
|
timestamp: testMessage.timestamp,
|
||||||
|
@ -158,17 +185,18 @@ describe('appendMessage()', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('messages appended shortly after the last message on the list', function () {
|
describe('messages appended shortly after the last message on the list', function () {
|
||||||
let list, message
|
let list, message, uniqueMessageIds
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
list = createTestMessageList()
|
list = createTestMessageList()
|
||||||
message = createTestMessage()
|
message = createTestMessage()
|
||||||
message.timestamp = list[1].timestamp + 6 * 1000 // 6 seconds after the last message in the list
|
message.timestamp = list[1].timestamp + 6 * 1000 // 6 seconds after the last message in the list
|
||||||
|
uniqueMessageIds = []
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('when the author is the same as the last message', function () {
|
describe('when the author is the same as the last message', function () {
|
||||||
it('should append the content to the last message', function () {
|
it('should append the content to the last message', function () {
|
||||||
const result = appendMessage(list, message)
|
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||||
expect(result.length).to.equal(list.length)
|
expect(result.length).to.equal(list.length)
|
||||||
expect(result[1].contents).to.deep.equal(
|
expect(result[1].contents).to.deep.equal(
|
||||||
list[1].contents.concat(message.content)
|
list[1].contents.concat(message.content)
|
||||||
|
@ -176,7 +204,7 @@ describe('appendMessage()', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should update the last message timestamp', function () {
|
it('should update the last message timestamp', function () {
|
||||||
const result = appendMessage(list, message)
|
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||||
expect(result[1].timestamp).to.equal(message.timestamp)
|
expect(result[1].timestamp).to.equal(message.timestamp)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -187,7 +215,7 @@ describe('appendMessage()', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should append the new message to the list', function () {
|
it('should append the new message to the list', function () {
|
||||||
const result = appendMessage(list, message)
|
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||||
expect(result.length).to.equal(list.length + 1)
|
expect(result.length).to.equal(list.length + 1)
|
||||||
expect(result[2]).to.deep.equal({
|
expect(result[2]).to.deep.equal({
|
||||||
id: 'appended_message',
|
id: 'appended_message',
|
||||||
|
@ -200,16 +228,17 @@ describe('appendMessage()', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('messages appended later after the last message on the list', function () {
|
describe('messages appended later after the last message on the list', function () {
|
||||||
let list, message
|
let list, message, uniqueMessageIds
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
list = createTestMessageList()
|
list = createTestMessageList()
|
||||||
message = createTestMessage()
|
message = createTestMessage()
|
||||||
message.timestamp = list[1].timestamp + 6 * 60 * 1000 // 6 minutes after the last message in the list
|
message.timestamp = list[1].timestamp + 6 * 60 * 1000 // 6 minutes after the last message in the list
|
||||||
|
uniqueMessageIds = []
|
||||||
})
|
})
|
||||||
|
|
||||||
it('when the author is the same as the last message, should be appended as new message', function () {
|
it('when the author is the same as the last message, should be appended as new message', function () {
|
||||||
const result = appendMessage(list, message)
|
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||||
expect(result.length).to.equal(3)
|
expect(result.length).to.equal(3)
|
||||||
expect(result[2]).to.deep.equal({
|
expect(result[2]).to.deep.equal({
|
||||||
id: 'appended_message',
|
id: 'appended_message',
|
||||||
|
@ -222,7 +251,7 @@ describe('appendMessage()', function () {
|
||||||
it('when the author is the different than the last message, should be appended as new message', function () {
|
it('when the author is the different than the last message, should be appended as new message', function () {
|
||||||
message.user = otherUser
|
message.user = otherUser
|
||||||
|
|
||||||
const result = appendMessage(list, message)
|
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||||
expect(result.length).to.equal(3)
|
expect(result.length).to.equal(3)
|
||||||
expect(result[2]).to.deep.equal({
|
expect(result[2]).to.deep.equal({
|
||||||
id: 'appended_message',
|
id: 'appended_message',
|
||||||
|
|
Loading…
Reference in a new issue