Compare commits

..
Author SHA1 Message Date
Sojan JoseandGitHub 64df27d8c9 Update .gitignore 2025-07-09 03:21:16 -07:00
Sojan Jose bf4a596726 Merge develop into feat/voice-channel
- Resolved merge conflicts in Vue components
- Updated color scheme to use new n-slate tokens
- Preserved voice channel functionality including isPrivateNoteOnly
- Removed files that were deleted in develop
- Fixed ESLint errors in App.vue
- Fixed SCSS imports in CollaboratorsPage.vue
- Fixed duplicate voice channel entries in ChannelList and ChannelItem
- Moved voice channel to end of channel list
- Integrated with new component structure
2025-07-09 02:42:18 -07:00
Sivin VargheseandGitHub 3e5ca9bca9 fix: Widget message input resize issue (#11896) 2025-07-09 09:23:20 +05:30
5ebe8c71ec feat: Support customizable welcome text, availability messages, and UI toggles (#11891)
# Pull Request Template

## Description

This PR allows users to dynamically pass custom welcome and availability
messages, along with UI feature toggles, via `window.chatwootSettings`.
If any of the following settings are provided, the widget will use them;
otherwise, it falls back to default behavior.

**New options:**
```
window.chatwootSettings = {
  welcomeTitle: 'Need help?',                        // Custom widget title
  welcomeDescription: 'We’re here to support you.',        // Subtitle in the header
  availableMessage: 'We’re online and ready to chat!', // Shown when team is online
  unavailableMessage: 'We’re currently offline.',      // Shown when team is unavailable

  enableFileUpload: true,          // Enable file attachments
  enableEmojiPicker: true,         // Enable emoji picker in chat input
  enableEndConversation: true     // Allow users to end the conversation
}
```


Fixes
https://linear.app/chatwoot/issue/CW-4589/add-options-to-windowchatwootsettings

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/413fc4aa59384366b071450bd19d1bf8?sid=ff30fb4c-267c-4beb-80ab-d6f583aa960d

## 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: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-08 14:26:00 -07:00
Sivin VargheseandGitHub 8c78573d9d chore: Remove defer attribute from widget-loader script (#11887) 2025-07-08 15:31:33 +05:30
c553997af8 fix: Translate Priority and Messages types in Automations and Macros (#11741)
# Pull Request Template

## Description

With these fixes, I could improve some translations in portuguese, and
also I added some improvements to make some drowpdown values, that were
not translatable into translatable strings, into the Automations, Macros
and Custom Attributes page. I also fixed some typos.

Here are the main improvements.

- ~Fixed typo in portuguese into `Reports > Agents` page:~
~Before:

![image](https://github.com/user-attachments/assets/5a911108-76a6-4e54-82f1-1185c3e1981b)
After:

![image](https://github.com/user-attachments/assets/2a4fc5bf-3239-47b5-9113-ba66e0f44b9c)~

- Added the possibility to make the `Priority` and `Message types`
translatables in other languages, into Macros and Automations page. Also
added the same feature for Custom attributes page at `applies to` and
`type` fields:
Before:

![image](https://github.com/user-attachments/assets/d53b3e6d-be3e-4e02-9478-7d3121cc11cb)

![image](https://github.com/user-attachments/assets/28a7bffe-fe8b-43f6-8e30-9d62ab8adf14)

![image](https://github.com/user-attachments/assets/18a983c1-2db8-445d-a4cc-982beb88015a)

![image](https://github.com/user-attachments/assets/2be8b0f2-ed9e-4bac-aeb0-596533200da4)
After:

![image](https://github.com/user-attachments/assets/0fa904ae-7b48-4ce4-afb8-e0586c5624b7)

![image](https://github.com/user-attachments/assets/a524f119-9222-4e98-9cd7-2fca3303e8d5)

![image](https://github.com/user-attachments/assets/7062f277-e9c9-4473-980b-6ca2d6bdcefc)

![image](https://github.com/user-attachments/assets/0bb66f07-ee10-4833-b950-b9aa9441a312)

- ~Improve Bots page. In the Brazilian portuguese is very common and
widely used bots to refer to chatbots, using `robô` as a direct
translations sounds weird, `robô` is used more often when we are talking
about robots, not chatbots.
Before:

![image](https://github.com/user-attachments/assets/3966cc11-51f4-4e1a-bc81-ada2795408e8)
After:

![image](https://github.com/user-attachments/assets/c9ec0ad5-974e-40d9-9542-031db99839e2)~

- Added multiselect both `no options` and `Select` placeholder
translatable strings:
Before:

![image](https://github.com/user-attachments/assets/545641d4-87ae-4305-8adc-3b73bbaf2ab1)

![image](https://github.com/user-attachments/assets/8800c001-abe7-41bb-bd68-feb5db13b8f0)
After:

![image](https://github.com/user-attachments/assets/46748652-28f2-4ae3-9d20-55db1015aaae)

- Added `.pnpm-store` to `.gitignore`, when I'm using docker, the pnpm
always creates this folder into my root directory, so I imagine the same
could happens with others, so I fixed it.


## Type of change

Please delete options that are not relevant.

- [x] 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?

I've added some translations for my language (brazilian portuguese), so
i just switched the languages between the original in EN to PT_BR.

## 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
- [ ] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-08 12:40:40 +05:30
Shivam MishraandGitHub 30a3a35281 feat: remove colon and semicolons when sanitizing inbox name (#11889) 2025-07-08 09:41:40 +05:30
Chatwoot BotandGitHub e4ba80e2f0 chore: Update translations (#11893) 2025-07-07 14:34:25 -07:00
b72848513f fix: Show billing upgrade page if there is a mismatch in the user count (#11886)
Disable features/show billing upgrade for accounts with more users than
the one in the license.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-06 19:30:27 -07:00
Sojan JoseandGitHub bb1a4fc466 Merge branch 'develop' into feat/voice-channel 2025-06-26 22:13:17 -07:00
SojanandClaude 4343ebde59 fix: restore missing functionality to enterprise voice model
Add back critical methods that were missing from enterprise voice model:
- initiate_call: Core functionality for making calls
- initiate_twilio_call: Twilio-specific call logic with conference support
- twilio_client: Twilio client initialization
- provider_config_hash: Config parsing utilities
- Rails routes inclusion for URL helpers

This ensures the enterprise model has complete functionality from the original.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-25 16:21:09 -07:00
SojanandClaude 9d95576f21 chore: remove redundant voice model
Remove app/models/channel/voice.rb as we now use the enterprise version from develop branch after merge.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-25 16:16:06 -07:00
SojanandClaude 3c7c460fb9 chore: remove duplicate voice channel migration
- Remove db/migrate/20250426130000_create_channel_voice.rb (older duplicate)
- Keep db/migrate/20250620120000_create_channel_voice.rb from develop

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-25 16:11:25 -07:00
SojanandClaude bd84d1b17e Merge develop into feat/voice-channel
Resolved conflicts:
- Voice.vue: Keep modern composition API version from develop
- ChannelItem.vue: Add voice channel feature flag check
- inboxes_controller.rb: Use allowed_channel_types method
- schema.rb: Add account_id index for voice channels
- inboxMgmt.json: Fix voice channel translations
- Note: Voice channel logic in base files removed in develop (moved to enterprise)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-25 16:04:27 -07:00
Sojan bca048fc69 chore: add phone number 2025-06-20 01:14:32 -07:00
Sojan 43b952d486 chore: clean up 2025-06-20 00:13:20 -07:00
Sojan e58f60c27b chore: changes 2025-06-20 00:09:05 -07:00
Sojan 759fe0d3f6 Merge develop into feat/voice-channel
Resolved merge conflicts in:
- MessagesView.vue: Combined VoiceTimelineView component from voice channel feature with component list updates from develop
- actionCable.js: Merged call event handlers (incoming_call, call_status_changed) with copilot message event handler from develop
- message.rb: Combined voice call helper methods with send_update_event method from develop

All conflicts resolved while preserving functionality from both branches.
2025-06-20 00:02:50 -07:00
Sojan JoseandGitHub ce6489b485 Merge branch 'develop' into feat/voice-channel 2025-05-27 16:49:02 -07:00
Sojan b8187ed8a7 chore: transcription recording now working 2025-05-13 20:19:41 -07:00
Sojan b3af194894 chore: more clean up 2025-05-13 16:52:38 -07:00
Sojan 1e9180d3cd chore: disable read status for voice channel 2025-05-13 04:05:01 -07:00
Sojan e06525181b chore: Clean up and add transcriptions 2025-05-13 03:50:41 -07:00
Sojan 3d29962969 chore: clean up 2025-05-11 15:57:17 -07:00
Sojan 7328e636ac Merge branch 'develop' into feat/voice-channel
# Conflicts:
#	db/schema.rb
2025-05-11 01:52:56 -07:00
Sojan e2e68868d5 chore: clean code 2025-05-11 01:49:24 -07:00
Sojan 670cf689f5 chore: clean up twilio tokens controller 2025-05-11 01:25:40 -07:00
Sojan ccdbc2c7f9 chore: new call button 2025-05-11 00:35:32 -07:00
Sojan aa4ef28e0e chore: fixes 2025-05-09 20:47:03 -07:00
Sojan 2879a0cd42 chore: new floating widget design 2025-05-08 03:42:47 -07:00
Sojan ce468bac01 chore: more fixes 2025-05-07 22:00:39 -07:00
Sojan 2b0c154bc5 chore: dummy reports page 2025-05-07 03:47:34 -07:00
Sojan ebabb69048 chore: call routing dummy setting 2025-05-07 03:10:46 -07:00
Sojan 5b77618a43 chore: voice debug component 2025-05-07 03:04:51 -07:00
Sojan d6dd8efe46 chore: add sender option for bot campaigns 2025-05-06 00:46:55 -07:00
Sojan 74cd639574 chore: voice call campaigns 2025-05-05 23:50:34 -07:00
Sojan 414daff4f1 chore: minor fixes 2025-05-04 21:05:19 -07:00
Sojan 4e8a39f358 chore: fix conversation list status 2025-05-04 18:07:16 -07:00
Sojan 5dc1735f69 chore: random clean up 2025-05-04 09:17:03 -07:00
Sojan 2f7c8f6cfc chore: Clean up some code andn simplify 2025-05-04 08:47:14 -07:00
Sojan e8d3679aba chore: stop auto resoluving voice conversations 2025-05-04 07:36:25 -07:00
Sojan c9daf70655 chore: message previews in conversation list 2025-05-04 06:02:51 -07:00
Sojan 4ea22f7f36 chore: voice call components working correctly 2025-05-04 05:08:55 -07:00
Sojan 4348c4ab87 chore: clean up voice message components 2025-05-04 05:00:20 -07:00
Sojan 196d7afccf Merge branch 'develop' into feat/voice-channel 2025-05-03 02:10:18 -07:00
Sojan 4c48a565f6 chore: code cleanup 2025-05-03 02:09:34 -07:00
Sojan 3692cde1a9 chore: inbound and outbound calls that work 2025-05-02 02:41:38 -07:00
Sojan 3f0c01e166 chore: floating button for incoming call 2025-04-29 03:45:08 -07:00
Sojan 4c579bc71e fix: incoming call functionality in voice webhooks controller - Fix polymorphic association query for finding voice inbox - Fix contact and conversation creation with proper associations - Remove unnecessary verify_authenticity_token skip - Ensure proper validation and error handling for contact creation 2025-04-28 02:16:57 -07:00
Sojan a7ff808d01 chore: floating call button 2025-04-28 01:08:03 -07:00
Sojan 8d660df4c4 feat: Add voice channel support for Chatwoot 2025-04-27 00:48:05 -07:00
536 changed files with 15881 additions and 1731 deletions
+1 -3
View File
@@ -89,7 +89,7 @@ gem 'wisper', '2.0.0'
##--- gems for channels ---##
gem 'facebook-messenger'
gem 'line-bot-api'
gem 'twilio-ruby', '~> 5.66'
gem 'twilio-ruby'
# twitty will handle subscription of twitter account events
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
gem 'twitty', '~> 0.1.5'
@@ -180,8 +180,6 @@ gem 'ruby-openai'
gem 'shopify_api'
gem 'ai-agents', '>= 0.2.1'
### Gems required only in specific deployment environments ###
##############################################################
+10 -20
View File
@@ -126,8 +126,6 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.2.1)
ruby_llm (~> 1.3)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
@@ -248,8 +246,10 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.9.0)
faraday-net_http (>= 2.0, < 3.2)
faraday (2.13.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-mashify (0.1.1)
@@ -257,8 +257,8 @@ GEM
hashie
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (3.1.0)
net-http
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
@@ -416,7 +416,7 @@ GEM
judoscale-sidekiq (1.8.2)
judoscale-ruby (= 1.8.2)
sidekiq (>= 5.0)
jwt (2.8.1)
jwt (2.10.1)
base64
kaminari (1.2.2)
activesupport (>= 4.1.0)
@@ -488,7 +488,7 @@ GEM
mutex_m (0.3.0)
neighbor (0.2.3)
activerecord (>= 5.2)
net-http (0.4.1)
net-http (0.6.0)
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
@@ -719,15 +719,6 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.3.1)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
faraday-multipart (>= 1)
faraday-net_http (>= 1)
faraday-retry (>= 1)
marcel (~> 1.0)
zeitwerk (~> 2)
ruby_parser (3.20.0)
sexp_processor (~> 4.16)
sass (3.7.4)
@@ -830,7 +821,7 @@ GEM
i18n
timeout (0.4.3)
trailblazer-option (0.1.2)
twilio-ruby (5.77.0)
twilio-ruby (7.6.0)
faraday (>= 0.9, < 3.0)
jwt (>= 1.5, < 3.0)
nokogiri (>= 1.6, < 2.0)
@@ -906,7 +897,6 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.2.1)
annotate
attr_extras
audited (~> 5.4, >= 5.4.1)
@@ -1020,7 +1010,7 @@ DEPENDENCIES
telephone_number
test-prof
time_diff
twilio-ruby (~> 5.66)
twilio-ruby
twitty (~> 0.1.5)
tzinfo-data
uglifier
+9 -2
View File
@@ -21,6 +21,8 @@ class ContactInboxBuilder
email_source_id
when 'Channel::Sms'
phone_source_id
when 'Channel::Voice'
phone_source_id # Voice uses phone number as source ID
when 'Channel::Api', 'Channel::WebWidget'
SecureRandom.uuid
else
@@ -35,7 +37,12 @@ class ContactInboxBuilder
end
def phone_source_id
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
unless @contact.phone_number.present?
# For voice channels, we'll create a fallback source ID if phone number is missing
return SecureRandom.uuid if @inbox.channel_type == 'Channel::Voice'
raise ActionController::ParameterMissing, 'contact phone number'
end
@contact.phone_number
end
@@ -100,6 +107,6 @@ class ContactInboxBuilder
end
def allowed_channels?
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp? || @inbox.channel_type == 'Channel::Voice'
end
end
+13 -10
View File
@@ -7,7 +7,7 @@ class Messages::MessageBuilder
@private = params[:private] || false
@conversation = conversation
@user = user
@message_type = params[:message_type] || 'outgoing'
@message_type = params[:message_type].to_s || 'outgoing'
@attachments = params[:attachments]
@automation_rule = content_attributes&.dig(:automation_rule_id)
return unless params.instance_of?(ActionController::Parameters)
@@ -33,11 +33,6 @@ class Messages::MessageBuilder
def content_attributes
params = convert_to_hash(@params)
content_attributes = params.fetch(:content_attributes, {})
return parse_json(content_attributes) if content_attributes.is_a?(String)
return content_attributes if content_attributes.is_a?(Hash)
{}
end
# Converts the given object to a hash.
@@ -105,8 +100,9 @@ class Messages::MessageBuilder
end
def message_type
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
# Allow incoming messages in both API and Voice channels
if !['Channel::Api', 'Channel::Voice'].include?(@conversation.inbox.channel_type) && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api and Voice inboxes'
end
@message_type
@@ -139,7 +135,7 @@ class Messages::MessageBuilder
end
def message_params
{
message_attrs = {
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: message_type,
@@ -152,5 +148,12 @@ class Messages::MessageBuilder
echo_id: @params[:echo_id],
source_id: @params[:source_id]
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
# Directly add content_attributes from params if present
if @params[:content_attributes].present?
message_attrs[:content_attributes] = content_attributes
end
message_attrs
end
end
end
@@ -0,0 +1,152 @@
class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts::BaseController
skip_before_action :authenticate_user!, :set_current_user, only: [:incoming, :conference_status]
protect_from_forgery with: :null_session, only: [:incoming, :conference_status]
before_action :validate_twilio_signature, only: [:incoming]
before_action :handle_options_request, only: [:incoming, :conference_status]
# Handle CORS preflight OPTIONS requests
def handle_options_request
if request.method == "OPTIONS"
set_cors_headers
head :ok
return true
end
false
end
def set_cors_headers
headers['Access-Control-Allow-Origin'] = '*'
headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS'
headers['Access-Control-Allow-Headers'] = 'Content-Type, X-Twilio-Signature'
headers['Access-Control-Max-Age'] = '86400' # 24 hours
end
# Handle incoming calls from Twilio
def incoming
# Set CORS headers first to ensure they're included
set_cors_headers
# Log basic request info
Rails.logger.info("🔔 INCOMING CALL WEBHOOK: CallSid=#{params['CallSid']} From=#{params['From']} To=#{params['To']}")
# Process incoming call using service
begin
# Ensure account is set properly
if !Current.account && params[:account_id].present?
Current.account = Account.find(params[:account_id])
Rails.logger.info("👑 Set Current.account to #{Current.account.id}")
end
# Validate required parameters
validate_incoming_params
# Process the call
service = Voice::IncomingCallService.new(
account: Current.account,
params: params.to_unsafe_h.merge(host_with_port: request.host_with_port)
)
twiml_response = service.process
# Return TwiML response
Rails.logger.info("✅ INCOMING CALL: Successfully processed")
render xml: twiml_response
rescue StandardError => e
# Log the error with detailed information
Rails.logger.error("❌ INCOMING CALL ERROR: #{e.message}")
Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..5].join("\n")}")
# Return friendly error message to caller
render_error("We're sorry, but we're experiencing technical difficulties. Please try your call again later.")
end
end
# Handle conference status updates
def conference_status
# Set CORS headers first to ensure they're always included
set_cors_headers
# Return immediately for OPTIONS requests
if request.method == "OPTIONS"
return head :ok
end
# Log basic request info
Rails.logger.info("🎧 CONFERENCE STATUS WEBHOOK: ConferenceSid=#{params['ConferenceSid']} Event=#{params['StatusCallbackEvent']}")
# Process conference status updates using service
begin
# Set account for local development if needed
if !Current.account && params[:account_id].present?
Current.account = Account.find(params[:account_id])
Rails.logger.info("👑 Set Current.account to #{Current.account.id}")
end
# Validate required parameters
if params['ConferenceSid'].blank? && params['CallSid'].blank?
Rails.logger.error("❌ MISSING REQUIRED PARAMS: Need either ConferenceSid or CallSid")
end
# Use service to process conference status
service = Voice::ConferenceStatusService.new(account: Current.account, params: params)
service.process
Rails.logger.info("✅ CONFERENCE STATUS: Successfully processed")
rescue StandardError => e
# Log errors but don't affect the response
Rails.logger.error("❌ CONFERENCE STATUS ERROR: #{e.message}")
Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..5].join("\n")}")
end
# Always return a successful response for Twilio
head :ok
end
private
def validate_incoming_params
if params['CallSid'].blank?
raise "Missing required parameter: CallSid"
end
if params['From'].blank?
raise "Missing required parameter: From"
end
if params['To'].blank?
raise "Missing required parameter: To"
end
if Current.account.nil?
raise "Current account not set"
end
end
def validate_twilio_signature
begin
validator = Voice::TwilioValidatorService.new(
account: Current.account,
params: params,
request: request
)
if !validator.valid?
Rails.logger.error("❌ INVALID TWILIO SIGNATURE")
render_error('Invalid Twilio signature')
return false
end
return true
rescue StandardError => e
Rails.logger.error("❌ TWILIO VALIDATION ERROR: #{e.message}")
render_error('Error validating Twilio request')
return false
end
end
def render_error(message)
response = Twilio::TwiML::VoiceResponse.new
response.say(message: message)
response.hangup
render xml: response.to_s
end
end
@@ -0,0 +1,39 @@
class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseController
before_action :fetch_contact
def create
# Validate that contact has a phone number
if @contact.phone_number.blank?
render json: { error: 'Contact has no phone number' }, status: :unprocessable_entity
return
end
begin
# Use the outgoing call service to handle the entire process
service = Voice::OutgoingCallService.new(
account: Current.account,
contact: @contact,
user: Current.user
)
# Process the call - this handles all the steps
conversation = service.process
# Assign to @conversation so jbuilder template can access it
@conversation = conversation
# Use the conversation jbuilder template to ensure consistent representation
# This will ensure only display_id is used as the id, not the internal database id
render 'api/v1/accounts/conversations/show'
rescue StandardError => e
Rails.logger.error("Error initiating call: #{e.message}")
render json: { error: e.message }, status: :unprocessable_entity
end
end
private
def fetch_contact
@contact = Current.account.contacts.find(params[:contact_id])
end
end
@@ -163,7 +163,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
'line' => Channel::Line,
'telegram' => Channel::Telegram,
'whatsapp' => Channel::Whatsapp,
'sms' => Channel::Sms
'sms' => Channel::Sms,
'voice' => Channel::Voice
}[permitted_params[:channel][:type]]
end
@@ -0,0 +1,80 @@
class Api::V1::Accounts::Voice::TokensController < Api::V1::Accounts::BaseController
before_action :set_voice_inbox
def create
render json: build_response
rescue StandardError => e
Rails.logger.error("Voice::TokensController#create: #{e.class} - #{e.message}\n#{e.backtrace.first(5).join("\n")}")
render json: { error: 'Failed to generate token', details: e.message }, status: :internal_server_error
end
private
def build_response
{
token: twilio_token.to_jwt,
identity: client_identity,
voice_enabled: true,
account_sid: twilio_config[:account_sid],
agent_id: Current.user.id,
account_id: Current.account.id,
inbox_id: @voice_inbox.id,
phone_number: twilio_config[:phone_number],
twiml_endpoint: twilio_config[:twiml_url],
has_twiml_app: twilio_config[:outgoing_app_sid].present?
}
end
def twilio_token
Twilio::JWT::AccessToken.new(
*twilio_credentials,
identity: client_identity,
ttl: 1.hour.to_i
).tap { |t| t.add_grant(voice_grant) }
end
def twilio_credentials
twilio_config.values_at(:account_sid, :api_key_sid, :api_key_secret)
end
def voice_grant
Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant|
grant.incoming_allow = true
grant.outgoing_application_sid = twilio_config[:outgoing_app_sid]
grant.outgoing_application_params = outgoing_params
end
end
def outgoing_params
{
account_id: Current.account.id,
agent_id: Current.user.id,
identity: client_identity,
client_name: client_identity,
accountSid: twilio_config[:account_sid],
is_agent: 'true'
}
end
def twilio_config
@twilio_config ||= begin
cfg = @voice_inbox.channel.provider_config_hash || {}
{
account_sid: cfg['account_sid'],
api_key_sid: cfg['api_key_sid'],
api_key_secret: cfg['api_key_secret'],
outgoing_app_sid: cfg['outgoing_application_sid'],
phone_number: @voice_inbox.channel.phone_number,
twiml_url: "#{ENV.fetch('FRONTEND_URL', '')}/api/v1/accounts/#{Current.account.id}/voice/twiml_for_client"
}.with_indifferent_access.merge(client_identity:)
end
end
def client_identity
@client_identity ||= "agent-#{Current.user.id}-account-#{Current.account.id}"
end
def set_voice_inbox
@voice_inbox = Current.account.inboxes.find(params[:inbox_id])
end
end
@@ -0,0 +1,230 @@
require 'twilio-ruby'
class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: %i[end_call join_call reject_call]
skip_before_action :authenticate_user!, only: :twiml_for_client
protect_from_forgery with: :null_session, only: :twiml_for_client
before_action :render_options, if: -> { request.options? }
after_action :set_cors_headers, if: -> { action_name == 'twiml_for_client' }
# ---------- PUBLIC ACTIONS --------------------------------------------------
def end_call
call_sid = params[:call_sid] || convo_attr('call_sid')
return render_not_found('active call') unless call_sid
twilio_client.calls(call_sid).update(status: 'completed') if in_progress?(call_sid)
Voice::CallStatus::Manager.new(conversation: @conversation,
call_sid: call_sid,
provider: :twilio)
.process_status_update('completed', nil, false, "Call ended by #{current_user.name}")
broadcast_status(call_sid, 'completed')
render_success('Call successfully ended')
rescue StandardError => e
render_error("Failed to end call: #{e.message}")
end
def join_call
call_sid = params[:call_sid] || convo_attr('call_sid')
outbound = convo_attr('requires_agent_join') == true
return render_not_found('active call') unless call_sid || outbound
conference_sid = convo_attr('conference_sid') || create_conference_sid!
update_join_metadata!(call_sid)
broadcast_status(call_sid, 'in-progress')
render json: {
status: 'success',
message: 'Agent joining call via WebRTC',
conference_sid: conference_sid,
using_webrtc: true,
conversation_id: @conversation.display_id,
account_id: Current.account.id
}
rescue StandardError => e
render_error("Failed to join call: #{e.message}")
end
def reject_call
call_sid = params[:call_sid] || convo_attr('call_sid')
return render_not_found('active call') unless call_sid
@conversation.update!(additional_attributes: convo_attrs.merge(
'agent_rejected' => true,
'rejected_at' => Time.current.to_i,
'rejected_by' => user_meta
))
Voice::CallStatus::Manager.new(conversation: @conversation,
call_sid: call_sid,
provider: :twilio)
.create_activity_message("#{current_user.name} declined to answer",
rejected_by: current_user.name,
rejected_at: Time.current.to_i)
render_success('Call rejected by agent')
end
def call_status
call_sid = params[:call_sid]
return render_not_found('active call') unless call_sid
call = twilio_client.calls(call_sid).fetch
render json: call.slice(:status, :duration, :direction, :from, :to, :start_time, :end_time)
rescue StandardError => e
render_error("Failed to fetch call status: #{e.message}")
end
# TwiML for agent WebRTC dialin
def twiml_for_client
to = params[:To] || params[:to]
return render_twiml_error('Missing conference ID parameter') if to.blank?
render xml: build_twiml(to), content_type: 'text/xml'
rescue StandardError => e
render_twiml_error(e.message)
end
# ---------- PRIVATE ---------------------------------------------------------
private
# ---- Helpers ---------------------------------------------------------------
def render_options
head :ok
end
def set_cors_headers
headers['Content-Type'] ||= 'text/xml; charset=utf-8'
headers['Access-Control-Allow-Origin'] = '*'
headers['Access-Control-Allow-Methods'] = 'POST, GET, OPTIONS'
headers['Access-Control-Allow-Headers'] = 'Content-Type, X-Twilio-Signature'
headers['Access-Control-Max-Age'] = '86400'
end
def render_success(msg) = render json: { status: 'success', message: msg }
def render_not_found(resource) = render json: { error: "No #{resource} found" }, status: :not_found
def render_error(msg) = render json: { error: msg }, status: :internal_server_error
def fetch_conversation
@conversation = Current.account.conversations.find_by(display_id: params[:conversation_id])
end
def twilio_client
@twilio_client ||= begin
cfg = @conversation.inbox.channel.provider_config_hash
Twilio::REST::Client.new(cfg['account_sid'], cfg['auth_token'])
end
end
def in_progress?(call_sid)
%w[in-progress ringing].include?(twilio_client.calls(call_sid).fetch.status)
end
def convo_attrs
@conversation.additional_attributes || {}
end
def convo_attr(key)
convo_attrs[key]
end
def user_meta
{ id: current_user.id, name: current_user.name }
end
def create_conference_sid!
sid = "conf_account_#{Current.account.id}_conv_#{@conversation.display_id}"
@conversation.update!(additional_attributes: convo_attrs.merge('conference_sid' => sid))
sid
end
def update_join_metadata!(call_sid)
@conversation.update!(additional_attributes: convo_attrs.merge(
'agent_joined' => true,
'joined_at' => Time.current.to_i,
'joined_by' => user_meta,
'call_status' => 'in-progress'
))
Voice::CallStatus::Manager.new(conversation: @conversation,
call_sid: call_sid,
provider: :twilio)
.process_status_update('in-progress', nil, false, "#{current_user.name} joined the call")
end
def broadcast_status(call_sid, status)
ActionCable.server.broadcast "account_#{@conversation.account_id}", {
event_name: 'call_status_changed',
data: {
call_sid: call_sid,
status: status,
conversation_id: @conversation.display_id,
inbox_id: @conversation.inbox_id,
timestamp: Time.current.to_i
}
}
end
# ---- TwiML -----------------------------------------------------------------
def build_twiml(conference_name)
# For agent legs, we need to add transcription too
account_id = params[:account_id] || Current.account&.id
agent_id = params[:agent_id] || current_user&.id
transcription_url = "#{base_url}/twilio/transcription_callback?account_id=#{account_id}&conference_sid=#{conference_name}&speaker_type=agent&agent_id=#{agent_id}"
Twilio::TwiML::VoiceResponse.new do |r|
# Add transcription for the agent leg too
r.start do |start|
start.transcription(
status_callback_url: transcription_url,
status_callback_method: 'POST',
track: 'inbound_track', # Use inbound_track consistently for conference calls
language_code: 'en-US'
)
end
r.dial do |dial|
dial.conference(
conference_name,
startConferenceOnEnter: true,
endConferenceOnExit: true,
muted: false,
beep: false,
waitUrl: '',
earlyMedia: true,
statusCallback: conference_callback_url,
statusCallbackEvent: 'start end join leave',
statusCallbackMethod: 'POST',
participantLabel: "agent-#{params[:agent_id] || current_user&.id}"
)
end
end.to_s
end
def conference_callback_url
account_id = params[:account_id] || Current.account&.id
"#{base_url.chomp('/')}/api/v1/accounts/#{account_id}/channels/voice/webhooks/conference_status"
end
def base_url
ENV.fetch('FRONTEND_URL', '')
end
# ---- TwiML Error -----------------------------------------------------------
def render_twiml_error(message)
response = Twilio::TwiML::VoiceResponse.new do |r|
r.say(message: "Error: #{message}")
r.hangup
end
set_cors_headers
render xml: response.to_s, content_type: 'text/xml'
end
end
@@ -0,0 +1,81 @@
# frozen_string_literal: true
class Twilio::RecordingController < ActionController::Base
skip_forgery_protection
# POST /twilio/recording_callback
# This endpoint is called by Twilio when a call recording is available
def recording_callback
conference_sid = params['conference_sid']
call_sid = params['CallSid']
recording_url = params['RecordingUrl']
recording_sid = params['RecordingSid']
account_id = params['account_id']
Rails.logger.info("[Twilio::RecordingController] Incoming recording_callback with params: #{params.inspect}")
unless recording_url && account_id && (conference_sid || call_sid)
Rails.logger.warn("[Twilio::RecordingController] Missing required params. recording_url: #{recording_url}, account_id: #{account_id}, conference_sid: #{conference_sid}, call_sid: #{call_sid}")
return head :bad_request
end
# Find the account
account = Account.find_by(id: account_id)
unless account
Rails.logger.warn("[Twilio::RecordingController] Account not found for id: #{account_id}")
return head :not_found
end
# Prefer lookup by conference_sid (most robust for conference recordings)
conversation = if conference_sid
account.conversations.find_by("additional_attributes ->> 'conference_sid' = ?", conference_sid)
elsif call_sid
account.conversations.find_by("additional_attributes ->> 'call_sid' = ?", call_sid)
end
unless conversation
Rails.logger.warn("[Twilio::RecordingController] Conversation not found for conference_sid: #{conference_sid} or call_sid: #{call_sid}")
return head :not_found
end
# Find the original voice call message (should be unique per conference)
message = conversation.messages.voice_call.order(:created_at).first
unless message
Rails.logger.warn("[Twilio::RecordingController] No voice_call message found in conversation_id: #{conversation.id}")
return head :not_found
end
# Download the recording from Twilio
begin
Rails.logger.info("[Twilio::RecordingController] Downloading recording from: #{recording_url}.mp3")
file = URI.open(recording_url + '.mp3')
rescue => e
Rails.logger.error("[Twilio::RecordingController] Failed to download recording: #{e.message}")
return head :internal_server_error
end
# Attach the audio file to the message as an audio attachment
begin
att = message.attachments.create!(
account_id: account.id,
file: {
io: file,
filename: "twilio_recording_#{recording_sid}.mp3",
content_type: 'audio/mpeg'
},
file_type: :audio,
external_url: recording_url + '.mp3',
meta: { recording_sid: recording_sid, conference_sid: conference_sid, call_sid: call_sid }
)
Rails.logger.info("[Twilio::RecordingController] Successfully attached recording to message_id: #{message.id}, attachment_id: #{att.id}")
rescue => e
Rails.logger.error("[Twilio::RecordingController] Failed to attach recording: #{e.message}")
return head :internal_server_error
end
# Optionally, update message content_attributes to indicate recording is attached
content_attributes = message.content_attributes || {}
content_attributes['recording_attached'] = true
content_attributes['conference_sid'] = conference_sid if conference_sid
message.update!(content_attributes: content_attributes)
head :ok
end
end
@@ -0,0 +1,62 @@
class Twilio::TranscriptionController < ActionController::Base
skip_forgery_protection
# Receives real-time transcription updates from Twilio
def transcription_callback
# Set Current.account
Current.account = Account.find_by(id: params[:account_id])
# Only process transcription content events
if params['TranscriptionEvent'] == 'transcription-content'
process_transcription_content
end
head :ok
end
private
def process_transcription_content
# Extract transcript content from JSON
data = JSON.parse(params['TranscriptionData'])
transcript_content = data['transcript']
confidence = data['confidence']
# Find conversation by conference_sid from our standard format
display_id = params[:conference_sid].match(/^conf_account_\d+_conv_(\d+)$/)[1]
conversation = Current.account.conversations.find_by(display_id: display_id)
# Create message based on speaker_type
create_message(conversation, transcript_content, confidence)
end
def create_message(conversation, content, confidence)
if params[:speaker_type] == 'contact'
# Contact message (incoming)
sender = conversation.contact
message_type = :incoming
else
# Agent message (outgoing)
sender = User.find_by(id: params[:agent_id])
message_type = :outgoing
end
# Create the message
Messages::MessageBuilder.new(
sender,
conversation,
content: content,
message_type: message_type,
private: false,
additional_attributes: {
transcription: true,
call_sid: params['CallSid'],
conference_sid: params[:conference_sid],
speaker_type: params[:speaker_type],
confidence: confidence,
track: params['Track']
}
).perform
end
end
+189
View File
@@ -0,0 +1,189 @@
class Twilio::VoiceController < ActionController::Base
skip_forgery_protection
before_action :set_call_details, only: %i[status_callback simple_twiml]
before_action :set_inbox, only: %i[status_callback simple_twiml]
def status_callback
return head :ok unless @inbox
conversation = Voice::ConversationFinderService.new(
account: @inbox.account,
call_sid: @call_sid,
phone_number: incoming_number,
is_outbound: outbound?,
inbox: @inbox
).perform
Voice::CallStatus::Manager.new(
conversation: conversation,
call_sid: @call_sid,
provider: :twilio
).process_status_update(params[:CallStatus], params[:CallDuration]&.to_i, first_status_response?)
head :ok
end
def simple_twiml
return fallback_twiml unless @inbox
conversation = Voice::ConversationFinderService.new(
account: @inbox.account,
call_sid: @call_sid,
phone_number: incoming_number,
is_outbound: outbound?,
inbox: @inbox
).perform
Voice::CallStatus::Manager.new(
conversation: conversation,
call_sid: @call_sid,
provider: :twilio
).process_status_update('in-progress', nil, true)
conference_name = ensure_conference_name(conversation, params[:conference_name])
conversation.update!(
additional_attributes: conversation.additional_attributes.merge(
'conference_sid' => conference_name,
'call_direction' => outbound? ? 'outbound' : 'inbound',
'requires_agent_join' => true
)
)
render_twiml do |r|
r.say(message: 'Please wait while we connect you to an agent')
# Enable real-time transcription for this call leg
# For outbound calls, we're connecting to the contact, so this track is for the contact
contact_id = conversation.contact_id
callback_url = "#{base_url}/twilio/transcription_callback?account_id=#{@inbox.account_id}&conference_sid=#{conference_name}&speaker_type=contact&contact_id=#{contact_id}"
Rails.logger.info("📞 VoiceController: Setting transcription callback to: #{callback_url}")
r.start do |start|
start.transcription(
status_callback_url: callback_url,
status_callback_method: 'POST',
track: 'inbound_track',
language_code: 'en-US'
)
end
# Set up the conference
conference_callback_url = "#{base_url}/api/v1/accounts/#{@inbox.account_id}/channels/voice/webhooks/conference_status"
Rails.logger.info("📞 VoiceController: Setting conference callback to: #{conference_callback_url}")
r.dial do |d|
d.conference(
conference_name,
startConferenceOnEnter: false,
endConferenceOnExit: true,
beep: false,
muted: false,
waitUrl: '',
earlyMedia: true,
statusCallback: conference_callback_url,
statusCallbackMethod: 'POST',
statusCallbackEvent: 'start end join leave',
participantLabel: "caller-#{@call_sid.last(8)}",
record: 'record-from-start',
recording_status_callback: "#{base_url}/twilio/recording_callback?account_id=#{@inbox.account_id}&conference_sid=#{conference_name}",
recording_status_callback_method: 'POST'
)
end
end
rescue StandardError => e
Rails.logger.error("Error creating voice conversation: #{e.message}")
fallback_twiml
end
private
def set_call_details
@call_sid = params[:CallSid]
@direction = params[:Direction]
end
def set_inbox
@inbox = find_inbox(outbound? ? params[:From] : params[:To])
end
def outbound?
@direction == 'outbound-api'
end
def incoming_number
outbound? ? params[:To] : params[:From]
end
def first_status_response?
params[:IsFirstResponseForStatus] == 'true'
end
def render_twiml(status: :ok)
response = Twilio::TwiML::VoiceResponse.new
yield response
render xml: response.to_s, status: status
end
def build_message(conversation, content)
Messages::MessageBuilder.new(
nil,
conversation,
content: content,
message_type: :activity,
additional_attributes: { call_sid: @call_sid, call_status: 'in-progress', user_input: true }
).perform
end
def input_text
return "Caller pressed #{params[:Digits]}" if params[:Digits].present?
return "Caller said: \"#{params[:SpeechResult]}\"" if params[:SpeechResult].present?
'Caller responded'
end
def ensure_conference_name(conversation, supplied)
name = supplied.presence ||
conversation.additional_attributes['conference_sid'] ||
conversation.additional_attributes['conference_name']
return name if name&.match?(/^conf_account_\d+_conv_\d+$/)
"conf_account_#{@inbox.account_id}_conv_#{conversation.display_id}"
end
def fallback_twiml
render_twiml do |r|
r.say(message: 'Hello from Chatwoot. This is a courtesy call to check on your recent signup.')
r.pause(length: 1)
r.say(message: 'We will connect you with an agent shortly.')
r.hangup
end
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
def find_inbox(phone_number)
return nil if phone_number.blank?
Inbox.joins('INNER JOIN channel_voice ON channel_voice.account_id = inboxes.account_id AND inboxes.channel_id = channel_voice.id')
.find_by('channel_voice.phone_number = ?', phone_number)
end
def find_or_create_conversation(inbox, phone_number, call_sid)
Voice::ConversationFinderService.new(
account: inbox.account,
call_sid: call_sid,
phone_number: phone_number,
is_outbound: false,
inbox: inbox
).perform
rescue StandardError => e
Rails.logger.error("find_or_create_conversation error: #{e.message}")
nil
end
end
+1
View File
@@ -15,6 +15,7 @@ class AsyncDispatcher < BaseDispatcher
CsatSurveyListener.instance,
HookListener.instance,
InstallationWebhookListener.instance,
MessageListener.instance,
NotificationListener.instance,
ParticipationListener.instance,
ReportingEventListener.instance,
+2 -1
View File
@@ -107,7 +107,8 @@ module Api::V1::InboxesHelper
'line' => Current.account.line_channels,
'telegram' => Current.account.telegram_channels,
'whatsapp' => Current.account.whatsapp_channels,
'sms' => Current.account.sms_channels
'sms' => Current.account.sms_channels,
'voice' => Current.account.voice_channels
}[permitted_params[:channel][:type]]
end
+122
View File
@@ -6,6 +6,7 @@ import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import FloatingCallWidget from './components/widgets/FloatingCallWidget.vue';
import vueActionCable from './helper/actionCable';
import { useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
@@ -14,6 +15,8 @@ import { setColorTheme } from './helper/themeHelper';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import { useAccount } from 'dashboard/composables/useAccount';
import { useFontSize } from 'dashboard/composables/useFontSize';
import { useAlert } from 'dashboard/composables';
import VoiceAPI from 'dashboard/api/channels/voice';
import {
registerSubscription,
verifyServiceWorkerExistence,
@@ -25,6 +28,7 @@ export default {
components: {
AddAccountModal,
FloatingCallWidget,
LoadingState,
NetworkNotification,
UpdateBanner,
@@ -51,6 +55,7 @@ export default {
showAddAccountModal: false,
latestChatwootVersion: null,
reconnectService: null,
showCallWidget: false, // Will be set to true when calls are active
};
},
computed: {
@@ -60,6 +65,10 @@ export default {
currentUser: 'getCurrentUser',
authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags',
activeCall: 'calls/getActiveCall',
hasActiveCall: 'calls/hasActiveCall',
incomingCall: 'calls/getIncomingCall',
hasIncomingCall: 'calls/hasIncomingCall',
}),
hasAccounts() {
const { accounts = [] } = this.currentUser || {};
@@ -84,11 +93,34 @@ export default {
}
},
},
hasIncomingCall: {
immediate: true,
handler(newVal) {
if (newVal) {
this.showCallWidget = true;
} else {
this.showCallWidget = false;
}
}
},
hasActiveCall: {
immediate: true,
handler(newVal) {
if (newVal) {
this.showCallWidget = true;
} else {
this.showCallWidget = false;
}
}
},
},
mounted() {
this.initializeColorTheme();
this.listenToThemeChanges();
this.setLocale(window.chatwootConfig.selectedLocale);
// Make app instance available globally for direct call widget updates
window.app = this;
},
unmounted() {
if (this.reconnectService) {
@@ -106,6 +138,77 @@ export default {
setLocale(locale) {
this.$root.$i18n.locale = locale;
},
handleCallEnded() {
this.showCallWidget = false;
this.$store.dispatch('calls/clearActiveCall');
this.$store.dispatch('calls/clearIncomingCall');
// Clear the activeCallConversation state in all ContactInfo components
this.$nextTick(() => {
const clearContactInfoCallState = (components) => {
if (!components) return;
components.forEach(component => {
if (component.$options && component.$options.name === 'ContactInfo') {
if (component.activeCallConversation) {
component.activeCallConversation = null;
component.$forceUpdate();
}
}
if (component.$children && component.$children.length) {
clearContactInfoCallState(component.$children);
}
});
};
clearContactInfoCallState(this.$children);
});
},
handleCallJoined() {
this.showCallWidget = true;
},
handleCallRejected() {
this.showCallWidget = false;
this.$store.dispatch('calls/clearIncomingCall');
},
forceEndCall() {
this.showCallWidget = false;
if (window.forceEndCallHandlers) {
window.forceEndCallHandlers.forEach(handler => {
try {
handler();
} catch (e) {
// Optionally log error in production
}
});
}
if (this.activeCall && this.activeCall.callSid) {
const { callSid, conversationId } = this.activeCall;
const savedCallSid = callSid;
const savedConversationId = conversationId;
this.$store.dispatch('calls/clearActiveCall');
if (savedConversationId) {
VoiceAPI.endCall(savedCallSid, savedConversationId)
.then(() => {
useAlert({ message: 'Call ended successfully', type: 'success' });
})
.catch(() => {
setTimeout(() => {
VoiceAPI.endCall(savedCallSid, savedConversationId)
.then(() => {
})
.catch(() => {
});
}, 1000);
useAlert({ message: 'Call UI has been reset', type: 'info' });
});
} else {
useAlert({ message: 'Call ended', type: 'success' });
}
} else {
this.$store.dispatch('calls/clearActiveCall');
}
},
async initializeAccount() {
await this.$store.dispatch('accounts/get');
this.$store.dispatch('setActiveAccount', {
@@ -153,6 +256,25 @@ export default {
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
<WootSnackbarBox />
<NetworkNotification />
<!-- Floating call widget that appears during active calls -->
<FloatingCallWidget
v-if="showCallWidget || hasActiveCall || hasIncomingCall"
:key="activeCall ? activeCall.callSid : (incomingCall ? incomingCall.callSid : 'no-call')"
:call-sid="activeCall ? activeCall.callSid : (incomingCall ? incomingCall.callSid : '')"
:inbox-name="activeCall ? (activeCall.inboxName || 'Primary') : (incomingCall ? incomingCall.inboxName : 'Primary')"
:conversation-id="activeCall ? activeCall.conversationId : (incomingCall ? incomingCall.conversationId : null)"
:contact-name="activeCall ? activeCall.contactName : (incomingCall ? incomingCall.contactName : '')"
:contact-id="activeCall ? activeCall.contactId : (incomingCall ? incomingCall.contactId : null)"
:inbox-id="activeCall ? activeCall.inboxId : (incomingCall ? incomingCall.inboxId : null)"
:inbox-avatar-url="activeCall ? activeCall.inboxAvatarUrl : (incomingCall ? incomingCall.inboxAvatarUrl : '')"
:inbox-phone-number="activeCall ? activeCall.inboxPhoneNumber : (incomingCall ? incomingCall.inboxPhoneNumber : '')"
:avatar-url="activeCall ? activeCall.avatarUrl : (incomingCall ? incomingCall.avatarUrl : '')"
:phone-number="activeCall ? activeCall.phoneNumber : (incomingCall ? incomingCall.phoneNumber : '')"
use-web-rtc
@callEnded="handleCallEnded"
@callJoined="handleCallJoined"
@callRejected="handleCallRejected"
/>
</div>
<LoadingState v-else />
</template>
@@ -0,0 +1,856 @@
/* global axios */
import ApiClient from '../ApiClient';
class VoiceAPI extends ApiClient {
constructor() {
// Use 'voice' as the resource with accountScoped: true
super('voice', { accountScoped: true });
// Client-side Twilio device
this.device = null;
this.activeConnection = null;
this.initialized = false;
}
// Initiate a call to a contact
initiateCall(contactId) {
if (!contactId) {
throw new Error('Contact ID is required to initiate a call');
}
// Based on the route definition, the correct URL path is /api/v1/accounts/{accountId}/contacts/{contactId}/call
// The endpoint is defined in the contacts namespace, not voice namespace
return axios.post(`${this.baseUrl().replace('/voice', '')}/contacts/${contactId}/call`);
}
// End an active call
endCall(callSid, conversationId) {
if (!conversationId) {
throw new Error('Conversation ID is required to end a call');
}
if (!callSid) {
throw new Error('Call SID is required to end a call');
}
// Validate call SID format - Twilio call SID starts with 'CA' or 'TJ'
if (!callSid.startsWith('CA') && !callSid.startsWith('TJ')) {
throw new Error(
'Invalid call SID format. Expected Twilio call SID starting with CA or TJ.'
);
}
return axios.post(`${this.url}/end_call`, {
call_sid: callSid,
conversation_id: conversationId,
id: conversationId,
});
}
// Get call status
getCallStatus(callSid) {
if (!callSid) {
throw new Error('Call SID is required to get call status');
}
return axios.get(`${this.url}/call_status`, {
params: { call_sid: callSid },
});
}
// Join an incoming call as an agent (join the conference)
// This is used for the WebRTC client-side setup, not for phone calls anymore
joinCall(params) {
// Check if we have individual parameters or a params object
const conversationId = params.conversation_id || params.conversationId;
const callSid = params.call_sid || params.callSid;
const accountId = params.account_id;
if (!conversationId) {
throw new Error('Conversation ID is required to join a call');
}
if (!callSid) {
throw new Error('Call SID is required to join a call');
}
// Build request payload with proper naming convention
const payload = {
call_sid: callSid,
conversation_id: conversationId,
};
// Add account_id if provided
if (accountId) {
payload.account_id = accountId;
}
console.log('Calling join_call API endpoint with payload:', payload);
return axios.post(`${this.url}/join_call`, payload);
}
// Reject an incoming call as an agent (don't join the conference)
rejectCall(callSid, conversationId) {
if (!conversationId) {
throw new Error('Conversation ID is required to reject a call');
}
if (!callSid) {
throw new Error('Call SID is required to reject a call');
}
return axios.post(`${this.url}/reject_call`, {
call_sid: callSid,
conversation_id: conversationId,
});
}
// Client SDK methods
// Get a capability token for the Twilio Client
getToken(inboxId) {
console.log(`Requesting token for inbox ID: ${inboxId} at URL: ${this.url}/tokens`);
// Log the base URL for debugging
console.log(`Base URL: ${this.baseUrl()}`);
// Check if inboxId is valid
if (!inboxId) {
console.error('No inbox ID provided for token request');
return Promise.reject(new Error('Inbox ID is required'));
}
// Add more request details to help debugging
return axios.post(`${this.url}/tokens`, { inbox_id: inboxId }, {
headers: { 'Content-Type': 'application/json' },
}).catch(error => {
// Extract useful error details for debugging
const errorInfo = {
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data,
url: `${this.url}/tokens`,
inboxId,
};
console.error('Token request error details:', errorInfo);
// Try to extract a more useful error message from the HTML response if it's a 500 error
if (error.response?.status === 500 && typeof error.response.data === 'string') {
// Look for specific error patterns in the HTML
const htmlData = error.response.data;
// Check for common Ruby/Rails error patterns
const nameMatchResult = htmlData.match(/<h2>(.*?)<\/h2>/);
const detailsMatchResult = htmlData.match(/<pre>([\s\S]*?)<\/pre>/);
const errorName = nameMatchResult ? nameMatchResult[1] : null;
const errorDetails = detailsMatchResult ? detailsMatchResult[1] : null;
if (errorName || errorDetails) {
const enhancedError = new Error(`Server error: ${errorName || 'Internal Server Error'}`);
enhancedError.details = errorDetails;
enhancedError.originalError = error;
throw enhancedError;
}
}
throw error;
});
}
// Initialize the Twilio Device
async initializeDevice(inboxId) {
// If already initialized, return the existing device after checking its health
if (this.initialized && this.device) {
const deviceState = this.device.state;
console.log('Device already initialized, current state:', deviceState);
// If the device is in a bad state, destroy and reinitialize
if (deviceState === 'error' || deviceState === 'unregistered') {
console.log('Device is in a bad state, destroying and reinitializing...');
try {
this.device.destroy();
} catch (e) {
console.log('Error destroying device:', e);
}
this.device = null;
this.initialized = false;
} else {
// Device is in a good state, return it
return this.device;
}
}
// Device needs to be initialized or reinitialized
try {
console.log(`Starting Twilio Device initialization for inbox: ${inboxId}`);
// Import the Twilio Voice SDK
let Device;
try {
// We know the package is installed via package.json
const { Device: TwilioDevice } = await import('@twilio/voice-sdk');
Device = TwilioDevice;
console.log('✓ Twilio Voice SDK imported successfully');
} catch (importError) {
console.error('✗ Failed to import Twilio Voice SDK:', importError);
throw new Error(`Failed to load Twilio Voice SDK: ${importError.message}`);
}
// Validate inbox ID
if (!inboxId) {
throw new Error('Inbox ID is required to initialize the Twilio Device');
}
// Step 1: Get a token from the server
console.log(`Requesting Twilio token for inbox: ${inboxId}`);
let response;
try {
response = await this.getToken(inboxId);
console.log(`✓ Token response received with status: ${response.status}`);
} catch (tokenError) {
console.error('✗ Token request failed:', tokenError);
// Enhanced error handling for token requests
if (tokenError.details) {
// If we already have extracted details from the error, include those
console.error('Token error details:', tokenError.details);
throw new Error(`Failed to get token: ${tokenError.message}`);
}
// Check for specific HTTP error status codes
if (tokenError.response) {
const status = tokenError.response.status;
const data = tokenError.response.data;
if (status === 401) {
throw new Error('Authentication error: Please check your Twilio credentials');
} else if (status === 403) {
throw new Error('Permission denied: You don\'t have access to this inbox');
} else if (status === 404) {
throw new Error('Inbox not found or does not have voice capability');
} else if (status === 500) {
throw new Error('Server error: The server encountered an error processing your request. Check your Twilio configuration.');
} else if (data && data.error) {
throw new Error(`Server error: ${data.error}`);
}
}
throw new Error(`Failed to get token: ${tokenError.message}`);
}
// Validate token response
if (!response.data || !response.data.token) {
console.error('✗ Invalid token response data:', response.data);
// Check if we have an error message in the response
if (response.data && response.data.error) {
throw new Error(`Server did not return a valid token: ${response.data.error}`);
} else {
throw new Error('Server did not return a valid token');
}
}
// Check for warnings about missing TwiML App SID
if (response.data.warning) {
console.warn('⚠️ Twilio Voice Warning:', response.data.warning);
if (!response.data.has_twiml_app) {
console.error(
'🚨 IMPORTANT: Missing TwiML App SID. Browser-based calling requires a ' +
'TwiML App configured in Twilio Console. Set the Voice Request URL to: ' +
response.data.twiml_endpoint
);
}
}
// Extract token data
const { token, identity, voice_enabled, account_sid } = response.data;
// Log diagnostic information
console.log(`✓ Token data received for identity: ${identity}`);
console.log(`✓ Voice enabled: ${voice_enabled}`);
console.log(`✓ Twilio Account SID available: ${!!account_sid}`);
// Log the TwiML endpoint that will be used
if (response.data.twiml_endpoint) {
console.log(`✓ TwiML endpoint: ${response.data.twiml_endpoint}`);
} else {
console.warn('⚠️ No TwiML endpoint found in token response');
}
// Check if voice is enabled
if (!voice_enabled) {
throw new Error('Voice is not enabled for this inbox. Check your Twilio configuration.');
}
// Store the TwiML endpoint URL for later use
this.twimlEndpoint = response.data.twiml_endpoint;
// Step 2: Create Twilio Device with better options
const deviceOptions = {
// Use absolute minimal options - less is more for audio compatibility
allowIncomingWhileBusy: true, // Allow incoming calls while already on a call
debug: true, // Enable debug logging
warnings: true, // Show warnings in console
disableAudioContextSounds: true, // Disable browser audio context for sounds
// Add explicit edge parameter - this helps avoid connectivity issues
edge: ['ashburn', 'sydney', 'roaming'],
// Explicitly set codec preferences
codecPreferences: ['opus', 'pcmu'],
// Add the account ID to any calls made by this device
appParams: {
account_id: response.data.account_id,
}
};
console.log('Creating Twilio Device with options:', deviceOptions);
try {
this.device = new Device(token, deviceOptions);
console.log('✓ Twilio Device created successfully');
} catch (deviceError) {
console.error('✗ Failed to create Twilio Device:', deviceError);
throw new Error(`Failed to create Twilio Device: ${deviceError.message}`);
}
// Step 3: Set up event listeners with enhanced error handling
this._setupDeviceEventListeners(inboxId);
// Step 4: Register the device with Twilio
console.log('Registering Twilio Device...');
try {
await this.device.register();
console.log('✓ Twilio Device registered successfully');
this.initialized = true;
return this.device;
} catch (registerError) {
console.error('✗ Failed to register Twilio Device:', registerError);
// Handle specific registration errors
if (registerError.message && registerError.message.includes('token')) {
throw new Error('Invalid Twilio token. Check your account credentials.');
} else if (registerError.message && registerError.message.includes('permission')) {
throw new Error('Missing microphone permission. Please allow microphone access.');
}
throw new Error(`Failed to register device: ${registerError.message}`);
}
} catch (error) {
// Clear device and initialized flag in case of error
this.device = null;
this.initialized = false;
console.error('Failed to initialize Twilio Device:', error);
// Create a detailed error with context for debugging
const enhancedError = new Error(`Twilio Device initialization failed: ${error.message}`);
enhancedError.originalError = error;
enhancedError.inboxId = inboxId;
enhancedError.timestamp = new Date().toISOString();
enhancedError.browserInfo = {
userAgent: navigator.userAgent,
hasGetUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
};
// Add specific advice for known error cases
if (error.message.includes('permission')) {
enhancedError.advice = 'Please ensure your browser allows microphone access.';
} else if (error.message.includes('token')) {
enhancedError.advice = 'Check your Twilio credentials in the Voice channel settings.';
} else if (error.message.includes('TwiML')) {
enhancedError.advice = 'Set up a valid TwiML app in your Twilio console and configure it in the inbox settings.';
} else if (error.message.includes('configuration')) {
enhancedError.advice = 'Review your Voice inbox configuration to ensure all required fields are completed.';
}
throw enhancedError;
}
}
// Helper method to set up device event listeners
_setupDeviceEventListeners(inboxId) {
if (!this.device) return;
// Remove any existing listeners to prevent duplicates
this.device.removeAllListeners();
// Add standard event listeners
this.device.on('registered', () => {
console.log('✓ Twilio Device registered with Twilio servers');
});
this.device.on('unregistered', () => {
console.log('⚠️ Twilio Device unregistered from Twilio servers');
});
this.device.on('tokenWillExpire', () => {
console.log('⚠️ Twilio token is about to expire, refreshing...');
this.getToken(inboxId)
.then(newTokenResponse => {
if (newTokenResponse.data && newTokenResponse.data.token) {
console.log('✓ Successfully obtained new token');
this.device.updateToken(newTokenResponse.data.token);
} else {
console.error('✗ Failed to get a valid token for renewal');
}
})
.catch(tokenError => {
console.error('✗ Error refreshing token:', tokenError);
});
});
this.device.on('incoming', connection => {
console.log('📞 Incoming call received via Twilio Device');
this.activeConnection = connection;
// Set up connection-specific events
this._setupConnectionEventListeners(connection);
});
this.device.on('error', error => {
// Enhanced error logging with full details
const errorDetails = {
code: error.code,
message: error.message,
description: error.description || 'No description',
twilioErrorObject: error,
connectionInfo: this.activeConnection ? {
parameters: this.activeConnection.parameters,
status: this.activeConnection.status && this.activeConnection.status(),
direction: this.activeConnection.direction,
} : 'No active connection',
deviceState: this.device.state,
browserInfo: {
userAgent: navigator.userAgent,
platform: navigator.platform
},
timestamp: new Date().toISOString()
};
console.error('❌ DETAILED Twilio Device Error:', errorDetails);
// Provide helpful troubleshooting tips based on error code
switch (error.code) {
case 31000:
console.error('⚠️ Error 31000: General Error. This could be an authentication, configuration, or network issue.');
console.error('31000 Error Details:', {
sdp: error.sdp || 'No SDP data',
callState: error.call ? error.call.state : 'No call state',
connectionState: error.connection ? error.connection.state : 'No connection state',
peerConnectionState: error.peerConnection ? error.peerConnection.iceConnectionState : 'No ICE state',
message: error.message,
twilioError: error,
info: error.info || 'No additional info',
solution: 'Check Twilio account status, SDP negotiations, and network connectivity'
});
// Create a network diagnostic to check connectivity
fetch('https://status.twilio.com/api/v2/status.json')
.then(response => response.json())
.then(data => {
console.log('Twilio service status check:', data);
})
.catch(statusError => {
console.error('Failed to check Twilio status:', statusError);
});
break;
case 31002:
console.error('⚠️ Error 31002: Permission Denied. Your browser microphone is blocked or unavailable.');
break;
case 31003:
console.error('⚠️ Error 31003: TwiML App Error. Your TwiML application does not exist or is misconfigured.');
break;
case 31005:
console.error('⚠️ Error 31005: Error sent from gateway in HANGUP. This usually means the TwiML endpoint is not reachable or returning invalid TwiML.');
console.error('Additional details for 31005:', {
activeConnection: this.activeConnection ? 'Yes' : 'No',
deviceState: this.device ? this.device.state : 'No device',
params: this.activeConnection ? this.activeConnection.parameters : 'No params',
twimlEndpoint: this.activeConnection && this.activeConnection.parameters ?
this.activeConnection.parameters.To : 'Unknown endpoint',
hangupReason: error.hangupReason || 'Unknown', // Capture hangup reason
message: error.message,
description: error.description,
customMessage: error.customMessage,
originalError: error.originalError ? JSON.stringify(error.originalError) : 'None'
});
break;
case 31008:
console.error('⚠️ Error 31008: Connection Error. The call could not be established.');
break;
case 31204:
console.error('⚠️ Error 31204: ICE Connection Failed. WebRTC connection failure, check firewall settings.');
break;
default:
console.error(`⚠️ Unspecified error with code ${error.code}: ${error.message}`);
}
});
this.device.on('connect', connection => {
console.log('📞 Call connected');
this.activeConnection = connection;
this._setupConnectionEventListeners(connection);
});
this.device.on('disconnect', () => {
console.log('📞 Call disconnected');
this.activeConnection = null;
});
}
// Set up event listeners for the active connection with enhanced audio diagnostic logging
_setupConnectionEventListeners(connection) {
if (!connection) return;
// Add advanced audio debug data
const getAudioDiagnostics = () => {
const audioContext = window.AudioContext || window.webkitAudioContext;
let audioInfo = { supported: !!audioContext };
try {
if (audioContext) {
const context = new audioContext();
audioInfo = {
...audioInfo,
sampleRate: context.sampleRate,
state: context.state,
baseLatency: context.baseLatency,
outputLatency: context.outputLatency,
destination: {
maxChannelCount: context.destination.maxChannelCount,
numberOfInputs: context.destination.numberOfInputs,
numberOfOutputs: context.destination.numberOfOutputs
}
};
context.close();
}
} catch (e) {
audioInfo.error = e.message;
}
// Check if microphone is accessible
let microphoneInfo = { detected: false, active: false, tracks: [] };
if (window.activeAudioStream) {
const tracks = window.activeAudioStream.getAudioTracks();
microphoneInfo = {
detected: true,
active: tracks.some(track => track.enabled && track.readyState === 'live'),
tracks: tracks.map(track => ({
id: track.id,
label: track.label,
enabled: track.enabled,
muted: track.muted,
readyState: track.readyState,
constraints: track.getConstraints()
}))
};
}
return {
audioContext: audioInfo,
microphone: microphoneInfo,
speakersMuted: typeof window.speechSynthesis !== 'undefined' ?
window.speechSynthesis.speaking === false : 'unknown'
};
};
connection.on('error', error => {
// Significantly enhanced connection error logging with audio diagnostics
const diagnostics = getAudioDiagnostics();
const connectionErrorDetails = {
code: error.code,
message: error.message,
description: error.description || 'No description',
twilioErrorObject: error,
connectionInfo: {
parameters: connection.parameters,
status: connection.status && connection.status(),
direction: connection.direction,
},
deviceState: this.device ? this.device.state : 'No device',
timestamp: new Date().toISOString(),
// Audio diagnostics for troubleshooting
audioDiagnostics: diagnostics,
// Browser media permissions
mediaPermissions: {
hasGetUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
activeAudioStream: !!window.activeAudioStream,
activeAudioTracks: window.activeAudioStream ?
window.activeAudioStream.getAudioTracks().length : 0
}
};
console.error('❌ DETAILED Connection Error with Audio Diagnostics:', connectionErrorDetails);
});
connection.on('mute', isMuted => {
console.log(`📞 Call ${isMuted ? 'muted' : 'unmuted'}`);
});
connection.on('accept', () => {
// Enhanced logging for accept event with audio diagnostics
const diagnostics = getAudioDiagnostics();
console.log('📞 Call accepted with audio diagnostics:', {
connectionParameters: connection.parameters,
status: connection.status && connection.status(),
audioDiagnostics: diagnostics,
activeAudioStream: window.activeAudioStream ? {
active: window.activeAudioStream.active,
id: window.activeAudioStream.id,
trackCount: window.activeAudioStream.getTracks().length
} : 'No active stream'
});
// AUDIO HEALTH CHECK AFTER CONNECTION
setTimeout(() => {
console.log('🔊 AUDIO HEALTH CHECK:', {
connectionActive: this.activeConnection === connection,
connectionState: connection.status && connection.status(),
audioTracks: window.activeAudioStream ?
window.activeAudioStream.getAudioTracks().map(track => ({
label: track.label,
enabled: track.enabled,
readyState: track.readyState,
muted: track.muted
})) : 'No active stream',
// Device state after 5 seconds
deviceState: this.device ? this.device.state : 'No device'
});
}, 5000);
});
connection.on('disconnect', () => {
console.log('📞 Call disconnected', {
disconnectCause: connection.parameters ? connection.parameters.DisconnectCause : 'Unknown',
finalStatus: connection.status && connection.status(),
audioDiagnostics: getAudioDiagnostics()
});
this.activeConnection = null;
});
connection.on('reject', () => {
console.log('📞 Call rejected', {
rejectCause: connection.parameters ? connection.parameters.DisconnectCause : 'Unknown',
audioDiagnostics: getAudioDiagnostics()
});
this.activeConnection = null;
});
// Additional event for warning messages
connection.on('warning', warning => {
console.warn('⚠️ Connection Warning:', warning);
});
// Listen for TwiML processing events
connection.on('twiml-processing', twiml => {
console.log('📄 Processing TwiML:', twiml);
});
// Enhanced audio events for debugging
if (typeof connection.on === 'function') {
try {
// Check for volume events
connection.on('volume', (inputVolume, outputVolume) => {
// Log only significant volume changes to avoid console spam
if (Math.abs(inputVolume) > 50 || Math.abs(outputVolume) > 50) {
console.log(`🔊 Volume change - Input: ${inputVolume}, Output: ${outputVolume}`);
}
});
// Check for media stream events if supported
if (typeof connection.getRemoteStream === 'function') {
const remoteStream = connection.getRemoteStream();
if (remoteStream) {
console.log('✅ Remote audio stream available:', {
active: remoteStream.active,
id: remoteStream.id,
tracks: remoteStream.getTracks().map(t => ({
kind: t.kind,
enabled: t.enabled,
readyState: t.readyState
}))
});
} else {
console.warn('⚠️ No remote audio stream available');
}
}
} catch (e) {
console.warn('Error setting up enhanced audio events:', e);
}
}
}
// Make a call using the Twilio Client
makeClientCall(params) {
if (!this.device || !this.initialized) {
throw new Error('Twilio Device not initialized');
}
this.activeConnection = this.device.connect(params);
return this.activeConnection;
}
// Join a conference call using the Twilio Client
joinClientCall(conferenceParams) {
if (!this.device || !this.initialized) {
throw new Error('Twilio Device not initialized');
}
try {
// IMPORTANT: Do NOT try to register if already registered
// Only check state is ready
if (this.device.state !== 'ready' && this.device.state !== 'registered') {
// Don't try to register again if already registered
}
// This is CRITICAL for Twilio - params must be formatted exactly right
// and passed directly in the format Twilio expects
const params = {
// REQUIRED: Twilio Voice JS SDK expects 'To' parameter to be a properly formatted string
To: `${conferenceParams.To}`,
// Additional params for our server
account_id: conferenceParams.account_id,
is_agent: 'true'
};
// Check To parameter exists - fail if missing
if (!params.To) {
throw new Error('Missing To parameter for conference');
}
// Make sure 'To' is explicitly a string
const stringifiedTo = String(params.To);
console.log('🎯 CRITICAL CONFERENCE CONNECTION: Connecting agent to conference with To=', stringifiedTo);
// Follow Twilio documentation format - params should be nested under 'params' property
console.log('🎯 TRYING CONNECTION: Using documented format with params property');
// Just use the minimal required parameters
const connection = this.device.connect({
params: {
To: stringifiedTo, // Conference ID
is_agent: 'true' // Flag to indicate agent is joining
}
});
console.log('🎯 CONFERENCE CONNECTION RESULT:', connection ? 'Success' : 'Failed');
this.activeConnection = connection;
if (connection && typeof connection.then === 'function') {
// It's a Promise - newer Twilio SDK version
connection.then(resolvedConnection => {
this.activeConnection = resolvedConnection;
try {
if (typeof resolvedConnection.on === 'function') {
resolvedConnection.on('accept', () => {
// Connection accepted
});
}
} catch (listenerError) {
// Could not add listeners to Promise connection
}
}).catch(connError => {
// WebRTC Promise connection error
});
} else {
// It's a synchronous connection - older Twilio SDK
}
return connection;
} catch (error) {
// Error joining conference
}
}
// Get the status of the device with additional diagnostic info
getDeviceStatus() {
if (!this.device) {
return 'not_initialized';
}
const deviceState = this.device.state;
// Append a recommended action based on the state
switch (deviceState) {
case 'registered':
return 'ready';
case 'unregistered':
return 'disconnected';
case 'destroyed':
return 'terminated';
case 'busy':
return 'busy';
case 'error':
return 'error';
default:
return deviceState;
}
}
// Get comprehensive diagnostic information about the device and connection
getDiagnosticInfo() {
const browserInfo = {
userAgent: navigator.userAgent,
platform: navigator.platform,
vendor: navigator.vendor,
hasMediaDevices: !!navigator.mediaDevices,
hasGetUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
};
const deviceInfo = this.device ? {
state: this.device.state,
isInitialized: this.initialized,
capabilities: this.device.capabilities || {},
isBusy: this.device.isBusy || false,
audio: {
isAudioSelectionSupported: this.device.isAudioSelectionSupported || false
}
} : { state: 'not_initialized' };
const connectionInfo = this.activeConnection ? {
status: this.activeConnection.status(),
isMuted: this.activeConnection.isMuted(),
direction: this.activeConnection.direction,
parameters: this.activeConnection.parameters,
} : { status: 'no_connection' };
return {
timestamp: new Date().toISOString(),
browser: browserInfo,
device: deviceInfo,
connection: connectionInfo
};
}
// Get the status of the active connection
getConnectionStatus() {
if (!this.activeConnection) {
return 'no_connection';
}
const status = this.activeConnection.status();
// Translate connection statuses to more user-friendly terms
switch (status) {
case 'pending':
return 'connecting';
case 'open':
return 'connected';
case 'connecting':
return 'connecting';
case 'ringing':
return 'ringing';
case 'closed':
return 'ended';
default:
return status;
}
}
}
export default new VoiceAPI();
@@ -0,0 +1,27 @@
<template>
<div>
<Button
icon="i-ri-phone-fill"
color="slate"
size="sm"
:tooltip="$t('CALL_BUTTON.TOOLTIP')"
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
@click="openCallModal"
/>
<div v-if="showCallModal" class="fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0">
<CallModal @close="showCallModal = false" />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CallModal from './CallModal.vue';
const showCallModal = ref(false);
const openCallModal = () => {
showCallModal.value = true;
};
</script>
@@ -0,0 +1,306 @@
<template>
<div class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl">
<div class="px-4 py-3 flex items-center">
<h3 class="text-base font-medium">{{ $t('CALL_MODAL.START_CALL') }}</h3>
</div>
<!-- Inbox Selector (First) -->
<div class="flex items-center flex-1 w-full gap-3 px-4 py-3 overflow-y-visible">
<label class="mb-0.5 text-sm font-medium text-n-slate-11 whitespace-nowrap">
{{ $t('CALL_MODAL.VIA') }}
</label>
<div
v-if="selectedInbox"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 h-7 min-w-0"
>
<span class="text-sm truncate text-n-slate-12 flex items-center gap-2">
<span class="i-ri-phone-fill text-n-slate-11"></span>
{{ selectedInbox.name }} - {{ selectedInbox.phoneNumber }}
</span>
<Button
variant="ghost"
icon="i-lucide-x"
color="slate"
size="xs"
class="flex-shrink-0"
@click="selectedInbox = null"
/>
</div>
<div
v-else
v-on-click-outside="() => showInboxDropdown = false"
class="relative flex items-center h-7"
>
<Button
:label="$t('CALL_MODAL.SELECT_INBOX')"
variant="link"
size="sm"
color="slate"
class="hover:!no-underline"
@click="showInboxDropdown = !showInboxDropdown"
/>
<DropdownMenu
v-if="voiceInboxesList.length > 0 && showInboxDropdown"
:menu-items="voiceInboxesList"
class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
@action="selectInbox($event)"
/>
</div>
</div>
<!-- Contact Selector -->
<ContactSelector
:contacts="contacts"
:selected-contact="selectedContact"
:show-contacts-dropdown="showContactsDropdown"
:is-loading="isSearching"
:is-creating-contact="false"
:contact-id="null"
:contactable-inboxes-list="[]"
:show-inboxes-dropdown="false"
:has-errors="false"
@search-contacts="handleContactSearch"
@set-selected-contact="handleSelectedContact"
@clear-selected-contact="clearSelectedContact"
@update-dropdown="handleDropdownUpdate"
/>
<!-- Action buttons -->
<div class="flex items-center justify-end w-full h-[3.25rem] gap-2 px-4 py-3">
<Button
:label="$t('CALL_MODAL.CANCEL')"
variant="faded"
color="slate"
size="sm"
class="!text-xs font-medium"
@click="$emit('close')"
/>
<Button
:label="$t('CALL_MODAL.CALL')"
icon="i-ri-phone-fill"
size="sm"
class="!text-xs font-medium"
:disabled="!selectedInbox || !selectedContact || isLoading"
:is-loading="isLoading"
@click="makeCall"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { debounce } from '@chatwoot/utils';
import { vOnClickOutside } from '@vueuse/components';
import ContactAPI from 'dashboard/api/contacts';
import VoiceAPI from 'dashboard/api/channels/voice';
import camelcaseKeys from 'camelcase-keys';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import ContactSelector from 'dashboard/components-next/NewConversation/components/ContactSelector.vue';
import axios from 'axios';
const { t } = useI18n();
const store = useStore();
const emit = defineEmits(['close']);
const selectedContact = ref(null);
const selectedInbox = ref(null);
const showContactsDropdown = ref(false);
const showInboxDropdown = ref(false);
const contacts = ref([]);
const isSearching = ref(false);
const isLoading = ref(false);
const inboxes = useMapGetter('inboxes/getInboxes');
const voiceInboxesList = computed(() => {
return inboxes.value
.filter(inbox => inbox.channel_type === INBOX_TYPES.VOICE)
.map(inbox => ({
id: inbox.id,
title: `${inbox.name}`,
subtitle: inbox.phone_number,
label: `${inbox.name} - ${inbox.phone_number}`,
action: 'select-inbox',
value: inbox.id,
sourceId: inbox.id,
phoneNumber: inbox.phone_number,
name: inbox.name,
icon: 'i-ri-phone-fill',
}));
});
// Auto-select the first available voice inbox
watch(voiceInboxesList, (newList) => {
if (newList.length > 0 && !selectedInbox.value) {
selectedInbox.value = newList[0];
}
}, { immediate: true });
const selectInbox = item => {
const inbox = voiceInboxesList.value.find(i => i.value === item.value);
if (inbox) {
selectedInbox.value = inbox;
showInboxDropdown.value = false;
}
};
const handleSelectedContact = ({ value, action, ...rest }) => {
// If this is a direct call to a phone number
if (action === 'create' && value.match(/^\+?[0-9\s\-()]+$/)) {
selectedContact.value = {
id: 'direct-call',
name: t('CALL_MODAL.CALL_DIRECTLY'),
sourceId: 'direct-call',
phoneNumber: value,
action: 'contact',
};
} else {
// For existing contacts, make sure we're capturing their ID properly
console.log('Contact selected from dropdown:', { value, action, ...rest });
selectedContact.value = {
...rest,
sourceId: rest.id || rest.value || value // Make sure we have the ID in sourceId
};
}
showContactsDropdown.value = false;
};
const handleDropdownUpdate = (type, value) => {
showContactsDropdown.value = value;
};
const clearSelectedContact = () => {
selectedContact.value = null;
};
// This function gets called from the ContactSelector component
const handleContactSearch = value => {
showContactsDropdown.value = true;
// Pass all the needed keys for search when using the value sent directly
debouncedSearchContacts(value);
};
const debouncedSearchContacts = debounce(async query => {
if (!query || query.length < 2) {
contacts.value = [];
return;
}
isSearching.value = true;
try {
// Use the simple search endpoint since it's more reliable for this use case
const { data } = await ContactAPI.search(query);
console.log('Search response:', data); // Log the search response
// Ensure contacts.value is an array and convert to camelCase
const searchResults = data?.payload ? camelcaseKeys(data.payload, { deep: true }) : [];
// Filter to only include contacts with phone numbers
const contactsWithPhone = searchResults.filter(contact => contact.phoneNumber);
// Map the contacts to ensure they have sourceId set to ID for consistency
contacts.value = contactsWithPhone.map(contact => ({
...contact,
sourceId: contact.id, // Make sure sourceId is set
value: contact.id // Make sure value is set for TagInput
}));
// If it looks like a phone number, add option to call directly
if (query.match(/^\+?[0-9\s\-()]+$/) && !contacts.value.some(c => c.phoneNumber === query)) {
contacts.value.push({
id: 'direct-call',
name: t('CALL_MODAL.CALL_DIRECTLY'),
phoneNumber: query,
sourceId: 'direct-call',
value: 'direct-call'
});
}
console.log('Processed contacts for dropdown:', contacts.value);
} catch (error) {
console.error('Error searching contacts:', error);
contacts.value = []; // Ensure this is always an array
useAlert(t('CALL_MODAL.CONTACT_SEARCH_ERROR'));
} finally {
isSearching.value = false;
}
}, 300);
const makeCall = async () => {
if (!selectedInbox.value || !selectedContact.value) {
useAlert(t('CALL_MODAL.VALIDATION_ERROR'));
return;
}
isLoading.value = true;
try {
const isDirect = selectedContact.value.sourceId === 'direct-call';
const contactId = isDirect ? null : (selectedContact.value.sourceId || selectedContact.value.id);
if (contactId) {
console.log('Making call to contact ID:', contactId, 'with full contact:', selectedContact.value);
// Use VoiceAPI.initiateCall instead of direct axios call
await VoiceAPI.initiateCall(contactId);
} else {
// For direct phone number calls
const phoneNumber = selectedContact.value.phoneNumber;
if (!phoneNumber) {
throw new Error('Phone number is required for direct calls');
}
// First create a contact with this phone number
console.log('Creating new contact with phone number:', phoneNumber);
const contactPayload = {
phone_number: phoneNumber,
inbox_id: selectedInbox.value.sourceId,
name: `Phone: ${phoneNumber}`,
};
const contactResponse = await ContactAPI.create(contactPayload);
console.log('Created contact:', contactResponse.data);
// Then initiate call to the newly created contact
if (contactResponse.data && contactResponse.data.payload && contactResponse.data.payload.contact) {
const newContactId = contactResponse.data.payload.contact.id;
console.log('Using new contact ID:', newContactId);
await VoiceAPI.initiateCall(newContactId);
} else {
throw new Error('Failed to create contact for direct call');
}
}
useAlert(t('CALL_MODAL.SUCCESS_MESSAGE'));
emit('close');
} catch (error) {
console.error('Error making call:', error);
let errorMessage = t('CALL_MODAL.ERROR_MESSAGE');
// Simple error handling - just show server message if available
if (error.response && error.response.data && error.response.data.error) {
errorMessage = error.response.data.error;
}
useAlert(errorMessage);
} finally {
isLoading.value = false;
}
};
onMounted(() => {
// The first inbox will be selected automatically via the watch
// This ensures it works even if voiceInboxesList is populated after mounting
});
</script>
@@ -8,6 +8,7 @@ import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import LiveChatCampaignDetails from './LiveChatCampaignDetails.vue';
import SMSCampaignDetails from './SMSCampaignDetails.vue';
import VoiceCampaignDetails from './VoiceCampaignDetails.vue';
const props = defineProps({
title: {
@@ -22,6 +23,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
isVoiceType: {
type: Boolean,
default: false,
},
isEnabled: {
type: Boolean,
default: false,
@@ -67,6 +72,12 @@ const campaignStatus = computed(() => {
? t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.ENABLED')
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
}
if (props.isVoiceType) {
return props.status === STATUS_COMPLETED
? t('CAMPAIGN.VOICE.CARD.STATUS.COMPLETED')
: t('CAMPAIGN.VOICE.CARD.STATUS.SCHEDULED');
}
return props.status === STATUS_COMPLETED
? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
@@ -108,6 +119,12 @@ const inboxIcon = computed(() => {
:inbox-name="inboxName"
:inbox-icon="inboxIcon"
/>
<VoiceCampaignDetails
v-else-if="isVoiceType"
:sender="sender"
:inbox-name="inboxName"
:inbox-icon="inboxIcon"
/>
<SMSCampaignDetails
v-else
:inbox-name="inboxName"
@@ -118,7 +135,7 @@ const inboxIcon = computed(() => {
</div>
<div class="flex items-center justify-end w-20 gap-2">
<Button
v-if="isLiveChatType"
v-if="isLiveChatType || isVoiceType"
variant="faded"
size="sm"
color="slate"
@@ -0,0 +1,63 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
sender: {
type: Object,
default: null,
},
inboxName: {
type: String,
default: '',
},
inboxIcon: {
type: String,
default: '',
},
campaign: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
const senderName = computed(() =>
props.sender?.name || t('CAMPAIGN.VOICE.CARD.CAMPAIGN_DETAILS.BOT')
);
const senderThumbnail = computed(() => props.sender?.thumbnail || '');
</script>
<template>
<div class="flex items-center gap-2 w-full overflow-hidden">
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
{{ t('CAMPAIGN.VOICE.CARD.CAMPAIGN_DETAILS.SENT_BY') }}
</span>
<div class="flex items-center gap-1.5 flex-shrink-0">
<Avatar
:name="senderName"
:src="senderThumbnail"
:size="16"
rounded-full
/>
<span class="text-sm font-medium text-n-slate-12">
{{ senderName }}
</span>
</div>
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
{{ t('CAMPAIGN.VOICE.CARD.CAMPAIGN_DETAILS.FROM') }}
</span>
<div class="flex items-center gap-1.5 flex-shrink-0">
<Icon :icon="inboxIcon" class="flex-shrink-0 text-n-slate-12 size-3" />
<span class="text-sm font-medium text-n-slate-12">
{{ inboxName }}
</span>
</div>
</div>
</template>
@@ -37,7 +37,7 @@ export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
id: 1,
name: 'Jamie Lee',
},
message: 'Hello! 👋 Any questions on pricing? Im here to help!',
message: 'Hello! 👋 Any questions on pricing? I am here to help!',
campaign_status: 'active',
enabled: false,
campaign_type: 'ongoing',
@@ -60,7 +60,8 @@ export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
},
sender: {
id: 1,
name: 'Chatwoot',
name: 'Alexa Rivera',
thumbnail: 'AR',
},
message: 'Hi! Chatwoot here. Need help setting up? Let me know!',
campaign_status: 'active',
@@ -88,7 +89,7 @@ export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
name: 'Chris Barlow',
},
message:
'Hi there! 👋 Im here for any questions you may have. Lets chat!',
'Hi there! 👋 I am here for any questions you may have. Let us chat!',
campaign_status: 'active',
enabled: true,
campaign_type: 'ongoing',
@@ -166,7 +167,7 @@ export const ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT = [
phone_number: '+29818373149903',
provider: 'default',
},
message: 'Hello! Were excited to have your business with us!',
message: 'Hello! We are excited to have your business with us!',
campaign_status: 'active',
enabled: true,
campaign_type: 'one_off',
@@ -210,3 +211,114 @@ export const ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT = [
updated_at: '2024-10-30T16:15:03.157Z',
},
];
export const VOICE_CAMPAIGN_EMPTY_STATE_CONTENT = [
{
id: 1,
title: 'Signup Confirmation Call',
inbox: {
id: 10,
name: 'PaperLayer Phone Support',
channel_type: 'Channel::Voice',
avatar_url: '',
phone_number: '+14155552671',
},
message: 'Hello! 👋 Thanks for signing up with PaperLayer. I am calling to confirm your account setup and see if you have any questions about getting started.',
campaign_status: 'scheduled',
enabled: true,
campaign_type: 'voice',
scheduled_at: new Date('2024-11-16T20:43:08.000Z').getTime(),
audience: [
{ id: 4, type: 'Label', title: 'Support Customers' },
{ id: 5, type: 'Label', title: 'Active Users' },
],
sender: {
id: 1,
name: 'Chris Barlow',
thumbnail: 'CB'
},
created_at: '2024-11-15T13:13:08.496Z',
updated_at: '2024-11-15T13:15:38.698Z',
},
{
id: 3,
title: 'Support Ticket Follow-Up',
inbox: {
id: 10,
name: 'PaperLayer Phone Support',
channel_type: 'Channel::Voice',
avatar_url: '',
phone_number: '+14155552671',
},
message: 'Hi, this is PaperLayer support calling to follow up on your recent ticket #12345. Has your issue been resolved to your satisfaction? If not, I can connect you with a specialist right away.',
campaign_status: 'completed',
enabled: true,
campaign_type: 'voice',
scheduled_at: new Date('2024-11-10T15:30:00.000Z').getTime(),
audience: [
{ id: 1, type: 'Label', title: 'Enterprise' },
{ id: 6, type: 'Label', title: 'Premium' },
],
sender: {
id: 2,
name: 'Sarah Wilson',
thumbnail: 'SW'
},
created_at: '2024-11-10T13:14:00.168Z',
updated_at: '2024-11-10T13:15:38.707Z',
},
{
id: 2,
title: 'Appointment Reminder',
inbox: {
id: 10,
name: 'PaperLayer Phone Support',
channel_type: 'Channel::Voice',
avatar_url: '',
phone_number: '+14155552671',
},
message: 'Hello, this is a reminder about your upcoming consultation scheduled for tomorrow at 2:00 PM. Would you like to confirm this appointment or would you prefer to reschedule?',
campaign_status: 'scheduled',
enabled: true,
campaign_type: 'voice',
scheduled_at: new Date('2024-11-20T18:00:00.000Z').getTime(),
audience: [
{ id: 7, type: 'Label', title: 'Consultation Clients' },
{ id: 8, type: 'Label', title: 'New Customers' },
],
sender: {
id: 3,
name: 'Michael Thompson',
thumbnail: 'MT'
},
created_at: '2024-11-12T09:30:45.123Z',
updated_at: '2024-11-12T09:30:45.123Z',
},
{
id: 4,
title: 'Customer Feedback Survey',
inbox: {
id: 10,
name: 'PaperLayer Phone Support',
channel_type: 'Channel::Voice',
avatar_url: '',
phone_number: '+14155552671',
},
message: 'Hello, this is PaperLayer reaching out for your valuable feedback. We noticed you\'ve been using our service for 30 days now. I\'d like to ask a few quick questions about your experience. Your feedback helps us improve our service. Would you have a moment to share your thoughts?',
campaign_status: 'scheduled',
enabled: true,
campaign_type: 'voice',
scheduled_at: new Date('2024-11-25T14:15:00.000Z').getTime(),
audience: [
{ id: 9, type: 'Label', title: 'Active 30+ Days' },
{ id: 10, type: 'Label', title: 'Product Users' },
],
sender: {
id: 4,
name: 'Jessica Rivera',
thumbnail: 'JR'
},
created_at: '2024-11-18T10:45:23.789Z',
updated_at: '2024-11-18T10:45:23.789Z',
}
];
@@ -0,0 +1,55 @@
<script setup>
import Policy from 'dashboard/components/policy.vue';
defineProps({
title: {
type: String,
required: true,
},
subtitle: {
type: String,
required: true,
},
actionPerms: {
type: Array,
default: () => [],
},
});
</script>
<template>
<section
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
>
<div
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[36rem]"
>
<div
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
>
<slot name="empty-state-item" />
</div>
<div
class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full bg-gradient-to-t from-n-background from-0% via-n-background/95 via-25% to-transparent"
>
<div class="flex flex-col items-center justify-center gap-6 w-full max-w-4xl mx-auto px-4">
<div class="flex flex-col items-center justify-center gap-3">
<h2
class="text-3xl font-medium text-center text-slate-900 dark:text-white font-interDisplay"
>
{{ title }}
</h2>
<p
class="max-w-2xl mx-auto text-base text-center text-slate-600 dark:text-slate-300 font-interDisplay tracking-[0.3px]"
>
{{ subtitle }}
</p>
</div>
<Policy :permissions="actionPerms">
<slot name="actions" />
</Policy>
</div>
</div>
</div>
</section>
</template>
@@ -0,0 +1,63 @@
<script setup>
import { VOICE_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
import { useI18n } from 'vue-i18n';
import EmptyStateLayout from './CustomEmptyStateLayout.vue';
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
});
const { t } = useI18n();
</script>
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="flex flex-col gap-4 p-px">
<div
v-for="(campaign, index) in VOICE_CAMPAIGN_EMPTY_STATE_CONTENT"
:key="campaign.id"
:style="{
opacity: index === 0 ? 1 : index === 1 ? 0.7 : index === 2 ? 0.4 : 0.2
}"
>
<CampaignCard
:title="campaign.title"
:message="campaign.message"
:is-enabled="campaign.enabled"
:status="campaign.campaign_status"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
:is-voice-type="true"
/>
</div>
</div>
</template>
<template #actions>
<div class="mt-10 py-5 px-8 rounded-lg bg-slate-800/5 border border-slate-300/10 max-w-[32rem] mx-auto text-center shadow-sm">
<p class="mb-4 text-sm text-slate-700 dark:text-slate-300 font-medium">
{{ t('CAMPAIGN.VOICE.EMPTY_STATE.JS_API_DESCRIPTION') }}
</p>
<code class="block px-5 py-4 overflow-auto text-xs rounded bg-slate-800 text-slate-200 text-left">
chatwoot.triggerVoiceCampaign({
campaignId: 'CAMPAIGN_ID',
user: {
name: 'John Doe',
phone: '+1234567890'
}
});
</code>
</div>
</template>
</EmptyStateLayout>
</template>
@@ -10,6 +10,10 @@ defineProps({
type: Boolean,
default: false,
},
isVoiceType: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['edit', 'delete']);
@@ -31,6 +35,7 @@ const handleDelete = campaign => emit('delete', campaign);
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
:is-live-chat-type="isLiveChatType"
:is-voice-type="isVoiceType"
@edit="handleEdit(campaign)"
@delete="handleDelete(campaign)"
/>
@@ -0,0 +1,60 @@
<script setup>
import { onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
import { CAMPAIGNS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events.js';
import VoiceCampaignForm from './VoiceCampaignForm.vue';
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
onMounted(() => {
store.dispatch('inboxes/get');
store.dispatch('labels/get');
});
const addCampaign = async campaignDetails => {
try {
await store.dispatch('campaigns/create', {
...campaignDetails,
campaign_type: CAMPAIGN_TYPES.VOICE,
});
// tracking this here instead of the store to track the type of campaign
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
type: CAMPAIGN_TYPES.VOICE,
});
useAlert(t('CAMPAIGN.VOICE.CREATE.FORM.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage = error?.response?.message || t('CAMPAIGN.VOICE.CREATE.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const handleClose = () => emit('close');
const handleSubmit = campaignDetails => {
addCampaign(campaignDetails);
handleClose();
};
</script>
<template>
<div
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6 max-h-[85vh] overflow-y-auto"
>
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{ t(`CAMPAIGN.VOICE.CREATE.TITLE`) }}
</h3>
<VoiceCampaignForm
@submit="handleSubmit"
@cancel="handleClose"
/>
</div>
</template>
@@ -0,0 +1,246 @@
<script setup>
import { reactive, computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const store = useStore();
const formState = {
uiFlags: useMapGetter('campaigns/getUIFlags'),
labels: useMapGetter('labels/getLabels'),
inboxes: useMapGetter('inboxes/getVoiceInboxes'),
};
const senderList = ref([]);
const initialState = {
title: '',
message: '',
inboxId: null,
senderId: null,
scheduledAt: null,
selectedAudience: [],
};
const state = reactive({ ...initialState });
const rules = {
title: { required, minLength: minLength(1) },
message: { required, minLength: minLength(1) },
inboxId: { required },
senderId: { required },
scheduledAt: { required },
selectedAudience: { required },
};
const v$ = useVuelidate(rules, state);
const isCreating = computed(() => formState.uiFlags.value.isCreating);
const currentDateTime = computed(() => {
// Added to disable the scheduled at field from being set to the current time
const now = new Date();
const localTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
return localTime.toISOString().slice(0, 16);
});
const mapToOptions = (items, valueKey, labelKey) =>
items?.map(item => ({
value: item[valueKey],
label: item[labelKey],
})) ?? [];
const audienceList = computed(() =>
mapToOptions(formState.labels.value, 'id', 'title')
);
const inboxOptions = computed(() =>
mapToOptions(formState.inboxes.value, 'id', 'name')
);
const sendersAndBotList = computed(() => [
{ value: 0, label: t('CAMPAIGN.VOICE.CARD.CAMPAIGN_DETAILS.BOT') },
...mapToOptions(senderList.value, 'id', 'name'),
]);
const getErrorMessage = (field, errorKey) => {
const baseKey = 'CAMPAIGN.VOICE.CREATE.FORM';
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
};
const formErrors = computed(() => ({
title: getErrorMessage('title', 'TITLE'),
message: getErrorMessage('message', 'MESSAGE'),
inbox: getErrorMessage('inboxId', 'INBOX'),
sender: getErrorMessage('senderId', 'SENT_BY'),
scheduledAt: getErrorMessage('scheduledAt', 'SCHEDULED_AT'),
audience: getErrorMessage('selectedAudience', 'AUDIENCE'),
}));
const isSubmitDisabled = computed(() => v$.value.$invalid);
const formatToUTCString = localDateTime =>
localDateTime ? new Date(localDateTime).toISOString() : null;
const handleInboxChange = async inboxId => {
if (!inboxId) {
senderList.value = [];
return;
}
try {
const response = await store.dispatch('inboxMembers/get', { inboxId });
senderList.value = response?.data?.payload ?? [];
} catch (error) {
senderList.value = [];
useAlert(
error?.response?.message ??
t('CAMPAIGN.VOICE.CREATE.FORM.API.ERROR_MESSAGE')
);
}
};
watch(
() => state.inboxId,
newInboxId => {
if (newInboxId) {
handleInboxChange(newInboxId);
}
},
{ immediate: true }
);
const resetState = () => {
Object.assign(state, initialState);
};
const handleCancel = () => emit('cancel');
const prepareCampaignDetails = () => ({
title: state.title,
message: state.message,
inbox_id: state.inboxId,
sender_id: state.senderId || null,
scheduled_at: formatToUTCString(state.scheduledAt),
audience: state.selectedAudience?.map(id => ({
id,
type: 'Label',
})),
});
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
emit('submit', prepareCampaignDetails());
resetState();
handleCancel();
};
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.title"
:label="t('CAMPAIGN.VOICE.CREATE.FORM.TITLE.LABEL')"
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.TITLE.PLACEHOLDER')"
:message="formErrors.title"
:message-type="formErrors.title ? 'error' : 'info'"
/>
<TextArea
v-model="state.message"
:label="t('CAMPAIGN.VOICE.CREATE.FORM.MESSAGE.LABEL')"
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.MESSAGE.PLACEHOLDER')"
show-character-count
:message="formErrors.message"
:message-type="formErrors.message ? 'error' : 'info'"
/>
<div class="flex flex-col gap-1">
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.VOICE.CREATE.FORM.INBOX.LABEL') }}
</label>
<ComboBox
id="inbox"
v-model="state.inboxId"
:options="inboxOptions"
:has-error="!!formErrors.inbox"
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.INBOX.PLACEHOLDER')"
:message="formErrors.inbox"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
</div>
<div class="flex flex-col gap-1">
<label for="sender" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.VOICE.CREATE.FORM.SENT_BY.LABEL') }}
</label>
<ComboBox
id="sender"
v-model="state.senderId"
:options="sendersAndBotList"
:has-error="!!formErrors.sender"
:disabled="!state.inboxId"
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.SENT_BY.PLACEHOLDER')"
:message="formErrors.sender"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
</div>
<div class="flex flex-col gap-1">
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.VOICE.CREATE.FORM.AUDIENCE.LABEL') }}
</label>
<TagMultiSelectComboBox
v-model="state.selectedAudience"
:options="audienceList"
:label="t('CAMPAIGN.VOICE.CREATE.FORM.AUDIENCE.LABEL')"
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.AUDIENCE.PLACEHOLDER')"
:has-error="!!formErrors.audience"
:message="formErrors.audience"
class="[&>div>button]:bg-n-alpha-black2"
/>
</div>
<Input
v-model="state.scheduledAt"
:label="t('CAMPAIGN.VOICE.CREATE.FORM.SCHEDULED_AT.LABEL')"
type="datetime-local"
:min="currentDateTime"
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.SCHEDULED_AT.PLACEHOLDER')"
:message="formErrors.scheduledAt"
:message-type="formErrors.scheduledAt ? 'error' : 'info'"
/>
<div class="flex items-center justify-between w-full gap-3">
<Button
variant="faded"
color="slate"
type="button"
:label="t('CAMPAIGN.VOICE.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
:label="t('CAMPAIGN.VOICE.CREATE.FORM.BUTTONS.CREATE')"
class="w-full"
type="submit"
:is-loading="isCreating"
:disabled="isCreating || isSubmitDisabled"
/>
</div>
</form>
</template>
@@ -272,11 +272,11 @@ defineExpose({
class="w-full"
@input="
isValidationField(item.key) &&
v$[getValidationKey(item.key)].$touch()
v$[getValidationKey(item.key)].$touch()
"
@blur="
isValidationField(item.key) &&
v$[getValidationKey(item.key)].$touch()
v$[getValidationKey(item.key)].$touch()
"
/>
</template>
@@ -13,13 +13,99 @@ const props = defineProps({
});
const { t } = useI18n();
const { getPlainText } = useMessageFormatter();
// Simple check: Is this a voice channel conversation?
const isVoiceChannel = computed(() => {
return props.conversation?.meta?.inbox?.channel_type === 'Channel::Voice';
});
// Get call direction: inbound or outbound
const isIncomingCall = computed(() => {
if (!isVoiceChannel.value) return false;
const direction = props.conversation?.additional_attributes?.call_direction;
return direction === 'inbound';
});
// Simple function to normalize call status
const normalizedCallStatus = computed(() => {
if (!isVoiceChannel.value) return null;
// Get the raw status directly from conversation
const status = props.conversation?.additional_attributes?.call_status;
// Simple mapping of call statuses
if (status === 'in-progress') return 'active';
if (status === 'completed') return 'ended';
if (status === 'canceled') return 'ended';
if (status === 'failed') return 'ended';
if (status === 'busy') return 'no-answer';
if (status === 'no-answer') return isIncomingCall.value ? 'missed' : 'no-answer';
// Return the status as is for explicit values
if (status === 'active') return 'active';
if (status === 'missed') return 'missed';
if (status === 'ended') return 'ended';
if (status === 'ringing') return 'ringing';
// If no status is set, default to 'ended'
return 'ended';
});
// Get formatted call status text for voice channel conversations
const callStatusText = computed(() => {
if (!isVoiceChannel.value) return '';
const status = normalizedCallStatus.value;
const isIncoming = isIncomingCall.value;
if (status === 'active') {
return t('CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS');
}
if (isIncoming) {
if (status === 'ringing') {
return t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
}
if (status === 'missed') {
return t('CONVERSATION.VOICE_CALL.MISSED_CALL');
}
if (status === 'ended') {
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
}
} else {
if (status === 'ringing') {
return t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
}
if (status === 'no-answer') {
return t('CONVERSATION.VOICE_CALL.NO_ANSWER');
}
if (status === 'ended') {
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
}
}
return isIncoming
? t('CONVERSATION.VOICE_CALL.INCOMING_CALL')
: t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
});
// Return proper message content based on message type
const lastNonActivityMessageContent = computed(() => {
const { lastNonActivityMessage = {}, customAttributes = {} } =
props.conversation;
const { email: { subject } = {} } = customAttributes;
// Return special formatting for voice calls
if (isVoiceChannel.value) {
return callStatusText.value;
}
return getPlainText(
subject || lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT')
);
@@ -42,9 +128,57 @@ const unreadMessagesCount = computed(() => {
<template>
<div class="flex items-end w-full gap-2 pb-1">
<p class="w-full mb-0 text-sm leading-7 text-n-slate-12 line-clamp-2">
<!-- Voice Call Message -->
<div
v-if="isVoiceChannel"
class="w-full mb-0 text-sm flex items-center gap-1 pt-0.5"
:class="{
'text-green-600 dark:text-green-400': normalizedCallStatus === 'ringing',
'text-woot-600 dark:text-woot-400': normalizedCallStatus === 'active',
'text-red-600 dark:text-red-400': normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer',
'text-slate-600 dark:text-slate-400': normalizedCallStatus === 'ended'
}"
>
<!-- Explicit icon based on call status -->
<!-- Missed call or no answer -->
<i v-if="normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer'"
class="i-ph-phone-x-fill text-base inline-block flex-shrink-0 text-red-600 dark:text-red-400 mr-1"></i>
<!-- Active call -->
<i v-else-if="normalizedCallStatus === 'active'"
class="i-ph-phone-call-fill text-base inline-block flex-shrink-0 text-woot-600 dark:text-woot-400 mr-1"></i>
<!-- Ended incoming call -->
<i v-else-if="normalizedCallStatus === 'ended' && isIncomingCall"
class="i-ph-phone-incoming-fill text-base inline-block flex-shrink-0 text-slate-600 dark:text-slate-400 mr-1"></i>
<!-- Ended outgoing call -->
<i v-else-if="normalizedCallStatus === 'ended'"
class="i-ph-phone-outgoing-fill text-base inline-block flex-shrink-0 text-slate-600 dark:text-slate-400 mr-1"></i>
<!-- Ringing incoming call -->
<i v-else-if="isIncomingCall"
class="i-ph-phone-incoming-fill text-base inline-block flex-shrink-0 text-green-600 dark:text-green-400 mr-1"
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"></i>
<!-- Ringing outgoing call -->
<i v-else
class="i-ph-phone-outgoing-fill text-base inline-block flex-shrink-0 text-green-600 dark:text-green-400 mr-1"
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"></i>
<span class="text-current truncate">{{ callStatusText }}</span>
<span
v-if="normalizedCallStatus === 'ringing'"
class="flex-shrink-0 text-xs font-medium text-green-600 dark:text-green-400"
>
({{ t('CONVERSATION.VOICE_CALL.JOIN_CALL') }})
</span>
</div>
<!-- Regular Message -->
<p v-else class="w-full mb-0 text-sm leading-7 text-n-slate-12 line-clamp-2">
{{ lastNonActivityMessageContent }}
</p>
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
<Avatar
:name="assignee.name"
@@ -64,3 +198,22 @@ const unreadMessagesCount = computed(() => {
</div>
</div>
</template>
<style lang="scss" scoped>
/* Animation for ringing calls */
.pulse-animation {
animation: icon-pulse 1.5s infinite;
}
@keyframes icon-pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
</style>
@@ -24,7 +24,93 @@ const slaCardLabelRef = ref(null);
const { getPlainText } = useMessageFormatter();
// Simple check: Is this a voice channel conversation?
const isVoiceChannel = computed(() => {
return props.conversation?.meta?.inbox?.channel_type === 'Channel::Voice';
});
// Get call direction: inbound or outbound
const isIncomingCall = computed(() => {
if (!isVoiceChannel.value) return false;
const direction = props.conversation?.additional_attributes?.call_direction;
return direction === 'inbound';
});
// Simple function to normalize call status
const normalizedCallStatus = computed(() => {
if (!isVoiceChannel.value) return null;
// Get the raw status directly from conversation
const status = props.conversation?.additional_attributes?.call_status;
// Simple mapping of call statuses
if (status === 'in-progress') return 'active';
if (status === 'completed') return 'ended';
if (status === 'canceled') return 'ended';
if (status === 'failed') return 'ended';
if (status === 'busy') return 'no-answer';
if (status === 'no-answer') return isIncomingCall.value ? 'missed' : 'no-answer';
// Return the status as is for explicit values
if (status === 'active') return 'active';
if (status === 'missed') return 'missed';
if (status === 'ended') return 'ended';
if (status === 'ringing') return 'ringing';
// If no status is set, default to 'ended'
return 'ended';
});
// Get formatted call status text for voice channel conversations
const callStatusText = computed(() => {
if (!isVoiceChannel.value) return '';
const status = normalizedCallStatus.value;
const isIncoming = isIncomingCall.value;
if (status === 'active') {
return t('CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS');
}
if (isIncoming) {
if (status === 'ringing') {
return t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
}
if (status === 'missed') {
return t('CONVERSATION.VOICE_CALL.MISSED_CALL');
}
if (status === 'ended') {
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
}
} else {
if (status === 'ringing') {
return t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
}
if (status === 'no-answer') {
return t('CONVERSATION.VOICE_CALL.NO_ANSWER');
}
if (status === 'ended') {
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
}
}
return isIncoming
? t('CONVERSATION.VOICE_CALL.INCOMING_CALL')
: t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
});
const lastNonActivityMessageContent = computed(() => {
// If it's a voice call, use the voice call text with icon
if (isVoiceChannel.value) {
return callStatusText.value;
}
// Otherwise use the regular message content
const { lastNonActivityMessage = {}, customAttributes = {} } =
props.conversation;
const { email: { subject } = {} } = customAttributes;
@@ -61,7 +147,49 @@ defineExpose({
<template>
<div class="flex flex-col w-full gap-1">
<div class="flex items-center justify-between w-full gap-2 py-1 h-7">
<p class="mb-0 text-sm leading-7 text-n-slate-12 line-clamp-1">
<!-- Voice Call Message display with icon -->
<div
v-if="isVoiceChannel"
class="flex items-center gap-1 mb-0 text-sm line-clamp-1"
:class="{
'text-green-600 dark:text-green-400': normalizedCallStatus === 'ringing',
'text-woot-600 dark:text-woot-400': normalizedCallStatus === 'active',
'text-red-600 dark:text-red-400': normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer',
'text-slate-600 dark:text-slate-400': normalizedCallStatus === 'ended'
}"
>
<!-- Explicit icon based on call status -->
<!-- Missed call or no answer -->
<i v-if="normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer'"
class="i-ph-phone-x-fill text-base inline-block flex-shrink-0 text-red-600 dark:text-red-400 mr-1"></i>
<!-- Active call -->
<i v-else-if="normalizedCallStatus === 'active'"
class="i-ph-phone-call-fill text-base inline-block flex-shrink-0 text-woot-600 dark:text-woot-400 mr-1"></i>
<!-- Ended incoming call -->
<i v-else-if="normalizedCallStatus === 'ended' && isIncomingCall"
class="i-ph-phone-incoming-fill text-base inline-block flex-shrink-0 text-slate-600 dark:text-slate-400 mr-1"></i>
<!-- Ended outgoing call -->
<i v-else-if="normalizedCallStatus === 'ended'"
class="i-ph-phone-outgoing-fill text-base inline-block flex-shrink-0 text-slate-600 dark:text-slate-400 mr-1"></i>
<!-- Ringing incoming call -->
<i v-else-if="isIncomingCall"
class="i-ph-phone-incoming-fill text-base inline-block flex-shrink-0 text-green-600 dark:text-green-400 mr-1"
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"></i>
<!-- Ringing outgoing call -->
<i v-else
class="i-ph-phone-outgoing-fill text-base inline-block flex-shrink-0 text-green-600 dark:text-green-400 mr-1"
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"></i>
<span class="text-current truncate">{{ callStatusText }}</span>
</div>
<!-- Regular Message display -->
<p v-else class="mb-0 text-sm leading-7 text-n-slate-12 line-clamp-1">
{{ lastNonActivityMessageContent }}
</p>
@@ -105,3 +233,22 @@ defineExpose({
</div>
</div>
</template>
<style lang="scss" scoped>
/* Animation for ringing calls */
.pulse-animation {
animation: icon-pulse 1.5s infinite;
}
@keyframes icon-pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
</style>
@@ -4,6 +4,7 @@ import { getInboxIconByType } from 'dashboard/helper/inbox';
import { useRouter, useRoute } from 'vue-router';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper.js';
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
@@ -32,6 +33,7 @@ const props = defineProps({
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const cardMessagePreviewWithMetaRef = ref(null);
@@ -57,6 +59,123 @@ const lastActivityAt = computed(() => {
return timestamp ? shortTimestamp(dynamicTime(timestamp)) : '';
});
const lastNonActivityMessage = computed(() => {
return props.conversation?.lastNonActivityMessage || {};
});
// Simple check: Is this a voice channel conversation?
const isVoiceChannel = computed(() => {
return props.conversation?.meta?.inbox?.channel_type === 'Channel::Voice';
});
// Get call direction: inbound or outbound
const isIncomingCall = computed(() => {
if (!isVoiceChannel.value) return false;
const direction = props.conversation?.additional_attributes?.call_direction;
return direction === 'inbound';
});
// Simple function to normalize call status
const normalizedCallStatus = computed(() => {
if (!isVoiceChannel.value) return null;
// Get the raw status directly from conversation
const status = props.conversation?.additional_attributes?.call_status;
// Simple mapping of call statuses
if (status === 'in-progress') return 'active';
if (status === 'completed') return 'ended';
if (status === 'canceled') return 'ended';
if (status === 'failed') return 'ended';
if (status === 'busy') return 'no-answer';
if (status === 'no-answer') return isIncomingCall.value ? 'missed' : 'no-answer';
// Return the status as is for explicit values
if (status === 'active') return 'active';
if (status === 'missed') return 'missed';
if (status === 'ended') return 'ended';
if (status === 'ringing') return 'ringing';
// If no status is set, default to 'ended'
return 'ended';
});
const isRingingCall = computed(() => {
return normalizedCallStatus.value === 'ringing';
});
const isActiveCall = computed(() => {
return normalizedCallStatus.value === 'active';
});
// Get formatted call status text for voice channel conversations
const callStatusText = computed(() => {
if (!isVoiceChannel.value) return '';
const status = normalizedCallStatus.value;
const isIncoming = isIncomingCall.value;
if (status === 'active') {
return t('CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS');
}
if (isIncoming) {
if (status === 'ringing') {
return t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
}
if (status === 'missed') {
return t('CONVERSATION.VOICE_CALL.MISSED_CALL');
}
if (status === 'ended') {
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
}
} else {
if (status === 'ringing') {
return t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
}
if (status === 'no-answer') {
return t('CONVERSATION.VOICE_CALL.NO_ANSWER');
}
if (status === 'ended') {
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
}
}
return isIncoming
? t('CONVERSATION.VOICE_CALL.INCOMING_CALL')
: t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
});
// Get icon class based on call status
const callIconClass = computed(() => {
if (!isVoiceChannel.value) return '';
const status = normalizedCallStatus.value;
const isIncoming = isIncomingCall.value;
if (status === 'missed' || status === 'no-answer') {
return 'i-ph-phone-x-fill';
}
if (status === 'active') {
return 'i-ph-phone-call-fill';
}
if (status === 'ended' || status === 'completed') {
return 'i-ph-phone-fill';
}
// Default phone icon for ringing state
return isIncoming
? 'i-ph-phone-incoming-fill'
: 'i-ph-phone-outgoing-fill';
});
const showMessagePreviewWithoutMeta = computed(() => {
const { labels = [] } = props.conversation;
return (
@@ -87,9 +206,21 @@ const onCardClick = e => {
<template>
<div
role="button"
class="flex w-full gap-3 px-3 py-4 transition-all duration-300 ease-in-out cursor-pointer"
class="flex w-full gap-3 px-3 py-4 transition-all duration-300 ease-in-out cursor-pointer relative"
:class="{
'border-l-2 border-green-500 dark:border-green-400': isRingingCall,
'border-l-2 border-woot-500 dark:border-woot-400': isActiveCall,
'border-l-2 border-red-500 dark:border-red-400': normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer',
'conversation-ringing': isRingingCall
}"
@click="onCardClick"
>
<!-- Ringing call indicator (pulse effect) -->
<div
v-if="isRingingCall"
class="absolute left-0 top-0 bottom-0 w-0.5 bg-green-500 dark:bg-green-400 animate-pulse"
></div>
<Avatar
:name="currentContactName"
:src="currentContactThumbnail"
@@ -108,7 +239,35 @@ const onCardClick = e => {
v-tooltip.left="inboxName"
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-5"
>
<!-- Special handling for voice channel with specific icons based on call status -->
<span
v-if="isVoiceChannel && normalizedCallStatus === 'missed'"
class="i-ph-phone-x-fill text-red-600 dark:text-red-400 size-3 inline-block"
></span>
<span
v-else-if="isVoiceChannel && normalizedCallStatus === 'active'"
class="i-ph-phone-call-fill text-woot-600 dark:text-woot-400 size-3 inline-block"
></span>
<span
v-else-if="isVoiceChannel && normalizedCallStatus === 'ended' && isIncomingCall"
class="i-ph-phone-incoming-fill text-n-slate-11 size-3 inline-block"
></span>
<span
v-else-if="isVoiceChannel && normalizedCallStatus === 'ended'"
class="i-ph-phone-outgoing-fill text-n-slate-11 size-3 inline-block"
></span>
<span
v-else-if="isVoiceChannel && isIncomingCall"
class="i-ph-phone-incoming-fill text-green-600 dark:text-green-400 size-3 inline-block"
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"
></span>
<span
v-else-if="isVoiceChannel"
class="i-ph-phone-outgoing-fill text-green-600 dark:text-green-400 size-3 inline-block"
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"
></span>
<Icon
v-else
:icon="inboxIcon"
class="flex-shrink-0 text-n-slate-11 size-3"
/>
@@ -118,16 +277,94 @@ const onCardClick = e => {
</span>
</div>
</div>
<CardMessagePreview
v-show="showMessagePreviewWithoutMeta"
:conversation="conversation"
/>
<CardMessagePreviewWithMeta
v-show="!showMessagePreviewWithoutMeta"
ref="cardMessagePreviewWithMetaRef"
:conversation="conversation"
:account-labels="accountLabels"
/>
<!-- Special preview for voice channel conversations -->
<div
v-if="isVoiceChannel"
class="flex items-center py-1 h-7 gap-1 mb-0 text-sm line-clamp-1"
:class="{
'text-green-600 dark:text-green-400': normalizedCallStatus === 'ringing',
'text-woot-600 dark:text-woot-400': normalizedCallStatus === 'active',
'text-red-600 dark:text-red-400': normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer',
'text-slate-600 dark:text-slate-400': normalizedCallStatus === 'ended'
}"
>
<!-- Icon based on call status -->
<i v-if="normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer'"
class="i-ph-phone-x-fill text-base inline-block flex-shrink-0 text-red-600 dark:text-red-400 mr-1"></i>
<i v-else-if="normalizedCallStatus === 'active'"
class="i-ph-phone-call-fill text-base inline-block flex-shrink-0 text-woot-600 dark:text-woot-400 mr-1"></i>
<i v-else-if="normalizedCallStatus === 'ended'"
class="i-ph-phone-fill text-base inline-block flex-shrink-0 text-slate-600 dark:text-slate-400 mr-1"></i>
<i v-else-if="isIncomingCall"
class="i-ph-phone-incoming-fill text-base inline-block flex-shrink-0 text-green-600 dark:text-green-400 mr-1"
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"></i>
<i v-else
class="i-ph-phone-outgoing-fill text-base inline-block flex-shrink-0 text-green-600 dark:text-green-400 mr-1"
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"></i>
<span class="text-current truncate">{{ callStatusText }}</span>
<!-- Join now prompt for ringing calls -->
<span
v-if="normalizedCallStatus === 'ringing'"
class="flex-shrink-0 text-xs font-medium text-green-600 dark:text-green-400"
>
({{ t('CONVERSATION.VOICE_CALL.JOIN_CALL') }})
</span>
</div>
<!-- Regular message previews for non-voice channel conversations -->
<template v-else>
<CardMessagePreview
v-show="showMessagePreviewWithoutMeta"
:conversation="conversation"
/>
<CardMessagePreviewWithMeta
v-show="!showMessagePreviewWithoutMeta"
ref="cardMessagePreviewWithMetaRef"
:conversation="conversation"
:account-labels="accountLabels"
/>
</template>
</div>
</div>
</template>
<style lang="scss" scoped>
.conversation-ringing {
animation: border-pulse 1.5s infinite;
}
.pulse-animation {
animation: icon-pulse 1.5s infinite;
}
@keyframes border-pulse {
0% {
border-color: rgba(34, 197, 94, 0.8); /* Green for ringing */
}
50% {
border-color: rgba(34, 197, 94, 0.2);
}
100% {
border-color: rgba(34, 197, 94, 0.8);
}
}
@keyframes icon-pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
</style>
@@ -15,6 +15,7 @@ import WhatsAppOptions from './WhatsAppOptions.vue';
const props = defineProps({
attachedFiles: { type: Array, default: () => [] },
isWhatsappInbox: { type: Boolean, default: false },
isVoiceInbox: { type: Boolean, default: false },
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
isTwilioSmsInbox: { type: Boolean, default: false },
messageTemplates: { type: Array, default: () => [] },
@@ -113,6 +114,12 @@ const { onFileUpload } = useFileUpload({
});
const sendButtonLabel = computed(() => {
// For voice inboxes, show "Make a Call" instead of "Send"
if (props.isVoiceInbox) {
return t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.MAKE_CALL');
}
// For all other inboxes, show the standard "Send" label
const keyCode = isEditorHotKeyEnabled('cmd_enter') ? '⌘ + ↵' : '↵';
return t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.SEND', {
keyCode,
@@ -157,7 +164,7 @@ useKeyboardEvents(keyboardEvents);
@send-message="emit('sendWhatsappMessage', $event)"
/>
<div
v-if="!isWhatsappInbox && !hasNoInbox"
v-if="!isWhatsappInbox && !isVoiceInbox && !hasNoInbox"
v-on-click-outside="() => (isEmojiPickerOpen = false)"
class="relative"
>
@@ -197,7 +204,7 @@ useKeyboardEvents(keyboardEvents);
/>
</FileUpload>
<Button
v-if="hasSelectedInbox && !isWhatsappInbox"
v-if="hasSelectedInbox && !isWhatsappInbox && !isVoiceInbox"
icon="i-lucide-signature"
color="slate"
size="sm"
@@ -217,6 +224,7 @@ useKeyboardEvents(keyboardEvents);
/>
<Button
v-if="!isWhatsappInbox"
:icon="isVoiceInbox ? 'i-ri-phone-fill' : undefined"
:label="sendButtonLabel"
size="sm"
class="!text-xs font-medium"
@@ -68,6 +68,7 @@ const inboxTypes = computed(() => ({
isWhatsapp: props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP,
isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB,
isApi: props.targetInbox?.channelType === INBOX_TYPES.API,
isVoice: props.targetInbox?.channelType === INBOX_TYPES.VOICE,
isEmailOrWebWidget:
props.targetInbox?.channelType === INBOX_TYPES.EMAIL ||
props.targetInbox?.channelType === INBOX_TYPES.WEB,
@@ -87,7 +88,7 @@ const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
const validationRules = computed(() => ({
selectedContact: { required },
targetInbox: { required },
message: { required: requiredIf(!inboxTypes.value.isWhatsapp) },
message: { required: requiredIf(!inboxTypes.value.isWhatsapp && !inboxTypes.value.isVoice) },
subject: { required: requiredIf(inboxTypes.value.isEmail) },
}));
@@ -311,7 +312,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
/>
<MessageEditor
v-if="!inboxTypes.isWhatsapp && !showNoInboxAlert"
v-if="!inboxTypes.isWhatsapp && !inboxTypes.isVoice && !showNoInboxAlert"
v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
@@ -321,7 +322,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
/>
<AttachmentPreviews
v-if="state.attachedFiles.length > 0"
v-if="state.attachedFiles.length > 0 && !inboxTypes.isVoice"
:attachments="state.attachedFiles"
@update:attachments="state.attachedFiles = $event"
/>
@@ -329,6 +330,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
<ActionButtons
:attached-files="state.attachedFiles"
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
:is-voice-inbox="inboxTypes.isVoice"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
:message-templates="whatsappMessageTemplates"
@@ -8,8 +8,9 @@ const CHANNEL_PRIORITY = {
'Channel::Whatsapp': 2,
'Channel::Sms': 3,
'Channel::TwilioSms': 4,
'Channel::WebWidget': 5,
'Channel::Api': 6,
'Channel::Voice': 5,
'Channel::WebWidget': 6,
'Channel::Api': 7,
};
export const generateLabelForContactableInboxesList = ({
@@ -23,7 +24,8 @@ export const generateLabelForContactableInboxesList = ({
}
if (
channelType === INBOX_TYPES.TWILIO ||
channelType === INBOX_TYPES.WHATSAPP
channelType === INBOX_TYPES.WHATSAPP ||
channelType === INBOX_TYPES.VOICE
) {
return `${name} (${phoneNumber})`;
}
@@ -60,38 +60,47 @@ const filteredAttrs = computed(() => {
const computedVariant = computed(() => {
if (props.variant) return props.variant;
// The useAttrs method returns attributes values an empty string (not boolean value as in props).
if (attrs.solid || attrs.solid === '') return 'solid';
if (attrs.outline || attrs.outline === '') return 'outline';
if (attrs.faded || attrs.faded === '') return 'faded';
if (attrs.link || attrs.link === '') return 'link';
if (attrs.ghost || attrs.ghost === '') return 'ghost';
// Add defensive checks for undefined attrs
const attrObj = attrs || {};
if (attrObj.solid || attrObj.solid === '') return 'solid';
if (attrObj.outline || attrObj.outline === '') return 'outline';
if (attrObj.faded || attrObj.faded === '') return 'faded';
if (attrObj.link || attrObj.link === '') return 'link';
if (attrObj.ghost || attrObj.ghost === '') return 'ghost';
return 'solid'; // Default variant
});
const computedColor = computed(() => {
if (props.color) return props.color;
if (attrs.blue || attrs.blue === '') return 'blue';
if (attrs.ruby || attrs.ruby === '') return 'ruby';
if (attrs.amber || attrs.amber === '') return 'amber';
if (attrs.slate || attrs.slate === '') return 'slate';
if (attrs.teal || attrs.teal === '') return 'teal';
// Add defensive checks for undefined attrs
const attrObj = attrs || {};
if (attrObj.blue || attrObj.blue === '') return 'blue';
if (attrObj.ruby || attrObj.ruby === '') return 'ruby';
if (attrObj.amber || attrObj.amber === '') return 'amber';
if (attrObj.slate || attrObj.slate === '') return 'slate';
if (attrObj.green || attrObj.green === '') return 'green';
if (attrObj.teal || attrObj.teal === '') return 'teal';
return 'blue'; // Default color
});
const computedSize = computed(() => {
if (props.size) return props.size;
if (attrs.xs || attrs.xs === '') return 'xs';
if (attrs.sm || attrs.sm === '') return 'sm';
if (attrs.md || attrs.md === '') return 'md';
if (attrs.lg || attrs.lg === '') return 'lg';
// Add defensive checks for undefined attrs
const attrObj = attrs || {};
if (attrObj.xs || attrObj.xs === '') return 'xs';
if (attrObj.sm || attrObj.sm === '') return 'sm';
if (attrObj.md || attrObj.md === '') return 'md';
if (attrObj.lg || attrObj.lg === '') return 'lg';
return 'md';
});
const computedJustify = computed(() => {
if (props.justify) return props.justify;
if (attrs.start || attrs.start === '') return 'start';
if (attrs.center || attrs.center === '') return 'center';
if (attrs.end || attrs.end === '') return 'end';
// Add defensive checks for undefined attrs
const attrObj = attrs || {};
if (attrObj.start || attrObj.start === '') return 'start';
if (attrObj.center || attrObj.center === '') return 'center';
if (attrObj.end || attrObj.end === '') return 'end';
return 'center';
});
@@ -141,6 +150,17 @@ const STYLE_CONFIG = {
ghost:
'text-n-slate-12 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
},
green: {
solid:
'bg-green-600 text-white hover:enabled:bg-green-700 focus-visible:bg-green-700 outline-transparent',
faded:
'bg-green-600/10 text-green-700 hover:enabled:bg-green-600/20 focus-visible:bg-green-600/20 outline-transparent',
outline:
'text-green-700 hover:enabled:bg-green-600/10 focus-visible:bg-green-600/10 outline-green-600',
ghost:
'text-green-700 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
link: 'text-green-700 hover:enabled:underline focus-visible:underline outline-transparent',
},
teal: {
solid:
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
@@ -36,6 +36,7 @@ import DyteBubble from './bubbles/Dyte.vue';
import LocationBubble from './bubbles/Location.vue';
import CSATBubble from './bubbles/CSAT.vue';
import FormBubble from './bubbles/Form.vue';
import VoiceCallBubble from './bubbles/VoiceCall.vue';
import MessageError from './MessageError.vue';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
@@ -296,6 +297,15 @@ const componentToRender = computed(() => {
return InstagramStoryBubble;
}
// Handle voice call bubble
if (
props.contentType === 'voice_call' ||
props.contentAttributes?.type === 'voice_call' ||
props.contentAttributes?.data?.callType === 'voice_call'
) {
return VoiceCallBubble;
}
if (Array.isArray(props.attachments) && props.attachments.length === 1) {
const fileType = props.attachments[0].fileType;
@@ -509,10 +519,11 @@ provideMessageContext({
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
'min-w-0 max-w-full': componentToRender === VoiceCallBubble,
}"
@contextmenu="openContextMenu($event)"
>
<Component :is="componentToRender" />
<Component :is="componentToRender" :message="props" />
</div>
<MessageError
v-if="contentAttributes.externalError"
@@ -20,6 +20,7 @@ const {
isAWhatsAppChannel,
isAnEmailChannel,
isAnInstagramChannel,
isAVoiceChannel,
} = useInbox();
const {
@@ -41,6 +42,8 @@ const showStatusIndicator = computed(() => {
if (status.value === MESSAGE_STATUS.FAILED) return false;
// Don't show status for deleted messages
if (contentAttributes.value?.deleted) return false;
// Don't show status for transcription messages
if (isAVoiceChannel.value) return false;
if (messageType.value === MESSAGE_TYPES.OUTGOING) return true;
if (messageType.value === MESSAGE_TYPES.TEMPLATE) return true;
@@ -0,0 +1,481 @@
<template>
<div
class="flex-col border border-slate-100 dark:border-slate-700 rounded-lg overflow-hidden w-full max-w-xs"
:class="statusClass"
>
<div class="flex items-center p-3 gap-3 w-full">
<!-- Call Icon -->
<div
class="shrink-0 flex items-center justify-center size-10 rounded-full"
:class="iconBgClass"
>
<span
:class="[iconName, 'text-white text-xl']"
></span>
</div>
<!-- Call Info -->
<div class="flex flex-col flex-grow overflow-hidden">
<span class="text-base font-medium" :class="labelTextClass">
{{ labelText }}
</span>
<span class="text-xs text-slate-500">
{{ subtextWithDuration }}
</span>
</div>
</div>
<template v-if="hasAudioAttachment && recordingUrl">
<div class="w-full m-0 p-1 min-w-[260px]">
<audio
ref="audioPlayer"
:src="recordingUrl"
preload="metadata"
@ended="handlePlaybackEnd"
controls
class="w-full"
/>
</div>
</template>
</div>
</template>
<script>
import { useVoiceCallHelpers } from 'dashboard/composables/useVoiceCallHelpers';
export default {
name: 'VoiceCallBubble',
components: {
},
inject: ['$emit'],
props: {
message: {
type: Object,
default: () => ({}),
},
isInbox: {
type: Boolean,
default: false,
},
},
data() {
return {
internalStatus: '',
refreshInterval: null,
statusCheckInterval: null,
isAnimating: false,
recordingUrl: '',
isPlaying: false,
hasAudioAttachment: false,
};
},
setup(props) {
// Initialize our composable for use in methods
const {
normalizeCallStatus,
isIncomingCall,
getCallIconName,
getStatusText
} = useVoiceCallHelpers({ conversation: props.message?.conversation }, {
t: (key) => {
// This is a simple passthrough for the t function since we're in options API
// In setup() we can't access this.$t directly
return key;
}
});
// Expose these helpers to the component instance
return {
normalizeCallHelper: normalizeCallStatus,
checkIsIncoming: isIncomingCall,
getCallIconHelper: getCallIconName,
getStatusTextHelper: getStatusText
};
},
computed: {
callData() {
return this.message?.contentAttributes?.data || {};
},
directionalStatus() {
const direction = this.callData?.call_direction;
if (direction) {
return direction === 'inbound' ? 'inbound' : 'outbound';
}
return this.message?.messageType === 0 ? 'inbound' : 'outbound';
},
isIncoming() {
return this.directionalStatus === 'inbound';
},
isOutgoing() {
return this.directionalStatus === 'outbound';
},
status() {
// Use internal status if we have one (from UI updates)
if (this.internalStatus) {
return this.internalStatus;
}
// First check for direct call_status in the conversation additional_attributes
// This is the most authoritative source for call status
const conversationCallStatus = this.message?.conversation?.additional_attributes?.call_status;
if (conversationCallStatus) {
// Use our composable helper for status normalization
return this.normalizeCallHelper(conversationCallStatus, this.isIncoming);
}
// Use the status from call data if present
const callStatus = this.callData?.status;
if (callStatus) {
// Use our composable helper for status normalization
return this.normalizeCallHelper(callStatus, this.isIncoming);
}
// Determine status from timestamps
if (this.callData?.ended_at) {
return 'ended';
}
if (this.callData?.missed) {
return this.isIncoming ? 'missed' : 'no-answer';
}
// Check both message data and conversation data for started_at
if (this.callData?.started_at ||
this.message?.conversation?.additional_attributes?.call_started_at) {
return 'active';
}
// Default to ringing
return 'ringing';
},
formattedDuration() {
if (
this.callData?.started_at &&
(this.status === 'active' || this.status === 'ended')
) {
const startTime = new Date(this.callData.started_at);
const endTime = this.callData?.ended_at
? new Date(this.callData.ended_at)
: new Date();
const durationMs = endTime - startTime;
return this.formatDuration(durationMs);
}
return '';
},
statusClass() {
return {
'bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100': !this.isInbox,
'bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-slate-100': this.isInbox,
'call-ringing': this.status === 'ringing',
};
},
iconName() {
// Use our composable helper for icon selection
return this.getCallIconHelper(this.status, this.isIncoming);
},
iconBgClass() {
// Icon background colors based on status
if (this.status === 'active') {
return 'bg-green-500'; // Green for calls in progress
}
if (this.status === 'missed' || this.status === 'no-answer') {
return 'bg-red-500'; // Red for missed calls
}
if (this.status === 'ended') {
return 'bg-purple-500'; // Purple for ended calls
}
// Default green for ringing
return 'bg-green-500 pulse-animation';
},
labelText() {
// Use our composable helper to get status text
// We need to convert the key to the actual text since we're in options API
const key = this.getStatusTextHelper(this.status, this.isIncoming);
// Special cases for floating widget compatibility
if (this.status === 'ringing') {
if (this.isIncoming) {
return this.$t('CONVERSATION.VOICE_CALL.INCOMING');
} else {
return this.$t('CONVERSATION.VOICE_CALL.OUTGOING');
}
}
// Map the key to the translated text
return this.$t(key);
},
labelTextClass() {
if (this.status === 'missed' || this.status === 'no-answer') {
return 'text-red-500';
}
return '';
},
subtext() {
// Checking call direction and status
const direction = this.isIncoming ? 'incoming' : 'outgoing';
// Check if we have agent_joined flag to determine if agent answered
const agentJoined = this.message?.conversation?.additional_attributes?.agent_joined === true;
const callStarted = !!this.message?.conversation?.additional_attributes?.call_started_at;
// Special handling for incoming calls that were previously joined but now ended
// This avoids showing "You didn't answer" when agent actually did answer
if (this.isIncoming && this.status === 'missed' && (agentJoined || callStarted)) {
return this.$t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
}
// Common subtext for all statuses
const subtextMap = {
incoming: {
ringing: this.$t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'),
active: this.$t('CONVERSATION.VOICE_CALL.YOU_ANSWERED'),
missed: this.$t('CONVERSATION.VOICE_CALL.YOU_DIDNT_ANSWER'),
ended: this.$t('CONVERSATION.VOICE_CALL.YOU_ANSWERED')
},
outgoing: {
ringing: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
active: this.$t('CONVERSATION.VOICE_CALL.THEY_ANSWERED'),
'no-answer': this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
ended: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
completed: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
canceled: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
failed: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
busy: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED')
}
};
// First check if we have a specific message for this status
if (subtextMap[direction] && subtextMap[direction][this.status]) {
return subtextMap[direction][this.status];
}
// Default for missing statuses
if (this.isIncoming) {
if (this.status === 'ringing') {
return this.$t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
} else if (agentJoined || callStarted) {
return this.$t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
} else {
return this.$t('CONVERSATION.VOICE_CALL.YOU_DIDNT_ANSWER');
}
} else {
return this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED');
}
},
subtextWithDuration() {
// Checking if we have start and end timestamps for duration calculation
let durationToShow = this.formattedDuration;
// Check if we have explicit call duration from the content attributes
if (!durationToShow && this.callData?.duration) {
const durationSeconds = parseInt(this.callData.duration, 10);
if (!isNaN(durationSeconds) && durationSeconds > 0) {
const minutes = Math.floor(durationSeconds / 60);
const seconds = durationSeconds % 60;
durationToShow = `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
}
// For completed calls, always show the duration if we have it
const shouldShowDuration =
(this.status === 'ended' || this.status === 'completed') &&
durationToShow;
if (shouldShowDuration) {
return `${this.subtext} · ${durationToShow}`;
}
return this.subtext;
}
},
watch: {
message: {
handler() {
this.setupVoiceCall();
this.setAudioAttachment();
},
deep: true,
},
},
mounted() {
this.setupVoiceCall();
this.setAudioAttachment();
},
beforeUnmount() {
// Clean up all intervals to prevent memory leaks
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
this.refreshInterval = null;
}
if (this.statusCheckInterval) {
clearInterval(this.statusCheckInterval);
this.statusCheckInterval = null;
}
},
methods: {
formatDuration(milliseconds) {
// Convert milliseconds to seconds
const totalSeconds = Math.floor(milliseconds / 1000);
// Calculate minutes and remaining seconds
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
// Format as MM:SS with leading zeros
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
},
setupVoiceCall() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
// Create refresh interval for active calls to update duration
if (this.status === 'active') {
this.refreshInterval = setInterval(() => {
this.$forceUpdate();
}, 1000);
// Set animation flag
this.isAnimating = true;
} else {
this.isAnimating = false;
}
// Always check for call status changes, not just when ringing
if (true) { // Always run status checks
// Create a separate interval to check if the call status has changed
this.statusCheckInterval = setInterval(() => {
// Check if content_attributes has been updated with new status
const updatedStatus = this.callData?.status;
const statusUpdatedAt = this.callData?.status_updated;
// Also check the conversation's call status (which might be more authoritative)
const conversationStatus = this.message?.conversation?.additional_attributes?.call_status;
// Check for any status changes from either source
const hasMessageStatusChanged = updatedStatus &&
updatedStatus !== this.internalStatus &&
statusUpdatedAt;
const hasConversationStatusChanged = conversationStatus &&
conversationStatus !== this.internalStatus;
// If either status has changed, update UI
if (hasMessageStatusChanged || hasConversationStatusChanged) {
// Prefer the conversation status if available (more reliable)
const newStatus = conversationStatus || updatedStatus;
// Status has changed, update UI
this.updateStatus(newStatus);
this.$forceUpdate();
// If call is now active or ended, update UI
if (newStatus === 'active' || newStatus === 'in-progress') {
this.setupVoiceCall();
}
}
}, 1000); // Check more frequently - every 1 second
} else if (this.statusCheckInterval) {
clearInterval(this.statusCheckInterval);
this.statusCheckInterval = null;
}
},
updateStatus(newStatus) {
if (
newStatus &&
newStatus !== this.status &&
newStatus !== this.internalStatus
) {
this.internalStatus = newStatus;
}
},
handlePlaybackEnd() {
this.isPlaying = false;
},
setAudioAttachment() {
// Look for audio attachment in message.attachments or message.contentAttributes.attachments
let attachments = [];
if (this.message?.attachments && Array.isArray(this.message.attachments)) {
attachments = this.message.attachments;
} else if (this.message?.contentAttributes?.attachments && Array.isArray(this.message.contentAttributes.attachments)) {
attachments = this.message.contentAttributes.attachments;
}
// Find the first audio attachment, supporting both camelCase and snake_case fields
const audio = attachments.find(att => {
if (!att) return false;
// Check file_type or fileType
if ((att.file_type && att.file_type.startsWith('audio')) ||
(att.fileType && att.fileType.startsWith('audio'))) return true;
// Check content_type or contentType
if ((att.content_type && att.content_type.startsWith('audio')) ||
(att.contentType && att.contentType.startsWith('audio'))) return true;
// Check data_url or dataUrl
if ((att.data_url && att.data_url.match(/\.(mp3|wav|ogg|m4a)$/i)) ||
(att.dataUrl && att.dataUrl.match(/\.(mp3|wav|ogg|m4a)$/i))) return true;
// Check file_url or fileUrl
if ((att.file_url && att.file_url.match(/\.(mp3|wav|ogg|m4a)$/i)) ||
(att.fileUrl && att.fileUrl.match(/\.(mp3|wav|ogg|m4a)$/i))) return true;
return false;
});
if (audio) {
this.recordingUrl = audio.data_url || audio.file_url || audio.dataUrl || audio.fileUrl || '';
this.hasAudioAttachment = true;
} else {
this.recordingUrl = '';
this.hasAudioAttachment = false;
}
}
},
};
</script>
<style lang="scss" scoped>
/* Voice call styling */
.pulse-animation {
animation: pulse 1.5s infinite;
}
.call-ringing {
animation: border-pulse 1.5s infinite;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); /* Green for ringing */
}
70% {
box-shadow: 0 0 0 10px rgba(34, 197, 94, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
}
}
@keyframes border-pulse {
0% {
border-color: rgba(34, 197, 94, 0.8); /* Green for ringing */
}
50% {
border-color: rgba(34, 197, 94, 0.2);
}
100% {
border-color: rgba(34, 197, 94, 0.8);
}
}
</style>
@@ -16,7 +16,9 @@ defineOptions({
});
const timeStampURL = computed(() => {
return timeStampAppendedURL(attachment.dataUrl);
// Safely access the URL, providing a fallback if not available
const url = attachment?.dataUrl || attachment?.data_url || '';
return timeStampAppendedURL(url);
});
const audioPlayer = useTemplateRef('audioPlayer');
@@ -91,8 +93,16 @@ const changePlaybackSpeed = () => {
};
const downloadAudio = async () => {
const { fileType, dataUrl, extension } = attachment;
downloadFile({ url: dataUrl, type: fileType, extension });
// Get the URL with fallback options
const url = attachment?.dataUrl || attachment?.data_url || '';
if (!url) {
return;
}
const fileType = attachment?.fileType || attachment?.file_type || 'file';
const extension = attachment?.extension || 'mp3';
downloadFile({ url, type: fileType, extension });
};
</script>
@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
@@ -16,6 +17,7 @@ import ChannelLeaf from './ChannelLeaf.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
import CallButton from 'dashboard/components-next/CallModal/CallButton.vue';
const emit = defineEmits([
'closeKeyShortcutModal',
@@ -63,6 +65,11 @@ const conversationCustomViews = useMapGetter(
'customViews/getConversationCustomViews'
);
// Check if there are any voice inboxes
const hasVoiceInbox = computed(() =>
inboxes.value.some(inbox => inbox.channel_type === INBOX_TYPES.VOICE)
);
onMounted(() => {
store.dispatch('labels/get');
store.dispatch('inboxes/get');
@@ -331,6 +338,11 @@ const menuItems = computed(() => {
label: t('SIDEBAR.SMS'),
to: accountScopedRoute('campaigns_sms_index'),
},
{
name: 'Voice',
label: t('SIDEBAR.VOICE'),
to: accountScopedRoute('campaigns_voice_index'),
},
],
},
{
@@ -511,6 +523,7 @@ const menuItems = computed(() => {
{{ searchShortcut }}
</span>
</RouterLink>
<CallButton v-if="hasVoiceInbox" class="flex-shrink-0" />
<ComposeConversation align-position="right">
<template #trigger="{ toggle }">
<Button
@@ -541,4 +554,4 @@ const menuItems = computed(() => {
/>
</section>
</aside>
</template>
</template>
@@ -133,7 +133,11 @@ export default {
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
/>
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div
v-else-if="inputType === 'multi_select'"
@@ -152,7 +156,11 @@ export default {
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
/>
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<input
v-else-if="inputType === 'email'"
@@ -48,13 +48,13 @@ export default {
return [
'website',
'twilio',
'voice',
'api',
'whatsapp',
'sms',
'telegram',
'line',
'instagram',
'voice',
].includes(key);
},
isComingSoon() {
@@ -203,7 +203,7 @@ export default {
v-model="values"
track-by="id"
label="name"
placeholder="Select"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
multiple
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
@@ -211,7 +211,11 @@ export default {
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
/>
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div
v-else-if="inputType === 'search_select'"
@@ -221,7 +225,7 @@ export default {
v-model="values"
track-by="id"
label="name"
placeholder="Select"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
@@ -229,7 +233,11 @@ export default {
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
/>
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div v-else-if="inputType === 'date'" class="multiselect-wrap--small">
<input
File diff suppressed because it is too large Load Diff
@@ -22,7 +22,13 @@ export default {
<div
class="inbox--name inline-flex items-center py-0.5 px-0 leading-3 whitespace-nowrap bg-none text-n-slate-11 text-xs my-0 mx-2.5"
>
<!-- Use i-ph- icons for phone specifically, and FluentIcon for others -->
<span
v-if="inbox.channel_type === 'Channel::Voice'"
class="mr-0.5 rtl:ml-0.5 rtl:mr-0 i-ph-phone text-sm"
></span>
<fluent-icon
v-else
class="mr-0.5 rtl:ml-0.5 rtl:mr-0"
:icon="computedInboxClass"
size="12"
@@ -311,6 +311,7 @@ export default {
<MessagePreview
v-if="lastMessageInChat"
:message="lastMessageInChat"
:conversation="chat"
class="conversation--message my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] text-sm"
:class="hasUnread ? 'font-medium text-n-slate-12' : 'text-n-slate-11'"
/>
@@ -10,6 +10,10 @@ export default {
type: Object,
required: true,
},
conversation: {
type: Object,
default: () => ({}),
},
showMessageType: {
type: Boolean,
default: true,
@@ -26,6 +30,13 @@ export default {
};
},
computed: {
shouldShowCallStatus() {
// Always show call status for voice channels if present
return (
this.conversation?.meta?.channel === 'Channel::Voice' &&
!!this.conversation?.additional_attributes?.call_status
);
},
messageByAgent() {
const { message_type: messageType } = this.message;
return messageType === MESSAGE_TYPE.OUTGOING;
@@ -38,7 +49,125 @@ export default {
const { private: isPrivate } = this.message;
return isPrivate;
},
// Simple check: Is this a voice channel conversation?
isVoiceChannel() {
return this.conversation?.meta?.channel === 'Channel::Voice';
},
// Check if this is a voice call message
isVoiceCall() {
return (
this.message?.content_type === 'voice_call'
);
},
// Get call direction for voice calls
isIncomingCall() {
if (!this.isVoiceChannel) return false;
// First check conversation attributes
const direction = this.conversation?.additional_attributes?.call_direction;
if (direction) {
return direction === 'inbound';
}
},
// Get normalized call status
callStatus() {
if (!this.isVoiceChannel) return null;
// Get raw status from conversation
const status = this.conversation?.additional_attributes?.call_status;
// Map status to normalized values
if (status === 'in-progress') return 'active';
if (status === 'completed') return 'ended';
if (status === 'canceled') return 'ended';
if (status === 'failed') return 'ended';
if (status === 'busy') return 'no-answer';
if (status === 'no-answer') return this.isIncomingCall ? 'missed' : 'no-answer';
// Return explicit status values as-is
if (status === 'active') return 'active';
if (status === 'missed') return 'missed';
if (status === 'ended') return 'ended';
if (status === 'ringing') return 'ringing';
// Default status
return 'active';
},
// Voice call icon based on status
voiceCallIcon() {
if (!this.isVoiceChannel) return null;
const status = this.callStatus;
const isIncoming = this.isIncomingCall;
if (status === 'missed' || status === 'no-answer') {
return 'phone-missed-call';
}
if (status === 'active') {
return 'phone-in-talk';
}
if (status === 'ended') {
return isIncoming ? 'phone-incoming' : 'phone-outgoing';
}
if (status === 'ringing') {
return isIncoming ? 'phone-incoming' : 'phone-outgoing';
}
// Default based on direction
return isIncoming ? 'phone-incoming' : 'phone-outgoing';
},
parsedLastMessage() {
// For voice calls, return status text
if (this.isVoiceChannel) {
// Get status-based text
const status = this.callStatus;
const isIncoming = this.isIncomingCall;
// Return appropriate status text based on call status and direction
if (status === 'active') {
// return last message content if message is not activity and not voice call
if (!this.isMessageAnActivity && !this.isVoiceCall) {
return this.getPlainText(this.message.content);
}
return this.$t('CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS');
}
if (isIncoming) {
if (status === 'ringing') {
return this.$t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
}
if (status === 'missed') {
return this.$t('CONVERSATION.VOICE_CALL.MISSED_CALL');
}
if (status === 'ended') {
return this.$t('CONVERSATION.VOICE_CALL.CALL_ENDED');
}
} else {
if (status === 'ringing') {
return this.$t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
}
if (status === 'no-answer') {
return this.$t('CONVERSATION.VOICE_CALL.NO_ANSWER');
}
if (status === 'ended') {
return this.$t('CONVERSATION.VOICE_CALL.CALL_ENDED');
}
}
// Default fallback based on direction
return isIncoming
? this.$t('CONVERSATION.VOICE_CALL.INCOMING_CALL')
: this.$t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
}
// Default behavior for non-voice calls
const { content_attributes: contentAttributes } = this.message;
const { email: { subject } = {} } = contentAttributes || {};
return this.getPlainText(subject || this.message.content);
@@ -62,48 +191,99 @@ export default {
<template>
<div class="overflow-hidden text-ellipsis whitespace-nowrap">
<template v-if="showMessageType">
<fluent-icon
v-if="isMessagePrivate"
size="16"
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
icon="lock-closed"
/>
<fluent-icon
v-else-if="messageByAgent"
size="16"
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
icon="arrow-reply"
/>
<fluent-icon
v-else-if="isMessageAnActivity"
size="16"
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
icon="info"
/>
<!-- Always show call status for voice channels if present -->
<template v-if="shouldShowCallStatus">
<span
class="-mt-0.5 align-middle inline-block mr-1"
:class="{
'text-red-600 dark:text-red-400': callStatus === 'missed' || callStatus === 'no-answer',
'text-green-600 dark:text-green-400': callStatus === 'active' || callStatus === 'ringing',
'text-n-slate-11': callStatus === 'ended'
}"
>
<!-- Missed call icon -->
<i v-if="callStatus === 'missed' || callStatus === 'no-answer'"
class="i-ph-phone-x text-base"></i>
<!-- Active call icon -->
<i v-else-if="callStatus === 'active'"
class="i-ph-phone-call text-base"></i>
<!-- Incoming call icon -->
<i v-else-if="(callStatus === 'ended' && isIncomingCall) || (isIncomingCall)"
class="i-ph-phone-incoming text-base"></i>
<!-- Outgoing call icon -->
<i v-else
class="i-ph-phone-outgoing text-base"></i>
</span>
<span>{{ parsedLastMessage }}</span>
</template>
<template v-else>
<template v-if="showMessageType">
<fluent-icon
v-if="isMessagePrivate"
size="16"
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
icon="lock-closed"
/>
<!-- Voice calls with phosphor icons (non-filled variants) -->
<span
v-else-if="isVoiceCall"
class="-mt-0.5 align-middle inline-block mr-1"
:class="{
'text-red-600 dark:text-red-400': callStatus === 'missed' || callStatus === 'no-answer',
'text-green-600 dark:text-green-400': callStatus === 'active' || callStatus === 'ringing',
'text-n-slate-11': callStatus === 'ended'
}"
>
<!-- Missed call icon -->
<i v-if="callStatus === 'missed' || callStatus === 'no-answer'"
class="i-ph-phone-x text-base"></i>
<!-- Active call icon -->
<i v-else-if="callStatus === 'active'"
class="i-ph-phone-call text-base"></i>
<!-- Incoming call icon -->
<i v-else-if="(callStatus === 'ended' && isIncomingCall) || (isIncomingCall)"
class="i-ph-phone-incoming text-base"></i>
<!-- Outgoing call icon -->
<i v-else
class="i-ph-phone-outgoing text-base"></i>
</span>
<fluent-icon
v-else-if="messageByAgent"
size="16"
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
icon="arrow-reply"
/>
<fluent-icon
v-else-if="isMessageAnActivity"
size="16"
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
icon="info"
/>
</template>
<span v-if="message.content && isMessageSticker">
<fluent-icon
size="16"
class="-mt-0.5 align-middle inline-block text-n-slate-11"
icon="image"
/>
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
</span>
<span v-else-if="message.content || isVoiceCall">
{{ parsedLastMessage }}
</span>
<span v-else-if="message.attachments">
<fluent-icon
v-if="attachmentIcon && showMessageType"
size="16"
class="-mt-0.5 align-middle inline-block text-n-slate-11"
:icon="attachmentIcon"
/>
{{ $t(`${attachmentMessageContent}`) }}
</span>
<span v-else>
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
</span>
</template>
<span v-if="message.content && isMessageSticker">
<fluent-icon
size="16"
class="-mt-0.5 align-middle inline-block text-n-slate-11"
icon="image"
/>
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
</span>
<span v-else-if="message.content">
{{ parsedLastMessage }}
</span>
<span v-else-if="message.attachments">
<fluent-icon
v-if="attachmentIcon && showMessageType"
size="16"
class="-mt-0.5 align-middle inline-block text-n-slate-11"
:icon="attachmentIcon"
/>
{{ $t(`${attachmentMessageContent}`) }}
</span>
<span v-else>
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
</span>
</div>
</template>
@@ -10,6 +10,7 @@ import ReplyBox from './ReplyBox.vue';
import MessageList from 'next/message/MessageList.vue';
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
import VoiceTimelineView from './VoiceTimelineView.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
// stores and apis
@@ -42,6 +43,7 @@ export default {
ReplyBox,
Banner,
ConversationLabelSuggestion,
VoiceTimelineView,
Spinner,
},
mixins: [inboxMixin],
@@ -115,6 +117,10 @@ export default {
inbox() {
return this.$store.getters['inboxes/getInbox'](this.inboxId);
},
isAVoiceChannel() {
// Use inbox.channel_type to match backend conventions
return this.inbox?.channel_type === 'Channel::Voice';
},
typingUsersList() {
const userList = this.$store.getters[
'conversationTypingStatus/getUserList'
@@ -519,9 +525,15 @@ export default {
/>
</div>
</div>
<VoiceTimelineView
v-if="isAVoiceChannel"
:conversation-id="currentChat.id"
class="mb-4"
/>
<ReplyBox
:pop-out-reply-box="isPopOutReplyBox"
@update:pop-out-reply-box="isPopOutReplyBox = $event"
:is-private-note-only="isAVoiceChannel"
/>
</div>
</div>
@@ -101,7 +101,7 @@ export default {
recordingAudioState: '',
recordingAudioDurationText: '',
isUploading: false,
replyType: REPLY_EDITOR_MODES.REPLY,
replyType: REPLY_EDITOR_MODES.NOTE,
mentionSearchKey: '',
hasSlashCommand: false,
bccEmails: '',
@@ -0,0 +1,643 @@
<script>
import { defineComponent, ref, onMounted, onBeforeUnmount, watch } from 'vue';
import { mapGetters } from 'vuex';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default defineComponent({
components: {
NextButton,
},
name: 'VoiceTimelineView',
props: {
conversationId: {
type: Number,
required: true,
},
isCallEnded: {
type: Boolean,
default: false,
},
},
data() {
// Use 01:34 (94 seconds) as our fallback/demo duration throughout the component
const defaultDuration = '01:34';
const defaultSeconds = 94;
return {
currentTime: '00:00',
duration: defaultDuration, // Default duration that will be shown even if API returns nothing
isPlaying: false,
// Generate more realistic waveform data with varied heights
waveformData: this.generateRealisticWaveform(180),
// Define different waveform segments for caller and agent with speaking patterns
waveformSegments: [
{ start: '00:00', end: '00:20', type: 'caller', data: this.generateRealisticWaveform(40) },
{ start: '00:21', end: '00:30', type: 'agent', data: this.generateRealisticWaveform(20) },
{ start: '00:31', end: '00:45', type: 'caller', data: this.generateRealisticWaveform(30) },
{ start: '00:46', end: '00:58', type: 'agent', data: this.generateRealisticWaveform(26) },
{ start: '00:59', end: '01:10', type: 'caller', data: this.generateRealisticWaveform(24) },
{ start: '01:11', end: '01:23', type: 'agent', data: this.generateRealisticWaveform(24) },
{ start: '01:24', end: '01:34', type: 'caller', data: this.generateRealisticWaveform(20) },
],
timeLabels: ['00:00', '00:12', '00:24', '00:36', '00:48', '01:00', '01:12', '01:24'],
markers: [
{ time: '00:15', label: 'systemEvent', text: 'Tool call: Customer identity verified (method: Voice)', icon: 'i-ph-check-circle-fill', ms: '4598ms' },
{ time: '00:32', label: 'systemEvent', text: 'Tool call: Order details retrieved (order_id: #38291)', icon: 'i-ph-database-fill', ms: '7436ms' },
{ time: '00:48', label: 'systemEvent', text: 'Tool call: Customer account verified (id: 57482)', icon: 'i-ph-user-circle-fill', ms: '22060ms' },
{ time: '01:05', label: 'systemEvent', text: 'Tool call: Address updated (1234 Main St, NY)', icon: 'i-ph-database-fill', ms: '84521ms' },
{ time: '01:20', label: 'systemEvent', text: 'Tool call: Email sent (template: address_update)', icon: 'i-ph-envelope-fill', ms: '3250ms' },
],
timer: null,
currentSeconds: 0,
totalSeconds: defaultSeconds, // Default duration in seconds
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
}),
playButtonIcon() {
return this.isPlaying ? 'pause' : 'play_arrow';
},
playButtonLabel() {
return this.isPlaying ? 'Pause' : 'Play';
},
progressPercentage() {
return this.totalSeconds > 0 ? (this.currentSeconds / this.totalSeconds) * 100 : 0;
},
callData() {
return this.currentChat?.additional_attributes || {};
},
callDuration() {
// Get call duration in seconds from call data
const duration = this.callData?.duration || 94; // Default to 94 seconds (01:34) if not available
return parseInt(duration, 10);
},
formattedCallDuration() {
// Always return a formatted duration, never 00:00
const formattedTime = this.formatTime(this.callDuration);
return formattedTime === '00:00' ? '01:34' : formattedTime;
},
shouldShowTimeline() {
// Always show the timeline (all conditions removed for demo/testing)
return false;
},
},
watch: {
conversationId: {
immediate: true,
handler() {
this.loadCallData();
},
},
currentSeconds(newVal) {
this.currentTime = this.formatTime(newVal);
},
callDuration: {
handler(newVal) {
if (newVal && newVal !== this.totalSeconds) {
this.totalSeconds = newVal;
this.duration = this.formattedCallDuration;
// Ensure duration is never empty
if (!this.duration || this.duration === '00:00') {
this.duration = '01:34'; // Fallback default
}
console.log('Call duration updated to:', this.duration);
}
},
immediate: true
}
},
methods: {
generateRealisticWaveform(length) {
// Create a waveform pattern that matches the marked style in the reference image
// with vertical bars grouped together in sections
const baseAmplitude = 40;
const minAmplitude = 10;
const waveform = [];
// Generate segments of active speech with groups of bars
let i = 0;
while (i < length) {
// Create a speech segment (cluster of vertical bars)
const segmentSize = Math.floor(Math.random() * 8) + 5; // 5-12 bars per segment
// For each bar in the segment
for (let j = 0; j < segmentSize && i < length; j++, i++) {
// Create bars of varying heights in a pattern
const barHeight = j % 2 === 0
? Math.random() * baseAmplitude + minAmplitude // Taller bars
: Math.random() * (baseAmplitude / 2) + minAmplitude; // Shorter bars
waveform.push(barHeight);
}
// Add a gap between segments (spaces between bar clusters)
const gapSize = Math.floor(Math.random() * 4) + 2; // 2-5 bars of space
for (let j = 0; j < gapSize && i < length; j++, i++) {
waveform.push(Math.random() * (minAmplitude / 5)); // Very low height for gaps
}
}
return waveform;
},
togglePlayPause() {
this.isPlaying = !this.isPlaying;
if (this.isPlaying) {
this.startTimer();
} else {
this.stopTimer();
}
},
startTimer() {
if (this.timer) return;
this.timer = setInterval(() => {
this.currentSeconds += 0.1;
if (this.currentSeconds >= this.totalSeconds) {
this.currentSeconds = this.totalSeconds;
this.stopTimer();
this.isPlaying = false;
}
}, 100);
},
stopTimer() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
},
formatTime(seconds) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
},
getMarkerPosition(marker) {
return this.getTimePercentage(marker.time);
},
getTimePercentage(timeString) {
const minutes = parseInt(timeString.split(':')[0], 10);
const seconds = parseInt(timeString.split(':')[1], 10);
const totalSeconds = minutes * 60 + seconds;
return (totalSeconds / this.totalSeconds) * 100;
},
timeToSeconds(timeString) {
const minutes = parseInt(timeString.split(':')[0], 10);
const seconds = parseInt(timeString.split(':')[1], 10);
return minutes * 60 + seconds;
},
getMarkerClass(marker) {
switch (marker.label) {
case 'assistantSentSMS':
return 'marker--sms';
case 'userResponse':
return 'marker--user';
case 'agentMessage':
return 'marker--agent';
case 'systemEvent':
return 'marker--system';
case 'callEnded':
return 'marker--end';
default:
return '';
}
},
setProgress(event) {
const timeline = event.currentTarget;
const rect = timeline.getBoundingClientRect();
const offsetX = event.clientX - rect.left;
const percentage = (offsetX / rect.width) * 100;
this.currentSeconds = (percentage / 100) * this.totalSeconds;
},
loadCallData() {
// Always set a default value first
this.duration = '01:34'; // Default fallback
this.totalSeconds = 94; // Default 01:34 in seconds
// If we have actual call duration, use it
if (this.callDuration && this.callDuration > 0) {
this.totalSeconds = this.callDuration;
this.duration = this.formattedCallDuration;
}
console.log('Call data loaded for conversation:', this.conversationId);
console.log('Call duration:', this.duration);
console.log('Sample conversation events:', this.markers.map(m => m.text).join(', '));
},
extractCallEvents() {
// Simulate real implementation - would parse messages to find call events
const messages = this.currentChat?.messages || [];
// In a real implementation, we would find call events in the messages
console.log('Analyzing conversation messages for call events and tool calls...');
console.log('Looking through', messages.length, 'messages for activities');
// This is just for demonstration - actual implementation would process real events
const eventTypes = [
'call_initiated',
'authentication_successful',
'customer_verified',
'tool_call:database_lookup',
'tool_call:payment_processed',
'tool_call:email_notification_sent',
'call_ended'
];
console.log('Found call events and tool calls:', eventTypes.join(', '));
// The actual markers we're showing in the UI simulate these events
console.log('Processing tool calls:');
console.log('- Tool call: Customer account verified (customer_id: 57482)');
console.log('- Tool call: Address updated (new_address: 1234 Main St, Apt 5B)');
console.log('- Tool call: Confirmation email sent (template: address_update)');
},
// Determine the active speaker at the current time
getActiveSpeaker() {
// In a real implementation, this would check the actual call data
// For now, we'll use the predefined segments to determine who's speaking
const currentTimeStr = this.formatTime(this.currentSeconds);
// Find the segment that contains the current time
const activeSegment = this.waveformSegments.find(segment => {
const startSeconds = this.timeToSeconds(segment.start);
const endSeconds = this.timeToSeconds(segment.end);
return this.currentSeconds >= startSeconds && this.currentSeconds <= endSeconds;
});
// Return the speaker type, or null if not found
return activeSegment ? activeSegment.type : null;
},
// Check if a specific speaker is active
isSpeakerActive(speakerType) {
return this.getActiveSpeaker() === speakerType;
},
},
mounted() {
this.loadCallData();
},
beforeUnmount() {
this.stopTimer();
},
});
</script>
<template>
<div v-if="shouldShowTimeline" class="voice-timeline-view">
<div class="timeline-header">
<div class="time-display">
<span class="current-time">{{ currentTime }}</span> / <span class="total-time">{{ duration || formattedCallDuration }}</span>
</div>
<div class="control-buttons">
<NextButton
@click="togglePlayPause"
v-tooltip.top-start="playButtonLabel"
:icon="isPlaying ? 'i-ph-pause-fill' : 'i-ph-play-fill'"
slate
faded
sm
round
/>
</div>
</div>
<div
class="timeline-container"
@click="setProgress"
>
<!-- Progress Indicator -->
<div
class="progress-indicator"
:style="{ left: `${progressPercentage}%` }"
></div>
<!-- Agent Timeline (Top) -->
<div class="agent-timeline">
<div class="waveform-bars">
<div
v-for="(value, index) in waveformData.slice(waveformData.length / 2)"
:key="'agent-' + index"
class="waveform-bar"
:class="{
'active-agent-bar': isSpeakerActive('agent'),
'inactive-agent-bar': !isSpeakerActive('agent'),
'progress-passed': index / (waveformData.length / 2) < progressPercentage / 100
}"
:style="{
height: `${value}%`
}"
></div>
</div>
</div>
<!-- Contact Timeline (Middle) -->
<div class="contact-timeline">
<div class="waveform-bars">
<div
v-for="(value, index) in waveformData.slice(0, waveformData.length / 2)"
:key="'contact-' + index"
class="waveform-bar"
:class="{
'active-contact-bar': isSpeakerActive('caller'),
'inactive-contact-bar': !isSpeakerActive('caller'),
'progress-passed': index / (waveformData.length / 2) < progressPercentage / 100
}"
:style="{
height: `${value}%`
}"
></div>
</div>
</div>
<!-- Events Timeline overlaid at bottom -->
<div class="events-timeline">
<!-- Event Markers -->
<div
v-for="(marker, index) in markers"
:key="index"
class="event-marker"
:class="getMarkerClass(marker)"
:style="{ left: `${getMarkerPosition(marker)}%` }"
>
<div class="marker-dot">
<span v-if="marker.icon" :class="[marker.icon, 'marker-icon']"></span>
</div>
<!-- Tooltips for all events -->
<div
class="marker-tooltip"
:class="[
`marker-tooltip-${marker.label}`,
{ 'marker-tooltip-visible': isPlaying && currentSeconds >= timeToSeconds(marker.time) && currentSeconds <= timeToSeconds(marker.time) + 2 }
]"
>
<div class="font-semibold mb-1">Tool Call</div>
<div class="marker-text">
<div class="whitespace-normal">
{{ marker.text.replace('Tool call:', '').trim() }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.voice-timeline-view {
@apply relative flex flex-col px-5 pt-3 pb-5 mb-4 mx-4 h-[220px] border border-n-slate-3 dark:border-n-slate-7 rounded-lg bg-n-solid-2 shadow-sm;
font-family: Inter, system-ui, -apple-system, sans-serif;
}
.timeline-header {
@apply flex justify-between items-center mb-2 px-1;
.control-buttons {
@apply flex items-center justify-center;
}
.time-display {
@apply text-sm font-medium text-n-slate-11;
.current-time {
@apply font-medium text-n-slate-11;
}
.total-time {
@apply font-normal text-n-slate-11;
}
}
}
.timeline-container {
@apply relative flex-1 bg-n-solid-2 rounded-lg overflow-hidden cursor-pointer shadow-sm;
display: flex;
flex-direction: column;
height: 190px;
}
.time-labels {
@apply absolute top-0 left-0 right-0 h-7 z-10;
.time-label {
@apply absolute text-sm transform -translate-x-1/2 font-medium text-n-blue-8 dark:text-n-blue-5;
top: 4px;
.h-3 {
@apply bg-n-slate-3 dark:bg-n-slate-6;
}
}
}
.progress-indicator {
@apply absolute top-0 bottom-0 w-[1.5px] z-30 bg-n-blue-7 dark:bg-n-blue-5;
box-shadow: 0 0 3px rgba(var(--blue-7), 0.5);
}
/* Agent Timeline (Top) */
.agent-timeline {
@apply relative h-[50px] px-2 mt-1 bg-n-blue-2 dark:bg-n-blue-7/20 rounded-xl;
.waveform-bars {
@apply flex items-end h-full relative z-10;
}
.waveform-bar {
@apply flex-1 mx-[0.5px] transition-all duration-300 rounded-sm;
min-width: 2px;
}
}
/* Contact Timeline (Middle) */
.contact-timeline {
@apply relative h-[55px] px-2 mt-3 bg-n-slate-2 dark:bg-n-slate-7/20 rounded-xl; /* Added background */
.waveform-bars {
@apply flex items-end h-full relative z-10;
}
.waveform-bar {
@apply flex-1 mx-[0.5px] transition-all duration-300 rounded-sm;
min-width: 2px;
}
}
/* Waveform bar states */
.waveform-bar {
@apply rounded-sm mx-[0.5px] transition-all duration-300 min-w-[2.5px];
}
.active-agent-bar {
@apply bg-n-blue-7 dark:bg-n-blue-6;
}
.inactive-agent-bar {
@apply bg-n-blue-5 dark:bg-n-blue-5;
}
.active-contact-bar {
@apply bg-n-slate-7 dark:bg-n-slate-6;
}
.inactive-contact-bar {
@apply bg-n-slate-4 dark:bg-n-slate-5;
}
.progress-passed {
@apply opacity-100;
}
.waveform-bar:not(.progress-passed) {
@apply opacity-40;
}
/* Events Timeline (Bottom) */
.events-timeline {
@apply absolute bottom-0 left-0 right-0 h-[60px] px-2 z-20;
}
.event-marker {
@apply absolute w-0 z-30;
/* All event markers are positioned at the bottom in the events timeline */
bottom: 15px;
.marker-dot {
@apply absolute -translate-x-1/2 size-8 rounded-full flex items-center justify-center shadow-lg;
top: -16px;
}
.marker-icon {
@apply text-[16px] font-bold;
}
/* Apply bright Tailwind color classes to each marker icon type */
.marker--systemEvent .marker-icon {
@apply text-n-slate-1;
}
.marker--agentMessage .marker-icon {
@apply text-n-slate-1;
}
.marker--userResponse .marker-icon {
@apply text-n-slate-12;
}
.marker--callEnded .marker-icon {
@apply text-n-slate-1;
}
.marker-tooltip {
@apply absolute -translate-x-1/2 p-3 rounded-md shadow-xl text-n-slate-1 text-sm min-w-[150px] max-w-[250px] transition-all duration-200 z-50 opacity-0 invisible bg-n-slate-12;
bottom: 40px;
&.marker-tooltip-visible {
@apply opacity-100 visible;
}
.marker-text {
@apply text-sm whitespace-normal font-normal text-n-slate-1;
}
.marker-time {
@apply text-xs text-n-slate-2 mt-1 font-medium;
}
&:after {
content: '';
@apply absolute w-0 h-0 border-l-[6px] border-l-transparent border-t-[6px] left-1/2 -translate-x-1/2;
border-top-color: #1E293B; /* Consistent color for both light/dark modes */
bottom: -6px;
}
/* Improve positioning when tooltip is near the edge */
&.marker-tooltip-left {
@apply -left-[50px];
&:after {
@apply left-[calc(50%+50px)];
}
}
&.marker-tooltip-right {
@apply -right-[50px];
&:after {
@apply right-[calc(50%+50px)];
}
}
}
&:hover {
.marker-tooltip {
@apply z-50 opacity-100 visible;
}
.marker-dot {
@apply transform scale-125;
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.3));
}
}
/* Marker type styling with bright Tailwind color classes */
&.marker--systemEvent .marker-dot {
@apply bg-n-iris-11;
}
&.marker--agentMessage .marker-dot {
@apply bg-n-blue-11;
}
&.marker--userResponse .marker-dot {
@apply bg-n-amber-11;
}
&.marker--callEnded .marker-dot {
@apply bg-n-ruby-11;
}
/* Tooltip type styling with bright Tailwind color classes */
&.marker-tooltip-systemEvent {
@apply bg-n-iris-11;
&:after {
@apply border-t-n-iris-11;
}
}
&.marker-tooltip-agentMessage {
@apply bg-n-blue-11;
&:after {
@apply border-t-n-blue-11;
}
}
&.marker-tooltip-userResponse {
@apply bg-n-amber-11;
&:after {
@apply border-t-n-amber-11;
}
}
&.marker-tooltip-callEnded {
@apply bg-n-ruby-11;
&:after {
@apply border-t-n-ruby-11;
}
}
}
</style>
@@ -9,6 +9,8 @@ import {
teams,
labels,
statusFilterOptions,
messageTypeOptions,
priorityOptions,
campaigns,
contacts,
inboxes,
@@ -16,7 +18,6 @@ import {
countries,
slaPolicies,
} from 'dashboard/helper/specs/fixtures/automationFixtures.js';
import { MESSAGE_CONDITION_VALUES } from 'dashboard/constants/automation';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables');
@@ -71,7 +72,9 @@ describe('useAutomation', () => {
case 'country_code':
return countries;
case 'message_type':
return MESSAGE_CONDITION_VALUES;
return messageTypeOptions;
case 'priority':
return priorityOptions;
default:
return [];
}
@@ -93,6 +96,8 @@ describe('useAutomation', () => {
return [];
case 'add_sla':
return slaPolicies;
case 'change_priority':
return priorityOptions;
default:
return [];
}
@@ -218,8 +223,9 @@ describe('useAutomation', () => {
expect(getConditionDropdownValues('browser_language')).toEqual(languages);
expect(getConditionDropdownValues('country_code')).toEqual(countries);
expect(getConditionDropdownValues('message_type')).toEqual(
MESSAGE_CONDITION_VALUES
messageTypeOptions
);
expect(getConditionDropdownValues('priority')).toEqual(priorityOptions);
});
it('gets action dropdown values correctly', () => {
@@ -231,6 +237,7 @@ describe('useAutomation', () => {
expect(getActionDropdownValues('send_email_to_team')).toEqual(teams);
expect(getActionDropdownValues('send_message')).toEqual([]);
expect(getActionDropdownValues('add_sla')).toEqual(slaPolicies);
expect(getActionDropdownValues('change_priority')).toEqual(priorityOptions);
});
it('handles event change correctly', () => {
@@ -5,6 +5,9 @@ import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/helper/automationHelper.js');
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: key => key }),
}));
describe('useMacros', () => {
const mockLabels = [
@@ -148,9 +151,11 @@ describe('useMacros', () => {
it('returns PRIORITY_CONDITION_VALUES for change_priority type', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('change_priority')).toEqual(
PRIORITY_CONDITION_VALUES
);
const expectedPriority = PRIORITY_CONDITION_VALUES.map(item => ({
id: item.id,
name: `MACROS.PRIORITY_TYPES.${item.i18nKey}`,
}));
expect(getMacroDropdownValues('change_priority')).toEqual(expectedPriority);
});
it('returns an empty array for unknown types', () => {
@@ -8,6 +8,10 @@ import {
getActionOptions,
getConditionOptions,
} from 'dashboard/helper/automationHelper';
import {
MESSAGE_CONDITION_VALUES,
PRIORITY_CONDITION_VALUES,
} from 'dashboard/constants/automation';
/**
* This is a shared composables that holds utilites used to build dropdown and file options
@@ -60,6 +64,20 @@ export default function useAutomationValues() {
];
});
const messageTypeOptions = computed(() =>
MESSAGE_CONDITION_VALUES.map(item => ({
id: item.id,
name: t(`AUTOMATION.MESSAGE_TYPES.${item.i18nKey}`),
}))
);
const priorityOptions = computed(() =>
PRIORITY_CONDITION_VALUES.map(item => ({
id: item.id,
name: t(`AUTOMATION.PRIORITY_TYPES.${item.i18nKey}`),
}))
);
/**
* Adds a translated "None" option to the beginning of a list
* @param {Array} list - The list to add "None" to
@@ -87,6 +105,8 @@ export default function useAutomationValues() {
customAttributes: getters['attributes/getAttributes'].value,
inboxes: inboxes.value,
statusFilterOptions: statusFilterOptions.value,
priorityOptions: priorityOptions.value,
messageTypeOptions: messageTypeOptions.value,
teams: teams.value,
languages,
countries,
@@ -108,6 +128,7 @@ export default function useAutomationValues() {
languages,
type,
addNoneToListFn: addNoneToList,
priorityOptions: priorityOptions.value,
});
};
@@ -115,6 +136,8 @@ export default function useAutomationValues() {
booleanFilterOptions,
statusFilterItems,
statusFilterOptions,
priorityOptions,
messageTypeOptions,
getConditionDropdownValues,
getActionDropdownValues,
agents,
@@ -78,6 +78,10 @@ export const useInbox = () => {
return inbox.value.provider || '';
});
const isAVoiceChannel = computed(() => {
return channelType.value === INBOX_TYPES.VOICE;
});
const isAMicrosoftInbox = computed(() => {
return isAnEmailChannel.value && inbox.value.provider === 'microsoft';
});
@@ -125,10 +129,6 @@ export const useInbox = () => {
return channelType.value === INBOX_TYPES.INSTAGRAM;
});
const isAVoiceChannel = computed(() => {
return channelType.value === INBOX_TYPES.VOICE;
});
return {
inbox,
isAFacebookInbox,
@@ -1,4 +1,5 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters } from 'dashboard/composables/store';
import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
@@ -7,6 +8,7 @@ import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
* @returns {Object} An object containing the getMacroDropdownValues function
*/
export const useMacros = () => {
const { t } = useI18n();
const getters = useStoreGetters();
const labels = computed(() => getters['labels/getLabels'].value);
@@ -32,7 +34,10 @@ export const useMacros = () => {
name: i.title,
}));
case 'change_priority':
return PRIORITY_CONDITION_VALUES;
return PRIORITY_CONDITION_VALUES.map(item => ({
id: item.id,
name: t(`MACROS.PRIORITY_TYPES.${item.i18nKey}`),
}));
default:
return [];
}
@@ -0,0 +1,180 @@
import { computed } from 'vue';
export const useVoiceCallHelpers = (props, { t }) => {
// Check if the conversation is from a voice channel
const isVoiceChannelConversation = computed(() => {
// First check the meta.inbox.channel_type
if (props.conversation?.meta?.inbox?.channel_type === 'Channel::Voice') {
return true;
}
// Also check the inbox_id to find the channel type
// This is useful when the meta.inbox is not fully populated
if (props.conversation?.inbox_id && props.conversation?.meta?.channel_type === 'Channel::Voice') {
return true;
}
return false;
});
// Function to check if a conversation is a voice channel conversation (non-computed version)
const isConversationFromVoiceChannel = (conversation) => {
if (!conversation) return false;
// Check meta inbox channel type
if (conversation.meta?.inbox?.channel_type === 'Channel::Voice') {
return true;
}
// Check meta channel type
if (conversation.meta?.channel_type === 'Channel::Voice') {
return true;
}
return false;
};
// Helper function to find call information from various sources
const getCallData = (conversation) => {
if (!conversation) return {};
// First check for data directly in conversation attributes
const conversationAttributes = conversation.custom_attributes || conversation.additional_attributes || {};
if (conversationAttributes.call_data) {
return conversationAttributes.call_data;
}
return {};
};
// Check if a message has an arrow prefix
const hasArrow = (message) => {
if (!message?.content) return false;
return (
typeof message.content === 'string' &&
(message.content.startsWith('←') ||
message.content.startsWith('→') ||
message.content.startsWith('↔️'))
);
};
// Determine if it's an incoming call
const isIncomingCall = (callData, message) => {
if (!message) return null;
// Check for arrow in content
if (hasArrow(message)) {
return message.content.startsWith('←');
}
// Try to use the direction stored in the call data
if (callData?.call_direction) {
return callData.call_direction === 'inbound';
}
// Fall back to message_type
return message.message_type === 0;
};
// Get normalized call status from multiple sources
const normalizeCallStatus = (status, isIncoming) => {
// Map from Twilio status to our UI status
const statusMap = {
'in-progress': 'active',
'completed': 'ended',
'canceled': 'ended',
'failed': 'ended',
'busy': 'no-answer',
'no-answer': isIncoming ? 'missed' : 'no-answer',
'active': 'active',
'missed': 'missed',
'ended': 'ended',
'ringing': 'ringing'
};
return statusMap[status] || status;
};
// Get the appropriate icon for a call status
const getCallIconName = (status, isIncoming) => {
if (status === 'missed' || status === 'no-answer') {
return 'i-ph-phone-x-fill';
}
if (status === 'active') {
return 'i-ph-phone-call-fill';
}
if (status === 'ended' || status === 'completed') {
return isIncoming ? 'i-ph-phone-incoming-fill' : 'i-ph-phone-outgoing-fill';
}
// Default phone icon for ringing state
return isIncoming
? 'i-ph-phone-incoming-fill'
: 'i-ph-phone-outgoing-fill';
};
// Get the appropriate text for a call status
const getStatusText = (status, isIncoming) => {
if (status === 'active') {
return t('CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS');
}
if (isIncoming) {
if (status === 'ringing') {
return t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
}
if (status === 'missed') {
return t('CONVERSATION.VOICE_CALL.MISSED_CALL');
}
if (status === 'ended') {
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
}
} else {
if (status === 'ringing') {
return t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
}
if (status === 'no-answer') {
return t('CONVERSATION.VOICE_CALL.NO_ANSWER');
}
if (status === 'ended') {
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
}
}
return isIncoming
? t('CONVERSATION.VOICE_CALL.INCOMING_CALL')
: t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
};
// Process message content with arrow prefix
const processArrowContent = (content, isIncoming, normalizedStatus) => {
// Remove arrows and clean up the text
let text = content.replace(/^[←→↔️]/, '').trim();
// If it only says "Voice Call" or "jo", add more descriptive status info
if (text === 'Voice Call' || text === 'jo' || text === '') {
return getStatusText(normalizedStatus, isIncoming);
}
return text;
};
return {
isVoiceChannelConversation,
isConversationFromVoiceChannel,
getCallData,
hasArrow,
isIncomingCall,
normalizeCallStatus,
getCallIconName,
getStatusText,
processArrowContent,
};
};
@@ -38,11 +38,13 @@ export const DEFAULT_ACTIONS = [
export const MESSAGE_CONDITION_VALUES = [
{
id: 'incoming',
name: 'Incoming Message',
name: 'Incoming',
i18nKey: 'INCOMING',
},
{
id: 'outgoing',
name: 'Outgoing Message',
name: 'Outgoing',
i18nKey: 'OUTGOING',
},
];
@@ -50,21 +52,26 @@ export const PRIORITY_CONDITION_VALUES = [
{
id: 'nil',
name: 'None',
i18nKey: 'NONE',
},
{
id: 'low',
name: 'Low',
i18nKey: 'LOW',
},
{
id: 'medium',
name: 'Medium',
i18nKey: 'MEDIUM',
},
{
id: 'high',
name: 'High',
i18nKey: 'HIGH',
},
{
id: 'urgent',
name: 'Urgent',
i18nKey: 'URGENT',
},
];
@@ -212,6 +212,33 @@ export class DashboardAudioNotificationHelper {
showBadgeOnFavicon();
this.playAudioEvery30Seconds();
};
onIncomingCall = () => {
// Always play audio alerts for incoming calls, regardless of other settings
// This ensures users never miss a call notification
// Use a different tone for calls if available, otherwise use regular tone
const originalTone = this.audioConfig.tone;
try {
// Temporarily set a call-specific tone if it exists
this.audioConfig.tone = 'call-ring';
this.intializeAudio();
this.playAudioAlert();
} catch (error) {
console.error('Error playing call notification:', error);
// Fallback to regular tone
this.audioConfig.tone = originalTone;
this.intializeAudio();
this.playAudioAlert();
} finally {
// Restore original tone for messages
this.audioConfig.tone = originalTone;
this.intializeAudio();
}
// Also show badge on favicon
showBadgeOnFavicon();
};
}
export default new DashboardAudioNotificationHelper(GlobalStore);
+16 -5
View File
@@ -110,12 +110,23 @@ export const hasValidAvatarUrl = avatarUrl => {
};
export const timeStampAppendedURL = dataUrl => {
const url = new URL(dataUrl);
if (!url.searchParams.has('t')) {
url.searchParams.append('t', Date.now());
}
try {
// Make sure the URL is valid before trying to construct it
if (!dataUrl || typeof dataUrl !== 'string') {
return '';
}
return url.toString();
const url = new URL(dataUrl);
if (!url.searchParams.has('t')) {
url.searchParams.append('t', Date.now());
}
return url.toString();
} catch (error) {
// If URL construction fails, just return the original URL
console.error('Invalid URL in timeStampAppendedURL:', error);
return dataUrl || '';
}
};
export const getHostNameFromURL = url => {
@@ -34,6 +34,10 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.updated': this.onConversationUpdated,
'account.cache_invalidated': this.onCacheInvalidate,
'copilot.message.created': this.onCopilotMessageCreated,
// Call events
'incoming_call': this.onIncomingCall,
'call_status_changed': this.onCallStatusChanged
};
}
@@ -200,6 +204,52 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
};
onIncomingCall = data => {
// Normalize snake_case to camelCase for consistency with frontend code
const normalizedPayload = {
callSid: data.call_sid,
conversationId: data.conversation_id,
inboxId: data.inbox_id,
inboxName: data.inbox_name,
inboxAvatarUrl: data.inbox_avatar_url,
inboxPhoneNumber: data.inbox_phone_number,
contactName: data.contact_name || 'Unknown Caller',
contactId: data.contact_id,
accountId: data.account_id,
isOutbound: data.is_outbound || false,
// CRITICAL: Use 'conference_sid' in camelCase format to match field names
conference_sid: data.conference_sid,
conferenceId: data.conference_sid, // Add aliases for consistency
conferenceSid: data.conference_sid, // Add aliases for consistency
requiresAgentJoin: data.requires_agent_join || false,
callDirection: data.call_direction,
phoneNumber: data.phone_number,
avatarUrl: data.avatar_url
};
// Update store
this.app.$store.dispatch('calls/setIncomingCall', normalizedPayload);
// Also update App.vue showCallWidget directly for immediate UI feedback
if (window.app && window.app.$data) {
window.app.$data.showCallWidget = true;
}
};
onCallStatusChanged = data => {
// Normalize snake_case to camelCase for consistency with frontend code
const normalizedPayload = {
callSid: data.call_sid,
status: data.status,
conversationId: data.conversation_id,
inboxId: data.inbox_id,
timestamp: data.timestamp || Date.now()
};
// Only dispatch to Vuex; Vuex handles widget and call state
this.app.$store.dispatch('calls/handleCallStatusChanged', normalizedPayload);
};
}
export default {
@@ -8,8 +8,6 @@ import {
DEFAULT_CONVERSATION_OPENED_CONDITION,
DEFAULT_OTHER_CONDITION,
DEFAULT_ACTIONS,
MESSAGE_CONDITION_VALUES,
PRIORITY_CONDITION_VALUES,
} from 'dashboard/constants/automation';
import filterQueryGenerator from './filterQueryGenerator';
import actionQueryGenerator from './actionQueryGenerator';
@@ -103,6 +101,7 @@ export const getActionOptions = ({
slaPolicies,
type,
addNoneToListFn,
priorityOptions,
}) => {
const actionsMap = {
assign_agent: addNoneToListFn ? addNoneToListFn(agents) : agents,
@@ -110,7 +109,7 @@ export const getActionOptions = ({
send_email_to_team: teams,
add_label: generateConditionOptions(labels, 'title'),
remove_label: generateConditionOptions(labels, 'title'),
change_priority: PRIORITY_CONDITION_VALUES,
change_priority: priorityOptions,
add_sla: slaPolicies,
};
return actionsMap[type];
@@ -128,6 +127,8 @@ export const getConditionOptions = ({
statusFilterOptions,
teams,
type,
priorityOptions,
messageTypeOptions,
}) => {
if (isCustomAttributeCheckbox(customAttributes, type)) {
return booleanFilterOptions;
@@ -147,8 +148,8 @@ export const getConditionOptions = ({
browser_language: languages,
conversation_language: languages,
country_code: countries,
message_type: MESSAGE_CONDITION_VALUES,
priority: PRIORITY_CONDITION_VALUES,
message_type: messageTypeOptions,
priority: priorityOptions,
};
return conditionFilterMaps[type];
+7 -8
View File
@@ -3,6 +3,7 @@ export const INBOX_TYPES = {
FB: 'Channel::FacebookPage',
TWITTER: 'Channel::TwitterProfile',
TWILIO: 'Channel::TwilioSms',
VOICE: 'Channel::Voice',
WHATSAPP: 'Channel::Whatsapp',
API: 'Channel::Api',
EMAIL: 'Channel::Email',
@@ -10,7 +11,6 @@ export const INBOX_TYPES = {
LINE: 'Channel::Line',
SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram',
VOICE: 'Channel::Voice',
};
const INBOX_ICON_MAP_FILL = {
@@ -50,7 +50,6 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
case INBOX_TYPES.TWILIO:
case INBOX_TYPES.WHATSAPP:
case INBOX_TYPES.VOICE:
return phoneNumber || '';
case INBOX_TYPES.EMAIL:
@@ -74,6 +73,9 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.TWILIO:
return phoneNumber?.startsWith('whatsapp') ? 'whatsapp' : 'sms';
case INBOX_TYPES.VOICE:
return 'voice';
case INBOX_TYPES.WHATSAPP:
return 'whatsapp';
@@ -89,9 +91,6 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.LINE:
return 'line';
case INBOX_TYPES.VOICE:
return 'voice';
default:
return 'chat';
}
@@ -113,6 +112,9 @@ export const getInboxClassByType = (type, phoneNumber) => {
? 'brand-whatsapp'
: 'brand-sms';
case INBOX_TYPES.VOICE:
return 'phone';
case INBOX_TYPES.WHATSAPP:
return 'brand-whatsapp';
@@ -131,9 +133,6 @@ export const getInboxClassByType = (type, phoneNumber) => {
case INBOX_TYPES.INSTAGRAM:
return 'brand-instagram';
case INBOX_TYPES.VOICE:
return 'phone';
default:
return 'chat';
}
@@ -2,6 +2,11 @@ import allLanguages from 'dashboard/components/widgets/conversation/advancedFilt
import allCountries from 'shared/constants/countries.js';
import {
MESSAGE_CONDITION_VALUES,
PRIORITY_CONDITION_VALUES,
} from 'dashboard/constants/automation';
export const customAttributes = [
{
id: 1,
@@ -468,7 +473,7 @@ export const inboxes = [
welcome_title: '',
welcome_tagline: '',
web_widget_script:
'\n <script>\n (function(d,t) {\n var BASE_URL="http://localhost:3000";\n var g=d.createElement(t),s=d.getElementsByTagName(t)[0];\n g.src=BASE_URL+"/packs/js/sdk.js";\n g.defer = true;\n g.async = true;\n s.parentNode.insertBefore(g,s);\n g.onload=function(){\n window.chatwootSDK.run({\n websiteToken: \'yZ7USzaEs7hrwUAHLGwjbxJ1\',\n baseUrl: BASE_URL\n })\n }\n })(document,"script");\n </script>\n ',
'\n <script>\n (function(d,t) {\n var BASE_URL="http://localhost:3000";\n var g=d.createElement(t),s=d.getElementsByTagName(t)[0];\n g.src=BASE_URL+"/packs/js/sdk.js";\n g.async = true;\n s.parentNode.insertBefore(g,s);\n g.onload=function(){\n window.chatwootSDK.run({\n websiteToken: \'yZ7USzaEs7hrwUAHLGwjbxJ1\',\n baseUrl: BASE_URL\n })\n }\n })(document,"script");\n </script>\n ',
website_token: 'yZ7USzaEs7hrwUAHLGwjbxJ1',
selected_feature_flags: ['attachments', 'emoji_picker', 'end_conversation'],
reply_time: 'in_a_few_minutes',
@@ -636,16 +641,16 @@ export const statusFilterOptions = [
];
export const languages = allLanguages;
export const countries = allCountries;
export const MESSAGE_CONDITION_VALUES = [
{
id: 'incoming',
name: 'Incoming Message',
},
{
id: 'outgoing',
name: 'Outgoing Message',
},
];
export const messageTypeOptions = MESSAGE_CONDITION_VALUES.map(item => ({
id: item.id,
name: `AUTOMATION.MESSAGE_TYPES.${item.i18nKey}`,
}));
export const priorityOptions = PRIORITY_CONDITION_VALUES.map(item => ({
id: item.id,
name: `AUTOMATION.PRIORITY_TYPES.${item.i18nKey}`,
}));
export const automationToSubmit = {
name: 'Fayaz',
@@ -147,7 +147,8 @@
"SEND_ATTACHMENT": "Send Attachment",
"SEND_MESSAGE": "Send a Message",
"CHANGE_PRIORITY": "Change Priority",
"ADD_SLA": "Add SLA"
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Open conversation"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
@@ -49,5 +49,8 @@
"HOURS": "Hours",
"DAYS": "Days",
"PLACEHOLDER": "Enter duration"
},
"CHANNEL_SELECTOR": {
"COMING_SOON": "Coming Soon!"
}
}
@@ -96,6 +96,10 @@
"NEXT_WEEK": "Next week"
}
},
"MENTION": {
"AGENTS": "Agents",
"TEAMS": "Teams"
},
"CUSTOM_SNOOZE": {
"TITLE": "Snooze until",
"APPLY": "Snooze",
@@ -3,7 +3,7 @@
"LIMIT_MESSAGES": {
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
"AGENTS": "You have exceeded the agent limit. Hacker plan allows only 2 agents.",
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
},
"TITLE": "Account settings",
@@ -268,6 +268,46 @@
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
}
},
"VOICE": {
"TITLE": "Voice Channel",
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
"PHONE_NUMBER": {
"LABEL": "Phone Number",
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
},
"TWILIO": {
"ACCOUNT_SID": {
"LABEL": "Account SID",
"PLACEHOLDER": "Enter your Twilio Account SID",
"REQUIRED": "Account SID is required"
},
"AUTH_TOKEN": {
"LABEL": "Auth Token",
"PLACEHOLDER": "Enter your Twilio Auth Token",
"REQUIRED": "Auth Token is required"
},
"API_KEY_SID": {
"LABEL": "API Key SID",
"PLACEHOLDER": "Enter your Twilio API Key SID",
"REQUIRED": "API Key SID is required"
},
"API_KEY_SECRET": {
"LABEL": "API Key Secret",
"PLACEHOLDER": "Enter your Twilio API Key Secret",
"REQUIRED": "API Key Secret is required"
},
"TWIML_APP_SID": {
"LABEL": "TwiML App SID",
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
"REQUIRED": "TwiML App SID is required"
}
},
"SUBMIT_BUTTON": "Create Voice Channel",
"API": {
"ERROR_MESSAGE": "We were not able to create the voice channel"
}
},
"API_CHANNEL": {
"TITLE": "API Channel",
"DESC": "Integrate with API channel and start supporting your customers.",
@@ -789,7 +829,8 @@
"TELEGRAM": "Telegram",
"LINE": "Line",
"API": "API Channel",
"INSTAGRAM": "Instagram"
"INSTAGRAM": "Instagram",
"VOICE": "Voice"
}
}
}
@@ -331,6 +331,14 @@
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
"BUTTON_TEXT": "Connect Linear workspace"
}
},
"NOTION": {
"DELETE": {
"TITLE": "Are you sure you want to delete the Notion integration?",
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
"CONFIRM": "Yes, delete",
"CANCEL": "Cancel"
}
}
},
"CAPTAIN": {
@@ -539,6 +547,8 @@
"CONVERSATION": "Conversation #{id}"
},
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Delete",
"BULK_APPROVE": {
@@ -147,7 +147,8 @@
"SEND_ATTACHMENT": "Send Attachment",
"SEND_MESSAGE": "Send a Message",
"CHANGE_PRIORITY": "تغيير الأولوية",
"ADD_SLA": "Add SLA"
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "فتح المحادثة"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
@@ -49,5 +49,8 @@
"HOURS": "Hours",
"DAYS": "Days",
"PLACEHOLDER": "Enter duration"
},
"CHANNEL_SELECTOR": {
"COMING_SOON": "Coming Soon!"
}
}
@@ -96,6 +96,10 @@
"NEXT_WEEK": "الأسبوع القادم"
}
},
"MENTION": {
"AGENTS": "الوكلاء",
"TEAMS": "الفرق"
},
"CUSTOM_SNOOZE": {
"TITLE": "تأجيل حتى",
"APPLY": "تأجيل",
@@ -3,7 +3,7 @@
"LIMIT_MESSAGES": {
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
"AGENTS": "You have exceeded the agent limit. Hacker plan allows only 2 agents.",
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
},
"TITLE": "إعدادات الحساب",
@@ -268,6 +268,46 @@
"ERROR_MESSAGE": "لم نتمكن من حفظ قناة واتساب"
}
},
"VOICE": {
"TITLE": "Voice Channel",
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
"PHONE_NUMBER": {
"LABEL": "رقم الهاتف",
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
},
"TWILIO": {
"ACCOUNT_SID": {
"LABEL": "معرف حساب Twilio (يعرف أيضاً بـ Account SID)",
"PLACEHOLDER": "Enter your Twilio Account SID",
"REQUIRED": "Account SID is required"
},
"AUTH_TOKEN": {
"LABEL": "رمز المصادقة Auth Token",
"PLACEHOLDER": "Enter your Twilio Auth Token",
"REQUIRED": "Auth Token is required"
},
"API_KEY_SID": {
"LABEL": "مفتاح API SID",
"PLACEHOLDER": "Enter your Twilio API Key SID",
"REQUIRED": "API Key SID is required"
},
"API_KEY_SECRET": {
"LABEL": "مفتاح سر API",
"PLACEHOLDER": "Enter your Twilio API Key Secret",
"REQUIRED": "API Key Secret is required"
},
"TWIML_APP_SID": {
"LABEL": "TwiML App SID",
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
"REQUIRED": "TwiML App SID is required"
}
},
"SUBMIT_BUTTON": "Create Voice Channel",
"API": {
"ERROR_MESSAGE": "We were not able to create the voice channel"
}
},
"API_CHANNEL": {
"TITLE": "قناة API",
"DESC": "اربط مع قناة API وابدأ في دعم عملائك.",
@@ -789,7 +829,8 @@
"TELEGRAM": "تيليجرام",
"LINE": "Line",
"API": "قناة API",
"INSTAGRAM": "Instagram"
"INSTAGRAM": "Instagram",
"VOICE": "Voice"
}
}
}
@@ -331,6 +331,14 @@
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
"BUTTON_TEXT": "Connect Linear workspace"
}
},
"NOTION": {
"DELETE": {
"TITLE": "Are you sure you want to delete the Notion integration?",
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
"CONFIRM": "نعم، احذف",
"CANCEL": "إلغاء"
}
}
},
"CAPTAIN": {
@@ -539,6 +547,8 @@
"CONVERSATION": "Conversation #{id}"
},
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "حذف",
"BULK_APPROVE": {
@@ -147,7 +147,8 @@
"SEND_ATTACHMENT": "Send Attachment",
"SEND_MESSAGE": "Send a Message",
"CHANGE_PRIORITY": "Change Priority",
"ADD_SLA": "Add SLA"
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Open conversation"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
@@ -49,5 +49,8 @@
"HOURS": "Hours",
"DAYS": "Days",
"PLACEHOLDER": "Enter duration"
},
"CHANNEL_SELECTOR": {
"COMING_SOON": "Coming Soon!"
}
}
@@ -96,6 +96,10 @@
"NEXT_WEEK": "Next week"
}
},
"MENTION": {
"AGENTS": "Agents",
"TEAMS": "Teams"
},
"CUSTOM_SNOOZE": {
"TITLE": "Snooze until",
"APPLY": "Snooze",
@@ -3,7 +3,7 @@
"LIMIT_MESSAGES": {
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
"AGENTS": "You have exceeded the agent limit. Hacker plan allows only 2 agents.",
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
},
"TITLE": "Account settings",
@@ -268,6 +268,46 @@
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
}
},
"VOICE": {
"TITLE": "Voice Channel",
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
"PHONE_NUMBER": {
"LABEL": "Phone Number",
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
},
"TWILIO": {
"ACCOUNT_SID": {
"LABEL": "Account SID",
"PLACEHOLDER": "Enter your Twilio Account SID",
"REQUIRED": "Account SID is required"
},
"AUTH_TOKEN": {
"LABEL": "Auth Token",
"PLACEHOLDER": "Enter your Twilio Auth Token",
"REQUIRED": "Auth Token is required"
},
"API_KEY_SID": {
"LABEL": "API Key SID",
"PLACEHOLDER": "Enter your Twilio API Key SID",
"REQUIRED": "API Key SID is required"
},
"API_KEY_SECRET": {
"LABEL": "API Key Secret",
"PLACEHOLDER": "Enter your Twilio API Key Secret",
"REQUIRED": "API Key Secret is required"
},
"TWIML_APP_SID": {
"LABEL": "TwiML App SID",
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
"REQUIRED": "TwiML App SID is required"
}
},
"SUBMIT_BUTTON": "Create Voice Channel",
"API": {
"ERROR_MESSAGE": "We were not able to create the voice channel"
}
},
"API_CHANNEL": {
"TITLE": "API Channel",
"DESC": "Integrate with API channel and start supporting your customers.",
@@ -789,7 +829,8 @@
"TELEGRAM": "Telegram",
"LINE": "Line",
"API": "API Channel",
"INSTAGRAM": "Instagram"
"INSTAGRAM": "Instagram",
"VOICE": "Voice"
}
}
}
@@ -331,6 +331,14 @@
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
"BUTTON_TEXT": "Connect Linear workspace"
}
},
"NOTION": {
"DELETE": {
"TITLE": "Are you sure you want to delete the Notion integration?",
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
"CONFIRM": "Yes, delete",
"CANCEL": "Cancel"
}
}
},
"CAPTAIN": {
@@ -539,6 +547,8 @@
"CONVERSATION": "Conversation #{id}"
},
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Delete",
"BULK_APPROVE": {
@@ -147,7 +147,8 @@
"SEND_ATTACHMENT": "Send Attachment",
"SEND_MESSAGE": "Send a Message",
"CHANGE_PRIORITY": "Change Priority",
"ADD_SLA": "Add SLA"
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Open conversation"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
@@ -49,5 +49,8 @@
"HOURS": "Hours",
"DAYS": "Days",
"PLACEHOLDER": "Enter duration"
},
"CHANNEL_SELECTOR": {
"COMING_SOON": "Coming Soon!"
}
}
@@ -96,6 +96,10 @@
"NEXT_WEEK": "Next week"
}
},
"MENTION": {
"AGENTS": "Агенти",
"TEAMS": "Teams"
},
"CUSTOM_SNOOZE": {
"TITLE": "Snooze until",
"APPLY": "Snooze",
@@ -3,7 +3,7 @@
"LIMIT_MESSAGES": {
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
"AGENTS": "You have exceeded the agent limit. Hacker plan allows only 2 agents.",
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
},
"TITLE": "Account settings",
@@ -268,6 +268,46 @@
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
}
},
"VOICE": {
"TITLE": "Voice Channel",
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
"PHONE_NUMBER": {
"LABEL": "Phone Number",
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
},
"TWILIO": {
"ACCOUNT_SID": {
"LABEL": "Account SID",
"PLACEHOLDER": "Enter your Twilio Account SID",
"REQUIRED": "Account SID is required"
},
"AUTH_TOKEN": {
"LABEL": "Auth Token",
"PLACEHOLDER": "Enter your Twilio Auth Token",
"REQUIRED": "Auth Token is required"
},
"API_KEY_SID": {
"LABEL": "API Key SID",
"PLACEHOLDER": "Enter your Twilio API Key SID",
"REQUIRED": "API Key SID is required"
},
"API_KEY_SECRET": {
"LABEL": "API Key Secret",
"PLACEHOLDER": "Enter your Twilio API Key Secret",
"REQUIRED": "API Key Secret is required"
},
"TWIML_APP_SID": {
"LABEL": "TwiML App SID",
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
"REQUIRED": "TwiML App SID is required"
}
},
"SUBMIT_BUTTON": "Create Voice Channel",
"API": {
"ERROR_MESSAGE": "We were not able to create the voice channel"
}
},
"API_CHANNEL": {
"TITLE": "API Channel",
"DESC": "Integrate with API channel and start supporting your customers.",
@@ -789,7 +829,8 @@
"TELEGRAM": "Telegram",
"LINE": "Line",
"API": "API Channel",
"INSTAGRAM": "Instagram"
"INSTAGRAM": "Instagram",
"VOICE": "Voice"
}
}
}
@@ -331,6 +331,14 @@
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
"BUTTON_TEXT": "Connect Linear workspace"
}
},
"NOTION": {
"DELETE": {
"TITLE": "Are you sure you want to delete the Notion integration?",
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
"CONFIRM": "Yes, delete",
"CANCEL": "Отмени"
}
}
},
"CAPTAIN": {
@@ -539,6 +547,8 @@
"CONVERSATION": "Conversation #{id}"
},
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Изтрий",
"BULK_APPROVE": {
@@ -147,7 +147,8 @@
"SEND_ATTACHMENT": "Send Attachment",
"SEND_MESSAGE": "Send a Message",
"CHANGE_PRIORITY": "Canvia la prioritat",
"ADD_SLA": "Afegeix SLA"
"ADD_SLA": "Afegeix SLA",
"OPEN_CONVERSATION": "Obrir conversa"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
@@ -49,5 +49,8 @@
"HOURS": "Hours",
"DAYS": "Days",
"PLACEHOLDER": "Enter duration"
},
"CHANNEL_SELECTOR": {
"COMING_SOON": "Coming Soon!"
}
}
@@ -96,6 +96,10 @@
"NEXT_WEEK": "Pròxima setmana"
}
},
"MENTION": {
"AGENTS": "Agents",
"TEAMS": "Equips"
},
"CUSTOM_SNOOZE": {
"TITLE": "Posposat fins a",
"APPLY": "Posposat",
@@ -3,7 +3,7 @@
"LIMIT_MESSAGES": {
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
"AGENTS": "You have exceeded the agent limit. Hacker plan allows only 2 agents.",
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
},
"TITLE": "Configuració del compte",
@@ -268,6 +268,46 @@
"ERROR_MESSAGE": "No hem pogut desar el canal WhatsApp"
}
},
"VOICE": {
"TITLE": "Voice Channel",
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
"PHONE_NUMBER": {
"LABEL": "Número de telèfon",
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
},
"TWILIO": {
"ACCOUNT_SID": {
"LABEL": "Compte SID",
"PLACEHOLDER": "Enter your Twilio Account SID",
"REQUIRED": "Account SID is required"
},
"AUTH_TOKEN": {
"LABEL": "Token d'autenticació",
"PLACEHOLDER": "Enter your Twilio Auth Token",
"REQUIRED": "Auth Token is required"
},
"API_KEY_SID": {
"LABEL": "API Key SID",
"PLACEHOLDER": "Enter your Twilio API Key SID",
"REQUIRED": "API Key SID is required"
},
"API_KEY_SECRET": {
"LABEL": "API Key Secret",
"PLACEHOLDER": "Enter your Twilio API Key Secret",
"REQUIRED": "API Key Secret is required"
},
"TWIML_APP_SID": {
"LABEL": "TwiML App SID",
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
"REQUIRED": "TwiML App SID is required"
}
},
"SUBMIT_BUTTON": "Create Voice Channel",
"API": {
"ERROR_MESSAGE": "We were not able to create the voice channel"
}
},
"API_CHANNEL": {
"TITLE": "Canal de l'API",
"DESC": "Integrat amb el canal API i comença a donar suport als teus clients.",
@@ -789,7 +829,8 @@
"TELEGRAM": "Telegram",
"LINE": "Line",
"API": "Canal de l'API",
"INSTAGRAM": "Instagram"
"INSTAGRAM": "Instagram",
"VOICE": "Voice"
}
}
}
@@ -331,6 +331,14 @@
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
"BUTTON_TEXT": "Connect Linear workspace"
}
},
"NOTION": {
"DELETE": {
"TITLE": "Are you sure you want to delete the Notion integration?",
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
"CONFIRM": "Sí, esborra",
"CANCEL": "Cancel·la"
}
}
},
"CAPTAIN": {
@@ -539,6 +547,8 @@
"CONVERSATION": "Conversation #{id}"
},
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Esborrar",
"BULK_APPROVE": {
@@ -147,7 +147,8 @@
"SEND_ATTACHMENT": "Odeslat přílohu",
"SEND_MESSAGE": "Odeslat zprávu",
"CHANGE_PRIORITY": "Change Priority",
"ADD_SLA": "Add SLA"
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Open conversation"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Typ zprávy",
@@ -49,5 +49,8 @@
"HOURS": "Hours",
"DAYS": "Days",
"PLACEHOLDER": "Enter duration"
},
"CHANNEL_SELECTOR": {
"COMING_SOON": "Coming Soon!"
}
}
@@ -96,6 +96,10 @@
"NEXT_WEEK": "Příští týden"
}
},
"MENTION": {
"AGENTS": "Agenti",
"TEAMS": "Týmy"
},
"CUSTOM_SNOOZE": {
"TITLE": "Odložit do",
"APPLY": "Odložit",
@@ -3,7 +3,7 @@
"LIMIT_MESSAGES": {
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
"AGENTS": "You have exceeded the agent limit. Hacker plan allows only 2 agents.",
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
},
"TITLE": "Nastavení účtu",
@@ -268,6 +268,46 @@
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
}
},
"VOICE": {
"TITLE": "Voice Channel",
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
"PHONE_NUMBER": {
"LABEL": "Telefonní číslo",
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
},
"TWILIO": {
"ACCOUNT_SID": {
"LABEL": "SID účtu",
"PLACEHOLDER": "Enter your Twilio Account SID",
"REQUIRED": "Account SID is required"
},
"AUTH_TOKEN": {
"LABEL": "Auth Token",
"PLACEHOLDER": "Enter your Twilio Auth Token",
"REQUIRED": "Auth Token is required"
},
"API_KEY_SID": {
"LABEL": "API Key SID",
"PLACEHOLDER": "Enter your Twilio API Key SID",
"REQUIRED": "API Key SID is required"
},
"API_KEY_SECRET": {
"LABEL": "API Key Secret",
"PLACEHOLDER": "Enter your Twilio API Key Secret",
"REQUIRED": "API Key Secret is required"
},
"TWIML_APP_SID": {
"LABEL": "TwiML App SID",
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
"REQUIRED": "TwiML App SID is required"
}
},
"SUBMIT_BUTTON": "Create Voice Channel",
"API": {
"ERROR_MESSAGE": "We were not able to create the voice channel"
}
},
"API_CHANNEL": {
"TITLE": "API Channel",
"DESC": "Integrate with API channel and start supporting your customers.",
@@ -789,7 +829,8 @@
"TELEGRAM": "Telegram",
"LINE": "Line",
"API": "API Channel",
"INSTAGRAM": "Instagram"
"INSTAGRAM": "Instagram",
"VOICE": "Voice"
}
}
}
@@ -331,6 +331,14 @@
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
"BUTTON_TEXT": "Connect Linear workspace"
}
},
"NOTION": {
"DELETE": {
"TITLE": "Are you sure you want to delete the Notion integration?",
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
"CONFIRM": "Yes, delete",
"CANCEL": "Zrušit"
}
}
},
"CAPTAIN": {
@@ -539,6 +547,8 @@
"CONVERSATION": "Conversation #{id}"
},
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Vymazat",
"BULK_APPROVE": {
@@ -147,7 +147,8 @@
"SEND_ATTACHMENT": "Send Attachment",
"SEND_MESSAGE": "Send a Message",
"CHANGE_PRIORITY": "Change Priority",
"ADD_SLA": "Add SLA"
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Åbn samtale"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
@@ -49,5 +49,8 @@
"HOURS": "Hours",
"DAYS": "Days",
"PLACEHOLDER": "Enter duration"
},
"CHANNEL_SELECTOR": {
"COMING_SOON": "Coming Soon!"
}
}

Some files were not shown because too many files have changed in this diff Show More