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
+1 -1
View File
@@ -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
@@ -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(() => {
<ChannelIcon :inbox="inbox" class="size-4" />
</span>
<div class="flex-1 truncate min-w-0">{{ label }}</div>
<SidebarUnreadBadge :count="badgeCount" />
<SidebarUnreadBadge :count="badgeCount" :tooltip="badgeTooltip" />
<div
v-if="reauthorizationRequired"
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
@@ -199,6 +199,18 @@ const getLabelUnreadCount = useMapGetter(
const getTeamUnreadCount = useMapGetter(
'conversationUnreadCounts/getTeamUnreadCount'
);
const mentionsUnreadCount = useMapGetter(
'conversationUnreadCounts/getMentionsUnreadCount'
);
const participatingUnreadCount = useMapGetter(
'conversationUnreadCounts/getParticipatingUnreadCount'
);
const unattendedUnreadCount = useMapGetter(
'conversationUnreadCounts/getUnattendedUnreadCount'
);
const getFolderUnreadCount = useMapGetter(
'conversationUnreadCounts/getFolderUnreadCount'
);
const teams = useMapGetter('teams/getMyTeams');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter(
@@ -280,6 +292,19 @@ const sortedLabels = computed(() =>
})
);
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 },
@@ -197,7 +197,10 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ subChild.label }}</span>
<SidebarUnreadBadge :count="subChild.badgeCount" />
<SidebarUnreadBadge
:count="subChild.badgeCount"
:tooltip="subChild.badgeTooltip"
/>
</button>
</li>
</ul>
@@ -220,7 +223,10 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ child.label }}</span>
<SidebarUnreadBadge :count="child.badgeCount" />
<SidebarUnreadBadge
:count="child.badgeCount"
:tooltip="child.badgeTooltip"
/>
</button>
</li>
</template>
@@ -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 =
<component
:is="component"
v-if="shouldRenderComponent"
v-bind="{ label, icon, active, badgeCount }"
v-bind="{ label, icon, active, badgeCount, badgeTooltip }"
/>
<template v-else>
<span v-if="icon" class="size-4 grid place-content-center rounded-full">
<Icon :icon="icon" class="size-4 inline-block" />
</span>
<div class="flex-1 truncate min-w-0 text-sm">{{ label }}</div>
<SidebarUnreadBadge :count="badgeCount" />
<SidebarUnreadBadge :count="badgeCount" :tooltip="badgeTooltip" />
</template>
</component>
</Policy>
@@ -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(() =>
<template>
<span
v-if="normalizedCount > 0"
v-tooltip.top="tooltip || null"
data-test-id="sidebar-unread-badge"
class="inline-grid h-5 min-w-5 place-items-center rounded-full bg-n-slate-4 px-1 text-xxs font-medium leading-3 text-n-slate-12 dark:bg-n-slate-5 flex-shrink-0"
>
@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils';
import ChannelLeaf from '../ChannelLeaf.vue';
import SidebarUnreadBadge from '../SidebarUnreadBadge.vue';
const mountChannelLeaf = props =>
mount(ChannelLeaf, {
@@ -28,6 +29,17 @@ describe('ChannelLeaf', () => {
expect(badge.text()).toBe('3');
});
it('passes tooltip copy to unread badge', () => {
const wrapper = mountChannelLeaf({
badgeCount: 3,
badgeTooltip: 'Total unread conversations in this inbox',
});
expect(wrapper.findComponent(SidebarUnreadBadge).props('tooltip')).toBe(
'Total unread conversations in this inbox'
);
});
it('does not render unread badge when count is zero', () => {
const wrapper = mountChannelLeaf({ badgeCount: 0 });
@@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils';
import { h } from 'vue';
import SidebarGroupLeaf from '../SidebarGroupLeaf.vue';
import SidebarUnreadBadge from '../SidebarUnreadBadge.vue';
vi.mock('../provider', () => ({
useSidebarContext: () => ({
@@ -44,6 +45,17 @@ describe('SidebarGroupLeaf', () => {
expect(badge.text()).toBe('7');
});
it('passes tooltip copy to unread badge', () => {
const wrapper = mountLeaf({
badgeCount: 7,
badgeTooltip: 'Total unread conversations in this label',
});
expect(wrapper.findComponent(SidebarUnreadBadge).props('tooltip')).toBe(
'Total unread conversations in this label'
);
});
it('does not render unread badge when count is zero', () => {
const wrapper = mountLeaf({ badgeCount: 0 });
@@ -63,14 +75,17 @@ describe('SidebarGroupLeaf', () => {
it('passes unread count to custom leaf components', () => {
const wrapper = mountLeaf({
badgeCount: 4,
badgeTooltip: 'Total unread conversations in this inbox',
component: leafProps =>
h(
'span',
{ 'data-test-id': 'custom-leaf-count' },
leafProps.badgeCount
`${leafProps.badgeCount}:${leafProps.badgeTooltip}`
),
});
expect(wrapper.find('[data-test-id="custom-leaf-count"]').text()).toBe('4');
expect(wrapper.find('[data-test-id="custom-leaf-count"]').text()).toBe(
'4:Total unread conversations in this inbox'
);
});
});
@@ -316,6 +316,16 @@
"MENTIONED_CONVERSATIONS": "Mentions",
"PARTICIPATING_CONVERSATIONS": "Participating",
"UNATTENDED_CONVERSATIONS": "Unattended",
"UNREAD_COUNT_TOOLTIP": {
"ALL": "Total unread conversations",
"INBOX": "Total unread conversations in this inbox",
"LABEL": "Total unread conversations in this label",
"TEAM": "Total unread conversations in this team",
"FOLDER": "Total unread conversations in this folder",
"MENTIONS": "Unread conversations where you were mentioned",
"PARTICIPATING": "Unread conversations you're participating in",
"UNATTENDED": "Unread unattended conversations"
},
"REPORTS": "Reports",
"SETTINGS": "Settings",
"CONTACTS": "Contacts",
@@ -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);
},
};
@@ -57,10 +57,32 @@ describe('#getters', () => {
inboxes: { 1: 2 },
labels: { 3: 4 },
teams: { 5: 6 },
folders: { 8: 9 },
};
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({ 8: 9 });
});
it('returns mentions, participating, unattended, and folder unread counts', () => {
const state = {
allCount: 0,
inboxes: {},
labels: {},
teams: {},
mentionsCount: 3,
participatingCount: 4,
unattendedCount: 5,
folders: { 7: 8 },
};
expect(getters.getMentionsUnreadCount(state)).toBe(3);
expect(getters.getParticipatingUnreadCount(state)).toBe(4);
expect(getters.getUnattendedUnreadCount(state)).toBe(5);
expect(getters.getFolderUnreadCount(state)(7)).toBe(8);
expect(getters.getFolderUnreadCount(state)('7')).toBe(8);
expect(getters.getFolderUnreadCount(state)(8)).toBe(0);
});
});
@@ -21,6 +21,13 @@ describe('#mutations', () => {
6: '7',
7: 0,
},
mentions_count: '8',
participating_count: 9,
unattended_count: '10',
folders: {
11: '12',
12: 0,
},
});
expect(state).toEqual({
@@ -28,6 +35,10 @@ describe('#mutations', () => {
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
mentionsCount: 8,
participatingCount: 9,
unattendedCount: 10,
folders: { 11: 12 },
});
});
@@ -37,6 +48,10 @@ describe('#mutations', () => {
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
mentionsCount: 8,
participatingCount: 9,
unattendedCount: 10,
folders: { 10: 11 },
};
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {});
@@ -46,6 +61,10 @@ describe('#mutations', () => {
inboxes: {},
labels: {},
teams: {},
mentionsCount: 0,
participatingCount: 0,
unattendedCount: 0,
folders: {},
});
});
+2 -2
View File
@@ -91,10 +91,10 @@ class ActionCableListener < BaseListener
end
def conversation_unread_count_changed(event)
account, inbox_members = ::Conversations::UnreadCounts::BroadcastScope.new(event).perform
account, inbox_members, include_admins = ::Conversations::UnreadCounts::BroadcastScope.new(event).perform
return if account.blank? || !account.feature_enabled?('conversation_unread_counts')
tokens = user_tokens(account, inbox_members)
tokens = include_admins ? user_tokens(account, inbox_members) : inbox_members.pluck(:pubsub_token)
broadcast(account, tokens, CONVERSATION_UNREAD_COUNT_CHANGED, {})
end
+1 -1
View File
@@ -181,7 +181,7 @@ class Account < ApplicationRecord
end
def clear_unread_conversation_counts_cache
::Conversations::UnreadCounts::Store.clear_account!(id)
::Conversations::UnreadCounts::Store.clear_all_account!(id)
end
trigger.after(:insert).for_each(:row) do
+9
View File
@@ -39,6 +39,7 @@ class AccountUser < ApplicationRecord
after_create_commit :notify_creation, :create_notification_setting
after_destroy :notify_deletion, :remove_user_from_account
after_save :update_presence_in_redis, if: :saved_change_to_availability?
after_commit :notify_unread_filter_counts_changed, on: [:update, :destroy], if: :unread_filter_access_changed?
validates :user_id, uniqueness: { scope: :account_id }
@@ -79,6 +80,14 @@ class AccountUser < ApplicationRecord
def update_presence_in_redis
OnlineStatusTracker.set_status(account.id, user.id, availability)
end
def unread_filter_access_changed?
destroyed? || previous_changes.key?('role') || previous_changes.key?('custom_role_id')
end
def notify_unread_filter_counts_changed
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
end
end
AccountUser.prepend_mod_with('AccountUser')
-1
View File
@@ -30,7 +30,6 @@ module CacheKeys
update_cache_key_for_account(id, model.name.underscore)
end
::Conversations::UnreadCounts::Store.clear_account!(id)
dispatch_cache_update_event
end
+5
View File
@@ -28,6 +28,7 @@ class ConversationParticipant < ApplicationRecord
belongs_to :user
before_validation :ensure_account_id
after_commit :notify_unread_filter_counts_changed, on: [:create, :destroy]
private
@@ -38,4 +39,8 @@ class ConversationParticipant < ApplicationRecord
def ensure_inbox_access
errors.add(:user, 'must have inbox access') if conversation && conversation.inbox.assignable_agents.exclude?(user)
end
def notify_unread_filter_counts_changed
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
end
end
+9
View File
@@ -22,10 +22,19 @@ class CustomFilter < ApplicationRecord
enum filter_type: { conversation: 0, contact: 1, report: 2 }
validate :validate_number_of_filters
after_commit :notify_unread_filter_counts_changed, on: [:create, :update, :destroy]
def validate_number_of_filters
return true if account.custom_filters.where(user_id: user_id).size < Limits::MAX_CUSTOM_FILTERS_PER_USER
errors.add :account_id, I18n.t('errors.custom_filters.number_of_records')
end
private
def notify_unread_filter_counts_changed
return unless conversation?
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
end
end
+12
View File
@@ -23,7 +23,9 @@ class InboxMember < ApplicationRecord
belongs_to :inbox
after_create :add_agent_to_round_robin
before_destroy :cache_unread_filter_notification_context
after_destroy :remove_agent_from_round_robin
after_commit :notify_unread_filter_counts_changed, on: [:create, :destroy]
private
@@ -34,6 +36,16 @@ class InboxMember < ApplicationRecord
def remove_agent_from_round_robin
::AutoAssignment::InboxRoundRobinService.new(inbox: inbox).remove_agent_from_queue(user_id) if inbox.present?
end
def cache_unread_filter_notification_context
@unread_filter_account = inbox&.account
@unread_filter_user = user
end
def notify_unread_filter_counts_changed
account = @unread_filter_account || inbox&.account
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: @unread_filter_user || user).perform
end
end
InboxMember.include_mod_with('Audit::InboxMember')
+5
View File
@@ -32,6 +32,7 @@ class Mention < ApplicationRecord
belongs_to :user
after_commit :notify_mentioned_user
after_commit :notify_unread_filter_counts_changed, on: [:create, :destroy]
scope :latest, -> { order(mentioned_at: :desc) }
@@ -55,4 +56,8 @@ class Mention < ApplicationRecord
def notify_mentioned_user
Rails.configuration.dispatcher.dispatch(CONVERSATION_MENTIONED, Time.zone.now, user: user, conversation: conversation)
end
def notify_unread_filter_counts_changed
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
end
end
+8 -2
View File
@@ -7,8 +7,7 @@ class Conversations::FilterService < FilterService
end
def perform
validate_query_operator
@conversations = query_builder(@filters['conversations'])
@conversations = filtered_relation
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
assigned_count = all_count - unassigned_count
@@ -23,6 +22,13 @@ class Conversations::FilterService < FilterService
}
end
def filtered_relation
validate_query_operator
return base_relation if @params[:payload].blank?
query_builder(@filters['conversations'])
end
def base_relation
conversations = @account.conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox
@@ -6,13 +6,23 @@ class Conversations::UnreadCounts::BroadcastScope
end
def perform
return [conversation.account, conversation.inbox.members] if conversation.present?
return user_scope if user.present?
return [conversation.account, conversation.inbox.members, true] if conversation.present?
deleted_conversation_scope
end
private
def user
event.data[:user]
end
def user_scope
account = event.data[:account] || user.account
[account, [user], false]
end
def conversation
event.data[:conversation]
end
@@ -24,7 +34,7 @@ class Conversations::UnreadCounts::BroadcastScope
account = Account.find_by(id: conversation_data[:account_id])
return if account.blank?
[account, inbox_members_for(account, conversation_data[:inbox_id])]
[account, inbox_members_for(account, conversation_data[:inbox_id]), true]
end
def inbox_members_for(account, inbox_id)
@@ -0,0 +1,15 @@
module Conversations::UnreadCounts::BuildLockKeys
private
def base_build_lock_key
format(Redis::Alfred::UNREAD_CONVERSATIONS_BASE_BUILD_LOCK, account_id: account.id)
end
def assignment_build_lock_key
format(Redis::Alfred::UNREAD_CONVERSATIONS_ASSIGNMENT_BUILD_LOCK, account_id: account.id)
end
def filters_build_lock_key
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FILTERS_BUILD_LOCK, account_id: account.id, user_id: user.id)
end
end
@@ -1,5 +1,14 @@
class Conversations::UnreadCounts::Builder
PARTICIPATING_PERMISSION = 'conversation_participating_manage'.freeze
RELATIVE_DATE_FILTER_OPERATOR = 'days_before'.freeze
BATCH_SIZE = 1000
FILTER_ERRORS = [
ActiveRecord::StatementInvalid,
CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue
].freeze
attr_reader :account
@@ -24,10 +33,37 @@ class Conversations::UnreadCounts::Builder
build_assignment!
end
def build_filters_for!(user)
store.clear_user_filters!(account.id, user.id)
version_snapshot = store.filter_version_snapshot(account.id, user.id)
custom_filters = conversation_custom_filters(user).to_a
store.add_filter_memberships(
account_id: account.id,
user_id: user.id,
filters: {
mentions: mentioned_unread_conversation_ids(user),
participating: participating_unread_conversation_ids(user),
unattended: unattended_unread_conversation_ids(user)
},
folders: folder_unread_conversation_ids(custom_filters, user)
)
mark_filters_ready_if_current(user, custom_filters, version_snapshot)
end
private
def mark_filters_ready_if_current(user, custom_filters, version_snapshot)
store.mark_filters_ready_if_current!(
account.id,
user.id,
version_snapshot: version_snapshot,
expires_in: filters_ready_ttl(custom_filters)
)
end
def write_memberships(assignment:)
unread_conversations.in_batches(of: BATCH_SIZE) do |relation|
unread_conversations(open_only: true).in_batches(of: BATCH_SIZE) do |relation|
columns = %i[id inbox_id assignee_id cached_label_list team_id]
memberships = relation.pluck(*columns).map do |id, inbox_id, assignee_id, cached_label_list, team_id|
{
@@ -43,14 +79,99 @@ class Conversations::UnreadCounts::Builder
end
end
def unread_conversations
account.conversations
.open
.joins(:messages)
.merge(Message.incoming.reorder(nil))
.where(messages: { account_id: account.id })
.where(unread_since_last_seen_condition)
.distinct
def mentioned_unread_conversation_ids(user)
visible_unread_conversations(user, open_only: true)
.joins(:mentions)
.where(mentions: { account_id: account.id, user_id: user.id })
.pluck(:id)
end
def participating_unread_conversation_ids(user)
participating_visible_unread_conversations(user, open_only: true)
.where(id: user.participating_conversations.where(account_id: account.id).select(:id))
.pluck(:id)
end
def unattended_unread_conversation_ids(user)
visible_unread_conversations(user, open_only: true)
.unattended
.pluck(:id)
end
def folder_unread_conversation_ids(custom_filters, user)
custom_filters.each_with_object({}) do |custom_filter, result|
result[custom_filter.id] = unread_ids_for_filter(custom_filter, user)
rescue *FILTER_ERRORS
next
end
end
def conversation_custom_filters(user)
account.custom_filters.where(user: user, filter_type: :conversation)
end
def filters_ready_ttl(custom_filters)
return Conversations::UnreadCounts::READY_TTL unless relative_date_filter?(custom_filters)
seconds_until_next_day
end
def relative_date_filter?(custom_filters)
custom_filters.any? do |custom_filter|
Array(custom_filter.query.with_indifferent_access[:payload]).any? do |condition|
condition[:filter_operator] == RELATIVE_DATE_FILTER_OPERATOR
end
end
end
def seconds_until_next_day
[(Time.zone.tomorrow.beginning_of_day - Time.current).ceil, 1].max
end
def unread_ids_for_filter(custom_filter, user)
filter_relation = ::Conversations::FilterService.new(custom_filter.query.with_indifferent_access, user, account).filtered_relation
filter_relation
.where(id: unread_conversations(open_only: false).select(:id))
.reorder(nil)
.distinct
.pluck(:id)
end
def unread_conversations(open_only:)
conversations = account.conversations
conversations = conversations.open if open_only
conversations.joins(:messages)
.merge(Message.incoming.reorder(nil))
.where(messages: { account_id: account.id })
.where(unread_since_last_seen_condition)
.distinct
end
def visible_unread_conversations(user, open_only:)
::Conversations::PermissionFilterService.new(unread_conversations(open_only: open_only), user, account).perform
end
def participating_visible_unread_conversations(user, open_only:)
return inbox_visible_unread_conversations(user, open_only: open_only) if custom_role_participating_permission?(user)
visible_unread_conversations(user, open_only: open_only)
end
def inbox_visible_unread_conversations(user, open_only:)
conversations = unread_conversations(open_only: open_only)
return conversations if account_user_for(user)&.administrator?
conversations.where(inbox: user.inboxes.where(account_id: account.id))
end
def custom_role_participating_permission?(user)
account_user = account_user_for(user)
account_user&.agent? && account_user.custom_role_id.present? && account_user.permissions.include?(PARTICIPATING_PERMISSION)
end
def account_user_for(user)
account.account_users.find_by(user_id: user.id)
end
def unread_since_last_seen_condition
@@ -1,4 +1,7 @@
class Conversations::UnreadCounts::Counter
include ::Conversations::UnreadCounts::BuildLockKeys
include ::Conversations::UnreadCounts::FilterCounter
MANAGE_ALL_PERMISSION = 'conversation_manage'.freeze
UNASSIGNED_PERMISSION = 'conversation_unassigned_manage'.freeze
PARTICIPATING_PERMISSION = 'conversation_participating_manage'.freeze
@@ -18,31 +21,38 @@ class Conversations::UnreadCounts::Counter
ensure_base_cache!
ensure_assignment_cache! if assignment_mode?
ensure_filters_cache!
inbox_counts = unread_inbox_counts
filter_counts = unread_filter_counts
{
all_count: inbox_counts.values.sum,
inboxes: inbox_counts,
labels: unread_label_counts,
teams: unread_team_counts
teams: unread_team_counts,
mentions_count: filter_counts[:mentions_count],
participating_count: filter_counts[:participating_count],
unattended_count: filter_counts[:unattended_count],
folders: filter_counts[:folders]
}
end
private
def ensure_base_cache!
ensure_cache_ready!(
ready: -> { store.base_ready?(account.id) },
lock_key: base_build_lock_key
) { ::Conversations::UnreadCounts::Builder.new(account).build_base! }
ensure_cache_ready!(ready: -> { store.base_ready?(account.id) }, lock_key: base_build_lock_key) { builder.build_base! }
end
def ensure_assignment_cache!
ensure_cache_ready!(ready: -> { store.assignment_ready?(account.id) }, lock_key: assignment_build_lock_key) { builder.build_assignment! }
end
def ensure_filters_cache!
ensure_cache_ready!(
ready: -> { store.assignment_ready?(account.id) },
lock_key: assignment_build_lock_key
) { ::Conversations::UnreadCounts::Builder.new(account).build_assignment! }
ready: -> { store.filters_ready?(account.id, user.id) },
lock_key: filters_build_lock_key
) { builder.build_filters_for!(user) }
end
def ensure_cache_ready!(ready:, lock_key:)
@@ -51,9 +61,10 @@ class Conversations::UnreadCounts::Counter
loop do
return if ready.call
return if lock_manager.with_lock(lock_key, BUILD_LOCK_TTL) { yield unless ready.call }
lock_acquired = lock_manager.with_lock(lock_key, BUILD_LOCK_TTL) { yield unless ready.call }
return if ready.call
wait_for_cache_ready(ready)
wait_for_cache_ready(ready) unless lock_acquired
end
end
@@ -62,14 +73,6 @@ class Conversations::UnreadCounts::Counter
sleep BUILD_WAIT_INTERVAL until ready.call || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
end
def base_build_lock_key
format(Redis::Alfred::UNREAD_CONVERSATIONS_BASE_BUILD_LOCK, account_id: account.id)
end
def assignment_build_lock_key
format(Redis::Alfred::UNREAD_CONVERSATIONS_ASSIGNMENT_BUILD_LOCK, account_id: account.id)
end
def unread_inbox_counts
counts_for_grouped_keys(visible_inbox_ids.index_with { |inbox_id| inbox_keys_for_mode(inbox_id) })
end
@@ -194,10 +197,14 @@ class Conversations::UnreadCounts::Counter
end
def empty_counts
{ all_count: 0, inboxes: {}, labels: {}, teams: {} }
{ all_count: 0, inboxes: {}, labels: {}, teams: {}, mentions_count: 0, participating_count: 0, unattended_count: 0, folders: {} }
end
def store
::Conversations::UnreadCounts::Store
end
def builder
@builder ||= ::Conversations::UnreadCounts::Builder.new(account)
end
end
@@ -0,0 +1,40 @@
module Conversations::UnreadCounts::FilterCounter
private
def unread_filter_counts
keys = user_filter_keys
counts_by_key = store.counts_for_keys(keys.values + folder_keys.values)
{
mentions_count: counts_by_key[keys[:mentions]].to_i,
participating_count: counts_by_key[keys[:participating]].to_i,
unattended_count: counts_by_key[keys[:unattended]].to_i,
folders: folder_counts(counts_by_key)
}
end
def user_filter_keys
{
mentions: store.user_mentions_key(account.id, user.id),
participating: store.user_participating_key(account.id, user.id),
unattended: store.user_unattended_key(account.id, user.id)
}
end
def conversation_folder_ids
@conversation_folder_ids ||= account.custom_filters.where(user: user, filter_type: :conversation).pluck(:id)
end
def folder_keys
@folder_keys ||= conversation_folder_ids.index_with do |custom_filter_id|
store.user_folder_key(account.id, user.id, custom_filter_id)
end
end
def folder_counts(counts_by_key)
folder_keys.each_with_object({}) do |(custom_filter_id, key), result|
count = counts_by_key[key].to_i
result[custom_filter_id.to_s] = count if count.positive?
end
end
end
@@ -3,10 +3,13 @@ class Conversations::UnreadCounts::Listener < BaseListener
def message_created(event)
message, = extract_message_and_account(event)
return unless message.incoming?
return unless message.account.feature_enabled?('conversation_unread_counts')
refresh(message.conversation)
if message.incoming?
refresh(message.conversation)
else
notify_filter_counts_changed(message.conversation)
end
end
def conversation_status_changed(event)
@@ -15,10 +18,21 @@ class Conversations::UnreadCounts::Listener < BaseListener
end
def conversation_updated(event)
return unless label_changed?(event.data[:changed_attributes])
conversation, = extract_conversation_and_account(event)
refresh(conversation, event.data[:changed_attributes])
return unless conversation.account.feature_enabled?('conversation_unread_counts')
if label_changed?(event.data[:changed_attributes])
refresh(conversation, event.data[:changed_attributes])
elsif folder_filter_changed?(event.data[:changed_attributes])
notify_filter_counts_changed(conversation)
end
end
def conversation_contact_changed(event)
conversation, = extract_conversation_and_account(event)
return unless conversation.account.feature_enabled?('conversation_unread_counts')
notify_filter_counts_changed(conversation)
end
def assignee_changed(event)
@@ -37,7 +51,10 @@ class Conversations::UnreadCounts::Listener < BaseListener
account = Account.find_by(id: conversation_data[:account_id])
return unless account&.feature_enabled?('conversation_unread_counts')
return unless remove_deleted_conversation(account, conversation_data)
filters_cleared = store.clear_filter_caches!(account.id)
memberships_removed = remove_deleted_conversation(account, conversation_data)
return unless memberships_removed || filters_cleared
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation_data: conversation_data.to_h)
end
@@ -90,6 +107,16 @@ class Conversations::UnreadCounts::Listener < BaseListener
changed_attributes.key?('cached_label_list') || changed_attributes.key?(:cached_label_list)
end
def folder_filter_changed?(changed_attributes)
changed_attributes.present?
end
def notify_filter_counts_changed(conversation)
return unless store.clear_filter_caches!(conversation.account_id)
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation)
end
def store
::Conversations::UnreadCounts::Store
end
@@ -11,7 +11,9 @@ class Conversations::UnreadCounts::Notifier
def perform
return false unless conversation.account.feature_enabled?('conversation_unread_counts')
return false unless ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform
filters_cleared = ::Conversations::UnreadCounts::Store.clear_filter_caches!(conversation.account_id)
memberships_refreshed = ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform
return false unless memberships_refreshed || filters_cleared
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation)
true
@@ -1,5 +1,6 @@
class Conversations::UnreadCounts::Store
extend ::Conversations::UnreadCounts::StoreKeys
extend ::Conversations::UnreadCounts::UserFilterStore
class << self
def base_ready?(account_id)
@@ -19,6 +20,10 @@ class Conversations::UnreadCounts::Store
end
def clear_account!(account_id)
account_key_patterns(account_id).each { |pattern| delete_matching(pattern) }
end
def clear_all_account!(account_id)
delete_matching("#{account_prefix(account_id)}::*")
end
@@ -178,9 +183,11 @@ class Conversations::UnreadCounts::Store
end
def delete_matching(pattern)
deleted = 0
Redis::Alfred.scan_each(match: pattern, count: 1000) do |key|
Redis::Alfred.delete(key)
deleted += 1 if Redis::Alfred.delete(key)
end
deleted.positive?
end
def assignment_key_patterns(account_id)
@@ -195,5 +202,16 @@ class Conversations::UnreadCounts::Store
"#{prefix}::TEAM::*::INBOX::*::ASSIGNEE::*"
]
end
def account_key_patterns(account_id)
prefix = account_prefix(account_id)
[
base_ready_key(account_id),
assignment_ready_key(account_id),
"#{prefix}::INBOX::*",
"#{prefix}::LABEL::*::INBOX::*",
"#{prefix}::TEAM::*::INBOX::*"
]
end
end
end
@@ -40,4 +40,20 @@ module Conversations::UnreadCounts::StoreKeys
def team_inbox_assignee_key(account_id, team_id, inbox_id, user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_TEAM_INBOX_ASSIGNEE, account_id: account_id, team_id: team_id, inbox_id: inbox_id, user_id: user_id)
end
def user_mentions_key(account_id, user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_MENTIONS, account_id: account_id, user_id: user_id)
end
def user_participating_key(account_id, user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_PARTICIPATING, account_id: account_id, user_id: user_id)
end
def user_unattended_key(account_id, user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_UNATTENDED, account_id: account_id, user_id: user_id)
end
def user_folder_key(account_id, user_id, custom_filter_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FOLDER, account_id: account_id, user_id: user_id, custom_filter_id: custom_filter_id)
end
end
@@ -0,0 +1,19 @@
class Conversations::UnreadCounts::UserFilterNotifier
include Events::Types
attr_reader :account, :user
def initialize(account:, user:)
@account = account
@user = user
end
def perform
return false if account.blank? || user.blank?
return false unless account.feature_enabled?('conversation_unread_counts')
::Conversations::UnreadCounts::Store.clear_user_filters!(account.id, user.id)
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, account: account, user: user)
true
end
end
@@ -0,0 +1,100 @@
module Conversations::UnreadCounts::UserFilterStore
USER_FILTER_KEY_SUFFIXES = [
'READY::FILTERS',
'MENTIONS',
'PARTICIPATING',
'UNATTENDED',
'FOLDER::*'
].freeze
def filters_ready?(account_id, user_id)
Redis::Alfred.exists?(filters_ready_key(account_id, user_id))
end
def mark_filters_ready!(account_id, user_id, expires_in: Conversations::UnreadCounts::READY_TTL)
Redis::Alfred.set(filters_ready_key(account_id, user_id), Time.current.to_i, ex: expires_in)
end
def mark_filters_ready_if_current!(account_id, user_id, version_snapshot:, expires_in: Conversations::UnreadCounts::READY_TTL)
return false unless filter_version_snapshot(account_id, user_id) == version_snapshot
mark_filters_ready!(account_id, user_id, expires_in: expires_in)
end
def filter_version_snapshot(account_id, user_id)
{
account: filter_version(account_filter_version_key(account_id)),
user: filter_version(user_filter_version_key(account_id, user_id))
}
end
def clear_filter_caches!(account_id)
bump_filter_version(account_filter_version_key(account_id))
delete_user_filter_patterns("#{account_prefix(account_id)}::USER::*")
end
def clear_user_filters!(account_id, user_id)
bump_filter_version(user_filter_version_key(account_id, user_id))
delete_user_filter_patterns(user_filter_prefix(account_id, user_id))
end
def add_filter_memberships(account_id:, user_id:, filters:, folders:)
memberships = {
user_mentions_key(account_id, user_id) => filters[:mentions],
user_participating_key(account_id, user_id) => filters[:participating],
user_unattended_key(account_id, user_id) => filters[:unattended]
}
folders.each do |custom_filter_id, conversation_ids|
memberships[user_folder_key(account_id, user_id, custom_filter_id)] = conversation_ids
end
write_membership_sets(memberships)
end
private
def filters_ready_key(account_id, user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FILTERS_READY, account_id: account_id, user_id: user_id)
end
def account_filter_version_key(account_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_FILTERS_VERSION, account_id: account_id)
end
def user_filter_version_key(account_id, user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FILTERS_VERSION, account_id: account_id, user_id: user_id)
end
def user_filter_prefix(account_id, user_id)
"#{account_prefix(account_id)}::USER::#{user_id}"
end
def filter_version(key)
Redis::Alfred.get(key).to_i
end
def bump_filter_version(key)
Redis::Alfred.incr(key).tap { Redis::Alfred.expire(key, Conversations::UnreadCounts::SET_TTL) }
end
def delete_user_filter_patterns(prefix)
deleted = false
USER_FILTER_KEY_SUFFIXES.each do |suffix|
deleted = delete_matching("#{prefix}::#{suffix}") || deleted
end
deleted
end
def write_membership_sets(memberships)
memberships = memberships.transform_values { |conversation_ids| Array(conversation_ids).compact_blank }
memberships = memberships.select { |_key, conversation_ids| conversation_ids.present? }
return if memberships.blank?
Redis::Alfred.pipelined do |pipeline|
memberships.each do |key, conversation_ids|
conversation_ids.each { |conversation_id| pipeline.sadd(key, conversation_id) }
pipeline.expire(key, Conversations::UnreadCounts::SET_TTL)
end
end
end
end
+11 -1
View File
@@ -11,7 +11,7 @@ class FilterService
}.with_indifferent_access
def initialize(params, user)
@params = params
@params = normalize_params(params)
@user = user
file = File.read('./lib/filters/filter_keys.yml')
@filters = YAML.safe_load(file)
@@ -140,6 +140,16 @@ class FilterService
private
def normalize_params(params)
return params unless params.respond_to?(:with_indifferent_access)
normalized_params = params.with_indifferent_access
normalized_params[:payload] = Array(normalized_params[:payload]).map do |condition|
condition.respond_to?(:with_indifferent_access) ? condition.with_indifferent_access : condition
end
normalized_params
end
def standard_attribute_data_type(attribute_key)
@filters.each_value do |section|
return section.dig(attribute_key, 'data_type') if section.is_a?(Hash) && section.key?(attribute_key)