diff --git a/VERSION_CW b/VERSION_CW index d2b9909a9..0fb7a35b6 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.14.1 +4.14.2 diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index d9f523dfc..f71652ca0 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -175,6 +175,9 @@ useEventListener(document, 'touchend', onResizeEnd); const inboxes = useMapGetter('inboxes/getInboxes'); const labels = useMapGetter('labels/getLabelsOnSidebar'); +const allUnreadCount = useMapGetter( + 'conversationUnreadCounts/getAllUnreadCount' +); const getInboxUnreadCount = useMapGetter( 'conversationUnreadCounts/getInboxUnreadCount' ); @@ -297,6 +300,7 @@ const menuItems = computed(() => { { name: 'All', label: t('SIDEBAR.ALL_CONVERSATIONS'), + badgeCount: allUnreadCount.value, activeOn: ['inbox_conversation'], to: accountScopedRoute('home'), }, diff --git a/app/javascript/dashboard/store/modules/conversationUnreadCounts.js b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js index 0503c0806..c03249311 100644 --- a/app/javascript/dashboard/store/modules/conversationUnreadCounts.js +++ b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js @@ -2,15 +2,21 @@ import ConversationAPI from '../../api/conversations'; import types from '../mutation-types'; export const state = { + allCount: 0, inboxes: {}, labels: {}, teams: {}, }; +const normalizeCount = count => { + const parsedCount = Number(count); + return Number.isFinite(parsedCount) && parsedCount > 0 ? parsedCount : 0; +}; + const normalizeCounts = counts => { return Object.entries(counts || {}).reduce((result, [id, count]) => { - const parsedCount = Number(count); - if (Number.isFinite(parsedCount) && parsedCount > 0) { + const parsedCount = normalizeCount(count); + if (parsedCount > 0) { result[String(id)] = parsedCount; } @@ -19,6 +25,9 @@ const normalizeCounts = counts => { }; export const getters = { + getAllUnreadCount($state) { + return $state.allCount; + }, getInboxUnreadCount: $state => inboxId => { return $state.inboxes[String(inboxId)] || 0; }, @@ -55,6 +64,7 @@ export const actions = { export const mutations = { [types.SET_CONVERSATION_UNREAD_COUNTS]($state, payload = {}) { + $state.allCount = normalizeCount(payload.all_count); $state.inboxes = normalizeCounts(payload.inboxes); $state.labels = normalizeCounts(payload.labels); $state.teams = normalizeCounts(payload.teams); diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js index 3100cdd10..29fadc897 100644 --- a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js @@ -15,6 +15,7 @@ describe('#actions', () => { describe('#get', () => { it('commits unread counts when API is successful', async () => { const payload = { + all_count: 2, inboxes: { 1: '2' }, labels: { 3: 4 }, teams: { 5: 6 }, diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js index a3e74fc37..9fe19e22d 100644 --- a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js @@ -3,6 +3,7 @@ import { getters } from '../../conversationUnreadCounts'; describe('#getters', () => { it('returns inbox unread count by id', () => { const state = { + allCount: 0, inboxes: { 1: 2 }, labels: {}, teams: {}, @@ -15,6 +16,7 @@ describe('#getters', () => { it('returns label unread count by id', () => { const state = { + allCount: 0, inboxes: {}, labels: { 3: 4 }, teams: {}, @@ -27,6 +29,7 @@ describe('#getters', () => { it('returns team unread count by id', () => { const state = { + allCount: 0, inboxes: {}, labels: {}, teams: { 5: 6 }, @@ -37,8 +40,20 @@ describe('#getters', () => { expect(getters.getTeamUnreadCount(state)(6)).toBe(0); }); + it('returns all unread count', () => { + const state = { + allCount: 7, + inboxes: {}, + labels: {}, + teams: {}, + }; + + expect(getters.getAllUnreadCount(state)).toBe(7); + }); + it('returns unread count maps', () => { const state = { + allCount: 0, inboxes: { 1: 2 }, labels: { 3: 4 }, teams: { 5: 6 }, diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js index 3f7e2b1ec..8941d7430 100644 --- a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js @@ -4,9 +4,10 @@ import { mutations } from '../../conversationUnreadCounts'; describe('#mutations', () => { describe('#SET_CONVERSATION_UNREAD_COUNTS', () => { it('normalizes unread count payload', () => { - const state = { inboxes: {}, labels: {}, teams: {} }; + const state = { allCount: 0, inboxes: {}, labels: {}, teams: {} }; mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, { + all_count: '3', inboxes: { 1: '2', 2: 0, @@ -23,6 +24,7 @@ describe('#mutations', () => { }); expect(state).toEqual({ + allCount: 3, inboxes: { 1: 2 }, labels: { 4: 5 }, teams: { 6: 7 }, @@ -31,6 +33,7 @@ describe('#mutations', () => { it('clears counts when payload is empty', () => { const state = { + allCount: 2, inboxes: { 1: 2 }, labels: { 4: 5 }, teams: { 6: 7 }, @@ -39,10 +42,21 @@ describe('#mutations', () => { mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {}); expect(state).toEqual({ + allCount: 0, inboxes: {}, labels: {}, teams: {}, }); }); + + it('normalizes invalid aggregate counts to zero', () => { + const state = { allCount: 2, inboxes: {}, labels: {}, teams: {} }; + + mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, { + all_count: 'invalid', + }); + + expect(state.allCount).toBe(0); + }); }); }); diff --git a/app/javascript/widget/helpers/actionCable.js b/app/javascript/widget/helpers/actionCable.js index 60c379ed8..546317018 100644 --- a/app/javascript/widget/helpers/actionCable.js +++ b/app/javascript/widget/helpers/actionCable.js @@ -36,6 +36,9 @@ class ActionCableConnector extends BaseActionCableConnector { onReconnect = () => { this.syncLatestMessages(); + // Re-fetch conversation attributes so a status change (e.g. auto-resolve) + // that happened while disconnected is reflected, keeping the reply box state correct. + this.app.$store.dispatch('conversationAttributes/getAttributes'); }; setLastMessageId = () => { diff --git a/app/javascript/widget/helpers/specs/actionCable.spec.js b/app/javascript/widget/helpers/specs/actionCable.spec.js new file mode 100644 index 000000000..6aa3872bf --- /dev/null +++ b/app/javascript/widget/helpers/specs/actionCable.spec.js @@ -0,0 +1,53 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; +import ActionCableConnector from '../actionCable'; + +vi.mock('@rails/actioncable', () => ({ + createConsumer: () => ({ + subscriptions: { create: () => ({}) }, + disconnect: vi.fn(), + }), +})); + +describe('Widget ActionCableConnector', () => { + let app; + let mockDispatch; + let connector; + + beforeEach(() => { + vi.useFakeTimers(); + mockDispatch = vi.fn(); + app = { + $store: { + dispatch: mockDispatch, + getters: { + getCurrentAccountId: 1, + getCurrentUserID: 1, + }, + }, + }; + connector = new ActionCableConnector(app, 'test-token'); + mockDispatch.mockClear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('registers the conversation.status_changed event handler', () => { + expect(connector.events['conversation.status_changed']).toBe( + connector.onStatusChange + ); + }); + + it('re-fetches conversation attributes on reconnect so a status change missed while disconnected is reflected', () => { + connector.onReconnect(); + + expect(mockDispatch).toHaveBeenCalledWith( + 'conversation/syncLatestMessages' + ); + expect(mockDispatch).toHaveBeenCalledWith( + 'conversationAttributes/getAttributes' + ); + }); +}); diff --git a/app/services/conversations/unread_counts/counter.rb b/app/services/conversations/unread_counts/counter.rb index f4126b61f..b1ba5ddb0 100644 --- a/app/services/conversations/unread_counts/counter.rb +++ b/app/services/conversations/unread_counts/counter.rb @@ -19,8 +19,11 @@ class Conversations::UnreadCounts::Counter ensure_base_cache! ensure_assignment_cache! if assignment_mode? + inbox_counts = unread_inbox_counts + { - inboxes: unread_inbox_counts, + all_count: inbox_counts.values.sum, + inboxes: inbox_counts, labels: unread_label_counts, teams: unread_team_counts } @@ -191,7 +194,7 @@ class Conversations::UnreadCounts::Counter end def empty_counts - { inboxes: {}, labels: {}, teams: {} } + { all_count: 0, inboxes: {}, labels: {}, teams: {} } end def store diff --git a/config/app.yml b/config/app.yml index fec34cd07..c2494befa 100644 --- a/config/app.yml +++ b/config/app.yml @@ -1,5 +1,5 @@ shared: &shared - version: '4.14.1' + version: '4.14.2' development: <<: *shared diff --git a/enterprise/app/services/whatsapp/call_service.rb b/enterprise/app/services/whatsapp/call_service.rb index 93eba957c..743409839 100644 --- a/enterprise/app/services/whatsapp/call_service.rb +++ b/enterprise/app/services/whatsapp/call_service.rb @@ -9,7 +9,7 @@ class Whatsapp::CallService call.with_lock do transition_to_in_progress! update_message_status('in_progress') - update_conversation_call_status(call.display_status) + claim_conversation_and_set_call_status broadcast(:accepted, accepted_by_agent_id: agent.id) end call @@ -56,7 +56,6 @@ class Whatsapp::CallService forward_answer_to_meta! call.update!(status: 'in_progress', accepted_by_agent_id: agent.id, started_at: Time.current, meta: (call.meta || {}).merge('sdp_answer' => sdp_answer)) - claim_conversation_for_agent end def forward_answer_to_meta! @@ -64,9 +63,13 @@ class Whatsapp::CallService invoke_provider!(:accept_call, sdp_answer) end - # Take ownership of the conversation if no one holds it; leave assignee alone otherwise (transfer via UI). - def claim_conversation_for_agent - call.conversation.update!(assignee: agent) if call.conversation.assignee_id.blank? + # Claim an unheld conversation and set call_status in one save so previous_changes carries both the + # assignee change (activity message + ASSIGNEE_CHANGED) and the call_status change (conversation.updated webhook). + def claim_conversation_and_set_call_status + conversation = call.conversation + attrs = { additional_attributes: (conversation.additional_attributes || {}).merge('call_status' => call.display_status) } + attrs[:assignee] = agent if conversation.assignee_id.blank? + conversation.update!(attrs) end # Raise on Meta failure (bool false or transport error) so callers bail before diff --git a/package.json b/package.json index 41313c8b3..09eb084f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/chatwoot", - "version": "4.14.1", + "version": "4.14.2", "license": "MIT", "scripts": { "eslint": "eslint app/**/*.{js,vue}", @@ -34,7 +34,7 @@ "@amplitude/analytics-browser": "^2.11.10", "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", - "@chatwoot/prosemirror-schema": "1.3.19", + "@chatwoot/prosemirror-schema": "1.3.21", "@chatwoot/utils": "^0.0.55", "@formkit/core": "^1.7.2", "@formkit/vue": "^1.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68e667953..e843f8b09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: specifier: 1.2.3 version: 1.2.3 '@chatwoot/prosemirror-schema': - specifier: 1.3.19 - version: 1.3.19 + specifier: 1.3.21 + version: 1.3.21 '@chatwoot/utils': specifier: ^0.0.55 version: 0.0.55 @@ -458,8 +458,8 @@ packages: '@chatwoot/ninja-keys@1.2.3': resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==} - '@chatwoot/prosemirror-schema@1.3.19': - resolution: {integrity: sha512-LbATIAeTzclvbIK6WjtrGUO37AtMWkzCJi+s/KpUIp81TORhc0fHnclGT3353AbxeH6dF51/4hFTTsmF+ziqGA==} + '@chatwoot/prosemirror-schema@1.3.21': + resolution: {integrity: sha512-Y/OfXH1orK14foRcMrUees8lDjnDS9qZylVMyrstbfNyP5hbkBofsGAi6SP+5oIbPZ8iSG0sJyXtscoXpu/OFw==} '@chatwoot/utils@0.0.55': resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==} @@ -5128,7 +5128,7 @@ snapshots: hotkeys-js: 3.8.7 lit: 2.2.6 - '@chatwoot/prosemirror-schema@1.3.19': + '@chatwoot/prosemirror-schema@1.3.21': dependencies: markdown-it-sup: 2.0.0 prosemirror-commands: 1.7.1 diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index f8fd446d2..bdb8117ac 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -141,6 +141,7 @@ RSpec.describe 'Conversations API', type: :request do expect(response).to have_http_status(:success) expect(response.parsed_body['payload']).to eq( + 'all_count' => 1, 'inboxes' => { visible_inbox.id.to_s => 1 }, 'labels' => { label.id.to_s => 1 }, 'teams' => {} diff --git a/spec/enterprise/services/conversations/unread_counts/counter_spec.rb b/spec/enterprise/services/conversations/unread_counts/counter_spec.rb index cbb2d6c98..e8f850e78 100644 --- a/spec/enterprise/services/conversations/unread_counts/counter_spec.rb +++ b/spec/enterprise/services/conversations/unread_counts/counter_spec.rb @@ -26,6 +26,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do result = described_class.new(account: account, user: agent).perform + expect(result[:all_count]).to eq(2) expect(result[:inboxes]).to eq(inbox.id.to_s => 2) expect(result[:labels]).to eq(label.id.to_s => 2) expect(result[:teams]).to eq(team.id.to_s => 2) @@ -40,6 +41,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do result = described_class.new(account: account, user: agent).perform + expect(result[:all_count]).to eq(2) expect(result[:inboxes]).to eq(inbox.id.to_s => 2) expect(result[:labels]).to eq(label.id.to_s => 2) expect(result[:teams]).to eq(team.id.to_s => 2) @@ -53,6 +55,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do result = described_class.new(account: account, user: agent).perform + expect(result[:all_count]).to eq(1) expect(result[:inboxes]).to eq(inbox.id.to_s => 1) expect(result[:labels]).to eq(label.id.to_s => 1) expect(result[:teams]).to eq(team.id.to_s => 1) @@ -65,7 +68,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do result = described_class.new(account: account, user: agent).perform - expect(result).to eq(inboxes: {}, labels: {}, teams: {}) + expect(result).to eq(all_count: 0, inboxes: {}, labels: {}, teams: {}) expect(store.base_ready?(account.id)).to be(false) expect(store.assignment_ready?(account.id)).to be(false) end diff --git a/spec/services/conversations/unread_counts/counter_spec.rb b/spec/services/conversations/unread_counts/counter_spec.rb index bfd10436e..723f2d437 100644 --- a/spec/services/conversations/unread_counts/counter_spec.rb +++ b/spec/services/conversations/unread_counts/counter_spec.rb @@ -62,6 +62,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do result = described_class.new(account: account, user: agent).perform expect(result).to eq( + all_count: 1, inboxes: { visible_inbox.id.to_s => 1 }, labels: { label.id.to_s => 1 }, teams: { visible_team.id.to_s => 1 } @@ -75,6 +76,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do result = described_class.new(account: account, user: admin).perform expect(result).to eq( + all_count: 2, inboxes: { visible_inbox.id.to_s => 1, hidden_inbox.id.to_s => 1 }, labels: { label.id.to_s => 2 }, teams: { visible_team.id.to_s => 2 } @@ -87,6 +89,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do result = described_class.new(account: account, user: agent).perform expect(result).to eq( + all_count: 1, inboxes: { visible_inbox.id.to_s => 1 }, labels: {}, teams: { visible_team.id.to_s => 1 }