Fix inbound voice call ownership flow

This commit is contained in:
Muhsin
2026-03-11 20:50:08 +04:00
parent 6e46be36c8
commit fac4675dc5
14 changed files with 518 additions and 50 deletions
@@ -0,0 +1,80 @@
import { ref } from 'vue';
import { mount } from '@vue/test-utils';
import VoiceCallBubble from './VoiceCall.vue';
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
let messageContext;
vi.mock('../provider.js', () => ({
useMessageContext: () => messageContext,
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, params = {}) => {
const translations = {
'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS': 'Call in progress',
'CONVERSATION.VOICE_CALL.AGENT_ANSWERED': `${params.agentName} answered`,
'CONVERSATION.VOICE_CALL.YOU_ANSWERED': 'You answered',
'CONVERSATION.VOICE_CALL.THEY_ANSWERED': 'They answered',
'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET': 'Not answered yet',
'CONVERSATION.VOICE_CALL.CALL_ENDED': 'Call ended',
'CONVERSATION.VOICE_CALL.NO_ANSWER': 'No answer',
};
return translations[key] || key;
},
}),
}));
const mountComponent = () =>
mount(VoiceCallBubble, {
global: {
stubs: {
BaseBubble: {
template: '<div><slot /></div>',
},
Icon: true,
},
mocks: {
$t: key => key,
},
},
});
describe('VoiceCall.vue', () => {
beforeEach(() => {
messageContext = {
contentAttributes: ref({
data: {
status: VOICE_CALL_STATUS.IN_PROGRESS,
meta: {},
},
}),
currentUserId: ref(1),
messageType: ref(MESSAGE_TYPES.INCOMING),
};
});
it('shows the answering agent name when another agent answers the call', () => {
messageContext.contentAttributes.value.data.meta.joinedBy = {
id: 2,
name: 'Ben',
};
const wrapper = mountComponent();
expect(wrapper.text()).toContain('Ben answered');
});
it('shows "You answered" when the current user answered the call', () => {
messageContext.contentAttributes.value.data.meta.joinedBy = {
id: 1,
name: 'John',
};
const wrapper = mountComponent();
expect(wrapper.text()).toContain('You answered');
});
});
@@ -1,21 +1,12 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageContext } from '../provider.js';
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
const LABEL_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const SUBTEXT_MAP = {
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
@@ -30,38 +21,69 @@ const BG_COLOR_MAP = {
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
};
const { contentAttributes, messageType } = useMessageContext();
const { t } = useI18n();
const { contentAttributes, currentUserId, messageType } = useMessageContext();
const data = computed(() => contentAttributes.value?.data);
const status = computed(() => data.value?.status?.toString());
const joinedBy = computed(() => {
return data.value?.meta?.joinedBy || data.value?.meta?.joined_by;
});
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
const didCurrentUserAnswer = computed(() => {
return joinedBy.value?.id === currentUserId.value;
});
const subtextKey = computed(() => {
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
const label = computed(() => {
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
return t('CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS');
}
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
}
if (status.value === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? t('CONVERSATION.VOICE_CALL.OUTGOING_CALL')
: t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
? t('CONVERSATION.VOICE_CALL.MISSED_CALL')
: t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
});
const subtext = computed(() => {
if (status.value === VOICE_CALL_STATUS.RINGING) {
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
}
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
}
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
if (didCurrentUserAnswer.value) {
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
}
if (joinedBy.value?.name) {
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
agentName: joinedBy.value.name,
});
}
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
}
return isFailed.value
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
});
const iconName = computed(() => {
@@ -92,10 +114,10 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
<div class="flex overflow-hidden flex-col flex-grow">
<span class="text-sm font-medium truncate text-n-slate-12">
{{ $t(labelKey) }}
{{ label }}
</span>
<span class="text-xs text-n-slate-11">
{{ $t(subtextKey) }}
{{ subtext }}
</span>
</div>
</div>