Merge branch 'develop' into feat/app-store-reviews

This commit is contained in:
Muhsin Keloth
2026-06-02 21:13:24 +04:00
committed by GitHub
729 changed files with 20146 additions and 2805 deletions
@@ -0,0 +1,45 @@
/* global axios */
import ApiClient from '../../ApiClient';
class WhatsappCallsAPI extends ApiClient {
constructor() {
super('whatsapp_calls', { accountScoped: true });
}
show(callId) {
return axios.get(`${this.url}/${callId}`).then(r => r.data);
}
initiate(conversationId, sdpOffer) {
return axios
.post(`${this.url}/initiate`, {
conversation_id: conversationId,
sdp_offer: sdpOffer,
})
.then(r => r.data);
}
accept(callId, sdpAnswer) {
return axios
.post(`${this.url}/${callId}/accept`, { sdp_answer: sdpAnswer })
.then(r => r.data);
}
reject(callId) {
return axios.post(`${this.url}/${callId}/reject`).then(r => r.data);
}
terminate(callId) {
return axios.post(`${this.url}/${callId}/terminate`).then(r => r.data);
}
uploadRecording(callId, blob, filename = 'call-recording.webm') {
const formData = new FormData();
formData.append('recording', blob, filename);
return axios
.post(`${this.url}/${callId}/upload_recording`, formData)
.then(r => r.data);
}
}
export default new WhatsappCallsAPI();
+5 -3
View File
@@ -35,8 +35,9 @@ class ContactAPI extends ApiClient {
return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data);
}
getConversations(contactId) {
return axios.get(`${this.url}/${contactId}/conversations`);
getConversations(contactId, { inboxId } = {}) {
const params = inboxId ? { inbox_id: inboxId } : {};
return axios.get(`${this.url}/${contactId}/conversations`, { params });
}
getContactableInboxes(contactId) {
@@ -47,9 +48,10 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/labels`);
}
initiateCall(contactId, inboxId) {
initiateCall(contactId, inboxId, conversationId = null) {
return axios.post(`${this.url}/${contactId}/call`, {
inbox_id: inboxId,
conversation_id: conversationId,
});
}
@@ -87,6 +87,13 @@ class ArticlesAPI extends PortalsAPI {
);
}
bulkUpdateCategory({ portalSlug, articleIds, categoryId }) {
return axios.patch(
`${this.url}/${portalSlug}/articles/bulk_actions/update_category`,
{ ids: articleIds, category_id: categoryId }
);
}
bulkDelete({ portalSlug, articleIds }) {
return axios.delete(
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
@@ -12,6 +12,7 @@ export const buildCreatePayload = ({
bccEmails = '',
toEmails = '',
templateParams,
isVoiceMessage = false,
}) => {
let payload;
if (files && files.length !== 0) {
@@ -33,6 +34,9 @@ export const buildCreatePayload = ({
if (contentAttributes) {
payload.append('content_attributes', JSON.stringify(contentAttributes));
}
if (isVoiceMessage) {
payload.append('is_voice_message', true);
}
} else {
payload = {
content: message,
@@ -64,6 +68,7 @@ class MessageApi extends ApiClient {
bccEmails = '',
toEmails = '',
templateParams,
isVoiceMessage = false,
}) {
return axios({
method: 'post',
@@ -78,6 +83,7 @@ class MessageApi extends ApiClient {
bccEmails,
toEmails,
templateParams,
isVoiceMessage,
}),
});
}
+8
View File
@@ -52,6 +52,14 @@ class Inboxes extends CacheEnabledApiClient {
resetSecret(inboxId) {
return axios.post(`${this.url}/${inboxId}/reset_secret`);
}
enableWhatsappCalling(inboxId) {
return axios.post(`${this.url}/${inboxId}/enable_whatsapp_calling`);
}
disableWhatsappCalling(inboxId) {
return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`);
}
}
export default new Inboxes();
@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from './ApiClient';
class OnboardingAPI extends ApiClient {
constructor() {
super('onboarding', { accountScoped: true });
}
update(data) {
return axios.patch(this.url, data);
}
}
export default new OnboardingAPI();
@@ -153,4 +153,33 @@ describe('#PortalAPI', () => {
);
});
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#bulkUpdateCategory', () => {
articlesAPI.bulkUpdateCategory({
portalSlug: 'room-rental',
articleIds: [1, 2, 3],
categoryId: 7,
});
expect(axiosMock.patch).toHaveBeenCalledWith(
'/api/v1/portals/room-rental/articles/bulk_actions/update_category',
{ ids: [1, 2, 3], category_id: 7 }
);
});
});
});
@@ -41,7 +41,8 @@ describe('#ContactsAPI', () => {
it('#getConversations', () => {
contactAPI.getConversations(1);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/1/conversations'
'/api/v1/contacts/1/conversations',
{ params: {} }
);
});
@@ -83,5 +83,29 @@ describe('#ConversationAPI', () => {
template_params: undefined,
});
});
it('appends is_voice_message when isVoiceMessage is true', () => {
const formPayload = buildCreatePayload({
message: 'voice message',
echoId: 42,
isPrivate: false,
files: [new Blob(['audio-data'], { type: 'audio/ogg' })],
isVoiceMessage: true,
});
expect(formPayload).toBeInstanceOf(FormData);
expect(formPayload.get('is_voice_message')).toEqual('true');
});
it('does not append is_voice_message when isVoiceMessage is false', () => {
const formPayload = buildCreatePayload({
message: 'regular audio',
echoId: 43,
isPrivate: false,
files: [new Blob(['audio-data'], { type: 'audio/ogg' })],
isVoiceMessage: false,
});
expect(formPayload).toBeInstanceOf(FormData);
expect(formPayload.get('is_voice_message')).toBeNull();
});
});
});
@@ -49,6 +49,7 @@ const emit = defineEmits(['edit', 'delete']);
const { t } = useI18n();
const STATUS_COMPLETED = 'completed';
const STATUS_PROCESSING = 'processing';
const { formatMessage } = useMessageFormatter();
@@ -68,9 +69,15 @@ const campaignStatus = computed(() => {
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
}
return props.status === STATUS_COMPLETED
? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
: t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
if (props.status === STATUS_COMPLETED) {
return t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED');
}
if (props.status === STATUS_PROCESSING) {
return t('CAMPAIGN.SMS.CARD.STATUS.PROCESSING');
}
return t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
});
const inboxName = computed(() => props.inbox?.name || '');
@@ -1,34 +1,48 @@
<script setup>
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import { usePolicy } from 'dashboard/composables/usePolicy';
const emit = defineEmits(['add', 'import', 'export']);
const { t } = useI18n();
const { checkPermissions } = usePolicy();
const contactMenuItems = [
const contactMenuItems = computed(() => [
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.ADD_CONTACT'),
action: 'add',
value: 'add',
icon: 'i-lucide-plus',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'),
action: 'export',
value: 'export',
icon: 'i-lucide-upload',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'),
action: 'import',
value: 'import',
icon: 'i-lucide-download',
},
];
...(checkPermissions(['administrator', 'contact_manage'])
? [
{
label: t(
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'
),
action: 'export',
value: 'export',
icon: 'i-lucide-upload',
},
]
: []),
...(checkPermissions(['administrator', 'contact_manage'])
? [
{
label: t(
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'
),
action: 'import',
value: 'import',
icon: 'i-lucide-download',
},
]
: []),
]);
const showActionsDropdown = ref(false);
const handleContactAction = ({ action }) => {
@@ -3,10 +3,20 @@ import { computed, ref, useAttrs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
import {
isVoiceCallEnabled,
getVoiceCallProvider,
VOICE_CALL_PROVIDERS,
} from 'dashboard/helper/inbox';
import {
VOICE_CALL_DIRECTION,
VOICE_CALL_OUTBOUND_INIT_STATUS,
} from 'dashboard/components-next/message/constants';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import ContactAPI from 'dashboard/api/contacts';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -14,6 +24,9 @@ import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
phone: { type: String, default: '' },
contactId: { type: [String, Number], required: true },
// When set, the WhatsApp call continues in this conversation (matching the
// header button) instead of looking up the contact's most recent one.
conversationId: { type: [String, Number], default: null },
label: { type: String, default: '' },
icon: { type: [String, Object, Function], default: '' },
size: { type: String, default: 'sm' },
@@ -30,6 +43,7 @@ const { t } = useI18n();
const dialogRef = ref(null);
const callsStore = useCallsStore();
const inboxesList = useMapGetter('inboxes/getInboxes');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
@@ -38,13 +52,22 @@ const voiceInboxes = computed(() =>
);
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
// Unified behavior: hide when no phone
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
const isInitiatingCall = computed(() => {
return contactsUiFlags.value?.isInitiatingCall || false;
});
// Mirror the conversation-header button: block a new call whenever any provider
// call is already active or ringing, otherwise starting a WhatsApp call here
// would leave a still-live Twilio (or other) session with no visible control.
const isCallButtonDisabled = computed(
() =>
callsStore.hasActiveCall ||
callsStore.hasIncomingCall ||
isInitiatingCall.value
);
const navigateToConversation = conversationId => {
const accountId = route.params.accountId;
if (conversationId && accountId) {
@@ -58,23 +81,96 @@ const navigateToConversation = conversationId => {
}
};
const startCall = async inboxId => {
if (isInitiatingCall.value) return;
const whatsappCallSession = useWhatsappCallSession();
// Find the most recent open conversation for this contact in the picked inbox.
// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
// Pass inboxId so the BE applies the filter before the 20-row cap — without it,
// contacts whose latest WhatsApp conversation falls outside the 20 most recent
// across all inboxes would be treated as having no conversation.
const findWhatsappConversationId = async inboxId => {
const { data } = await ContactAPI.getConversations(props.contactId, {
inboxId,
});
const conversations = data?.payload || [];
const match = [...conversations].sort(
(a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0)
)[0];
return match?.id || null;
};
const startWhatsappCall = async (inboxId, conversationIdHint) => {
// WhatsApp /initiate is conversation-scoped, so we must hand it a
// conversation. Use the caller's hint when given (in-conversation flow);
// otherwise pick the most recent one in the inbox.
const conversationId =
conversationIdHint || (await findWhatsappConversationId(inboxId));
if (!conversationId) {
useAlert(t('CONTACT_PANEL.CALL_FAILED'));
return;
}
const response =
await whatsappCallSession.initiateOutboundCall(conversationId);
// The composable returns { status: 'locked' } when an init is already in
// flight or a call is already active; treat that as a soft no-op rather than
// claiming success.
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
if (!response?.id) {
// Permission template path returns no call id. Mirror the header button and
// surface whether the request was just sent or is already pending instead of
// claiming the call started. The permission message lands in the
// conversation, so still navigate there.
const messageKey =
response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
? 'CONTACT_PANEL.WHATSAPP_CALL_PERMISSION_PENDING'
: 'CONTACT_PANEL.WHATSAPP_CALL_PERMISSION_REQUESTED';
useAlert(t(messageKey));
navigateToConversation(conversationId);
return;
}
// Stay non-active until the connect cable event arrives — flipping to active
// here would start the duration timer before the contact picks up.
callsStore.addCall({
callSid: response.call_id,
callId: response.id,
conversationId,
inboxId,
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
provider: VOICE_CALL_PROVIDERS.WHATSAPP,
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
navigateToConversation(conversationId);
};
const startCall = async (inboxId, conversationIdHint = null) => {
if (isCallButtonDisabled.value) return;
const inbox = (inboxesList.value || []).find(i => i.id === inboxId);
if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) {
try {
await startWhatsappCall(inboxId, conversationIdHint);
} catch (error) {
useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED'));
}
return;
}
try {
const response = await store.dispatch('contacts/initiateCall', {
contactId: props.contactId,
inboxId,
conversationId: conversationIdHint,
});
const { call_sid: callSid, conversation_id: conversationId } = response;
// Add call to store immediately so widget shows
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
conversationId,
inboxId,
callDirection: 'outbound',
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
@@ -86,6 +182,22 @@ const startCall = async inboxId => {
};
const onClick = async () => {
// In conversation context, only stay in this conversation if its inbox is
// itself voice-capable (works the same for Twilio and WhatsApp). For
// non-voice channels (email, web, …) fall back to the picker so the call
// goes out via a voice inbox.
if (props.conversationId) {
const conversation = store.getters.getConversationById(
props.conversationId
);
const conversationInbox = (inboxesList.value || []).find(
i => i.id === conversation?.inbox_id
);
if (conversationInbox && isVoiceCallEnabled(conversationInbox)) {
await startCall(conversationInbox.id, props.conversationId);
return;
}
}
if (voiceInboxes.value.length > 1) {
dialogRef.value?.open();
return;
@@ -106,7 +218,7 @@ const onPickInbox = async inbox => {
v-if="shouldRender"
v-tooltip.top-end="tooltipLabel || null"
v-bind="attrs"
:disabled="isInitiatingCall"
:disabled="isCallButtonDisabled"
:is-loading="isInitiatingCall"
:label="label"
:icon="icon"
@@ -2,6 +2,7 @@
import { ref, computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
@@ -18,6 +19,7 @@ import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/A
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import BulkTranslateDialog from './BulkTranslateDialog.vue';
const props = defineProps({
@@ -62,6 +64,7 @@ const isFeatureEnabledonAccount = useMapGetter(
const selectedArticleIds = ref(new Set());
const deleteConfirmDialogRef = ref(null);
const isCategoryMenuOpen = ref(false);
const { isEnterprise } = useConfig();
@@ -221,6 +224,34 @@ const bulkUpdateStatus = async status => {
}
};
const categoryMenuItems = computed(() =>
props.categories.map(category => ({
label: category.name,
value: category.id,
action: 'move',
emoji: category.icon,
}))
);
const handleBulkUpdateCategory = async ({ value }) => {
isCategoryMenuOpen.value = false;
try {
await articlesAPI.bulkUpdateCategory({
portalSlug: route.params.portalSlug,
articleIds: [...selectedArticleIds.value],
categoryId: value,
});
onBulkActionSuccess(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.CATEGORY_SUCCESS')
);
} catch (error) {
useAlert(
error?.message ||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.CATEGORY_ERROR')
);
}
};
const confirmBulkDelete = () => {
deleteConfirmDialogRef.value?.open();
};
@@ -340,6 +371,30 @@ watch(
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('archived')"
/>
<div v-if="categoryMenuItems.length" class="relative group">
<OnClickOutside @trigger="isCategoryMenuOpen = false">
<Button
sm
faded
slate
icon="i-lucide-folder-input"
:label="
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.MOVE_TO_CATEGORY'
)
"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="isCategoryMenuOpen = !isCategoryMenuOpen"
/>
<DropdownMenu
v-if="isCategoryMenuOpen"
:menu-items="categoryMenuItems"
show-search
class="right-0 w-48 mt-2 top-full max-h-60"
@action="handleBulkUpdateCategory"
/>
</OnClickOutside>
</div>
<Button
v-if="isTranslationAvailable"
sm
@@ -202,7 +202,7 @@ defineExpose({ state, isSubmitDisabled });
size="sm"
type="button"
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
class="!h-[2.4rem] !w-[2.375rem] absolute top-[1.94rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
class="!h-[2.38rem] !w-[2.375rem] absolute top-[2rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiInput
@@ -32,7 +32,11 @@ const modelValue = defineModel({ type: Boolean, default: false });
<span class="text-heading-3 text-n-slate-12">
{{ header }}
</span>
<div v-if="hideToggle" class="size-2" />
<template v-if="hideToggle">
<slot name="hiddenToggle">
<div class="size-2" />
</slot>
</template>
<ToggleSwitch v-else v-model="modelValue" />
</div>
<span v-if="description" class="text-body-main text-n-slate-11">
@@ -0,0 +1,179 @@
<script setup>
import CallCard from './CallCard.vue';
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
const whatsappCall = {
callSid: 'wamid.HBgM',
conversationId: 1234,
provider: VOICE_CALL_PROVIDERS.WHATSAPP,
};
const twilioCall = {
callSid: 'CA1234567890',
conversationId: 1234,
};
const richCallInfo = {
contactName: 'John Does',
phoneNumber: '+1 412 245 0242',
inboxName: 'Customer support',
location: 'San Francisco, United States',
countryFlag: '\u{1F1FA}\u{1F1F8}',
hasLocation: true,
avatar:
'https://app.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBd3FodGc9PSIsImV4cCI6bnVsbCwicHVyIjoiYmxvYl9pZCJ9fQ==--d218a325af0ef45061eefd352f8efb9ac84275e8/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lKYW5CbFp3WTZCa1ZVT2hOeVpYTnBlbVZmZEc5ZlptbHNiRnNIYVFINk1BPT0iLCJleHAiOm51bGwsInB1ciI6InZhcmlhdGlvbiJ9fQ==--533c3ad7218e24c4b0e8f8959dc1953ce1d279b9/1707423736896.jpeg',
};
// Falls back to inbox name when neither city nor country is on the contact.
const noLocationCallInfo = {
contactName: 'Anonymous Lead',
phoneNumber: '+44 20 7916 0428',
inboxName: 'Sales',
location: 'Sales',
countryFlag: '',
hasLocation: false,
avatar: '',
};
// Truncation regression check — both contact name and location should ellipsize.
const longCallInfo = {
contactName: 'Aleksandra Konstantinopolskaya',
phoneNumber: '+49 30 911890',
inboxName: 'Customer support',
location:
'Friedrichshain-Kreuzberg, Federal Republic of Germany (Deutschland)',
countryFlag: '\u{1F1E9}\u{1F1EA}',
hasLocation: true,
avatar: '',
};
const unknownCallerInfo = {
contactName: 'Unknown caller',
phoneNumber: '',
inboxName: 'Customer support',
location: 'Customer support',
countryFlag: '',
hasLocation: false,
avatar: '',
};
const log =
(label, ...args) =>
() => {
// eslint-disable-next-line no-console
console.log(`[CallCard.story] ${label}`, ...args);
};
</script>
<template>
<Story
title="Components/Call/CallCard"
:layout="{ type: 'grid', width: '400px' }"
>
<Variant title="Incoming · WhatsApp">
<CallCard
:call="whatsappCall"
:call-info="richCallInfo"
:state="VOICE_CALL_DIRECTION.INCOMING"
@accept="log('accept')"
@reject="log('reject')"
@go-to-conversation="log('goToConversation')"
/>
</Variant>
<Variant title="Incoming · Twilio (no flag)">
<CallCard
:call="twilioCall"
:call-info="noLocationCallInfo"
:state="VOICE_CALL_DIRECTION.INCOMING"
@accept="log('accept')"
@reject="log('reject')"
@go-to-conversation="log('goToConversation')"
/>
</Variant>
<Variant title="Outgoing · WhatsApp ringing">
<CallCard
:call="whatsappCall"
:call-info="richCallInfo"
:state="VOICE_CALL_DIRECTION.OUTGOING"
@reject="log('reject')"
@go-to-conversation="log('goToConversation')"
/>
</Variant>
<Variant title="Ongoing · WhatsApp (mute visible, unmuted)">
<CallCard
:call="whatsappCall"
:call-info="richCallInfo"
:state="VOICE_CALL_DIRECTION.ONGOING"
duration="01:23"
:is-muted="false"
show-mute
@end="log('end')"
@toggle-mute="log('toggleMute')"
@go-to-conversation="log('goToConversation')"
/>
</Variant>
<Variant title="Ongoing · WhatsApp muted">
<CallCard
:call="whatsappCall"
:call-info="richCallInfo"
:state="VOICE_CALL_DIRECTION.ONGOING"
duration="03:47"
is-muted
show-mute
@end="log('end')"
@toggle-mute="log('toggleMute')"
@go-to-conversation="log('goToConversation')"
/>
</Variant>
<Variant title="Ongoing · Twilio (no mute control)">
<CallCard
:call="twilioCall"
:call-info="richCallInfo"
:state="VOICE_CALL_DIRECTION.ONGOING"
duration="00:42"
@end="log('end')"
@go-to-conversation="log('goToConversation')"
/>
</Variant>
<Variant title="Incoming · Unknown caller">
<CallCard
:call="whatsappCall"
:call-info="unknownCallerInfo"
:state="VOICE_CALL_DIRECTION.INCOMING"
@accept="log('accept')"
@reject="log('reject')"
@go-to-conversation="log('goToConversation')"
/>
</Variant>
<Variant title="Truncation · long name and location">
<CallCard
:call="whatsappCall"
:call-info="longCallInfo"
:state="VOICE_CALL_DIRECTION.INCOMING"
@accept="log('accept')"
@reject="log('reject')"
@go-to-conversation="log('goToConversation')"
/>
</Variant>
<Variant title="Ongoing · no conversation link in footer">
<CallCard
:call="{ ...whatsappCall, conversationId: null }"
:call-info="richCallInfo"
:state="VOICE_CALL_DIRECTION.ONGOING"
duration="02:10"
show-mute
@end="log('end')"
@toggle-mute="log('toggleMute')"
/>
</Variant>
</Story>
</template>
@@ -0,0 +1,242 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
call: {
type: Object,
required: true,
},
callInfo: {
type: Object,
required: true,
},
// 'incoming' | 'outgoing' | 'ongoing'
state: {
type: String,
required: true,
},
duration: {
type: String,
default: '',
},
isMuted: {
type: Boolean,
default: false,
},
showMute: {
type: Boolean,
default: false,
},
});
defineEmits([
'accept',
'reject',
'end',
'toggleMute',
'goToConversation',
'dismiss',
]);
const { t } = useI18n();
const isOngoing = computed(() => props.state === VOICE_CALL_DIRECTION.ONGOING);
const isIncoming = computed(
() => props.state === VOICE_CALL_DIRECTION.INCOMING
);
const isOutgoing = computed(
() => props.state === VOICE_CALL_DIRECTION.OUTGOING
);
const statusIcon = computed(() => {
if (isOngoing.value) return 'i-ph-phone-call-bold';
if (isOutgoing.value) return 'i-ph-phone-outgoing-bold';
return 'i-ph-phone-incoming-bold';
});
const statusLabel = computed(() => {
if (isOngoing.value) return t('CONVERSATION.VOICE_WIDGET.CALL_IN_PROGRESS');
if (isOutgoing.value) return t('CONVERSATION.VOICE_WIDGET.OUTGOING_CALL');
return t('CONVERSATION.VOICE_WIDGET.INCOMING_CALL');
});
const channelIcon = computed(() => {
if (props.call?.provider === VOICE_CALL_PROVIDERS.WHATSAPP)
return 'i-ri-whatsapp-fill';
return 'i-ph-phone-bold';
});
</script>
<template>
<div
class="flex flex-col gap-2 pt-4 bg-n-solid-2/95 rounded-2xl shadow-xl outline outline-1 outline-n-strong backdrop-blur-md"
:class="call?.conversationId ? 'pb-2' : 'pb-4'"
>
<!-- Top section: status badge + location/inbox + duration -->
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2 px-4">
<!-- Ongoing: status badge on left -->
<div v-if="isOngoing" class="flex items-center gap-1.5 shrink-0">
<Icon :icon="statusIcon" class="size-3.5 text-n-teal-9 shrink-0" />
<span class="text-xs font-medium text-n-teal-9 tracking-tight">
{{ statusLabel }}
</span>
</div>
<!-- Caller location (city, country) or fallback to channel + inbox name -->
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<span
v-if="callInfo.hasLocation && callInfo.countryFlag"
class="text-sm leading-none shrink-0"
>
{{ callInfo.countryFlag }}
</span>
<Icon
v-else-if="!isOngoing"
:icon="channelIcon"
class="size-3.5 text-n-slate-10 shrink-0"
/>
<span
class="text-xs font-medium text-n-slate-11 tracking-tight truncate"
>
{{ callInfo.location }}
</span>
</div>
<!-- Ongoing: duration on right -->
<p
v-if="isOngoing"
class="font-display text-base font-medium text-n-slate-11 shrink-0 mb-0 tabular-nums tracking-tight"
>
{{ duration }}
</p>
<!-- Incoming/Outgoing: status badge on right -->
<div v-else class="flex items-center gap-1.5 shrink-0">
<Icon :icon="statusIcon" class="size-3.5 text-n-teal-9 shrink-0" />
<span class="text-xs font-medium text-n-teal-9 tracking-tight">
{{ statusLabel }}
</span>
<!-- Dismiss: removes the notification from the UI without declining.
Incoming only outgoing/ongoing calls are ended via the call
controls, not silently dismissed. -->
<NextButton
v-if="isIncoming"
v-tooltip.top="$t('CONVERSATION.VOICE_WIDGET.DISMISS_CALL')"
icon="i-ph-x-bold"
slate
ghost
xs
class="!rounded-full -my-1"
@click="$emit('dismiss')"
/>
</div>
</div>
<!-- Main row: avatar + name/phone + actions -->
<div class="flex items-center gap-3 px-4">
<div class="shrink-0">
<Avatar
:src="callInfo.avatar"
:name="callInfo.contactName"
:size="40"
/>
</div>
<div class="flex-1 min-w-0">
<p
class="font-display text-sm font-medium text-n-slate-12 truncate mb-0.5 tracking-tight leading-tight"
>
{{ callInfo.contactName }}
</p>
<p
v-if="callInfo.phoneNumber"
class="text-sm text-n-slate-11 truncate mb-0 tracking-tight leading-tight"
>
{{ callInfo.phoneNumber }}
</p>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 shrink-0">
<!-- Mute toggle (WhatsApp ongoing only) -->
<NextButton
v-if="isOngoing && showMute"
v-tooltip.top="
isMuted
? $t('CONVERSATION.VOICE_WIDGET.UNMUTE')
: $t('CONVERSATION.VOICE_WIDGET.MUTE')
"
:icon="
isMuted ? 'i-ph-microphone-slash-bold' : 'i-ph-microphone-bold'
"
:variant="isMuted ? 'solid' : 'faded'"
:color="isMuted ? 'amber' : 'teal'"
class="!rounded-full"
@click="$emit('toggleMute')"
/>
<!-- Accept call (incoming only) -->
<NextButton
v-if="isIncoming"
v-tooltip.top="$t('CONVERSATION.VOICE_WIDGET.JOIN_CALL')"
icon="i-ph-phone-bold"
teal
class="!rounded-full"
@click="$emit('accept')"
/>
<!-- Reject / end call (all states) -->
<NextButton
v-tooltip.top="
isOngoing
? $t('CONVERSATION.VOICE_WIDGET.END_CALL')
: $t('CONVERSATION.VOICE_WIDGET.REJECT_CALL')
"
icon="i-ph-phone-x-bold"
ruby
class="!rounded-full rotate-[134deg]"
@click="isOngoing ? $emit('end') : $emit('reject')"
/>
</div>
</div>
</div>
<!-- Footer: go to conversation thread -->
<NextButton
v-if="call?.conversationId"
slate
ghost
trailing-icon
class="!justify-between !px-2 !mx-2"
@click="$emit('goToConversation')"
>
<template #icon>
<span
class="flex items-center gap-1 text-n-slate-11 group-hover:text-n-slate-12"
>
<Icon
icon="i-ph-chat-circle-text-bold"
class="size-3.5 text-n-slate-11 shrink-0"
/>
<span class="text-sm tracking-tight tabular-nums">
#{{ call.conversationId }}
</span>
<Icon
icon="i-ph-caret-right-bold"
class="size-3 text-n-slate-11 shrink-0"
/>
</span>
</template>
<span
class="text-sm text-n-slate-11 tracking-tight group-hover:text-n-slate-12"
>
{{ $t('CONVERSATION.VOICE_WIDGET.GO_TO_CONVERSATION') }}
</span>
</NextButton>
</div>
</template>
@@ -0,0 +1,268 @@
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useCallSession } from 'dashboard/composables/useCallSession';
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
import CallCard from 'dashboard/components-next/call/CallCard.vue';
import countriesList from 'shared/constants/countries.js';
const RINGTONE_URL = '/audio/dashboard/bell.mp3';
const route = useRoute();
const router = useRouter();
const store = useStore();
const {
activeCall,
incomingCalls,
hasActiveCall,
isJoining,
joinCall,
endCall: endCallSession,
rejectIncomingCall,
dismissCall,
formattedCallDuration,
} = useCallSession();
// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and
// don't expose a mic track on the browser side.
const isMuted = ref(false);
const isWhatsappActive = computed(
() => activeCall.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
);
const primaryIncomingCall = computed(() =>
hasActiveCall.value ? null : incomingCalls.value[0] || null
);
const stackedIncomingCalls = computed(() =>
hasActiveCall.value ? incomingCalls.value : incomingCalls.value.slice(1)
);
const mainCardState = computed(() => {
if (hasActiveCall.value) return VOICE_CALL_DIRECTION.ONGOING;
const direction = primaryIncomingCall.value?.callDirection;
return direction === VOICE_CALL_DIRECTION.OUTBOUND
? VOICE_CALL_DIRECTION.OUTGOING
: VOICE_CALL_DIRECTION.INCOMING;
});
// Stacked cards are always non-active (ringing) calls, so reflect each call's
// real direction. An outbound call must render as OUTGOING — otherwise it shows
// the incoming-only dismiss (✕) control and the agent could drop it locally
// without terminating, leaving the customer ringing with no widget to end it.
const stackedCardState = call =>
call?.callDirection === VOICE_CALL_DIRECTION.OUTBOUND
? VOICE_CALL_DIRECTION.OUTGOING
: VOICE_CALL_DIRECTION.INCOMING;
const toggleMute = () => {
isMuted.value = !isMuted.value;
setWhatsappCallMuted(isMuted.value);
};
watch(hasActiveCall, active => {
if (!active) isMuted.value = false;
});
// Convert ISO 3166-1 alpha-2 country code (e.g. "US") to its regional indicator
// flag emoji. Returns empty string if the code is missing or malformed.
const countryCodeToFlag = code => {
if (!code || code.length !== 2) return '';
const base = 0x1f1e6;
const offset = 'A'.charCodeAt(0);
return String.fromCodePoint(
...code
.toUpperCase()
.split('')
.map(c => base + (c.charCodeAt(0) - offset))
);
};
const getCallInfo = call => {
const conversation = store.getters.getConversationById(call?.conversationId);
// Look up inbox from the call's own inboxId — the conversation can drop out
// of the Vuex store when the user navigates between inbox views, so going
// through `conversation.inbox_id` would lose the inbox name (and fall back
// to the literal "Customer support" string).
const inbox = store.getters['inboxes/getInbox'](call?.inboxId);
const sender = conversation?.meta?.sender;
// `caller` is the snapshot captured when the call first landed (from the
// message sender or the WhatsApp cable payload). It outlives the
// conversation being in the store, so prefer it for display.
const caller = call?.caller;
const additional =
sender?.additional_attributes || caller?.additionalAttributes || {};
const city = additional.city || '';
const countryCode = additional.country_code || '';
const country =
additional.country ||
countriesList.find(c => c.id === countryCode.toUpperCase())?.name ||
'';
// Prefer the richest available location string ("City, Country"); fall back to
// whichever single field is present; finally fall back to the inbox name so
// there's always something to show.
const locationParts = [city, country].filter(Boolean);
const location =
locationParts.join(', ') || inbox?.name || 'Customer support';
return {
conversation,
inbox,
contactName:
caller?.name ||
sender?.name ||
caller?.phone ||
sender?.phone_number ||
'Unknown caller',
phoneNumber: caller?.phone || sender?.phone_number || '',
inboxName: inbox?.name || 'Customer support',
location,
countryFlag: countryCodeToFlag(countryCode),
hasLocation: locationParts.length > 0,
avatar: caller?.avatar || sender?.avatar || sender?.thumbnail,
};
};
const goToConversation = call => {
const conversationId = call?.conversationId;
const accountId = route.params.accountId;
if (!conversationId || !accountId) return;
router.push({
path: frontendURL(conversationUrl({ accountId, id: conversationId })),
});
};
const handleEndCall = async () => {
const call = activeCall.value;
if (!call) return;
const inboxId = call.inboxId || getCallInfo(call).conversation?.inbox_id;
if (!inboxId) return;
await endCallSession({
conversationId: call.conversationId,
inboxId,
callSid: call.callSid,
});
};
const handleJoinCall = async call => {
if (!call || isJoining.value) return;
const { conversation } = getCallInfo(call);
if (hasActiveCall.value) {
await handleEndCall();
}
// The conversation may not be hydrated yet (post-refresh seeding path);
// call.inboxId already carries what joinCall needs.
const result = await joinCall({
conversationId: call.conversationId,
inboxId: call.inboxId || conversation?.inbox_id,
callSid: call.callSid,
});
if (result && conversation) {
router.push({
name: 'inbox_conversation',
params: { conversation_id: call.conversationId },
});
}
};
// Auto-join outbound calls when window is visible. WhatsApp outbound has no
// separate join step (the offer was sent at initiate time and the answer is
// applied directly by the cable handler), so this only covers Twilio.
watch(
() => incomingCalls.value[0],
call => {
if (
call?.callDirection === VOICE_CALL_DIRECTION.OUTBOUND &&
call?.provider !== VOICE_CALL_PROVIDERS.WHATSAPP &&
!hasActiveCall.value &&
WindowVisibilityHelper.isWindowVisible()
) {
handleJoinCall(call);
}
},
{ immediate: true }
);
// Loop the ringtone while an inbound call is unanswered. Stop the moment any
// call is active (we joined), every inbound call cleared, or the widget tears
// down. The watcher only fires on the boolean transitioning, so additional
// ringing calls arriving while one is already ringing don't restart the audio
// — they silently stack into the UI without producing a fresh ring.
// Browser autoplay may reject the first play() if the tab has no prior
// user gesture; that's fine — the visual widget still surfaces the call.
const ringtone = new Audio(RINGTONE_URL);
ringtone.loop = true;
ringtone.volume = 1;
const stopRingtone = () => {
ringtone.pause();
ringtone.currentTime = 0;
};
const ringingInbound = computed(() =>
incomingCalls.value.some(
call => call.callDirection !== VOICE_CALL_DIRECTION.OUTBOUND
)
);
watch(
() => ringingInbound.value && !hasActiveCall.value,
shouldRing => {
if (shouldRing) {
ringtone.play().catch(() => {});
} else {
stopRingtone();
}
},
{ immediate: true }
);
onBeforeUnmount(stopRingtone);
</script>
<template>
<div
v-if="incomingCalls.length || hasActiveCall"
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-3 w-[400px]"
>
<!-- Stacked incoming calls (shown above the primary card) -->
<CallCard
v-for="call in stackedIncomingCalls"
:key="call.callSid"
:call="call"
:state="stackedCardState(call)"
:call-info="getCallInfo(call)"
@accept="handleJoinCall(call)"
@reject="rejectIncomingCall(call.callSid)"
@dismiss="dismissCall(call.callSid)"
@go-to-conversation="goToConversation(call)"
/>
<!-- Main Call Widget -->
<CallCard
v-if="hasActiveCall || primaryIncomingCall"
:call="activeCall || primaryIncomingCall"
:state="mainCardState"
:call-info="getCallInfo(activeCall || primaryIncomingCall)"
:duration="hasActiveCall ? formattedCallDuration : ''"
:is-muted="isMuted"
:show-mute="hasActiveCall && isWhatsappActive"
@accept="handleJoinCall(primaryIncomingCall)"
@reject="rejectIncomingCall(primaryIncomingCall?.callSid)"
@dismiss="dismissCall(primaryIncomingCall?.callSid)"
@end="handleEndCall"
@toggle-mute="toggleMute"
@go-to-conversation="goToConversation(activeCall || primaryIncomingCall)"
/>
</div>
</template>
@@ -25,7 +25,7 @@ const store = useStore();
const bulkDeleteDialog = ref(null);
const isSyncableDocument = doc =>
!doc.pdf_document && doc.status === 'available' && !doc.sync_in_progress;
!doc.pdf_document && doc.status === 'available';
const syncableSelectedIds = computed(() => {
if (!props.selectedIds.size) return [];
@@ -127,7 +127,6 @@ const menuItems = computed(() => {
value: 'sync',
action: 'sync',
icon: 'i-lucide-refresh-cw',
disabled: props.syncInProgress,
});
}
@@ -0,0 +1,65 @@
<script setup>
import ChannelIcon from './ChannelIcon.vue';
import { useChannelBrandIcon } from './provider';
const inboxes = [
{ name: 'API', channel_type: 'Channel::Api' },
{ name: 'Email', channel_type: 'Channel::Email' },
{ name: 'Gmail', channel_type: 'Channel::Email', provider: 'google' },
{ name: 'Outlook', channel_type: 'Channel::Email', provider: 'microsoft' },
{ name: 'Messenger', channel_type: 'Channel::FacebookPage' },
{ name: 'Instagram', channel_type: 'Channel::Instagram' },
{ name: 'Line', channel_type: 'Channel::Line' },
{ name: 'Telegram', channel_type: 'Channel::Telegram' },
{ name: 'WhatsApp', channel_type: 'Channel::Whatsapp' },
{ name: 'TikTok', channel_type: 'Channel::Tiktok' },
{ name: 'SMS', channel_type: 'Channel::Sms' },
{ name: 'Twilio SMS', channel_type: 'Channel::TwilioSms' },
{
name: 'Twilio WhatsApp',
channel_type: 'Channel::TwilioSms',
medium: 'whatsapp',
},
{
name: 'Voice',
channel_type: 'Channel::TwilioSms',
voice_enabled: true,
},
{ name: 'Website', channel_type: 'Channel::WebWidget' },
];
const brandInboxes = inboxes.filter(inbox => useChannelBrandIcon(inbox).value);
</script>
<template>
<Story title="Components/Icons/ChannelIcon">
<Variant title="Glyph (default)">
<div class="grid grid-cols-4 gap-5">
<div
v-for="inbox in inboxes"
:key="inbox.name"
class="flex items-center gap-2"
>
<ChannelIcon :inbox="inbox" class="size-6 text-n-slate-11" />
<span>{{ inbox.name }}</span>
</div>
</div>
</Variant>
<Variant title="Brand icon">
<div class="grid grid-cols-4 gap-5">
<div
v-for="inbox in brandInboxes"
:key="inbox.name"
class="flex items-center gap-2"
>
<ChannelIcon
:inbox="inbox"
use-brand-icon
class="size-6 text-n-slate-11"
/>
<span>{{ inbox.name }}</span>
</div>
</div>
</Variant>
</Story>
</template>
@@ -1,6 +1,7 @@
<script setup>
import { toRef } from 'vue';
import { useChannelIcon } from './provider';
import { computed, toRef } from 'vue';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
import { useChannelIcon, useChannelBrandIcon } from './provider';
import Icon from 'next/icon/Icon.vue';
const props = defineProps({
@@ -8,11 +9,35 @@ const props = defineProps({
type: Object,
required: true,
},
// When true, render the full-color brand icon (when one exists for the
// channel type) and fall back to the monochrome glyph otherwise.
useBrandIcon: {
type: Boolean,
default: false,
},
});
const channelIcon = useChannelIcon(toRef(props, 'inbox'));
defineOptions({ inheritAttrs: false });
const inboxRef = toRef(props, 'inbox');
const hasVoiceBadge = computed(() => isVoiceCallEnabled(props.inbox));
const channelIcon = useChannelIcon(inboxRef);
const brandIcon = useChannelBrandIcon(inboxRef);
const icon = computed(() =>
props.useBrandIcon && brandIcon.value ? brandIcon.value : channelIcon.value
);
</script>
<template>
<Icon :icon="channelIcon" />
<span class="relative inline-flex" v-bind="$attrs">
<Icon :icon="icon" class="size-full" />
<span
v-if="hasVoiceBadge"
class="absolute top-0 ltr:right-0 rtl:left-0 inline-flex items-center justify-center size-2 rounded-full bg-n-surface-1"
>
<Icon icon="i-lucide-audio-lines" class="size-1.5 text-n-slate-12" />
</span>
</span>
</template>
@@ -1,46 +1,76 @@
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import { computed } from 'vue';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
const channelTypeIconMap = {
'Channel::Api': 'i-woot-api',
'Channel::Email': 'i-woot-mail',
'Channel::FacebookPage': 'i-woot-messenger',
'Channel::Line': 'i-woot-line',
'Channel::Sms': 'i-woot-sms',
'Channel::Telegram': 'i-woot-telegram',
'Channel::TwilioSms': 'i-woot-sms',
'Channel::TwitterProfile': 'i-woot-x',
'Channel::WebWidget': 'i-woot-website',
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok',
'Channel::AppStore': 'i-ri-app-store-fill',
};
const providerIconMap = {
microsoft: 'i-woot-outlook',
google: 'i-woot-gmail',
};
// Full-color brand icons. Most come from the `logos` Iconify set; Instagram,
// Outlook and Line use custom `woot` glyphs since the `logos` versions are
// monochrome or missing. Channels not listed here have no brand variant and
// callers should fall back to the monochrome glyph via useChannelIcon.
const channelTypeBrandIconMap = {
'Channel::FacebookPage': 'i-logos-messenger',
'Channel::Line': 'i-woot-line-color',
'Channel::Telegram': 'i-logos-telegram',
'Channel::Whatsapp': 'i-logos-whatsapp-icon',
'Channel::Instagram': 'i-woot-instagram-color',
'Channel::Tiktok': 'i-logos-tiktok-icon',
};
const providerBrandIconMap = {
microsoft: 'i-woot-outlook-color',
google: 'i-logos-google-gmail',
};
const resolveInbox = inbox => inbox?.value ?? inbox;
export function useChannelIcon(inbox) {
const channelTypeIconMap = {
'Channel::Api': 'i-woot-api',
'Channel::Email': 'i-woot-mail',
'Channel::FacebookPage': 'i-woot-messenger',
'Channel::Line': 'i-woot-line',
'Channel::Sms': 'i-woot-sms',
'Channel::Telegram': 'i-woot-telegram',
'Channel::TwilioSms': 'i-woot-sms',
'Channel::TwitterProfile': 'i-woot-x',
'Channel::WebWidget': 'i-woot-website',
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok',
'Channel::AppStore': 'i-ri-app-store-fill',
};
const providerIconMap = {
microsoft: 'i-woot-outlook',
google: 'i-woot-gmail',
};
const channelIcon = computed(() => {
const inboxDetails = inbox.value || inbox;
const inboxDetails = resolveInbox(inbox);
const type = inboxDetails.channel_type;
let icon = channelTypeIconMap[type];
if (type === 'Channel::Email' && inboxDetails.provider) {
if (type === INBOX_TYPES.EMAIL && inboxDetails.provider) {
if (Object.keys(providerIconMap).includes(inboxDetails.provider)) {
icon = providerIconMap[inboxDetails.provider];
}
}
// Special case for Twilio whatsapp
if (type === 'Channel::TwilioSms' && inboxDetails.medium === 'whatsapp') {
if (
type === INBOX_TYPES.TWILIO &&
inboxDetails.medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
) {
icon = 'i-woot-whatsapp';
}
// Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.)
if (isVoiceCallEnabled(inboxDetails)) {
// Native Twilio voice inbox: a TwilioSms with voice enabled (and no WhatsApp medium)
// is presented as a Voice channel, so show the phone icon.
const voiceEnabled =
inboxDetails.voice_enabled || inboxDetails.voiceEnabled;
if (
type === INBOX_TYPES.TWILIO &&
voiceEnabled &&
inboxDetails.medium !== TWILIO_CHANNEL_MEDIUM.WHATSAPP
) {
icon = 'i-woot-voice';
}
@@ -49,3 +79,26 @@ export function useChannelIcon(inbox) {
return channelIcon;
}
export function useChannelBrandIcon(inbox) {
return computed(() => {
const inboxDetails = resolveInbox(inbox);
const type = inboxDetails.channel_type;
let icon = channelTypeBrandIconMap[type];
if (type === INBOX_TYPES.EMAIL && inboxDetails.provider) {
if (Object.keys(providerBrandIconMap).includes(inboxDetails.provider)) {
icon = providerBrandIconMap[inboxDetails.provider];
}
}
if (
type === INBOX_TYPES.TWILIO &&
inboxDetails.medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
) {
icon = channelTypeBrandIconMap['Channel::Whatsapp'];
}
return icon ?? null;
});
}
@@ -31,6 +31,7 @@ import FileBubble from './bubbles/File.vue';
import AudioBubble from './bubbles/Audio.vue';
import VideoBubble from './bubbles/Video.vue';
import EmbedBubble from './bubbles/Embed.vue';
import FallbackBubble from './bubbles/Fallback.vue';
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
import EmailBubble from './bubbles/Email/Index.vue';
import UnsupportedBubble from './bubbles/Unsupported.vue';
@@ -328,6 +329,8 @@ const componentToRender = computed(() => {
if (Array.isArray(props.attachments) && props.attachments.length === 1) {
const fileType = props.attachments[0].fileType;
if (fileType === ATTACHMENT_TYPES.FALLBACK) return FallbackBubble;
if (!props.content) {
if (fileType === ATTACHMENT_TYPES.IMAGE) return ImageBubble;
if (fileType === ATTACHMENT_TYPES.FILE) return FileBubble;
@@ -554,11 +557,10 @@ provideMessageContext({
<Avatar v-bind="avatarInfo" :size="24" />
</div>
<div
class="[grid-area:bubble] flex"
class="[grid-area:bubble] flex min-w-0"
:class="{
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
}"
@contextmenu="openContextMenu($event)"
>
@@ -95,7 +95,7 @@ const replyToPreview = computed(() => {
<template>
<div
class="text-sm"
class="text-sm min-w-0"
:class="[
messageClass,
{
@@ -0,0 +1,37 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import FormattedContent from './Text/FormattedContent.vue';
import { useMessageContext } from '../provider.js';
const { attachments, content } = useMessageContext();
const attachment = computed(() => attachments.value?.[0] || {});
const url = computed(
() => attachment.value.dataUrl || attachment.value.data_url
);
const title = computed(
() =>
attachment.value.fallbackTitle ||
attachment.value.fallback_title ||
url.value
);
</script>
<template>
<BaseBubble class="p-3" data-bubble-name="fallback">
<FormattedContent v-if="content" :content="content" class="mb-2" />
<a
v-if="url"
:href="url"
target="_blank"
rel="noopener noreferrer"
class="block max-w-[320px] truncate text-sm text-n-brand underline"
>
{{ title }}
</a>
<span v-else class="text-sm text-n-slate-11">
{{ title }}
</span>
</BaseBubble>
</template>
@@ -6,9 +6,12 @@ import BaseBubble from './Base.vue';
const { inboxId } = useMessageContext();
const { isAFacebookInbox, isAnInstagramChannel, isATiktokChannel } = useInbox(
inboxId.value
);
const {
isAFacebookInbox,
isAnInstagramChannel,
isATiktokChannel,
isAWhatsAppChannel,
} = useInbox(inboxId.value);
const unsupportedMessageKey = computed(() => {
if (isAFacebookInbox.value)
@@ -16,6 +19,8 @@ const unsupportedMessageKey = computed(() => {
if (isAnInstagramChannel.value)
return 'CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM';
if (isATiktokChannel.value) return 'CONVERSATION.UNSUPPORTED_MESSAGE_TIKTOK';
if (isAWhatsAppChannel.value)
return 'CONVERSATION.UNSUPPORTED_MESSAGE_WHATSAPP';
return 'CONVERSATION.UNSUPPORTED_MESSAGE';
});
</script>
@@ -2,14 +2,27 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useMapGetter } from 'dashboard/composables/store';
import { useMessageContext } from '../provider.js';
import { VOICE_CALL_STATUS } from '../constants';
import { useCallSession } from 'dashboard/composables/useCallSession';
import {
VOICE_CALL_STATUS,
VOICE_CALL_DIRECTION,
VOICE_CALL_OUTBOUND_INIT_STATUS,
VOICE_CALL_END_REASON,
MESSAGE_TYPES,
ATTACHMENT_TYPES,
} from '../constants';
import { useCallActions } from 'dashboard/composables/useCallSession';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import { useCallsStore } from 'dashboard/stores/calls';
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { formatDuration } from 'shared/helpers/timeHelper';
import { useAlert } from 'dashboard/composables';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import AudioChip from 'next/message/chips/Audio.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const LABEL_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
@@ -17,39 +30,67 @@ const LABEL_MAP = {
};
const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
};
const BG_COLOR_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'bg-n-teal-9',
[VOICE_CALL_STATUS.RINGING]: 'bg-n-teal-9 animate-pulse',
[VOICE_CALL_STATUS.COMPLETED]: 'bg-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'bg-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call-bold',
[VOICE_CALL_STATUS.COMPLETED]: 'i-ph-phone-bold',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x-bold',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x-bold',
};
const { t } = useI18n();
const store = useStore();
const { call, conversationId, currentUserId, inboxId } = useMessageContext();
const {
call,
attachments,
contentAttributes,
conversationId,
currentUserId,
inboxId,
sender,
messageType,
} = useMessageContext();
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
useCallSession();
useCallActions();
const whatsappCallSession = useWhatsappCallSession();
const callsStore = useCallsStore();
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const isInitiatingCall = computed(
() => contactsUiFlags.value?.isInitiatingCall || false
);
const status = computed(() => call.value?.status);
const isOutbound = computed(() => call.value?.direction === 'outgoing');
// Server-side call records use `outgoing`/`incoming`, while the Pinia store
// and a few API hops normalise to `outbound`/`inbound`. Accept either so the
// bubble label matches the message orientation no matter the source.
const isOutbound = computed(() => {
const dir = call.value?.direction;
if (
dir === VOICE_CALL_DIRECTION.OUTGOING ||
dir === VOICE_CALL_DIRECTION.OUTBOUND
)
return true;
if (
dir === VOICE_CALL_DIRECTION.INCOMING ||
dir === VOICE_CALL_DIRECTION.INBOUND
)
return false;
// Fall back to the message orientation: agent-authored messages sit on the
// right (outbound) and contact-authored ones on the left.
return messageType.value === MESSAGE_TYPES.OUTGOING;
});
const isWhatsapp = computed(
() => call.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
const didCurrentUserAnswer = computed(
const isMissedInbound = computed(() => isFailed.value && !isOutbound.value);
const endReason = computed(() => call.value?.endReason);
const wasDeclinedByAgent = computed(
() =>
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
isMissedInbound.value &&
endReason.value === VOICE_CALL_END_REASON.AGENT_REJECTED
);
// Pickup auto-assigns the conversation, so the assignee is a safe display proxy
// for the answerer when the Call payload lacks accepted_by_agent_id (e.g.,
// Twilio's call-status webhook flipped the call to in-progress before the
// participant-join webhook claimed it).
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
const conversationAssignee = computed(() => {
const conversation = store.getters.getConversationById?.(
conversationId?.value
@@ -66,67 +107,107 @@ const displayAgentName = computed(() => {
return conversationAssignee.value?.name || null;
});
const audioAttachment = computed(() =>
(attachments?.value || []).find(a => a.fileType === ATTACHMENT_TYPES.AUDIO)
);
const durationSeconds = computed(() => {
const fromCall = call.value?.durationSeconds || call.value?.duration_seconds;
if (fromCall != null) return fromCall;
const data = contentAttributes?.value?.data;
return data?.durationSeconds || data?.duration_seconds;
});
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) {
if (isFailed.value) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL'
: 'CONVERSATION.VOICE_CALL.MISSED_CALL';
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.MISSED_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 formattedDuration = computed(() =>
formatDuration(call.value?.durationSeconds)
);
const subtext = computed(() => {
if (status.value === VOICE_CALL_STATUS.RINGING) {
return 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 t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
if (didCurrentUserAnswer.value) {
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
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');
}
if (displayAgentName.value) {
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
if (wasDeclinedByAgent.value && displayAgentName.value) {
return t('CONVERSATION.VOICE_CALL.MISSED_CALL_DECLINED_BY', {
agentName: displayAgentName.value,
});
}
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
return t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT');
}
return isFailed.value
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
// 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');
});
const iconName = computed(() => {
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
return isOutbound.value
? 'i-ph-phone-outgoing-bold'
: 'i-ph-phone-incoming-bold';
});
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
// Subtle icon container — matches the design's tonal swatch over the bubble bg.
// Status drives the accent: teal for live, ruby for missed, neutral otherwise.
const iconContainerClass = computed(() => {
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
return 'bg-n-teal-3 text-n-teal-11';
}
if (status.value === VOICE_CALL_STATUS.RINGING) {
return 'bg-n-teal-3 text-n-teal-11';
}
if (isMissedInbound.value) {
return 'bg-n-alpha-2 text-n-ruby-9';
}
return 'bg-n-alpha-2 text-n-slate-12';
});
const callSid = computed(() => call.value?.providerCallId);
// Show "Join call" when the call is still ringing, no agent has claimed it,
// and the conversation is unassigned or assigned to the current user. Mirrors
// the eligibility used by FloatingCallWidget so the bubble can act as a
// recovery affordance after a refresh or missed widget.
const canJoinCall = computed(() => {
if (status.value !== VOICE_CALL_STATUS.RINGING) return false;
if (isOutbound.value) return false;
if (acceptedByAgentId.value) return false;
if (!callSid.value || !inboxId.value || !conversationId.value) return false;
// Suppress the button once this call is the local active session — the
// message status webhook may lag behind, so we can't rely on `status` alone
// to hide it after a successful join from this client.
if (hasActiveCall.value && activeCall.value?.callSid === callSid.value)
return false;
const assignee = conversationAssignee.value;
@@ -135,11 +216,12 @@ const canJoinCall = computed(() => {
});
const recordingAttachment = computed(() => {
if (audioAttachment.value) return audioAttachment.value;
const url = call.value?.recordingUrl;
if (!url) return null;
return {
dataUrl: url,
fileType: 'audio',
fileType: ATTACHMENT_TYPES.AUDIO,
extension: 'wav',
transcribedText: call.value?.transcript || '',
};
@@ -162,48 +244,117 @@ const handleJoinCall = async () => {
callSid: callSid.value,
});
};
const canCallBack = computed(
() =>
isMissedInbound.value &&
!!inboxId.value &&
!!conversationId.value &&
!hasActiveCall.value &&
!callsStore.hasIncomingCall
);
const handleCallBack = async () => {
if (!canCallBack.value || isInitiatingCall.value) return;
try {
if (isWhatsapp.value) {
const response = await whatsappCallSession.initiateOutboundCall(
conversationId.value
);
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
// Permission template path returns no call id — show banner, no widget yet.
if (!response?.id) {
useAlert(
response?.status ===
VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
? t('CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_PENDING')
: t('CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_REQUESTED')
);
return;
}
callsStore.addCall({
callSid: response.call_id,
callId: response.id,
conversationId: conversationId.value,
inboxId: inboxId.value,
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
provider: VOICE_CALL_PROVIDERS.WHATSAPP,
});
return;
}
const response = await store.dispatch('contacts/initiateCall', {
contactId: sender.value?.id,
inboxId: inboxId.value,
conversationId: conversationId.value,
});
callsStore.addCall({
callSid: response?.call_sid,
conversationId: response?.conversation_id ?? conversationId.value,
inboxId: inboxId.value,
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
});
} catch (error) {
useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED'));
}
};
</script>
<template>
<BaseBubble class="p-0 border-none" hide-meta>
<div class="flex overflow-hidden flex-col w-full max-w-sm">
<div class="flex gap-3 items-center p-3 w-full">
<BaseBubble class="!p-3 !max-w-md min-w-[240px]" hide-meta>
<div class="flex flex-col gap-3 w-full">
<!-- Header row: icon + title + duration/subtext -->
<div class="flex gap-2.5 items-start">
<div
class="flex justify-center items-center rounded-full size-10 shrink-0"
:class="bgColor"
class="flex justify-center items-center rounded-xl size-11 shrink-0"
:class="iconContainerClass"
>
<Icon
class="size-5"
:icon="iconName"
:class="{
'text-n-slate-1': status === VOICE_CALL_STATUS.COMPLETED,
'text-white': status !== VOICE_CALL_STATUS.COMPLETED,
}"
/>
<Icon class="size-4" :icon="iconName" />
</div>
<div class="flex overflow-hidden flex-col flex-grow">
<span class="text-sm font-medium truncate text-n-slate-12">
<div class="flex flex-col flex-1 min-w-0 self-center">
<span
class="font-display text-sm font-medium leading-tight truncate tracking-tight"
>
{{ $t(labelKey) }}
</span>
<span v-if="subtext" class="text-xs text-n-slate-11">
<span
v-if="subtext"
class="text-sm leading-tight truncate tracking-tight opacity-75"
>
{{ subtext }}
</span>
<button
v-if="canJoinCall"
type="button"
class="p-0 mt-1 text-xs font-medium text-start text-n-teal-10 hover:text-n-teal-11 disabled:opacity-50"
:disabled="isJoining"
@click="handleJoinCall"
>
{{ $t('CONVERSATION.VOICE_CALL.JOIN_CALL') }}
</button>
</div>
</div>
<div v-if="recordingAttachment" class="px-3 pb-3">
<AudioChip :attachment="recordingAttachment" />
</div>
<!-- Audio player (when there's a recording) -->
<AudioChip
v-if="recordingAttachment"
:attachment="recordingAttachment"
show-transcribed-text
/>
<!-- Call back button (missed inbound) -->
<NextButton
v-if="canCallBack"
type="button"
:label="$t('CONVERSATION.VOICE_CALL.CALL_BACK')"
icon="i-ph-phone-bold"
teal
class="!rounded-full"
:disabled="isInitiatingCall"
@click="handleCallBack"
/>
<!-- Join call button (ringing inbound) -->
<NextButton
v-if="canJoinCall"
type="button"
:label="$t('CONVERSATION.VOICE_CALL.JOIN_CALL')"
icon="i-ph-phone-bold"
teal
class="!rounded-full"
:disabled="isJoining"
@click="handleJoinCall"
/>
</div>
</BaseBubble>
</template>
@@ -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);
@@ -41,8 +52,33 @@ const playbackSpeed = ref(1);
const { uid } = getCurrentInstance();
// MediaRecorder-produced WebM/Opus blobs lack a Duration header → <audio>.duration
// resolves to Infinity until we seek past the end, which forces the engine to
// scan the file and compute the real length. Safe no-op for files with a real
// duration already (mp3/m4a/etc).
const resolveStreamingDuration = () => {
const el = audioPlayer.value;
if (!el) return;
const onTimeUpdate = () => {
el.removeEventListener('timeupdate', onTimeUpdate);
el.currentTime = 0;
duration.value = el.duration;
};
el.addEventListener('timeupdate', onTimeUpdate);
try {
el.currentTime = Number.MAX_SAFE_INTEGER;
} catch {
el.removeEventListener('timeupdate', onTimeUpdate);
}
};
const onLoadedMetadata = () => {
duration.value = audioPlayer.value?.duration;
const d = audioPlayer.value?.duration;
if (!Number.isFinite(d)) {
resolveStreamingDuration();
return;
}
duration.value = d;
};
const playbackSpeedLabel = computed(() => {
@@ -53,7 +89,8 @@ const playbackSpeedLabel = computed(() => {
// When the onLoadMetadata is called, so we need to set the duration
// value when the component is mounted
onMounted(() => {
duration.value = audioPlayer.value?.duration;
const d = audioPlayer.value?.duration;
if (Number.isFinite(d)) duration.value = d;
audioPlayer.value.playbackRate = playbackSpeed.value;
});
@@ -189,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>
@@ -88,5 +88,18 @@ export const VOICE_CALL_STATUS = {
export const VOICE_CALL_DIRECTION = {
INBOUND: 'inbound',
INCOMING: 'incoming',
OUTGOING: 'outgoing',
ONGOING: 'ongoing',
OUTBOUND: 'outbound',
};
export const VOICE_CALL_OUTBOUND_INIT_STATUS = {
LOCKED: 'locked',
PERMISSION_REQUESTED: 'permission_requested',
PERMISSION_PENDING: 'permission_pending',
};
export const VOICE_CALL_END_REASON = {
AGENT_REJECTED: 'agent_rejected',
};
@@ -78,26 +78,27 @@ function changeAvailabilityStatus(availability) {
<template>
<DropdownSection class="[&>ul]:overflow-visible">
<div class="grid gap-0">
<DropdownItem preserve-open>
<div class="flex-grow flex items-center gap-1">
<DropdownItem preserve-open class="gap-1">
<div class="flex-grow flex items-center gap-1 min-w-0">
{{ $t('SIDEBAR.SET_YOUR_AVAILABILITY') }}
</div>
<DropdownContainer>
<DropdownContainer class="shrink-0">
<template #trigger="{ toggle }">
<Button
size="sm"
color="slate"
variant="faded"
class="min-w-[96px]"
icon="i-lucide-chevron-down"
trailing-icon
@click="toggle"
>
<div class="flex gap-1 items-center flex-grow text-sm">
<div class="flex gap-1 items-center min-w-0 text-sm">
<div class="p-1 flex-shrink-0">
<div class="size-2 rounded-sm" :class="activeStatus.color" />
</div>
<span>{{ activeStatus.label }}</span>
<span class="truncate max-w-[7rem]">
{{ activeStatus.label }}
</span>
</div>
</Button>
</template>
@@ -114,12 +115,12 @@ function changeAvailabilityStatus(availability) {
</DropdownContainer>
</DropdownItem>
<DropdownItem>
<div class="flex-grow flex items-center gap-1">
<div class="flex-grow min-w-0">
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
<Icon
v-tooltip.top="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT')"
icon="i-lucide-info"
class="size-4 text-n-slate-10"
class="inline-block align-middle ms-1 size-4 text-n-slate-10"
/>
</div>
<ToggleSwitch v-model="autoOfflineToggle" />
@@ -19,7 +19,7 @@ const displayCount = computed(() =>
<span
v-if="normalizedCount > 0"
data-test-id="sidebar-unread-badge"
class="inline-grid h-5 min-w-5 place-items-center rounded-full bg-n-brand px-1 text-xxs font-medium leading-3 text-white flex-shrink-0"
class="inline-grid h-5 min-w-5 place-items-center rounded-full bg-n-slate-4 px-1 text-xxs font-medium leading-3 text-n-slate-12 dark:bg-n-slate-5 flex-shrink-0"
>
{{ displayCount }}
</span>
@@ -24,6 +24,10 @@ defineProps({
type: Boolean,
default: false,
},
hasVoiceBadge: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
@@ -38,10 +42,18 @@ const { t } = useI18n();
'cursor-not-allowed disabled:opacity-80': isComingSoon,
}"
>
<div
class="flex size-10 items-center justify-center rounded-full bg-n-alpha-2"
>
<Icon :icon="icon" class="text-n-slate-10 size-6" />
<div class="relative">
<div
class="flex size-10 items-center justify-center rounded-full bg-n-alpha-2"
>
<Icon :icon="icon" class="text-n-slate-10 size-6" />
</div>
<div
v-if="hasVoiceBadge"
class="absolute -top-1 ltr:-right-1 rtl:-left-1 flex size-4 items-center justify-center rounded-full bg-n-alpha-2 ring-2 ring-n-solid-1"
>
<Icon icon="i-lucide-audio-lines" class="text-n-slate-10 size-2.5" />
</div>
</div>
<div class="flex flex-col items-start gap-1.5">
@@ -14,11 +14,11 @@ const props = defineProps({
const emit = defineEmits(['removeAttachment']);
const nonRecordedAudioAttachments = computed(() => {
return props.attachments.filter(attachment => !attachment?.isRecordedAudio);
return props.attachments.filter(attachment => !attachment?.isVoiceMessage);
});
const recordedAudioAttachments = computed(() =>
props.attachments.filter(attachment => attachment.isRecordedAudio)
props.attachments.filter(attachment => attachment.isVoiceMessage)
);
const onRemoveAttachment = itemIndex => {
@@ -52,13 +52,22 @@ const isActive = computed(() => {
return props.enabledFeatures.channel_tiktok && hasTiktokConfigured.value;
}
if (key === 'voice') {
return props.enabledFeatures.channel_voice;
}
if (key === 'whatsapp_call') {
return (
props.enabledFeatures.channel_voice &&
!!window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
}
if (key === 'app_store') {
return props.enabledFeatures.channel_app_store;
}
if (key === 'voice') {
return props.enabledFeatures.channel_voice;
}
return [
'website',
@@ -82,7 +91,14 @@ const isComingSoon = computed(() => {
});
const isBeta = computed(() => {
return ['tiktok', 'app_store', 'voice'].includes(props.channel.key);
return ['tiktok', 'voice', 'whatsapp_call', 'app_store'].includes(props.channel.key);
});
const hasVoiceBadge = computed(() => {
return (
['voice', 'whatsapp_call'].includes(props.channel.key) &&
!!props.enabledFeatures.channel_voice
);
});
const onItemClick = () => {
@@ -99,6 +115,7 @@ const onItemClick = () => {
:icon="channel.icon"
:is-coming-soon="isComingSoon"
:is-beta="isBeta"
:has-voice-badge="hasVoiceBadge"
:disabled="!isActive"
@click="onItemClick"
/>
@@ -1,185 +0,0 @@
<script setup>
import { watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useCallSession } from 'dashboard/composables/useCallSession';
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const router = useRouter();
const store = useStore();
const {
activeCall,
incomingCalls,
hasActiveCall,
isJoining,
joinCall,
endCall: endCallSession,
rejectIncomingCall,
dismissCall,
formattedCallDuration,
} = useCallSession();
const getCallInfo = call => {
const conversation = store.getters.getConversationById(call?.conversationId);
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
const sender = conversation?.meta?.sender;
return {
conversation,
inbox,
contactName: sender?.name || sender?.phone_number || 'Unknown caller',
inboxName: inbox?.name || 'Customer support',
avatar: sender?.avatar || sender?.thumbnail,
};
};
const handleEndCall = async () => {
const call = activeCall.value;
if (!call) return;
const inboxId = call.inboxId || getCallInfo(call).conversation?.inbox_id;
if (!inboxId) return;
await endCallSession({
conversationId: call.conversationId,
inboxId,
callSid: call.callSid,
});
};
const handleJoinCall = async call => {
const { conversation } = getCallInfo(call);
if (!call || !conversation || isJoining.value) return;
// End current active call before joining new one
if (hasActiveCall.value) {
await handleEndCall();
}
const result = await joinCall({
conversationId: call.conversationId,
inboxId: conversation.inbox_id,
callSid: call.callSid,
});
if (result) {
router.push({
name: 'inbox_conversation',
params: { conversation_id: call.conversationId },
});
}
};
// Auto-join outbound calls when window is visible
watch(
() => incomingCalls.value[0],
call => {
if (
call?.callDirection === 'outbound' &&
!hasActiveCall.value &&
WindowVisibilityHelper.isWindowVisible()
) {
handleJoinCall(call);
}
},
{ immediate: true }
);
</script>
<template>
<div
v-if="incomingCalls.length || hasActiveCall"
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-2 w-72"
>
<!-- Incoming Calls (shown above active call) -->
<div
v-for="call in hasActiveCall ? incomingCalls : []"
:key="call.callSid"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex">
<Avatar
:src="getCallInfo(call).avatar"
:name="getCallInfo(call).contactName"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{ getCallInfo(call).contactName }}
</p>
<p class="text-xs text-n-slate-11 truncate">
{{ getCallInfo(call).inboxName }}
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
@click="dismissCall(call.callSid)"
>
<i class="text-lg text-white i-ph-phone-x-bold" />
</button>
<button
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
@click="handleJoinCall(call)"
>
<i class="text-lg text-white i-ph-phone-bold" />
</button>
</div>
</div>
<!-- Main Call Widget -->
<div
v-if="hasActiveCall || incomingCalls.length"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
:class="{ 'animate-pulse': !hasActiveCall }"
>
<Avatar
:src="getCallInfo(activeCall || incomingCalls[0]).avatar"
:name="getCallInfo(activeCall || incomingCalls[0]).contactName"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{ getCallInfo(activeCall || incomingCalls[0]).contactName }}
</p>
<p v-if="hasActiveCall" class="font-mono text-sm text-n-teal-9">
{{ formattedCallDuration }}
</p>
<p v-else class="text-xs text-n-slate-11">
{{
incomingCalls[0]?.callDirection === 'outbound'
? $t('CONVERSATION.VOICE_WIDGET.OUTGOING_CALL')
: $t('CONVERSATION.VOICE_WIDGET.INCOMING_CALL')
}}
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
@click="
hasActiveCall
? handleEndCall()
: rejectIncomingCall(incomingCalls[0]?.callSid)
"
>
<i class="text-lg text-white i-ph-phone-x-bold" />
</button>
<button
v-if="!hasActiveCall"
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
@click="handleJoinCall(incomingCalls[0])"
>
<i class="text-lg text-white i-ph-phone-bold" />
</button>
</div>
</div>
</div>
</template>
@@ -4,7 +4,7 @@ import { ref, onMounted, onUnmounted } from 'vue';
import WaveSurfer from 'wavesurfer.js';
import RecordPlugin from 'wavesurfer.js/dist/plugins/record.js';
import { format, intervalToDuration } from 'date-fns';
import { convertAudio } from './utils/mp3ConversionUtils';
import { convertAudio } from './utils/audioConversionUtils';
const props = defineProps({
audioRecordFormat: {
@@ -18,6 +18,7 @@ const emit = defineEmits([
'finishRecord',
'pause',
'play',
'recordError',
]);
const waveformContainer = ref(null);
@@ -26,6 +27,7 @@ const record = ref(null);
const isRecording = ref(false);
const isPlaying = ref(false);
const hasRecording = ref(false);
const recordedAudioUrl = ref(null);
const formatTimeProgress = time => {
const duration = intervalToDuration({ start: 0, end: time });
@@ -35,6 +37,28 @@ const formatTimeProgress = time => {
);
};
const AUDIO_EXTENSION_MAP = {
'audio/ogg': 'ogg',
'audio/mp3': 'mp3',
'audio/mpeg': 'mp3',
'audio/wav': 'wav',
'audio/webm': 'webm',
};
const getRecordPluginOptions = audioFormat => {
const options = {
scrollingWaveform: true,
renderRecordedAudio: false,
};
if (
audioFormat === 'audio/ogg' &&
MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')
) {
options.mimeType = 'audio/ogg;codecs=opus';
}
return options;
};
const initWaveSurfer = () => {
wavesurfer.value = WaveSurfer.create({
container: waveformContainer.value,
@@ -45,10 +69,7 @@ const initWaveSurfer = () => {
barGap: 1,
barRadius: 2,
plugins: [
RecordPlugin.create({
scrollingWaveform: true,
renderRecordedAudio: false,
}),
RecordPlugin.create(getRecordPluginOptions(props.audioRecordFormat)),
],
});
@@ -62,21 +83,34 @@ const initWaveSurfer = () => {
});
record.value.on('record-end', async blob => {
const audioUrl = URL.createObjectURL(blob);
const audioBlob = await convertAudio(blob, props.audioRecordFormat);
const fileName = `${getUuid()}.mp3`;
const file = new File([audioBlob], fileName, {
type: props.audioRecordFormat,
});
wavesurfer.value.load(audioUrl);
emit('finishRecord', {
name: file.name,
type: file.type,
size: file.size,
file,
});
hasRecording.value = true;
isRecording.value = false;
try {
const audioBlob = await convertAudio(blob, props.audioRecordFormat);
// Use the converted blob's actual type, which may differ from the
// requested format when the browser can't produce it (e.g. Safari falls
// back to MP3 instead of OGG). This keeps the filename, content type, and
// voice-note flag consistent with the real bytes.
const audioType = audioBlob.type || props.audioRecordFormat;
const ext = AUDIO_EXTENSION_MAP[audioType] || 'mp3';
const fileName = `${getUuid()}.${ext}`;
const file = new File([audioBlob], fileName, {
type: audioType,
});
if (recordedAudioUrl.value) URL.revokeObjectURL(recordedAudioUrl.value);
recordedAudioUrl.value = URL.createObjectURL(audioBlob);
wavesurfer.value.load(recordedAudioUrl.value);
emit('finishRecord', {
name: file.name,
type: file.type,
size: file.size,
file,
});
hasRecording.value = true;
isRecording.value = false;
} catch (error) {
isRecording.value = false;
hasRecording.value = false;
emit('recordError', { error });
}
});
record.value.on('record-progress', time => {
@@ -109,6 +143,10 @@ onMounted(() => {
});
onUnmounted(() => {
if (recordedAudioUrl.value) {
URL.revokeObjectURL(recordedAudioUrl.value);
recordedAudioUrl.value = null;
}
if (wavesurfer.value) {
wavesurfer.value.destroy();
}
@@ -589,11 +589,11 @@ function isCmdPlusEnterToSendEnabled() {
useKeyboardEvents({
'Alt+KeyP': {
action: focusEditorInputField,
allowOnFocusedInput: true,
allowOnFocusedInput: false,
},
'Alt+KeyL': {
action: focusEditorInputField,
allowOnFocusedInput: true,
allowOnFocusedInput: false,
},
});
@@ -105,11 +105,11 @@ export default {
const keyboardEvents = {
'Alt+KeyP': {
action: () => handleNoteClick(),
allowOnFocusedInput: true,
allowOnFocusedInput: false,
},
'Alt+KeyL': {
action: () => handleReplyClick(),
allowOnFocusedInput: true,
allowOnFocusedInput: false,
},
};
useKeyboardEvents(keyboardEvents);
@@ -1,5 +1,7 @@
import lamejs from '@breezystack/lamejs';
import { remuxWebmToOgg } from './webmOpusToOgg';
const writeString = (view, offset, string) => {
// eslint-disable-next-line no-plusplus
for (let i = 0; i < string.length; i++) {
@@ -139,6 +141,20 @@ export const convertAudio = async (inputBlob, outputFormat, bitrate = 128) => {
audio = await convertToWav(inputBlob);
} else if (outputFormat === 'audio/mp3') {
audio = await convertToMp3(inputBlob, bitrate);
} else if (outputFormat === 'audio/ogg') {
const inputType = inputBlob.type.split(';')[0].trim();
if (inputType === 'audio/webm' || inputType === 'video/webm') {
audio = await remuxWebmToOgg(inputBlob);
} else if (inputType === 'audio/ogg') {
audio = inputBlob;
} else {
// Browsers that record neither WebM nor OGG (e.g. Safari records
// audio/mp4) cannot produce OGG/Opus. Fall back to MP3 so the recording
// still sends as a regular audio message instead of failing. The caller
// keys the voice-note flag off the returned blob type, so an MP3 result
// is never mislabeled as an OGG/Opus voice note.
audio = await convertToMp3(inputBlob, bitrate);
}
} else {
throw new Error('Unsupported output format');
}
@@ -0,0 +1,457 @@
/* eslint-disable no-bitwise */
/**
* WebM/Opus → OGG/Opus remuxer
*
* Chrome's MediaRecorder produces WebM containers even when
* `audio/ogg;codecs=opus` is requested. WhatsApp Cloud API requires
* proper OGG/Opus files for voice messages.
*
* This module extracts raw Opus packets from the WebM (EBML) container
* and repackages them into a valid OGG bitstream. The audio data itself
* is never re-encoded — only the container format changes.
*
* References:
* EBML (container for WebM): RFC 8794 — https://www.rfc-editor.org/rfc/rfc8794
* Matroska/WebM elements: https://www.matroska.org/technical/elements.html
* OGG bitstream framing: RFC 3533 — https://www.rfc-editor.org/rfc/rfc3533
* Opus codec: RFC 6716 — https://www.rfc-editor.org/rfc/rfc6716
* Opus in OGG (OpusHead/Tags): RFC 7845 — https://www.rfc-editor.org/rfc/rfc7845
*/
// ======================== EBML / WebM parser ========================
const EBML_IDS = {
Segment: 0x18538067,
SegmentInfo: 0x1549a966,
Tracks: 0x1654ae6b,
TrackEntry: 0xae,
CodecPrivate: 0x63a2,
Audio: 0xe1,
SamplingFrequency: 0xb5,
Channels: 0x9f,
Cluster: 0x1f43b675,
Timecode: 0xe7,
SimpleBlock: 0xa3,
BlockGroup: 0xa0,
Block: 0xa1,
};
const MASTER_ELEMENTS = new Set([
0x1a45dfa3, // EBML header
EBML_IDS.Segment,
EBML_IDS.SegmentInfo,
EBML_IDS.Tracks,
EBML_IDS.TrackEntry,
EBML_IDS.Audio,
EBML_IDS.Cluster,
EBML_IDS.BlockGroup,
]);
/** Read an EBML variable-length integer (data size). */
function readVint(data, pos) {
if (pos >= data.length) return null;
const first = data[pos];
if (first === 0) return null;
let len = 1;
let mask = 0x80;
while (len <= 8 && !(first & mask)) {
len += 1;
mask >>= 1;
}
if (len > 8 || pos + len > data.length) return null;
let value = first & (mask - 1);
for (let i = 1; i < len; i += 1) {
value = value * 256 + data[pos + i];
}
return { value, length: len };
}
/** Read an EBML element ID (leading marker bits are kept). */
function readElementId(data, pos) {
if (pos >= data.length) return null;
const first = data[pos];
if (first === 0) return null;
let len = 1;
let mask = 0x80;
while (len <= 4 && !(first & mask)) {
len += 1;
mask >>= 1;
}
if (len > 4 || pos + len > data.length) return null;
let id = first;
for (let i = 1; i < len; i += 1) {
id = id * 256 + data[pos + i];
}
return { id, length: len };
}
function readUintBE(data, offset, length) {
let v = 0;
for (let i = 0; i < length; i += 1) v = v * 256 + data[offset + i];
return v;
}
function readFloatBE(data, offset, length) {
if (length !== 4 && length !== 8) return NaN;
const buf = new ArrayBuffer(length);
const u8 = new Uint8Array(buf);
for (let i = 0; i < length; i += 1) u8[i] = data[offset + i];
const view = new DataView(buf);
return length === 4 ? view.getFloat32(0) : view.getFloat64(0);
}
/** Extract the raw Opus frame from a SimpleBlock / Block element. */
function extractFrameFromBlock(data, offset, end) {
const trackVint = readVint(data, offset);
if (!trackVint) return null;
let pos = offset + trackVint.length;
// int16 relative timecode (big-endian, signed) skip
pos += 2;
// Flags byte skip. Lacing (Xiph/EBML/fixed-size) is NOT supported;
// this assumes single-frame blocks as produced by MediaRecorder.
const flags = data[pos];
const lacingBits = (flags >> 1) & 0x03;
if (lacingBits !== 0) {
// eslint-disable-next-line no-console
console.warn(
'webmOpusToOgg: laced SimpleBlock detected (unsupported), frame may be invalid'
);
}
pos += 1;
if (pos >= end) return null;
return data.slice(pos, end);
}
/**
* Walk the EBML tree and collect metadata + Opus frames.
* We only descend into master elements and only extract the fields we need.
*/
function parseWebM(buffer) {
const data = new Uint8Array(buffer);
const result = {
channels: 1,
sampleRate: 48000,
codecPrivate: null,
frames: [],
};
function walk(start, end) {
let pos = start;
while (pos < end) {
const idRes = readElementId(data, pos);
if (!idRes) break;
pos += idRes.length;
const sizeRes = readVint(data, pos);
if (!sizeRes) break;
pos += sizeRes.length;
// Handle "unknown size" (all-ones VINT) by treating it as the rest of the parent
// Use Math.pow instead of bit-shift to avoid 32-bit overflow for 5+ byte VINTs
const maxVint = 2 ** (7 * sizeRes.length) - 1;
const elEnd =
sizeRes.value === maxVint ? end : Math.min(pos + sizeRes.value, end);
if (MASTER_ELEMENTS.has(idRes.id)) {
walk(pos, elEnd);
} else {
switch (idRes.id) {
case EBML_IDS.Channels:
result.channels = readUintBE(data, pos, sizeRes.value);
break;
case EBML_IDS.SamplingFrequency:
result.sampleRate = readFloatBE(data, pos, sizeRes.value);
break;
case EBML_IDS.CodecPrivate:
result.codecPrivate = data.slice(pos, elEnd);
break;
case EBML_IDS.SimpleBlock:
case EBML_IDS.Block: {
const frame = extractFrameFromBlock(data, pos, elEnd);
if (frame && frame.length > 0) result.frames.push(frame);
break;
}
default:
break;
}
}
pos = elEnd;
}
}
walk(0, data.length);
return result;
}
// ======================== OGG writer ========================
/** OGG CRC-32 table (polynomial 0x04C11DB7). */
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i += 1) {
let c = i << 24;
for (let j = 0; j < 8; j += 1) {
c = ((c << 1) ^ (c & 0x80000000 ? 0x04c11db7 : 0)) >>> 0;
}
t[i] = c;
}
return t;
})();
function oggCrc32(bytes) {
let crc = 0;
for (let i = 0; i < bytes.length; i += 1) {
crc = (CRC_TABLE[((crc >>> 24) ^ bytes[i]) & 0xff] ^ (crc << 8)) >>> 0;
}
return crc;
}
/**
* Build one OGG page.
*
* @param {number} headerType 0x02 = BOS, 0x04 = EOS, 0x00 = normal
* @param {number} granulePosition 48 kHz sample count
* @param {number} serialNumber logical stream id
* @param {number} pageSeq page sequence counter
* @param {Uint8Array[]} packets one or more complete Opus packets
*/
function createOggPage(
headerType,
granulePosition,
serialNumber,
pageSeq,
packets
) {
// Build the lacing / segment table
const segTable = [];
let dataLen = 0;
packets.forEach(pkt => {
let rem = pkt.length;
while (rem >= 255) {
segTable.push(255);
rem -= 255;
}
segTable.push(rem); // final segment (0 when pkt.length is a multiple of 255)
dataLen += pkt.length;
});
const hdrLen = 27 + segTable.length;
const page = new Uint8Array(hdrLen + dataLen);
const dv = new DataView(page.buffer);
// Capture pattern
page.set([0x4f, 0x67, 0x67, 0x53]); // "OggS"
page[4] = 0; // version
page[5] = headerType;
// Granule position (int64 LE)
dv.setUint32(6, granulePosition & 0xffffffff, true);
dv.setUint32(
10,
Math.floor(granulePosition / 0x100000000) & 0xffffffff,
true
);
dv.setUint32(14, serialNumber, true); // serial
dv.setUint32(18, pageSeq, true); // page sequence
dv.setUint32(22, 0, true); // CRC placeholder
page[26] = segTable.length;
for (let i = 0; i < segTable.length; i += 1) page[27 + i] = segTable[i];
let off = hdrLen;
packets.forEach(pkt => {
page.set(pkt, off);
off += pkt.length;
});
// Fill in the CRC
dv.setUint32(22, oggCrc32(page), true);
return page;
}
// ======================== Opus helpers ========================
/** Lookup table: frame duration in ms for each Opus TOC config index (0-31). */
const OPUS_FRAME_MS = [
10,
20,
40,
60, // 0-3 SILK NB
10,
20,
40,
60, // 4-7 SILK MB
10,
20,
40,
60, // 8-11 SILK WB
10,
20, // 12-13 Hybrid SWB
10,
20, // 14-15 Hybrid FB
2.5,
5,
10,
20, // 16-19 CELT NB
2.5,
5,
10,
20, // 20-23 CELT WB
2.5,
5,
10,
20, // 24-27 CELT SWB
2.5,
5,
10,
20, // 28-31 CELT FB
];
/** Return the total number of 48 kHz PCM samples represented by an Opus packet. */
function opusPacketSamples(pkt) {
if (!pkt || pkt.length === 0) return 960; // default 20 ms
const toc = pkt[0];
const config = (toc >> 3) & 0x1f;
const code = toc & 0x03;
const samplesPerFrame = ((OPUS_FRAME_MS[config] || 20) * 48000) / 1000;
let frameCount;
if (code <= 1) frameCount = code + 1;
else if (code === 2) frameCount = 2;
else frameCount = pkt.length >= 2 ? pkt[1] & 0x3f : 1;
return samplesPerFrame * frameCount;
}
function buildOpusHead(channels, sampleRate, preSkip) {
const buf = new Uint8Array(19);
const dv = new DataView(buf.buffer);
buf.set(new TextEncoder().encode('OpusHead'));
buf[8] = 1; // version
buf[9] = channels;
dv.setUint16(10, preSkip, true);
dv.setUint32(12, sampleRate, true);
dv.setInt16(16, 0, true); // output gain
buf[18] = 0; // channel mapping family
return buf;
}
function buildOpusTags() {
const vendor = new TextEncoder().encode('chatwoot');
const buf = new Uint8Array(8 + 4 + vendor.length + 4);
const dv = new DataView(buf.buffer);
buf.set(new TextEncoder().encode('OpusTags'));
dv.setUint32(8, vendor.length, true);
buf.set(vendor, 12);
dv.setUint32(12 + vendor.length, 0, true); // 0 user comments
return buf;
}
// ======================== Public API ========================
const MAX_FRAMES_PER_PAGE = 50; // ~1 s at 20 ms/frame
const MAX_SEGMENTS_PER_PAGE = 255;
/**
* Remux a WebM/Opus blob into an OGG/Opus blob.
* If the input is already OGG (starts with "OggS"), it is returned as-is.
*
* @param {Blob} webmBlob
* @returns {Promise<Blob>} OGG/Opus blob
*/
export async function remuxWebmToOgg(webmBlob) {
const buffer = await webmBlob.arrayBuffer();
const bytes = new Uint8Array(buffer);
// Already OGG? Return unchanged.
if (
bytes.length >= 4 &&
bytes[0] === 0x4f &&
bytes[1] === 0x67 &&
bytes[2] === 0x67 &&
bytes[3] === 0x53
) {
return webmBlob;
}
const { channels, sampleRate, codecPrivate, frames } = parseWebM(buffer);
if (frames.length === 0) {
throw new Error('No Opus frames found in WebM input');
}
// Extract pre-skip from the WebM CodecPrivate (which IS the OpusHead)
let preSkip = 312;
if (codecPrivate && codecPrivate.length >= 12) {
const magic = new TextDecoder().decode(codecPrivate.slice(0, 8));
if (magic === 'OpusHead') {
preSkip = new DataView(
codecPrivate.buffer,
codecPrivate.byteOffset,
codecPrivate.length
).getUint16(10, true);
}
}
const serial = (Math.random() * 0x100000000) >>> 0;
let pageSeq = 0;
const pages = [];
// Page 0 OpusHead (BOS)
pages.push(
createOggPage(0x02, 0, serial, pageSeq, [
buildOpusHead(channels, sampleRate, preSkip),
])
);
pageSeq += 1;
// Page 1 OpusTags
pages.push(createOggPage(0x00, 0, serial, pageSeq, [buildOpusTags()]));
pageSeq += 1;
// Audio pages
let granule = 0;
let idx = 0;
while (idx < frames.length) {
const packets = [];
let segs = 0;
while (idx < frames.length && packets.length < MAX_FRAMES_PER_PAGE) {
const pkt = frames[idx];
// createOggPage always appends a terminating lacing value, so a packet
// spans floor(len/255)+1 segments (including the extra 0 when len is an
// exact multiple of 255). Math.ceil would undercount those cases.
const pktSegs = Math.floor(pkt.length / 255) + 1;
if (segs + pktSegs > MAX_SEGMENTS_PER_PAGE && packets.length > 0) break;
packets.push(pkt);
segs += pktSegs;
granule += opusPacketSamples(pkt);
idx += 1;
}
const isLast = idx >= frames.length;
pages.push(
createOggPage(isLast ? 0x04 : 0x00, granule, serial, pageSeq, packets)
);
pageSeq += 1;
}
// Concatenate pages into a single buffer
const total = pages.reduce((s, p) => s + p.length, 0);
const out = new Uint8Array(total);
let off = 0;
pages.forEach(p => {
out.set(p, off);
off += p.length;
});
return new Blob([out], { type: 'audio/ogg' });
}
@@ -0,0 +1,144 @@
<script setup>
import { computed } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import {
getVoiceCallProvider,
VOICE_CALL_PROVIDERS,
} from 'dashboard/helper/inbox';
import {
VOICE_CALL_DIRECTION,
VOICE_CALL_OUTBOUND_INIT_STATUS,
} from 'dashboard/components-next/message/constants';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import { useCallsStore } from 'dashboard/stores/calls';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAlert } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
inbox: {
type: Object,
default: () => ({}),
},
chat: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
const store = useStore();
const callsStore = useCallsStore();
const whatsappCallSession = useWhatsappCallSession();
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const { isCloudFeatureEnabled } = useAccount();
const voiceCallProvider = computed(() => getVoiceCallProvider(props.inbox));
const isVoiceCallInbox = computed(
() =>
voiceCallProvider.value !== null &&
isCloudFeatureEnabled(FEATURE_FLAGS.CHANNEL_VOICE)
);
const isWhatsappVoiceInbox = computed(
() => voiceCallProvider.value === VOICE_CALL_PROVIDERS.WHATSAPP
);
const isCallButtonDisabled = computed(() => {
if (callsStore.hasActiveCall || callsStore.hasIncomingCall) return true;
if (isWhatsappVoiceInbox.value) {
return whatsappCallSession.isInitiating.value;
}
return contactsUiFlags.value?.isInitiatingCall || false;
});
const isCallButtonLoading = computed(() =>
isWhatsappVoiceInbox.value
? whatsappCallSession.isInitiating.value
: !!contactsUiFlags.value?.isInitiatingCall
);
const callButtonTooltip = computed(() =>
isWhatsappVoiceInbox.value
? t('CONVERSATION.HEADER.WHATSAPP_CALL')
: t('CONVERSATION.HEADER.VOICE_CALL')
);
const startWhatsappCall = async () => {
if (whatsappCallSession.isInitiating.value) return;
try {
const response = await whatsappCallSession.initiateOutboundCall(
props.chat.id
);
// Composable returns LOCKED when init is already in flight or a call is
// active; soft no-op so a parallel click doesn't trigger a banner.
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
// Permission template path returns no call id — show banner, no widget yet.
if (!response?.id) {
const status = response?.status;
const messageKey =
status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
? 'CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_PENDING'
: 'CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_REQUESTED';
useAlert(t(messageKey));
return;
}
// Stay non-active until Meta delivers the connect webhook (sdp_answer);
// flipping to active here would start the duration timer before pickup.
callsStore.addCall({
callSid: response.call_id,
callId: response.id,
conversationId: props.chat.id,
inboxId: props.inbox?.id,
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
provider: VOICE_CALL_PROVIDERS.WHATSAPP,
});
} catch (error) {
useAlert(error?.message || t('CONVERSATION.HEADER.WHATSAPP_CALL_FAILED'));
}
};
const startTwilioCall = async () => {
if (contactsUiFlags.value?.isInitiatingCall) return;
try {
const response = await store.dispatch('contacts/initiateCall', {
contactId: props.chat?.meta?.sender?.id,
inboxId: props.inbox?.id,
conversationId: props.chat.id,
});
callsStore.addCall({
callSid: response?.call_sid,
conversationId: response?.conversation_id ?? props.chat.id,
inboxId: props.inbox?.id,
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
});
} catch (error) {
useAlert(error?.message || t('CONVERSATION.HEADER.VOICE_CALL_FAILED'));
}
};
const startCall = () => {
if (isWhatsappVoiceInbox.value) return startWhatsappCall();
return startTwilioCall();
};
</script>
<template>
<NextButton
v-if="isVoiceCallInbox"
v-tooltip.bottom="callButtonTooltip"
sm
ghost
slate
icon="i-lucide-phone"
:is-loading="isCallButtonLoading"
:disabled="isCallButtonDisabled"
@click="startCall"
/>
<template v-else />
</template>
@@ -8,13 +8,14 @@ import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue';
import Avatar from 'next/avatar/Avatar.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import ConversationCallButton from './ConversationCallButton.vue';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { useInbox } from 'dashboard/composables/useInbox';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
const props = defineProps({
chat: {
@@ -172,6 +173,7 @@ const copyConversationId = async () => {
:parent-width="width"
class="hidden md:flex"
/>
<ConversationCallButton :inbox="inbox" :chat="currentChat" />
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>
@@ -375,7 +375,10 @@ export default {
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
},
audioRecordFormat() {
if (this.isAWhatsAppChannel || this.isATelegramChannel) {
if (this.isAWhatsAppChannel) {
return AUDIO_FORMATS.OGG;
}
if (this.isATelegramChannel) {
return AUDIO_FORMATS.MP3;
}
if (this.isAPIInbox) {
@@ -1008,14 +1011,18 @@ export default {
onFinishRecorder(file) {
this.recordingAudioState = 'stopped';
this.hasRecordedAudio = true;
// Added a new key isRecordedAudio to the file to find it's and recorded audio
// Added a new key isVoiceMessage to the file to identify recorded audio
// Because to filter and show only non recorded audio and other attachments
const autoRecordedFile = {
...file,
isRecordedAudio: true,
isVoiceMessage: true,
};
return file && this.onFileUpload(autoRecordedFile);
},
onRecordError() {
this.toggleAudioRecorder();
useAlert(this.$t('CONVERSATION.REPLYBOX.AUDIO_CONVERSION_FAILED'));
},
toggleTyping(status) {
const conversationId = this.currentChat.id;
const isPrivate = this.isPrivate;
@@ -1042,7 +1049,7 @@ export default {
isPrivate: this.isPrivate,
thumb: reader.result,
blobSignedId: blob ? blob.signed_id : undefined,
isRecordedAudio: file?.isRecordedAudio || false,
isVoiceMessage: file?.isVoiceMessage || false,
});
};
},
@@ -1078,6 +1085,7 @@ export default {
private: false,
message: caption,
sender: this.sender,
isVoiceMessage: attachment.isVoiceMessage || false,
};
attachmentPayload = this.setReplyToInPayload(attachmentPayload);
@@ -1127,6 +1135,9 @@ export default {
this.attachedFiles.forEach(attachment => {
if (this.globalConfig.directUploadsEnabled) {
messagePayload.files.push(attachment.blobSignedId);
if (attachment.isVoiceMessage) {
messagePayload.isVoiceMessage = true;
}
} else {
messagePayload.files.push(attachment.resource.file);
}
@@ -1215,7 +1226,7 @@ export default {
this.hasRecordedAudio = false;
// Only clear the recorded audio when we click toggle button.
this.attachedFiles = this.attachedFiles.filter(
file => !file?.isRecordedAudio
file => !file?.isVoiceMessage
);
},
toggleEditorSize() {
@@ -1293,6 +1304,7 @@ export default {
:audio-record-format="audioRecordFormat"
@recorder-progress-changed="onRecordProgressChanged"
@finish-record="onFinishRecorder"
@record-error="onRecordError"
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
@@ -14,6 +14,11 @@ const props = defineProps({
type: String,
default: 'conversation',
},
action: {
type: String,
default: 'assign',
validator: value => ['assign', 'remove'].includes(value),
},
isLoading: {
type: Boolean,
default: false,
@@ -22,9 +27,13 @@ const props = defineProps({
type: Boolean,
default: false,
},
appliedLabels: {
type: Array,
default: null,
},
});
const emit = defineEmits(['assign']);
const emit = defineEmits(['assign', 'remove']);
const { t } = useI18n();
@@ -35,17 +44,43 @@ const [showDropdown, toggleDropdown] = useToggle(false);
const selectedLabels = ref([]);
const isTypeContact = computed(() => props.type === 'contact');
const isRemoveAction = computed(() => props.action === 'remove');
const buttonLabel = computed(() =>
props.type === 'contact' ? t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS') : ''
const buttonLabel = computed(() => {
if (!isTypeContact.value) return '';
return isRemoveAction.value
? t('CONTACTS_BULK_ACTIONS.REMOVE_LABELS')
: t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS');
});
const tooltipLabel = computed(() =>
isRemoveAction.value
? t('BULK_ACTION.LABELS.REMOVE_LABELS')
: t('BULK_ACTION.LABELS.ASSIGN_LABELS')
);
const confirmLabel = computed(() =>
isRemoveAction.value
? t('BULK_ACTION.LABELS.REMOVE_SELECTED_LABELS')
: t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')
);
const isLabelSelected = labelTitle => {
return selectedLabels.value.includes(labelTitle);
};
const visibleLabels = computed(() => {
if (!isRemoveAction.value || props.appliedLabels === null) {
return labels.value;
}
const applied = new Set(props.appliedLabels);
return labels.value.filter(label => applied.has(label.title));
});
const labelMenuItems = computed(() => {
return labels.value.map(label => ({
return visibleLabels.value.map(label => ({
action: 'select',
value: label.title,
label: label.title,
@@ -64,9 +99,13 @@ const toggleLabelSelection = labelTitle => {
}
};
const handleAssign = () => {
const handleApply = () => {
if (selectedLabels.value.length > 0) {
emit('assign', selectedLabels.value);
if (isRemoveAction.value) {
emit('remove', selectedLabels.value);
} else {
emit('assign', selectedLabels.value);
}
toggleDropdown(false);
selectedLabels.value = [];
}
@@ -81,9 +120,9 @@ const handleDismiss = () => {
<template>
<div ref="containerRef" class="relative">
<NextButton
v-tooltip="isTypeContact ? '' : $t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
v-tooltip="tooltipLabel"
:label="buttonLabel"
icon="i-lucide-tag"
:icon="isRemoveAction ? 'i-woot-tag-remove' : 'i-lucide-tag'"
slate
:size="isTypeContact ? 'sm' : 'xs'"
ghost
@@ -148,9 +187,9 @@ const handleDismiss = () => {
<NextButton
sm
class="w-full [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:label="confirmLabel"
:disabled="!selectedLabels.length"
@click="handleAssign"
@click="handleApply"
/>
</div>
</template>
@@ -1,5 +1,6 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, useAttrs } from 'vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { emitter } from 'shared/helpers/mitt';
@@ -55,12 +56,25 @@ defineOptions({
const attrs = useAttrs();
const {
selectedConversations,
onAssignAgent,
onAssignLabels,
onRemoveLabels,
onAssignTeamsForBulk: onAssignTeam,
onUpdateConversations,
} = useBulkActions();
const getConversationById = useMapGetter('getConversationById');
const appliedLabelsForSelection = computed(() => {
const applied = new Set();
selectedConversations.value.forEach(id => {
const conversation = getConversationById.value(id);
(conversation?.labels || []).forEach(label => applied.add(label));
});
return Array.from(applied);
});
const showCustomTimeSnoozeModal = ref(false);
function onCmdSnoozeConversation(snoozeType) {
@@ -161,6 +175,11 @@ onUnmounted(() => {
</div>
<div class="flex items-center gap-2">
<BulkLabelActions @assign="onAssignLabels" />
<BulkLabelActions
action="remove"
:applied-labels="appliedLabelsForSelection"
@remove="onRemoveLabels"
/>
<BulkUpdateActions
:show-resolve="!showResolvedAction"
:show-reopen="!showOpenAction"
@@ -108,7 +108,7 @@ export function useBulkActions() {
}
}
// Only used in context menu
// Used by both context menu and bulk action bar.
async function onRemoveLabels(labelsToRemove, conversationId = null) {
try {
await store.dispatch('bulkActions/process', {
@@ -119,14 +119,24 @@ export function useBulkActions() {
},
});
useAlert(
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
labelName: labelsToRemove[0],
conversationId,
})
);
// Context-menu remove should not disturb an existing bulk selection.
if (conversationId) {
useAlert(
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
labelName: labelsToRemove[0],
conversationId,
})
);
} else {
store.dispatch('bulkActions/clearSelectedConversationIds');
useAlert(t('BULK_ACTION.LABELS.REMOVE_SUCCESFUL'));
}
} catch (err) {
useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED'));
useAlert(
conversationId
? t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED')
: t('BULK_ACTION.LABELS.REMOVE_FAILED')
);
}
}
@@ -10,10 +10,12 @@ vi.mock('shared/helpers/mitt', () => ({
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal();
actual.default = {
track: vi.fn(),
return {
...actual,
default: {
track: vi.fn(),
},
};
return actual;
});
describe('useTrack', () => {
@@ -16,10 +16,12 @@ vi.mock('vue-i18n');
vi.mock('dashboard/api/captain/tasks');
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal();
actual.default = {
track: vi.fn(),
return {
...actual,
default: {
track: vi.fn(),
},
};
return actual;
});
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
CAPTAIN_EVENTS: {
@@ -1,6 +1,47 @@
const keyboardEventsMock = vi.hoisted(() => ({
mountedCallbacks: [],
unmountedCallbacks: [],
registeredKeybindings: {},
}));
vi.mock('vue', async importOriginal => ({
...(await importOriginal()),
onMounted: vi.fn(callback =>
keyboardEventsMock.mountedCallbacks.push(callback)
),
onUnmounted: vi.fn(callback =>
keyboardEventsMock.unmountedCallbacks.push(callback)
),
}));
vi.mock('dashboard/composables/useDetectKeyboardLayout', () => ({
useDetectKeyboardLayout: vi.fn(() => Promise.resolve('QWERTY')),
}));
vi.mock('tinykeys', () => ({
createKeybindingsHandler: vi.fn(keybindings => {
keyboardEventsMock.registeredKeybindings = keybindings;
return event => keybindings[event.shortcut]?.(event);
}),
}));
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { LAYOUT_QWERTZ } from 'shared/helpers/KeyboardHelpers';
const mount = async events => {
useKeyboardEvents(events);
await keyboardEventsMock.mountedCallbacks[0]();
return keyboardEventsMock.registeredKeybindings;
};
describe('useKeyboardEvents', () => {
beforeEach(() => {
keyboardEventsMock.mountedCallbacks = [];
keyboardEventsMock.unmountedCallbacks = [];
keyboardEventsMock.registeredKeybindings = {};
});
it('should be defined', () => {
expect(useKeyboardEvents).toBeDefined();
});
@@ -9,18 +50,46 @@ describe('useKeyboardEvents', () => {
expect(useKeyboardEvents).toBeInstanceOf(Function);
});
it('should set up listeners on mount and remove them on unmount', async () => {
const events = {
'ALT+KeyL': () => {},
};
it('registers keybindings on mount and removes the listener on unmount', async () => {
const addEventListener = vi.spyOn(document, 'addEventListener');
const mountedMock = vi.fn();
const unmountedMock = vi.fn();
useKeyboardEvents(events);
mountedMock();
unmountedMock();
const keybindings = await mount({ 'ALT+KeyL': () => {} });
expect(Object.keys(keybindings)).toEqual(['ALT+KeyL']);
expect(mountedMock).toHaveBeenCalled();
expect(unmountedMock).toHaveBeenCalled();
const { signal } = addEventListener.mock.calls[0][2];
expect(signal.aborted).toBe(false);
keyboardEventsMock.unmountedCallbacks[0]();
expect(signal.aborted).toBe(true);
});
it('prefixes Shift on QWERTZ layouts for the affected keys', async () => {
useDetectKeyboardLayout.mockResolvedValueOnce(LAYOUT_QWERTZ);
const keybindings = await mount({ 'Alt+KeyL': () => {} });
expect(Object.keys(keybindings)).toEqual(['Shift+Alt+KeyL']);
});
it('ignores shortcuts on focused typeable elements by default', async () => {
const action = vi.fn();
const keybindings = await mount({
'Alt+KeyL': { action, allowOnFocusedInput: false },
});
keybindings['Alt+KeyL']({ target: document.createElement('textarea') });
expect(action).not.toHaveBeenCalled();
});
it('runs shortcuts on focused typeable elements when allowed', async () => {
const action = vi.fn();
const keybindings = await mount({
'Alt+KeyL': { action, allowOnFocusedInput: true },
});
keybindings['Alt+KeyL']({ target: document.createElement('textarea') });
expect(action).toHaveBeenCalledTimes(1);
});
});
@@ -52,6 +52,10 @@ export function useAccount() {
});
};
const finishOnboarding = async data => {
await store.dispatch('accounts/finishOnboarding', data);
};
return {
accountId,
route,
@@ -61,5 +65,6 @@ export function useAccount() {
isCloudFeatureEnabled,
isOnChatwootCloud,
updateAccount,
finishOnboarding,
};
}
@@ -1,62 +1,145 @@
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
import { computed, readonly, ref, watch, onUnmounted, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { useCallsStore } from 'dashboard/stores/calls';
import { useAlert } from 'dashboard/composables';
import {
useWhatsappCallSession,
sendWhatsappTerminateBeacon,
cleanupWhatsappSession,
} from 'dashboard/composables/useWhatsappCallSession';
import { handleVoiceCallCreated } from 'dashboard/helper/voice';
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import {
CONTENT_TYPES,
VOICE_CALL_DIRECTION,
VOICE_CALL_STATUS,
} from 'dashboard/components-next/message/constants';
import Timer from 'dashboard/helper/Timer';
export function useCallSession() {
const callsStore = useCallsStore();
const { t } = useI18n();
const isJoining = ref(false);
const callDuration = ref(0);
const durationTimer = new Timer(elapsed => {
callDuration.value = elapsed;
const isWhatsappCall = call => call?.provider === VOICE_CALL_PROVIDERS.WHATSAPP;
// Dismissed call sids must not be re-seeded by the conversation-load watcher.
// Lives at module scope so all consumers share the same set.
const dismissedCallSids = new Set();
const markDismissed = callSid => {
if (callSid) dismissedCallSids.add(callSid);
};
// Globals attached once across all useCallSession() consumers — bubbles in a
// long thread call this composable many times, and a per-instance Timer +
// window listener stack would multiply work.
let globalsAttachedCount = 0;
let globalDurationTimer = null;
const globalCallDuration = ref(0);
let storedCallsStoreRef = null;
// Shared join lock so two surfaces (bubble + widget) clicking concurrently
// see one in-flight join, not two unrelated isJoining refs.
const globalIsJoining = ref(false);
const globalIsJoiningReadonly = readonly(globalIsJoining);
const handleBeforeUnloadGlobal = event => {
const store = storedCallsStoreRef;
if (!store) return;
if (!store.hasActiveCall && !store.hasIncomingCall) return;
event.preventDefault();
event.returnValue = '';
};
const handlePageHideGlobal = () => sendWhatsappTerminateBeacon();
const handleTwilioDisconnectedGlobal = () =>
storedCallsStoreRef?.clearActiveCall();
const attachGlobalsOnFirstMount = callsStore => {
globalsAttachedCount += 1;
if (globalsAttachedCount > 1) return;
storedCallsStoreRef = callsStore;
globalDurationTimer = new Timer(elapsed => {
globalCallDuration.value = elapsed;
});
const activeCall = computed(() => callsStore.activeCall);
const incomingCalls = computed(() => callsStore.incomingCalls);
const hasActiveCall = computed(() => callsStore.hasActiveCall);
watch(
hasActiveCall,
active => {
if (active) {
durationTimer.start();
} else {
durationTimer.stop();
callDuration.value = 0;
}
},
{ immediate: true }
TwilioVoiceClient.addEventListener(
'call:disconnected',
handleTwilioDisconnectedGlobal
);
window.addEventListener('beforeunload', handleBeforeUnloadGlobal);
window.addEventListener('pagehide', handlePageHideGlobal);
};
onMounted(() => {
TwilioVoiceClient.addEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
});
const detachGlobalsOnLastUnmount = () => {
globalsAttachedCount -= 1;
if (globalsAttachedCount > 0) return;
globalDurationTimer?.stop();
globalDurationTimer = null;
globalCallDuration.value = 0;
storedCallsStoreRef = null;
TwilioVoiceClient.removeEventListener(
'call:disconnected',
handleTwilioDisconnectedGlobal
);
window.removeEventListener('beforeunload', handleBeforeUnloadGlobal);
window.removeEventListener('pagehide', handlePageHideGlobal);
};
onUnmounted(() => {
durationTimer.stop();
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
});
// Build the action surface used by both the root session composable and the
// lighter useCallActions consumer. All state is module-scoped — the actions
// don't depend on per-instance refs, so they're cheap to call from anywhere.
const buildCallActions = ({ callsStore, whatsappSession, t }) => {
const findCall = callSid => callsStore.calls.find(c => c.callSid === callSid);
const endCall = async ({ conversationId, inboxId, callSid }) => {
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
TwilioVoiceClient.endClientCall();
durationTimer.stop();
callsStore.clearActiveCall();
const call = findCall(callSid);
if (isWhatsappCall(call)) {
// Pass call.callId so a wiped module state (e.g. a prior accept attempt
// tore down the WebRTC session) doesn't stop us hitting /terminate.
await whatsappSession.endActiveCall(call?.callId);
globalDurationTimer?.stop();
callsStore.clearActiveCall();
return;
}
// try/finally so a failed leaveConference (e.g. backend 5xx) still
// tears down the local Device and UI state — otherwise the call stays
// visually active with the mic open.
try {
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
} finally {
TwilioVoiceClient.endClientCall();
globalDurationTimer?.stop();
callsStore.clearActiveCall();
}
};
const joinCall = async ({ conversationId, inboxId, callSid }) => {
if (isJoining.value) return null;
if (globalIsJoining.value) return null;
isJoining.value = true;
const call = findCall(callSid);
// Outbound *WhatsApp* calls have no separate join step — the offer was
// sent at initiate time and the answer is applied by the cable handler.
// Routing through acceptIncomingCall here would call prepareInboundAnswer →
// cleanup() and destroy the live outbound session. Outbound *Twilio*
// calls still need joinConference + joinClientCall (FloatingCallWidget
// auto-joins them), so don't short-circuit those.
if (
call?.callDirection === VOICE_CALL_DIRECTION.OUTBOUND &&
isWhatsappCall(call)
) {
return null;
}
globalIsJoining.value = true;
try {
if (isWhatsappCall(call)) {
await whatsappSession.acceptIncomingCall({
callId: call.callId,
sdpOffer: call.sdpOffer,
iceServers: call.iceServers,
});
callsStore.setCallActive(callSid);
globalDurationTimer?.start();
return { callId: call.callId };
}
const device = await TwilioVoiceClient.initializeDevice(inboxId);
if (!device) return null;
@@ -73,47 +156,165 @@ export function useCallSession() {
});
callsStore.setCallActive(callSid);
durationTimer.start();
globalDurationTimer?.start();
return { conferenceSid: joinResponse?.conference_sid };
} catch (error) {
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
if (error?.response?.status === 409) {
TwilioVoiceClient.endClientCall();
markDismissed(callSid);
callsStore.dismissCall(callSid);
} else if (!isWhatsappCall(call)) {
// Tear down the Twilio Device on any other join error so a retry
// starts from a clean state — joinClientCall can leave the device
// half-initialized after a network blip.
TwilioVoiceClient.endClientCall();
}
// eslint-disable-next-line no-console
console.error('Failed to join call:', error);
// Drop any half-built WebRTC state so the next click starts fresh.
cleanupWhatsappSession();
return null;
} finally {
isJoining.value = false;
globalIsJoining.value = false;
}
};
const rejectIncomingCall = callSid => {
TwilioVoiceClient.endClientCall();
callsStore.dismissCall(callSid);
// Await provider-side reject before dismissing the local entry; if the API
// call fails the call should stay surfaced so the agent can retry instead of
// disappearing while the backend still rings.
const rejectIncomingCall = async callSid => {
const call = findCall(callSid);
try {
if (isWhatsappCall(call) && call?.callId) {
if (call.callDirection === VOICE_CALL_DIRECTION.OUTBOUND) {
// Outbound calls that are still ringing must be terminated, not
// rejected (reject is the inbound-side verb on Meta's API).
await whatsappSession.endActiveCall(call.callId);
} else {
await whatsappSession.rejectIncomingCall(call.callId);
}
} else if (call?.inboxId && call?.conversationId) {
// Twilio incoming reject: agent hasn't joined the Device yet, so
// endClientCall is a no-op. End the conference server-side instead
// so Twilio hangs up the inbound leg.
await VoiceAPI.leaveConference({
inboxId: call.inboxId,
conversationId: call.conversationId,
callSid,
});
} else {
TwilioVoiceClient.endClientCall();
}
} finally {
markDismissed(callSid);
callsStore.dismissCall(callSid);
}
};
const dismissCall = callSid => {
markDismissed(callSid);
callsStore.dismissCall(callSid);
};
return { endCall, joinCall, rejectIncomingCall, dismissCall };
};
const buildReactiveSurface = callsStore => {
const activeCall = computed(() => callsStore.activeCall);
const incomingCalls = computed(() => callsStore.incomingCalls);
const hasActiveCall = computed(() => callsStore.hasActiveCall);
const formattedCallDuration = computed(() => {
const minutes = Math.floor(callDuration.value / 60);
const seconds = callDuration.value % 60;
const total = globalCallDuration.value;
const minutes = Math.floor(total / 60);
const seconds = total % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
});
return {
activeCall,
incomingCalls,
hasActiveCall,
isJoining,
isJoining: globalIsJoiningReadonly,
formattedCallDuration,
joinCall,
endCall,
rejectIncomingCall,
dismissCall,
};
};
// Root-mount composable. Call once at the dashboard root (FloatingCallWidget
// is the natural anchor — always mounted, lifetime spans the whole session).
// This is the only path that registers global window/Twilio listeners and
// owns the duration Timer.
export function useCallSession() {
const store = useStore();
const callsStore = useCallsStore();
const whatsappSession = useWhatsappCallSession();
const { t } = useI18n();
const reactive = buildReactiveSurface(callsStore);
// Cable broadcasts (voice_call.incoming / message.created) are one-shot, so
// on a hard refresh they leave the calls store empty. Seed it from any
// ringing voice_call message in the conversation cache. Skip calls the
// agent has already dismissed locally so they don't re-pop on the next
// conversation update.
const seedCallsFromHydratedMessages = () => {
const conversations = store.getters.getAllConversations || [];
const currentUserId = store.getters.getCurrentUserID;
const currentUserAvailability = store.getters.getCurrentUserAvailability;
conversations.forEach(conv => {
(conv.messages || []).forEach(msg => {
if (msg.content_type !== CONTENT_TYPES.VOICE_CALL) return;
if (msg.call?.status !== VOICE_CALL_STATUS.RINGING) return;
const callSid = msg.call?.provider_call_id;
if (callSid && dismissedCallSids.has(callSid)) return;
handleVoiceCallCreated(msg, currentUserId, currentUserAvailability);
});
});
};
watch(
reactive.hasActiveCall,
active => {
if (active) {
globalDurationTimer?.start();
} else {
globalDurationTimer?.stop();
globalCallDuration.value = 0;
}
},
{ immediate: true }
);
onMounted(() => {
attachGlobalsOnFirstMount(callsStore);
seedCallsFromHydratedMessages();
});
// Re-seed when conversations stream in after mount; addCall merges by callSid
// and dismissed sids are filtered, so this is idempotent.
watch(
() => store.getters.getAllConversations?.length,
() => seedCallsFromHydratedMessages()
);
onUnmounted(() => detachGlobalsOnLastUnmount());
const actions = buildCallActions({ callsStore, whatsappSession, t });
return { ...reactive, ...actions };
}
// Lightweight consumer for components that need to read state and trigger
// actions but should NOT mount global listeners (e.g., per-message bubbles
// rendered in a thread). Reads from the same module-level state that
// useCallSession owns, so the duration timer and dismissed set stay coherent.
export function useCallActions() {
const callsStore = useCallsStore();
const whatsappSession = useWhatsappCallSession();
const { t } = useI18n();
const reactive = buildReactiveSurface(callsStore);
const actions = buildCallActions({ callsStore, whatsappSession, t });
return { ...reactive, ...actions };
}
@@ -0,0 +1,455 @@
import { readonly, ref } from 'vue';
import Cookies from 'js-cookie';
import WhatsappCallsAPI from 'dashboard/api/channel/whatsapp/whatsappCallsAPI';
import { VOICE_CALL_OUTBOUND_INIT_STATUS } from 'dashboard/components-next/message/constants';
// Module-level state lets the cable handlers and unload listeners reach the
// live PeerConnection without prop-drilling refs through every composable.
let pc = null;
let localStream = null;
let remoteStream = null;
let remoteAudioEl = null;
let mediaRecorder = null;
let recorderChunks = [];
let audioContext = null;
let activeCallId = null;
// voice_call.outbound_connected (the sole source of the outbound SDP answer) is
// broadcast account-wide and can arrive before the /initiate response sets
// activeCallId in this tab. Until we know our own call id we can't tell our
// answer from a concurrent agent's, so the cable handler buffers them here keyed
// by call id (a single slot would let a concurrent agent's event clobber ours)
// and initiateOutboundCall flushes the matching one by id.
const pendingOutboundAnswers = new Map();
// Module-scoped so multiple composable callers (header button + contact-panel
// button) share the same lock. A per-instance ref let two parallel callers
// both pass the guard and tear down each other's WebRTC state in cleanup().
// ref() so consumers can reactively gate buttons on it (the composable
// re-exports this as `isInitiating`).
const isInitiatingOutbound = ref(false);
const isInitiatingOutboundReadonly = readonly(isInitiatingOutbound);
// Inbound calls record from the moment the agent clicks accept (their click =
// pickup). Outbound calls must wait — Meta's `connect` webhook (which lands
// during ringing) negotiates remote tracks ~20s before the contact actually
// answers, and we don't want pre-pickup audio in the recording. This flag is
// flipped to true by armOutboundRecorder() when the ACCEPTED status arrives.
let recorderArmed = false;
const ensureRemoteAudioElement = () => {
if (remoteAudioEl) return remoteAudioEl;
remoteAudioEl = document.createElement('audio');
remoteAudioEl.id = 'whatsapp-call-remote-audio';
remoteAudioEl.autoplay = true;
remoteAudioEl.playsInline = true;
remoteAudioEl.style.display = 'none';
document.body.appendChild(remoteAudioEl);
return remoteAudioEl;
};
const playRemoteStream = stream => {
const el = ensureRemoteAudioElement();
el.srcObject = stream;
el.play().catch(err => {
// eslint-disable-next-line no-console
console.warn('[WhatsApp Call] remote audio play() failed:', err);
});
};
// 1s timeslice keeps a recent recording chunk in memory so a remote hangup
// that races cleanup still has data to upload.
const RECORDING_TIMESLICE_MS = 1000;
const ICE_GATHER_TIMEOUT_MS = 10000;
const RECORDER_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
];
// Outbound calls have no backend-supplied ice_servers (the call doesn't exist
// at offer time). Without STUN the browser only sends host candidates and
// browser→Meta media silently drops through any non-trivial NAT.
const DEFAULT_OUTBOUND_ICE_SERVERS = [{ urls: 'stun:stun.l.google.com:19302' }];
const waitForIceGatheringComplete = peer =>
new Promise(resolve => {
if (peer.iceGatheringState === 'complete') {
resolve();
return;
}
const timer = setTimeout(resolve, ICE_GATHER_TIMEOUT_MS);
peer.addEventListener('icegatheringstatechange', () => {
if (peer.iceGatheringState === 'complete') {
clearTimeout(timer);
resolve();
}
});
});
const setupRecorder = () => {
if (!localStream || !remoteStream || mediaRecorder) return;
// createMediaStreamSource on a stream with no audio tracks wires up to
// nothing — the recorded mix would be silence. Wait until ontrack fires.
if (remoteStream.getAudioTracks().length === 0) return;
audioContext = new AudioContext({ sampleRate: 48000 });
// AudioContext starts suspended under most autoplay policies; without
// resume() the destination stream produces silence.
audioContext.resume().catch(() => {});
const destination = audioContext.createMediaStreamDestination();
audioContext.createMediaStreamSource(localStream).connect(destination);
audioContext.createMediaStreamSource(remoteStream).connect(destination);
const mimeType = RECORDER_MIME_CANDIDATES.find(t =>
MediaRecorder.isTypeSupported(t)
);
if (!mimeType) return;
recorderChunks = [];
mediaRecorder = new MediaRecorder(destination.stream, { mimeType });
mediaRecorder.ondataavailable = event => {
if (event.data && event.data.size > 0) recorderChunks.push(event.data);
};
mediaRecorder.start(RECORDING_TIMESLICE_MS);
};
const cleanup = () => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
try {
mediaRecorder.stop();
} catch (_) {
/* noop */
}
}
if (audioContext && audioContext.state !== 'closed') {
audioContext.close().catch(() => {});
}
if (localStream) localStream.getTracks().forEach(t => t.stop());
if (remoteStream) remoteStream.getTracks().forEach(t => t.stop());
if (pc) pc.close();
if (remoteAudioEl) remoteAudioEl.srcObject = null;
pc = null;
localStream = null;
remoteStream = null;
mediaRecorder = null;
recorderChunks = [];
audioContext = null;
activeCallId = null;
pendingOutboundAnswers.clear();
recorderArmed = false;
};
const buildPeerConnection = iceServers => {
const config = iceServers && iceServers.length ? { iceServers } : {};
pc = new RTCPeerConnection(config);
remoteStream = new MediaStream();
pc.ontrack = event => {
// Reuse the same MediaStream object — the recorder's audioContext source
// taps it once, so reassigning would orphan the recorder.
const tracks =
event.streams && event.streams[0]
? event.streams[0].getTracks()
: [event.track];
tracks.forEach(track => {
if (!remoteStream.getTracks().includes(track))
remoteStream.addTrack(track);
});
playRemoteStream(remoteStream);
// Only arm the recorder when the call is actually accepted. For outbound
// this is the ACCEPTED status webhook; for inbound this is the agent's
// own click (acceptIncomingCall flips recorderArmed before returning).
if (recorderArmed) setupRecorder();
};
return pc;
};
const stopRecorderAndUpload = async callId => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
await new Promise(resolve => {
mediaRecorder.addEventListener('stop', resolve, { once: true });
try {
mediaRecorder.stop();
} catch (_) {
resolve();
}
});
}
if (!recorderChunks.length || !callId) return;
const blob = new Blob(recorderChunks, { type: recorderChunks[0].type });
// Best-effort — the controller's idempotency guard handles a retry.
try {
await WhatsappCallsAPI.uploadRecording(callId, blob);
} catch (_) {
/* noop */
}
};
// devise-token-auth requires access-token / client / uid headers on every
// request — navigator.sendBeacon can't set custom headers, so we rehydrate
// the auth payload from the cw_d_session_info cookie that the dashboard sets
// at login. Used by the page-close terminate path below.
const getDeviseAuthHeaders = () => {
try {
const raw = Cookies.get('cw_d_session_info');
if (!raw) return null;
const session = JSON.parse(raw);
return {
'access-token': session['access-token'] || '',
client: session.client || '',
uid: session.uid || '',
expiry: session.expiry || '',
'token-type': session['token-type'] || 'Bearer',
};
} catch (_) {
return null;
}
};
const beaconTerminate = callId => {
if (!callId) return;
const accountId = window.location.pathname.split('/')[3];
if (!accountId) return;
const headers = getDeviseAuthHeaders();
if (!headers) return;
const url = `/api/v1/accounts/${accountId}/whatsapp_calls/${callId}/terminate`;
// fetch+keepalive (instead of navigator.sendBeacon) so we can attach auth
// headers — without them devise-token-auth 401s and the call stays open on
// Meta until its carrier-side timeout (~60s).
try {
fetch(url, {
method: 'POST',
keepalive: true,
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...headers },
body: '{}',
}).catch(() => {});
} catch (_) {
/* noop */
}
};
export const hasActiveWhatsappCall = () => !!(activeCallId || pc);
export const isLocalWhatsappCall = callId =>
!!callId && activeCallId != null && callId === activeCallId;
export function useWhatsappCallSession() {
const prepareInboundAnswer = async (sdpOffer, iceServers) => {
cleanup();
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
buildPeerConnection(iceServers);
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await waitForIceGatheringComplete(pc);
return pc.localDescription.sdp;
};
const prepareOutboundOffer = async () => {
cleanup();
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
buildPeerConnection(DEFAULT_OUTBOUND_ICE_SERVERS);
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await waitForIceGatheringComplete(pc);
return pc.localDescription.sdp;
};
const acceptIncomingCall = async ({ callId, sdpOffer, iceServers }) => {
// The store may not have sdpOffer yet (the cable broadcast can race the
// click). Fall back to GET /whatsapp_calls/:id which exposes it.
let offer = sdpOffer;
let ice = iceServers;
if (!offer && callId) {
try {
const fresh = await WhatsappCallsAPI.show(callId);
offer = fresh?.sdp_offer || fresh?.sdpOffer;
ice = ice || fresh?.ice_servers || fresh?.iceServers;
} catch (e) {
// eslint-disable-next-line no-console
console.error(
'[WhatsApp Call] failed to fetch call data for accept:',
e
);
}
}
if (!offer) {
throw new Error('Missing sdp_offer for accept — call may have ended.');
}
// Release the mic + peer connection if anything between here and the accept
// round-trip fails — otherwise a rejected accept leaves the mic live and
// activeCallId set. Mirrors rejectIncomingCall's self-cleanup; rethrow so
// the caller can surface the failure and skip marking the call active.
try {
const sdpAnswer = await prepareInboundAnswer(offer, ice);
activeCallId = callId;
// Inbound: agent's click is the pickup. Arm the recorder before the API
// round-trip so when ontrack fires (triggered by setRemoteDescription
// back in prepareInboundAnswer) the recorder is already authorized.
recorderArmed = true;
setupRecorder();
await WhatsappCallsAPI.accept(callId, sdpAnswer);
} catch (e) {
cleanup();
throw e;
}
};
const rejectIncomingCall = async callId => {
try {
await WhatsappCallsAPI.reject(callId);
} finally {
cleanup();
}
};
const initiateOutboundCall = async conversationId => {
// Module-scoped lock + active-session guard so a second click — from the
// same composable instance OR a different one (header vs contact panel)
// OR while a call is already live — can't tear down the in-flight setup
// via prepareOutboundOffer's cleanup().
if (isInitiatingOutbound.value)
return { status: VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED };
if (hasActiveWhatsappCall())
return { status: VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED };
isInitiatingOutbound.value = true;
try {
const sdpOffer = await prepareOutboundOffer();
const response = await WhatsappCallsAPI.initiate(
conversationId,
sdpOffer
);
if (response?.id) {
activeCallId = response.id;
// A connect webhook that raced ahead of this response was buffered;
// apply our own by id now that we know it, then drop every buffered
// answer (concurrent agents' calls aren't ours to apply).
const buffered = pendingOutboundAnswers.get(activeCallId);
pendingOutboundAnswers.clear();
if (buffered) {
await pc.setRemoteDescription({
type: 'answer',
sdp: buffered,
});
}
return response;
}
// No call id back: this is the permission-request branch. The mic +
// PeerConnection allocated by prepareOutboundOffer aren't useful until
// the contact opts in and the agent retries — release them.
cleanup();
return response;
} catch (e) {
cleanup();
// BE returns 422 when the contact hasn't opted in (permission template
// sent or already pending). Surface it to the caller as a normal
// response shape so it can render the banner instead of an error toast.
const data = e?.response?.data;
if (
data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_REQUESTED ||
data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
) {
return { status: data.status };
}
throw e;
} finally {
isInitiatingOutbound.value = false;
}
};
// callIdOverride is the call.id from the dashboard's calls store. Module
// `activeCallId` may be null after a prior accept attempt's cleanup() — but
// the call still exists on Meta and must still be terminated. Falling back
// to the override means hangup is robust to a wiped local session.
const endActiveCall = async (callIdOverride = null) => {
const callId = activeCallId || callIdOverride;
if (!callId) {
cleanup();
return;
}
try {
// Terminate on Meta first so the contact is disconnected immediately. The
// recording upload below can be slow on long calls or poor networks, and
// the peer connection / mic must not stay live for the contact during it.
await WhatsappCallsAPI.terminate(callId).catch(() => {});
await stopRecorderAndUpload(callId);
} finally {
cleanup();
}
};
return {
isInitiating: isInitiatingOutboundReadonly,
prepareInboundAnswer,
prepareOutboundOffer,
acceptIncomingCall,
rejectIncomingCall,
initiateOutboundCall,
endActiveCall,
};
}
// Cable handlers fire outside any composable instance, so the shared session
// surface is exposed as module-level functions for them.
export const applyOutboundAnswer = async (callId, sdpAnswer) => {
// No in-flight peer connection in this tab → not our call.
if (!pc) return;
// /initiate hasn't returned our call id yet (the connect webhook raced
// ahead). Buffer the answer; initiateOutboundCall flushes it by id once it
// knows which call is ours, so a concurrent agent's answer is never applied.
if (activeCallId == null) {
pendingOutboundAnswers.set(callId, sdpAnswer);
return;
}
// activeCallId known → only apply the answer for this tab's call.
if (callId !== activeCallId) return;
await pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
};
// Called by the cable handler when Meta delivers status=ACCEPTED for the
// outbound call (real pickup). Flips the recorder gate and starts the
// MediaRecorder. Idempotent — safe if ontrack hasn't fired yet (setupRecorder
// bails until the remote stream has audio tracks; ontrack will retry).
export const armOutboundRecorder = () => {
recorderArmed = true;
setupRecorder();
};
export const cleanupWhatsappSession = () => cleanup();
export const handleWhatsappRemoteEnd = async callId => {
// Snapshot before cleanup nulls activeCallId.
const id = callId || activeCallId;
if (!id) {
cleanup();
return;
}
try {
await stopRecorderAndUpload(id);
} finally {
cleanup();
}
};
export const setWhatsappCallMuted = muted => {
if (!localStream) return false;
localStream.getAudioTracks().forEach(track => {
track.enabled = !muted;
});
return muted;
};
export const sendWhatsappTerminateBeacon = () => {
// Always fire when there's a live callId. The beacon endpoint is idempotent,
// so racing it with an in-flight Axios terminate (which unload may abort) is
// fine — Meta gets exactly one terminate either way, and we avoid leaving
// the call ringing on Meta until its carrier-side timeout.
if (!activeCallId) return;
beaconTerminate(activeCallId);
};
@@ -4,6 +4,15 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
import { useCallsStore } from 'dashboard/stores/calls';
import {
applyOutboundAnswer,
armOutboundRecorder,
handleWhatsappRemoteEnd,
isLocalWhatsappCall,
} from 'dashboard/composables/useWhatsappCallSession';
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const { isImpersonating } = useImpersonation();
@@ -41,6 +50,10 @@ class ActionCableConnector extends BaseActionCableConnector {
'account.cache_invalidated': this.onCacheInvalidate,
'account.enrichment_completed': this.onEnrichmentCompleted,
'copilot.message.created': this.onCopilotMessageCreated,
'voice_call.incoming': this.onVoiceCallIncoming,
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
'voice_call.outbound_accepted': this.onVoiceCallOutboundAccepted,
'voice_call.ended': this.onVoiceCallEnded,
};
}
@@ -261,6 +274,74 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
};
onVoiceCallIncoming = data => {
if (data?.provider !== VOICE_CALL_PROVIDERS.WHATSAPP) return;
// Defense in depth: the server already filters to online agent streams,
// but if anything ever broadcasts to a broader stream (e.g. account-wide),
// an agent who's set availability=offline/busy shouldn't ring.
const availability = this.app.$store.getters.getCurrentUserAvailability;
if (availability !== 'online') return;
useCallsStore().addCall({
callSid: data.call_id,
callId: data.id,
conversationId: data.conversation_id,
inboxId: data.inbox_id,
callDirection: VOICE_CALL_DIRECTION.INBOUND,
provider: VOICE_CALL_PROVIDERS.WHATSAPP,
sdpOffer: data.sdp_offer,
iceServers: data.ice_servers,
caller: data.caller,
});
};
// `connect` is the WebRTC tunnel-ready signal (fires ~20s before pickup
// for outbound). Apply the SDP answer so the handshake completes during
// ringing, but stay non-active until `outbound_accepted` arrives.
// eslint-disable-next-line class-methods-use-this
onVoiceCallOutboundConnected = async data => {
if (data?.provider !== VOICE_CALL_PROVIDERS.WHATSAPP || !data.sdp_answer)
return;
// Account-wide broadcast that can arrive before /initiate sets this tab's
// call id. applyOutboundAnswer filters foreign calls and buffers the answer
// until the id is known, so we must not drop it here on a null activeCallId.
try {
await applyOutboundAnswer(data.id, data.sdp_answer);
} catch (_) {
/* noop */
}
};
// Real pickup signal — Meta sends status=ACCEPTED on the call when the
// contact answers. Flip active (timer starts) and arm the recorder.
// eslint-disable-next-line class-methods-use-this
onVoiceCallOutboundAccepted = data => {
if (data?.provider !== VOICE_CALL_PROVIDERS.WHATSAPP) return;
const store = useCallsStore();
if (!store.calls.some(c => c.callSid === data.call_id)) return;
store.setCallActive(data.call_id);
armOutboundRecorder();
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallEnded = async data => {
if (data?.provider !== VOICE_CALL_PROVIDERS.WHATSAPP) return;
// The store entry should always be removed for this account-wide broadcast,
// but the WebRTC/recorder teardown must only run for the call this tab owns
// — otherwise an unrelated agent's call ending would stop this tab's
// recorder and upload its chunks against the wrong call id.
if (isLocalWhatsappCall(data.id)) {
// Await upload before removeCall — the store's sync teardown would otherwise
// wipe the recorder chunks before they reach the server.
try {
await handleWhatsappRemoteEnd(data.id);
} catch (_) {
/* noop */
}
}
useCallsStore().removeCall(data.call_id);
};
}
export default {
+6 -3
View File
@@ -17,6 +17,7 @@ export const INBOX_TYPES = {
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio',
WHATSAPP: 'whatsapp',
};
export const getVoiceCallProvider = inbox => {
@@ -26,9 +27,11 @@ export const getVoiceCallProvider = inbox => {
const channelType = inbox.channel_type || inbox.channelType;
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
return VOICE_CALL_PROVIDERS.TWILIO;
}
if (!voiceEnabled) return null;
if (channelType === INBOX_TYPES.TWILIO) return VOICE_CALL_PROVIDERS.TWILIO;
if (channelType === INBOX_TYPES.WHATSAPP)
return VOICE_CALL_PROVIDERS.WHATSAPP;
return null;
};
+83 -5
View File
@@ -1,4 +1,5 @@
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { useCallsStore } from 'dashboard/stores/calls';
import types from 'dashboard/store/mutation-types';
@@ -45,23 +46,66 @@ const shouldShowCall = ({
return !isAssignedToAnotherAgent(assigneeId, currentUserId);
};
// Offline/busy agents shouldn't get a ringing popup for inbound calls, but
// outbound calls always belong to the initiator regardless of their status,
// and existing (already-surfaced) calls keep going so a status change
// mid-call doesn't yank away an active widget.
const shouldRingInbound = (callDirection, currentUserAvailability) => {
if (callDirection === 'outbound') return true;
return currentUserAvailability === 'online';
};
function extractCallerSnapshot(message) {
// Snapshot caller info from the message at add-time so the widget can keep
// rendering it after the user navigates away from a conversation list that
// had the conversation hydrated (and Vuex evicts it from the store).
// Only incoming messages carry the contact as the sender; on outbound calls
// the sender is the initiating agent, so skip the snapshot and let the widget
// fall back to the conversation's contact (conversation.meta.sender).
if (message?.message_type !== MESSAGE_TYPE.INCOMING) return null;
const sender = message?.sender;
if (!sender) return null;
return {
name: sender.name,
phone: sender.phone_number,
avatar: sender.avatar || sender.thumbnail,
additionalAttributes: sender.additional_attributes || {},
};
}
function extractCallData(message) {
const call = message?.call || {};
return {
callSid: call.provider_call_id,
callId: call.id,
provider: call.provider,
status: call.status,
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
conversationId: message?.conversation_id,
inboxId: message?.inbox_id ?? message?.conversation?.inbox_id,
assigneeId: extractAssigneeId(message?.conversation),
senderId: message?.sender?.id,
caller: extractCallerSnapshot(message),
};
}
export function handleVoiceCallCreated(message, currentUserId) {
export function handleVoiceCallCreated(
message,
currentUserId,
currentUserAvailability
) {
if (!isVoiceCallMessage(message)) return;
const { callSid, callDirection, conversationId, assigneeId, senderId } =
extractCallData(message);
const {
callSid,
callId,
provider,
callDirection,
conversationId,
inboxId,
assigneeId,
senderId,
} = extractCallData(message);
if (
!shouldShowCall({
@@ -74,23 +118,37 @@ export function handleVoiceCallCreated(message, currentUserId) {
return;
}
if (!shouldRingInbound(callDirection, currentUserAvailability)) return;
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
callId,
provider,
conversationId,
inboxId,
callDirection,
senderId,
caller: extractCallerSnapshot(message),
});
}
export function handleVoiceCallUpdated(commit, message, currentUserId) {
export function handleVoiceCallUpdated(
commit,
message,
currentUserId,
currentUserAvailability
) {
if (!isVoiceCallMessage(message)) return;
const {
callSid,
callId,
provider,
status,
callDirection,
conversationId,
inboxId,
assigneeId,
senderId,
} = extractCallData(message);
@@ -118,11 +176,17 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
}
if (status === 'ringing') {
if (!shouldRingInbound(callDirection, currentUserAvailability)) return;
callsStore.addCall({
callSid,
callId,
provider,
conversationId,
inboxId,
callDirection,
senderId,
caller: extractCallerSnapshot(message),
});
}
}
@@ -131,6 +195,20 @@ export function syncConversationCallVisibility(conversation, currentUserId) {
const assigneeId = extractAssigneeId(conversation);
if (!isAssignedToAnotherAgent(assigneeId, currentUserId)) return;
// Outbound calls belong to the initiator regardless of who the conversation
// is currently assigned to (auto-assignment may flip mid-call). Mirror
// shouldShowCall's outbound exception so an in-progress outbound call isn't
// ripped out from under the caller when the conversation reassigns.
const callsStore = useCallsStore();
callsStore.removeCallsForConversation(conversation.id);
const callsToRemove = callsStore.calls.filter(
call =>
call.conversationId === conversation.id &&
!shouldShowCall({
callDirection: call.callDirection,
senderId: call.senderId,
assigneeId,
currentUserId,
})
);
callsToRemove.forEach(call => callsStore.removeCall(call.callSid));
}
@@ -1,11 +1,6 @@
{
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "{conversationCount} conversations selected",
"AGENT_SELECT_LABEL": "Select agent",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {conversationCount} {conversationLabel} to",
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assign",
"NONE": "None",
"CLEAR_SELECTION": "Clear",
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
@@ -20,7 +15,6 @@
"RESOLVE_SUCCESFUL": "Conversations resolved successfully.",
"RESOLVE_FAILED": "Failed to resolve conversations. Please try again.",
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
"AGENT_LIST_LOADING": "Loading agents",
"UPDATE": {
"CHANGE_STATUS": "Change status",
"SNOOZE_UNTIL": "Snooze",
@@ -33,16 +27,16 @@
},
"LABELS": {
"ASSIGN_LABELS": "Assign labels",
"NO_LABELS_FOUND": "No labels found",
"REMOVE_LABELS": "Remove labels",
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
"REMOVE_SELECTED_LABELS": "Remove selected labels",
"ASSIGN_SUCCESFUL": "Labels assigned successfully.",
"ASSIGN_FAILED": "Failed to assign labels. Please try again."
"ASSIGN_FAILED": "Failed to assign labels. Please try again.",
"REMOVE_SUCCESFUL": "Labels removed successfully.",
"REMOVE_FAILED": "Failed to remove labels. Please try again."
},
"TEAMS": {
"TEAM_SELECT_LABEL": "Select team",
"NONE": "None",
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"ASSIGN_SUCCESFUL": "Teams assigned successfully.",
@@ -88,6 +88,7 @@
},
"CARD": {
"STATUS": {
"PROCESSING": "በሂደት ላይ",
"COMPLETED": "Completed",
"SCHEDULED": "Scheduled"
},
@@ -146,6 +147,7 @@
},
"CARD": {
"STATUS": {
"PROCESSING": "በሂደት ላይ",
"COMPLETED": "Completed",
"SCHEDULED": "Scheduled"
},
@@ -63,6 +63,7 @@
"CODE": "Code",
"BULLET_LIST": "Bullet List",
"ORDERED_LIST": "Ordered List",
"TABLE": "Table"
"TABLE": "Table",
"IMAGE": "Image"
}
}
@@ -20,6 +20,8 @@
"CALL": "ደውል",
"CALL_INITIATED": "ለእውነተኛው እውቀት መደወል እየተከናወነ ነው…",
"CALL_FAILED": "ጥሪውን መጀመር አልቻልንም። እባክዎ ደግመው ይሞክሩ።.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"CLICK_TO_EDIT": "Click to edit",
"VOICE_INBOX_PICKER": {
"TITLE": "የድምፅ ኢንቦክስ ይምረጡ"
@@ -583,8 +585,11 @@
},
"CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "መለያዎችን መሰጠት",
"REMOVE_LABELS": "Remove Labels",
"ASSIGN_LABELS_SUCCESS": "መለያዎች በተሳካ ሁኔታ ተመዝግበዋል።.",
"ASSIGN_LABELS_FAILED": "መለያዎችን ማስመዝገብ አልተሳካም",
"REMOVE_LABELS_SUCCESS": "Labels removed successfully.",
"REMOVE_LABELS_FAILED": "Failed to remove labels",
"DESCRIPTION": "ለተመረጡት እውነተኛዎች የሚያክሉትን መለያዎች ይምረጡ።.",
"NO_LABELS_FOUND": "እስካሁን መለያዎች አልተገኙም።.",
"SELECTED_COUNT": "{count} ተመርጧል",
@@ -62,6 +62,7 @@
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
"UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok app.",
"UNSUPPORTED_MESSAGE_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
"NO_RESPONSE": "No response",
@@ -79,13 +80,22 @@
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NO_ANSWER": "No answer",
"NO_ANSWER_OUTBOUND_LABEL": "No answer",
"NO_ANSWER_OUTBOUND_SUBTEXT": "Contact didn't pick up",
"MISSED_CALL": "Missed call",
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
"CALL_ENDED": "Call ended",
"HANDLED_BY": "Handled by {agentName}",
"NOT_ANSWERED_YET": "Not answered yet",
"CALLING": "Calling…",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "Join call"
"JOIN_CALL": "Join call",
"CALL_BACK": "Call back",
"TRANSCRIPT_SHOW_MORE": "Show more",
"TRANSCRIPT_SHOW_LESS": "Show less"
},
"HEADER": {
"RESOLVE_ACTION": "ተፈትኗል",
@@ -100,6 +110,12 @@
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply",
"WHATSAPP_CALL": "Start WhatsApp call",
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
"VOICE_CALL": "Start call",
"VOICE_CALL_FAILED": "Could not start the call.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"SLA_STATUS": {
"FRT": "FRT {status}",
"NRT": "NRT {status}",
@@ -215,6 +231,7 @@
"TIP_AUDIORECORDER_ICON": "Record audio",
"TIP_AUDIORECORDER_PERMISSION": "Allow access to audio",
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Drag and drop here to attach",
"START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording",
@@ -299,8 +316,13 @@
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"DISMISS_CALL": "Dismiss",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
"END_CALL": "End call",
"MUTE": "Mute mic",
"UNMUTE": "Unmute mic",
"VIEW_CHAT_HISTORY": "View chat history",
"GO_TO_CONVERSATION": "Go to conversation thread"
}
},
"EMAIL_TRANSCRIPT": {
@@ -607,9 +607,12 @@
"DRAFT": "እቅድ",
"ARCHIVE": "አርክቭ",
"TRANSLATE": "Translate",
"MOVE_TO_CATEGORY": "Category",
"DELETE": "Delete",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
"CATEGORY_SUCCESS": "Articles moved successfully",
"CATEGORY_ERROR": "Failed to move articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
"DELETE_CONFIRM": "Delete",
@@ -866,6 +869,28 @@
},
"EDIT_CONFIGURATION": "ቅንብር አርትዕ"
},
"LAYOUT_CONTENT": {
"HEADER": "Appearance",
"DESCRIPTION": "Pick the layout that fits how your visitors read.",
"LAYOUT": {
"CLASSIC": {
"TITLE": "Classic",
"DESCRIPTION": "A welcoming home page with search and featured topics."
},
"SIDEBAR": {
"TITLE": "Documentation",
"DESCRIPTION": "Side-by-side navigation that keeps every guide a click away."
}
},
"SOCIAL_LINKS": {
"HEADER": "Social links",
"DESCRIPTION": "Add the handle for each network and your help center builds the full link. Shown in the documentation layout footer.",
"PLACEHOLDER": "handle",
"ADD": "Add social link",
"REMOVE": "አስወግድ"
},
"SAVE": "ለውጦች አስቀምጥ"
},
"API": {
"CREATE_PORTAL": {
"SUCCESS_MESSAGE": "ፖርታል በተሳካ ሁኔታ ተፈጥሯል",
@@ -308,6 +308,7 @@
"AUTH_PROCESSING": "ከMeta ጋር በመረጋገጥ ላይ ነው",
"WAITING_FOR_BUSINESS_INFO": "እባክዎ በMeta መስኮት ውስጥ የንግድ ቅንብር ያርኩ...",
"PROCESSING": "የWhatsApp ቢዝነስ መለያዎን እየተዘጋጀ ነው",
"ENABLING_CALLING": "Enabling WhatsApp Calling on your number…",
"LOADING_SDK": "Facebook SDK እየተጫነ ነው...",
"CANCELLED": "የWhatsApp ምዝገባ ተሰርዟል",
"SUCCESS_TITLE": "የWhatsApp ቢዝነስ መለያ ተገናኝቷል!",
@@ -317,7 +318,8 @@
"AUTH_NOT_COMPLETED": "ማረጋገጫ አልተጠናቀቀም። ሂደቱን እባክዎ ዳግም ይጀምሩ።.",
"SUCCESS_FALLBACK": "የWhatsApp የንግድ መለያ በተሳካ ሁኔታ ተቋቋመ",
"MANUAL_FALLBACK": "ቁጥርዎ ከWhatsApp Business Platform (API) ጋር ከተገናኘ እና ወይም እርስዎ የቴክኖሎጂ አቅራቢ ከሆኑ እና የእርስዎን ቁጥር በራስዎ ሲያስገቡ፣ እባክዎ የ{link} ሂደትን ይጠቀሙ",
"MANUAL_LINK_TEXT": "የእጅ ማቀናበሪያ ሂደት"
"MANUAL_LINK_TEXT": "የእጅ ማቀናበሪያ ሂደት",
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
},
"API": {
"ERROR_MESSAGE": "WhatsApp ቻናሉን ማስቀመጥ አልተቻለም"
@@ -465,6 +467,10 @@
"TITLE": "ዋትስአፕ",
"DESCRIPTION": "በWhatsApp ላይ ደንበኞችዎን ድጋፍ ያድርጉ"
},
"WHATSAPP_CALL": {
"TITLE": "WhatsApp Call",
"DESCRIPTION": "Take voice calls on your WhatsApp number"
},
"EMAIL": {
"TITLE": "ኢሜይል",
"DESCRIPTION": "ከGmail, Outlook ወይም ከሌሎች አቅራቢዎች ጋር ያገናኙ"
@@ -637,7 +643,8 @@
"BOT_CONFIGURATION": "የቦት ቅንብሮች",
"ACCOUNT_HEALTH": "የመለያ ጤና",
"CSAT": "የደንበኞች ደህንነት ግምገማ (CSAT)",
"VOICE": "ድምጽ"
"VOICE": "ድምጽ",
"CALLS": "Calls"
},
"VOICE_CONFIGURATION": {
"ENABLE_VOICE": {
@@ -648,6 +655,26 @@
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
}
},
"WHATSAPP_CALLING": {
"ENABLE": {
"LABEL": "Enable WhatsApp Calling",
"DESCRIPTION": "Allow agents to receive and place WhatsApp Cloud calls on this inbox. Customers can call this business number directly from WhatsApp."
},
"ENABLE_FAILED": "Voice calling couldn't be turned on for this number — it isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then try again.",
"PHONE_NUMBER": {
"LABEL": "Business phone number",
"HELP_TEXT": "WhatsApp number that customers will call."
},
"HOW_IT_WORKS": {
"LABEL": "How it works",
"DESCRIPTION": "Calls are placed peer-to-peer between the agent's browser and Meta — no extra credentials are required. Make sure the agent's browser has microphone permission for this site."
},
"PERMISSION_REQUEST_BODY": {
"LABEL": "Call permission request message",
"HELP_TEXT": "Shown to the contact when they haven't yet consented to receive calls. Leave blank to use the default.",
"PLACEHOLDER": "We would like to call you regarding your conversation."
}
},
"CHANNEL_PREFERENCES": "የቻናል ቅድሚያዎች",
"WIDGET_FEATURES": "የዊጅት ባህሪያት",
"ACCOUNT_HEALTH": {
@@ -800,6 +827,10 @@
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "ከWhatsApp መልእክት አብነቶች እጅግ በእጅ ማስተካከል ለእንደገና የሚገኙ አብነቶችን ያዘምኑ።.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "አብነቶችን ያዘምኑ",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "የአብነት ማስተካከያ በተሳካ ሁኔታ ተጀምሯል። ለማዘመን ጥቂት ደቂቃዎች ሊወስድ ይችላል።.",
"WHATSAPP_CALLING_ENABLED": {
"LABEL": "Enable voice calling",
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
},
"UPDATE_PRE_CHAT_FORM_SETTINGS": "የቀደም ቻት ቅጥያ ቅንብሮችን አዘምን"
},
"HELP_CENTER": {
@@ -57,7 +57,8 @@
"CONTACT_CREATED": "እውቂያ ተፈጥሯል",
"CONTACT_UPDATED": "እውቂያ ተሻሽሏል",
"CONVERSATION_TYPING_ON": "ውይይት ማስተካከያ በተጠቃሚ ላይ ነው",
"CONVERSATION_TYPING_OFF": "ውይይት ማስተካከያ ከተጠቃሚ ላይ አልተጠቀሰም"
"CONVERSATION_TYPING_OFF": "ውይይት ማስተካከያ ከተጠቃሚ ላይ አልተጠቀሰም",
"INBOX_UPDATED": "Inbox updated"
}
},
"NAME": {
@@ -1,11 +1,6 @@
{
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "{conversationCount} المحادثات المحددة",
"AGENT_SELECT_LABEL": "اختر وكيل",
"ASSIGN_CONFIRMATION_LABEL": "هل أنت متأكد من أنك تريد تعيين {conversationCount} {conversationLabel} إلى",
"UNASSIGN_CONFIRMATION_LABEL": "هل أنت متأكد من إلغاء تعيين {conversationCount} {conversationLabel}؟",
"GO_BACK_LABEL": "العودة للخلف",
"ASSIGN_LABEL": "تكليف",
"NONE": "لا شيء",
"CLEAR_SELECTION": "مسح",
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
@@ -20,7 +15,6 @@
"RESOLVE_SUCCESFUL": "تم تسوية المحادثات بنجاح.",
"RESOLVE_FAILED": "فشل في حل المحادثات، يرجى المحاولة مرة أخرى.",
"ALL_CONVERSATIONS_SELECTED_ALERT": "المحادثات المرئية في هذه الصفحة هي المحددة فقط.",
"AGENT_LIST_LOADING": "جاري جلب الوكلاء",
"UPDATE": {
"CHANGE_STATUS": "تغيير الحالة",
"SNOOZE_UNTIL": "تأجيل",
@@ -33,16 +27,16 @@
},
"LABELS": {
"ASSIGN_LABELS": "إضافة وسم",
"NO_LABELS_FOUND": "لم يتم العثور على تصنيفات",
"REMOVE_LABELS": "Remove labels",
"ASSIGN_SELECTED_LABELS": "تعيين التسميات المحددة",
"REMOVE_SELECTED_LABELS": "Remove selected labels",
"ASSIGN_SUCCESFUL": "تم تعيين التسميات بنجاح.",
"ASSIGN_FAILED": "فشل في تعيين التسميات ، الرجاء المحاولة مرة أخرى."
"ASSIGN_FAILED": "فشل في تعيين التسميات ، الرجاء المحاولة مرة أخرى.",
"REMOVE_SUCCESFUL": "Labels removed successfully.",
"REMOVE_FAILED": "Failed to remove labels. Please try again."
},
"TEAMS": {
"TEAM_SELECT_LABEL": "اختر فريق",
"NONE": "لا شيء",
"NO_TEAMS_AVAILABLE": "لا توجد فرق مضافة إلى هذا الحساب حتى الآن.",
"ASSIGN_SELECTED_TEAMS": "تعيين فريق محدد.",
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"ASSIGN_SUCCESFUL": "تم تعيين الفرق بنجاح.",
@@ -88,6 +88,7 @@
},
"CARD": {
"STATUS": {
"PROCESSING": "Processing",
"COMPLETED": "مكتمل",
"SCHEDULED": "Scheduled"
},
@@ -146,6 +147,7 @@
},
"CARD": {
"STATUS": {
"PROCESSING": "Processing",
"COMPLETED": "مكتمل",
"SCHEDULED": "Scheduled"
},
@@ -63,6 +63,7 @@
"CODE": "Code",
"BULLET_LIST": "Bullet List",
"ORDERED_LIST": "Ordered List",
"TABLE": "Table"
"TABLE": "Table",
"IMAGE": "Image"
}
}
@@ -20,6 +20,8 @@
"CALL": "Call",
"CALL_INITIATED": "جار الاتصال بجهة الاتصال…",
"CALL_FAILED": "تعذر بدء المكالمة. الرجاء المحاولة مرة أخرى.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"CLICK_TO_EDIT": "Click to edit",
"VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox"
@@ -583,8 +585,11 @@
},
"CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "تعيين التسميات",
"REMOVE_LABELS": "Remove Labels",
"ASSIGN_LABELS_SUCCESS": "تم تعيين التسميات بنجاح.",
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
"REMOVE_LABELS_SUCCESS": "Labels removed successfully.",
"REMOVE_LABELS_FAILED": "Failed to remove labels",
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
@@ -62,6 +62,7 @@
"UNSUPPORTED_MESSAGE_FACEBOOK": "هذه الرسالة غير مدعومة، يمكنك مشاهدة هذه الرسالة على تطبيق فيسبوك (Messenger).",
"UNSUPPORTED_MESSAGE_INSTAGRAM": "هذه الرسالة غير مدعومة، يمكنك عرض هذه الرسالة على تطبيق Instagram.",
"UNSUPPORTED_MESSAGE_TIKTOK": "هذه الرسالة غير مدعومة. يمكنك مشاهدة هذه الرسالة على تطبيق TikTok.",
"UNSUPPORTED_MESSAGE_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
"SUCCESS_DELETE_MESSAGE": "تم حذف الرسالة بنجاح",
"FAIL_DELETE_MESSSAGE": "تعذر حذف الرسالة! حاول مرة أخرى",
"NO_RESPONSE": "لا توجد استجابة",
@@ -79,13 +80,22 @@
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NO_ANSWER": "No answer",
"NO_ANSWER_OUTBOUND_LABEL": "No answer",
"NO_ANSWER_OUTBOUND_SUBTEXT": "Contact didn't pick up",
"MISSED_CALL": "Missed call",
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
"CALL_ENDED": "Call ended",
"HANDLED_BY": "Handled by {agentName}",
"NOT_ANSWERED_YET": "Not answered yet",
"CALLING": "Calling…",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "انضم إلى المكالمة"
"JOIN_CALL": "انضم إلى المكالمة",
"CALL_BACK": "Call back",
"TRANSCRIPT_SHOW_MORE": "Show more",
"TRANSCRIPT_SHOW_LESS": "Show less"
},
"HEADER": {
"RESOLVE_ACTION": "حل المحادثة",
@@ -100,6 +110,12 @@
"SNOOZED_UNTIL_TOMORROW": "تأجيل حتى الغد",
"SNOOZED_UNTIL_NEXT_WEEK": "تأجيل حتى الأسبوع القادم",
"SNOOZED_UNTIL_NEXT_REPLY": "تأجيل حتى الرد التالي",
"WHATSAPP_CALL": "Start WhatsApp call",
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
"VOICE_CALL": "Start call",
"VOICE_CALL_FAILED": "Could not start the call.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"SLA_STATUS": {
"FRT": "FRT {status}",
"NRT": "NRT {status}",
@@ -215,6 +231,7 @@
"TIP_AUDIORECORDER_ICON": "تسجيل الصوت",
"TIP_AUDIORECORDER_PERMISSION": "السماح بالوصول إلى الصوت",
"TIP_AUDIORECORDER_ERROR": "تعذر فتح الصوت",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "اسحب و أسقط هنا للإرفاق",
"START_AUDIO_RECORDING": "بدء التسجيل الصوتي",
"STOP_AUDIO_RECORDING": "إيقاف التسجيل الصوتي",
@@ -299,8 +316,13 @@
"NOT_ANSWERED_YET": "لم يتم الرد بعد",
"HANDLED_IN_ANOTHER_TAB": "يتم التعامل معها في علامة تبويب أخرى",
"REJECT_CALL": "رفض",
"DISMISS_CALL": "تجاهل",
"JOIN_CALL": "انضم إلى المكالمة",
"END_CALL": "إنهاء المكالمة"
"END_CALL": "إنهاء المكالمة",
"MUTE": "Mute mic",
"UNMUTE": "Unmute mic",
"VIEW_CHAT_HISTORY": "View chat history",
"GO_TO_CONVERSATION": "Go to conversation thread"
}
},
"EMAIL_TRANSCRIPT": {
@@ -607,9 +607,12 @@
"DRAFT": "مسودة",
"ARCHIVE": "Archive",
"TRANSLATE": "ترجم",
"MOVE_TO_CATEGORY": "الفئة",
"DELETE": "حذف",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
"CATEGORY_SUCCESS": "Articles moved successfully",
"CATEGORY_ERROR": "Failed to move articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
"DELETE_CONFIRM": "حذف",
@@ -866,6 +869,28 @@
},
"EDIT_CONFIGURATION": "Edit configuration"
},
"LAYOUT_CONTENT": {
"HEADER": "المظهر",
"DESCRIPTION": "Pick the layout that fits how your visitors read.",
"LAYOUT": {
"CLASSIC": {
"TITLE": "Classic",
"DESCRIPTION": "A welcoming home page with search and featured topics."
},
"SIDEBAR": {
"TITLE": "Documentation",
"DESCRIPTION": "Side-by-side navigation that keeps every guide a click away."
}
},
"SOCIAL_LINKS": {
"HEADER": "Social links",
"DESCRIPTION": "Add the handle for each network and your help center builds the full link. Shown in the documentation layout footer.",
"PLACEHOLDER": "handle",
"ADD": "Add social link",
"REMOVE": "حذف"
},
"SAVE": "Save changes"
},
"API": {
"CREATE_PORTAL": {
"SUCCESS_MESSAGE": "تم إنشاء البوابة بنجاح",
@@ -308,6 +308,7 @@
"AUTH_PROCESSING": "Authenticating with Meta",
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
"PROCESSING": "Setting up your WhatsApp Business Account",
"ENABLING_CALLING": "Enabling WhatsApp Calling on your number…",
"LOADING_SDK": "Loading Facebook SDK...",
"CANCELLED": "WhatsApp Signup was cancelled",
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
@@ -317,7 +318,8 @@
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} flow",
"MANUAL_LINK_TEXT": "manual setup flow"
"MANUAL_LINK_TEXT": "manual setup flow",
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
},
"API": {
"ERROR_MESSAGE": "لم نتمكن من حفظ قناة واتساب"
@@ -465,6 +467,10 @@
"TITLE": "واتساب",
"DESCRIPTION": "Support your customers on WhatsApp"
},
"WHATSAPP_CALL": {
"TITLE": "WhatsApp Call",
"DESCRIPTION": "Take voice calls on your WhatsApp number"
},
"EMAIL": {
"TITLE": "البريد الإلكتروني",
"DESCRIPTION": "Connect with Gmail, Outlook, or other providers"
@@ -637,7 +643,8 @@
"BOT_CONFIGURATION": "اعدادات البوت",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "تقييم رضاء العملاء",
"VOICE": "Voice"
"VOICE": "Voice",
"CALLS": "Calls"
},
"VOICE_CONFIGURATION": {
"ENABLE_VOICE": {
@@ -648,6 +655,26 @@
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
}
},
"WHATSAPP_CALLING": {
"ENABLE": {
"LABEL": "Enable WhatsApp Calling",
"DESCRIPTION": "Allow agents to receive and place WhatsApp Cloud calls on this inbox. Customers can call this business number directly from WhatsApp."
},
"ENABLE_FAILED": "Voice calling couldn't be turned on for this number — it isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then try again.",
"PHONE_NUMBER": {
"LABEL": "Business phone number",
"HELP_TEXT": "WhatsApp number that customers will call."
},
"HOW_IT_WORKS": {
"LABEL": "How it works",
"DESCRIPTION": "Calls are placed peer-to-peer between the agent's browser and Meta — no extra credentials are required. Make sure the agent's browser has microphone permission for this site."
},
"PERMISSION_REQUEST_BODY": {
"LABEL": "Call permission request message",
"HELP_TEXT": "Shown to the contact when they haven't yet consented to receive calls. Leave blank to use the default.",
"PLACEHOLDER": "We would like to call you regarding your conversation."
}
},
"CHANNEL_PREFERENCES": "Channel Preferences",
"WIDGET_FEATURES": "Widget features",
"ACCOUNT_HEALTH": {
@@ -800,6 +827,10 @@
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
"WHATSAPP_CALLING_ENABLED": {
"LABEL": "Enable voice calling",
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
},
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
},
"HELP_CENTER": {
@@ -57,7 +57,8 @@
"CONTACT_CREATED": "Contact created",
"CONTACT_UPDATED": "Contact updated",
"CONVERSATION_TYPING_ON": "Conversation Typing On",
"CONVERSATION_TYPING_OFF": "Conversation Typing Off"
"CONVERSATION_TYPING_OFF": "Conversation Typing Off",
"INBOX_UPDATED": "Inbox updated"
}
},
"NAME": {
@@ -1,11 +1,6 @@
{
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "{conversationCount} conversations selected",
"AGENT_SELECT_LABEL": "Select agent",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {conversationCount} {conversationLabel} to",
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assign",
"NONE": "Heç biri",
"CLEAR_SELECTION": "Clear",
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
@@ -20,7 +15,6 @@
"RESOLVE_SUCCESFUL": "Conversations resolved successfully.",
"RESOLVE_FAILED": "Failed to resolve conversations. Please try again.",
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
"AGENT_LIST_LOADING": "Loading agents",
"UPDATE": {
"CHANGE_STATUS": "Change status",
"SNOOZE_UNTIL": "Snooze",
@@ -33,16 +27,16 @@
},
"LABELS": {
"ASSIGN_LABELS": "Assign labels",
"NO_LABELS_FOUND": "No labels found",
"REMOVE_LABELS": "Remove labels",
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
"REMOVE_SELECTED_LABELS": "Remove selected labels",
"ASSIGN_SUCCESFUL": "Labels assigned successfully.",
"ASSIGN_FAILED": "Failed to assign labels. Please try again."
"ASSIGN_FAILED": "Failed to assign labels. Please try again.",
"REMOVE_SUCCESFUL": "Labels removed successfully.",
"REMOVE_FAILED": "Failed to remove labels. Please try again."
},
"TEAMS": {
"TEAM_SELECT_LABEL": "Select team",
"NONE": "None",
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"ASSIGN_SUCCESFUL": "Teams assigned successfully.",
@@ -88,6 +88,7 @@
},
"CARD": {
"STATUS": {
"PROCESSING": "Processing",
"COMPLETED": "Completed",
"SCHEDULED": "Scheduled"
},
@@ -146,6 +147,7 @@
},
"CARD": {
"STATUS": {
"PROCESSING": "Processing",
"COMPLETED": "Completed",
"SCHEDULED": "Scheduled"
},
@@ -63,6 +63,7 @@
"CODE": "Code",
"BULLET_LIST": "Bullet List",
"ORDERED_LIST": "Ordered List",
"TABLE": "Table"
"TABLE": "Table",
"IMAGE": "Image"
}
}
@@ -20,6 +20,8 @@
"CALL": "Zəng et",
"CALL_INITIATED": "Calling the contact…",
"CALL_FAILED": "Unable to start the call. Please try again.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"CLICK_TO_EDIT": "Click to edit",
"VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox"
@@ -583,8 +585,11 @@
},
"CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "Assign Labels",
"REMOVE_LABELS": "Remove Labels",
"ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.",
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
"REMOVE_LABELS_SUCCESS": "Labels removed successfully.",
"REMOVE_LABELS_FAILED": "Failed to remove labels",
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} seçildi",
@@ -62,6 +62,7 @@
"UNSUPPORTED_MESSAGE_FACEBOOK": "Bu mesaj dəstəklənmir. Bu mesajı Facebook Messenger tətbiqində görə bilərsiniz.",
"UNSUPPORTED_MESSAGE_INSTAGRAM": "Bu mesaj dəstəklənmir. Bu mesajı Instagram tətbiqində görə bilərsiniz.",
"UNSUPPORTED_MESSAGE_TIKTOK": "Bu mesaj dəstəklənmir. Bu mesajı TikTok tətbiqində görə bilərsiniz.",
"UNSUPPORTED_MESSAGE_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
"SUCCESS_DELETE_MESSAGE": "Mesaj uğurla silindi",
"FAIL_DELETE_MESSSAGE": "Mesajı silmək mümkün olmadı! Yenidən cəhd edin",
"NO_RESPONSE": "Cavab yoxdur",
@@ -79,13 +80,22 @@
"OUTGOING_CALL": "Gedən zəng",
"CALL_IN_PROGRESS": "Zəng davam edir",
"NO_ANSWER": "Cavab yoxdur",
"NO_ANSWER_OUTBOUND_LABEL": "Cavab yoxdur",
"NO_ANSWER_OUTBOUND_SUBTEXT": "Contact didn't pick up",
"MISSED_CALL": "Qeyri-işlək zəng",
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
"CALL_ENDED": "Zəng bitdi",
"HANDLED_BY": "Handled by {agentName}",
"NOT_ANSWERED_YET": "Hələ cavab verilməyib",
"CALLING": "Calling…",
"THEY_ANSWERED": "Onlar cavab verdi",
"YOU_ANSWERED": "Siz cavab verdiniz",
"AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "Zəngə qoşul"
"JOIN_CALL": "Zəngə qoşul",
"CALL_BACK": "Call back",
"TRANSCRIPT_SHOW_MORE": "Show more",
"TRANSCRIPT_SHOW_LESS": "Show less"
},
"HEADER": {
"RESOLVE_ACTION": "Həll et",
@@ -100,6 +110,12 @@
"SNOOZED_UNTIL_TOMORROW": "Sabaha qədər təxirə salındı",
"SNOOZED_UNTIL_NEXT_WEEK": "Gələn həftəyə qədər təxirə salındı",
"SNOOZED_UNTIL_NEXT_REPLY": "Növbəti cavaba qədər təxirə salındı",
"WHATSAPP_CALL": "Start WhatsApp call",
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
"VOICE_CALL": "Start call",
"VOICE_CALL_FAILED": "Could not start the call.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"SLA_STATUS": {
"FRT": "FRT {status}",
"NRT": "NRT {status}",
@@ -215,6 +231,7 @@
"TIP_AUDIORECORDER_ICON": "Səs yaz",
"TIP_AUDIORECORDER_PERMISSION": "Səsə girişə icazə ver",
"TIP_AUDIORECORDER_ERROR": "Səsi açmaq mümkün olmadı",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Qoşmaq üçün buraya sürükləyin və buraxın",
"START_AUDIO_RECORDING": "Səs yazısını başla",
"STOP_AUDIO_RECORDING": "Səs yazısını dayandırın",
@@ -299,8 +316,13 @@
"NOT_ANSWERED_YET": "Hələ cavab verilməyib",
"HANDLED_IN_ANOTHER_TAB": "Başqa sekmədə işlənir",
"REJECT_CALL": "İmtina et",
"DISMISS_CALL": "Dismiss",
"JOIN_CALL": "Zəngə qoşul",
"END_CALL": "Zəngi bitir"
"END_CALL": "Zəngi bitir",
"MUTE": "Mute mic",
"UNMUTE": "Unmute mic",
"VIEW_CHAT_HISTORY": "View chat history",
"GO_TO_CONVERSATION": "Go to conversation thread"
}
},
"EMAIL_TRANSCRIPT": {
@@ -607,9 +607,12 @@
"DRAFT": "Qaralama",
"ARCHIVE": "Archive",
"TRANSLATE": "Tərcümə et",
"MOVE_TO_CATEGORY": "Category",
"DELETE": "Delete",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
"CATEGORY_SUCCESS": "Articles moved successfully",
"CATEGORY_ERROR": "Failed to move articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
"DELETE_CONFIRM": "Delete",
@@ -866,6 +869,28 @@
},
"EDIT_CONFIGURATION": "Edit configuration"
},
"LAYOUT_CONTENT": {
"HEADER": "Appearance",
"DESCRIPTION": "Pick the layout that fits how your visitors read.",
"LAYOUT": {
"CLASSIC": {
"TITLE": "Classic",
"DESCRIPTION": "A welcoming home page with search and featured topics."
},
"SIDEBAR": {
"TITLE": "Documentation",
"DESCRIPTION": "Side-by-side navigation that keeps every guide a click away."
}
},
"SOCIAL_LINKS": {
"HEADER": "Social links",
"DESCRIPTION": "Add the handle for each network and your help center builds the full link. Shown in the documentation layout footer.",
"PLACEHOLDER": "handle",
"ADD": "Add social link",
"REMOVE": "Sil"
},
"SAVE": "Save changes"
},
"API": {
"CREATE_PORTAL": {
"SUCCESS_MESSAGE": "Portal created successfully",
@@ -308,6 +308,7 @@
"AUTH_PROCESSING": "Meta ilə autentifikasiya olunur",
"WAITING_FOR_BUSINESS_INFO": "Zəhmət olmasa Meta pəncərəsində biznes quraşdırmasını tamamlayın...",
"PROCESSING": "WhatsApp Biznes Hesabınızı quraşdırırıq",
"ENABLING_CALLING": "Enabling WhatsApp Calling on your number…",
"LOADING_SDK": "Facebook SDK yüklənir...",
"CANCELLED": "WhatsApp Qeydiyyatı ləğv edildi",
"SUCCESS_TITLE": "WhatsApp Biznes Hesabı Qoşuldu!",
@@ -317,7 +318,8 @@
"AUTH_NOT_COMPLETED": "Autentifikasiya tamamlanmadı. Zəhmət olmasa prosesi yenidən başladın.",
"SUCCESS_FALLBACK": "WhatsApp Biznes Hesabı uğurla konfiqurasiya edildi",
"MANUAL_FALLBACK": "Əgər nömrəniz artıq WhatsApp Business Platformasına (API) qoşulubsa və ya texnoloji təminatçı olaraq öz nömrənizi əlavə edirsinizsə, zəhmət olmasa {link} prosesindən istifadə edin",
"MANUAL_LINK_TEXT": "əl ilə qurma prosesi"
"MANUAL_LINK_TEXT": "əl ilə qurma prosesi",
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
},
"API": {
"ERROR_MESSAGE": "WhatsApp kanalını yadda saxlaya bilmədik"
@@ -465,6 +467,10 @@
"TITLE": "WhatsApp",
"DESCRIPTION": "Müştərilərinizi WhatsApp-da dəstəkləyin"
},
"WHATSAPP_CALL": {
"TITLE": "WhatsApp Call",
"DESCRIPTION": "Take voice calls on your WhatsApp number"
},
"EMAIL": {
"TITLE": "Elektron Poçt",
"DESCRIPTION": "Gmail, Outlook və ya digər təminatçılarla qoşulun"
@@ -637,7 +643,8 @@
"BOT_CONFIGURATION": "Bot Konfiqurasiyası",
"ACCOUNT_HEALTH": "Hesabın sağlamlığı",
"CSAT": "CSAT",
"VOICE": "Səs"
"VOICE": "Səs",
"CALLS": "Calls"
},
"VOICE_CONFIGURATION": {
"ENABLE_VOICE": {
@@ -648,6 +655,26 @@
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
}
},
"WHATSAPP_CALLING": {
"ENABLE": {
"LABEL": "Enable WhatsApp Calling",
"DESCRIPTION": "Allow agents to receive and place WhatsApp Cloud calls on this inbox. Customers can call this business number directly from WhatsApp."
},
"ENABLE_FAILED": "Voice calling couldn't be turned on for this number — it isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then try again.",
"PHONE_NUMBER": {
"LABEL": "Business phone number",
"HELP_TEXT": "WhatsApp number that customers will call."
},
"HOW_IT_WORKS": {
"LABEL": "How it works",
"DESCRIPTION": "Calls are placed peer-to-peer between the agent's browser and Meta — no extra credentials are required. Make sure the agent's browser has microphone permission for this site."
},
"PERMISSION_REQUEST_BODY": {
"LABEL": "Call permission request message",
"HELP_TEXT": "Shown to the contact when they haven't yet consented to receive calls. Leave blank to use the default.",
"PLACEHOLDER": "We would like to call you regarding your conversation."
}
},
"CHANNEL_PREFERENCES": "Kanal üstünlükləri",
"WIDGET_FEATURES": "Widget xüsusiyyətləri",
"ACCOUNT_HEALTH": {
@@ -800,6 +827,10 @@
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Mövcud şablonlarınızı yeniləmək üçün WhatsApp-dan mesaj şablonlarını əl ilə sinxronlaşdırın.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Şablonları sinxronlaşdır",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Şablonların sinxronizasiyası uğurla başlandı. Yenilənməsi bir neçə dəqiqə çəkə bilər.",
"WHATSAPP_CALLING_ENABLED": {
"LABEL": "Enable voice calling",
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
},
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Pre Chat Form Parametrlərini Yeniləyin"
},
"HELP_CENTER": {
@@ -57,7 +57,8 @@
"CONTACT_CREATED": "Əlaqə yaradıldı",
"CONTACT_UPDATED": "Əlaqə yeniləndi",
"CONVERSATION_TYPING_ON": "Söhbət Yazılır",
"CONVERSATION_TYPING_OFF": "Söhbət Yazması Söndürülüb"
"CONVERSATION_TYPING_OFF": "Söhbət Yazması Söndürülüb",
"INBOX_UPDATED": "Inbox updated"
}
},
"NAME": {
@@ -1,11 +1,6 @@
{
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "{conversationCount} conversations selected",
"AGENT_SELECT_LABEL": "Изберете агент",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {conversationCount} {conversationLabel} to",
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assign",
"NONE": "None",
"CLEAR_SELECTION": "Clear",
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
@@ -20,7 +15,6 @@
"RESOLVE_SUCCESFUL": "Conversations resolved successfully.",
"RESOLVE_FAILED": "Failed to resolve conversations. Please try again.",
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
"AGENT_LIST_LOADING": "Loading agents",
"UPDATE": {
"CHANGE_STATUS": "Change status",
"SNOOZE_UNTIL": "Snooze",
@@ -33,16 +27,16 @@
},
"LABELS": {
"ASSIGN_LABELS": "Assign labels",
"NO_LABELS_FOUND": "Няма намерени етикети",
"REMOVE_LABELS": "Remove labels",
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
"REMOVE_SELECTED_LABELS": "Remove selected labels",
"ASSIGN_SUCCESFUL": "Labels assigned successfully.",
"ASSIGN_FAILED": "Failed to assign labels. Please try again."
"ASSIGN_FAILED": "Failed to assign labels. Please try again.",
"REMOVE_SUCCESFUL": "Labels removed successfully.",
"REMOVE_FAILED": "Failed to remove labels. Please try again."
},
"TEAMS": {
"TEAM_SELECT_LABEL": "Изберете екип",
"NONE": "Нито един",
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"ASSIGN_SUCCESFUL": "Teams assigned successfully.",
@@ -88,6 +88,7 @@
},
"CARD": {
"STATUS": {
"PROCESSING": "Processing",
"COMPLETED": "Завършено",
"SCHEDULED": "Scheduled"
},
@@ -146,6 +147,7 @@
},
"CARD": {
"STATUS": {
"PROCESSING": "Processing",
"COMPLETED": "Завършено",
"SCHEDULED": "Scheduled"
},
@@ -63,6 +63,7 @@
"CODE": "Code",
"BULLET_LIST": "Bullet List",
"ORDERED_LIST": "Ordered List",
"TABLE": "Table"
"TABLE": "Table",
"IMAGE": "Image"
}
}
@@ -20,6 +20,8 @@
"CALL": "Call",
"CALL_INITIATED": "Calling the contact…",
"CALL_FAILED": "Unable to start the call. Please try again.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"CLICK_TO_EDIT": "Click to edit",
"VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox"
@@ -583,8 +585,11 @@
},
"CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "Assign Labels",
"REMOVE_LABELS": "Remove Labels",
"ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.",
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
"REMOVE_LABELS_SUCCESS": "Labels removed successfully.",
"REMOVE_LABELS_FAILED": "Failed to remove labels",
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
@@ -62,6 +62,7 @@
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
"UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok app.",
"UNSUPPORTED_MESSAGE_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
"NO_RESPONSE": "No response",
@@ -79,13 +80,22 @@
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NO_ANSWER": "No answer",
"NO_ANSWER_OUTBOUND_LABEL": "No answer",
"NO_ANSWER_OUTBOUND_SUBTEXT": "Contact didn't pick up",
"MISSED_CALL": "Missed call",
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
"CALL_ENDED": "Call ended",
"HANDLED_BY": "Handled by {agentName}",
"NOT_ANSWERED_YET": "Not answered yet",
"CALLING": "Calling…",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "Join call"
"JOIN_CALL": "Join call",
"CALL_BACK": "Call back",
"TRANSCRIPT_SHOW_MORE": "Show more",
"TRANSCRIPT_SHOW_LESS": "Show less"
},
"HEADER": {
"RESOLVE_ACTION": "Resolve",
@@ -100,6 +110,12 @@
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply",
"WHATSAPP_CALL": "Start WhatsApp call",
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
"VOICE_CALL": "Start call",
"VOICE_CALL_FAILED": "Could not start the call.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"SLA_STATUS": {
"FRT": "FRT {status}",
"NRT": "NRT {status}",
@@ -215,6 +231,7 @@
"TIP_AUDIORECORDER_ICON": "Record audio",
"TIP_AUDIORECORDER_PERMISSION": "Allow access to audio",
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Drag and drop here to attach",
"START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording",
@@ -299,8 +316,13 @@
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"DISMISS_CALL": "Dismiss",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
"END_CALL": "End call",
"MUTE": "Mute mic",
"UNMUTE": "Unmute mic",
"VIEW_CHAT_HISTORY": "View chat history",
"GO_TO_CONVERSATION": "Go to conversation thread"
}
},
"EMAIL_TRANSCRIPT": {
@@ -607,9 +607,12 @@
"DRAFT": "Draft",
"ARCHIVE": "Archive",
"TRANSLATE": "Translate",
"MOVE_TO_CATEGORY": "Category",
"DELETE": "Изтрий",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
"CATEGORY_SUCCESS": "Articles moved successfully",
"CATEGORY_ERROR": "Failed to move articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
"DELETE_CONFIRM": "Изтрий",
@@ -866,6 +869,28 @@
},
"EDIT_CONFIGURATION": "Edit configuration"
},
"LAYOUT_CONTENT": {
"HEADER": "Appearance",
"DESCRIPTION": "Pick the layout that fits how your visitors read.",
"LAYOUT": {
"CLASSIC": {
"TITLE": "Classic",
"DESCRIPTION": "A welcoming home page with search and featured topics."
},
"SIDEBAR": {
"TITLE": "Documentation",
"DESCRIPTION": "Side-by-side navigation that keeps every guide a click away."
}
},
"SOCIAL_LINKS": {
"HEADER": "Social links",
"DESCRIPTION": "Add the handle for each network and your help center builds the full link. Shown in the documentation layout footer.",
"PLACEHOLDER": "handle",
"ADD": "Add social link",
"REMOVE": "Remove"
},
"SAVE": "Save changes"
},
"API": {
"CREATE_PORTAL": {
"SUCCESS_MESSAGE": "Portal created successfully",
@@ -308,6 +308,7 @@
"AUTH_PROCESSING": "Authenticating with Meta",
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
"PROCESSING": "Setting up your WhatsApp Business Account",
"ENABLING_CALLING": "Enabling WhatsApp Calling on your number…",
"LOADING_SDK": "Loading Facebook SDK...",
"CANCELLED": "WhatsApp Signup was cancelled",
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
@@ -317,7 +318,8 @@
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} flow",
"MANUAL_LINK_TEXT": "manual setup flow"
"MANUAL_LINK_TEXT": "manual setup flow",
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
},
"API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
@@ -465,6 +467,10 @@
"TITLE": "WhatsApp",
"DESCRIPTION": "Support your customers on WhatsApp"
},
"WHATSAPP_CALL": {
"TITLE": "WhatsApp Call",
"DESCRIPTION": "Take voice calls on your WhatsApp number"
},
"EMAIL": {
"TITLE": "Email",
"DESCRIPTION": "Connect with Gmail, Outlook, or other providers"
@@ -637,7 +643,8 @@
"BOT_CONFIGURATION": "Bot Configuration",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "CSAT",
"VOICE": "Voice"
"VOICE": "Voice",
"CALLS": "Calls"
},
"VOICE_CONFIGURATION": {
"ENABLE_VOICE": {
@@ -648,6 +655,26 @@
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
}
},
"WHATSAPP_CALLING": {
"ENABLE": {
"LABEL": "Enable WhatsApp Calling",
"DESCRIPTION": "Allow agents to receive and place WhatsApp Cloud calls on this inbox. Customers can call this business number directly from WhatsApp."
},
"ENABLE_FAILED": "Voice calling couldn't be turned on for this number — it isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then try again.",
"PHONE_NUMBER": {
"LABEL": "Business phone number",
"HELP_TEXT": "WhatsApp number that customers will call."
},
"HOW_IT_WORKS": {
"LABEL": "How it works",
"DESCRIPTION": "Calls are placed peer-to-peer between the agent's browser and Meta — no extra credentials are required. Make sure the agent's browser has microphone permission for this site."
},
"PERMISSION_REQUEST_BODY": {
"LABEL": "Call permission request message",
"HELP_TEXT": "Shown to the contact when they haven't yet consented to receive calls. Leave blank to use the default.",
"PLACEHOLDER": "We would like to call you regarding your conversation."
}
},
"CHANNEL_PREFERENCES": "Channel Preferences",
"WIDGET_FEATURES": "Widget features",
"ACCOUNT_HEALTH": {
@@ -800,6 +827,10 @@
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
"WHATSAPP_CALLING_ENABLED": {
"LABEL": "Enable voice calling",
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
},
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
},
"HELP_CENTER": {
@@ -57,7 +57,8 @@
"CONTACT_CREATED": "Contact created",
"CONTACT_UPDATED": "Contact updated",
"CONVERSATION_TYPING_ON": "Conversation Typing On",
"CONVERSATION_TYPING_OFF": "Conversation Typing Off"
"CONVERSATION_TYPING_OFF": "Conversation Typing Off",
"INBOX_UPDATED": "Inbox updated"
}
},
"NAME": {
@@ -1,11 +1,6 @@
{
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "{conversationCount} conversations selected",
"AGENT_SELECT_LABEL": "Select agent",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {conversationCount} {conversationLabel} to",
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assign",
"NONE": "কিছুই না",
"CLEAR_SELECTION": "Clear",
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
@@ -20,7 +15,6 @@
"RESOLVE_SUCCESFUL": "Conversations resolved successfully.",
"RESOLVE_FAILED": "Failed to resolve conversations. Please try again.",
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
"AGENT_LIST_LOADING": "Loading agents",
"UPDATE": {
"CHANGE_STATUS": "Change status",
"SNOOZE_UNTIL": "Snooze",
@@ -33,16 +27,16 @@
},
"LABELS": {
"ASSIGN_LABELS": "Assign labels",
"NO_LABELS_FOUND": "No labels found",
"REMOVE_LABELS": "Remove labels",
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
"REMOVE_SELECTED_LABELS": "Remove selected labels",
"ASSIGN_SUCCESFUL": "Labels assigned successfully.",
"ASSIGN_FAILED": "Failed to assign labels. Please try again."
"ASSIGN_FAILED": "Failed to assign labels. Please try again.",
"REMOVE_SUCCESFUL": "Labels removed successfully.",
"REMOVE_FAILED": "Failed to remove labels. Please try again."
},
"TEAMS": {
"TEAM_SELECT_LABEL": "Select team",
"NONE": "None",
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"ASSIGN_SUCCESFUL": "Teams assigned successfully.",
@@ -88,6 +88,7 @@
},
"CARD": {
"STATUS": {
"PROCESSING": "প্রক্রিয়াধীন",
"COMPLETED": "Completed",
"SCHEDULED": "Scheduled"
},
@@ -146,6 +147,7 @@
},
"CARD": {
"STATUS": {
"PROCESSING": "প্রক্রিয়াধীন",
"COMPLETED": "Completed",
"SCHEDULED": "Scheduled"
},
@@ -63,6 +63,7 @@
"CODE": "Code",
"BULLET_LIST": "Bullet List",
"ORDERED_LIST": "Ordered List",
"TABLE": "Table"
"TABLE": "Table",
"IMAGE": "Image"
}
}
@@ -20,6 +20,8 @@
"CALL": "Call",
"CALL_INITIATED": "Calling the contact…",
"CALL_FAILED": "Unable to start the call. Please try again.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"CLICK_TO_EDIT": "Click to edit",
"VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox"
@@ -583,8 +585,11 @@
},
"CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "Assign Labels",
"REMOVE_LABELS": "Remove Labels",
"ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.",
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
"REMOVE_LABELS_SUCCESS": "Labels removed successfully.",
"REMOVE_LABELS_FAILED": "Failed to remove labels",
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
@@ -62,6 +62,7 @@
"UNSUPPORTED_MESSAGE_FACEBOOK": "এই বার্তাটি সমর্থিত নয়। আপনি Facebook Messenger অ্যাপে এই বার্তাটি দেখতে পারেন।",
"UNSUPPORTED_MESSAGE_INSTAGRAM": "এই বার্তাটি সমর্থিত নয়। আপনি Instagram অ্যাপে এই বার্তাটি দেখতে পারেন।",
"UNSUPPORTED_MESSAGE_TIKTOK": "এই বার্তাটি সমর্থিত নয়। TikTok অ্যাপে এই বার্তাটি দেখতে পারেন।",
"UNSUPPORTED_MESSAGE_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
"SUCCESS_DELETE_MESSAGE": "বার্তা সফলভাবে মুছে ফেলা হয়েছে",
"FAIL_DELETE_MESSSAGE": "বার্তা মুছে ফেলা যায়নি! আবার চেষ্টা করুন",
"NO_RESPONSE": "কোনো উত্তর নেই",
@@ -79,13 +80,22 @@
"OUTGOING_CALL": "আউটগোয়িং কল",
"CALL_IN_PROGRESS": "কল চলছে",
"NO_ANSWER": "উত্তর নেই",
"NO_ANSWER_OUTBOUND_LABEL": "উত্তর নেই",
"NO_ANSWER_OUTBOUND_SUBTEXT": "Contact didn't pick up",
"MISSED_CALL": "মিসড কল",
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
"CALL_ENDED": "কল শেষ হয়েছে",
"HANDLED_BY": "Handled by {agentName}",
"NOT_ANSWERED_YET": "এখনও উত্তর পাওয়া যায়নি",
"CALLING": "Calling…",
"THEY_ANSWERED": "তারা উত্তর দিয়েছে",
"YOU_ANSWERED": "আপনি উত্তর দিয়েছেন",
"AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "কলে যোগ দিন"
"JOIN_CALL": "কলে যোগ দিন",
"CALL_BACK": "Call back",
"TRANSCRIPT_SHOW_MORE": "Show more",
"TRANSCRIPT_SHOW_LESS": "Show less"
},
"HEADER": {
"RESOLVE_ACTION": "সমাধান করুন",
@@ -100,6 +110,12 @@
"SNOOZED_UNTIL_TOMORROW": "আগামীকাল পর্যন্ত স্থগিত",
"SNOOZED_UNTIL_NEXT_WEEK": "পরবর্তী সপ্তাহ পর্যন্ত স্থগিত",
"SNOOZED_UNTIL_NEXT_REPLY": "পরবর্তী উত্তর পর্যন্ত স্থগিত",
"WHATSAPP_CALL": "Start WhatsApp call",
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
"VOICE_CALL": "Start call",
"VOICE_CALL_FAILED": "Could not start the call.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"SLA_STATUS": {
"FRT": "FRT {status}",
"NRT": "NRT {status}",
@@ -215,6 +231,7 @@
"TIP_AUDIORECORDER_ICON": "অডিও রেকর্ড করুন",
"TIP_AUDIORECORDER_PERMISSION": "অডিও অ্যাক্সেসের অনুমতি দিন",
"TIP_AUDIORECORDER_ERROR": "অডিও খোলা যায়নি",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "সংযুক্ত করতে এখানে ড্র্যাগ ও ড্রপ করুন",
"START_AUDIO_RECORDING": "অডিও রেকর্ডিং শুরু করুন",
"STOP_AUDIO_RECORDING": "অডিও রেকর্ডিং বন্ধ করুন",
@@ -299,8 +316,13 @@
"NOT_ANSWERED_YET": "এখনও উত্তর পাওয়া যায়নি",
"HANDLED_IN_ANOTHER_TAB": "অন্য ট্যাবে পরিচালিত হচ্ছে",
"REJECT_CALL": "প্রত্যাখ্যান করুন",
"DISMISS_CALL": "Dismiss",
"JOIN_CALL": "কলে যোগ দিন",
"END_CALL": "কল শেষ করুন"
"END_CALL": "কল শেষ করুন",
"MUTE": "Mute mic",
"UNMUTE": "Unmute mic",
"VIEW_CHAT_HISTORY": "View chat history",
"GO_TO_CONVERSATION": "Go to conversation thread"
}
},
"EMAIL_TRANSCRIPT": {
@@ -607,9 +607,12 @@
"DRAFT": "খসড়া",
"ARCHIVE": "আর্কাইভ",
"TRANSLATE": "অনুবাদ করুন",
"MOVE_TO_CATEGORY": "Category",
"DELETE": "মুছে ফেলুন",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
"CATEGORY_SUCCESS": "Articles moved successfully",
"CATEGORY_ERROR": "Failed to move articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
"DELETE_CONFIRM": "মুছে ফেলুন",
@@ -866,6 +869,28 @@
},
"EDIT_CONFIGURATION": "কনফিগারেশন সম্পাদনা করুন"
},
"LAYOUT_CONTENT": {
"HEADER": "Appearance",
"DESCRIPTION": "Pick the layout that fits how your visitors read.",
"LAYOUT": {
"CLASSIC": {
"TITLE": "Classic",
"DESCRIPTION": "A welcoming home page with search and featured topics."
},
"SIDEBAR": {
"TITLE": "Documentation",
"DESCRIPTION": "Side-by-side navigation that keeps every guide a click away."
}
},
"SOCIAL_LINKS": {
"HEADER": "Social links",
"DESCRIPTION": "Add the handle for each network and your help center builds the full link. Shown in the documentation layout footer.",
"PLACEHOLDER": "handle",
"ADD": "Add social link",
"REMOVE": "সরান"
},
"SAVE": "পরিবর্তন সংরক্ষণ করুন"
},
"API": {
"CREATE_PORTAL": {
"SUCCESS_MESSAGE": "পোর্টাল সফলভাবে তৈরি হয়েছে",
@@ -308,6 +308,7 @@
"AUTH_PROCESSING": "Meta-র সাথে প্রমাণীকরণ চলছে",
"WAITING_FOR_BUSINESS_INFO": "অনুগ্রহ করে Meta উইন্ডোতে ব্যবসার সেটআপ সম্পন্ন করুন...",
"PROCESSING": "আপনার WhatsApp Business Account সেটআপ করা হচ্ছে",
"ENABLING_CALLING": "Enabling WhatsApp Calling on your number…",
"LOADING_SDK": "Facebook SDK লোড হচ্ছে...",
"CANCELLED": "WhatsApp সাইনআপ বাতিল করা হয়েছে",
"SUCCESS_TITLE": "WhatsApp Business Account সংযুক্ত হয়েছে!",
@@ -317,7 +318,8 @@
"AUTH_NOT_COMPLETED": "প্রমাণীকরণ সম্পন্ন হয়নি। অনুগ্রহ করে প্রক্রিয়াটি পুনরায় শুরু করুন।.",
"SUCCESS_FALLBACK": "WhatsApp Business Account সফলভাবে কনফিগার করা হয়েছে",
"MANUAL_FALLBACK": "আপনার নম্বর যদি ইতিমধ্যে WhatsApp Business Platform (API)-এর সাথে সংযুক্ত থাকে, অথবা আপনি যদি নিজস্ব নম্বর অনবোর্ড করতে টেক প্রোভাইডার হন, তাহলে অনুগ্রহ করে {link} ফ্লো ব্যবহার করুন",
"MANUAL_LINK_TEXT": "ম্যানুয়াল সেটআপ ফ্লো"
"MANUAL_LINK_TEXT": "ম্যানুয়াল সেটআপ ফ্লো",
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
},
"API": {
"ERROR_MESSAGE": "আমরা WhatsApp চ্যানেল সংরক্ষণ করতে পারিনি"
@@ -465,6 +467,10 @@
"TITLE": "WhatsApp",
"DESCRIPTION": "WhatsApp-এ আপনার গ্রাহকদের সহায়তা দিন"
},
"WHATSAPP_CALL": {
"TITLE": "WhatsApp Call",
"DESCRIPTION": "Take voice calls on your WhatsApp number"
},
"EMAIL": {
"TITLE": "ইমেইল",
"DESCRIPTION": "Gmail, Outlook, অথবা অন্যান্য প্রদানকারীর সাথে সংযোগ করুন"
@@ -637,7 +643,8 @@
"BOT_CONFIGURATION": "বট কনফিগারেশন",
"ACCOUNT_HEALTH": "অ্যাকাউন্টের স্বাস্থ্য",
"CSAT": "CSAT",
"VOICE": "ভয়েস"
"VOICE": "ভয়েস",
"CALLS": "Calls"
},
"VOICE_CONFIGURATION": {
"ENABLE_VOICE": {
@@ -648,6 +655,26 @@
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
}
},
"WHATSAPP_CALLING": {
"ENABLE": {
"LABEL": "Enable WhatsApp Calling",
"DESCRIPTION": "Allow agents to receive and place WhatsApp Cloud calls on this inbox. Customers can call this business number directly from WhatsApp."
},
"ENABLE_FAILED": "Voice calling couldn't be turned on for this number — it isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then try again.",
"PHONE_NUMBER": {
"LABEL": "Business phone number",
"HELP_TEXT": "WhatsApp number that customers will call."
},
"HOW_IT_WORKS": {
"LABEL": "How it works",
"DESCRIPTION": "Calls are placed peer-to-peer between the agent's browser and Meta — no extra credentials are required. Make sure the agent's browser has microphone permission for this site."
},
"PERMISSION_REQUEST_BODY": {
"LABEL": "Call permission request message",
"HELP_TEXT": "Shown to the contact when they haven't yet consented to receive calls. Leave blank to use the default.",
"PLACEHOLDER": "We would like to call you regarding your conversation."
}
},
"CHANNEL_PREFERENCES": "চ্যানেল পছন্দসমূহ",
"WIDGET_FEATURES": "উইজেট ফিচারসমূহ",
"ACCOUNT_HEALTH": {
@@ -800,6 +827,10 @@
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "আপনার উপলব্ধ টেমপ্লেট আপডেট করতে WhatsApp থেকে মেসেজ টেমপ্লেটগুলো ম্যানুয়ালি সিঙ্ক করুন।.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "টেমপ্লেট সিঙ্ক করুন",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "টেমপ্লেট সিঙ্ক সফলভাবে শুরু হয়েছে। আপডেট হতে কয়েক মিনিট সময় লাগতে পারে।.",
"WHATSAPP_CALLING_ENABLED": {
"LABEL": "Enable voice calling",
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
},
"UPDATE_PRE_CHAT_FORM_SETTINGS": "প্রি-চ্যাট ফর্ম সেটিংস আপডেট করুন"
},
"HELP_CENTER": {
@@ -57,7 +57,8 @@
"CONTACT_CREATED": "যোগাযোগ তৈরি হয়েছে",
"CONTACT_UPDATED": "যোগাযোগ আপডেট হয়েছে",
"CONVERSATION_TYPING_ON": "কথোপকথন টাইপিং চালু আছে",
"CONVERSATION_TYPING_OFF": "কথোপকথন টাইপিং বন্ধ আছে"
"CONVERSATION_TYPING_OFF": "কথোপকথন টাইপিং বন্ধ আছে",
"INBOX_UPDATED": "Inbox updated"
}
},
"NAME": {
@@ -1,11 +1,6 @@
{
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "{conversationCount} converses seleccionades",
"AGENT_SELECT_LABEL": "Seleccionar Agent",
"ASSIGN_CONFIRMATION_LABEL": "Estas segur d'assignar {conversationCount} {conversationLabel} a",
"UNASSIGN_CONFIRMATION_LABEL": "Estàs segur de desassignar {conversationCount} {conversationLabel}?",
"GO_BACK_LABEL": "Torna",
"ASSIGN_LABEL": "Assignar",
"NONE": "Ningú",
"CLEAR_SELECTION": "Neteja",
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
@@ -20,7 +15,6 @@
"RESOLVE_SUCCESFUL": "Les converses s'han resolt correctament.",
"RESOLVE_FAILED": "No s'han pogut resoldre les converses. Torna-ho a provar.",
"ALL_CONVERSATIONS_SELECTED_ALERT": "Les converses visibles en aquesta pàgina només estan seleccionades.",
"AGENT_LIST_LOADING": "S'estan carregant els agents",
"UPDATE": {
"CHANGE_STATUS": "Canvia l'estat",
"SNOOZE_UNTIL": "Posposat",
@@ -33,16 +27,16 @@
},
"LABELS": {
"ASSIGN_LABELS": "Assignar etiquetes",
"NO_LABELS_FOUND": "No s'han trobat etiquetes",
"REMOVE_LABELS": "Remove labels",
"ASSIGN_SELECTED_LABELS": "Assigna les etiquetes seleccionades",
"REMOVE_SELECTED_LABELS": "Remove selected labels",
"ASSIGN_SUCCESFUL": "L'etiqueta s'ha assignat correctament.",
"ASSIGN_FAILED": "No s'han pogut assignar les etiquetes. Torna-ho a provar."
"ASSIGN_FAILED": "No s'han pogut assignar les etiquetes. Torna-ho a provar.",
"REMOVE_SUCCESFUL": "Labels removed successfully.",
"REMOVE_FAILED": "Failed to remove labels. Please try again."
},
"TEAMS": {
"TEAM_SELECT_LABEL": "Selecciona equip",
"NONE": "Ningú",
"NO_TEAMS_AVAILABLE": "Encara no hi ha cap equip afegit a aquest compte.",
"ASSIGN_SELECTED_TEAMS": "Assigna l'equip seleccionat.",
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"ASSIGN_SUCCESFUL": "Equips assignats correctament.",
@@ -88,6 +88,7 @@
},
"CARD": {
"STATUS": {
"PROCESSING": "Processing",
"COMPLETED": "Completat",
"SCHEDULED": "Scheduled"
},
@@ -146,6 +147,7 @@
},
"CARD": {
"STATUS": {
"PROCESSING": "Processing",
"COMPLETED": "Completat",
"SCHEDULED": "Scheduled"
},
@@ -63,6 +63,7 @@
"CODE": "Code",
"BULLET_LIST": "Bullet List",
"ORDERED_LIST": "Ordered List",
"TABLE": "Table"
"TABLE": "Table",
"IMAGE": "Image"
}
}
@@ -20,6 +20,8 @@
"CALL": "Call",
"CALL_INITIATED": "Calling the contact…",
"CALL_FAILED": "Unable to start the call. Please try again.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"CLICK_TO_EDIT": "Click to edit",
"VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox"
@@ -583,8 +585,11 @@
},
"CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "Assign Labels",
"REMOVE_LABELS": "Remove Labels",
"ASSIGN_LABELS_SUCCESS": "L'etiqueta s'ha assignat correctament.",
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
"REMOVE_LABELS_SUCCESS": "Labels removed successfully.",
"REMOVE_LABELS_FAILED": "Failed to remove labels",
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",

Some files were not shown because too many files have changed in this diff Show More