feat: voice-call UX fixes (#14579)
## Linear ticket https://linear.app/chatwoot/issue/CW-7187/voice-calls-followup-tasks ## Description Improvements to the WhatsApp voice-calling experience plus a cheaper, more accurate audio-transcription model. - First-time callers now get a real name. An inbound WhatsApp call creates the contact from the caller's WhatsApp profile name instead of the bare phone number. - Clear, consistent call attribution. Call bubbles show a unified "Handled by {agent}" - Cleaner call widget. The dismiss (✕) button is shown only for incoming calls - WhatsApp calling for manual inboxes. voice_calling_supported? now covers any whatsapp_cloud inbox - Transcription: whisper-1 → gpt-4o-mini-transcribe. ## Type of change - [ ] New feature (non-breaking change which adds functionality) ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules
This commit is contained in:
@@ -91,10 +91,6 @@ const wasDeclinedByAgent = computed(
|
||||
endReason.value === VOICE_CALL_END_REASON.AGENT_REJECTED
|
||||
);
|
||||
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
|
||||
const didCurrentUserAnswer = computed(
|
||||
() =>
|
||||
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
|
||||
);
|
||||
const conversationAssignee = computed(() => {
|
||||
const conversation = store.getters.getConversationById?.(
|
||||
conversationId?.value
|
||||
@@ -124,43 +120,48 @@ const durationSeconds = computed(() => {
|
||||
|
||||
const formattedDuration = computed(() => formatDuration(durationSeconds.value));
|
||||
|
||||
// Agent who handled the call (initiator on outbound, answerer on inbound), taken
|
||||
// strictly from the persisted accept fields — never the conversation's current
|
||||
// assignee, which would mis-attribute a historical call after a reassignment.
|
||||
const handlerName = computed(() => {
|
||||
if (call.value?.acceptedByAgentName) return call.value.acceptedByAgentName;
|
||||
if (!acceptedByAgentId.value) return null;
|
||||
const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value);
|
||||
return agent?.available_name || agent?.name || null;
|
||||
});
|
||||
|
||||
const handledBy = computed(() =>
|
||||
handlerName.value
|
||||
? t('CONVERSATION.VOICE_CALL.HANDLED_BY', { agentName: handlerName.value })
|
||||
: null
|
||||
);
|
||||
|
||||
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';
|
||||
}
|
||||
if (isFailed.value) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL'
|
||||
: 'CONVERSATION.VOICE_CALL.MISSED_CALL';
|
||||
}
|
||||
return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
// RINGING or an as-yet-unknown/initial status: orient purely by direction so an
|
||||
// outbound call never falls through to the "Incoming call" label.
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const subtext = computed(() => {
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return isOutbound.value
|
||||
? t('CONVERSATION.VOICE_CALL.CALLING')
|
||||
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
}
|
||||
// Completed: "Handled by {agent} · 0:42" (drops either part when absent).
|
||||
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
|
||||
return formattedDuration.value;
|
||||
return [handledBy.value, formattedDuration.value]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
if (isOutbound.value) return null;
|
||||
if (didCurrentUserAnswer.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
||||
}
|
||||
if (displayAgentName.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
|
||||
agentName: displayAgentName.value,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
return handledBy.value;
|
||||
}
|
||||
if (isFailed.value) {
|
||||
// Missed/failed calls have no handler, so keep the reason rather than "Handled by".
|
||||
if (isOutbound.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT');
|
||||
}
|
||||
@@ -171,6 +172,10 @@ const subtext = computed(() => {
|
||||
}
|
||||
return t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT');
|
||||
}
|
||||
// RINGING or an as-yet-unknown/initial status.
|
||||
if (isOutbound.value) {
|
||||
return handledBy.value || t('CONVERSATION.VOICE_CALL.CALLING');
|
||||
}
|
||||
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
});
|
||||
|
||||
|
||||
@@ -31,6 +31,17 @@ const timeStampURL = computed(() => {
|
||||
return timeStampAppendedURL(attachment.dataUrl);
|
||||
});
|
||||
|
||||
const TRANSCRIPT_PREVIEW_LENGTH = 200;
|
||||
const isTranscriptExpanded = ref(false);
|
||||
const isTranscriptLong = computed(
|
||||
() => (attachment.transcribedText?.length || 0) > TRANSCRIPT_PREVIEW_LENGTH
|
||||
);
|
||||
const displayedTranscript = computed(() => {
|
||||
const text = attachment.transcribedText || '';
|
||||
if (!isTranscriptLong.value || isTranscriptExpanded.value) return text;
|
||||
return `${text.slice(0, TRANSCRIPT_PREVIEW_LENGTH).trimEnd()}…`;
|
||||
});
|
||||
|
||||
const audioPlayer = useTemplateRef('audioPlayer');
|
||||
|
||||
const isPlaying = ref(false);
|
||||
@@ -215,7 +226,18 @@ const downloadAudio = async () => {
|
||||
v-if="attachment.transcribedText && showTranscribedText"
|
||||
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
|
||||
>
|
||||
{{ attachment.transcribedText }}
|
||||
{{ displayedTranscript }}
|
||||
<button
|
||||
v-if="isTranscriptLong"
|
||||
class="block mt-1 p-0 border-0 bg-transparent text-n-slate-11 hover:text-n-slate-12 font-medium"
|
||||
@click="isTranscriptExpanded = !isTranscriptExpanded"
|
||||
>
|
||||
{{
|
||||
isTranscriptExpanded
|
||||
? $t('CONVERSATION.VOICE_CALL.TRANSCRIPT_SHOW_LESS')
|
||||
: $t('CONVERSATION.VOICE_CALL.TRANSCRIPT_SHOW_MORE')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user