From 3c67c415442ea115dc0b1bc142875ff43eb65695 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 22 May 2026 11:09:27 +0530 Subject: [PATCH 01/49] chore: support PFX filetype in attachment uploads (#14456) # Pull Request Template ## Description This PR expands the default upload rules to support PFX certificate files (`application/x-pkcs12`, `application/pkcs12`, `.pfx`) across private notes, Website, Email, and Telegram channels. Also adds `.xls` / `.xlsx` extension fallbacks for cases where browsers upload Excel files with an empty or generic MIME type. ### Utils Repo PR: https://github.com/chatwoot/utils/pull/61 Fixes https://linear.app/chatwoot/issue/CW-7085/support-more-file-types-in-private-notes-and-in-app ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? ### Screenshots image ## 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 - [ ] 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 --------- Co-authored-by: aakashb95 --- .../components-next/icon/FileIcon.vue | 1 + .../widgets/WootWriter/ReplyBottomPanel.vue | 10 +--------- app/javascript/shared/helpers/FileHelper.js | 4 +--- app/models/attachment.rb | 18 ++++++++++++++++-- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- theme/icons.js | 17 +++++++++++++++++ 7 files changed, 42 insertions(+), 20 deletions(-) diff --git a/app/javascript/dashboard/components-next/icon/FileIcon.vue b/app/javascript/dashboard/components-next/icon/FileIcon.vue index 8dd9e7ce1..d82be3e69 100644 --- a/app/javascript/dashboard/components-next/icon/FileIcon.vue +++ b/app/javascript/dashboard/components-next/icon/FileIcon.vue @@ -18,6 +18,7 @@ const fileTypeIcon = computed(() => { json: 'i-woot-file-txt', odt: 'i-woot-file-doc', pdf: 'i-woot-file-pdf', + pfx: 'i-woot-file-pfx', ppt: 'i-woot-file-ppt', pptx: 'i-woot-file-ppt', rar: 'i-woot-file-zip', diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue index ff569d763..2e5a9aab2 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue @@ -7,7 +7,6 @@ import * as ActiveStorage from 'activestorage'; import inboxMixin from 'shared/mixins/inboxMixin'; import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { getAllowedFileTypesByChannel } from '@chatwoot/utils'; -import { ALLOWED_FILE_TYPES } from 'shared/constants/messages'; import VideoCallButton from '../VideoCallButton.vue'; import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { mapGetters } from 'vuex'; @@ -166,11 +165,6 @@ export default { uploadRef, }; }, - data() { - return { - ALLOWED_FILE_TYPES, - }; - }, computed: { ...mapGetters({ accountId: 'getCurrentAccountId', @@ -212,13 +206,11 @@ export default { return this.conversationType === 'instagram_direct_message'; }, allowedFileTypes() { - // Use default file types for private notes if (this.isOnPrivateNote) { - return this.ALLOWED_FILE_TYPES; + return getAllowedFileTypesByChannel(); } let channelType = this.channelType || this.inbox?.channel_type; - if (this.isAnInstagramChannel || this.isInstagramDM) { channelType = INBOX_TYPES.INSTAGRAM; } diff --git a/app/javascript/shared/helpers/FileHelper.js b/app/javascript/shared/helpers/FileHelper.js index 2616c868a..93c8a7156 100644 --- a/app/javascript/shared/helpers/FileHelper.js +++ b/app/javascript/shared/helpers/FileHelper.js @@ -1,6 +1,5 @@ import { getAllowedFileTypesByChannel } from '@chatwoot/utils'; import { INBOX_TYPES } from 'dashboard/helper/inbox'; -import { ALLOWED_FILE_TYPES } from 'shared/constants/messages'; export const DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE = 40; @@ -58,9 +57,8 @@ export const isFileTypeAllowedForChannel = (file, options = {}) => { isOnPrivateNote, } = options; - // Use broader file types for private notes (matches file picker behavior) const allowedFileTypes = isOnPrivateNote - ? ALLOWED_FILE_TYPES + ? getAllowedFileTypesByChannel() : getAllowedFileTypesByChannel({ channelType: isInstagramChannel || conversationType === 'instagram_direct_message' diff --git a/app/models/attachment.rb b/app/models/attachment.rb index 2d46f3b7e..102d90beb 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -33,7 +33,10 @@ class Attachment < ApplicationRecord application/vnd.openxmlformats-officedocument.presentationml.presentation application/vnd.openxmlformats-officedocument.spreadsheetml.sheet application/vnd.openxmlformats-officedocument.wordprocessingml.document + application/x-pkcs12 application/pkcs12 ].freeze + ACCEPTABLE_FILE_EXTENSIONS = %w[pfx].freeze + GENERIC_FILE_CONTENT_TYPES = %w[application/octet-stream].freeze belongs_to :account belongs_to :message has_one_attached :file @@ -195,7 +198,10 @@ class Attachment < ApplicationRecord end def validate_file_content_type(file_content_type) - errors.add(:file, 'type not supported') unless media_file?(file_content_type) || ACCEPTABLE_FILE_TYPES.include?(file_content_type) + return if media_file?(file_content_type) || ACCEPTABLE_FILE_TYPES.include?(file_content_type) + return if generic_file_content_type?(file_content_type) && ACCEPTABLE_FILE_EXTENSIONS.include?(file_extension) + + errors.add(:file, 'type not supported') end def validate_file_size(byte_size) @@ -206,7 +212,15 @@ class Attachment < ApplicationRecord end def media_file?(file_content_type) - file_content_type.start_with?('image/', 'video/', 'audio/') + file_content_type.to_s.start_with?('image/', 'video/', 'audio/') + end + + def generic_file_content_type?(file_content_type) + file_content_type.blank? || GENERIC_FILE_CONTENT_TYPES.include?(file_content_type) + end + + def file_extension + File.extname(file.filename.to_s).delete_prefix('.').downcase end end diff --git a/package.json b/package.json index ed8bbf00f..1e506d6c5 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", "@chatwoot/prosemirror-schema": "1.3.13", - "@chatwoot/utils": "^0.0.52", + "@chatwoot/utils": "^0.0.55", "@formkit/core": "^1.7.2", "@formkit/vue": "^1.7.2", "@hcaptcha/vue3-hcaptcha": "^1.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f1e9e2cf..3a9efa7eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,8 +29,8 @@ importers: specifier: 1.3.13 version: 1.3.13 '@chatwoot/utils': - specifier: ^0.0.52 - version: 0.0.52 + specifier: ^0.0.55 + version: 0.0.55 '@formkit/core': specifier: ^1.7.2 version: 1.7.2 @@ -462,8 +462,8 @@ packages: '@chatwoot/prosemirror-schema@1.3.13': resolution: {integrity: sha512-T6FBUinMJbwDCD7975g8M/Tsn2+G3O2pTGIXdcLkMRpbAAC6mVdl4ZcZektlt5y/PVmPVqNHPsfee1XB/C3vAw==} - '@chatwoot/utils@0.0.52': - resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==} + '@chatwoot/utils@0.0.55': + resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==} engines: {node: '>=10'} '@codemirror/commands@6.7.0': @@ -5011,7 +5011,7 @@ snapshots: prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3) prosemirror-view: 1.34.1 - '@chatwoot/utils@0.0.52': + '@chatwoot/utils@0.0.55': dependencies: date-fns: 2.30.0 diff --git a/theme/icons.js b/theme/icons.js index 266c7ddfa..281e21c23 100644 --- a/theme/icons.js +++ b/theme/icons.js @@ -113,6 +113,23 @@ export const icons = { width: 16, height: 20, }, + 'file-pfx': { + body: ` + + + + + + + + + + + + `, + width: 16, + height: 20, + }, bin: { body: ``, width: 16, From 1d7a9093d227cc7d1627bda9408a050e78cd3d66 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Fri, 22 May 2026 11:33:19 +0530 Subject: [PATCH 02/49] fix: clarify agent availability swagger fields (#14533) Clarifies the agent availability API documentation so request payloads use the writable `availability` field, while `availability_status` remains documented as a read-only response field. ## Closes Closes #13873 ## Why The backend already supports updating an agent's configured availability through `availability`, but the Swagger request payloads documented `availability_status`. That made clients follow a read-only response field and see successful requests without the intended availability change. ## What changed - Replaces `availability_status` with `availability` in agent create/update request schemas. - Updates the availability enum to `online`, `busy`, and `offline`. - Marks response `availability_status` as read-only and explains that it is derived from configured availability, auto-offline, and presence. - Regenerates the combined and tag-group Swagger JSON files. ## Validation - `bundle exec rails swagger:build` - `bundle exec rspec spec/swagger/openapi_spec.rb` - `git diff --check` --- .../request/agent/create_payload.yml | 10 +++---- .../request/agent/update_payload.yml | 10 +++---- swagger/definitions/resource/agent.yml | 10 ++++--- swagger/swagger.json | 27 ++++++++++--------- swagger/tag_groups/application_swagger.json | 27 ++++++++++--------- swagger/tag_groups/client_swagger.json | 27 ++++++++++--------- swagger/tag_groups/other_swagger.json | 27 ++++++++++--------- swagger/tag_groups/platform_swagger.json | 27 ++++++++++--------- 8 files changed, 87 insertions(+), 78 deletions(-) diff --git a/swagger/definitions/request/agent/create_payload.yml b/swagger/definitions/request/agent/create_payload.yml index 1daeae83a..77180d282 100644 --- a/swagger/definitions/request/agent/create_payload.yml +++ b/swagger/definitions/request/agent/create_payload.yml @@ -17,12 +17,12 @@ properties: enum: ['agent', 'administrator'] description: Whether its administrator or agent example: 'agent' - availability_status: + availability: type: string - enum: ['available', 'busy', 'offline'] - description: The availability setting of the agent. - example: 'available' + enum: ['online', 'busy', 'offline'] + description: The configured availability of the agent. + example: 'online' auto_offline: type: boolean - description: Whether the availability status of agent is configured to go offline automatically when away. + description: Whether the agent is automatically marked offline when they are away. example: true diff --git a/swagger/definitions/request/agent/update_payload.yml b/swagger/definitions/request/agent/update_payload.yml index fc8d1457d..168d46f49 100644 --- a/swagger/definitions/request/agent/update_payload.yml +++ b/swagger/definitions/request/agent/update_payload.yml @@ -7,12 +7,12 @@ properties: enum: ['agent', 'administrator'] description: Whether its administrator or agent example: 'agent' - availability_status: + availability: type: string - enum: ['available', 'busy', 'offline'] - description: The availability status of the agent. - example: 'available' + enum: ['online', 'busy', 'offline'] + description: The configured availability of the agent. + example: 'online' auto_offline: type: boolean - description: Whether the availability status of agent is configured to go offline automatically when away. + description: Whether the agent is automatically marked offline when they are away. example: true diff --git a/swagger/definitions/resource/agent.yml b/swagger/definitions/resource/agent.yml index 1d7b2b4c3..cabd1ee27 100644 --- a/swagger/definitions/resource/agent.yml +++ b/swagger/definitions/resource/agent.yml @@ -6,11 +6,15 @@ properties: type: integer availability_status: type: string - enum: ['available', 'busy', 'offline'] - description: The availability status of the agent computed by Chatwoot. + enum: ['online', 'busy', 'offline'] + readOnly: true + description: >- + The effective availability status of the agent, derived from the configured availability, + auto-offline setting, and current presence. To update an agent's configured availability, + use the availability field in create or update requests. auto_offline: type: boolean - description: Whether the availability status of agent is configured to go offline automatically when away. + description: Whether the agent is automatically marked offline when they are away. confirmed: type: boolean description: Whether the agent has confirmed their email address. diff --git a/swagger/swagger.json b/swagger/swagger.json index 94d1f04d3..b8b64b009 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -9892,15 +9892,16 @@ "availability_status": { "type": "string", "enum": [ - "available", + "online", "busy", "offline" ], - "description": "The availability status of the agent computed by Chatwoot." + "readOnly": true, + "description": "The effective availability status of the agent, derived from the configured availability, auto-offline setting, and current presence. To update an agent's configured availability, use the availability field in create or update requests." }, "auto_offline": { "type": "boolean", - "description": "Whether the availability status of agent is configured to go offline automatically when away." + "description": "Whether the agent is automatically marked offline when they are away." }, "confirmed": { "type": "boolean", @@ -11595,19 +11596,19 @@ "description": "Whether its administrator or agent", "example": "agent" }, - "availability_status": { + "availability": { "type": "string", "enum": [ - "available", + "online", "busy", "offline" ], - "description": "The availability setting of the agent.", - "example": "available" + "description": "The configured availability of the agent.", + "example": "online" }, "auto_offline": { "type": "boolean", - "description": "Whether the availability status of agent is configured to go offline automatically when away.", + "description": "Whether the agent is automatically marked offline when they are away.", "example": true } } @@ -11627,19 +11628,19 @@ "description": "Whether its administrator or agent", "example": "agent" }, - "availability_status": { + "availability": { "type": "string", "enum": [ - "available", + "online", "busy", "offline" ], - "description": "The availability status of the agent.", - "example": "available" + "description": "The configured availability of the agent.", + "example": "online" }, "auto_offline": { "type": "boolean", - "description": "Whether the availability status of agent is configured to go offline automatically when away.", + "description": "Whether the agent is automatically marked offline when they are away.", "example": true } } diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json index a013b3694..17e015139 100644 --- a/swagger/tag_groups/application_swagger.json +++ b/swagger/tag_groups/application_swagger.json @@ -8399,15 +8399,16 @@ "availability_status": { "type": "string", "enum": [ - "available", + "online", "busy", "offline" ], - "description": "The availability status of the agent computed by Chatwoot." + "readOnly": true, + "description": "The effective availability status of the agent, derived from the configured availability, auto-offline setting, and current presence. To update an agent's configured availability, use the availability field in create or update requests." }, "auto_offline": { "type": "boolean", - "description": "Whether the availability status of agent is configured to go offline automatically when away." + "description": "Whether the agent is automatically marked offline when they are away." }, "confirmed": { "type": "boolean", @@ -10102,19 +10103,19 @@ "description": "Whether its administrator or agent", "example": "agent" }, - "availability_status": { + "availability": { "type": "string", "enum": [ - "available", + "online", "busy", "offline" ], - "description": "The availability setting of the agent.", - "example": "available" + "description": "The configured availability of the agent.", + "example": "online" }, "auto_offline": { "type": "boolean", - "description": "Whether the availability status of agent is configured to go offline automatically when away.", + "description": "Whether the agent is automatically marked offline when they are away.", "example": true } } @@ -10134,19 +10135,19 @@ "description": "Whether its administrator or agent", "example": "agent" }, - "availability_status": { + "availability": { "type": "string", "enum": [ - "available", + "online", "busy", "offline" ], - "description": "The availability status of the agent.", - "example": "available" + "description": "The configured availability of the agent.", + "example": "online" }, "auto_offline": { "type": "boolean", - "description": "Whether the availability status of agent is configured to go offline automatically when away.", + "description": "Whether the agent is automatically marked offline when they are away.", "example": true } } diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json index 763e090b1..7bc7227fb 100644 --- a/swagger/tag_groups/client_swagger.json +++ b/swagger/tag_groups/client_swagger.json @@ -1664,15 +1664,16 @@ "availability_status": { "type": "string", "enum": [ - "available", + "online", "busy", "offline" ], - "description": "The availability status of the agent computed by Chatwoot." + "readOnly": true, + "description": "The effective availability status of the agent, derived from the configured availability, auto-offline setting, and current presence. To update an agent's configured availability, use the availability field in create or update requests." }, "auto_offline": { "type": "boolean", - "description": "Whether the availability status of agent is configured to go offline automatically when away." + "description": "Whether the agent is automatically marked offline when they are away." }, "confirmed": { "type": "boolean", @@ -3367,19 +3368,19 @@ "description": "Whether its administrator or agent", "example": "agent" }, - "availability_status": { + "availability": { "type": "string", "enum": [ - "available", + "online", "busy", "offline" ], - "description": "The availability setting of the agent.", - "example": "available" + "description": "The configured availability of the agent.", + "example": "online" }, "auto_offline": { "type": "boolean", - "description": "Whether the availability status of agent is configured to go offline automatically when away.", + "description": "Whether the agent is automatically marked offline when they are away.", "example": true } } @@ -3399,19 +3400,19 @@ "description": "Whether its administrator or agent", "example": "agent" }, - "availability_status": { + "availability": { "type": "string", "enum": [ - "available", + "online", "busy", "offline" ], - "description": "The availability status of the agent.", - "example": "available" + "description": "The configured availability of the agent.", + "example": "online" }, "auto_offline": { "type": "boolean", - "description": "Whether the availability status of agent is configured to go offline automatically when away.", + "description": "Whether the agent is automatically marked offline when they are away.", "example": true } } diff --git a/swagger/tag_groups/other_swagger.json b/swagger/tag_groups/other_swagger.json index 50bf2212b..6dbfbdd8e 100644 --- a/swagger/tag_groups/other_swagger.json +++ b/swagger/tag_groups/other_swagger.json @@ -1079,15 +1079,16 @@ "availability_status": { "type": "string", "enum": [ - "available", + "online", "busy", "offline" ], - "description": "The availability status of the agent computed by Chatwoot." + "readOnly": true, + "description": "The effective availability status of the agent, derived from the configured availability, auto-offline setting, and current presence. To update an agent's configured availability, use the availability field in create or update requests." }, "auto_offline": { "type": "boolean", - "description": "Whether the availability status of agent is configured to go offline automatically when away." + "description": "Whether the agent is automatically marked offline when they are away." }, "confirmed": { "type": "boolean", @@ -2782,19 +2783,19 @@ "description": "Whether its administrator or agent", "example": "agent" }, - "availability_status": { + "availability": { "type": "string", "enum": [ - "available", + "online", "busy", "offline" ], - "description": "The availability setting of the agent.", - "example": "available" + "description": "The configured availability of the agent.", + "example": "online" }, "auto_offline": { "type": "boolean", - "description": "Whether the availability status of agent is configured to go offline automatically when away.", + "description": "Whether the agent is automatically marked offline when they are away.", "example": true } } @@ -2814,19 +2815,19 @@ "description": "Whether its administrator or agent", "example": "agent" }, - "availability_status": { + "availability": { "type": "string", "enum": [ - "available", + "online", "busy", "offline" ], - "description": "The availability status of the agent.", - "example": "available" + "description": "The configured availability of the agent.", + "example": "online" }, "auto_offline": { "type": "boolean", - "description": "Whether the availability status of agent is configured to go offline automatically when away.", + "description": "Whether the agent is automatically marked offline when they are away.", "example": true } } diff --git a/swagger/tag_groups/platform_swagger.json b/swagger/tag_groups/platform_swagger.json index f1e471e79..952813f62 100644 --- a/swagger/tag_groups/platform_swagger.json +++ b/swagger/tag_groups/platform_swagger.json @@ -1840,15 +1840,16 @@ "availability_status": { "type": "string", "enum": [ - "available", + "online", "busy", "offline" ], - "description": "The availability status of the agent computed by Chatwoot." + "readOnly": true, + "description": "The effective availability status of the agent, derived from the configured availability, auto-offline setting, and current presence. To update an agent's configured availability, use the availability field in create or update requests." }, "auto_offline": { "type": "boolean", - "description": "Whether the availability status of agent is configured to go offline automatically when away." + "description": "Whether the agent is automatically marked offline when they are away." }, "confirmed": { "type": "boolean", @@ -3543,19 +3544,19 @@ "description": "Whether its administrator or agent", "example": "agent" }, - "availability_status": { + "availability": { "type": "string", "enum": [ - "available", + "online", "busy", "offline" ], - "description": "The availability setting of the agent.", - "example": "available" + "description": "The configured availability of the agent.", + "example": "online" }, "auto_offline": { "type": "boolean", - "description": "Whether the availability status of agent is configured to go offline automatically when away.", + "description": "Whether the agent is automatically marked offline when they are away.", "example": true } } @@ -3575,19 +3576,19 @@ "description": "Whether its administrator or agent", "example": "agent" }, - "availability_status": { + "availability": { "type": "string", "enum": [ - "available", + "online", "busy", "offline" ], - "description": "The availability status of the agent.", - "example": "available" + "description": "The configured availability of the agent.", + "example": "online" }, "auto_offline": { "type": "boolean", - "description": "Whether the availability status of agent is configured to go offline automatically when away.", + "description": "Whether the agent is automatically marked offline when they are away.", "example": true } } From bef25781dede466ba4613fb88d4292faf4cdddb2 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Fri, 22 May 2026 11:55:16 +0530 Subject: [PATCH 03/49] feat(attachments): add XML and PFX file support (#14539) Update frontend allowed file types and FileIcon mapping, and backend Attachment constants to accept .xml and .pfx files # Pull Request Template ## Description Customer also wanted XML support along with .pfx Following up on #14456 ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. locally CleanShot 2026-05-22 at 11 43 20@2x CleanShot 2026-05-22 at 11 44 03@2x ## 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/javascript/dashboard/components-next/icon/FileIcon.vue | 1 + app/javascript/shared/constants/messages.js | 4 +++- app/models/attachment.rb | 5 +++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/javascript/dashboard/components-next/icon/FileIcon.vue b/app/javascript/dashboard/components-next/icon/FileIcon.vue index d82be3e69..66a971bc6 100644 --- a/app/javascript/dashboard/components-next/icon/FileIcon.vue +++ b/app/javascript/dashboard/components-next/icon/FileIcon.vue @@ -27,6 +27,7 @@ const fileTypeIcon = computed(() => { txt: 'i-woot-file-txt', xls: 'i-woot-file-xls', xlsx: 'i-woot-file-xls', + xml: 'i-woot-file-txt', zip: 'i-woot-file-zip', }; diff --git a/app/javascript/shared/constants/messages.js b/app/javascript/shared/constants/messages.js index 989aa12ca..18bc380ec 100644 --- a/app/javascript/shared/constants/messages.js +++ b/app/javascript/shared/constants/messages.js @@ -39,12 +39,14 @@ export const ALLOWED_FILE_TYPES = 'audio/*,' + 'video/*,' + '.3gpp,' + + '.xls, .xlsx, .xml, .pfx,' + 'text/csv, text/plain, application/json, application/pdf, text/rtf,' + 'application/xml, text/xml,' + 'application/zip, application/x-7z-compressed application/vnd.rar application/x-tar,' + 'application/msword, application/vnd.ms-excel, application/vnd.ms-powerpoint, application/vnd.oasis.opendocument.text,' + 'application/vnd.openxmlformats-officedocument.presentationml.presentation, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,' + - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document,'; + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document,' + + 'application/x-pkcs12, application/pkcs12,'; export const CSAT_RATINGS = [ { diff --git a/app/models/attachment.rb b/app/models/attachment.rb index 102d90beb..79a8021b3 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -25,8 +25,9 @@ class Attachment < ApplicationRecord include Rails.application.routes.url_helpers ACCEPTABLE_FILE_TYPES = %w[ - text/csv text/plain text/rtf + text/csv text/plain text/rtf text/xml application/json application/pdf + application/xml application/zip application/x-7z-compressed application/vnd.rar application/x-tar application/msword application/vnd.ms-excel application/vnd.ms-powerpoint application/rtf application/vnd.oasis.opendocument.text @@ -35,7 +36,7 @@ class Attachment < ApplicationRecord application/vnd.openxmlformats-officedocument.wordprocessingml.document application/x-pkcs12 application/pkcs12 ].freeze - ACCEPTABLE_FILE_EXTENSIONS = %w[pfx].freeze + ACCEPTABLE_FILE_EXTENSIONS = %w[pfx xml].freeze GENERIC_FILE_CONTENT_TYPES = %w[application/octet-stream].freeze belongs_to :account belongs_to :message From 0722750a553409a7b82c92c840151514c58813ff Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 22 May 2026 12:16:19 +0530 Subject: [PATCH 04/49] chore: Captain reply actions not showing correctly with content (#14160) --- .../widgets/WootWriter/CopilotMenuBar.vue | 22 +++------ .../components/widgets/WootWriter/Editor.vue | 48 ++++++++++--------- .../widgets/WootWriter/ReplyTopPanel.vue | 14 +++++- .../widgets/conversation/ReplyBox.vue | 16 +++++++ .../dashboard/helper/editorHelper.js | 6 +-- .../helper/specs/editorHelper.spec.js | 27 +++++------ 6 files changed, 77 insertions(+), 56 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue b/app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue index af9cc9f68..524d84ede 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue @@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n'; import { useElementSize, useWindowSize } from '@vueuse/core'; import { useMapGetter } from 'dashboard/composables/store'; import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants'; -import { useCaptain } from 'dashboard/composables/useCaptain'; import Button from 'dashboard/components-next/button/Button.vue'; import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue'; @@ -19,9 +18,11 @@ const props = defineProps({ type: Boolean, default: false, }, - editorContent: { - type: String, - default: undefined, + // Signature-aware emptiness is computed by the parent (which has access to + // the signature + channel context) and passed in as a boolean. + hasContent: { + type: Boolean, + default: false, }, conversationId: { type: Number, @@ -33,17 +34,8 @@ const emit = defineEmits(['executeCopilotAction']); const { t } = useI18n(); -const { draftMessage } = useCaptain(); - const replyMode = useMapGetter('draftMessages/getReplyEditorMode'); -// When editorContent prop is passed, use it exclusively (even if empty) -// This ensures each editor instance shows menu items based on its own content -// Falls back to global draftMessage only when editorContent is not provided -const effectiveContent = computed(() => - props.editorContent !== undefined ? props.editorContent : draftMessage.value -); - // Selection-based menu items (when text is selected) const menuItems = computed(() => { const items = []; @@ -63,7 +55,7 @@ const menuItems = computed(() => { } else if ( props.conversationId && replyMode.value === REPLY_EDITOR_MODES.REPLY && - effectiveContent.value + props.hasContent ) { items.push({ label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'), @@ -72,7 +64,7 @@ const menuItems = computed(() => { }); } - if (effectiveContent.value) { + if (props.hasContent) { items.push( { label: t( diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index d7adb07d0..7881a0d25 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -354,16 +354,17 @@ function isBodyEmpty(content) { // if content is undefined, we assume that the body is empty if (!content) return true; - // if the signature is present, we need to remove it before checking - // note that we don't update the editorView, so this is safe - // Use effective channel type to match how signature was appended - const bodyWithoutSignature = props.signature - ? removeSignatureHelper( - content, - props.signature, - effectiveChannelType.value - ) - : content; + // Only strip the signature when it's actually being auto-appended for this + // draft. Otherwise an agent whose typed text happens to match their saved + // signature would be mistakenly treated as empty. + const bodyWithoutSignature = + sendWithSignature.value && props.signature + ? removeSignatureHelper( + content, + props.signature, + effectiveChannelType.value + ) + : content; // trimming should remove all the whitespaces, so we can check the length return bodyWithoutSignature.trim().length === 0; @@ -474,17 +475,6 @@ function removeSignature() { reloadState(content); } -function toggleSignatureInEditor(signatureEnabled) { - // The toggleSignatureInEditor gets the new value from the - // watcher, this means that if the value is true, the signature - // is supposed to be added, else we remove it. - if (signatureEnabled) { - addSignature(); - } else { - removeSignature(); - } -} - function setToolbarPosition() { const editorRect = editorRoot.value.getBoundingClientRect(); const rect = selectedImageNode.value.getBoundingClientRect(); @@ -559,6 +549,20 @@ function emitOnChange() { emit('update:modelValue', contentFromEditor()); } +function toggleSignatureInEditor(signatureEnabled) { + // The toggleSignatureInEditor gets the new value from the + // watcher, this means that if the value is true, the signature + // is supposed to be added, else we remove it. + if (signatureEnabled) { + addSignature(); + } else { + removeSignature(); + } + // reloadState replaces editor state directly and bypasses dispatchTransaction, + // so v-model never hears about the signature change — sync it back explicitly. + emitOnChange(); +} + function updateImgToolbarOnDelete() { // check if the selected node is present or not on keyup // this is needed because the user can select an image and then delete it @@ -899,7 +903,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor); v-on-click-outside="handleClickOutside" :has-selection="isTextSelected" :is-editor-menu-popover="isEditorMenuPopover" - :editor-content="modelValue" + :has-content="!isBodyEmpty(modelValue)" :conversation-id="conversationId" :show-selection-menu="showSelectionMenu" :show-general-menu="false" diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue index cdf577c21..a2929f7f8 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue @@ -53,6 +53,10 @@ export default { type: String, default: undefined, }, + hasContent: { + type: Boolean, + default: false, + }, }, emits: ['setReplyMode', 'toggleEditorSize', 'executeCopilotAction'], setup(props, { emit }) { @@ -76,6 +80,7 @@ export default { const { captainTasksEnabled } = useCaptain(); const showCopilotMenu = ref(false); + const copilotToggleRef = ref(null); const handleCopilotAction = (actionKey, data) => { emit('executeCopilotAction', actionKey, data || props.editorContent); @@ -117,6 +122,7 @@ export default { captainTasksEnabled, handleCopilotAction, showCopilotMenu, + copilotToggleRef, toggleCopilotMenu, handleClickOutside, }; @@ -164,6 +170,7 @@ export default {
Date: Fri, 22 May 2026 13:46:43 +0700 Subject: [PATCH 05/49] chore: resolve sass and vue compiler deprecation warnings (#13794) --- .../components-next/Editor/Editor.vue | 36 +++++------ .../Pages/ArticleEditorPage/ArticleEditor.vue | 62 +++++++++---------- .../components-next/breadcrumb/Breadcrumb.vue | 1 - .../pageComponents/customTool/AuthConfig.vue | 2 +- .../pageComponents/customTool/ParamRow.vue | 2 +- .../colorpicker/ColorPicker.vue | 2 +- .../dropdown-menu/DropdownMenu.vue | 2 +- .../components-next/filter/ConditionRow.vue | 2 +- .../filter/inputs/MultiSelect.vue | 2 +- .../filter/inputs/SingleSelect.vue | 2 +- .../dashboard/components-next/flag/Flag.vue | 2 +- .../components-next/message/MessageList.vue | 2 +- .../message/TranslationToggle.vue | 2 - .../message/chips/AttachmentChips.vue | 2 +- .../components/Accordion/AccordionItem.vue | 1 - .../dashboard/components/CustomAttribute.vue | 16 +++-- .../components/IntersectionObserver.vue | 2 +- app/javascript/dashboard/components/Modal.vue | 2 +- .../components/ui/Dropdown/DropdownSearch.vue | 1 - .../components/widgets/ColorPicker.vue | 6 +- .../widgets/WootWriter/AudioRecorder.vue | 2 +- .../widgets/WootWriter/ReplyBottomPanel.vue | 2 +- .../linear/SearchableDropdown.vue | 2 +- .../widgets/mentions/MentionBox.vue | 2 +- .../components/MessageContextMenu.vue | 10 ++- .../search/components/MessageContent.vue | 4 +- .../components/SearchContactAgentSelector.vue | 2 +- .../components/SearchDateRangeSelector.vue | 2 +- .../search/components/SearchFilters.vue | 2 +- .../search/components/SearchHeader.vue | 2 +- .../search/components/SearchInboxSelector.vue | 2 +- .../SearchResultConversationsList.vue | 2 +- .../widget-preview/components/WidgetBody.vue | 2 - .../dashboard/conversation/ContactPanel.vue | 6 +- .../dashboard/settings/canned/AddCanned.vue | 16 +++-- .../dashboard/settings/canned/EditCanned.vue | 16 +++-- .../dashboard/settings/canned/Index.vue | 2 +- .../component/CustomRolePaywall.vue | 36 ++++++----- .../settings/inbox/PreChatForm/Settings.vue | 6 +- .../inbox/channels/emailChannels/Google.vue | 1 - .../channels/emailChannels/Microsoft.vue | 1 - .../inbox/components/WeeklyAvailability.vue | 2 +- .../inbox/settingsPage/ConfigurationPage.vue | 2 +- .../settingsPage/CustomerSatisfactionPage.vue | 2 +- .../integrations/SingleIntegrationHooks.vue | 1 - .../dashboard/settings/labels/AddLabel.vue | 6 +- .../dashboard/settings/labels/EditLabel.vue | 6 +- .../dashboard/settings/macros/MacroNode.vue | 2 +- .../settings/macros/MacroProperties.vue | 4 +- .../heatmaps/HeatmapDateRangeSelector.vue | 2 +- .../routes/dashboard/upgrade/UpgradePage.vue | 2 +- .../shared/components/StarRating.vue | 2 +- .../components/ui/MultiselectDropdown.vue | 2 +- .../components/ui/dropdown/DropdownItem.vue | 8 +-- app/javascript/v3/components/Form/Input.vue | 2 +- .../widget/components/GroupedAvatars.vue | 2 +- .../widget/components/UserMessageBubble.vue | 8 +-- .../Home/Article/ArticleBlock.vue | 2 +- .../Home/Article/ArticleListItem.vue | 1 - vite.config.ts | 7 +++ 60 files changed, 153 insertions(+), 179 deletions(-) diff --git a/app/javascript/dashboard/components-next/Editor/Editor.vue b/app/javascript/dashboard/components-next/Editor/Editor.vue index 847bbd600..b12d0331a 100644 --- a/app/javascript/dashboard/components-next/Editor/Editor.vue +++ b/app/javascript/dashboard/components-next/Editor/Editor.vue @@ -142,29 +142,27 @@ watch( diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue index 831312e0b..59c710a37 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue @@ -145,45 +145,43 @@ const handleCreateArticle = event => { diff --git a/app/javascript/dashboard/components/IntersectionObserver.vue b/app/javascript/dashboard/components/IntersectionObserver.vue index c650a8c0e..36135bd44 100644 --- a/app/javascript/dashboard/components/IntersectionObserver.vue +++ b/app/javascript/dashboard/components/IntersectionObserver.vue @@ -1,5 +1,5 @@ + + diff --git a/app/javascript/dashboard/components-next/call/CallCard.vue b/app/javascript/dashboard/components-next/call/CallCard.vue new file mode 100644 index 000000000..fcd7ad232 --- /dev/null +++ b/app/javascript/dashboard/components-next/call/CallCard.vue @@ -0,0 +1,222 @@ + + + diff --git a/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue new file mode 100644 index 000000000..39e75ddbf --- /dev/null +++ b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue @@ -0,0 +1,256 @@ + + + diff --git a/app/javascript/dashboard/components-next/icon/ChannelIcon.vue b/app/javascript/dashboard/components-next/icon/ChannelIcon.vue index 68102dbd3..aef6a57ec 100644 --- a/app/javascript/dashboard/components-next/icon/ChannelIcon.vue +++ b/app/javascript/dashboard/components-next/icon/ChannelIcon.vue @@ -1,5 +1,6 @@ diff --git a/app/javascript/dashboard/components-next/icon/provider.js b/app/javascript/dashboard/components-next/icon/provider.js index d7a9c93ad..40fa2da98 100644 --- a/app/javascript/dashboard/components-next/icon/provider.js +++ b/app/javascript/dashboard/components-next/icon/provider.js @@ -1,5 +1,5 @@ +import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox'; import { computed } from 'vue'; -import { isVoiceCallEnabled } from 'dashboard/helper/inbox'; export function useChannelIcon(inbox) { const channelTypeIconMap = { @@ -27,19 +27,29 @@ export function useChannelIcon(inbox) { const type = inboxDetails.channel_type; let icon = channelTypeIconMap[type]; - if (type === 'Channel::Email' && inboxDetails.provider) { + if (type === INBOX_TYPES.EMAIL && inboxDetails.provider) { if (Object.keys(providerIconMap).includes(inboxDetails.provider)) { icon = providerIconMap[inboxDetails.provider]; } } // Special case for Twilio whatsapp - if (type === 'Channel::TwilioSms' && inboxDetails.medium === 'whatsapp') { + if ( + type === INBOX_TYPES.TWILIO && + inboxDetails.medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP + ) { icon = 'i-woot-whatsapp'; } - // Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.) - if (isVoiceCallEnabled(inboxDetails)) { + // Native Twilio voice inbox: a TwilioSms with voice enabled (and no WhatsApp medium) + // is presented as a Voice channel, so show the phone icon. + const voiceEnabled = + inboxDetails.voice_enabled || inboxDetails.voiceEnabled; + if ( + type === INBOX_TYPES.TWILIO && + voiceEnabled && + inboxDetails.medium !== TWILIO_CHANNEL_MEDIUM.WHATSAPP + ) { icon = 'i-woot-voice'; } diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index a0f950ad4..ae9c4ec75 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -2,14 +2,27 @@ import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { useStore } from 'vuex'; +import { useMapGetter } from 'dashboard/composables/store'; import { useMessageContext } from '../provider.js'; -import { VOICE_CALL_STATUS } from '../constants'; -import { useCallSession } from 'dashboard/composables/useCallSession'; +import { + VOICE_CALL_STATUS, + VOICE_CALL_DIRECTION, + VOICE_CALL_OUTBOUND_INIT_STATUS, + VOICE_CALL_END_REASON, + MESSAGE_TYPES, + ATTACHMENT_TYPES, +} from '../constants'; +import { useCallActions } from 'dashboard/composables/useCallSession'; +import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession'; +import { useCallsStore } from 'dashboard/stores/calls'; +import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox'; import { formatDuration } from 'shared/helpers/timeHelper'; +import { useAlert } from 'dashboard/composables'; import Icon from 'dashboard/components-next/icon/Icon.vue'; import BaseBubble from 'next/message/bubbles/Base.vue'; import AudioChip from 'next/message/chips/Audio.vue'; +import NextButton from 'dashboard/components-next/button/Button.vue'; const LABEL_MAP = { [VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS', @@ -17,39 +30,71 @@ const LABEL_MAP = { }; const ICON_MAP = { - [VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call', - [VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x', - [VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x', -}; - -const BG_COLOR_MAP = { - [VOICE_CALL_STATUS.IN_PROGRESS]: 'bg-n-teal-9', - [VOICE_CALL_STATUS.RINGING]: 'bg-n-teal-9 animate-pulse', - [VOICE_CALL_STATUS.COMPLETED]: 'bg-n-slate-11', - [VOICE_CALL_STATUS.NO_ANSWER]: 'bg-n-ruby-9', - [VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9', + [VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call-bold', + [VOICE_CALL_STATUS.COMPLETED]: 'i-ph-phone-bold', + [VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x-bold', + [VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x-bold', }; const { t } = useI18n(); const store = useStore(); -const { call, conversationId, currentUserId, inboxId } = useMessageContext(); +const { + call, + attachments, + contentAttributes, + conversationId, + currentUserId, + inboxId, + sender, + messageType, +} = useMessageContext(); const { joinCall, endCall, activeCall, hasActiveCall, isJoining } = - useCallSession(); + useCallActions(); +const whatsappCallSession = useWhatsappCallSession(); +const callsStore = useCallsStore(); +const contactsUiFlags = useMapGetter('contacts/getUIFlags'); +const isInitiatingCall = computed( + () => contactsUiFlags.value?.isInitiatingCall || false +); const status = computed(() => call.value?.status); -const isOutbound = computed(() => call.value?.direction === 'outgoing'); +// Server-side call records use `outgoing`/`incoming`, while the Pinia store +// and a few API hops normalise to `outbound`/`inbound`. Accept either so the +// bubble label matches the message orientation no matter the source. +const isOutbound = computed(() => { + const dir = call.value?.direction; + if ( + dir === VOICE_CALL_DIRECTION.OUTGOING || + dir === VOICE_CALL_DIRECTION.OUTBOUND + ) + return true; + if ( + dir === VOICE_CALL_DIRECTION.INCOMING || + dir === VOICE_CALL_DIRECTION.INBOUND + ) + return false; + // Fall back to the message orientation: agent-authored messages sit on the + // right (outbound) and contact-authored ones on the left. + return messageType.value === MESSAGE_TYPES.OUTGOING; +}); +const isWhatsapp = computed( + () => call.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP +); const isFailed = computed(() => [VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value) ); +const isMissedInbound = computed(() => isFailed.value && !isOutbound.value); +const endReason = computed(() => call.value?.endReason); +const wasDeclinedByAgent = computed( + () => + isMissedInbound.value && + endReason.value === VOICE_CALL_END_REASON.AGENT_REJECTED +); const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId); const didCurrentUserAnswer = computed( () => !!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value ); -// Pickup auto-assigns the conversation, so the assignee is a safe display proxy -// for the answerer when the Call payload lacks accepted_by_agent_id (e.g., -// Twilio's call-status webhook flipped the call to in-progress before the -// participant-join webhook claimed it). const conversationAssignee = computed(() => { const conversation = store.getters.getConversationById?.( conversationId?.value @@ -66,6 +111,19 @@ const displayAgentName = computed(() => { return conversationAssignee.value?.name || null; }); +const audioAttachment = computed(() => + (attachments?.value || []).find(a => a.fileType === ATTACHMENT_TYPES.AUDIO) +); + +const durationSeconds = computed(() => { + const fromCall = call.value?.durationSeconds || call.value?.duration_seconds; + if (fromCall != null) return fromCall; + const data = contentAttributes?.value?.data; + return data?.durationSeconds || data?.duration_seconds; +}); + +const formattedDuration = computed(() => formatDuration(durationSeconds.value)); + const labelKey = computed(() => { if (LABEL_MAP[status.value]) return LABEL_MAP[status.value]; if (status.value === VOICE_CALL_STATUS.RINGING) { @@ -73,24 +131,25 @@ const labelKey = computed(() => { ? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL' : 'CONVERSATION.VOICE_CALL.INCOMING_CALL'; } - return isFailed.value - ? 'CONVERSATION.VOICE_CALL.MISSED_CALL' - : 'CONVERSATION.VOICE_CALL.INCOMING_CALL'; + if (isFailed.value) { + return isOutbound.value + ? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL' + : 'CONVERSATION.VOICE_CALL.MISSED_CALL'; + } + return 'CONVERSATION.VOICE_CALL.INCOMING_CALL'; }); -const formattedDuration = computed(() => - formatDuration(call.value?.durationSeconds) -); - const subtext = computed(() => { if (status.value === VOICE_CALL_STATUS.RINGING) { - return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'); + return isOutbound.value + ? t('CONVERSATION.VOICE_CALL.CALLING') + : t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'); } if (status.value === VOICE_CALL_STATUS.COMPLETED) { return formattedDuration.value; } if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) { - if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED'); + if (isOutbound.value) return null; if (didCurrentUserAnswer.value) { return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED'); } @@ -99,34 +158,51 @@ const subtext = computed(() => { agentName: displayAgentName.value, }); } - return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED'); + return null; } - return isFailed.value - ? t('CONVERSATION.VOICE_CALL.NO_ANSWER') - : t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'); + if (isFailed.value) { + if (isOutbound.value) { + return t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT'); + } + if (wasDeclinedByAgent.value && displayAgentName.value) { + return t('CONVERSATION.VOICE_CALL.MISSED_CALL_DECLINED_BY', { + agentName: displayAgentName.value, + }); + } + return t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT'); + } + return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'); }); const iconName = computed(() => { if (ICON_MAP[status.value]) return ICON_MAP[status.value]; - return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming'; + return isOutbound.value + ? 'i-ph-phone-outgoing-bold' + : 'i-ph-phone-incoming-bold'; }); -const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9'); +// Subtle icon container — matches the design's tonal swatch over the bubble bg. +// Status drives the accent: teal for live, ruby for missed, neutral otherwise. +const iconContainerClass = computed(() => { + if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) { + return 'bg-n-teal-3 text-n-teal-11'; + } + if (status.value === VOICE_CALL_STATUS.RINGING) { + return 'bg-n-teal-3 text-n-teal-11'; + } + if (isMissedInbound.value) { + return 'bg-n-alpha-2 text-n-ruby-9'; + } + return 'bg-n-alpha-2 text-n-slate-12'; +}); const callSid = computed(() => call.value?.providerCallId); -// Show "Join call" when the call is still ringing, no agent has claimed it, -// and the conversation is unassigned or assigned to the current user. Mirrors -// the eligibility used by FloatingCallWidget so the bubble can act as a -// recovery affordance after a refresh or missed widget. const canJoinCall = computed(() => { if (status.value !== VOICE_CALL_STATUS.RINGING) return false; if (isOutbound.value) return false; if (acceptedByAgentId.value) return false; if (!callSid.value || !inboxId.value || !conversationId.value) return false; - // Suppress the button once this call is the local active session — the - // message status webhook may lag behind, so we can't rely on `status` alone - // to hide it after a successful join from this client. if (hasActiveCall.value && activeCall.value?.callSid === callSid.value) return false; const assignee = conversationAssignee.value; @@ -135,11 +211,12 @@ const canJoinCall = computed(() => { }); const recordingAttachment = computed(() => { + if (audioAttachment.value) return audioAttachment.value; const url = call.value?.recordingUrl; if (!url) return null; return { dataUrl: url, - fileType: 'audio', + fileType: ATTACHMENT_TYPES.AUDIO, extension: 'wav', transcribedText: call.value?.transcript || '', }; @@ -162,48 +239,117 @@ const handleJoinCall = async () => { callSid: callSid.value, }); }; + +const canCallBack = computed( + () => + isMissedInbound.value && + !!inboxId.value && + !!conversationId.value && + !hasActiveCall.value && + !callsStore.hasIncomingCall +); + +const handleCallBack = async () => { + if (!canCallBack.value || isInitiatingCall.value) return; + try { + if (isWhatsapp.value) { + const response = await whatsappCallSession.initiateOutboundCall( + conversationId.value + ); + if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return; + // Permission template path returns no call id — show banner, no widget yet. + if (!response?.id) { + useAlert( + response?.status === + VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING + ? t('CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_PENDING') + : t('CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_REQUESTED') + ); + return; + } + callsStore.addCall({ + callSid: response.call_id, + callId: response.id, + conversationId: conversationId.value, + inboxId: inboxId.value, + callDirection: VOICE_CALL_DIRECTION.OUTBOUND, + provider: VOICE_CALL_PROVIDERS.WHATSAPP, + }); + return; + } + const response = await store.dispatch('contacts/initiateCall', { + contactId: sender.value?.id, + inboxId: inboxId.value, + conversationId: conversationId.value, + }); + callsStore.addCall({ + callSid: response?.call_sid, + conversationId: response?.conversation_id ?? conversationId.value, + inboxId: inboxId.value, + callDirection: VOICE_CALL_DIRECTION.OUTBOUND, + }); + } catch (error) { + useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED')); + } +}; diff --git a/app/javascript/dashboard/components-next/message/chips/Audio.vue b/app/javascript/dashboard/components-next/message/chips/Audio.vue index 9c7a44b23..ec50d4a62 100644 --- a/app/javascript/dashboard/components-next/message/chips/Audio.vue +++ b/app/javascript/dashboard/components-next/message/chips/Audio.vue @@ -41,8 +41,33 @@ const playbackSpeed = ref(1); const { uid } = getCurrentInstance(); +// MediaRecorder-produced WebM/Opus blobs lack a Duration header →