mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-09 08:36:23 +00:00
439 lines
14 KiB
Vue
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> |