mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-12 10:06:13 +00:00
- Fix outgoing call auto-join without manual button click - Fix end call button to properly terminate calls on contact side - Fix decline button to immediately disconnect customer calls - Improve call state detection for outgoing vs incoming calls - Add proper WebRTC disconnect handling when contact hangs up - Enhanced error handling and state cleanup for all call scenarios
155 lines
5.0 KiB
Vue
155 lines
5.0 KiB
Vue
<script setup>
|
|
import { computed } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
|
|
const props = defineProps({
|
|
message: { type: Object, default: () => ({}) },
|
|
});
|
|
|
|
const { t } = useI18n();
|
|
|
|
const callData = computed(() => props.message?.contentAttributes?.data || {});
|
|
|
|
const normalizeStatus = status => {
|
|
const statusMap = {
|
|
queued: 'ringing',
|
|
initiated: 'ringing',
|
|
ringing: 'ringing',
|
|
'in-progress': 'in_progress',
|
|
active: 'in_progress',
|
|
completed: 'ended',
|
|
ended: 'ended',
|
|
missed: 'missed',
|
|
busy: 'no_answer',
|
|
failed: 'no_answer',
|
|
'no-answer': 'no_answer',
|
|
canceled: 'no_answer',
|
|
};
|
|
return statusMap[status] || status;
|
|
};
|
|
|
|
const isIncoming = computed(() => {
|
|
const direction = callData.value?.call_direction;
|
|
if (direction) return direction === 'inbound';
|
|
return props.message?.messageType === 0; // incoming
|
|
});
|
|
|
|
const status = computed(() => {
|
|
const cStatus =
|
|
props.message?.conversation?.additional_attributes?.call_status;
|
|
if (cStatus) return normalizeStatus(cStatus);
|
|
|
|
const dStatus = callData.value?.status;
|
|
if (dStatus) return normalizeStatus(dStatus);
|
|
|
|
if (callData.value?.ended_at) return 'ended';
|
|
if (callData.value?.missed) return isIncoming.value ? 'missed' : 'no_answer';
|
|
|
|
if (
|
|
callData.value?.started_at ||
|
|
props.message?.conversation?.additional_attributes?.call_started_at
|
|
) {
|
|
return 'in_progress';
|
|
}
|
|
return 'ringing';
|
|
});
|
|
|
|
const iconName = computed(() => {
|
|
if (['missed', 'no_answer'].includes(status.value))
|
|
return 'i-ph-phone-x-fill';
|
|
if (status.value === 'in_progress') return 'i-ph-phone-call-fill';
|
|
if (status.value === 'ended')
|
|
return isIncoming.value
|
|
? 'i-ph-phone-incoming-fill'
|
|
: 'i-ph-phone-outgoing-fill';
|
|
return isIncoming.value
|
|
? 'i-ph-phone-incoming-fill'
|
|
: 'i-ph-phone-outgoing-fill';
|
|
});
|
|
|
|
const iconBgClass = computed(() => {
|
|
if (status.value === 'in_progress') return 'bg-green-500';
|
|
if (['missed', 'no_answer'].includes(status.value)) return 'bg-red-500';
|
|
if (status.value === 'ended') return 'bg-purple-500';
|
|
// Ringing default with subtle pulse
|
|
return 'bg-green-500 animate-pulse';
|
|
});
|
|
|
|
const labelText = computed(() => {
|
|
if (status.value === 'in_progress')
|
|
return t('CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS');
|
|
if (isIncoming.value) {
|
|
if (status.value === 'ringing')
|
|
return t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
|
|
if (status.value === 'missed')
|
|
return t('CONVERSATION.VOICE_CALL.MISSED_CALL');
|
|
if (status.value === 'ended')
|
|
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
|
} else {
|
|
if (status.value === 'ringing')
|
|
return t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
|
|
if (status.value === 'no_answer')
|
|
return t('CONVERSATION.VOICE_CALL.NO_ANSWER');
|
|
if (status.value === 'ended')
|
|
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
|
}
|
|
return isIncoming.value
|
|
? t('CONVERSATION.VOICE_CALL.INCOMING_CALL')
|
|
: t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
|
|
});
|
|
|
|
const subtext = computed(() => {
|
|
const attrs = props.message?.conversation?.additional_attributes || {};
|
|
const agentJoined = attrs?.agent_joined === true;
|
|
const callStarted = !!attrs?.call_started_at;
|
|
if (isIncoming.value) {
|
|
if (status.value === 'ringing')
|
|
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
|
if (status.value === 'in_progress')
|
|
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
|
if (status.value === 'missed' && (agentJoined || callStarted))
|
|
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
|
if (status.value === 'missed')
|
|
return t('CONVERSATION.VOICE_CALL.YOU_DIDNT_ANSWER');
|
|
if (status.value === 'ended')
|
|
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
|
} else {
|
|
if (status.value === 'ringing')
|
|
return t('CONVERSATION.VOICE_CALL.YOU_CALLED');
|
|
if (status.value === 'in_progress')
|
|
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
|
|
if (['no_answer', 'ended'].includes(status.value))
|
|
return t('CONVERSATION.VOICE_CALL.YOU_CALLED');
|
|
}
|
|
return isIncoming.value
|
|
? t('CONVERSATION.VOICE_CALL.YOU_DIDNT_ANSWER')
|
|
: t('CONVERSATION.VOICE_CALL.YOU_CALLED');
|
|
});
|
|
|
|
const containerRingClass = computed(() => {
|
|
// Add a subtle ring effect for ringing state without custom CSS
|
|
return status.value === 'ringing' ? 'ring-1 ring-emerald-300' : '';
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="flex w-full max-w-xs flex-col overflow-hidden rounded-lg border border-slate-100 bg-white text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
|
:class="containerRingClass"
|
|
>
|
|
<div class="flex w-full items-center gap-3 p-3">
|
|
<div
|
|
class="size-10 shrink-0 flex items-center justify-center rounded-full text-white"
|
|
:class="iconBgClass"
|
|
>
|
|
<span class="text-xl" :class="[iconName]" />
|
|
</div>
|
|
|
|
<div class="flex flex-grow flex-col overflow-hidden">
|
|
<span class="truncate text-base font-medium">{{ labelText }}</span>
|
|
<span class="text-xs text-slate-500">{{ subtext }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|