From fe6368b42ef2de901dffdc0c1a4d1b84a112db5d Mon Sep 17 00:00:00 2001 From: Sony Mathew Date: Tue, 16 Jun 2026 23:46:49 +0530 Subject: [PATCH] feat: Add sidebar unread counts for filters (CW-7262) (#14726) ## Description Extends the conversation unread-count system so the left sidebar can show unread badges for Mentions, Participating, Unattended, and saved conversation folders. Folder badges reuse the existing `custom_filters` conversation filter semantics, store user-scoped Redis sets lazily, and skip unsupported folder filters so invalid saved folders continue to render without a badge. The Unattended badge counts all visible unread open conversations that match the existing unattended conversation scope. Closes - [CW-7262](https://linear.app/chatwoot/issue/CW-7262/unread-counts-for-filters-folders) ## What changed - Added user-scoped unread-count Redis keys and cache builders for mentions, participating conversations, unattended conversations, and saved folder filters. - Reused `Conversations::FilterService` through a relation-returning path so folder counts match the folder conversation list behavior. - Invalidated user filter caches from mention, participant, custom-filter, and relevant conversation update events. - Extended the unread-count endpoint payload and sidebar Vuex/sidebar rendering for the new badge counts, including the Unattended sidebar item. - Added Ruby, Enterprise, request, listener, and frontend store coverage for the new unread-count dimensions. ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - Created local validation folders for `john@acme.inc` and confirmed the unread-count payload includes open, resolved, and high-priority folder badges while excluding the invalid unsupported folder. - Added coverage for the Unattended badge rule: all visible unread open conversations matching `Conversation.unattended`. - Ran focused unread-count Ruby specs, including service, listener, request, and Enterprise counter coverage. - Ran frontend unread-count store specs. - Ran RuboCop on the touched Ruby files. - Ran ESLint through the project script; it completed with warnings in existing unrelated files and no errors. Screenshot 2026-06-13 at 10 51 39 PM ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [x] Documentation changes are not required for this internal unread-count behavior - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] No dependent downstream changes are required --------- Co-authored-by: Muhsin Keloth --- app/finders/conversation_finder.rb | 2 +- .../components-next/sidebar/ChannelLeaf.vue | 6 +- .../components-next/sidebar/Sidebar.vue | 38 +++ .../sidebar/SidebarCollapsedPopover.vue | 10 +- .../sidebar/SidebarGroupLeaf.vue | 5 +- .../sidebar/SidebarUnreadBadge.vue | 2 + .../sidebar/specs/ChannelLeaf.spec.js | 12 + .../sidebar/specs/SidebarGroupLeaf.spec.js | 19 +- .../dashboard/i18n/locale/en/settings.json | 10 + .../store/modules/conversationUnreadCounts.js | 23 ++ .../conversationUnreadCounts/getters.spec.js | 22 ++ .../mutations.spec.js | 19 ++ app/listeners/action_cable_listener.rb | 4 +- app/models/account.rb | 2 +- app/models/account_user.rb | 9 + app/models/concerns/cache_keys.rb | 1 - app/models/conversation_participant.rb | 5 + app/models/custom_filter.rb | 9 + app/models/inbox_member.rb | 12 + app/models/mention.rb | 5 + app/services/conversations/filter_service.rb | 10 +- .../unread_counts/broadcast_scope.rb | 14 +- .../unread_counts/build_lock_keys.rb | 15 ++ .../conversations/unread_counts/builder.rb | 139 ++++++++++- .../conversations/unread_counts/counter.rb | 45 ++-- .../unread_counts/filter_counter.rb | 40 ++++ .../conversations/unread_counts/listener.rb | 39 +++- .../conversations/unread_counts/notifier.rb | 4 +- .../conversations/unread_counts/store.rb | 20 +- .../conversations/unread_counts/store_keys.rb | 16 ++ .../unread_counts/user_filter_notifier.rb | 19 ++ .../unread_counts/user_filter_store.rb | 100 ++++++++ app/services/filter_service.rb | 12 +- .../finders/enterprise/conversation_finder.rb | 26 +++ enterprise/app/models/custom_role.rb | 23 ++ lib/redis/redis_keys.rb | 15 ++ .../accounts/conversations_controller_spec.rb | 43 +++- .../super_admin/accounts_controller_spec.rb | 2 +- .../finders/conversation_finder_spec.rb | 24 ++ spec/enterprise/models/account_user_spec.rb | 18 ++ spec/enterprise/models/custom_role_spec.rb | 34 +++ .../unread_counts/counter_spec.rb | 60 +++-- spec/finders/conversation_finder_spec.rb | 19 ++ spec/listeners/action_cable_listener_spec.rb | 14 ++ spec/models/account_spec.rb | 63 ++++- spec/models/account_user_spec.rb | 28 +++ spec/models/inbox_member_spec.rb | 26 +++ .../unread_counts/builder_spec.rb | 197 +++++++++++++++- .../unread_counts/counter_spec.rb | 78 ++++++- .../unread_counts/listener_spec.rb | 51 +++- .../unread_counts/notifier_spec.rb | 12 + .../unread_counts/refresher_spec.rb | 2 +- .../conversations/unread_counts/store_spec.rb | 218 +++++++++++++++++- .../user_filter_notifier_spec.rb | 37 +++ 54 files changed, 1581 insertions(+), 97 deletions(-) create mode 100644 app/services/conversations/unread_counts/build_lock_keys.rb create mode 100644 app/services/conversations/unread_counts/filter_counter.rb create mode 100644 app/services/conversations/unread_counts/user_filter_notifier.rb create mode 100644 app/services/conversations/unread_counts/user_filter_store.rb create mode 100644 spec/enterprise/finders/conversation_finder_spec.rb create mode 100644 spec/services/conversations/unread_counts/user_filter_notifier_spec.rb diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index 74bf903f5..9d53ec97e 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -142,7 +142,7 @@ class ConversationFinder conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id) @conversations = @conversations.where(id: conversation_ids) when 'participating' - @conversations = current_user.participating_conversations.where(account_id: current_account.id) + @conversations = @conversations.where(id: current_user.participating_conversations.where(account_id: current_account.id).select(:id)) when 'unattended' @conversations = @conversations.unattended end diff --git a/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue b/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue index a995cf510..234737008 100644 --- a/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue +++ b/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue @@ -22,6 +22,10 @@ const props = defineProps({ type: [Number, String], default: 0, }, + badgeTooltip: { + type: String, + default: '', + }, }); const reauthorizationRequired = computed(() => { @@ -34,7 +38,7 @@ const reauthorizationRequired = computed(() => {
{{ label }}
- +
}) ); +const unreadBadgeTooltips = computed(() => ({ + ALL: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.ALL'), + INBOX: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.INBOX'), + LABEL: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.LABEL'), + TEAM: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.TEAM'), + FOLDER: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.FOLDER'), + MENTIONS: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.MENTIONS'), + PARTICIPATING: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.PARTICIPATING'), + UNATTENDED: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.UNATTENDED'), +})); + +const unreadBadgeTooltip = type => unreadBadgeTooltips.value[type]; + const closeMobileSidebar = () => { if (!props.isMobileSidebarOpen) return; emit('closeMobileSidebar'); @@ -335,12 +360,15 @@ const menuItems = computed(() => { label: t('SIDEBAR.ALL_CONVERSATIONS'), icon: 'i-lucide-inbox', badgeCount: allUnreadCount.value, + badgeTooltip: unreadBadgeTooltip('ALL'), activeOn: ['inbox_conversation'], to: accountScopedRoute('home'), }, { name: 'Mentions', label: t('SIDEBAR.MENTIONED_CONVERSATIONS'), + badgeCount: mentionsUnreadCount.value, + badgeTooltip: unreadBadgeTooltip('MENTIONS'), icon: 'i-lucide-at-sign', activeOn: ['conversation_through_mentions'], to: accountScopedRoute('conversation_mentions'), @@ -348,6 +376,8 @@ const menuItems = computed(() => { { name: 'Participating', label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'), + badgeCount: participatingUnreadCount.value, + badgeTooltip: unreadBadgeTooltip('PARTICIPATING'), icon: 'i-lucide-user-round-check', activeOn: ['conversation_through_participating'], to: accountScopedRoute('conversation_participating'), @@ -355,6 +385,8 @@ const menuItems = computed(() => { { name: 'Unattended', activeOn: ['conversation_through_unattended'], + badgeCount: unattendedUnreadCount.value, + badgeTooltip: unreadBadgeTooltip('UNATTENDED'), label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'), icon: 'i-lucide-clock-alert', to: accountScopedRoute('conversation_unattended'), @@ -370,6 +402,8 @@ const menuItems = computed(() => { children: sortedFolders.value.map(view => ({ name: `${view.name}-${view.id}`, label: view.name, + badgeCount: getFolderUnreadCount.value(view.id), + badgeTooltip: unreadBadgeTooltip('FOLDER'), to: accountScopedRoute('folder_conversations', { id: view.id }), })), }, @@ -385,6 +419,7 @@ const menuItems = computed(() => { name: `${team.name}-${team.id}`, label: team.name, badgeCount: getTeamUnreadCount.value(team.id), + badgeTooltip: unreadBadgeTooltip('TEAM'), to: accountScopedRoute('team_conversations', { teamId: team.id }), })), }, @@ -400,6 +435,7 @@ const menuItems = computed(() => { name: `${inbox.name}-${inbox.id}`, label: inbox.name, badgeCount: getInboxUnreadCount.value(inbox.id), + badgeTooltip: unreadBadgeTooltip('INBOX'), icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }), to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }), component: leafProps => @@ -408,6 +444,7 @@ const menuItems = computed(() => { active: leafProps.active, inbox, badgeCount: leafProps.badgeCount, + badgeTooltip: leafProps.badgeTooltip, }), })), }, @@ -423,6 +460,7 @@ const menuItems = computed(() => { name: `${label.title}-${label.id}`, label: label.title, badgeCount: getLabelUnreadCount.value(label.id), + badgeTooltip: unreadBadgeTooltip('LABEL'), 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 2e431eb7e..8f1de246e 100644 --- a/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue +++ b/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue @@ -197,7 +197,10 @@ onMounted(async () => { class="size-4 flex-shrink-0" /> {{ subChild.label }} - + @@ -220,7 +223,10 @@ 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 1d23ad0ed..404bbe3f3 100644 --- a/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue +++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue @@ -12,6 +12,7 @@ const props = defineProps({ active: { type: Boolean, default: false }, component: { type: Function, default: null }, badgeCount: { type: [Number, String], default: 0 }, + badgeTooltip: { type: String, default: '' }, hideTreeLine: { type: Boolean, default: false }, thinTreeLine: { type: Boolean, default: false }, }); @@ -52,14 +53,14 @@ const TREE_CONNECTOR = diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue b/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue index 95d95236a..2a91bfcb4 100644 --- a/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue +++ b/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue @@ -3,6 +3,7 @@ import { computed } from 'vue'; const props = defineProps({ count: { type: [Number, String], default: 0 }, + tooltip: { type: String, default: '' }, }); const normalizedCount = computed(() => { @@ -18,6 +19,7 @@ const displayCount = computed(() =>