chore: clean up voice channel code for MVP

- Simplify message builder content_attributes handling
- Remove AI captain integration from incoming call service
- Clean up FloatingCallWidget by removing non-essential features:
  - Remove Gravatar/MD5 dependency
  - Remove keypad/DTMF functionality
  - Remove fullscreen toggle
  - Simplify avatar handling
- Apply consistent code formatting across voice components
- Remove debug logging and unused code

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sojan Jose
2025-07-14 02:21:12 -07:00
parent ecb37200a1
commit cb9d45a355
20 changed files with 1556 additions and 1533 deletions

View File

@@ -7,7 +7,7 @@ class Messages::MessageBuilder
@private = params[:private] || false
@conversation = conversation
@user = user
@message_type = params[:message_type].to_s || 'outgoing'
@message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
@automation_rule = content_attributes&.dig(:automation_rule_id)
return unless params.instance_of?(ActionController::Parameters)
@@ -33,6 +33,11 @@ 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.
@@ -135,7 +140,7 @@ class Messages::MessageBuilder
end
def message_params
message_attrs = {
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: message_type,
@@ -143,17 +148,11 @@ class Messages::MessageBuilder
private: @private,
sender: sender,
content_type: @params[:content_type],
content_attributes: content_attributes,
items: @items,
in_reply_to: @in_reply_to,
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

View File

@@ -101,7 +101,7 @@ export default {
} else {
this.showCallWidget = false;
}
}
},
},
hasActiveCall: {
immediate: true,
@@ -111,14 +111,14 @@ export default {
} 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;
},
@@ -142,14 +142,17 @@ export default {
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) => {
const clearContactInfoCallState = components => {
if (!components) return;
components.forEach(component => {
if (component.$options && component.$options.name === 'ContactInfo') {
if (
component.$options &&
component.$options.name === 'ContactInfo'
) {
if (component.activeCallConversation) {
component.activeCallConversation = null;
component.$forceUpdate();
@@ -160,7 +163,7 @@ export default {
}
});
};
clearContactInfoCallState(this.$children);
});
},
@@ -195,10 +198,8 @@ export default {
.catch(() => {
setTimeout(() => {
VoiceAPI.endCall(savedCallSid, savedConversationId)
.then(() => {
})
.catch(() => {
});
.then(() => {})
.catch(() => {});
}, 1000);
useAlert({ message: 'Call UI has been reset', type: 'info' });
});
@@ -259,21 +260,87 @@ export default {
<!-- 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 : '')"
: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"
@call-ended="handleCallEnded"
@call-joined="handleCallJoined"
@call-rejected="handleCallRejected"
/>
</div>
<LoadingState v-else />

View File

@@ -5,7 +5,7 @@ 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;
@@ -20,7 +20,9 @@ class VoiceAPI extends ApiClient {
// 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`);
return axios.post(
`${this.baseUrl().replace('/voice', '')}/contacts/${contactId}/call`
);
}
// End an active call
@@ -105,71 +107,88 @@ class VoiceAPI extends ApiClient {
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`);
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;
return axios
.post(
`${this.url}/tokens`,
{ inbox_id: inboxId },
{
headers: { 'Content-Type': 'application/json' },
}
}
throw error;
});
)
.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...');
console.log(
'Device is in a bad state, destroying and reinitializing...'
);
try {
this.device.destroy();
} catch (e) {
@@ -182,11 +201,13 @@ class VoiceAPI extends ApiClient {
return this.device;
}
}
// Device needs to be initialized or reinitialized
try {
console.log(`Starting Twilio Device initialization for inbox: ${inboxId}`);
console.log(
`Starting Twilio Device initialization for inbox: ${inboxId}`
);
// Import the Twilio Voice SDK
let Device;
try {
@@ -196,96 +217,112 @@ class VoiceAPI extends ApiClient {
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}`);
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}`);
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');
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');
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');
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.');
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}`);
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
'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.');
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;
@@ -303,22 +340,24 @@ class VoiceAPI extends ApiClient {
// 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}`);
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 {
@@ -328,64 +367,79 @@ class VoiceAPI extends ApiClient {
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(
'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}`);
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),
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.';
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.';
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.';
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.';
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)
@@ -401,15 +455,15 @@ class VoiceAPI extends ApiClient {
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 = {
@@ -417,36 +471,46 @@ class VoiceAPI extends ApiClient {
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',
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
platform: navigator.platform,
},
timestamp: new Date().toISOString()
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(
'⚠️ 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',
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'
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())
@@ -458,58 +522,76 @@ class VoiceAPI extends ApiClient {
});
break;
case 31002:
console.error('⚠️ Error 31002: Permission Denied. Your browser microphone is blocked or unavailable.');
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.');
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(
'⚠️ 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
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'
originalError: error.originalError
? JSON.stringify(error.originalError)
: 'None',
});
break;
case 31008:
console.error('⚠️ Error 31008: Connection Error. The call could not be established.');
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.');
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}`);
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();
@@ -522,45 +604,49 @@ class VoiceAPI extends ApiClient {
destination: {
maxChannelCount: context.destination.maxChannelCount,
numberOfInputs: context.destination.numberOfInputs,
numberOfOutputs: context.destination.numberOfOutputs
}
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'),
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()
}))
constraints: track.getConstraints(),
})),
};
}
return {
audioContext: audioInfo,
return {
audioContext: audioInfo,
microphone: microphoneInfo,
speakersMuted: typeof window.speechSynthesis !== 'undefined' ?
window.speechSynthesis.speaking === false : 'unknown'
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,
@@ -577,80 +663,93 @@ class VoiceAPI extends ApiClient {
audioDiagnostics: diagnostics,
// Browser media permissions
mediaPermissions: {
hasGetUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
hasGetUserMedia: !!(
navigator.mediaDevices && navigator.mediaDevices.getUserMedia
),
activeAudioStream: !!window.activeAudioStream,
activeAudioTracks: window.activeAudioStream ?
window.activeAudioStream.getAudioTracks().length : 0
}
activeAudioTracks: window.activeAudioStream
? window.activeAudioStream.getAudioTracks().length
: 0,
},
};
console.error('❌ DETAILED Connection Error with Audio Diagnostics:', connectionErrorDetails);
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'
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',
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'
deviceState: this.device ? this.device.state : 'No device',
});
}, 5000);
});
connection.on('disconnect', () => {
console.log('📞 Call disconnected', {
disconnectCause: connection.parameters ? connection.parameters.DisconnectCause : 'Unknown',
disconnectCause: connection.parameters
? connection.parameters.DisconnectCause
: 'Unknown',
finalStatus: connection.status && connection.status(),
audioDiagnostics: getAudioDiagnostics()
audioDiagnostics: getAudioDiagnostics(),
});
this.activeConnection = null;
});
connection.on('reject', () => {
console.log('📞 Call rejected', {
rejectCause: connection.parameters ? connection.parameters.DisconnectCause : 'Unknown',
audioDiagnostics: getAudioDiagnostics()
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 {
@@ -658,10 +757,12 @@ class VoiceAPI extends ApiClient {
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}`);
console.log(
`🔊 Volume change - Input: ${inputVolume}, Output: ${outputVolume}`
);
}
});
// Check for media stream events if supported
if (typeof connection.getRemoteStream === 'function') {
const remoteStream = connection.getRemoteStream();
@@ -672,8 +773,8 @@ class VoiceAPI extends ApiClient {
tracks: remoteStream.getTracks().map(t => ({
kind: t.kind,
enabled: t.enabled,
readyState: t.readyState
}))
readyState: t.readyState,
})),
});
} else {
console.warn('⚠️ No remote audio stream available');
@@ -684,17 +785,17 @@ class VoiceAPI extends ApiClient {
}
}
}
// 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) {
@@ -716,7 +817,7 @@ class VoiceAPI extends ApiClient {
// Additional params for our server
account_id: conferenceParams.account_id,
is_agent: 'true'
is_agent: 'true',
};
// Check To parameter exists - fail if missing
@@ -726,38 +827,48 @@ class VoiceAPI extends ApiClient {
// Make sure 'To' is explicitly a string
const stringifiedTo = String(params.To);
console.log('🎯 CRITICAL CONFERENCE CONNECTION: Connecting agent to conference with To=', stringifiedTo);
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');
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
}
To: stringifiedTo, // Conference ID
is_agent: 'true', // Flag to indicate agent is joining
},
});
console.log('🎯 CONFERENCE CONNECTION RESULT:', connection ? 'Success' : 'Failed');
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
});
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 (listenerError) {
// Could not add listeners to Promise connection
}
}).catch(connError => {
// WebRTC Promise connection error
});
})
.catch(connError => {
// WebRTC Promise connection error
});
} else {
// It's a synchronous connection - older Twilio SDK
}
@@ -772,9 +883,9 @@ class VoiceAPI extends ApiClient {
if (!this.device) {
return 'not_initialized';
}
const deviceState = this.device.state;
// Append a recommended action based on the state
switch (deviceState) {
case 'registered':
@@ -791,7 +902,7 @@ class VoiceAPI extends ApiClient {
return deviceState;
}
}
// Get comprehensive diagnostic information about the device and connection
getDiagnosticInfo() {
const browserInfo = {
@@ -799,42 +910,49 @@ class VoiceAPI extends ApiClient {
platform: navigator.platform,
vendor: navigator.vendor,
hasMediaDevices: !!navigator.mediaDevices,
hasGetUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
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' };
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
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':

View File

@@ -275,11 +275,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>

View File

@@ -23,7 +23,7 @@ const isVoiceChannel = computed(() => {
// 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';
});
@@ -31,24 +31,25 @@ const isIncomingCall = computed(() => {
// 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';
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';
});
@@ -56,23 +57,23 @@ const normalizedCallStatus = computed(() => {
// 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');
}
@@ -80,18 +81,18 @@ const callStatusText = computed(() => {
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')
return isIncoming
? t('CONVERSATION.VOICE_CALL.INCOMING_CALL')
: t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
});
@@ -100,12 +101,12 @@ 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')
);
@@ -129,56 +130,75 @@ const unreadMessagesCount = computed(() => {
<template>
<div class="flex items-end w-full gap-2 pb-1">
<!-- Voice Call Message -->
<div
v-if="isVoiceChannel"
<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-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'
'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>
<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"
/>
<!-- 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>
<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"
/>
<!-- 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>
<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"
/>
<!-- 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>
<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"
/>
<!-- 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>
<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' }"
/>
<!-- 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>
<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' }"
/>
<span class="text-current truncate">{{ callStatusText }}</span>
<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">
<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"

View File

@@ -32,7 +32,7 @@ const isVoiceChannel = computed(() => {
// 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';
});
@@ -40,24 +40,25 @@ const isIncomingCall = computed(() => {
// 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';
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';
});
@@ -65,23 +66,23 @@ const normalizedCallStatus = computed(() => {
// 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');
}
@@ -89,18 +90,18 @@ const callStatusText = computed(() => {
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')
return isIncoming
? t('CONVERSATION.VOICE_CALL.INCOMING_CALL')
: t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
});
@@ -109,7 +110,7 @@ const lastNonActivityMessageContent = computed(() => {
if (isVoiceChannel.value) {
return callStatusText.value;
}
// Otherwise use the regular message content
const { lastNonActivityMessage = {}, customAttributes = {} } =
props.conversation;
@@ -148,46 +149,62 @@ defineExpose({
<div class="flex flex-col w-full gap-1">
<div class="flex items-center justify-between w-full gap-2 py-1 h-7">
<!-- Voice Call Message display with icon -->
<div
v-if="isVoiceChannel"
<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-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'
'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>
<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"
/>
<!-- 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>
<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"
/>
<!-- 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>
<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"
/>
<!-- 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>
<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"
/>
<!-- 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>
<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' }"
/>
<!-- 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>
<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' }"
/>
<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 }}

View File

@@ -71,7 +71,7 @@ const isVoiceChannel = computed(() => {
// 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';
});
@@ -79,24 +79,25 @@ const isIncomingCall = computed(() => {
// 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';
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';
});
@@ -112,23 +113,23 @@ const isActiveCall = computed(() => {
// 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');
}
@@ -136,44 +137,42 @@ const callStatusText = computed(() => {
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')
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';
return isIncoming ? 'i-ph-phone-incoming-fill' : 'i-ph-phone-outgoing-fill';
});
const showMessagePreviewWithoutMeta = computed(() => {
@@ -207,20 +206,22 @@ const onCardClick = e => {
<div
role="button"
class="flex w-full gap-3 px-3 py-4 transition-all duration-300 ease-in-out cursor-pointer relative"
:class="{
: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
'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"
<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"
@@ -243,29 +244,33 @@ const onCardClick = e => {
<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"
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"
@@ -277,47 +282,61 @@ const onCardClick = e => {
</span>
</div>
</div>
<!-- Special preview for voice channel conversations -->
<div
<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-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'
'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>
<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
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
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
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
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' }"
/>
<span class="text-current truncate">{{ callStatusText }}</span>
<!-- Join now prompt for ringing calls -->
<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 previews for non-voice channel conversations -->
<template v-else>
<CardMessagePreview

View File

@@ -60,47 +60,38 @@ 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).
// 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';
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';
return 'solid'; // Default variant
});
const computedColor = computed(() => {
if (props.color) return props.color;
// 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';
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';
return 'blue'; // Default color
});
const computedSize = computed(() => {
if (props.size) return props.size;
// 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';
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';
return 'md';
});
const computedJustify = computed(() => {
if (props.justify) return props.justify;
// 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';
if (attrs.start || attrs.start === '') return 'start';
if (attrs.center || attrs.center === '') return 'center';
if (attrs.end || attrs.end === '') return 'end';
return 'center';
});
@@ -150,17 +141,6 @@ 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',

View File

@@ -1,52 +1,9 @@
<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: {
},
components: {},
inject: ['$emit'],
props: {
message: {
@@ -58,6 +15,32 @@ export default {
default: 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,
};
},
data() {
return {
internalStatus: '',
@@ -69,34 +52,11 @@ export default {
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) {
@@ -104,36 +64,40 @@ export default {
}
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;
const conversationCallStatus =
this.message?.conversation?.additional_attributes?.call_status;
if (conversationCallStatus) {
// Use our composable helper for status normalization
return this.normalizeCallHelper(conversationCallStatus, this.isIncoming);
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';
@@ -141,17 +105,19 @@ export default {
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) {
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 &&
@@ -161,90 +127,98 @@ export default {
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,
'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');
}
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;
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)) {
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')
ended: this.$t('CONVERSATION.VOICE_CALL.YOU_ANSWERED'),
},
outgoing: {
ringing: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
@@ -254,33 +228,32 @@ export default {
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')
}
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');
if (agentJoined || callStarted) {
return this.$t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
}
return this.$t('CONVERSATION.VOICE_CALL.YOU_DIDNT_ANSWER');
}
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);
@@ -290,18 +263,18 @@ export default {
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') &&
const shouldShowDuration =
(this.status === 'ended' || this.status === 'completed') &&
durationToShow;
if (shouldShowDuration) {
return `${this.subtext} · ${durationToShow}`;
}
return this.subtext;
}
},
},
watch: {
message: {
@@ -322,7 +295,7 @@ export default {
clearInterval(this.refreshInterval);
this.refreshInterval = null;
}
if (this.statusCheckInterval) {
clearInterval(this.statusCheckInterval);
this.statusCheckInterval = null;
@@ -332,11 +305,11 @@ export default {
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')}`;
},
@@ -344,47 +317,50 @@ export default {
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
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;
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;
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();
@@ -411,40 +387,90 @@ export default {
setAudioAttachment() {
// Look for audio attachment in message.attachments or message.contentAttributes.attachments
let attachments = [];
if (this.message?.attachments && Array.isArray(this.message.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)) {
} 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;
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;
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;
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;
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.recordingUrl =
audio.data_url ||
audio.file_url ||
audio.dataUrl ||
audio.fileUrl ||
'';
this.hasAudioAttachment = true;
} else {
this.recordingUrl = '';
this.hasAudioAttachment = false;
}
}
},
},
};
</script>
<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="text-white text-xl" :class="[iconName]" />
</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>
</div>
</template>
<style lang="scss" scoped>
/* Voice call styling */
.pulse-animation {
@@ -478,4 +504,4 @@ export default {
border-color: rgba(34, 197, 94, 0.8);
}
}
</style>
</style>

View File

@@ -16,9 +16,7 @@ defineOptions({
});
const timeStampURL = computed(() => {
// Safely access the URL, providing a fallback if not available
const url = attachment?.dataUrl || attachment?.data_url || '';
return timeStampAppendedURL(url);
return timeStampAppendedURL(attachment.dataUrl);
});
const audioPlayer = useTemplateRef('audioPlayer');
@@ -93,16 +91,8 @@ const changePlaybackSpeed = () => {
};
const downloadAudio = async () => {
// 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 });
const { fileType, dataUrl, extension } = attachment;
downloadFile({ url: dataUrl, type: fileType, extension });
};
</script>

View File

@@ -23,10 +23,10 @@ export default {
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
<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"

View File

@@ -55,16 +55,15 @@ export default {
},
// Check if this is a voice call message
isVoiceCall() {
return (
this.message?.content_type === 'voice_call'
);
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;
const direction =
this.conversation?.additional_attributes?.call_direction;
if (direction) {
return direction === 'inbound';
}
@@ -72,50 +71,51 @@ export default {
// 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';
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';
},
@@ -125,7 +125,7 @@ export default {
// 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
@@ -134,16 +134,16 @@ export default {
}
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');
}
@@ -151,22 +151,22 @@ export default {
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')
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 || {};
@@ -196,23 +196,34 @@ export default {
<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'
'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>
<i
v-if="callStatus === 'missed' || callStatus === 'no-answer'"
class="i-ph-phone-x text-base"
/>
<!-- Active call icon -->
<i v-else-if="callStatus === 'active'"
class="i-ph-phone-call text-base"></i>
<i
v-else-if="callStatus === 'active'"
class="i-ph-phone-call text-base"
/>
<!-- Incoming call icon -->
<i v-else-if="(callStatus === 'ended' && isIncomingCall) || (isIncomingCall)"
class="i-ph-phone-incoming text-base"></i>
<i
v-else-if="(callStatus === 'ended' && isIncomingCall) || (isIncomingCall)"
"
class="i-ph-phone-incoming text-base"
/>
<!-- Outgoing call icon -->
<i v-else
class="i-ph-phone-outgoing text-base"></i>
<i
v-else
class="i-ph-phone-outgoing text-base"
/>
</span>
<span>{{ parsedLastMessage }}</span>
</template>
@@ -229,23 +240,33 @@ export default {
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'
'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>
<i
v-if="callStatus === 'missed' || callStatus === 'no-answer'"
class="i-ph-phone-x text-base"
/>
<!-- Active call icon -->
<i v-else-if="callStatus === 'active'"
class="i-ph-phone-call text-base"></i>
<i
v-else-if="callStatus === 'active'"
class="i-ph-phone-call text-base"
/>
<!-- Incoming call icon -->
<i v-else-if="(callStatus === 'ended' && isIncomingCall) || (isIncomingCall)"
class="i-ph-phone-incoming text-base"></i>
<i
v-else-if="(callStatus === 'ended' && isIncomingCall) || (isIncomingCall)"
class="i-ph-phone-incoming text-base"
/>
<!-- Outgoing call icon -->
<i v-else
class="i-ph-phone-outgoing text-base"></i>
<i
v-else
class="i-ph-phone-outgoing text-base"
/>
</span>
<fluent-icon
v-else-if="messageByAgent"
@@ -286,4 +307,3 @@ export default {
</template>
</div>
</template>

View File

@@ -7,130 +7,136 @@ export const useVoiceCallHelpers = (props, { t }) => {
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') {
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) => {
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) => {
const getCallData = conversation => {
if (!conversation) return {};
// First check for data directly in conversation attributes
const conversationAttributes = conversation.custom_attributes || conversation.additional_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) => {
const hasArrow = message => {
if (!message?.content) return false;
return (
typeof message.content === 'string' &&
(message.content.startsWith('←') ||
message.content.startsWith('→') ||
message.content.startsWith('↔️'))
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',
completed: 'ended',
canceled: 'ended',
failed: 'ended',
busy: 'no-answer',
'no-answer': isIncoming ? 'missed' : 'no-answer',
'active': 'active',
'missed': 'missed',
'ended': 'ended',
'ringing': 'ringing'
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';
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';
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');
}
@@ -138,34 +144,34 @@ export const useVoiceCallHelpers = (props, { t }) => {
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')
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,
@@ -177,4 +183,4 @@ export const useVoiceCallHelpers = (props, { t }) => {
getStatusText,
processArrowContent,
};
};
};

View File

@@ -33,10 +33,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
incoming_call: this.onIncomingCall,
call_status_changed: this.onCallStatusChanged,
};
}
@@ -223,12 +223,12 @@ class ActionCableConnector extends BaseActionCableConnector {
requiresAgentJoin: data.requires_agent_join || false,
callDirection: data.call_direction,
phoneNumber: data.phone_number,
avatarUrl: data.avatar_url
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;
@@ -242,12 +242,14 @@ class ActionCableConnector extends BaseActionCableConnector {
status: data.status,
conversationId: data.conversation_id,
inboxId: data.inbox_id,
timestamp: data.timestamp || Date.now()
timestamp: data.timestamp || Date.now(),
};
// Only dispatch to Vuex; Vuex handles widget and call state
this.app.$store.dispatch('calls/handleCallStatusChanged', normalizedPayload);
this.app.$store.dispatch(
'calls/handleCallStatusChanged',
normalizedPayload
);
};
}
export default {

View File

@@ -20,7 +20,11 @@ const actions = {
handleCallStatusChanged({ state, dispatch }, { callSid, status }) {
// Debug logging for conference call widget close issue
// eslint-disable-next-line no-console
console.log('[CALL DEBUG] handleCallStatusChanged invoked', { callSid, status, activeCall: state.activeCall });
console.log('[CALL DEBUG] handleCallStatusChanged invoked', {
callSid,
status,
activeCall: state.activeCall,
});
const isActiveCall = callSid === state.activeCall?.callSid;
const terminalStatuses = [
'ended',
@@ -31,8 +35,12 @@ const actions = {
'no_answer',
];
if (isActiveCall && terminalStatuses.includes(status)) {
// eslint-disable-next-line no-console
console.log('[CALL DEBUG] Terminal status match. Closing widget.', { callSid, status, activeCall: state.activeCall });
// eslint-disable-next-line no-console
console.log('[CALL DEBUG] Terminal status match. Closing widget.', {
callSid,
status,
activeCall: state.activeCall,
});
// Clean up active call state
dispatch('clearActiveCall');
// Hide floating widget reactively
@@ -55,9 +63,12 @@ const actions = {
if (!callData || !callData.callSid) {
throw new Error('Invalid call data provided');
}
// If the call has a status, check if it's a terminal status
if (callData.status && ['ended', 'missed', 'completed'].includes(callData.status)) {
if (
callData.status &&
['ended', 'missed', 'completed'].includes(callData.status)
) {
// If the call is already in a terminal state, clear any active call
if (callData.callSid === state.activeCall?.callSid) {
return dispatch('clearActiveCall');
@@ -77,14 +88,14 @@ const actions = {
clearActiveCall({ commit }) {
// Store the messageId before clearing the call
const messageId = state.activeCall?.messageId;
commit('CLEAR_ACTIVE_CALL');
// Update app state if in browser environment
if (typeof window !== 'undefined' && window.app?.$data) {
window.app.$data.showCallWidget = false;
}
// We no longer need to update call widget status as we'll use reactive Vue props
// and updates will come through Chatwoot's standard message update events
},
@@ -93,29 +104,29 @@ const actions = {
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;
}
const enrichedCallData = {
...callData,
receivedAt: Date.now(),
};
commit('SET_INCOMING_CALL', enrichedCallData);
// Update app state if in browser environment
if (typeof window !== 'undefined' && window.app && window.app.$data) {
window.app.$data.showCallWidget = true;
}
// We no longer need to update call widget status as we'll use reactive Vue props
// and updates will come through Chatwoot's standard message update events
},
@@ -123,9 +134,9 @@ const actions = {
clearIncomingCall({ commit }) {
// Store the messageId before clearing the call
const messageId = state.incomingCall?.messageId;
commit('CLEAR_INCOMING_CALL');
// We no longer need to update call widget status as we'll use reactive Vue props
// and updates will come through Chatwoot's standard message update events
},
@@ -143,7 +154,7 @@ const actions = {
startedAt: Date.now(),
});
commit('CLEAR_INCOMING_CALL');
// We no longer need to update call widget status as we'll use reactive Vue props
// and updates will come through Chatwoot's standard message update events
},
@@ -163,7 +174,7 @@ const mutations = {
$state.incomingCall = null;
},
// We no longer need to update call widget status as we'll use reactive Vue props
// We no longer need subscription mutations
};

View File

@@ -105,13 +105,18 @@ export const actions = {
if (contactId) {
// Use the regular contacts call endpoint for existing contacts
const response = await axios.post(`/api/v1/accounts/${accountId}/contacts/${contactId}/call`);
const response = await axios.post(
`/api/v1/accounts/${accountId}/contacts/${contactId}/call`
);
data = response.data;
} else {
// For direct phone calls without a contact, use a special endpoint
// Add phoneNumber to the payload for voice call
payload.phone_number = params.phoneNumber || '';
const response = await axios.post(`/api/v1/accounts/${accountId}/conversations/trigger_voice`, payload);
const response = await axios.post(
`/api/v1/accounts/${accountId}/conversations/trigger_voice`,
payload
);
data = response.data;
}
} else {

View File

@@ -97,8 +97,7 @@ export const getters = {
},
getVoiceInboxes($state) {
return $state.records.filter(
item =>
item.channel_type === INBOX_TYPES.VOICE
item => item.channel_type === INBOX_TYPES.VOICE
);
},
dialogFlowEnabledInboxes($state) {
@@ -194,7 +193,7 @@ export const actions = {
createVoiceChannel: async ({ commit }, params) => {
try {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
// Create a formatted payload for the voice channel
const inboxParams = {
name: params.voice.name || `Voice (${params.voice.phone_number})`,
@@ -205,10 +204,10 @@ export const actions = {
provider_config: params.voice.provider_config,
},
};
// Use InboxesAPI to create the channel which handles authentication properly
const response = await InboxesAPI.create(inboxParams);
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
sendAnalyticsEvent('voice');

View File

@@ -32,28 +32,28 @@ export default {
// To support icons with multiple paths
const key = `${this.icon}-${this.type}`;
const path = this.icons[key];
// If not found, try default icon
if (path === undefined) {
const defaultKey = `call-${this.type}`;
const defaultPath = this.icons[defaultKey];
// If default icon also not found, return empty array to prevent errors
if (defaultPath === undefined) {
return [];
}
if (Array.isArray(defaultPath)) {
return defaultPath;
}
return [defaultPath];
}
if (Array.isArray(path)) {
return path;
}
return [path];
},
},

View File

@@ -4,9 +4,6 @@ module Voice
def process
find_inbox
return captain_twiml if @inbox.captain_active?
create_contact
# Use a transaction to ensure the conversation and voice call message are created together
@@ -39,15 +36,6 @@ module Voice
private
def captain_twiml
response = Twilio::TwiML::VoiceResponse.new
media_service_url = "#{ENV.fetch('AI_MEDIA_SERVICE_URL', nil)}/incoming-call"
response.redirect(media_service_url)
response.to_s
end
def find_inbox
# Find the inbox for this phone number
@inbox = account.inboxes