diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index 1c27d8260..74bf903f5 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -12,6 +12,7 @@ class ConversationFinder 'waiting_since_asc' => %w[sort_on_waiting_since asc], 'waiting_since_desc' => %w[sort_on_waiting_since desc], 'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc], + 'unread' => %w[sort_on_unread desc], # To be removed in v3.5.0 'latest' => %w[sort_on_last_activity_at desc], diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index c96716c5d..39fe35f3d 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -308,6 +308,15 @@ function filterByAssigneeTab(conversations) { return [...conversations]; } +function sortByUnreadStatus(conversations) { + return [...conversations].sort((a, b) => { + const unreadCountDiff = (b.unread_count || 0) - (a.unread_count || 0); + if (unreadCountDiff !== 0) return unreadCountDiff; + + return (b.last_activity_at || 0) - (a.last_activity_at || 0); + }); +} + const conversationList = computed(() => { let localConversationList = []; @@ -337,6 +346,13 @@ const conversationList = computed(() => { }); } + if ( + !hasAppliedFiltersOrActiveFolders.value && + activeSortBy.value === wootConstants.SORT_BY_TYPE.UNREAD + ) { + localConversationList = sortByUnreadStatus(localConversationList); + } + return localConversationList; }); diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationBasicFilter.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationBasicFilter.vue index fa1563ed8..38e85b485 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationBasicFilter.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationBasicFilter.vue @@ -78,6 +78,10 @@ const chatSortOptions = computed(() => [ label: t('CHAT_LIST.SORT_ORDER_ITEMS.created_at_asc.TEXT'), value: 'created_at_asc', }, + { + label: t('CHAT_LIST.SORT_ORDER_ITEMS.unread.TEXT'), + value: 'unread', + }, { label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_desc.TEXT'), value: 'priority_desc', diff --git a/app/javascript/dashboard/constants/globals.js b/app/javascript/dashboard/constants/globals.js index 889ece96a..24c0f07b1 100644 --- a/app/javascript/dashboard/constants/globals.js +++ b/app/javascript/dashboard/constants/globals.js @@ -27,6 +27,7 @@ export default { WAITING_SINCE_ASC: 'waiting_since_asc', WAITING_SINCE_DESC: 'waiting_since_desc', PRIORITY_DESC_CREATED_AT_ASC: 'priority_desc_created_at_asc', + UNREAD: 'unread', }, ARTICLE_STATUS_TYPES: { DRAFT: 0, diff --git a/app/javascript/dashboard/i18n/locale/en/chatlist.json b/app/javascript/dashboard/i18n/locale/en/chatlist.json index 1384dae2b..45755892d 100644 --- a/app/javascript/dashboard/i18n/locale/en/chatlist.json +++ b/app/javascript/dashboard/i18n/locale/en/chatlist.json @@ -79,6 +79,9 @@ }, "priority_desc_created_at_asc": { "TEXT": "Priority: Highest first, Created: Oldest first" + }, + "unread": { + "TEXT": "Unread Count: Highest first" } }, "ATTACHMENTS": { diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 0005ae4a4..d9c06c4d8 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -78,6 +78,9 @@ class Conversation < ApplicationRecord scope :unassigned, -> { where(assignee_id: nil) } scope :assigned, -> { where.not(assignee_id: nil) } scope :assigned_to, ->(agent) { where(assignee_id: agent.id) } + scope :sort_on_unread, lambda { |_direction| + order(unread_messages_count_arel.desc).sort_on_last_activity_at('desc') + } scope :unattended, -> { where(first_reply_created_at: nil).or(where.not(waiting_since: nil)) } scope :resolvable_not_waiting, lambda { |auto_resolve_after| return none if auto_resolve_after.to_i.zero? @@ -206,6 +209,26 @@ class Conversation < ApplicationRecord inbox.inbox_type == 'Twitter' && additional_attributes['type'] == 'tweet' end + def self.unread_messages_count_arel + messages = Message.arel_table + conversations = arel_table + unread_messages = messages + .project(messages[:id].count) + .where(unread_messages_condition(messages, conversations)) + + Arel::Nodes::Grouping.new(unread_messages.ast) + end + + def self.unread_messages_condition(messages, conversations) + messages[:conversation_id].eq(conversations[:id]) + .and(messages[:account_id].eq(conversations[:account_id])) + .and(messages[:message_type].eq(Message.message_types[:incoming])) + .and( + conversations[:agent_last_seen_at].eq(nil) + .or(messages[:created_at].gt(conversations[:agent_last_seen_at])) + ) + end + def recent_messages messages.chat.last(5) end diff --git a/spec/finders/conversation_finder_spec.rb b/spec/finders/conversation_finder_spec.rb index 9427e8157..64f134fe2 100644 --- a/spec/finders/conversation_finder_spec.rb +++ b/spec/finders/conversation_finder_spec.rb @@ -96,6 +96,67 @@ describe ConversationFinder do end end + context 'with unread sort' do + let(:params) { { status: 'open', sort_by: 'unread' } } + + it 'returns all conversations matching the selected status with the highest unread count first' do + most_unread_conversation = create(:conversation, account: account, inbox: inbox, + agent_last_seen_at: 1.hour.ago) + unread_conversation = create(:conversation, account: account, inbox: inbox, + agent_last_seen_at: 1.hour.ago) + read_conversation = create(:conversation, account: account, inbox: inbox, + agent_last_seen_at: 1.minute.from_now) + resolved_unread_conversation = create(:conversation, account: account, inbox: inbox, status: 'resolved', + agent_last_seen_at: 1.hour.ago) + + [most_unread_conversation, unread_conversation, read_conversation, resolved_unread_conversation].each do |conversation| + create(:message, account: account, inbox: inbox, conversation: conversation, + message_type: :incoming, created_at: 5.minutes.ago) + end + create(:message, account: account, inbox: inbox, conversation: most_unread_conversation, + message_type: :incoming, created_at: 4.minutes.ago) + resolved_unread_conversation.update!(status: 'resolved') + read_conversation.update!(last_activity_at: 1.minute.from_now) + unread_conversation.update!(last_activity_at: 2.minutes.from_now) + + result = conversation_finder.perform + conversation_ids = result[:conversations].map(&:id) + + expect(conversation_ids).to include(most_unread_conversation.id, unread_conversation.id, read_conversation.id) + expect(conversation_ids).not_to include(resolved_unread_conversation.id) + expect(conversation_ids.index(most_unread_conversation.id)).to be < conversation_ids.index(unread_conversation.id) + expect(conversation_ids.index(unread_conversation.id)).to be < conversation_ids.index(read_conversation.id) + end + + it 'includes private incoming messages in unread counts used for ordering' do + private_unread_conversation = create(:conversation, account: account, inbox: inbox, + agent_last_seen_at: 1.hour.ago) + unread_conversation = create(:conversation, account: account, inbox: inbox, + agent_last_seen_at: 1.hour.ago) + read_conversation = create(:conversation, account: account, inbox: inbox, + agent_last_seen_at: 1.minute.from_now) + + 2.times do + create(:message, account: account, inbox: inbox, conversation: private_unread_conversation, + message_type: :incoming, private: true, created_at: 5.minutes.ago) + end + create(:message, account: account, inbox: inbox, conversation: unread_conversation, + message_type: :incoming, created_at: 5.minutes.ago) + create(:message, account: account, inbox: inbox, conversation: read_conversation, + message_type: :incoming, created_at: 5.minutes.ago) + private_unread_conversation.update!(last_activity_at: 10.minutes.ago) + unread_conversation.update!(last_activity_at: 2.minutes.from_now) + read_conversation.update!(last_activity_at: 1.minute.from_now) + + result = conversation_finder.perform + conversation_ids = result[:conversations].map(&:id) + + expect(private_unread_conversation.unread_incoming_messages.count).to eq 2 + expect(conversation_ids.index(private_unread_conversation.id)).to be < conversation_ids.index(unread_conversation.id) + expect(conversation_ids.index(unread_conversation.id)).to be < conversation_ids.index(read_conversation.id) + end + end + context 'with assignee_type assigned' do let(:params) { { assignee_type: 'assigned' } }