From e5d66020fdd900299be92e0e0b7717905e6e8af1 Mon Sep 17 00:00:00 2001 From: Sony Mathew Date: Fri, 22 May 2026 14:01:17 +0530 Subject: [PATCH 01/41] chore: made the design for unread-counts more subtle [DESN-43] (#14542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request Template ## Description Made the design for unread-counts more subtle Fixes # DESN-43 ## Type of change Please delete options that are not relevant. - [ ] Bug fix (non-breaking change which fixes an issue) - [x] 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? Tested manually. Here are the screenshots. Light theme: Screenshot 2026-05-22 at 1 28 31 PM Dark theme: Screenshot 2026-05-22 at 1 27 59 PM ## 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 --- .../dashboard/components-next/sidebar/SidebarUnreadBadge.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue b/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue index d9329fdaf..95d95236a 100644 --- a/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue +++ b/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue @@ -19,7 +19,7 @@ const displayCount = computed(() => {{ displayCount }} From b9757447a855d92720f7607419dc1eaa98b33fc3 Mon Sep 17 00:00:00 2001 From: Syed Muhammad Bilal <32861875+sdmhbilal@users.noreply.github.com> Date: Fri, 22 May 2026 15:40:17 +0500 Subject: [PATCH 02/41] fix(openapi): document webhook secret in API schema (#14199) Fixes #13862 Updates the webhook OpenAPI schema to match the current API behavior for webhook secrets and supported subscription events. ## Why Current source already creates per-webhook secrets, returns `secret` from the account webhook API, and uses that secret to sign outbound webhook requests with `X-Chatwoot-Signature`. The OpenAPI schema was behind that contract: - `components.schemas.webhook` did not include the returned `secret` field. - Webhook subscription enums did not include the typing events that are already available in the dashboard webhook form and handled by `WebhookListener`. ## What this change does - Documents `secret` on the webhook response schema. - Documents the outbound webhook signing headers associated with `secret`: `X-Chatwoot-Timestamp`, `X-Chatwoot-Signature`, and `X-Chatwoot-Delivery`. - Adds `conversation_typing_on` and `conversation_typing_off` to webhook subscription enums. - Regenerates the main and tag-group swagger JSON files. ## Validation - Ran `bundle exec rails swagger:build`. - Ran `bundle exec rspec spec/swagger/openapi_spec.rb`. - Verified generated swagger JSON includes `secret`, `conversation_typing_on`, and `conversation_typing_off` in the webhook schemas. --------- Co-authored-by: Syed Muhammad Bilal Co-authored-by: Sojan Jose --- .../request/webhooks/create_update_payload.yml | 2 ++ swagger/definitions/resource/webhook.yml | 8 +++++++- swagger/swagger.json | 13 +++++++++++-- swagger/tag_groups/application_swagger.json | 13 +++++++++++-- swagger/tag_groups/client_swagger.json | 13 +++++++++++-- swagger/tag_groups/other_swagger.json | 13 +++++++++++-- swagger/tag_groups/platform_swagger.json | 13 +++++++++++-- 7 files changed, 64 insertions(+), 11 deletions(-) diff --git a/swagger/definitions/request/webhooks/create_update_payload.yml b/swagger/definitions/request/webhooks/create_update_payload.yml index 485d532e4..d3ad3560c 100644 --- a/swagger/definitions/request/webhooks/create_update_payload.yml +++ b/swagger/definitions/request/webhooks/create_update_payload.yml @@ -21,6 +21,8 @@ properties: 'contact_created', 'contact_updated', 'webwidget_triggered', + 'conversation_typing_on', + 'conversation_typing_off', ] description: The events you want to subscribe to. example: diff --git a/swagger/definitions/resource/webhook.yml b/swagger/definitions/resource/webhook.yml index da5cb112a..a0184afb9 100644 --- a/swagger/definitions/resource/webhook.yml +++ b/swagger/definitions/resource/webhook.yml @@ -21,9 +21,15 @@ properties: "contact_updated", "message_created", "message_updated", - "webwidget_triggered" + "webwidget_triggered", + "conversation_typing_on", + "conversation_typing_off" ] description: The list of subscribed events + secret: + type: string + nullable: true + description: Secret used to sign webhook requests. Signed webhook deliveries include `X-Chatwoot-Timestamp` and `X-Chatwoot-Signature`; the signature is `sha256=` followed by the HMAC-SHA256 of `{timestamp}.{raw_request_body}` using this secret. Deliveries also include `X-Chatwoot-Delivery` when a delivery id is available. account_id: type: number description: The id of the account which the webhook object belongs to diff --git a/swagger/swagger.json b/swagger/swagger.json index b8b64b009..859453d89 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -10316,11 +10316,18 @@ "contact_updated", "message_created", "message_updated", - "webwidget_triggered" + "webwidget_triggered", + "conversation_typing_on", + "conversation_typing_off" ] }, "description": "The list of subscribed events" }, + "secret": { + "type": "string", + "nullable": true, + "description": "Secret used to sign webhook requests. Signed webhook deliveries include `X-Chatwoot-Timestamp` and `X-Chatwoot-Signature`; the signature is `sha256=` followed by the HMAC-SHA256 of `{timestamp}.{raw_request_body}` using this secret. Deliveries also include `X-Chatwoot-Delivery` when a delivery id is available." + }, "account_id": { "type": "number", "description": "The id of the account which the webhook object belongs to" @@ -12340,7 +12347,9 @@ "message_updated", "contact_created", "contact_updated", - "webwidget_triggered" + "webwidget_triggered", + "conversation_typing_on", + "conversation_typing_off" ] }, "description": "The events you want to subscribe to.", diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json index 17e015139..3eb055649 100644 --- a/swagger/tag_groups/application_swagger.json +++ b/swagger/tag_groups/application_swagger.json @@ -8823,11 +8823,18 @@ "contact_updated", "message_created", "message_updated", - "webwidget_triggered" + "webwidget_triggered", + "conversation_typing_on", + "conversation_typing_off" ] }, "description": "The list of subscribed events" }, + "secret": { + "type": "string", + "nullable": true, + "description": "Secret used to sign webhook requests. Signed webhook deliveries include `X-Chatwoot-Timestamp` and `X-Chatwoot-Signature`; the signature is `sha256=` followed by the HMAC-SHA256 of `{timestamp}.{raw_request_body}` using this secret. Deliveries also include `X-Chatwoot-Delivery` when a delivery id is available." + }, "account_id": { "type": "number", "description": "The id of the account which the webhook object belongs to" @@ -10847,7 +10854,9 @@ "message_updated", "contact_created", "contact_updated", - "webwidget_triggered" + "webwidget_triggered", + "conversation_typing_on", + "conversation_typing_off" ] }, "description": "The events you want to subscribe to.", diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json index 7bc7227fb..721345716 100644 --- a/swagger/tag_groups/client_swagger.json +++ b/swagger/tag_groups/client_swagger.json @@ -2088,11 +2088,18 @@ "contact_updated", "message_created", "message_updated", - "webwidget_triggered" + "webwidget_triggered", + "conversation_typing_on", + "conversation_typing_off" ] }, "description": "The list of subscribed events" }, + "secret": { + "type": "string", + "nullable": true, + "description": "Secret used to sign webhook requests. Signed webhook deliveries include `X-Chatwoot-Timestamp` and `X-Chatwoot-Signature`; the signature is `sha256=` followed by the HMAC-SHA256 of `{timestamp}.{raw_request_body}` using this secret. Deliveries also include `X-Chatwoot-Delivery` when a delivery id is available." + }, "account_id": { "type": "number", "description": "The id of the account which the webhook object belongs to" @@ -4112,7 +4119,9 @@ "message_updated", "contact_created", "contact_updated", - "webwidget_triggered" + "webwidget_triggered", + "conversation_typing_on", + "conversation_typing_off" ] }, "description": "The events you want to subscribe to.", diff --git a/swagger/tag_groups/other_swagger.json b/swagger/tag_groups/other_swagger.json index 6dbfbdd8e..0a526b38b 100644 --- a/swagger/tag_groups/other_swagger.json +++ b/swagger/tag_groups/other_swagger.json @@ -1503,11 +1503,18 @@ "contact_updated", "message_created", "message_updated", - "webwidget_triggered" + "webwidget_triggered", + "conversation_typing_on", + "conversation_typing_off" ] }, "description": "The list of subscribed events" }, + "secret": { + "type": "string", + "nullable": true, + "description": "Secret used to sign webhook requests. Signed webhook deliveries include `X-Chatwoot-Timestamp` and `X-Chatwoot-Signature`; the signature is `sha256=` followed by the HMAC-SHA256 of `{timestamp}.{raw_request_body}` using this secret. Deliveries also include `X-Chatwoot-Delivery` when a delivery id is available." + }, "account_id": { "type": "number", "description": "The id of the account which the webhook object belongs to" @@ -3527,7 +3534,9 @@ "message_updated", "contact_created", "contact_updated", - "webwidget_triggered" + "webwidget_triggered", + "conversation_typing_on", + "conversation_typing_off" ] }, "description": "The events you want to subscribe to.", diff --git a/swagger/tag_groups/platform_swagger.json b/swagger/tag_groups/platform_swagger.json index 952813f62..0b2a3f398 100644 --- a/swagger/tag_groups/platform_swagger.json +++ b/swagger/tag_groups/platform_swagger.json @@ -2264,11 +2264,18 @@ "contact_updated", "message_created", "message_updated", - "webwidget_triggered" + "webwidget_triggered", + "conversation_typing_on", + "conversation_typing_off" ] }, "description": "The list of subscribed events" }, + "secret": { + "type": "string", + "nullable": true, + "description": "Secret used to sign webhook requests. Signed webhook deliveries include `X-Chatwoot-Timestamp` and `X-Chatwoot-Signature`; the signature is `sha256=` followed by the HMAC-SHA256 of `{timestamp}.{raw_request_body}` using this secret. Deliveries also include `X-Chatwoot-Delivery` when a delivery id is available." + }, "account_id": { "type": "number", "description": "The id of the account which the webhook object belongs to" @@ -4288,7 +4295,9 @@ "message_updated", "contact_created", "contact_updated", - "webwidget_triggered" + "webwidget_triggered", + "conversation_typing_on", + "conversation_typing_off" ] }, "description": "The events you want to subscribe to.", From c4a6a19e9be899c96fd2c1cbb3454b56b7ef76fc Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Fri, 22 May 2026 18:42:39 +0530 Subject: [PATCH 03/41] =?UTF-8?q?feat(voice):=20WhatsApp=20Cloud=20Calling?= =?UTF-8?q?=20=E2=80=94=20UI=20[6]=20(#14346)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Frontend for WhatsApp Cloud Calling: header / contact-panel call buttons, ringing widget, accept/reject/hangup, mute, in-bubble audio player + transcript, recording-on-hangup upload, mid-call reload warning. WebRTC is browser-direct to Meta — no media server bridge. ## Closes - https://linear.app/chatwoot/issue/PLA-150 ## How to test Requires backend support — the controller, services, model changes, and routes ship in **#14334** (`feature/pla-150`). Merge / deploy that first (or simultaneously); the FE alone won't function without those endpoints. Then on staging, for a WhatsApp Cloud + embedded-signup inbox with the new \`Configuration → Enable voice calling\` toggle ON and webhook registered: 1. **Outbound** — open a conversation, click the phone icon in the conversation header (or contact panel), grant mic, your phone rings, answer, audio both ways, hang up. Recording + transcript land in the bubble within ~10s. 2. **Inbound** — call the business number from your phone. The FloatingCallWidget appears bottom-right with caller name. Click accept, audio both ways, hang up. Recording + transcript appear. 3. **Mute** — during an active WhatsApp call, click the mic icon next to hangup. Speech stops reaching Meta until you click again. 4. **Mid-call reload guard** — try `Cmd-R` during an active call; browser shows a confirm prompt. --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: iamsivin Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- .../api/channel/whatsapp/whatsappCallsAPI.js | 45 ++ app/javascript/dashboard/api/contacts.js | 8 +- app/javascript/dashboard/api/inboxes.js | 8 + .../dashboard/api/specs/contacts.spec.js | 3 +- .../Contacts/VoiceCallButton.vue | 128 ++++- .../Settings/SettingsToggleSection.vue | 6 +- .../components-next/call/CallCard.story.vue | 179 +++++++ .../components-next/call/CallCard.vue | 222 +++++++++ .../call/FloatingCallWidget.vue | 256 ++++++++++ .../components-next/icon/ChannelIcon.vue | 16 +- .../components-next/icon/provider.js | 20 +- .../message/bubbles/VoiceCall.vue | 290 ++++++++--- .../components-next/message/chips/Audio.vue | 30 +- .../components-next/message/constants.js | 13 + .../dashboard/components/ChannelSelector.vue | 20 +- .../components/widgets/ChannelItem.vue | 18 +- .../components/widgets/FloatingCallWidget.vue | 185 ------- .../conversation/ConversationCallButton.vue | 144 ++++++ .../conversation/ConversationHeader.vue | 4 +- .../dashboard/composables/useCallSession.js | 309 +++++++++--- .../composables/useWhatsappCallSession.js | 455 ++++++++++++++++++ .../dashboard/helper/actionCable.js | 81 ++++ app/javascript/dashboard/helper/inbox.js | 9 +- app/javascript/dashboard/helper/voice.js | 83 +++- .../dashboard/i18n/locale/en/contact.json | 2 + .../i18n/locale/en/conversation.json | 20 +- .../dashboard/i18n/locale/en/inboxMgmt.json | 35 +- .../dashboard/routes/dashboard/Dashboard.vue | 2 +- .../conversation/contact/ContactInfo.vue | 14 +- .../settings/inbox/ChannelFactory.vue | 2 + .../dashboard/settings/inbox/ChannelList.vue | 7 + .../dashboard/settings/inbox/Settings.vue | 25 + .../settings/inbox/channels/WhatsappCall.vue | 11 + .../inbox/channels/WhatsappEmbeddedSignup.vue | 32 +- .../settingsPage/WhatsappCallingPage.vue | 160 ++++++ .../store/modules/contacts/actions.js | 8 +- .../store/modules/conversations/actions.js | 13 +- app/javascript/dashboard/stores/calls.js | 50 +- app/models/channel/whatsapp.rb | 46 +- app/policies/inbox_policy.rb | 8 + app/services/whatsapp/facebook_api_client.rb | 10 +- .../whatsapp/webhook_setup_service.rb | 14 +- config/routes.rb | 2 + .../api/v1/accounts/conference_controller.rb | 20 + .../api/v1/accounts/inboxes_controller.rb | 26 + enterprise/app/models/call.rb | 1 + .../providers/whatsapp_cloud_service.rb | 16 + .../app/services/voice/call_status/manager.rb | 3 + .../services/voice/inbound_call_builder.rb | 15 +- .../app/services/whatsapp/call_service.rb | 3 +- .../whatsapp/incoming_call_service.rb | 9 +- 51 files changed, 2687 insertions(+), 399 deletions(-) create mode 100644 app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js create mode 100644 app/javascript/dashboard/components-next/call/CallCard.story.vue create mode 100644 app/javascript/dashboard/components-next/call/CallCard.vue create mode 100644 app/javascript/dashboard/components-next/call/FloatingCallWidget.vue delete mode 100644 app/javascript/dashboard/components/widgets/FloatingCallWidget.vue create mode 100644 app/javascript/dashboard/components/widgets/conversation/ConversationCallButton.vue create mode 100644 app/javascript/dashboard/composables/useWhatsappCallSession.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/WhatsappCallingPage.vue diff --git a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js new file mode 100644 index 000000000..ec24aae34 --- /dev/null +++ b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js @@ -0,0 +1,45 @@ +/* global axios */ +import ApiClient from '../../ApiClient'; + +class WhatsappCallsAPI extends ApiClient { + constructor() { + super('whatsapp_calls', { accountScoped: true }); + } + + show(callId) { + return axios.get(`${this.url}/${callId}`).then(r => r.data); + } + + initiate(conversationId, sdpOffer) { + return axios + .post(`${this.url}/initiate`, { + conversation_id: conversationId, + sdp_offer: sdpOffer, + }) + .then(r => r.data); + } + + accept(callId, sdpAnswer) { + return axios + .post(`${this.url}/${callId}/accept`, { sdp_answer: sdpAnswer }) + .then(r => r.data); + } + + reject(callId) { + return axios.post(`${this.url}/${callId}/reject`).then(r => r.data); + } + + terminate(callId) { + return axios.post(`${this.url}/${callId}/terminate`).then(r => r.data); + } + + uploadRecording(callId, blob, filename = 'call-recording.webm') { + const formData = new FormData(); + formData.append('recording', blob, filename); + return axios + .post(`${this.url}/${callId}/upload_recording`, formData) + .then(r => r.data); + } +} + +export default new WhatsappCallsAPI(); diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index bae5623a7..c39a4cf9d 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -35,8 +35,9 @@ class ContactAPI extends ApiClient { return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data); } - getConversations(contactId) { - return axios.get(`${this.url}/${contactId}/conversations`); + getConversations(contactId, { inboxId } = {}) { + const params = inboxId ? { inbox_id: inboxId } : {}; + return axios.get(`${this.url}/${contactId}/conversations`, { params }); } getContactableInboxes(contactId) { @@ -47,9 +48,10 @@ class ContactAPI extends ApiClient { return axios.get(`${this.url}/${contactId}/labels`); } - initiateCall(contactId, inboxId) { + initiateCall(contactId, inboxId, conversationId = null) { return axios.post(`${this.url}/${contactId}/call`, { inbox_id: inboxId, + conversation_id: conversationId, }); } diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index cc564fe96..114dbb6f4 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -52,6 +52,14 @@ class Inboxes extends CacheEnabledApiClient { resetSecret(inboxId) { return axios.post(`${this.url}/${inboxId}/reset_secret`); } + + enableWhatsappCalling(inboxId) { + return axios.post(`${this.url}/${inboxId}/enable_whatsapp_calling`); + } + + disableWhatsappCalling(inboxId) { + return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`); + } } export default new Inboxes(); diff --git a/app/javascript/dashboard/api/specs/contacts.spec.js b/app/javascript/dashboard/api/specs/contacts.spec.js index b21aeb102..f55ecdfaa 100644 --- a/app/javascript/dashboard/api/specs/contacts.spec.js +++ b/app/javascript/dashboard/api/specs/contacts.spec.js @@ -41,7 +41,8 @@ describe('#ContactsAPI', () => { it('#getConversations', () => { contactAPI.getConversations(1); expect(axiosMock.get).toHaveBeenCalledWith( - '/api/v1/contacts/1/conversations' + '/api/v1/contacts/1/conversations', + { params: {} } ); }); diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue index b258dc763..7e2b6f0c4 100644 --- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue +++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue @@ -3,10 +3,20 @@ import { computed, ref, useAttrs } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute, useRouter } from 'vue-router'; import { useMapGetter, useStore } from 'dashboard/composables/store'; -import { isVoiceCallEnabled } from 'dashboard/helper/inbox'; +import { + isVoiceCallEnabled, + getVoiceCallProvider, + VOICE_CALL_PROVIDERS, +} from 'dashboard/helper/inbox'; +import { + VOICE_CALL_DIRECTION, + VOICE_CALL_OUTBOUND_INIT_STATUS, +} from 'dashboard/components-next/message/constants'; import { useAlert } from 'dashboard/composables'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; import { useCallsStore } from 'dashboard/stores/calls'; +import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession'; +import ContactAPI from 'dashboard/api/contacts'; import Button from 'dashboard/components-next/button/Button.vue'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; @@ -14,6 +24,9 @@ import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; const props = defineProps({ phone: { type: String, default: '' }, contactId: { type: [String, Number], required: true }, + // When set, the WhatsApp call continues in this conversation (matching the + // header button) instead of looking up the contact's most recent one. + conversationId: { type: [String, Number], default: null }, label: { type: String, default: '' }, icon: { type: [String, Object, Function], default: '' }, size: { type: String, default: 'sm' }, @@ -30,6 +43,7 @@ const { t } = useI18n(); const dialogRef = ref(null); +const callsStore = useCallsStore(); const inboxesList = useMapGetter('inboxes/getInboxes'); const contactsUiFlags = useMapGetter('contacts/getUIFlags'); @@ -38,13 +52,22 @@ const voiceInboxes = computed(() => ); const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0); -// Unified behavior: hide when no phone const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone); const isInitiatingCall = computed(() => { return contactsUiFlags.value?.isInitiatingCall || false; }); +// Mirror the conversation-header button: block a new call whenever any provider +// call is already active or ringing, otherwise starting a WhatsApp call here +// would leave a still-live Twilio (or other) session with no visible control. +const isCallButtonDisabled = computed( + () => + callsStore.hasActiveCall || + callsStore.hasIncomingCall || + isInitiatingCall.value +); + const navigateToConversation = conversationId => { const accountId = route.params.accountId; if (conversationId && accountId) { @@ -58,23 +81,96 @@ const navigateToConversation = conversationId => { } }; -const startCall = async inboxId => { - if (isInitiatingCall.value) return; +const whatsappCallSession = useWhatsappCallSession(); + +// Find the most recent open conversation for this contact in the picked inbox. +// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path). +// Pass inboxId so the BE applies the filter before the 20-row cap — without it, +// contacts whose latest WhatsApp conversation falls outside the 20 most recent +// across all inboxes would be treated as having no conversation. +const findWhatsappConversationId = async inboxId => { + const { data } = await ContactAPI.getConversations(props.contactId, { + inboxId, + }); + const conversations = data?.payload || []; + const match = [...conversations].sort( + (a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0) + )[0]; + return match?.id || null; +}; + +const startWhatsappCall = async (inboxId, conversationIdHint) => { + // WhatsApp /initiate is conversation-scoped, so we must hand it a + // conversation. Use the caller's hint when given (in-conversation flow); + // otherwise pick the most recent one in the inbox. + const conversationId = + conversationIdHint || (await findWhatsappConversationId(inboxId)); + if (!conversationId) { + useAlert(t('CONTACT_PANEL.CALL_FAILED')); + return; + } + + const response = + await whatsappCallSession.initiateOutboundCall(conversationId); + // The composable returns { status: 'locked' } when an init is already in + // flight or a call is already active; treat that as a soft no-op rather than + // claiming success. + if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return; + if (!response?.id) { + // Permission template path returns no call id. Mirror the header button and + // surface whether the request was just sent or is already pending instead of + // claiming the call started. The permission message lands in the + // conversation, so still navigate there. + const messageKey = + response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING + ? 'CONTACT_PANEL.WHATSAPP_CALL_PERMISSION_PENDING' + : 'CONTACT_PANEL.WHATSAPP_CALL_PERMISSION_REQUESTED'; + useAlert(t(messageKey)); + navigateToConversation(conversationId); + return; + } + + // Stay non-active until the connect cable event arrives — flipping to active + // here would start the duration timer before the contact picks up. + callsStore.addCall({ + callSid: response.call_id, + callId: response.id, + conversationId, + inboxId, + callDirection: VOICE_CALL_DIRECTION.OUTBOUND, + provider: VOICE_CALL_PROVIDERS.WHATSAPP, + }); + + useAlert(t('CONTACT_PANEL.CALL_INITIATED')); + navigateToConversation(conversationId); +}; + +const startCall = async (inboxId, conversationIdHint = null) => { + if (isCallButtonDisabled.value) return; + + const inbox = (inboxesList.value || []).find(i => i.id === inboxId); + if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) { + try { + await startWhatsappCall(inboxId, conversationIdHint); + } catch (error) { + useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED')); + } + return; + } try { const response = await store.dispatch('contacts/initiateCall', { contactId: props.contactId, inboxId, + conversationId: conversationIdHint, }); const { call_sid: callSid, conversation_id: conversationId } = response; - // Add call to store immediately so widget shows - const callsStore = useCallsStore(); callsStore.addCall({ callSid, conversationId, inboxId, - callDirection: 'outbound', + callDirection: VOICE_CALL_DIRECTION.OUTBOUND, }); useAlert(t('CONTACT_PANEL.CALL_INITIATED')); @@ -86,6 +182,22 @@ const startCall = async inboxId => { }; const onClick = async () => { + // In conversation context, only stay in this conversation if its inbox is + // itself voice-capable (works the same for Twilio and WhatsApp). For + // non-voice channels (email, web, …) fall back to the picker so the call + // goes out via a voice inbox. + if (props.conversationId) { + const conversation = store.getters.getConversationById( + props.conversationId + ); + const conversationInbox = (inboxesList.value || []).find( + i => i.id === conversation?.inbox_id + ); + if (conversationInbox && isVoiceCallEnabled(conversationInbox)) { + await startCall(conversationInbox.id, props.conversationId); + return; + } + } if (voiceInboxes.value.length > 1) { dialogRef.value?.open(); return; @@ -106,7 +218,7 @@ const onPickInbox = async inbox => { v-if="shouldRender" v-tooltip.top-end="tooltipLabel || null" v-bind="attrs" - :disabled="isInitiatingCall" + :disabled="isCallButtonDisabled" :is-loading="isInitiatingCall" :label="label" :icon="icon" diff --git a/app/javascript/dashboard/components-next/Settings/SettingsToggleSection.vue b/app/javascript/dashboard/components-next/Settings/SettingsToggleSection.vue index ab3f8488e..332b91066 100644 --- a/app/javascript/dashboard/components-next/Settings/SettingsToggleSection.vue +++ b/app/javascript/dashboard/components-next/Settings/SettingsToggleSection.vue @@ -32,7 +32,11 @@ const modelValue = defineModel({ type: Boolean, default: false }); {{ header }} -
+
diff --git a/app/javascript/dashboard/components-next/call/CallCard.story.vue b/app/javascript/dashboard/components-next/call/CallCard.story.vue new file mode 100644 index 000000000..0f9ba4660 --- /dev/null +++ b/app/javascript/dashboard/components-next/call/CallCard.story.vue @@ -0,0 +1,179 @@ + + + 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 →