chore: clean up voice message components
This commit is contained in:
+260
-2
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import { useVoiceCallHelpers } from 'dashboard/composables/useVoiceCallHelpers';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
@@ -13,13 +14,212 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
|
||||
// Use our shared voice call helper
|
||||
const {
|
||||
isVoiceChannelConversation,
|
||||
hasArrow: checkHasArrow,
|
||||
isIncomingCall: checkIsIncoming,
|
||||
normalizeCallStatus,
|
||||
getCallIconName,
|
||||
getStatusText,
|
||||
processArrowContent,
|
||||
} = useVoiceCallHelpers(props, { t });
|
||||
|
||||
// Utility function to find the last voice call message in conversation
|
||||
const findVoiceCallMessage = (conversation) => {
|
||||
// If conversation has messages property, look for voice call messages
|
||||
if (conversation && conversation.messages && Array.isArray(conversation.messages)) {
|
||||
// Look through messages in reverse to find the latest voice call
|
||||
for (let i = conversation.messages.length - 1; i >= 0; i--) {
|
||||
const msg = conversation.messages[i];
|
||||
if (msg.content_type === 'voice_call' || msg.content_type === 'voice') {
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no voice call found in messages or messages not available, check lastNonActivityMessage
|
||||
const { lastNonActivityMessage } = conversation || {};
|
||||
|
||||
if (lastNonActivityMessage?.content_type === 'voice_call' ||
|
||||
lastNonActivityMessage?.content_type === 'voice') {
|
||||
return lastNonActivityMessage;
|
||||
}
|
||||
|
||||
// Check if conversation has a call_status in additional_attributes
|
||||
// This is a strong indicator of a voice call conversation
|
||||
if (conversation?.additional_attributes?.call_status) {
|
||||
// If we have a call status but no voice call message, the lastNonActivityMessage
|
||||
// might still be related to the call (even if it doesn't have the right content_type)
|
||||
if (lastNonActivityMessage) {
|
||||
return lastNonActivityMessage;
|
||||
}
|
||||
}
|
||||
|
||||
// As a fallback, check if last message content includes "Voice Call" or common call-related terms
|
||||
if (lastNonActivityMessage?.content &&
|
||||
typeof lastNonActivityMessage.content === 'string') {
|
||||
const content = lastNonActivityMessage.content.toLowerCase();
|
||||
if (content.includes('voice call') ||
|
||||
content.includes('call ') ||
|
||||
content.includes('missed call') ||
|
||||
content.includes('incoming call') ||
|
||||
content.includes('outgoing call') ||
|
||||
content.startsWith('←') ||
|
||||
content.startsWith('→')) {
|
||||
return lastNonActivityMessage;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const voiceCallMessage = computed(() => {
|
||||
return findVoiceCallMessage(props.conversation);
|
||||
});
|
||||
|
||||
const isVoiceCall = computed(() => {
|
||||
// Force voice call view for voice channel conversations
|
||||
if (isVoiceChannelConversation.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if conversation has a call_status in additional_attributes
|
||||
if (props.conversation?.additional_attributes?.call_status) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for voice call message
|
||||
return !!voiceCallMessage.value;
|
||||
});
|
||||
|
||||
const callData = computed(() => {
|
||||
if (!isVoiceCall.value) return {};
|
||||
|
||||
// First check for data directly in conversation attributes
|
||||
const conversationAttributes = props.conversation?.custom_attributes ||
|
||||
props.conversation?.additional_attributes || {};
|
||||
if (conversationAttributes.call_data) {
|
||||
return conversationAttributes.call_data;
|
||||
}
|
||||
|
||||
// Then check message content attributes
|
||||
if (voiceCallMessage.value?.content_attributes?.data) {
|
||||
return voiceCallMessage.value.content_attributes.data;
|
||||
}
|
||||
|
||||
return {};
|
||||
});
|
||||
|
||||
const hasArrow = computed(() => {
|
||||
return checkHasArrow(voiceCallMessage.value);
|
||||
});
|
||||
|
||||
const isIncomingCall = computed(() => {
|
||||
if (!isVoiceCall.value) return null;
|
||||
|
||||
// Get the conversation call_status
|
||||
const conversationCallStatus = props.conversation?.additional_attributes?.call_status;
|
||||
|
||||
return checkIsIncoming(callData.value, voiceCallMessage.value);
|
||||
});
|
||||
|
||||
const normalizedCallStatus = computed(() => {
|
||||
if (!isVoiceCall.value) return '';
|
||||
|
||||
// First check for direct call_status in the conversation additional_attributes
|
||||
// This is the most authoritative source for call status
|
||||
const conversationCallStatus = props.conversation?.additional_attributes?.call_status;
|
||||
if (conversationCallStatus) {
|
||||
return normalizeCallStatus(conversationCallStatus, isIncomingCall.value);
|
||||
}
|
||||
|
||||
// If there's an arrow in the message, this is a legacy format message
|
||||
if (hasArrow.value) {
|
||||
const content = voiceCallMessage.value?.content || '';
|
||||
if (content.includes('ended') || content.includes('Call ended')) {
|
||||
return 'ended';
|
||||
}
|
||||
if (content.includes('missed') || content.includes('Missed call') || content.includes('no answer')) {
|
||||
return isIncomingCall.value ? 'missed' : 'no-answer';
|
||||
}
|
||||
if (content.includes('in progress') || content.includes('active') || content.includes('answered')) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
// For voice channel conversations, default to ended for better display
|
||||
if (isVoiceChannelConversation.value) {
|
||||
return 'ended';
|
||||
}
|
||||
|
||||
// Default to ended for legacy messages
|
||||
return 'ended';
|
||||
}
|
||||
|
||||
// Apply the same status mapping logic as VoiceCall component
|
||||
const callStatus = callData.value?.status;
|
||||
if (callStatus) {
|
||||
return normalizeCallStatus(callStatus, isIncomingCall.value);
|
||||
}
|
||||
|
||||
// Determine status from timestamps
|
||||
if (callData.value?.ended_at) {
|
||||
return 'ended';
|
||||
}
|
||||
if (callData.value?.missed) {
|
||||
return isIncomingCall.value ? 'missed' : 'no-answer';
|
||||
}
|
||||
if (callData.value?.started_at || props.conversation?.additional_attributes?.call_started_at) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
// For voice channel conversations, default to ended for better display
|
||||
if (isVoiceChannelConversation.value) {
|
||||
return 'ended';
|
||||
}
|
||||
|
||||
// Default to ended for any remaining cases to avoid showing incorrect status
|
||||
return 'ended';
|
||||
});
|
||||
|
||||
const callIconName = computed(() => {
|
||||
return getCallIconName(normalizedCallStatus.value, isIncomingCall.value);
|
||||
});
|
||||
|
||||
const callStatusText = computed(() => {
|
||||
if (!isVoiceCall.value) return '';
|
||||
|
||||
// For voice channel conversations, force more descriptive text
|
||||
if (isVoiceChannelConversation.value) {
|
||||
return getStatusText(normalizedCallStatus.value, isIncomingCall.value);
|
||||
}
|
||||
|
||||
// For legacy messages with arrows, just use a cleaner version of the content
|
||||
if (hasArrow.value && voiceCallMessage.value?.content) {
|
||||
return processArrowContent(
|
||||
voiceCallMessage.value.content,
|
||||
isIncomingCall.value,
|
||||
normalizedCallStatus.value
|
||||
);
|
||||
}
|
||||
|
||||
// Generate the correct status text based on call status and direction
|
||||
return getStatusText(normalizedCallStatus.value, isIncomingCall.value);
|
||||
});
|
||||
|
||||
// Return proper message content based on message type
|
||||
const lastNonActivityMessageContent = computed(() => {
|
||||
const { lastNonActivityMessage = {}, customAttributes = {} } =
|
||||
props.conversation;
|
||||
const { email: { subject } = {} } = customAttributes;
|
||||
|
||||
// Return special formatting for voice calls
|
||||
if (isVoiceCall.value) {
|
||||
return callStatusText.value;
|
||||
}
|
||||
|
||||
return getPlainText(
|
||||
subject || lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT')
|
||||
);
|
||||
@@ -42,9 +242,48 @@ const unreadMessagesCount = computed(() => {
|
||||
|
||||
<template>
|
||||
<div class="flex items-end w-full gap-2 pb-1">
|
||||
<p class="w-full mb-0 text-sm leading-7 text-n-slate-12 line-clamp-2">
|
||||
<!-- Voice Call Message -->
|
||||
<div
|
||||
v-if="isVoiceCall"
|
||||
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-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'
|
||||
}"
|
||||
>
|
||||
<!-- Explicit icon based on call status - force specific icons instead of computed properties -->
|
||||
<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' || normalizedCallStatus === 'completed'"
|
||||
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>
|
||||
<span class="text-current truncate">{{ callStatusText }}</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">
|
||||
{{ lastNonActivityMessageContent }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
|
||||
<Avatar
|
||||
:name="assignee.name"
|
||||
@@ -64,3 +303,22 @@ const unreadMessagesCount = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* Animation for ringing calls */
|
||||
.pulse-animation {
|
||||
animation: icon-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes icon-pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+222
-1
@@ -2,6 +2,7 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import { useVoiceCallHelpers } from 'dashboard/composables/useVoiceCallHelpers';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import CardLabels from 'dashboard/components-next/Conversation/ConversationCard/CardLabels.vue';
|
||||
@@ -24,7 +25,175 @@ const slaCardLabelRef = ref(null);
|
||||
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
|
||||
// Use our voice call helpers composable
|
||||
const {
|
||||
isVoiceChannelConversation,
|
||||
hasArrow,
|
||||
isIncomingCall: checkIsIncoming,
|
||||
normalizeCallStatus,
|
||||
getCallIconName,
|
||||
getStatusText,
|
||||
processArrowContent,
|
||||
} = useVoiceCallHelpers(props, { t });
|
||||
|
||||
// Force voice call view for voice channel conversations
|
||||
const isVoiceCall = computed(() => {
|
||||
if (isVoiceChannelConversation.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if conversation has a call_status in additional_attributes
|
||||
if (props.conversation?.additional_attributes?.call_status) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for voice call in last message
|
||||
const { lastNonActivityMessage } = props.conversation || {};
|
||||
if (lastNonActivityMessage?.content_type === 'voice_call' ||
|
||||
lastNonActivityMessage?.content_type === 'voice') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Look for voice call content with expanded terms
|
||||
if (lastNonActivityMessage?.content &&
|
||||
typeof lastNonActivityMessage.content === 'string') {
|
||||
const content = lastNonActivityMessage.content.toLowerCase();
|
||||
if (content.includes('voice call') ||
|
||||
content.includes('call ') ||
|
||||
content.includes('missed call') ||
|
||||
content.includes('incoming call') ||
|
||||
content.includes('outgoing call') ||
|
||||
content.startsWith('←') ||
|
||||
content.startsWith('→')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// Check if content has arrow prefix using our helper
|
||||
const messageHasArrow = computed(() => {
|
||||
const { lastNonActivityMessage } = props.conversation || {};
|
||||
return hasArrow(lastNonActivityMessage);
|
||||
});
|
||||
|
||||
// Get call data from multiple sources
|
||||
const callData = computed(() => {
|
||||
// First check for data directly in conversation attributes
|
||||
const conversationAttributes = props.conversation?.custom_attributes || {};
|
||||
if (conversationAttributes.call_data) {
|
||||
return conversationAttributes.call_data;
|
||||
}
|
||||
|
||||
// Then check message content attributes
|
||||
const { lastNonActivityMessage } = props.conversation || {};
|
||||
if (lastNonActivityMessage?.content_attributes?.data) {
|
||||
return lastNonActivityMessage.content_attributes.data;
|
||||
}
|
||||
|
||||
return {};
|
||||
});
|
||||
|
||||
const isIncomingCall = computed(() => {
|
||||
if (!isVoiceCall.value) return null;
|
||||
|
||||
const { lastNonActivityMessage } = props.conversation || {};
|
||||
return checkIsIncoming(callData.value, lastNonActivityMessage);
|
||||
});
|
||||
|
||||
const normalizedCallStatus = computed(() => {
|
||||
if (!isVoiceCall.value) return '';
|
||||
|
||||
// First check for direct call_status in the conversation additional_attributes
|
||||
// This is the most authoritative source for call status
|
||||
const conversationCallStatus = props.conversation?.additional_attributes?.call_status;
|
||||
if (conversationCallStatus) {
|
||||
return normalizeCallStatus(conversationCallStatus, isIncomingCall.value);
|
||||
}
|
||||
|
||||
// If there's an arrow in the message, this is a legacy format message
|
||||
if (messageHasArrow.value) {
|
||||
const { lastNonActivityMessage } = props.conversation || {};
|
||||
const content = lastNonActivityMessage?.content || '';
|
||||
|
||||
if (content.includes('ended') || content.includes('Call ended')) {
|
||||
return 'ended';
|
||||
}
|
||||
if (content.includes('missed') || content.includes('Missed call') || content.includes('no answer')) {
|
||||
return isIncomingCall.value ? 'missed' : 'no-answer';
|
||||
}
|
||||
if (content.includes('in progress') || content.includes('active') || content.includes('answered')) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
// For voice channel conversations, default to ended for better display
|
||||
if (isVoiceChannelConversation.value) {
|
||||
return 'ended';
|
||||
}
|
||||
|
||||
// Default to 'ended' for any legacy messages without clear status
|
||||
return 'ended';
|
||||
}
|
||||
|
||||
// Apply status mapping logic to call data status
|
||||
const callStatus = callData.value?.status;
|
||||
if (callStatus) {
|
||||
return normalizeCallStatus(callStatus, isIncomingCall.value);
|
||||
}
|
||||
|
||||
// Determine status from timestamps
|
||||
if (callData.value?.ended_at) {
|
||||
return 'ended';
|
||||
}
|
||||
if (callData.value?.missed) {
|
||||
return isIncomingCall.value ? 'missed' : 'no-answer';
|
||||
}
|
||||
if (callData.value?.started_at || props.conversation?.additional_attributes?.call_started_at) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
// For voice channel conversations, default to ended for better display
|
||||
if (isVoiceChannelConversation.value) {
|
||||
return 'ended';
|
||||
}
|
||||
|
||||
// Default to ended for any remaining cases to avoid showing incorrect status
|
||||
return 'ended';
|
||||
});
|
||||
|
||||
const callIconName = computed(() => {
|
||||
return getCallIconName(normalizedCallStatus.value, isIncomingCall.value);
|
||||
});
|
||||
|
||||
const callStatusText = computed(() => {
|
||||
if (!isVoiceCall.value) return '';
|
||||
|
||||
// For legacy messages with arrows, process using our helper
|
||||
if (messageHasArrow.value) {
|
||||
const { lastNonActivityMessage } = props.conversation || {};
|
||||
const content = lastNonActivityMessage?.content || '';
|
||||
|
||||
// Process arrow content with our helper
|
||||
return processArrowContent(content, isIncomingCall.value, normalizedCallStatus.value);
|
||||
}
|
||||
|
||||
// For voice channel conversations, use our helper for descriptive text
|
||||
if (isVoiceChannelConversation.value) {
|
||||
return getStatusText(normalizedCallStatus.value, isIncomingCall.value);
|
||||
}
|
||||
|
||||
// Generate the correct status text based on call status and direction
|
||||
return getStatusText(normalizedCallStatus.value, isIncomingCall.value);
|
||||
});
|
||||
|
||||
const lastNonActivityMessageContent = computed(() => {
|
||||
// If it's a voice call, use the voice call text with icon
|
||||
if (isVoiceCall.value) {
|
||||
return callStatusText.value;
|
||||
}
|
||||
|
||||
// Otherwise use the regular message content
|
||||
const { lastNonActivityMessage = {}, customAttributes = {} } =
|
||||
props.conversation;
|
||||
const { email: { subject } = {} } = customAttributes;
|
||||
@@ -61,7 +230,40 @@ defineExpose({
|
||||
<template>
|
||||
<div class="flex flex-col w-full gap-1">
|
||||
<div class="flex items-center justify-between w-full gap-2 py-1 h-7">
|
||||
<p class="mb-0 text-sm leading-7 text-n-slate-12 line-clamp-1">
|
||||
<!-- Voice Call Message display with icon -->
|
||||
<div
|
||||
v-if="isVoiceCall"
|
||||
class="flex items-center gap-1 mb-0 text-sm line-clamp-1"
|
||||
:class="{
|
||||
'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'
|
||||
}"
|
||||
>
|
||||
<!-- Explicit icon based on call status - force specific icons instead of computed properties -->
|
||||
<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' || normalizedCallStatus === 'completed'"
|
||||
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>
|
||||
|
||||
<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 }}
|
||||
</p>
|
||||
|
||||
@@ -105,3 +307,22 @@ defineExpose({
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* Animation for ringing calls */
|
||||
.pulse-animation {
|
||||
animation: icon-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes icon-pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+98
-2
@@ -57,6 +57,72 @@ const lastActivityAt = computed(() => {
|
||||
return timestamp ? shortTimestamp(dynamicTime(timestamp)) : '';
|
||||
});
|
||||
|
||||
const lastNonActivityMessage = computed(() => {
|
||||
return props.conversation?.lastNonActivityMessage || {};
|
||||
});
|
||||
|
||||
const isVoiceCall = computed(() => {
|
||||
return lastNonActivityMessage.value?.content_type === 'voice_call' ||
|
||||
lastNonActivityMessage.value?.content_type === 'voice';
|
||||
});
|
||||
|
||||
const callData = computed(() => {
|
||||
if (!isVoiceCall.value) return null;
|
||||
return lastNonActivityMessage.value?.content_attributes?.data || {};
|
||||
});
|
||||
|
||||
const isIncomingCall = computed(() => {
|
||||
if (!isVoiceCall.value) return false;
|
||||
|
||||
const direction = callData.value?.call_direction;
|
||||
if (direction) {
|
||||
return direction === 'inbound';
|
||||
}
|
||||
|
||||
return lastNonActivityMessage.value?.message_type === 0;
|
||||
});
|
||||
|
||||
const normalizedCallStatus = computed(() => {
|
||||
if (!isVoiceCall.value) return null;
|
||||
|
||||
// Apply the same status mapping as in VoiceCall component
|
||||
const callStatus = callData.value?.status;
|
||||
if (callStatus) {
|
||||
const statusMap = {
|
||||
'in-progress': 'active',
|
||||
'completed': 'ended',
|
||||
'canceled': 'ended',
|
||||
'failed': 'ended',
|
||||
'busy': 'no-answer',
|
||||
'no-answer': isIncomingCall.value ? 'missed' : 'no-answer'
|
||||
};
|
||||
|
||||
return statusMap[callStatus] || callStatus;
|
||||
}
|
||||
|
||||
// Determine status from timestamps
|
||||
if (callData.value?.ended_at) {
|
||||
return 'ended';
|
||||
}
|
||||
if (callData.value?.missed) {
|
||||
return isIncomingCall.value ? 'missed' : 'no-answer';
|
||||
}
|
||||
if (callData.value?.started_at) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
// Default to ringing
|
||||
return 'ringing';
|
||||
});
|
||||
|
||||
const isRingingCall = computed(() => {
|
||||
return normalizedCallStatus.value === 'ringing';
|
||||
});
|
||||
|
||||
const isActiveCall = computed(() => {
|
||||
return normalizedCallStatus.value === 'active';
|
||||
});
|
||||
|
||||
const showMessagePreviewWithoutMeta = computed(() => {
|
||||
const { labels = [] } = props.conversation;
|
||||
return (
|
||||
@@ -87,9 +153,21 @@ const onCardClick = e => {
|
||||
<template>
|
||||
<div
|
||||
role="button"
|
||||
class="flex w-full gap-3 px-3 py-4 transition-all duration-300 ease-in-out cursor-pointer"
|
||||
class="flex w-full gap-3 px-3 py-4 transition-all duration-300 ease-in-out cursor-pointer relative"
|
||||
: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
|
||||
}"
|
||||
@click="onCardClick"
|
||||
>
|
||||
<!-- Ringing call indicator (pulse effect) -->
|
||||
<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"
|
||||
@@ -111,7 +189,7 @@ const onCardClick = e => {
|
||||
<!-- Special handling for voice channel -->
|
||||
<span
|
||||
v-if="inbox.channelType === 'Channel::Voice'"
|
||||
class="i-ph-phone-fill text-n-slate-11 size-3"
|
||||
class="i-ph-phone-fill text-n-slate-11 size-3 inline-block"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
@@ -137,3 +215,21 @@ const onCardClick = e => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.conversation-ringing {
|
||||
animation: border-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@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>
|
||||
|
||||
@@ -36,6 +36,7 @@ import DyteBubble from './bubbles/Dyte.vue';
|
||||
import LocationBubble from './bubbles/Location.vue';
|
||||
import CSATBubble from './bubbles/CSAT.vue';
|
||||
import FormBubble from './bubbles/Form.vue';
|
||||
import VoiceCallBubble from './bubbles/VoiceCall.vue';
|
||||
|
||||
import MessageError from './MessageError.vue';
|
||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
||||
@@ -288,6 +289,15 @@ const componentToRender = computed(() => {
|
||||
return InstagramStoryBubble;
|
||||
}
|
||||
|
||||
// Handle voice call bubble
|
||||
if (
|
||||
props.contentType === 'voice_call' ||
|
||||
props.contentAttributes?.type === 'voice_call' ||
|
||||
props.contentAttributes?.data?.callType === 'voice_call'
|
||||
) {
|
||||
return VoiceCallBubble;
|
||||
}
|
||||
|
||||
if (Array.isArray(props.attachments) && props.attachments.length === 1) {
|
||||
const fileType = props.attachments[0].fileType;
|
||||
|
||||
@@ -487,10 +497,11 @@ provideMessageContext({
|
||||
:class="{
|
||||
'ltr:pl-9 rtl:pl-0 justify-end': orientation === ORIENTATION.RIGHT,
|
||||
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
|
||||
'min-w-0 max-w-full': componentToRender === VoiceCallBubble,
|
||||
}"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
>
|
||||
<Component :is="componentToRender" />
|
||||
<Component :is="componentToRender" :message="props" />
|
||||
</div>
|
||||
<MessageError
|
||||
v-if="contentAttributes.externalError"
|
||||
|
||||
@@ -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>
|
||||
@@ -61,15 +61,15 @@ export default {
|
||||
|
||||
// Define local fallback translations in case i18n fails
|
||||
const translations = {
|
||||
'CONVERSATION.END_CALL': 'End call',
|
||||
'CONVERSATION.JOIN_CALL': 'Join call',
|
||||
'CONVERSATION.REJECT_CALL': 'Reject',
|
||||
'CONVERSATION.CALL_ENDED': 'Call ended',
|
||||
'CONVERSATION.CALL_END_ERROR': 'Failed to end call',
|
||||
'CONVERSATION.CALL_ACCEPTED': 'Joining call...',
|
||||
'CONVERSATION.CALL_REJECTED': 'Call rejected',
|
||||
'CONVERSATION.CALL_JOIN_ERROR': 'Failed to join call',
|
||||
'CONVERSATION.INCOMING_CALL': 'Incoming call',
|
||||
'CONVERSATION.VOICE_CALL.END_CALL': 'End call',
|
||||
'CONVERSATION.VOICE_CALL.JOIN_CALL': 'Join call',
|
||||
'CONVERSATION.VOICE_CALL.REJECT_CALL': 'Reject',
|
||||
'CONVERSATION.VOICE_CALL.CALL_ENDED': 'Call ended',
|
||||
'CONVERSATION.VOICE_CALL.CALL_END_ERROR': 'Failed to end call',
|
||||
'CONVERSATION.VOICE_CALL.CALL_ACCEPTED': 'Joining call...',
|
||||
'CONVERSATION.VOICE_CALL.CALL_REJECTED': 'Call rejected',
|
||||
'CONVERSATION.VOICE_CALL.CALL_JOIN_ERROR': 'Failed to join call',
|
||||
'CONVERSATION.VOICE_CALL.INCOMING_CALL': 'Incoming call',
|
||||
};
|
||||
|
||||
// Computed properties
|
||||
@@ -402,7 +402,7 @@ export default {
|
||||
const { callSid, conversationId } = incomingCall.value;
|
||||
|
||||
// Show user feedback
|
||||
useAlert(safeTranslate('CONVERSATION.CALL_REJECTED'));
|
||||
useAlert(safeTranslate('CONVERSATION.VOICE_CALL.CALL_REJECTED'));
|
||||
|
||||
// Make API call to reject the call (optional, the caller will stay in the queue)
|
||||
await VoiceAPI.rejectCall(callSid, conversationId);
|
||||
@@ -1602,12 +1602,12 @@ export default {
|
||||
{{ displayContactName }}
|
||||
</h3>
|
||||
<div class="call-subtitle">
|
||||
{{ isIncoming ? $t('CONVERSATION.INCOMING_CALL') : (callInfo.inboxName || 'Voice Call') }}
|
||||
{{ isIncoming ? $t('CONVERSATION.VOICE_CALL.INCOMING_CALL') : $t('CONVERSATION.VOICE_CALL.OUTGOING_CALL') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="call-duration" v-if="!isIncoming">
|
||||
{{ formattedCallDuration }}
|
||||
<span class="call-duration-label">{{ formattedCallDuration }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1617,27 +1617,27 @@ export default {
|
||||
v-if="isIncoming"
|
||||
class="control-button accept-call-button"
|
||||
@click="acceptCall"
|
||||
:title="$t('CONVERSATION.JOIN_CALL')"
|
||||
:title="$t('CONVERSATION.VOICE_CALL.JOIN_CALL')"
|
||||
>
|
||||
<span class="i-ph-phone" />
|
||||
<span class="button-text">{{ $t('CONVERSATION.JOIN_CALL') }}</span>
|
||||
<span class="button-text">{{ $t('CONVERSATION.VOICE_CALL.JOIN_CALL') }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="isIncoming"
|
||||
class="control-button reject-call-button"
|
||||
@click="rejectCall"
|
||||
:title="$t('CONVERSATION.REJECT_CALL')"
|
||||
:title="$t('CONVERSATION.VOICE_CALL.REJECT_CALL')"
|
||||
>
|
||||
<span class="i-ph-phone-x" />
|
||||
<span class="button-text">{{ $t('CONVERSATION.REJECT_CALL') }}</span>
|
||||
<span class="button-text">{{ $t('CONVERSATION.VOICE_CALL.REJECT_CALL') }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="!isIncoming"
|
||||
class="control-button end-call-button"
|
||||
@click="handleEndCallClick"
|
||||
:title="$t('CONVERSATION.END_CALL')"
|
||||
:title="$t('CONVERSATION.VOICE_CALL.END_CALL')"
|
||||
>
|
||||
<span class="i-ph-phone-x" />
|
||||
</button>
|
||||
|
||||
@@ -9,7 +9,6 @@ import BubbleLocation from './bubble/Location.vue';
|
||||
import BubbleMailHead from './bubble/MailHead.vue';
|
||||
import BubbleReplyTo from './bubble/ReplyTo.vue';
|
||||
import BubbleText from './bubble/Text.vue';
|
||||
import VoiceCall from './VoiceCall.vue';
|
||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
||||
import InstagramStory from './bubble/InstagramStory.vue';
|
||||
import InstagramStoryReply from './bubble/InstagramStoryReply.vue';
|
||||
@@ -44,7 +43,6 @@ export default {
|
||||
InstagramStoryReply,
|
||||
Spinner,
|
||||
NextButton,
|
||||
VoiceCall,
|
||||
},
|
||||
props: {
|
||||
data: {
|
||||
@@ -496,15 +494,11 @@ export default {
|
||||
</template>
|
||||
</div>
|
||||
<BubbleText
|
||||
v-else-if="data.content && !isVoiceCall"
|
||||
v-else-if="data.content"
|
||||
:message="message"
|
||||
:is-email="isEmailContentType"
|
||||
:display-quoted-button="displayQuotedButton"
|
||||
/>
|
||||
<VoiceCall
|
||||
v-else-if="isVoiceCall"
|
||||
:message="data"
|
||||
/>
|
||||
<BubbleIntegration
|
||||
v-else-if="isAnIntegrationMessage"
|
||||
:message-id="data.id"
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
<template>
|
||||
<div class="voice-call-widget" :class="statusClass">
|
||||
<div class="status-icon">
|
||||
<span :class="statusIcon"></span>
|
||||
</div>
|
||||
<div class="call-info">
|
||||
<div class="call-status">{{ callStatusText }}</div>
|
||||
<div v-if="statusSubtext" class="call-subtext">{{ statusSubtext }}</div>
|
||||
</div>
|
||||
<NextButton
|
||||
v-if="showJoinButton"
|
||||
size="sm"
|
||||
icon="i-lucide-phone"
|
||||
variant="success"
|
||||
:label="$t('CONVERSATION.VOICE_CALL.JOIN_CALL')"
|
||||
:is-loading="isLoading"
|
||||
@click="joinCall"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import VoiceAPI from 'dashboard/api/channels/voice';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
status: this.message.content_attributes?.data?.status || 'ringing',
|
||||
joinedAt: null,
|
||||
duration: this.message.content_attributes?.data?.duration || null,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
// Watch for changes to message content_attributes to update call status
|
||||
'message.content_attributes.data': {
|
||||
handler(newData) {
|
||||
if (newData) {
|
||||
if (newData.status && newData.status !== this.status) {
|
||||
const oldStatus = this.status;
|
||||
this.status = newData.status;
|
||||
|
||||
// If call status changes to 'ended' or 'missed', close the floating call widget
|
||||
if ((newData.status === 'ended' || newData.status === 'missed') &&
|
||||
['ringing', 'active'].includes(oldStatus)) {
|
||||
this.closeCallWidget();
|
||||
}
|
||||
}
|
||||
if (newData.duration && newData.duration !== this.duration) {
|
||||
this.duration = newData.duration;
|
||||
}
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentConversationId: 'getSelectedChatConversationId',
|
||||
}),
|
||||
hasActiveCall() {
|
||||
return this.$store.getters['calls/hasActiveCall'];
|
||||
},
|
||||
callData() {
|
||||
return this.message.content_attributes?.data || {};
|
||||
},
|
||||
callStatusText() {
|
||||
switch (this.status) {
|
||||
case 'ringing':
|
||||
return this.$t('CONVERSATION.VOICE_CALL.RINGING');
|
||||
case 'active':
|
||||
return this.$t('CONVERSATION.VOICE_CALL.ACTIVE');
|
||||
case 'missed':
|
||||
return this.$t('CONVERSATION.VOICE_CALL.MISSED');
|
||||
case 'ended':
|
||||
return this.$t('CONVERSATION.VOICE_CALL.ENDED');
|
||||
default:
|
||||
return this.$t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
|
||||
}
|
||||
},
|
||||
statusIcon() {
|
||||
switch (this.status) {
|
||||
case 'ringing':
|
||||
return 'i-lucide-phone-incoming';
|
||||
case 'active':
|
||||
return 'i-lucide-phone';
|
||||
case 'missed':
|
||||
return 'i-lucide-phone-missed';
|
||||
case 'ended':
|
||||
return 'i-lucide-phone-off';
|
||||
default:
|
||||
return 'i-lucide-phone-incoming';
|
||||
}
|
||||
},
|
||||
statusClass() {
|
||||
return {
|
||||
'ringing': this.status === 'ringing',
|
||||
'active': this.status === 'active',
|
||||
'missed': this.status === 'missed',
|
||||
'ended': this.status === 'ended',
|
||||
};
|
||||
},
|
||||
callConversationId() {
|
||||
return this.callData.conversation_id;
|
||||
},
|
||||
showJoinButton() {
|
||||
// Show join button only if the call is ringing or active and we're on the same conversation
|
||||
return (['ringing', 'active'].includes(this.status)) &&
|
||||
(this.callConversationId === this.currentConversationId);
|
||||
},
|
||||
formattedDuration() {
|
||||
if (!this.duration) return '';
|
||||
|
||||
const minutes = Math.floor(this.duration / 60);
|
||||
const seconds = this.duration % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
},
|
||||
statusSubtext() {
|
||||
if (this.status === 'ended' && this.formattedDuration) {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.DURATION', { duration: this.formattedDuration });
|
||||
}
|
||||
if (this.status === 'missed') {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.MISSED_CALL');
|
||||
}
|
||||
return '';
|
||||
},
|
||||
isIncoming() {
|
||||
return this.message.message_type === 0; // 0 = incoming
|
||||
},
|
||||
isOutgoing() {
|
||||
return this.message.message_type === 1; // 1 = outgoing
|
||||
},
|
||||
senderText() {
|
||||
if (this.isIncoming) {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.INCOMING_FROM', { name: this.message.sender?.name || 'Unknown' });
|
||||
}
|
||||
if (this.isOutgoing) {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.OUTGOING_FROM', { name: this.message.sender?.name || 'Unknown' });
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// We don't need custom subscriptions anymore as we'll
|
||||
// get updates through Chatwoot's standard message update system
|
||||
// Just check if we have an active call with the same call_sid
|
||||
this.checkActiveCall();
|
||||
},
|
||||
methods: {
|
||||
async joinCall() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
// Update UI immediately
|
||||
this.status = 'active';
|
||||
this.joinedAt = new Date();
|
||||
|
||||
// Get the current conversation
|
||||
const conversation = await this.$store.dispatch('getConversation', {
|
||||
conversationId: this.callConversationId,
|
||||
});
|
||||
|
||||
const callSid = this.callData.call_sid;
|
||||
|
||||
if (!callSid) {
|
||||
throw new Error('No call SID found');
|
||||
}
|
||||
|
||||
// Show floating call widget
|
||||
this.$store.dispatch('calls/setActiveCall', {
|
||||
callSid,
|
||||
conversationId: this.callConversationId,
|
||||
inboxId: this.message.inbox_id,
|
||||
contactId: conversation.contact_id,
|
||||
contactName: conversation.contact?.name || 'Unknown',
|
||||
messageId: this.message.id,
|
||||
});
|
||||
|
||||
// Join the call via API
|
||||
await VoiceAPI.joinCall({
|
||||
call_sid: callSid,
|
||||
conversation_id: this.callConversationId,
|
||||
});
|
||||
|
||||
// Success notification
|
||||
useAlert(this.$t('CONVERSATION.VOICE_CALL.CALL_JOINED'));
|
||||
} catch (err) {
|
||||
console.error('Failed to join call:', err);
|
||||
useAlert(this.$t('CONVERSATION.VOICE_CALL.JOIN_ERROR'));
|
||||
// Reset status if join failed
|
||||
this.status = 'ringing';
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// We don't need custom subscription methods anymore as we'll use
|
||||
// Chatwoot's standard message update events
|
||||
|
||||
checkActiveCall() {
|
||||
// If there's an active call in the store with the same conversation ID
|
||||
// update our local state to match
|
||||
const activeCall = this.$store.getters['calls/getActiveCall'];
|
||||
if (activeCall && activeCall.conversationId === this.callConversationId) {
|
||||
this.status = 'active';
|
||||
this.joinedAt = new Date();
|
||||
}
|
||||
},
|
||||
|
||||
updateStatus(newStatus, duration = null) {
|
||||
this.status = newStatus;
|
||||
|
||||
if (newStatus === 'ended') {
|
||||
if (duration) {
|
||||
this.duration = duration;
|
||||
} else if (this.joinedAt) {
|
||||
this.duration = Math.floor((new Date() - this.joinedAt) / 1000);
|
||||
}
|
||||
// Close the floating call widget when call ends
|
||||
this.closeCallWidget();
|
||||
}
|
||||
},
|
||||
|
||||
closeCallWidget() {
|
||||
// Get the call SID from the message data
|
||||
const callSid = this.callData.call_sid;
|
||||
if (!callSid) return;
|
||||
|
||||
// Handle the call status change directly
|
||||
this.$store.dispatch('calls/handleCallStatusChanged', {
|
||||
callSid,
|
||||
status: 'ended'
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.voice-call-widget {
|
||||
@apply flex items-center gap-2 p-3 rounded-lg my-1;
|
||||
@apply bg-slate-50 dark:bg-slate-700;
|
||||
@apply border border-slate-200 dark:border-slate-600;
|
||||
@apply transition-all duration-200;
|
||||
|
||||
&.ringing {
|
||||
@apply border-green-500 dark:border-green-500;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
&.active {
|
||||
@apply border-woot-500 dark:border-woot-500 bg-woot-50 dark:bg-woot-800;
|
||||
}
|
||||
|
||||
&.missed {
|
||||
@apply border-red-300 dark:border-slate-600 bg-red-50 dark:bg-slate-700;
|
||||
}
|
||||
|
||||
&.ended {
|
||||
@apply border-slate-300 dark:border-slate-600;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
@apply flex items-center justify-center;
|
||||
@apply w-10 h-10 rounded-full;
|
||||
@apply bg-slate-200 dark:bg-slate-600;
|
||||
@apply text-slate-700 dark:text-slate-200;
|
||||
|
||||
.i-lucide-phone-incoming {
|
||||
@apply text-green-600 dark:text-green-400;
|
||||
}
|
||||
|
||||
.i-lucide-phone {
|
||||
@apply text-woot-600 dark:text-woot-400;
|
||||
}
|
||||
|
||||
.i-lucide-phone-missed {
|
||||
@apply text-red-600 dark:text-red-400;
|
||||
}
|
||||
|
||||
.i-lucide-phone-off {
|
||||
@apply text-slate-600 dark:text-slate-400;
|
||||
}
|
||||
}
|
||||
|
||||
.call-info {
|
||||
@apply flex-1;
|
||||
|
||||
.call-status {
|
||||
@apply font-medium text-slate-800 dark:text-slate-200;
|
||||
}
|
||||
|
||||
.call-subtext {
|
||||
@apply text-xs text-slate-500 dark:text-slate-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 8px rgba(16, 185, 129, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,151 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
export const useVoiceCallHelpers = (props, { t }) => {
|
||||
// Check if the conversation is from a voice channel
|
||||
const isVoiceChannelConversation = computed(() => {
|
||||
return props.conversation?.meta?.inbox?.channel_type === 'Channel::Voice';
|
||||
});
|
||||
|
||||
// Helper function to find call information from various sources
|
||||
const getCallData = (conversation) => {
|
||||
if (!conversation) return {};
|
||||
|
||||
// First check for data directly in conversation 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) => {
|
||||
if (!message?.content) return false;
|
||||
|
||||
return (
|
||||
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',
|
||||
'no-answer': isIncoming ? 'missed' : 'no-answer',
|
||||
'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 'i-ph-phone-fill';
|
||||
}
|
||||
|
||||
// Default phone icon for ringing state
|
||||
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');
|
||||
}
|
||||
} else {
|
||||
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')
|
||||
: 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,
|
||||
getCallData,
|
||||
hasArrow,
|
||||
isIncomingCall,
|
||||
normalizeCallStatus,
|
||||
getCallIconName,
|
||||
getStatusText,
|
||||
processArrowContent,
|
||||
};
|
||||
};
|
||||
@@ -237,19 +237,47 @@
|
||||
"CONTACT": "Contact",
|
||||
"COPILOT": "Copilot"
|
||||
},
|
||||
"INCOMING_CALL": "Incoming call",
|
||||
"JOIN_CALL": "Join call",
|
||||
"REJECT_CALL": "Reject call",
|
||||
"END_CALL": "End call",
|
||||
"MINIMIZE_CALL": "Minimize call",
|
||||
"EXPAND_CALL": "Expand call",
|
||||
"CALL_STATUS": {
|
||||
"CONNECTING": "Connecting...",
|
||||
"RINGING": "Ringing...",
|
||||
"CONNECTED": "Connected",
|
||||
"ENDED": "Call ended",
|
||||
"FAILED": "Call failed",
|
||||
"REJECTED": "Call rejected"
|
||||
"VOICE_CALL": {
|
||||
"TITLE": "Call",
|
||||
"RINGING": "Ringing",
|
||||
"ACTIVE": "Call in progress",
|
||||
"MISSED": "Missed Call",
|
||||
"ENDED": "Call Ended",
|
||||
"INCOMING": "Incoming call...",
|
||||
"OUTGOING": "Call started...",
|
||||
"INCOMING_CALL": "Incoming call...",
|
||||
"OUTGOING_CALL": "Outgoing call",
|
||||
"CALL_IN_PROGRESS": "Call in progress...",
|
||||
"NO_ANSWER": "No answer",
|
||||
"MISSED_CALL": "Missed call",
|
||||
"CALL_ENDED": "Call ended",
|
||||
"DURATION": "{duration}",
|
||||
"UNKNOWN": "Unknown",
|
||||
"UNKNOWN_CALLER": "Unknown caller",
|
||||
"UNKNOWN_NUMBER": "Unknown number",
|
||||
"CALL_ERROR": "Failed to initiate call. Please try again.",
|
||||
"CALL_INITIATED": "Call initiated successfully.",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"YOU_CALLED": "You called",
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered",
|
||||
"YOU_DIDNT_ANSWER": "You didn't answer",
|
||||
"RINGING_STATUS": "Ringing",
|
||||
"ACTIVE_STATUS": "Call in progress",
|
||||
"MISSED_STATUS": "Missed Call",
|
||||
"ENDED_STATUS": "Call Ended",
|
||||
"CALL_DURATION": "Duration: {duration}",
|
||||
"INCOMING_FROM": "Incoming from {name}",
|
||||
"OUTGOING_TO": "Outgoing to {name}",
|
||||
"JOIN_CALL": "Join call",
|
||||
"REJECT_CALL": "Reject call",
|
||||
"END_CALL": "End call"
|
||||
},
|
||||
"COPILOT": {
|
||||
"TRY_THESE_PROMPTS": "Try these prompts"
|
||||
},
|
||||
"GALLERY_VIEW": {
|
||||
"ERROR_DOWNLOADING": "Unable to download attachment. Please try again"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
@@ -394,11 +422,6 @@
|
||||
"TWO": "{user} and {secondUser} are typing",
|
||||
"MULTIPLE": "{user} and {count} others are typing"
|
||||
},
|
||||
"VOICE_CALL": "Call",
|
||||
"CALL_ERROR": "Failed to initiate call. Please try again.",
|
||||
"CALL_INITIATED": "Call initiated successfully.",
|
||||
"AUDIO_NOT_SUPPORTED": "Your browser does not support audio playback",
|
||||
"TRANSCRIPTION": "Call transcription",
|
||||
"COPILOT": {
|
||||
"TRY_THESE_PROMPTS": "Try these prompts"
|
||||
},
|
||||
|
||||
@@ -30,10 +30,30 @@ export default {
|
||||
computed: {
|
||||
pathSource() {
|
||||
// To support icons with multiple paths
|
||||
const path = this.icons[`${this.icon}-${this.type}`];
|
||||
if (path.constructor === Array) {
|
||||
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];
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user