mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-03 04:27:53 +00:00
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:
@@ -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
|
||||
@@ -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 />
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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];
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user