Merge branch 'develop' into feat/captain-credit-usage
This commit is contained in:
@@ -10,10 +10,13 @@ class WhatsappCallsAPI extends ApiClient {
|
||||
return axios.get(`${this.url}/${callId}`).then(r => r.data);
|
||||
}
|
||||
|
||||
initiate(conversationId, sdpOffer) {
|
||||
// Either conversationId, or contactId + inboxId to let the BE resolve the conversation.
|
||||
initiate({ conversationId, contactId, inboxId }, sdpOffer) {
|
||||
return axios
|
||||
.post(`${this.url}/initiate`, {
|
||||
conversation_id: conversationId,
|
||||
contact_id: contactId,
|
||||
inbox_id: inboxId,
|
||||
sdp_offer: sdpOffer,
|
||||
})
|
||||
.then(r => r.data);
|
||||
|
||||
@@ -16,7 +16,6 @@ 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';
|
||||
@@ -83,39 +82,18 @@ const navigateToConversation = conversationId => {
|
||||
|
||||
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);
|
||||
const response = await whatsappCallSession.initiateOutboundCall(
|
||||
conversationIdHint
|
||||
? { conversationId: conversationIdHint }
|
||||
: { contactId: props.contactId, inboxId }
|
||||
);
|
||||
// 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;
|
||||
|
||||
const conversationId = response?.conversation_id || conversationIdHint;
|
||||
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
|
||||
|
||||
@@ -234,6 +234,7 @@ onMounted(() => resetContacts());
|
||||
ref="popoverRef"
|
||||
:align="align"
|
||||
:show-content-border="false"
|
||||
:close-on-scroll="false"
|
||||
@show="onPopoverShow"
|
||||
@hide="onPopoverHide"
|
||||
>
|
||||
|
||||
@@ -263,9 +263,9 @@ const handleCallBack = async () => {
|
||||
if (!canCallBack.value || isInitiatingCall.value) return;
|
||||
try {
|
||||
if (isWhatsapp.value) {
|
||||
const response = await whatsappCallSession.initiateOutboundCall(
|
||||
conversationId.value
|
||||
);
|
||||
const response = await whatsappCallSession.initiateOutboundCall({
|
||||
conversationId: 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) {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core';
|
||||
import {
|
||||
useBreakpoints,
|
||||
breakpointsTailwind,
|
||||
useEventListener,
|
||||
} from '@vueuse/core';
|
||||
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
@@ -16,6 +20,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closeOnScroll: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showContentBorder: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
@@ -41,8 +49,12 @@ const { fixedPosition, updatePosition } = useDropdownPosition(
|
||||
{ align: props.align }
|
||||
);
|
||||
|
||||
const SCROLL_CLOSE_THRESHOLD = 24;
|
||||
const triggerTopAtOpen = ref(0);
|
||||
|
||||
const show = async () => {
|
||||
isActive.value = true;
|
||||
triggerTopAtOpen.value = triggerRef.value?.getBoundingClientRect().top ?? 0;
|
||||
if (!isMobile.value) {
|
||||
await nextTick();
|
||||
updatePosition();
|
||||
@@ -56,6 +68,22 @@ const hide = () => {
|
||||
emit('hide');
|
||||
};
|
||||
|
||||
// The teleported popover tracks its trigger while ancestors scroll; allow
|
||||
// small drift (trackpad inertia), but close once the trigger moves further.
|
||||
useEventListener(
|
||||
window,
|
||||
'scroll',
|
||||
event => {
|
||||
if (!props.closeOnScroll || !showPopover.value) return;
|
||||
if (popoverRef.value?.contains(event.target)) return;
|
||||
const top = triggerRef.value?.getBoundingClientRect().top ?? 0;
|
||||
if (Math.abs(top - triggerTopAtOpen.value) > SCROLL_CLOSE_THRESHOLD) {
|
||||
hide();
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: true }
|
||||
);
|
||||
|
||||
const toggle = async () => {
|
||||
if (isActive.value) hide();
|
||||
else await show();
|
||||
|
||||
@@ -69,9 +69,9 @@ const callButtonTooltip = computed(() =>
|
||||
const startWhatsappCall = async () => {
|
||||
if (whatsappCallSession.isInitiating.value) return;
|
||||
try {
|
||||
const response = await whatsappCallSession.initiateOutboundCall(
|
||||
props.chat.id
|
||||
);
|
||||
const response = await whatsappCallSession.initiateOutboundCall({
|
||||
conversationId: 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.
|
||||
|
||||
@@ -308,7 +308,8 @@ export function useWhatsappCallSession() {
|
||||
}
|
||||
};
|
||||
|
||||
const initiateOutboundCall = async conversationId => {
|
||||
// target: { conversationId } or { contactId, inboxId }
|
||||
const initiateOutboundCall = async target => {
|
||||
// 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
|
||||
@@ -320,10 +321,7 @@ export function useWhatsappCallSession() {
|
||||
isInitiatingOutbound.value = true;
|
||||
try {
|
||||
const sdpOffer = await prepareOutboundOffer();
|
||||
const response = await WhatsappCallsAPI.initiate(
|
||||
conversationId,
|
||||
sdpOffer
|
||||
);
|
||||
const response = await WhatsappCallsAPI.initiate(target, sdpOffer);
|
||||
if (response?.id) {
|
||||
activeCallId = response.id;
|
||||
// A connect webhook that raced ahead of this response was buffered;
|
||||
@@ -354,7 +352,7 @@ export function useWhatsappCallSession() {
|
||||
data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_REQUESTED ||
|
||||
data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
|
||||
) {
|
||||
return { status: data.status };
|
||||
return { status: data.status, conversation_id: data.conversation_id };
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
|
||||
@@ -7,8 +7,7 @@ export const FEATURE_FLAGS = {
|
||||
AUTOMATIONS: 'automations',
|
||||
CAMPAIGNS: 'campaigns',
|
||||
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
|
||||
WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION:
|
||||
'whatsapp_embedded_signup_inbox_creation',
|
||||
WHATSAPP_EMBEDDED_SIGNUP_FLOW: 'whatsapp_embedded_signup_inbox_creation',
|
||||
WHATSAPP_MANUAL_TRANSFER: 'whatsapp_manual_transfer',
|
||||
WHATSAPP_RECONFIGURE: 'whatsapp_reconfigure',
|
||||
CANNED_RESPONSES: 'canned_responses',
|
||||
|
||||
+1
-3
@@ -18,9 +18,7 @@ export function useChannelConfig() {
|
||||
// app id (not the 'none' sentinel) and the signup configuration id.
|
||||
whatsapp: () =>
|
||||
(!isOnChatwootCloud.value ||
|
||||
isCloudFeatureEnabled(
|
||||
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION
|
||||
)) &&
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW)) &&
|
||||
Boolean(installationConfig.whatsappAppId) &&
|
||||
installationConfig.whatsappAppId !== 'none' &&
|
||||
Boolean(installationConfig.whatsappConfigurationId),
|
||||
|
||||
@@ -390,6 +390,11 @@ export default {
|
||||
return (
|
||||
this.isAWhatsAppCloudChannel &&
|
||||
this.isEmbeddedSignupWhatsApp &&
|
||||
(!this.isOnChatwootCloud ||
|
||||
this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW
|
||||
)) &&
|
||||
this.inbox.reauthorization_required
|
||||
);
|
||||
},
|
||||
|
||||
@@ -42,9 +42,7 @@ const shouldShowWhatsappEmbeddedSignup = computed(() => {
|
||||
selectedProvider.value === PROVIDER_TYPES.WHATSAPP &&
|
||||
hasWhatsappAppId.value &&
|
||||
(!isOnChatwootCloud.value ||
|
||||
isCloudFeatureEnabled(
|
||||
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION
|
||||
))
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW))
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+4
-1
@@ -56,6 +56,7 @@ export default {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
}),
|
||||
isEmbeddedSignupWhatsApp() {
|
||||
return this.inbox.provider_config?.source === 'embedded_signup';
|
||||
@@ -65,7 +66,9 @@ export default {
|
||||
this.isEmbeddedSignupWhatsApp &&
|
||||
this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.WHATSAPP_RECONFIGURE
|
||||
this.isOnChatwootCloud
|
||||
? FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW
|
||||
: FEATURE_FLAGS.WHATSAPP_RECONFIGURE
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user