feat(voice): WhatsApp Cloud Calling — UI [6] (#14346)
## Summary Frontend for WhatsApp Cloud Calling: header / contact-panel call buttons, ringing widget, accept/reject/hangup, mute, in-bubble audio player + transcript, recording-on-hangup upload, mid-call reload warning. WebRTC is browser-direct to Meta — no media server bridge. ## Closes - https://linear.app/chatwoot/issue/PLA-150 ## How to test Requires backend support — the controller, services, model changes, and routes ship in **#14334** (`feature/pla-150`). Merge / deploy that first (or simultaneously); the FE alone won't function without those endpoints. Then on staging, for a WhatsApp Cloud + embedded-signup inbox with the new \`Configuration → Enable voice calling\` toggle ON and webhook registered: 1. **Outbound** — open a conversation, click the phone icon in the conversation header (or contact panel), grant mic, your phone rings, answer, audio both ways, hang up. Recording + transcript land in the bubble within ~10s. 2. **Inbound** — call the business number from your phone. The FloatingCallWidget appears bottom-right with caller name. Click accept, audio both ways, hang up. Recording + transcript appear. 3. **Mute** — during an active WhatsApp call, click the mic icon next to hangup. Speech stops reaching Meta until you click again. 4. **Mid-call reload guard** — try `Cmd-R` during an active call; browser shows a confirm prompt. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: iamsivin <iamsivin@gmail.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
iamsivin
Sivin Varghese
parent
b9757447a8
commit
c4a6a19e9b
@@ -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,71 @@ 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 isMissedInbound = computed(() => isFailed.value && !isOutbound.value);
|
||||
const endReason = computed(() => call.value?.endReason);
|
||||
const wasDeclinedByAgent = computed(
|
||||
() =>
|
||||
isMissedInbound.value &&
|
||||
endReason.value === VOICE_CALL_END_REASON.AGENT_REJECTED
|
||||
);
|
||||
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
|
||||
const didCurrentUserAnswer = computed(
|
||||
() =>
|
||||
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
|
||||
);
|
||||
// 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 conversationAssignee = computed(() => {
|
||||
const conversation = store.getters.getConversationById?.(
|
||||
conversationId?.value
|
||||
@@ -66,6 +111,19 @@ 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));
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
@@ -73,24 +131,25 @@ const labelKey = computed(() => {
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
}
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
if (isFailed.value) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL'
|
||||
: 'CONVERSATION.VOICE_CALL.MISSED_CALL';
|
||||
}
|
||||
return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
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');
|
||||
return isOutbound.value
|
||||
? t('CONVERSATION.VOICE_CALL.CALLING')
|
||||
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
|
||||
return formattedDuration.value;
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
|
||||
if (isOutbound.value) return null;
|
||||
if (didCurrentUserAnswer.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
||||
}
|
||||
@@ -99,34 +158,51 @@ const subtext = computed(() => {
|
||||
agentName: displayAgentName.value,
|
||||
});
|
||||
}
|
||||
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
|
||||
return null;
|
||||
}
|
||||
return isFailed.value
|
||||
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
|
||||
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
if (isFailed.value) {
|
||||
if (isOutbound.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT');
|
||||
}
|
||||
if (wasDeclinedByAgent.value && displayAgentName.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.MISSED_CALL_DECLINED_BY', {
|
||||
agentName: displayAgentName.value,
|
||||
});
|
||||
}
|
||||
return t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT');
|
||||
}
|
||||
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 +211,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 +239,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>
|
||||
|
||||
@@ -41,8 +41,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 +78,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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user