diff --git a/app/javascript/dashboard/api/conversations.js b/app/javascript/dashboard/api/conversations.js
index 876103694..1de9aee29 100644
--- a/app/javascript/dashboard/api/conversations.js
+++ b/app/javascript/dashboard/api/conversations.js
@@ -13,6 +13,10 @@ class ConversationApi extends ApiClient {
updateLabels(conversationID, labels) {
return axios.post(`${this.url}/${conversationID}/labels`, { labels });
}
+
+ getUnreadCounts() {
+ return axios.get(`${this.url}/unread_counts`);
+ }
}
export default new ConversationApi();
diff --git a/app/javascript/dashboard/api/specs/conversations.spec.js b/app/javascript/dashboard/api/specs/conversations.spec.js
index 7ae4eb774..686db4098 100644
--- a/app/javascript/dashboard/api/specs/conversations.spec.js
+++ b/app/javascript/dashboard/api/specs/conversations.spec.js
@@ -11,6 +11,7 @@ describe('#ConversationApi', () => {
expect(conversationsAPI).toHaveProperty('delete');
expect(conversationsAPI).toHaveProperty('getLabels');
expect(conversationsAPI).toHaveProperty('updateLabels');
+ expect(conversationsAPI).toHaveProperty('getUnreadCounts');
});
describe('API calls', () => {
@@ -47,5 +48,12 @@ describe('#ConversationApi', () => {
}
);
});
+
+ it('#getUnreadCounts', () => {
+ conversationsAPI.getUnreadCounts();
+ expect(axiosMock.get).toHaveBeenCalledWith(
+ '/api/v1/conversations/unread_counts'
+ );
+ });
});
});
diff --git a/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue b/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue
index 83d1d4e20..a995cf510 100644
--- a/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue
+++ b/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import ChannelIcon from 'next/icon/ChannelIcon.vue';
+import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
const props = defineProps({
label: {
@@ -17,6 +18,10 @@ const props = defineProps({
type: Object,
required: true,
},
+ badgeCount: {
+ type: [Number, String],
+ default: 0,
+ },
});
const reauthorizationRequired = computed(() => {
@@ -29,6 +34,7 @@ const reauthorizationRequired = computed(() => {
-import { h, ref, computed, onMounted } from 'vue';
+import { h, ref, computed, onMounted, watch } from 'vue';
import { provideSidebarContext, useSidebarResize } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd';
@@ -61,6 +61,24 @@ const hasAdvancedAssignment = computed(() => {
);
});
+const hasConversationUnreadCounts = computed(() => {
+ return isFeatureEnabledonAccount.value(
+ accountId.value,
+ FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
+ );
+});
+
+const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
+ if (!currentAccountId) return;
+
+ if (!isEnabled) {
+ store.dispatch('conversationUnreadCounts/clear');
+ return;
+ }
+
+ store.dispatch('conversationUnreadCounts/get');
+};
+
const toggleShortcutModalFn = show => {
if (show) {
emit('openKeyShortcutModal');
@@ -157,6 +175,15 @@ useEventListener(document, 'touchend', onResizeEnd);
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar');
+const getInboxUnreadCount = useMapGetter(
+ 'conversationUnreadCounts/getInboxUnreadCount'
+);
+const getLabelUnreadCount = useMapGetter(
+ 'conversationUnreadCounts/getLabelUnreadCount'
+);
+const getTeamUnreadCount = useMapGetter(
+ 'conversationUnreadCounts/getTeamUnreadCount'
+);
const teams = useMapGetter('teams/getMyTeams');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter(
@@ -173,6 +200,10 @@ onMounted(() => {
store.dispatch('customViews/get', 'contact');
});
+watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, {
+ immediate: true,
+});
+
const sortedInboxes = computed(() =>
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
);
@@ -270,6 +301,7 @@ const menuItems = computed(() => {
children: teams.value.map(team => ({
name: `${team.name}-${team.id}`,
label: team.name,
+ badgeCount: getTeamUnreadCount.value(team.id),
to: accountScopedRoute('team_conversations', { teamId: team.id }),
})),
},
@@ -281,6 +313,7 @@ const menuItems = computed(() => {
children: sortedInboxes.value.map(inbox => ({
name: `${inbox.name}-${inbox.id}`,
label: inbox.name,
+ badgeCount: getInboxUnreadCount.value(inbox.id),
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
component: leafProps =>
@@ -288,6 +321,7 @@ const menuItems = computed(() => {
label: leafProps.label,
active: leafProps.active,
inbox,
+ badgeCount: leafProps.badgeCount,
}),
})),
},
@@ -299,6 +333,7 @@ const menuItems = computed(() => {
children: labels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
+ badgeCount: getLabelUnreadCount.value(label.id),
icon: h('span', {
class: `size-[8px] rounded-sm`,
style: { backgroundColor: label.color },
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue b/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue
index 1beea47df..18e1aea23 100644
--- a/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue
@@ -5,6 +5,7 @@ import { useSidebarContext } from './provider';
import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'next/icon/Icon.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
+import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
const props = defineProps({
label: { type: String, required: true },
@@ -166,6 +167,7 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
{{ subChild.label }}
+
@@ -188,6 +190,7 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
{{ child.label }}
+
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue b/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue
index b1b8dd740..31e41a6c2 100644
--- a/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue
@@ -3,6 +3,7 @@ import { isVNode, computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import Policy from 'dashboard/components/policy.vue';
import { useSidebarContext } from './provider';
+import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
const props = defineProps({
label: { type: String, required: true },
@@ -10,6 +11,7 @@ const props = defineProps({
icon: { type: [String, Object], default: null },
active: { type: Boolean, default: false },
component: { type: Function, default: null },
+ badgeCount: { type: [Number, String], default: 0 },
});
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
@@ -39,15 +41,14 @@ const shouldRenderComponent = computed(() => {
{{ label }}
+
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue b/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue
new file mode 100644
index 000000000..d9329fdaf
--- /dev/null
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue
@@ -0,0 +1,27 @@
+
+
+
+
+ {{ displayCount }}
+
+
+
diff --git a/app/javascript/dashboard/components-next/sidebar/specs/ChannelLeaf.spec.js b/app/javascript/dashboard/components-next/sidebar/specs/ChannelLeaf.spec.js
new file mode 100644
index 000000000..03d963134
--- /dev/null
+++ b/app/javascript/dashboard/components-next/sidebar/specs/ChannelLeaf.spec.js
@@ -0,0 +1,38 @@
+import { mount } from '@vue/test-utils';
+import ChannelLeaf from '../ChannelLeaf.vue';
+
+const mountChannelLeaf = props =>
+ mount(ChannelLeaf, {
+ props: {
+ label: 'Website',
+ inbox: { reauthorization_required: false },
+ ...props,
+ },
+ global: {
+ mocks: {
+ $t: key => key,
+ },
+ stubs: {
+ ChannelIcon: true,
+ Icon: true,
+ },
+ },
+ });
+
+describe('ChannelLeaf', () => {
+ it('renders unread badge when count is present', () => {
+ const wrapper = mountChannelLeaf({ badgeCount: 3 });
+ const badge = wrapper.find('[data-test-id="sidebar-unread-badge"]');
+
+ expect(badge.exists()).toBe(true);
+ expect(badge.text()).toBe('3');
+ });
+
+ it('does not render unread badge when count is zero', () => {
+ const wrapper = mountChannelLeaf({ badgeCount: 0 });
+
+ expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').exists()).toBe(
+ false
+ );
+ });
+});
diff --git a/app/javascript/dashboard/components-next/sidebar/specs/SidebarGroupLeaf.spec.js b/app/javascript/dashboard/components-next/sidebar/specs/SidebarGroupLeaf.spec.js
new file mode 100644
index 000000000..79b62d191
--- /dev/null
+++ b/app/javascript/dashboard/components-next/sidebar/specs/SidebarGroupLeaf.spec.js
@@ -0,0 +1,76 @@
+import { mount } from '@vue/test-utils';
+import { h } from 'vue';
+import SidebarGroupLeaf from '../SidebarGroupLeaf.vue';
+
+vi.mock('../provider', () => ({
+ useSidebarContext: () => ({
+ resolvePermissions: () => [],
+ resolveFeatureFlag: () => '',
+ }),
+}));
+
+const PolicyStub = {
+ props: ['as', 'permissions', 'featureFlag'],
+ template: '
',
+};
+
+const RouterLinkStub = {
+ props: ['to'],
+ template: '
',
+};
+
+const mountLeaf = props =>
+ mount(SidebarGroupLeaf, {
+ props: {
+ label: 'Support',
+ to: '/support',
+ ...props,
+ },
+ global: {
+ stubs: {
+ Icon: true,
+ Policy: PolicyStub,
+ RouterLink: RouterLinkStub,
+ },
+ },
+ });
+
+describe('SidebarGroupLeaf', () => {
+ it('renders unread badge when count is present', () => {
+ const wrapper = mountLeaf({ badgeCount: 7 });
+ const badge = wrapper.find('[data-test-id="sidebar-unread-badge"]');
+
+ expect(badge.exists()).toBe(true);
+ expect(badge.text()).toBe('7');
+ });
+
+ it('does not render unread badge when count is zero', () => {
+ const wrapper = mountLeaf({ badgeCount: 0 });
+
+ expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').exists()).toBe(
+ false
+ );
+ });
+
+ it('caps large unread counts', () => {
+ const wrapper = mountLeaf({ badgeCount: 120 });
+
+ expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').text()).toBe(
+ '99+'
+ );
+ });
+
+ it('passes unread count to custom leaf components', () => {
+ const wrapper = mountLeaf({
+ badgeCount: 4,
+ component: leafProps =>
+ h(
+ 'span',
+ { 'data-test-id': 'custom-leaf-count' },
+ leafProps.badgeCount
+ ),
+ });
+
+ expect(wrapper.find('[data-test-id="custom-leaf-count"]').text()).toBe('4');
+ });
+});
diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js
index b97e30984..00a79763b 100644
--- a/app/javascript/dashboard/featureFlags.js
+++ b/app/javascript/dashboard/featureFlags.js
@@ -46,6 +46,7 @@ export const FEATURE_FLAGS = {
COMPANIES: 'companies',
ADVANCED_SEARCH: 'advanced_search',
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
+ CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts',
};
export const PREMIUM_FEATURES = [
diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js
index 6feebb35d..74b82105c 100644
--- a/app/javascript/dashboard/helper/actionCable.js
+++ b/app/javascript/dashboard/helper/actionCable.js
@@ -4,14 +4,18 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
+import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const { isImpersonating } = useImpersonation();
+const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
const { websocketURL = '' } = window.chatwootConfig || {};
super(app, pubsubToken, websocketURL);
this.CancelTyping = [];
+ this.lastUnreadCountsFetchAt = null;
+ this.unreadCountsFetchTimer = null;
this.events = {
'message.created': this.onMessageCreated,
'message.updated': this.onMessageUpdated,
@@ -32,6 +36,8 @@ class ActionCableConnector extends BaseActionCableConnector {
'notification.updated': this.onNotificationUpdated,
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
+ 'conversation.unread_count_changed':
+ this.onConversationUnreadCountChanged,
'account.cache_invalidated': this.onCacheInvalidate,
'account.enrichment_completed': this.onEnrichmentCompleted,
'copilot.message.created': this.onCopilotMessageCreated,
@@ -120,6 +126,56 @@ class ActionCableConnector extends BaseActionCableConnector {
this.fetchConversationStats();
};
+ onConversationUnreadCountChanged = () => {
+ this.throttledFetchConversationUnreadCounts();
+ };
+
+ throttledFetchConversationUnreadCounts = () => {
+ const now = Date.now();
+ const elapsedTime = now - this.lastUnreadCountsFetchAt;
+
+ if (
+ this.lastUnreadCountsFetchAt === null ||
+ elapsedTime >= UNREAD_COUNTS_REFETCH_THROTTLE_MS
+ ) {
+ this.clearUnreadCountsFetchTimer();
+ this.fetchConversationUnreadCounts();
+ return;
+ }
+
+ if (this.unreadCountsFetchTimer) return;
+
+ this.unreadCountsFetchTimer = setTimeout(() => {
+ this.unreadCountsFetchTimer = null;
+ this.fetchConversationUnreadCounts();
+ }, UNREAD_COUNTS_REFETCH_THROTTLE_MS - elapsedTime);
+ };
+
+ clearUnreadCountsFetchTimer = () => {
+ if (!this.unreadCountsFetchTimer) return;
+
+ clearTimeout(this.unreadCountsFetchTimer);
+ this.unreadCountsFetchTimer = null;
+ };
+
+ fetchConversationUnreadCounts = () => {
+ if (!this.isConversationUnreadCountsEnabled()) return;
+
+ this.lastUnreadCountsFetchAt = Date.now();
+ this.app.$store.dispatch('conversationUnreadCounts/get');
+ };
+
+ isConversationUnreadCountsEnabled = () => {
+ const accountId = this.app.$store.getters.getCurrentAccountId;
+ const isFeatureEnabled =
+ this.app.$store.getters['accounts/isFeatureEnabledonAccount'];
+
+ return isFeatureEnabled?.(
+ accountId,
+ FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
+ );
+ };
+
onTypingOn = ({ conversation, user }) => {
const conversationId = conversation.id;
diff --git a/app/javascript/dashboard/helper/specs/actionCable.spec.js b/app/javascript/dashboard/helper/specs/actionCable.spec.js
index 4ad8a52c6..8ba411a5f 100644
--- a/app/javascript/dashboard/helper/specs/actionCable.spec.js
+++ b/app/javascript/dashboard/helper/specs/actionCable.spec.js
@@ -1,4 +1,4 @@
-import { describe, it, beforeEach, expect, vi } from 'vitest';
+import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import ActionCableConnector from '../actionCable';
vi.mock('shared/helpers/mitt', () => ({
@@ -30,12 +30,17 @@ describe('ActionCableConnector - Copilot Tests', () => {
dispatch: mockDispatch,
getters: {
getCurrentAccountId: 1,
+ 'accounts/isFeatureEnabledonAccount': vi.fn(() => true),
},
},
};
actionCable = ActionCableConnector.init(store.$store, 'test-token');
});
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
describe('copilot event handlers', () => {
it('should register the copilot.message.created event handler', () => {
expect(Object.keys(actionCable.events)).toContain(
@@ -64,4 +69,95 @@ describe('ActionCableConnector - Copilot Tests', () => {
);
});
});
+
+ describe('conversation unread count event handlers', () => {
+ it('should register the conversation.unread_count_changed event handler', () => {
+ expect(Object.keys(actionCable.events)).toContain(
+ 'conversation.unread_count_changed'
+ );
+ expect(actionCable.events['conversation.unread_count_changed']).toBe(
+ actionCable.onConversationUnreadCountChanged
+ );
+ });
+
+ it('should refetch unread counts when unread count changes', () => {
+ actionCable.onReceived({
+ event: 'conversation.unread_count_changed',
+ data: { account_id: 1 },
+ });
+
+ expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
+ });
+
+ it('does not refetch unread counts when unread count feature is disabled', () => {
+ store.$store.getters[
+ 'accounts/isFeatureEnabledonAccount'
+ ].mockReturnValue(false);
+
+ actionCable.onReceived({
+ event: 'conversation.unread_count_changed',
+ data: { account_id: 1 },
+ });
+
+ expect(mockDispatch).not.toHaveBeenCalledWith(
+ 'conversationUnreadCounts/get'
+ );
+ });
+
+ it('should throttle unread count refetches for repeated events', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
+
+ actionCable.onReceived({
+ event: 'conversation.unread_count_changed',
+ data: { account_id: 1 },
+ });
+ actionCable.onReceived({
+ event: 'conversation.unread_count_changed',
+ data: { account_id: 1 },
+ });
+ actionCable.onReceived({
+ event: 'conversation.unread_count_changed',
+ data: { account_id: 1 },
+ });
+
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(4999);
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(1);
+ expect(mockDispatch).toHaveBeenCalledTimes(2);
+ expect(mockDispatch).toHaveBeenLastCalledWith(
+ 'conversationUnreadCounts/get'
+ );
+ });
+
+ it('clears pending unread count refetch before immediate refetch', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
+
+ actionCable.onReceived({
+ event: 'conversation.unread_count_changed',
+ data: { account_id: 1 },
+ });
+
+ vi.advanceTimersByTime(1000);
+ actionCable.onReceived({
+ event: 'conversation.unread_count_changed',
+ data: { account_id: 1 },
+ });
+
+ vi.setSystemTime(new Date('2026-01-01T00:00:06Z'));
+ actionCable.onReceived({
+ event: 'conversation.unread_count_changed',
+ data: { account_id: 1 },
+ });
+
+ expect(mockDispatch).toHaveBeenCalledTimes(2);
+
+ vi.advanceTimersByTime(4000);
+ expect(mockDispatch).toHaveBeenCalledTimes(2);
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js
index d56958eb5..054a823c6 100755
--- a/app/javascript/dashboard/store/index.js
+++ b/app/javascript/dashboard/store/index.js
@@ -25,6 +25,7 @@ import conversations from './modules/conversations';
import conversationSearch from './modules/conversationSearch';
import conversationStats from './modules/conversationStats';
import conversationTypingStatus from './modules/conversationTypingStatus';
+import conversationUnreadCounts from './modules/conversationUnreadCounts';
import conversationWatchers from './modules/conversationWatchers';
import csat from './modules/csat';
import customRole from './modules/customRole';
@@ -88,6 +89,7 @@ export default createStore({
conversationSearch,
conversationStats,
conversationTypingStatus,
+ conversationUnreadCounts,
conversationWatchers,
csat,
customRole,
diff --git a/app/javascript/dashboard/store/modules/conversationUnreadCounts.js b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js
new file mode 100644
index 000000000..0503c0806
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js
@@ -0,0 +1,70 @@
+import ConversationAPI from '../../api/conversations';
+import types from '../mutation-types';
+
+export const state = {
+ inboxes: {},
+ labels: {},
+ teams: {},
+};
+
+const normalizeCounts = counts => {
+ return Object.entries(counts || {}).reduce((result, [id, count]) => {
+ const parsedCount = Number(count);
+ if (Number.isFinite(parsedCount) && parsedCount > 0) {
+ result[String(id)] = parsedCount;
+ }
+
+ return result;
+ }, {});
+};
+
+export const getters = {
+ getInboxUnreadCount: $state => inboxId => {
+ return $state.inboxes[String(inboxId)] || 0;
+ },
+ getLabelUnreadCount: $state => labelId => {
+ return $state.labels[String(labelId)] || 0;
+ },
+ getTeamUnreadCount: $state => teamId => {
+ return $state.teams[String(teamId)] || 0;
+ },
+ getInboxUnreadCounts($state) {
+ return $state.inboxes;
+ },
+ getLabelUnreadCounts($state) {
+ return $state.labels;
+ },
+ getTeamUnreadCounts($state) {
+ return $state.teams;
+ },
+};
+
+export const actions = {
+ get: async function getUnreadCounts({ commit }) {
+ try {
+ const response = await ConversationAPI.getUnreadCounts();
+ commit(types.SET_CONVERSATION_UNREAD_COUNTS, response.data.payload);
+ } catch (error) {
+ // Ignore errors so the sidebar can continue rendering without badges.
+ }
+ },
+ clear({ commit }) {
+ commit(types.SET_CONVERSATION_UNREAD_COUNTS, {});
+ },
+};
+
+export const mutations = {
+ [types.SET_CONVERSATION_UNREAD_COUNTS]($state, payload = {}) {
+ $state.inboxes = normalizeCounts(payload.inboxes);
+ $state.labels = normalizeCounts(payload.labels);
+ $state.teams = normalizeCounts(payload.teams);
+ },
+};
+
+export default {
+ namespaced: true,
+ state,
+ getters,
+ actions,
+ mutations,
+};
diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js
new file mode 100644
index 000000000..3100cdd10
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js
@@ -0,0 +1,53 @@
+import axios from 'axios';
+import { actions } from '../../conversationUnreadCounts';
+import types from '../../../mutation-types';
+
+const commit = vi.fn();
+global.axios = axios;
+vi.mock('axios');
+
+describe('#actions', () => {
+ beforeEach(() => {
+ commit.mockClear();
+ axios.get.mockReset();
+ });
+
+ describe('#get', () => {
+ it('commits unread counts when API is successful', async () => {
+ const payload = {
+ inboxes: { 1: '2' },
+ labels: { 3: 4 },
+ teams: { 5: 6 },
+ };
+ axios.get.mockResolvedValue({ data: { payload } });
+
+ await actions.get({ commit });
+
+ expect(axios.get).toHaveBeenCalledWith(
+ '/api/v1/conversations/unread_counts'
+ );
+ expect(commit.mock.calls).toEqual([
+ [types.SET_CONVERSATION_UNREAD_COUNTS, payload],
+ ]);
+ });
+
+ it('does not commit when API fails', async () => {
+ axios.get.mockRejectedValue({ message: 'Incorrect header' });
+
+ await actions.get({ commit });
+
+ expect(commit).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('#clear', () => {
+ it('clears unread counts', () => {
+ actions.clear({ commit });
+
+ expect(commit).toHaveBeenCalledWith(
+ types.SET_CONVERSATION_UNREAD_COUNTS,
+ {}
+ );
+ });
+ });
+});
diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
new file mode 100644
index 000000000..a3e74fc37
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
@@ -0,0 +1,51 @@
+import { getters } from '../../conversationUnreadCounts';
+
+describe('#getters', () => {
+ it('returns inbox unread count by id', () => {
+ const state = {
+ inboxes: { 1: 2 },
+ labels: {},
+ teams: {},
+ };
+
+ expect(getters.getInboxUnreadCount(state)(1)).toBe(2);
+ expect(getters.getInboxUnreadCount(state)('1')).toBe(2);
+ expect(getters.getInboxUnreadCount(state)(2)).toBe(0);
+ });
+
+ it('returns label unread count by id', () => {
+ const state = {
+ inboxes: {},
+ labels: { 3: 4 },
+ teams: {},
+ };
+
+ expect(getters.getLabelUnreadCount(state)(3)).toBe(4);
+ expect(getters.getLabelUnreadCount(state)('3')).toBe(4);
+ expect(getters.getLabelUnreadCount(state)(4)).toBe(0);
+ });
+
+ it('returns team unread count by id', () => {
+ const state = {
+ inboxes: {},
+ labels: {},
+ teams: { 5: 6 },
+ };
+
+ expect(getters.getTeamUnreadCount(state)(5)).toBe(6);
+ expect(getters.getTeamUnreadCount(state)('5')).toBe(6);
+ expect(getters.getTeamUnreadCount(state)(6)).toBe(0);
+ });
+
+ it('returns unread count maps', () => {
+ const state = {
+ inboxes: { 1: 2 },
+ labels: { 3: 4 },
+ teams: { 5: 6 },
+ };
+
+ expect(getters.getInboxUnreadCounts(state)).toEqual({ 1: 2 });
+ expect(getters.getLabelUnreadCounts(state)).toEqual({ 3: 4 });
+ expect(getters.getTeamUnreadCounts(state)).toEqual({ 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
new file mode 100644
index 000000000..3f7e2b1ec
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js
@@ -0,0 +1,48 @@
+import types from '../../../mutation-types';
+import { mutations } from '../../conversationUnreadCounts';
+
+describe('#mutations', () => {
+ describe('#SET_CONVERSATION_UNREAD_COUNTS', () => {
+ it('normalizes unread count payload', () => {
+ const state = { inboxes: {}, labels: {}, teams: {} };
+
+ mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
+ inboxes: {
+ 1: '2',
+ 2: 0,
+ 3: 'invalid',
+ },
+ labels: {
+ 4: 5,
+ 5: -1,
+ },
+ teams: {
+ 6: '7',
+ 7: 0,
+ },
+ });
+
+ expect(state).toEqual({
+ inboxes: { 1: 2 },
+ labels: { 4: 5 },
+ teams: { 6: 7 },
+ });
+ });
+
+ it('clears counts when payload is empty', () => {
+ const state = {
+ inboxes: { 1: 2 },
+ labels: { 4: 5 },
+ teams: { 6: 7 },
+ };
+
+ mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {});
+
+ expect(state).toEqual({
+ inboxes: {},
+ labels: {},
+ teams: {},
+ });
+ });
+ });
+});
diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js
index 82858b6b6..1597b7ea6 100644
--- a/app/javascript/dashboard/store/mutation-types.js
+++ b/app/javascript/dashboard/store/mutation-types.js
@@ -187,6 +187,9 @@ export default {
CLEAR_SELECTED_CONVERSATION_IDS: 'CLEAR_SELECTED_CONVERSATION_IDS',
REMOVE_SELECTED_CONVERSATION_IDS: 'REMOVE_SELECTED_CONVERSATION_IDS',
+ // Conversation Unread Counts
+ SET_CONVERSATION_UNREAD_COUNTS: 'SET_CONVERSATION_UNREAD_COUNTS',
+
// Reports
SET_ACCOUNT_REPORTS: 'SET_ACCOUNT_REPORTS',
SET_HEATMAP_DATA: 'SET_HEATMAP_DATA',
diff --git a/app/models/account.rb b/app/models/account.rb
index efaca8850..667058a2f 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -169,6 +169,11 @@ class Account < ApplicationRecord
Redis::Alfred.exists?(enrichment_key) ? 'enrichment' : step
end
+ def reset_cache_keys
+ super
+ clear_unread_conversation_counts_cache
+ end
+
private
def notify_creation
diff --git a/app/services/conversations/unread_counts/refresher.rb b/app/services/conversations/unread_counts/refresher.rb
index 9456cab55..9e49ecd28 100644
--- a/app/services/conversations/unread_counts/refresher.rb
+++ b/app/services/conversations/unread_counts/refresher.rb
@@ -114,7 +114,7 @@ class Conversations::UnreadCounts::Refresher
end
def affected_assignee_ids
- return [conversation.assignee_id].compact unless changed_attribute?(:assignee_id)
+ return [conversation.assignee_id] unless changed_attribute?(:assignee_id)
[previous_value_for(:assignee_id), conversation.assignee_id].uniq
end
diff --git a/spec/services/conversations/unread_counts/refresher_spec.rb b/spec/services/conversations/unread_counts/refresher_spec.rb
index 9b22c2186..5f361e7aa 100644
--- a/spec/services/conversations/unread_counts/refresher_spec.rb
+++ b/spec/services/conversations/unread_counts/refresher_spec.rb
@@ -119,6 +119,43 @@ RSpec.describe Conversations::UnreadCounts::Refresher do
expect(store).to have_received(:remove_assignment_membership).with(hash_including(assignee_ids: [assignee.id]))
end
+ it 'moves assignment-aware unassigned label membership when labels change' do
+ conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title])
+ Conversations::UnreadCounts::Builder.new(account).build_assignment!
+
+ conversation.update_labels([new_label.title])
+ result = described_class.new(
+ conversation.reload,
+ changed_attributes: { label_list: [[label.title], [new_label.title]] }
+ ).perform
+
+ expect(result).to be(true)
+ expect(store.counts_for_keys([
+ store.label_inbox_unassigned_key(account.id, label.id, inbox.id),
+ store.label_inbox_unassigned_key(account.id, new_label.id, inbox.id)
+ ])).to eq(
+ store.label_inbox_unassigned_key(account.id, label.id, inbox.id) => 0,
+ store.label_inbox_unassigned_key(account.id, new_label.id, inbox.id) => 1
+ )
+ end
+
+ it 'removes assignment-aware unassigned membership when conversation is resolved' do
+ conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title])
+ Conversations::UnreadCounts::Builder.new(account).build_assignment!
+
+ conversation.update!(status: :resolved)
+ result = described_class.new(conversation.reload, changed_attributes: { status: %w[open resolved] }).perform
+
+ expect(result).to be(true)
+ expect(store.counts_for_keys([
+ store.inbox_unassigned_key(account.id, inbox.id),
+ store.label_inbox_unassigned_key(account.id, label.id, inbox.id)
+ ])).to eq(
+ store.inbox_unassigned_key(account.id, inbox.id) => 0,
+ store.label_inbox_unassigned_key(account.id, label.id, inbox.id) => 0
+ )
+ end
+
it 'moves assignment-aware team membership when team changes' do
create(:team_member, user: assignee, team: new_team)
conversation = create_unread_conversation(account: account, inbox: inbox, assignee: assignee, team: team)