chore: clean up voice message components

This commit is contained in:
Sojan
2025-05-04 05:00:20 -07:00
parent 196d7afccf
commit 4348c4ab87
27 changed files with 2652 additions and 985 deletions
@@ -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>
@@ -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>
@@ -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];
},
},