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.

<img width="369" height="525" alt="Screenshot 2026-06-13 at 10 51 39 PM"
src="https://github.com/user-attachments/assets/36b1d2c4-dac1-4f6f-9c0e-7ef5a6cc2975"
/>

## 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 <muhsinkeramam@gmail.com>
This commit is contained in:
Sony Mathew
2026-06-16 23:46:49 +05:30
committed by GitHub
co-authored by Muhsin Keloth
parent 352f120c6a
commit fe6368b42e
54 changed files with 1581 additions and 97 deletions
+52 -11
View File
@@ -53,38 +53,79 @@ RSpec.describe Account do
describe 'conversation unread counts feature flag' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:user) { create(:user) }
let(:store) { Conversations::UnreadCounts::Store }
let(:inbox_key) { store.inbox_key(account.id, inbox.id) }
after do
store.clear_account!(account.id)
let(:filter_keys) do
[
store.user_mentions_key(account.id, user.id),
store.user_participating_key(account.id, user.id),
store.user_unattended_key(account.id, user.id),
store.user_folder_key(account.id, user.id, 1)
]
end
it 'clears unread count cache when the feature is enabled' do
after do
store.clear_all_account!(account.id)
end
it 'clears all unread count cache when the feature is enabled' do
build_unread_count_cache
account.enable_features!(:conversation_unread_counts)
expect(store.base_ready?(account.id)).to be(false)
expect(store.assignment_ready?(account.id)).to be(false)
expect(store.counts_for_keys([inbox_key])).to eq(inbox_key => 0)
expect_unread_count_cache_cleared
end
it 'clears unread count cache when the feature is disabled' do
it 'clears all unread count cache when the feature is disabled' do
account.enable_features!(:conversation_unread_counts)
build_unread_count_cache
account.disable_features!(:conversation_unread_counts)
expect(store.base_ready?(account.id)).to be(false)
expect(store.assignment_ready?(account.id)).to be(false)
expect(store.counts_for_keys([inbox_key])).to eq(inbox_key => 0)
expect_unread_count_cache_cleared
end
it 'clears all unread count cache when account cache keys are reset' do
build_unread_count_cache
account.reset_cache_keys
expect_unread_count_cache_cleared
end
def expect_unread_count_cache_cleared
expect(unread_count_ready_markers).to all(be(false))
expect(store.counts_for_keys(unread_count_keys).values).to all(eq(0))
end
def unread_count_ready_markers
[
store.base_ready?(account.id),
store.assignment_ready?(account.id),
store.filters_ready?(account.id, user.id)
]
end
def unread_count_keys
[inbox_key] + filter_keys
end
def build_unread_count_cache
store.mark_base_ready!(account.id)
store.mark_assignment_ready!(account.id)
store.mark_filters_ready!(account.id, user.id)
store.add_base_membership(account_id: account.id, inbox_id: inbox.id, label_ids: [], conversation_id: 1)
store.add_filter_memberships(
account_id: account.id,
user_id: user.id,
filters: {
mentions: [1],
participating: [2],
unattended: [3]
},
folders: { 1 => [4] }
)
end
end
+28
View File
@@ -42,4 +42,32 @@ RSpec.describe AccountUser do
expect(user.assigned_conversations.count).to eq(0)
end
end
describe 'unread filter count invalidation' do
let(:notifier) { instance_double(Conversations::UnreadCounts::UserFilterNotifier, perform: true) }
before do
allow(Conversations::UnreadCounts::UserFilterNotifier).to receive(:new).and_return(notifier)
end
it 'notifies when the account role changes' do
account_user.update!(role: :administrator)
expect(Conversations::UnreadCounts::UserFilterNotifier).to have_received(:new).with(
account: account_user.account,
user: account_user.user
)
expect(notifier).to have_received(:perform)
end
it 'notifies when account access is removed' do
expect(Conversations::UnreadCounts::UserFilterNotifier).to receive(:new).with(
account: account_user.account,
user: account_user.user
).and_return(notifier)
expect(notifier).to receive(:perform)
account_user.destroy!
end
end
end
+26
View File
@@ -18,4 +18,30 @@ RSpec.describe InboxMember do
end
end
end
describe 'unread filter count invalidation' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:user) { create(:user, account: account, role: :agent) }
let(:notifier) { instance_double(Conversations::UnreadCounts::UserFilterNotifier, perform: true) }
before do
allow(Conversations::UnreadCounts::UserFilterNotifier).to receive(:new).and_return(notifier)
end
it 'notifies when inbox access is added' do
create(:inbox_member, inbox: inbox, user: user)
expect(Conversations::UnreadCounts::UserFilterNotifier).to have_received(:new).with(account: account, user: user)
expect(notifier).to have_received(:perform)
end
it 'notifies when inbox access is removed' do
inbox_member = create(:inbox_member, inbox: inbox, user: user)
expect(Conversations::UnreadCounts::UserFilterNotifier).to receive(:new).with(account: account, user: user).and_return(notifier)
expect(notifier).to receive(:perform)
inbox_member.destroy!
end
end
end