chore: clean up voice message components
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user