feat: Add unread count filters feature flag (1/6) (#14885)

## Description

Adds the account-level `unread_count_for_filters` feature flag as the
dark-launch gate for filtered sidebar unread counts. This reuses the
deprecated `quoted_email_reply` flag slot, resets the reused bit for
existing accounts, and removes stale defaults so new accounts do not
reference the old flag.

This also adds the feature where we are now calculating the unread counts for built in filters like mentions, participating and unattended along with unread count for saved filters/folders.

Closes
[CW-7262](https://linear.app/chatwoot/issue/CW-7262/unread-counts-for-filters-folders)

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update
This commit is contained in:
Sony Mathew
2026-07-08 23:50:40 +05:30
committed by GitHub
parent b9536fb8ed
commit 66cfb26c77
77 changed files with 4539 additions and 57 deletions
@@ -6,6 +6,10 @@ export const state = {
inboxes: {},
labels: {},
teams: {},
mentionsCount: 0,
participatingCount: 0,
unattendedCount: 0,
folders: {},
};
const normalizeCount = count => {
@@ -37,6 +41,18 @@ export const getters = {
getTeamUnreadCount: $state => teamId => {
return $state.teams[String(teamId)] || 0;
},
getMentionsUnreadCount($state) {
return $state.mentionsCount;
},
getParticipatingUnreadCount($state) {
return $state.participatingCount;
},
getUnattendedUnreadCount($state) {
return $state.unattendedCount;
},
getFolderUnreadCount: $state => folderId => {
return $state.folders[String(folderId)] || 0;
},
getInboxUnreadCounts($state) {
return $state.inboxes;
},
@@ -46,6 +62,9 @@ export const getters = {
getTeamUnreadCounts($state) {
return $state.teams;
},
getFolderUnreadCounts($state) {
return $state.folders;
},
};
export const actions = {
@@ -68,6 +87,10 @@ export const mutations = {
$state.inboxes = normalizeCounts(payload.inboxes);
$state.labels = normalizeCounts(payload.labels);
$state.teams = normalizeCounts(payload.teams);
$state.mentionsCount = normalizeCount(payload.mentions_count);
$state.participatingCount = normalizeCount(payload.participating_count);
$state.unattendedCount = normalizeCount(payload.unattended_count);
$state.folders = normalizeCounts(payload.folders);
},
};
@@ -1,8 +1,15 @@
import types from '../mutation-types';
import { throwErrorMessage } from 'dashboard/store/utils/api';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import ConversationInboxApi from '../../api/inbox/conversation';
const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS = 30000;
const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS = 15000;
const getFilteredUnreadCountsRefreshRetryDelay = () =>
FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS +
Math.random() * FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS;
const state = {
records: {},
uiFlags: {
@@ -20,6 +27,43 @@ export const getters = {
},
};
const hasFeatureEnabled = (rootGetters, featureFlag) => {
const accountId = rootGetters?.getCurrentAccountId;
const isFeatureEnabled = rootGetters?.['accounts/isFeatureEnabledonAccount'];
return Boolean(accountId && isFeatureEnabled?.(accountId, featureFlag));
};
const hasCurrentUser = (participants, currentUserId) =>
(Array.isArray(participants) ? participants : []).some(
participant => participant.id === currentUserId
);
const refreshConversationUnreadCounts = dispatch => {
dispatch('conversationUnreadCounts/get', {}, { root: true });
setTimeout(
() => dispatch('conversationUnreadCounts/get', {}, { root: true }),
getFilteredUnreadCountsRefreshRetryDelay()
);
};
const shouldRefreshConversationUnreadCounts = (
{ rootGetters, state: moduleState },
conversationId,
participants
) => {
const currentUserId =
rootGetters?.getCurrentUserID || rootGetters?.getCurrentUser?.id;
return (
currentUserId &&
hasFeatureEnabled(rootGetters, FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS) &&
hasFeatureEnabled(rootGetters, FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS) &&
hasCurrentUser(moduleState.records[conversationId], currentUserId) !==
hasCurrentUser(participants, currentUserId)
);
};
export const actions = {
show: async ({ commit }, { conversationId }) => {
commit(types.SET_CONVERSATION_PARTICIPANTS_UI_FLAG, {
@@ -42,7 +86,10 @@ export const actions = {
}
},
update: async ({ commit }, { conversationId, userIds }) => {
update: async (
{ commit, dispatch, rootGetters, state: moduleState },
{ conversationId, userIds }
) => {
commit(types.SET_CONVERSATION_PARTICIPANTS_UI_FLAG, {
isUpdating: true,
});
@@ -52,10 +99,18 @@ export const actions = {
conversationId,
userIds,
});
const shouldRefreshUnreadCounts = shouldRefreshConversationUnreadCounts(
{ rootGetters, state: moduleState },
conversationId,
response.data
);
commit(types.SET_CONVERSATION_PARTICIPANTS, {
conversationId,
data: response.data,
});
if (shouldRefreshUnreadCounts) {
refreshConversationUnreadCounts(dispatch);
}
} catch (error) {
throwErrorMessage(error);
} finally {
@@ -1,11 +1,17 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
import CustomViewsAPI from '../../api/customViews';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const VIEW_TYPES = {
CONVERSATION: 'conversation',
CONTACT: 'contact',
};
const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS = 30000;
const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS = 15000;
const getFilteredUnreadCountsRefreshRetryDelay = () =>
FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS +
Math.random() * FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS;
// use to normalize the filter type
const FILTER_KEYS = {
@@ -21,6 +27,38 @@ const getFolderContactId = folder =>
folder?.query?.payload?.find(filter => filter.attribute_key === 'contact_id')
?.values?.[0];
const hasFeatureEnabled = (rootGetters, featureFlag) => {
const accountId = rootGetters?.getCurrentAccountId;
const isFeatureEnabled = rootGetters?.['accounts/isFeatureEnabledonAccount'];
return Boolean(accountId && isFeatureEnabled?.(accountId, featureFlag));
};
const shouldRefreshConversationUnreadCounts = (filterType, rootGetters) => {
return (
FILTER_KEYS[filterType] === VIEW_TYPES.CONVERSATION &&
hasFeatureEnabled(rootGetters, FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS) &&
hasFeatureEnabled(rootGetters, FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS)
);
};
const dispatchConversationUnreadCounts = dispatch => {
dispatch('conversationUnreadCounts/get', {}, { root: true });
};
const refreshConversationUnreadCounts = (
{ dispatch, rootGetters },
filterType
) => {
if (!shouldRefreshConversationUnreadCounts(filterType, rootGetters)) return;
dispatchConversationUnreadCounts(dispatch);
setTimeout(
() => dispatchConversationUnreadCounts(dispatch),
getFilteredUnreadCountsRefreshRetryDelay()
);
};
export const state = {
[VIEW_TYPES.CONVERSATION]: {
records: [],
@@ -71,14 +109,19 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isFetching: false });
}
},
create: async function createCustomViews({ commit }, obj) {
create: async function createCustomViews(
{ commit, dispatch, rootGetters },
obj
) {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true });
try {
const response = await CustomViewsAPI.create(obj);
const filterType = FILTER_KEYS[obj.filter_type];
commit(types.ADD_CUSTOM_VIEW, {
data: response.data,
filterType: FILTER_KEYS[obj.filter_type],
filterType,
});
refreshConversationUnreadCounts({ dispatch, rootGetters }, filterType);
return response;
} catch (error) {
const errorMessage = error?.response?.data?.message;
@@ -87,14 +130,19 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false });
}
},
update: async function updateCustomViews({ commit }, obj) {
update: async function updateCustomViews(
{ commit, dispatch, rootGetters },
obj
) {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true });
try {
const response = await CustomViewsAPI.update(obj.id, obj);
const filterType = FILTER_KEYS[obj.filter_type];
commit(types.UPDATE_CUSTOM_VIEW, {
data: response.data,
filterType: FILTER_KEYS[obj.filter_type],
filterType,
});
refreshConversationUnreadCounts({ dispatch, rootGetters }, filterType);
} catch (error) {
const errorMessage = error?.response?.data?.message;
throw new Error(errorMessage);
@@ -102,11 +150,12 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false });
}
},
delete: async ({ commit }, { id, filterType }) => {
delete: async ({ commit, dispatch, rootGetters }, { id, filterType }) => {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: true });
try {
await CustomViewsAPI.deleteCustomViews(id, filterType);
commit(types.DELETE_CUSTOM_VIEW, { data: id, filterType });
refreshConversationUnreadCounts({ dispatch, rootGetters }, filterType);
} catch (error) {
throw new Error(error);
} finally {
@@ -19,6 +19,10 @@ describe('#actions', () => {
inboxes: { 1: '2' },
labels: { 3: 4 },
teams: { 5: 6 },
mentions_count: 7,
participating_count: 8,
unattended_count: 9,
folders: { 10: 11 },
};
axios.get.mockResolvedValue({ data: { payload } });
@@ -7,6 +7,7 @@ describe('#getters', () => {
inboxes: { 1: 2 },
labels: {},
teams: {},
folders: {},
};
expect(getters.getInboxUnreadCount(state)(1)).toBe(2);
@@ -20,6 +21,7 @@ describe('#getters', () => {
inboxes: {},
labels: { 3: 4 },
teams: {},
folders: {},
};
expect(getters.getLabelUnreadCount(state)(3)).toBe(4);
@@ -33,6 +35,7 @@ describe('#getters', () => {
inboxes: {},
labels: {},
teams: { 5: 6 },
folders: {},
};
expect(getters.getTeamUnreadCount(state)(5)).toBe(6);
@@ -46,21 +49,44 @@ describe('#getters', () => {
inboxes: {},
labels: {},
teams: {},
folders: {},
};
expect(getters.getAllUnreadCount(state)).toBe(7);
});
it('returns filtered unread counts', () => {
const state = {
allCount: 0,
inboxes: {},
labels: {},
teams: {},
mentionsCount: 1,
participatingCount: 2,
unattendedCount: 3,
folders: { 8: 4 },
};
expect(getters.getMentionsUnreadCount(state)).toBe(1);
expect(getters.getParticipatingUnreadCount(state)).toBe(2);
expect(getters.getUnattendedUnreadCount(state)).toBe(3);
expect(getters.getFolderUnreadCount(state)(8)).toBe(4);
expect(getters.getFolderUnreadCount(state)('8')).toBe(4);
expect(getters.getFolderUnreadCount(state)(9)).toBe(0);
});
it('returns unread count maps', () => {
const state = {
allCount: 0,
inboxes: { 1: 2 },
labels: { 3: 4 },
teams: { 5: 6 },
folders: { 7: 8 },
};
expect(getters.getInboxUnreadCounts(state)).toEqual({ 1: 2 });
expect(getters.getLabelUnreadCounts(state)).toEqual({ 3: 4 });
expect(getters.getTeamUnreadCounts(state)).toEqual({ 5: 6 });
expect(getters.getFolderUnreadCounts(state)).toEqual({ 7: 8 });
});
});
@@ -4,7 +4,16 @@ import { mutations } from '../../conversationUnreadCounts';
describe('#mutations', () => {
describe('#SET_CONVERSATION_UNREAD_COUNTS', () => {
it('normalizes unread count payload', () => {
const state = { allCount: 0, inboxes: {}, labels: {}, teams: {} };
const state = {
allCount: 0,
inboxes: {},
labels: {},
teams: {},
mentionsCount: 0,
participatingCount: 0,
unattendedCount: 0,
folders: {},
};
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
all_count: '3',
@@ -21,6 +30,13 @@ describe('#mutations', () => {
6: '7',
7: 0,
},
mentions_count: '8',
participating_count: 9,
unattended_count: 0,
folders: {
10: '11',
12: -1,
},
});
expect(state).toEqual({
@@ -28,6 +44,10 @@ describe('#mutations', () => {
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
mentionsCount: 8,
participatingCount: 9,
unattendedCount: 0,
folders: { 10: 11 },
});
});
@@ -37,6 +57,10 @@ describe('#mutations', () => {
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
mentionsCount: 8,
participatingCount: 9,
unattendedCount: 10,
folders: { 11: 12 },
};
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {});
@@ -46,17 +70,36 @@ describe('#mutations', () => {
inboxes: {},
labels: {},
teams: {},
mentionsCount: 0,
participatingCount: 0,
unattendedCount: 0,
folders: {},
});
});
it('normalizes invalid aggregate counts to zero', () => {
const state = { allCount: 2, inboxes: {}, labels: {}, teams: {} };
const state = {
allCount: 2,
inboxes: {},
labels: {},
teams: {},
mentionsCount: 2,
participatingCount: 3,
unattendedCount: 4,
folders: {},
};
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
all_count: 'invalid',
mentions_count: 'invalid',
participating_count: -1,
unattended_count: 0,
});
expect(state.allCount).toBe(0);
expect(state.mentionsCount).toBe(0);
expect(state.participatingCount).toBe(0);
expect(state.unattendedCount).toBe(0);
});
});
});
@@ -1,11 +1,32 @@
import axios from 'axios';
import { actions } from '../../conversationWatchers';
import types from '../../../mutation-types';
import { FEATURE_FLAGS } from '../../../../featureFlags';
const commit = vi.fn();
global.axios = axios;
vi.mock('axios');
const mockRetryJitter = value =>
vi.spyOn(Math, 'random').mockReturnValue(value);
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});
const conversationUnreadCountsEnabledRootGetters = {
getCurrentAccountId: 1,
getCurrentUserID: 1,
'accounts/isFeatureEnabledonAccount': vi.fn((_, featureFlag) =>
[
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS,
FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS,
].includes(featureFlag)
),
};
describe('#actions', () => {
describe('#get', () => {
it('sends correct actions if API is success', async () => {
@@ -48,6 +69,56 @@ describe('#actions', () => {
[types.SET_CONVERSATION_PARTICIPANTS_UI_FLAG, { isUpdating: false }],
]);
});
it('refetches unread counts when the current user starts watching', async () => {
vi.useFakeTimers();
mockRetryJitter(0.5);
const dispatch = vi.fn();
const moduleState = { records: { 2: [] } };
const mutatingCommit = vi.fn((mutation, payload) => {
if (mutation === types.SET_CONVERSATION_PARTICIPANTS) {
moduleState.records[payload.conversationId] = payload.data;
}
});
axios.patch.mockResolvedValue({ data: [{ id: 1 }] });
await actions.update(
{
commit: mutatingCommit,
dispatch,
rootGetters: conversationUnreadCountsEnabledRootGetters,
state: moduleState,
},
{ conversationId: 2, userIds: [1] }
);
expect(dispatch).toHaveBeenCalledWith(
'conversationUnreadCounts/get',
{},
{ root: true }
);
vi.advanceTimersByTime(37499);
expect(dispatch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(dispatch).toHaveBeenCalledTimes(2);
});
it('does not refetch unread counts when another watcher changes', async () => {
const dispatch = vi.fn();
axios.patch.mockResolvedValue({ data: [{ id: 1 }, { id: 2 }] });
await actions.update(
{
commit,
dispatch,
rootGetters: conversationUnreadCountsEnabledRootGetters,
state: { records: { 2: [{ id: 1 }] } },
},
{ conversationId: 2, userIds: [1, 2] }
);
expect(dispatch).not.toHaveBeenCalled();
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(
@@ -1,6 +1,7 @@
import axios from 'axios';
import * as types from '../../../mutation-types';
import { actions } from '../../customViews';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import {
contactFilterView,
customViewList,
@@ -11,6 +12,25 @@ const commit = vi.fn();
global.axios = axios;
vi.mock('axios');
const mockRetryJitter = value =>
vi.spyOn(Math, 'random').mockReturnValue(value);
const conversationUnreadCountsEnabledRootGetters = {
getCurrentAccountId: 1,
'accounts/isFeatureEnabledonAccount': vi.fn((_, featureFlag) =>
[
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS,
FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS,
].includes(featureFlag)
),
};
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});
describe('#actions', () => {
describe('#get', () => {
it('sends correct actions if API is success', async () => {
@@ -49,6 +69,36 @@ describe('#actions', () => {
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false }],
]);
});
it('refetches unread counts after creating a conversation folder', async () => {
vi.useFakeTimers();
mockRetryJitter(0.5);
const dispatch = vi.fn();
const firstItem = customViewList[0];
axios.post.mockResolvedValue({ data: firstItem });
await actions.create(
{
commit,
dispatch,
rootGetters: conversationUnreadCountsEnabledRootGetters,
},
firstItem
);
expect(dispatch).toHaveBeenCalledWith(
'conversationUnreadCounts/get',
{},
{ root: true }
);
vi.advanceTimersByTime(37499);
expect(dispatch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(dispatch).toHaveBeenCalledTimes(2);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.create({ commit })).rejects.toThrow(Error);
@@ -69,6 +119,44 @@ describe('#actions', () => {
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: false }],
]);
});
it('refetches unread counts after deleting a conversation folder', async () => {
vi.useFakeTimers();
const dispatch = vi.fn();
axios.delete.mockResolvedValue({ data: customViewList[0] });
await actions.delete(
{
commit,
dispatch,
rootGetters: conversationUnreadCountsEnabledRootGetters,
},
{ id: 1, filterType: 'conversation' }
);
expect(dispatch).toHaveBeenCalledWith(
'conversationUnreadCounts/get',
{},
{ root: true }
);
});
it('does not refetch unread counts after deleting a contact segment', async () => {
const dispatch = vi.fn();
axios.delete.mockResolvedValue({ data: contactFilterView });
await actions.delete(
{
commit,
dispatch,
rootGetters: conversationUnreadCountsEnabledRootGetters,
},
{ id: 1, filterType: 'contact' }
);
expect(dispatch).not.toHaveBeenCalled();
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.delete({ commit }, 1)).rejects.toThrow(Error);
@@ -93,6 +181,29 @@ describe('#actions', () => {
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false }],
]);
});
it('refetches unread counts after updating a conversation folder', async () => {
vi.useFakeTimers();
const dispatch = vi.fn();
const item = updateCustomViewList[0];
axios.patch.mockResolvedValue({ data: item });
await actions.update(
{
commit,
dispatch,
rootGetters: conversationUnreadCountsEnabledRootGetters,
},
item
);
expect(dispatch).toHaveBeenCalledWith(
'conversationUnreadCounts/get',
{},
{ root: true }
);
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.update({ commit }, 1)).rejects.toThrow(Error);
@@ -83,7 +83,7 @@ describe('#actions', () => {
);
});
it('ignores invalid preferences', () => {
it('ignores invalid sort values', () => {
actions.setSectionSort(
{
commit,
@@ -95,7 +95,7 @@ describe('#actions', () => {
},
{
section: SIDEBAR_SORT_SECTIONS.FOLDERS,
sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
sortBy: 'invalid_sort',
}
);