From 5a0e9ecada84d3b09753b333c71e126e95fc164b Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 14 Sep 2026 21:39:56 +0800 Subject: [PATCH] fix: hydrate realtime conversations before rendering --- .../dashboard/helper/actionCable.js | 34 ++++++++++- .../helper/specs/actionCable.spec.js | 56 +++++++++++++++++++ .../store/modules/conversations/actions.js | 30 +++++++--- .../specs/conversations/actions.spec.js | 39 +++++++++++++ 4 files changed, 149 insertions(+), 10 deletions(-) diff --git a/frontend/app/javascript/dashboard/helper/actionCable.js b/frontend/app/javascript/dashboard/helper/actionCable.js index 8c1da16c..b8470a2f 100644 --- a/frontend/app/javascript/dashboard/helper/actionCable.js +++ b/frontend/app/javascript/dashboard/helper/actionCable.js @@ -24,6 +24,8 @@ class ActionCableConnector extends BaseActionCableConnector { 'user:logout': this.onLogout, 'page:reload': this.onReload, 'assignee.changed': this.onAssigneeChanged, + 'conversation.assigned': this.onConversationAssigned, + 'conversation.unassigned': this.onConversationAssigned, 'conversation.typing_on': this.onTypingOn, 'conversation.typing_off': this.onTypingOff, 'conversation.contact_changed': this.onConversationContactChange, @@ -88,8 +90,18 @@ class ActionCableConnector extends BaseActionCableConnector { this.fetchConversationStats(); }; - onConversationCreated = data => { - this.app.$store.dispatch('addConversation', data); + onConversationAssigned = data => { + if (data?.id) { + this.app.$store.dispatch('getConversation', data.id); + } + this.fetchConversationStats(); + }; + + onConversationCreated = async data => { + const added = await this.app.$store.dispatch('addConversation', data); + if (added && !data.messages?.length) { + await this.app.$store.dispatch('getConversation', data.id); + } this.fetchConversationStats(); }; @@ -105,8 +117,24 @@ class ActionCableConnector extends BaseActionCableConnector { conversation: { last_activity_at: lastActivityAt }, conversation_id: conversationId, } = data; + const hasConversation = + this.app.$store.state?.conversations?.allConversations?.some( + conversation => Number(conversation.id) === Number(conversationId) + ); const applied = await this.app.$store.dispatch('addMessage', data); - if (!applied) return; + if (!applied) { + if (!hasConversation) { + const conversation = await this.app.$store.dispatch( + 'getConversation', + conversationId + ); + if (conversation) { + DashboardAudioNotificationHelper.onNewMessage(data); + this.fetchConversationStats(); + } + } + return; + } DashboardAudioNotificationHelper.onNewMessage(data); this.app.$store.dispatch('updateConversationLastActivity', { diff --git a/frontend/app/javascript/dashboard/helper/specs/actionCable.spec.js b/frontend/app/javascript/dashboard/helper/specs/actionCable.spec.js index a6cd9923..5db895d0 100644 --- a/frontend/app/javascript/dashboard/helper/specs/actionCable.spec.js +++ b/frontend/app/javascript/dashboard/helper/specs/actionCable.spec.js @@ -102,6 +102,62 @@ describe('ActionCableConnector - Copilot Tests', () => { }); }); + it('handles native assignment events and refreshes authoritative conversation data', async () => { + expect(actionCable.events['conversation.assigned']).toBe( + actionCable.onConversationAssigned + ); + expect(actionCable.events['conversation.unassigned']).toBe( + actionCable.onConversationAssigned + ); + + actionCable.onReceived({ + event: 'conversation.assigned', + data: { account_id: 1, id: 42 }, + }); + + expect(mockDispatch).toHaveBeenCalledWith('getConversation', 42); + }); + + it('hydrates a message whose realtime event arrives before conversation creation', async () => { + const message = { + id: 42, + account_id: 1, + conversation_id: 7, + conversation: { last_activity_at: 123 }, + }; + mockDispatch.mockImplementation(action => { + if (action === 'addMessage') return Promise.resolve(false); + if (action === 'getConversation') { + return Promise.resolve({ id: 7, messages: [message] }); + } + return undefined; + }); + + await actionCable.onMessageCreated(message); + + expect(mockDispatch).toHaveBeenCalledWith('getConversation', 7); + expect(DashboardAudioNotificationHelper.onNewMessage).toHaveBeenCalledWith( + message + ); + }); + + it('hydrates a conversation-created event that has no embedded messages', async () => { + mockDispatch.mockImplementation(action => { + if (action === 'addConversation') return Promise.resolve(true); + if (action === 'getConversation') return Promise.resolve({ id: 7 }); + return undefined; + }); + + await actionCable.onConversationCreated({ + id: 7, + inbox_id: 1, + messages: [], + meta: { sender: {} }, + }); + + expect(mockDispatch).toHaveBeenCalledWith('getConversation', 7); + }); + it('plays new message audio only once when realtime replays a message', async () => { const message = { id: 42, diff --git a/frontend/app/javascript/dashboard/store/modules/conversations/actions.js b/frontend/app/javascript/dashboard/store/modules/conversations/actions.js index fc9e8576..a29a4aa3 100644 --- a/frontend/app/javascript/dashboard/store/modules/conversations/actions.js +++ b/frontend/app/javascript/dashboard/store/modules/conversations/actions.js @@ -51,10 +51,19 @@ const actions = { getConversation: async ({ commit }, conversationId) => { try { const response = await ConversationApi.show(conversationId); - commit(types.UPDATE_CONVERSATION, response.data); - commit(`contacts/${types.SET_CONTACT_ITEM}`, response.data.meta.sender); + const conversation = response.data; + commit(types.UPDATE_CONVERSATION, conversation); + if (Array.isArray(conversation.messages)) { + commit(types.SET_MISSING_MESSAGES, { + id: conversation.id, + data: conversation.messages, + }); + } + commit(`contacts/${types.SET_CONTACT_ITEM}`, conversation.meta.sender); + return conversation; } catch (error) { // Ignore error + return null; } }, @@ -225,11 +234,16 @@ const actions = { commit(types.CLEAR_ALL_MESSAGES_LOADED, data.id); if (data.dataFetched === undefined) { try { - await dispatch('fetchPreviousMessages', { - after, - before: data.messages[0].id, - conversationId: data.id, - }); + if (!data.messages?.length) { + const conversation = await dispatch('getConversation', data.id); + if (!conversation) return; + } else { + await dispatch('fetchPreviousMessages', { + after, + before: data.messages[0].id, + conversationId: data.id, + }); + } commit(types.SET_CHAT_DATA_FETCHED, data.id); } catch (error) { // Ignore error @@ -435,7 +449,9 @@ const actions = { ) { commit(types.ADD_CONVERSATION, conversation); dispatch('contacts/setContact', sender); + return true; } + return false; }, addMentions({ dispatch, rootState }, conversation) { diff --git a/frontend/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js b/frontend/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js index 6d383e1b..b7e141cc 100644 --- a/frontend/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js +++ b/frontend/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js @@ -70,6 +70,31 @@ describe('#actions', () => { ['contacts/SET_CONTACT_ITEM', { id: 1, name: 'Contact 1' }], ]); }); + it('hydrates messages returned with the conversation', async () => { + const messages = [{ id: 2, conversation_id: 1, content: 'hello' }]; + axios.get.mockResolvedValue({ + data: { + id: 1, + messages, + meta: { sender: { id: 1, name: 'Contact 1' } }, + }, + }); + + await actions.getConversation({ commit }, 1); + + expect(commit.mock.calls).toEqual([ + [ + types.UPDATE_CONVERSATION, + { + id: 1, + messages, + meta: { sender: { id: 1, name: 'Contact 1' } }, + }, + ], + [types.SET_MISSING_MESSAGES, { id: 1, data: messages }], + ['contacts/SET_CONTACT_ITEM', { id: 1, name: 'Contact 1' }], + ]); + }); it('sends correct actions if API is error', async () => { axios.get.mockRejectedValue({ message: 'Incorrect header' }); await actions.getConversation({ commit }); @@ -957,6 +982,20 @@ describe('#addMentions', () => { expect(localDispatch).not.toHaveBeenCalled(); }); + it('hydrates a realtime conversation when its event has no messages', async () => { + const localCommit = vi.fn(); + const localDispatch = vi.fn().mockResolvedValue({ id: 42 }); + const data = { id: 42, messages: [] }; + + await actions.setActiveChat( + { commit: localCommit, dispatch: localDispatch }, + { data } + ); + + expect(localDispatch).toHaveBeenCalledWith('getConversation', 42); + expect(localCommit).toHaveBeenCalledWith(types.SET_CHAT_DATA_FETCHED, 42); + }); + it('should commit SET_CHAT_DATA_FETCHED by ID, not mutate the data object directly (race condition fix)', async () => { const localCommit = vi.fn(); const localDispatch = vi.fn().mockResolvedValue();