From c70a57407d4722e878beeccc9ad756fbb2d75810 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Wed, 17 Jun 2026 00:05:29 +0530 Subject: [PATCH 01/77] fix: label Chatwoot Mobile sessions instead of 'Unknown Device' (#14753) ## Description Sessions created by logins from the Chatwoot Mobile app currently render as "Unknown Device" in the dashboard sessions UI. The mobile app's HTTP layer sends: - Android: `User-Agent: okhttp/4.9.2` - iOS: `User-Agent: Chatwoot/ CFNetwork/ Darwin/` Neither pattern is classifiable by the `browser` gem, so \`browser_name\`, \`platform_name\`, and \`device_name\` all end up "Unknown". This change adds a backend-only fallback in \`UserSessionTrackingService\`. When \`Browser.new(ua)\` returns "Unknown Browser" and the UA matches a known Chatwoot Mobile pattern, the labels are overridden to \`Chatwoot Mobile\` + \`Android\` / \`iPhone\`. The Vue sessions list (\`ActiveSessions.vue\`) then renders "Chatwoot Mobile on Android" or "Chatwoot Mobile on iPhone" with the smartphone icon. This is the immediate floor. A follow-up will add structured \`X-Chatwoot-*\` headers from the mobile app so we can render full version + device model. Fixes INF-75. ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - Added specs ## 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 - [ ] I have made corresponding changes to the documentation - [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] Any dependent changes have been merged and published in downstream modules --- app/services/user_session_tracking_service.rb | 26 ++++++- .../user_session_tracking_service_spec.rb | 69 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/app/services/user_session_tracking_service.rb b/app/services/user_session_tracking_service.rb index 28f272a18..d7e6f6efb 100644 --- a/app/services/user_session_tracking_service.rb +++ b/app/services/user_session_tracking_service.rb @@ -1,4 +1,11 @@ class UserSessionTrackingService + # CFNetwork UAs cannot distinguish iPhone from iPad; both get labelled iPhone here. + LEGACY_MOBILE_UAS = [ + { match: %r{\Aokhttp/}, platform: 'Android', device: 'Android' }, + { match: %r{\AChatwoot/.*CFNetwork.*Darwin}, platform: 'iPhone', device: 'iPhone' } + ].freeze + private_constant :LEGACY_MOBILE_UAS + def initialize(user:, request:, client_id:) @user = user @request = request @@ -26,7 +33,7 @@ class UserSessionTrackingService def session_attributes browser = Browser.new(@request.user_agent) - { + attrs = { ip_address: @request.remote_ip, user_agent: @request.user_agent, browser_name: browser.name, @@ -35,5 +42,22 @@ class UserSessionTrackingService platform_name: browser.platform.name, platform_version: browser.platform.version } + + patch_for_legacy_mobile(attrs) + end + + def patch_for_legacy_mobile(attrs) + return attrs unless attrs[:browser_name] == 'Unknown Browser' + + hit = LEGACY_MOBILE_UAS.find { |m| @request.user_agent.to_s.match?(m[:match]) } + return attrs unless hit + + attrs.merge( + browser_name: 'Chatwoot Mobile', + browser_version: nil, + platform_name: hit[:platform], + platform_version: nil, + device_name: hit[:device] + ) end end diff --git a/spec/services/user_session_tracking_service_spec.rb b/spec/services/user_session_tracking_service_spec.rb index 71b6d5924..2b98b95bf 100644 --- a/spec/services/user_session_tracking_service_spec.rb +++ b/spec/services/user_session_tracking_service_spec.rb @@ -47,6 +47,75 @@ RSpec.describe UserSessionTrackingService do expect(existing.reload.ip_address).to eq('8.8.8.8') expect(existing.last_activity_at).to be_within(1.second).of(Time.current) end + + context 'with a Chatwoot Mobile legacy User-Agent' do + let(:request) do + instance_double( + ActionDispatch::Request, + user_agent: ua, + remote_ip: '8.8.8.8' + ) + end + + context 'when the UA is okhttp (Android Chatwoot Mobile)' do + let(:ua) { 'okhttp/4.9.2' } + + it 'labels the session as Chatwoot Mobile on Android', :aggregate_failures do + service.create_or_update! + + session = user.user_sessions.last + expect(session.browser_name).to eq('Chatwoot Mobile') + expect(session.browser_version).to be_nil + expect(session.platform_name).to eq('Android') + expect(session.platform_version).to be_nil + expect(session.device_name).to eq('Android') + expect(session.user_agent).to eq(ua) + end + end + + context 'when the UA is CFNetwork (iOS Chatwoot Mobile)' do + let(:ua) { 'Chatwoot/3759 CFNetwork/3886.100.1 Darwin/27.0.0' } + + it 'labels the session as Chatwoot Mobile on iPhone', :aggregate_failures do + service.create_or_update! + + session = user.user_sessions.last + expect(session.browser_name).to eq('Chatwoot Mobile') + expect(session.browser_version).to be_nil + expect(session.platform_name).to eq('iPhone') + expect(session.platform_version).to be_nil + expect(session.device_name).to eq('iPhone') + expect(session.user_agent).to eq(ua) + end + end + + context 'when the UA is a real browser (Firefox on Linux)' do + let(:ua) { 'Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0' } + + it 'does not override the Browser-derived metadata', :aggregate_failures do + service.create_or_update! + + session = user.user_sessions.last + expect(session.browser_name).to eq('Firefox') + expect(session.platform_name).to eq('Generic Linux') + expect(session.device_name).not_to eq('Android') + expect(session.device_name).not_to eq('iPhone') + end + end + + context 'when the UA is unknown but does not match any mobile pattern' do + let(:ua) { 'curl/8.4.0' } + + it 'leaves the Unknown labels untouched', :aggregate_failures do + service.create_or_update! + + session = user.user_sessions.last + expect(session.browser_name).to eq('Unknown Browser') + expect(session.platform_name).to eq('Unknown') + expect(session.device_name).to eq('Unknown') + end + end + end end describe '#update_activity!' do From 124782938ddbbca065296d77115bb8d9b2057c69 Mon Sep 17 00:00:00 2001 From: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:10:47 +0530 Subject: [PATCH 02/77] Bump version to 4.15.0 --- VERSION_CW | 2 +- config/app.yml | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/VERSION_CW b/VERSION_CW index 0fb7a35b6..5c517bf11 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.14.2 +4.15.0 diff --git a/config/app.yml b/config/app.yml index c2494befa..c2c192018 100644 --- a/config/app.yml +++ b/config/app.yml @@ -1,5 +1,5 @@ shared: &shared - version: '4.14.2' + version: '4.15.0' development: <<: *shared diff --git a/package.json b/package.json index fc4598bc4..b8ab60839 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/chatwoot", - "version": "4.14.2", + "version": "4.15.0", "license": "MIT", "scripts": { "eslint": "eslint app/**/*.{js,vue}", From f66b551c7d6778b7984041832cbba70bc833cde3 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Wed, 17 Jun 2026 16:28:48 +0530 Subject: [PATCH 03/77] revert: Sidebar unread counts for filters (CW-7262) (#14769) ## Description Reverts [#14726](https://github.com/chatwoot/chatwoot/pull/14726) (\"feat: Add sidebar unread counts for filters (CW-7262)\"), which shipped in 4.15.0. After 4.15.0 rolled out to prod the unread-counts-for-filters code path caused a cascading incident: - `Counter#ensure_filters_cache!` fires on every `/unread_counts/index` and `update_last_seen` request. - On cache miss it calls `Builder#build_filters_for!`, which: - invokes `store.clear_user_filters!` -> `delete_matching` -> a Redis `SCAN_each` over a per-user pattern keyspace, and - runs 4 fresh SQL passes per user (mentions, participating, unattended, and per-folder `Conversations::FilterService` queries). - Threads blocked in the SCAN held their DB connections, the connection pool exhausted, Sidekiq jobs were discarded with `ActiveJob::DeserializationError: could not obtain a connection from the pool`, and the enqueued queue blew past 200K. Related: [CW-7262](https://linear.app/chatwoot/issue/CW-7262/unread-counts-for-filters-folders) ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) - [ ] 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 ## How Has This Been Tested? ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [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 - [ ] Any dependent changes have been merged and published in downstream modules --- 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 | 59 +---- 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, 95 insertions(+), 1579 deletions(-) delete mode 100644 app/services/conversations/unread_counts/build_lock_keys.rb delete mode 100644 app/services/conversations/unread_counts/filter_counter.rb delete mode 100644 app/services/conversations/unread_counts/user_filter_notifier.rb delete mode 100644 app/services/conversations/unread_counts/user_filter_store.rb delete mode 100644 spec/enterprise/finders/conversation_finder_spec.rb delete 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 9d53ec97e..74bf903f5 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 = @conversations.where(id: current_user.participating_conversations.where(account_id: current_account.id).select(:id)) + @conversations = current_user.participating_conversations.where(account_id: current_account.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 234737008..a995cf510 100644 --- a/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue +++ b/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue @@ -22,10 +22,6 @@ const props = defineProps({ type: [Number, String], default: 0, }, - badgeTooltip: { - type: String, - default: '', - }, }); const reauthorizationRequired = computed(() => { @@ -38,7 +34,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'); @@ -360,15 +335,12 @@ 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'), @@ -376,8 +348,6 @@ 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'), @@ -385,8 +355,6 @@ 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'), @@ -402,8 +370,6 @@ 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 }), })), }, @@ -419,7 +385,6 @@ 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 }), })), }, @@ -435,7 +400,6 @@ 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 => @@ -444,7 +408,6 @@ const menuItems = computed(() => { active: leafProps.active, inbox, badgeCount: leafProps.badgeCount, - badgeTooltip: leafProps.badgeTooltip, }), })), }, @@ -460,7 +423,6 @@ 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 8f1de246e..2e431eb7e 100644 --- a/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue +++ b/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue @@ -197,10 +197,7 @@ onMounted(async () => { class="size-4 flex-shrink-0" /> {{ subChild.label }} - + @@ -223,10 +220,7 @@ 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 404bbe3f3..1d23ad0ed 100644 --- a/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue +++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue @@ -12,7 +12,6 @@ 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 }, }); @@ -53,14 +52,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 2a91bfcb4..95d95236a 100644 --- a/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue +++ b/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue @@ -3,7 +3,6 @@ import { computed } from 'vue'; const props = defineProps({ count: { type: [Number, String], default: 0 }, - tooltip: { type: String, default: '' }, }); const normalizedCount = computed(() => { @@ -19,7 +18,6 @@ const displayCount = computed(() =>