Files
chatwoot/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
2025-05-04 05:00:20 -07:00

439 lines
14 KiB
Vue

<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>
<audio
ref="audioPlayer"
:src="recordingUrl"
preload="metadata"
@ended="handlePlaybackEnd"
/>
</div>
</template>
<script>
import { useVoiceCallHelpers } from 'dashboard/composables/useVoiceCallHelpers';
export default {
name: 'VoiceCallBubble',
components: {
},
inject: ['$emit'],
props: {
message: {
type: Object,
default: () => ({}),
},
isInbox: {
type: Boolean,
default: false,
},
},
data() {
return {
internalStatus: '',
refreshInterval: null,
statusCheckInterval: null,
isAnimating: false,
recordingUrl: '',
isPlaying: false,
};
},
setup(props) {
// Initialize our composable for use in methods
const {
normalizeCallStatus,
isIncomingCall,
getCallIconName,
getStatusText
} = useVoiceCallHelpers({ conversation: props.message?.conversation }, {
t: (key) => {
// This is a simple passthrough for the t function since we're in options API
// In setup() we can't access this.$t directly
return key;
}
});
// Expose these helpers to the component instance
return {
normalizeCallHelper: normalizeCallStatus,
checkIsIncoming: isIncomingCall,
getCallIconHelper: getCallIconName,
getStatusTextHelper: getStatusText
};
},
computed: {
callData() {
return this.message?.contentAttributes?.data || {};
},
directionalStatus() {
const direction = this.callData?.call_direction;
if (direction) {
return direction === 'inbound' ? 'inbound' : 'outbound';
}
return this.message?.messageType === 0 ? 'inbound' : 'outbound';
},
isIncoming() {
return this.directionalStatus === 'inbound';
},
isOutgoing() {
return this.directionalStatus === 'outbound';
},
status() {
// Use internal status if we have one (from UI updates)
if (this.internalStatus) {
return this.internalStatus;
}
// First check for direct call_status in the conversation additional_attributes
// This is the most authoritative source for call status
const conversationCallStatus = this.message?.conversation?.additional_attributes?.call_status;
if (conversationCallStatus) {
// Use our composable helper for status normalization
return this.normalizeCallHelper(conversationCallStatus, this.isIncoming);
}
// Use the status from call data if present
const callStatus = this.callData?.status;
if (callStatus) {
// Use our composable helper for status normalization
return this.normalizeCallHelper(callStatus, this.isIncoming);
}
// Determine status from timestamps
if (this.callData?.ended_at) {
return 'ended';
}
if (this.callData?.missed) {
return this.isIncoming ? 'missed' : 'no-answer';
}
// Check both message data and conversation data for started_at
if (this.callData?.started_at ||
this.message?.conversation?.additional_attributes?.call_started_at) {
return 'active';
}
// Default to ringing
return 'ringing';
},
formattedDuration() {
if (
this.callData?.started_at &&
(this.status === 'active' || this.status === 'ended')
) {
const startTime = new Date(this.callData.started_at);
const endTime = this.callData?.ended_at
? new Date(this.callData.ended_at)
: new Date();
const durationMs = endTime - startTime;
return this.formatDuration(durationMs);
}
return '';
},
statusClass() {
return {
'bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100': !this.isInbox,
'bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-slate-100': this.isInbox,
'call-ringing': this.status === 'ringing',
};
},
iconName() {
// Use our composable helper for icon selection
return this.getCallIconHelper(this.status, this.isIncoming);
},
iconBgClass() {
// Icon background colors based on status
if (this.status === 'active') {
return 'bg-green-500'; // Green for calls in progress
}
if (this.status === 'missed' || this.status === 'no-answer') {
return 'bg-red-500'; // Red for missed calls
}
if (this.status === 'ended') {
return 'bg-purple-500'; // Purple for ended calls
}
// Default green for ringing
return 'bg-green-500 pulse-animation';
},
labelText() {
// Use our composable helper to get status text
// We need to convert the key to the actual text since we're in options API
const key = this.getStatusTextHelper(this.status, this.isIncoming);
// Special cases for floating widget compatibility
if (this.status === 'ringing') {
if (this.isIncoming) {
return this.$t('CONVERSATION.VOICE_CALL.INCOMING');
} else {
return this.$t('CONVERSATION.VOICE_CALL.OUTGOING');
}
}
// Map the key to the translated text
return this.$t(key);
},
labelTextClass() {
if (this.status === 'missed' || this.status === 'no-answer') {
return 'text-red-500';
}
return '';
},
subtext() {
// Checking call direction and status
const direction = this.isIncoming ? 'incoming' : 'outgoing';
// Check if we have agent_joined flag to determine if agent answered
const agentJoined = this.message?.conversation?.additional_attributes?.agent_joined === true;
const callStarted = !!this.message?.conversation?.additional_attributes?.call_started_at;
// Special handling for incoming calls that were previously joined but now ended
// This avoids showing "You didn't answer" when agent actually did answer
if (this.isIncoming && this.status === 'missed' && (agentJoined || callStarted)) {
return this.$t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
}
// Common subtext for all statuses
const subtextMap = {
incoming: {
ringing: this.$t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'),
active: this.$t('CONVERSATION.VOICE_CALL.YOU_ANSWERED'),
missed: this.$t('CONVERSATION.VOICE_CALL.YOU_DIDNT_ANSWER'),
ended: this.$t('CONVERSATION.VOICE_CALL.YOU_ANSWERED')
},
outgoing: {
ringing: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
active: this.$t('CONVERSATION.VOICE_CALL.THEY_ANSWERED'),
'no-answer': this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
ended: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
completed: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
canceled: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
failed: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
busy: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED')
}
};
// First check if we have a specific message for this status
if (subtextMap[direction] && subtextMap[direction][this.status]) {
return subtextMap[direction][this.status];
}
// Default for missing statuses
if (this.isIncoming) {
if (this.status === 'ringing') {
return this.$t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
} else if (agentJoined || callStarted) {
return this.$t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
} else {
return this.$t('CONVERSATION.VOICE_CALL.YOU_DIDNT_ANSWER');
}
} else {
return this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED');
}
},
subtextWithDuration() {
// Checking if we have start and end timestamps for duration calculation
let durationToShow = this.formattedDuration;
// Check if we have explicit call duration from the content attributes
if (!durationToShow && this.callData?.duration) {
const durationSeconds = parseInt(this.callData.duration, 10);
if (!isNaN(durationSeconds) && durationSeconds > 0) {
const minutes = Math.floor(durationSeconds / 60);
const seconds = durationSeconds % 60;
durationToShow = `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
}
// For completed calls, always show the duration if we have it
const shouldShowDuration =
(this.status === 'ended' || this.status === 'completed') &&
durationToShow;
if (shouldShowDuration) {
return `${this.subtext} · ${durationToShow}`;
}
return this.subtext;
}
},
watch: {
message: {
handler() {
this.setupVoiceCall();
},
deep: true,
},
},
mounted() {
this.setupVoiceCall();
},
beforeUnmount() {
// Clean up all intervals to prevent memory leaks
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
this.refreshInterval = null;
}
if (this.statusCheckInterval) {
clearInterval(this.statusCheckInterval);
this.statusCheckInterval = null;
}
},
methods: {
formatDuration(milliseconds) {
// Convert milliseconds to seconds
const totalSeconds = Math.floor(milliseconds / 1000);
// Calculate minutes and remaining seconds
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
// Format as MM:SS with leading zeros
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
},
setupVoiceCall() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
// Create refresh interval for active calls to update duration
if (this.status === 'active') {
this.refreshInterval = setInterval(() => {
this.$forceUpdate();
}, 1000);
// Set animation flag
this.isAnimating = true;
} else {
this.isAnimating = false;
}
// Always check for call status changes, not just when ringing
if (true) { // Always run status checks
// Create a separate interval to check if the call status has changed
this.statusCheckInterval = setInterval(() => {
// Check if content_attributes has been updated with new status
const updatedStatus = this.callData?.status;
const statusUpdatedAt = this.callData?.status_updated;
// Also check the conversation's call status (which might be more authoritative)
const conversationStatus = this.message?.conversation?.additional_attributes?.call_status;
// Check for any status changes from either source
const hasMessageStatusChanged = updatedStatus &&
updatedStatus !== this.internalStatus &&
statusUpdatedAt;
const hasConversationStatusChanged = conversationStatus &&
conversationStatus !== this.internalStatus;
// If either status has changed, update UI
if (hasMessageStatusChanged || hasConversationStatusChanged) {
// Prefer the conversation status if available (more reliable)
const newStatus = conversationStatus || updatedStatus;
// Status has changed, update UI
this.updateStatus(newStatus);
this.$forceUpdate();
// If call is now active or ended, update UI
if (newStatus === 'active' || newStatus === 'in-progress') {
this.setupVoiceCall();
}
}
}, 1000); // Check more frequently - every 1 second
} else if (this.statusCheckInterval) {
clearInterval(this.statusCheckInterval);
this.statusCheckInterval = null;
}
},
updateStatus(newStatus) {
if (
newStatus &&
newStatus !== this.status &&
newStatus !== this.internalStatus
) {
this.internalStatus = newStatus;
}
},
handlePlaybackEnd() {
this.isPlaying = false;
}
},
};
</script>
<style lang="scss" scoped>
/* Voice call styling */
.pulse-animation {
animation: pulse 1.5s infinite;
}
.call-ringing {
animation: border-pulse 1.5s infinite;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); /* Green for ringing */
}
70% {
box-shadow: 0 0 0 10px rgba(34, 197, 94, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
}
}
@keyframes border-pulse {
0% {
border-color: rgba(34, 197, 94, 0.8); /* Green for ringing */
}
50% {
border-color: rgba(34, 197, 94, 0.2);
}
100% {
border-color: rgba(34, 197, 94, 0.8);
}
}
</style>