diff --git a/app/controllers/api/v1/accounts/channels/voice/webhooks_controller.rb b/app/controllers/api/v1/accounts/channels/voice/webhooks_controller.rb index c149f1b05..bb9e6eec1 100644 --- a/app/controllers/api/v1/accounts/channels/voice/webhooks_controller.rb +++ b/app/controllers/api/v1/accounts/channels/voice/webhooks_controller.rb @@ -61,6 +61,16 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts } ).perform + # Broadcast incoming call notification on account-level channel + broadcast_call_status('incoming_call', { + call_sid: call_sid, + conversation_id: conversation.id, + inbox_id: inbox.id, + inbox_name: inbox.name, + contact_name: contact.name || from_number, + contact_id: contact.id + }, account_id: inbox.account_id) + # Generate minimal TwiML response response = Twilio::TwiML::VoiceResponse.new response.say(message: 'Thank you for calling. An agent will be with you shortly.') @@ -144,18 +154,12 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts } ).perform - # Broadcast minimal update to frontend - ActionCable.server.broadcast( - "#{conversation.account_id}_#{conversation.inbox_id}", - { - event_name: 'call_status_changed', - data: { - call_sid: call_sid, - status: conversation.additional_attributes['call_status'] || 'in-progress', - conversation_id: conversation.id - } - } - ) + # Broadcast call status updates on account-level channel + broadcast_call_status('call_status_changed', { + call_sid: call_sid, + status: conversation.additional_attributes['call_status'] || 'in-progress', + conversation_id: conversation.id + }, account_id: conversation.account_id) # Return minimal response head :ok @@ -210,4 +214,17 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts def base_url ENV.fetch('FRONTEND_URL', "https://#{request.host_with_port}") end + + def broadcast_call_status(event_name, data, account_id:) + # Include account_id in the data to help with validation + data_with_account = data.merge(account_id: account_id) + + ActionCable.server.broadcast( + "account_#{account_id}", + { + event: event_name, + data: data_with_account + } + ) + end end diff --git a/app/controllers/api/v1/accounts/voice_controller.rb b/app/controllers/api/v1/accounts/voice_controller.rb index f5e5899fd..b023d332e 100644 --- a/app/controllers/api/v1/accounts/voice_controller.rb +++ b/app/controllers/api/v1/accounts/voice_controller.rb @@ -1,5 +1,5 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController - before_action :fetch_conversation, only: [:end_call, :call_status] + before_action :fetch_conversation, only: [:end_call, :join_call, :reject_call, :call_status] def end_call call_sid = params[:call_sid] || @conversation.additional_attributes&.dig('call_sid') @@ -53,6 +53,121 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController end end + def join_call + call_sid = params[:call_sid] || @conversation.additional_attributes&.dig('call_sid') + return render json: { error: 'No active call found' }, status: :not_found unless call_sid + + # Get the inbox and channel information + inbox = @conversation.inbox + channel = inbox&.channel + + if channel.is_a?(Channel::Voice) && channel.provider == 'twilio' + config = channel.provider_config_hash + + begin + # Create a Twilio client + client = Twilio::REST::Client.new(config['account_sid'], config['auth_token']) + + # Get the conference SID from the conversation attributes + # For incoming calls, Twilio typically places the caller in a conference that agents can join + conference_sid = @conversation.additional_attributes&.dig('conference_sid') + + if conference_sid + # Create a call that connects the agent to the conference + client.calls.create( + to: current_user.phone_number || config['agent_phone_number'], + from: channel.phone_number, + status_callback: "#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/status_callback", + status_callback_event: ['initiated', 'ringing', 'answered', 'completed'], + status_callback_method: 'POST', + url: "#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/twiml?conference_sid=#{conference_sid}&agent_id=#{current_user.id}" + ) + + # Update conversation to show agent joined + @conversation.additional_attributes ||= {} + @conversation.additional_attributes['agent_joined'] = true + @conversation.additional_attributes['joined_at'] = Time.now.to_i + @conversation.additional_attributes['joined_by'] = { + id: current_user.id, + name: current_user.name + } + @conversation.save! + + # Create an activity message noting the agent joined + Messages::MessageBuilder.new( + nil, + @conversation, + { + content: "#{current_user.name} joined the call", + message_type: :activity, + additional_attributes: { + call_sid: call_sid, + conference_sid: conference_sid, + joined_by: current_user.name, + joined_at: Time.now.to_i + } + } + ).perform + + render json: { + status: 'success', + message: 'Agent joining call', + conference_sid: conference_sid + } + else + render json: { error: 'Conference not found for this call' }, status: :unprocessable_entity + end + rescue Twilio::REST::RestError => e + render json: { error: "Failed to join call: #{e.message}" }, status: :internal_server_error + end + else + render json: { error: 'Unsupported channel provider for call control' }, status: :unprocessable_entity + end + end + + def reject_call + call_sid = params[:call_sid] || @conversation.additional_attributes&.dig('call_sid') + return render json: { error: 'No active call found' }, status: :not_found unless call_sid + + # Get the inbox and channel information + inbox = @conversation.inbox + channel = inbox&.channel + + if channel.is_a?(Channel::Voice) && channel.provider == 'twilio' + # Update conversation to show agent rejected call + @conversation.additional_attributes ||= {} + @conversation.additional_attributes['agent_rejected'] = true + @conversation.additional_attributes['rejected_at'] = Time.now.to_i + @conversation.additional_attributes['rejected_by'] = { + id: current_user.id, + name: current_user.name + } + @conversation.save! + + # Create an activity message noting the agent rejected the call + Messages::MessageBuilder.new( + nil, + @conversation, + { + content: "#{current_user.name} declined to answer", + message_type: :activity, + additional_attributes: { + call_sid: call_sid, + rejected_by: current_user.name, + rejected_at: Time.now.to_i + } + } + ).perform + + render json: { + status: 'success', + message: 'Call rejected by agent' + } + else + render json: { error: 'Unsupported channel provider for call control' }, status: :unprocessable_entity + end + end + def call_status call_sid = @conversation.additional_attributes&.dig('call_sid') return render json: { error: 'No call found' }, status: :not_found unless call_sid diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 8998c0df0..81445bf78 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -55,7 +55,7 @@ export default { showAddAccountModal: false, latestChatwootVersion: null, reconnectService: null, - showCallWidget: false, // Set to true for testing, false for production + showCallWidget: false, // Will be set to true when calls are active }; }, computed: { @@ -67,6 +67,8 @@ export default { accountUIFlags: 'accounts/getUIFlags', activeCall: 'calls/getActiveCall', hasActiveCall: 'calls/hasActiveCall', + incomingCall: 'calls/getIncomingCall', + hasIncomingCall: 'calls/hasIncomingCall', }), hasAccounts() { const { accounts = [] } = this.currentUser || {}; @@ -91,18 +93,34 @@ export default { } }, }, + hasIncomingCall: { + immediate: true, + handler(newVal) { + console.log('App.vue detected change in hasIncomingCall to', newVal); + if (newVal) { + console.log('Incoming call data:', this.incomingCall); + this.showCallWidget = true; + } + } + }, + hasActiveCall: { + immediate: true, + handler(newVal) { + console.log('App.vue detected change in hasActiveCall to', newVal); + if (newVal) { + console.log('Active call data:', this.activeCall); + this.showCallWidget = true; + } + } + }, }, mounted() { - // Make app instance available globally for debugging and cross-component access - window.app = this; - - // Set up global force end call mechanism - window.forceEndCall = () => this.forceEndCall(); - window.forceEndCallHandlers = []; - 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) { @@ -121,80 +139,52 @@ export default { this.$root.$i18n.locale = locale; }, handleCallEnded() { - console.log('Call ended event received in App.vue'); - // Update our local state first for immediate UI update this.showCallWidget = false; - // Then update the store this.$store.dispatch('calls/clearActiveCall'); + this.$store.dispatch('calls/clearIncomingCall'); }, - - // Public method that can be called from anywhere + handleCallJoined() { + this.showCallWidget = true; + }, + handleCallRejected() { + this.showCallWidget = false; + this.$store.dispatch('calls/clearIncomingCall'); + }, forceEndCall() { - console.log('Force end call triggered in App.vue'); - - // 1. Update UI immediately this.showCallWidget = false; - - // 2. Try to notify any other components if (window.forceEndCallHandlers) { window.forceEndCallHandlers.forEach(handler => { try { handler(); } catch (e) { - console.error('Error in end call handler:', e); + // Optionally log error in production } }); } - - // 3. CRITICAL: Make API call to actually end the call on the server if (this.activeCall && this.activeCall.callSid) { const { callSid, conversationId } = this.activeCall; - - // Save references before clearing the store const savedCallSid = callSid; const savedConversationId = conversationId; - - // Now clear the store this.$store.dispatch('calls/clearActiveCall'); - - // Make API call if we have a conversation ID if (savedConversationId) { - console.log( - 'App.vue making API call to end call with SID:', - savedCallSid, - 'for conversation:', - savedConversationId - ); - - // Make the API call to end the call on the server with both parameters VoiceAPI.endCall(savedCallSid, savedConversationId) .then(response => { - console.log('Call ended successfully via API:', response); useAlert({ message: 'Call ended successfully', type: 'success' }); }) .catch(error => { - console.error('Error ending call via API:', error); - - // If first attempt fails, try one more time with additional logging - console.log('Retrying end call with more debugging...'); setTimeout(() => { VoiceAPI.endCall(savedCallSid, savedConversationId) .then(retryResponse => { - console.log('Retry successful:', retryResponse); }) .catch(retryError => { - console.error('Retry also failed:', retryError); }); }, 1000); - useAlert({ message: 'Call UI has been reset', type: 'info' }); }); } else { - console.log('App.vue: Not making API call because conversation ID is missing'); useAlert({ message: 'Call ended', type: 'success' }); } } else { - // No active call data, just clear the store this.$store.dispatch('calls/clearActiveCall'); } }, @@ -247,12 +237,16 @@ export default { diff --git a/app/javascript/dashboard/api/channels/voice.js b/app/javascript/dashboard/api/channels/voice.js index 1d345de50..a07950f53 100644 --- a/app/javascript/dashboard/api/channels/voice.js +++ b/app/javascript/dashboard/api/channels/voice.js @@ -3,76 +3,87 @@ import ApiClient from '../ApiClient'; class VoiceAPI extends ApiClient { constructor() { - // Use empty string for resource to avoid duplicate 'accounts' in URL - super('', { accountScoped: true }); + // Use 'voice' as the resource with accountScoped: true + super('voice', { accountScoped: true }); } // Initiate a call to a contact initiateCall(contactId) { - // Get the account ID from the current URL - const accountId = this.accountIdFromRoute; - // Make sure we have the right endpoint path - return axios.post( - `/api/v1/accounts/${accountId}/contacts/${contactId}/call` - ); + 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) { - console.error('VoiceAPI: Cannot end call - conversation ID is required'); - return Promise.reject( - new Error('Conversation ID is required to end a call') - ); + throw new Error('Conversation ID is required to end a call'); } if (!callSid) { - console.error('VoiceAPI: Cannot end call - call SID is required'); - return Promise.reject(new Error('Call SID is required to end a call')); + throw new Error('Call SID is required to end a call'); } - // Validate call SID format - Twilio call SID starts with 'CA' followed by alphanumeric characters + // Validate call SID format - Twilio call SID starts with 'CA' or 'TJ' if (!callSid.startsWith('CA') && !callSid.startsWith('TJ')) { - console.error('VoiceAPI: Invalid call SID format:', callSid); - return Promise.reject( - new Error( - 'Invalid call SID format. Expected Twilio call SID starting with CA or TJ.' - ) + throw new Error( + 'Invalid call SID format. Expected Twilio call SID starting with CA or TJ.' ); } - // Get the account ID from the current URL - const accountId = this.accountIdFromRoute; - console.log( - `VoiceAPI: Ending call with SID ${callSid} for conversation ${conversationId} in account ${accountId}` - ); - - // Make the actual API call with conversation ID as a parameter - // Using the route structure that matches the Rails routes.rb definition - return axios - .post(`/api/v1/accounts/${accountId}/voice/end_call`, { - call_sid: callSid, - conversation_id: conversationId, - id: conversationId, // Also include as 'id' as the controller may check for it - }) - .then(response => { - console.log('VoiceAPI: End call API succeeded:', response); - return response; - }) - .catch(error => { - console.error('VoiceAPI: End call API failed:', error); - throw error; - }); + return axios.post(`${this.url}/end_call`, { + call_sid: callSid, + conversation_id: conversationId, + id: conversationId, + }); } // Get call status getCallStatus(callSid) { - // Get the account ID from the current URL - const accountId = this.accountIdFromRoute; - return axios.get(`/api/v1/accounts/${accountId}/voice/call_status`, { + 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) + joinCall(callSid, conversationId) { + 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'); + } + + return axios.post(`${this.url}/join_call`, { + call_sid: callSid, + conversation_id: conversationId, + }); + } + + // 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, + }); + } } export default new VoiceAPI(); diff --git a/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue b/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue index 29e4f7559..b42d4c2e0 100644 --- a/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue +++ b/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue @@ -4,6 +4,7 @@ import { useStore } from 'vuex'; import { useAlert } from 'dashboard/composables'; import { useI18n } from 'vue-i18n'; import VoiceAPI from 'dashboard/api/channels/voice'; +import ContactAPI from 'dashboard/api/contacts'; export default { name: 'FloatingCallWidget', @@ -20,8 +21,16 @@ export default { type: [Number, String], default: null, }, + contactName: { + type: String, + default: '', + }, + contactId: { + type: [Number, String], + default: null, + }, }, - emits: ['call-ended'], + emits: ['callEnded', 'callJoined', 'callRejected'], setup(props, { emit }) { const store = useStore(); const { t } = useI18n(); @@ -31,20 +40,47 @@ export default { const isMuted = ref(false); const showCallOptions = ref(false); const isFullscreen = ref(false); + const ringtoneAudio = ref(null); + const displayContactName = ref(props.contactName || 'Loading...'); // Define local fallback translations in case i18n fails const translations = { 'CONVERSATION.END_CALL': 'End call', + 'CONVERSATION.JOIN_CALL': 'Join call', + 'CONVERSATION.REJECT_CALL': 'Reject', 'CONVERSATION.CALL_ENDED': 'Call ended', 'CONVERSATION.CALL_END_ERROR': 'Failed to end call', + 'CONVERSATION.CALL_ACCEPTED': 'Joining call...', + 'CONVERSATION.CALL_REJECTED': 'Call rejected', + 'CONVERSATION.CALL_JOIN_ERROR': 'Failed to join call', + 'CONVERSATION.INCOMING_CALL': 'Incoming call', }; + // Computed properties + const activeCall = computed(() => store.getters['calls/getActiveCall']); + const incomingCall = computed(() => store.getters['calls/getIncomingCall']); + const hasIncomingCall = computed(() => store.getters['calls/hasIncomingCall']); + const hasActiveCall = computed(() => store.getters['calls/hasActiveCall']); + + const isIncoming = computed(() => { + return hasIncomingCall.value && !hasActiveCall.value; + }); + + const callInfo = computed(() => { + return isIncoming.value ? incomingCall.value : activeCall.value; + }); + + const isJoined = computed(() => { + return activeCall.value && activeCall.value.isJoined; + }); + const formattedCallDuration = computed(() => { const minutes = Math.floor(callDuration.value / 60); const seconds = callDuration.value % 60; return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; }); + // Methods const startDurationTimer = () => { console.log('Starting duration timer'); if (durationTimer.value) clearInterval(durationTimer.value); @@ -61,21 +97,58 @@ export default { } }; + const playRingtone = () => { + if (!ringtoneAudio.value) { + // Fixed path to an existing audio file + ringtoneAudio.value = new Audio('/audio/dashboard/call-ring.mp3'); + ringtoneAudio.value.loop = true; + + // Preload the audio to reduce delay + ringtoneAudio.value.preload = 'auto'; + + // Make sure volume is set appropriately + ringtoneAudio.value.volume = 0.7; + + // Log confirmation + console.log('Ringtone audio initialized with path: /audio/dashboard/call-ring.mp3'); + } + + // Force play with user interaction if needed + const playPromise = ringtoneAudio.value.play(); + + if (playPromise !== undefined) { + playPromise.catch(error => { + console.error('Failed to play ringtone:', error); + + // If autoplay was prevented, try again on next user interaction + document.addEventListener('click', () => { + ringtoneAudio.value.play().catch(() => {}); + }, { once: true }); + }); + } + }; + + const stopRingtone = () => { + if (ringtoneAudio.value) { + ringtoneAudio.value.pause(); + ringtoneAudio.value.currentTime = 0; + } + }; + // Emergency force end call function - simpler and more direct const forceEndCall = () => { console.log('FORCE END CALL triggered from floating widget'); // Try all methods to ensure call ends - - // 1. Local component state stopDurationTimer(); + stopRingtone(); isCallActive.value = false; // Save the call data before potential reset const savedCallSid = props.callSid; const savedConversationId = props.conversationId; - // 2. First, make direct API call if we have a valid call SID and conversation ID + // First, make direct API call if we have a valid call SID and conversation ID if (savedConversationId && savedCallSid && savedCallSid !== 'pending') { // Check if it's a valid Twilio call SID (starts with CA or TJ) const isValidTwilioSid = @@ -121,45 +194,45 @@ export default { console.log('FloatingCallWidget: Missing required data for API call'); } - // 3. Also use global method to update UI states + // Also use global method to update UI states if (window.forceEndCall) { console.log('Using global forceEndCall method'); window.forceEndCall(); } - // Fallbacks if global method not available - - // 4. Force App state update directly + // Force App state update directly if (window.app) { console.log('Forcing app state update'); window.app.$data.showCallWidget = false; } - // 5. Emit event - emit('call-ended'); + // Emit event + emit('callEnded'); - // 6. Update store - using store from setup scope + // Update store - using store from setup scope store.dispatch('calls/clearActiveCall'); + store.dispatch('calls/clearIncomingCall'); - // 7. User feedback + // User feedback useAlert({ message: 'Call ended', type: 'success' }); }; - // Original more careful implementation + // End active call const endCall = async () => { console.log('Attempting to end call with SID:', props.callSid); // First, always hide the UI for immediate feedback stopDurationTimer(); + stopRingtone(); isCallActive.value = false; - // Force update the app's state + // Force update the app's state to hide widget if (typeof window !== 'undefined' && window.app && window.app.$data) { window.app.$data.showCallWidget = false; } // Emit the event to parent components - emit('call-ended'); + emit('callEnded'); // Show success message to user useAlert({ message: 'Call ended', type: 'success' }); @@ -174,7 +247,7 @@ export default { !props.callSid.startsWith('debug-') ) { console.log('Ending real call with SID:', props.callSid); - await VoiceAPI.endCall(props.callSid); + await VoiceAPI.endCall(props.callSid, props.conversationId); } else { console.log('Using fake/temp call SID, skipping API call'); } @@ -184,8 +257,95 @@ export default { } // Clear from store as last step - const store = useStore(); store.dispatch('calls/clearActiveCall'); + store.dispatch('calls/clearIncomingCall'); + + // Set global call status in all possible places to ensure widget is removed + if (window.globalCallStatus) { + window.globalCallStatus.active = false; + window.globalCallStatus.incoming = false; + } + + // Add a timeout to ensure UI is properly reset if there are async issues + setTimeout(() => { + if (window.app && window.app.$data) { + window.app.$data.showCallWidget = false; + } + store.dispatch('calls/clearActiveCall'); + store.dispatch('calls/clearIncomingCall'); + }, 300); + }; + + // Accept incoming call + const acceptCall = async () => { + console.log('Accepting incoming call with SID:', incomingCall.value?.callSid); + + stopRingtone(); + + try { + // Call the API to join the call (conference) as an agent + if (incomingCall.value) { + const { callSid, conversationId } = incomingCall.value; + + // Show user feedback + useAlert({ message: safeTranslate('CONVERSATION.CALL_ACCEPTED'), type: 'info' }); + + // Make API call to join the conference + await VoiceAPI.joinCall(callSid, conversationId); + + // Move incoming call to active call + store.dispatch('calls/acceptIncomingCall'); + + // Start call duration timer + startDurationTimer(); + + // Emit event + emit('callJoined'); + } + } catch (error) { + console.error('Error joining call:', error); + useAlert({ message: safeTranslate('CONVERSATION.CALL_JOIN_ERROR'), type: 'error' }); + forceEndCall(); + } + }; + + // Reject incoming call + const rejectCall = async () => { + console.log('Rejecting incoming call with SID:', incomingCall.value?.callSid); + + stopRingtone(); + + try { + if (incomingCall.value) { + const { callSid, conversationId } = incomingCall.value; + + // Show user feedback + useAlert({ message: safeTranslate('CONVERSATION.CALL_REJECTED'), type: 'info' }); + + // Make API call to reject the call (optional, the caller will stay in the queue) + await VoiceAPI.rejectCall(callSid, conversationId); + + // Clear the incoming call from store + store.dispatch('calls/clearIncomingCall'); + + // Emit event + emit('callRejected'); + + // Update app state - checking for $data property + if (window.app && window.app.$data) { + window.app.$data.showCallWidget = false; + } + } + } catch (error) { + console.error('Error rejecting call:', error); + // Clear anyway for UX purposes + store.dispatch('calls/clearIncomingCall'); + + // Update app state + if (window.app) { + window.app.$data.showCallWidget = false; + } + } }; const toggleMute = () => { @@ -211,33 +371,34 @@ export default { }; // Explicit debug handler for end call click - const handleEndCallClick = () => { - console.log('END CALL BUTTON CLICKED in FloatingCallWidget'); - console.log( - 'Current call SID:', - props.callSid, - 'Conversation ID:', - props.conversationId - ); - + const handleEndCallClick = async () => { // Save the call data before UI updates - const savedCallSid = props.callSid; - const savedConversationId = props.conversationId; + const callData = isIncoming.value ? incomingCall.value : activeCall.value; + + if (!callData) { + console.log('No call data found'); + return; + } + + const savedCallSid = callData.callSid; + const savedConversationId = callData.conversationId; // Always update UI immediately for better user experience stopDurationTimer(); + stopRingtone(); isCallActive.value = false; - // Update app state - if (window.app) { + // Update app state - checking for $data property + if (window.app && window.app.$data) { window.app.$data.showCallWidget = false; } // Update store store.dispatch('calls/clearActiveCall'); + store.dispatch('calls/clearIncomingCall'); // Emit event - emit('call-ended'); + emit('callEnded'); // Make API call if we have a valid conversation ID and a real call SID (not pending) if (savedConversationId && savedCallSid && savedCallSid !== 'pending') { @@ -246,54 +407,37 @@ export default { savedCallSid.startsWith('CA') || savedCallSid.startsWith('TJ'); if (isValidTwilioSid) { - console.log( - 'handleEndCallClick: Making API call to end Twilio call with SID:', - savedCallSid, - 'for conversation:', - savedConversationId - ); - - // Make the API call after UI is updated - VoiceAPI.endCall(savedCallSid, savedConversationId) - .then(response => { - console.log( - 'handleEndCallClick: Call ended successfully via API:', - response - ); - useAlert({ message: 'Call ended', type: 'success' }); - }) - .catch(error => { - console.error( - 'handleEndCallClick: Error ending call via API:', - error - ); - useAlert({ - message: 'Call ended (but server may still show as active)', - type: 'warning', - }); + try { + await VoiceAPI.endCall(savedCallSid, savedConversationId); + useAlert({ message: 'Call ended', type: 'success' }); + } catch (error) { + console.error('Error ending call:', error); + useAlert({ + message: 'Call ended (but server may still show as active)', + type: 'warning', }); + } } else { - console.log( - 'handleEndCallClick: Invalid Twilio call SID format:', - savedCallSid - ); useAlert({ message: 'Call ended', type: 'success' }); } } else { - if (savedCallSid === 'pending') { - console.log( - 'handleEndCallClick: Call was still in pending state, no API call needed' - ); - } else if (!savedConversationId) { - console.log( - 'handleEndCallClick: No conversation ID available for ending call' - ); - } else { - console.log('handleEndCallClick: Missing required data for API call'); - } - useAlert({ message: 'Call ended', type: 'success' }); } + + // Set global call status in all possible places to ensure widget is removed + if (window.globalCallStatus) { + window.globalCallStatus.active = false; + window.globalCallStatus.incoming = false; + } + + // Add a timeout to ensure UI is properly reset if there are async issues + setTimeout(() => { + if (window.app && window.app.$data) { + window.app.$data.showCallWidget = false; + } + store.dispatch('calls/clearActiveCall'); + store.dispatch('calls/clearIncomingCall'); + }, 300); }; // Safe translation helper with fallback @@ -304,30 +448,97 @@ export default { return translations[key] || key; } }; + + // Function to fetch contact details if needed + const fetchContactDetails = async () => { + // If we already have a contact name, don't fetch + if (displayContactName.value !== 'Loading...' && displayContactName.value !== 'Unknown Caller') { + return; + } + + // If we have a contact ID, fetch the details + const contactId = props.contactId || callInfo.value?.contactId; + if (contactId) { + try { + console.log('Fetching contact details for ID:', contactId); + const response = await ContactAPI.show(contactId); + if (response.data && response.data.payload) { + const contact = response.data.payload; + displayContactName.value = contact.name || 'Unknown Caller'; + console.log('Contact details fetched:', contact.name); + } + } catch (error) { + console.error('Error fetching contact details:', error); + displayContactName.value = 'Unknown Caller'; + } + } else { + displayContactName.value = 'Unknown Caller'; + } + }; onMounted(() => { - console.log('FloatingCallWidget mounted with callSid:', props.callSid); - // Always start the timer, regardless of callSid - startDurationTimer(); + // If this is an active call, start timer + if (hasActiveCall.value) { + startDurationTimer(); + } + + // If this is an incoming call, play ringtone + if (isIncoming.value) { + // Slight delay to ensure DOM is fully rendered + setTimeout(() => { + playRingtone(); + }, 300); + } + + // Fetch contact details if needed (after slight delay to ensure callInfo is populated) + setTimeout(() => { + fetchContactDetails(); + }, 500); }); onBeforeUnmount(() => { stopDurationTimer(); + stopRingtone(); }); - // Watch for call SID changes + // Watch for call store changes watch( - () => props.callSid, - newCallSid => { - isCallActive.value = !!newCallSid; - - if (newCallSid) { - startDurationTimer(); - } else { + () => isIncoming.value, + newIsIncoming => { + if (newIsIncoming) { + // Immediate UI feedback with delay for audio to allow browser autoplay policies stopDurationTimer(); + setTimeout(() => { + playRingtone(); + }, 300); + } else { + stopRingtone(); + } + }, + { immediate: true } // Check immediately on component creation + ); + + watch( + () => isJoined.value, + newIsJoined => { + if (newIsJoined) { + stopRingtone(); + startDurationTimer(); } } ); + + // Watch for call info changes to fetch contact details if needed + watch( + () => callInfo.value, + (newCallInfo) => { + if (newCallInfo && newCallInfo.contactId) { + // Try to fetch contact details when call info changes + fetchContactDetails(); + } + }, + { immediate: true } + ); return { isCallActive, @@ -336,8 +547,16 @@ export default { isMuted, showCallOptions, isFullscreen, + isIncoming, + isJoined, + activeCall, + incomingCall, + callInfo, + displayContactName, endCall, forceEndCall, + acceptCall, + rejectCall, handleEndCallClick, toggleMute, toggleCallOptions, @@ -349,49 +568,76 @@ export default { @@ -400,122 +646,190 @@ export default { position: fixed; bottom: 20px; right: 20px; - background-color: #1f2937; - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - padding: 12px 16px; + background-color: rgba(31, 41, 55, 0.95); /* var(--b-700, #1f2937) with opacity */ + backdrop-filter: blur(4px); + border-radius: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); + padding: 16px; z-index: 10000; display: flex; flex-direction: column; - min-width: 220px; + width: 320px; color: white; + transition: all 0.3s ease; + border: 1px solid var(--b-600, #374151); - .call-info { + &.is-minimized { + min-width: auto; + padding: 12px; + } + + &.is-fullscreen { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100%; + height: 100%; + border-radius: 0; + } + + &.is-incoming { + animation: pulse 1.5s infinite; + border-color: var(--b-600, #374151); + background-color: rgba(31, 41, 55, 0.95); /* Same background, no red */ + } +} + +.call-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + padding: 0 0 8px 0; +} + +.call-icon-wrapper { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 50%; + background-color: var(--b-500, #4b5563); + color: white; +} + +.call-title { + margin: 0; + font-size: 16px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 180px; + line-height: 1.2; +} + +.call-subtitle { + font-size: 12px; + color: var(--s-200, #9ca3af); + margin-top: 2px; +} + +.call-duration { + font-size: 14px; + font-weight: 500; + color: var(--s-100, #f3f4f6); + background-color: var(--b-600, #374151); + padding: 4px 8px; + border-radius: 12px; +} + +.call-actions { + display: flex; + gap: 12px; + justify-content: center; + + .control-button { display: flex; - justify-content: space-between; align-items: center; - margin-bottom: 10px; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 50%; + border: none; + cursor: pointer; + background: var(--b-600, #374151); + color: white; + font-size: 18px; + transition: all 0.2s ease; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); - .inbox-name { - font-weight: 500; + &:hover { + background: var(--b-500, #4b5563); + transform: translateY(-2px); } - .call-duration { - font-variant-numeric: tabular-nums; + &:active { + transform: translateY(0); } - } - .call-controls { - display: flex; - justify-content: space-around; - gap: 8px; + &.active { + background: var(--w-500, #2563eb); + } - .control-button { + &.end-call-button { + background: var(--r-500, #dc2626); + + &:hover { + background: var(--r-600, #b91c1c); + } + } + + &.accept-call-button, + &.reject-call-button { display: flex; align-items: center; justify-content: center; - width: 40px; - height: 40px; - border-radius: 50%; + gap: 8px; + flex: 1; + height: 44px; + border-radius: 22px; border: none; cursor: pointer; - background: #374151; + font-weight: 600; + font-size: 14px; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); + } + + &.accept-call-button { + background: var(--g-500, #10b981); color: white; - font-size: 18px; &:hover { - background: #4b5563; + background: var(--g-600, #059669); + transform: translateY(-2px); } - - &.active { - background: #2563eb; - } - - &.end-call-button { - background: #dc2626; - - &:hover { - background: #b91c1c; - } - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - - &:hover { - background: #374151; - } + + &:active { + transform: translateY(0); } } - .status-indicator { - display: flex; - align-items: center; - justify-content: center; - min-width: 40px; - height: 40px; - font-size: 12px; - font-weight: 500; - background: #2563eb; - border-radius: 16px; - padding: 0 12px; + &.reject-call-button { + background: var(--r-500, #dc2626); color: white; - animation: pulse 1.5s infinite; - } - - @keyframes pulse { - 0% { - opacity: 0.6; - } - 50% { - opacity: 1; - } - 100% { - opacity: 0.6; - } - } - } - - .call-options { - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid rgba(255, 255, 255, 0.1); - - button { - display: block; - width: 100%; - text-align: left; - padding: 6px 0; - background: transparent; - border: none; - color: white; - cursor: pointer; &:hover { - color: #e5e7eb; + background: var(--r-600, #b91c1c); + transform: translateY(-2px); } + + &:active { + transform: translateY(0); + } + } + + .button-text { + margin-left: 8px; } } } + +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.4); + transform: scale(1); + } + 50% { + box-shadow: 0 0 0 10px rgba(220, 38, 38, 0); + transform: scale(1.01); + } + 100% { + box-shadow: 0 0 0 0 rgba(220, 38, 38, 0); + transform: scale(1); + } +} diff --git a/app/javascript/dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper.js b/app/javascript/dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper.js index 9e79bb3a2..79ee8691c 100644 --- a/app/javascript/dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper.js +++ b/app/javascript/dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper.js @@ -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); diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index adb33eb6d..9c5cc20dc 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -30,6 +30,10 @@ class ActionCableConnector extends BaseActionCableConnector { 'conversation.read': this.onConversationRead, 'conversation.updated': this.onConversationUpdated, 'account.cache_invalidated': this.onCacheInvalidate, + + // Call events + 'incoming_call': this.onIncomingCall, + 'call_status_changed': this.onCallStatusChanged }; } @@ -191,6 +195,38 @@ 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, + contactName: data.contact_name, + contactId: data.contact_id, + }; + + // 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, + }; + + // Update store + this.app.$store.dispatch('calls/setActiveCall', normalizedPayload); + }; } export default { diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index d24444784..dfc539839 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -236,6 +236,20 @@ "SIDEBAR": { "CONTACT": "Contact", "COPILOT": "Copilot" + }, + "INCOMING_CALL": "Incoming call", + "JOIN_CALL": "Join call", + "REJECT_CALL": "Reject call", + "END_CALL": "End call", + "MINIMIZE_CALL": "Minimize call", + "EXPAND_CALL": "Expand call", + "CALL_STATUS": { + "CONNECTING": "Connecting...", + "RINGING": "Ringing...", + "CONNECTED": "Connected", + "ENDED": "Call ended", + "FAILED": "Call failed", + "REJECTED": "Call rejected" } }, "EMAIL_TRANSCRIPT": { @@ -382,7 +396,6 @@ "VOICE_CALL": "Call", "CALL_ERROR": "Failed to initiate call. Please try again.", "CALL_INITIATED": "Call initiated successfully.", - "END_CALL": "End call", "AUDIO_NOT_SUPPORTED": "Your browser does not support audio playback", "TRANSCRIPTION": "Call transcription", "COPILOT": { diff --git a/app/javascript/dashboard/store/modules/calls.js b/app/javascript/dashboard/store/modules/calls.js index f53a0e004..4ca449f7f 100644 --- a/app/javascript/dashboard/store/modules/calls.js +++ b/app/javascript/dashboard/store/modules/calls.js @@ -1,30 +1,77 @@ const state = { activeCall: null, + incomingCall: null, }; const getters = { getActiveCall: $state => $state.activeCall, hasActiveCall: $state => !!$state.activeCall, + getIncomingCall: $state => $state.incomingCall, + hasIncomingCall: $state => !!$state.incomingCall, }; const actions = { setActiveCall({ commit }, callData) { - console.log('Setting active call in store:', callData); + if (!callData || !callData.callSid) { + throw new Error('Invalid call data provided'); + } + commit('SET_ACTIVE_CALL', callData); - // If we're in a browser environment, try to set the app state + // Update app state if in browser environment + if (typeof window !== 'undefined' && window.app?.$data) { + window.app.$data.showCallWidget = true; + } + }, + + clearActiveCall({ commit }) { + commit('CLEAR_ACTIVE_CALL'); + + // Update app state if in browser environment + if (typeof window !== 'undefined' && window.app?.$data) { + window.app.$data.showCallWidget = false; + } + }, + + setIncomingCall({ commit, state }, callData) { + if (!callData || !callData.callSid) { + throw new Error('Invalid call data provided'); + } + + // Don't set as incoming if call is already active + if (state.activeCall?.callSid === callData.callSid) { + return; + } + + // Don't set as incoming if call is already incoming + if (state.incomingCall?.callSid === callData.callSid) { + return; + } + + commit('SET_INCOMING_CALL', callData); + + // Update app state if in browser environment if (typeof window !== 'undefined' && window.app && window.app.$data) { window.app.$data.showCallWidget = true; } }, - clearActiveCall({ commit }) { - console.log('Clearing active call in store'); - commit('CLEAR_ACTIVE_CALL'); - // If we're in a browser environment, try to clear the app state - if (typeof window !== 'undefined' && window.app && window.app.$data) { - window.app.$data.showCallWidget = false; + clearIncomingCall({ commit }) { + commit('CLEAR_INCOMING_CALL'); + }, + + acceptIncomingCall({ commit, state }) { + const incomingCall = state.incomingCall; + if (!incomingCall) { + throw new Error('No incoming call to accept'); } + + // Move incoming call to active call + commit('SET_ACTIVE_CALL', { + ...incomingCall, + isJoined: true, + }); + commit('CLEAR_INCOMING_CALL'); }, }; @@ -35,6 +82,12 @@ const mutations = { CLEAR_ACTIVE_CALL($state) { $state.activeCall = null; }, + SET_INCOMING_CALL($state, callData) { + $state.incomingCall = callData; + }, + CLEAR_INCOMING_CALL($state) { + $state.incomingCall = null; + }, }; export default { diff --git a/config/routes.rb b/config/routes.rb index c22d2e7ea..55db8e099 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -184,6 +184,8 @@ Rails.application.routes.draw do # Voice call management post 'voice/end_call', to: 'voice#end_call' + post 'voice/join_call', to: 'voice#join_call' + post 'voice/reject_call', to: 'voice#reject_call' get 'voice/call_status', to: 'voice#call_status' resources :inbox_members, only: [:create, :show], param: :inbox_id do collection do diff --git a/public/audio/dashboard/call-ring.mp3 b/public/audio/dashboard/call-ring.mp3 new file mode 100644 index 000000000..77087072f Binary files /dev/null and b/public/audio/dashboard/call-ring.mp3 differ