Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab0370fbe1 | ||
|
|
e415b0576a |
@@ -4,6 +4,8 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useStore } from 'vuex';
|
import { useStore } from 'vuex';
|
||||||
import { useMessageContext } from '../provider.js';
|
import { useMessageContext } from '../provider.js';
|
||||||
import { VOICE_CALL_STATUS } from '../constants';
|
import { VOICE_CALL_STATUS } from '../constants';
|
||||||
|
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||||
|
import { canCurrentUserJoinCall } from 'dashboard/helper/voice';
|
||||||
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||||
@@ -27,9 +29,19 @@ const BG_COLOR_MAP = {
|
|||||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const JOINABLE_STATUSES = [
|
||||||
|
VOICE_CALL_STATUS.RINGING,
|
||||||
|
VOICE_CALL_STATUS.IN_PROGRESS,
|
||||||
|
];
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { call, conversationId, currentUserId } = useMessageContext();
|
const { call, conversationId, currentUserId, inboxId, sender } =
|
||||||
|
useMessageContext();
|
||||||
|
const { activeCall, hasActiveCall, isJoining, joinCall, endCall } =
|
||||||
|
useCallSession({
|
||||||
|
manageSessionState: false,
|
||||||
|
});
|
||||||
|
|
||||||
const status = computed(() => call.value?.status);
|
const status = computed(() => call.value?.status);
|
||||||
const isOutbound = computed(() => call.value?.direction === 'outgoing');
|
const isOutbound = computed(() => call.value?.direction === 'outgoing');
|
||||||
@@ -104,11 +116,83 @@ const iconName = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||||
|
|
||||||
|
const callSid = computed(() => call.value?.provider_call_id);
|
||||||
|
const isAlreadyOnThisCall = computed(
|
||||||
|
() => !!callSid.value && activeCall.value?.callSid === callSid.value
|
||||||
|
);
|
||||||
|
const resolvedInboxId = computed(() => {
|
||||||
|
if (inboxId?.value) return inboxId.value;
|
||||||
|
const conversation = store.getters.getConversationById?.(
|
||||||
|
conversationId?.value
|
||||||
|
);
|
||||||
|
return conversation?.inbox_id || null;
|
||||||
|
});
|
||||||
|
const conversationForVisibility = computed(() =>
|
||||||
|
store.getters.getConversationById?.(conversationId?.value)
|
||||||
|
);
|
||||||
|
const isVisibleToCurrentUser = computed(() =>
|
||||||
|
canCurrentUserJoinCall({
|
||||||
|
call: call.value,
|
||||||
|
conversation: conversationForVisibility.value,
|
||||||
|
senderId: sender?.value?.id,
|
||||||
|
currentUserId: currentUserId?.value,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const canJoin = computed(
|
||||||
|
() =>
|
||||||
|
JOINABLE_STATUSES.includes(status.value) &&
|
||||||
|
!!callSid.value &&
|
||||||
|
!!resolvedInboxId.value &&
|
||||||
|
!!conversationId?.value &&
|
||||||
|
!isAlreadyOnThisCall.value &&
|
||||||
|
isVisibleToCurrentUser.value
|
||||||
|
);
|
||||||
|
const joinLabel = computed(() =>
|
||||||
|
status.value === VOICE_CALL_STATUS.IN_PROGRESS && didCurrentUserAnswer.value
|
||||||
|
? t('CONVERSATION.VOICE_CALL.REJOIN_CALL')
|
||||||
|
: t('CONVERSATION.VOICE_CALL.JOIN_CALL')
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeConversation = computed(() => {
|
||||||
|
const id = activeCall.value?.conversationId;
|
||||||
|
return id ? store.getters.getConversationById?.(id) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleJoinClick = async () => {
|
||||||
|
if (!canJoin.value || isJoining.value) return;
|
||||||
|
if (hasActiveCall.value && activeCall.value?.callSid !== callSid.value) {
|
||||||
|
const activeInboxId = activeConversation.value?.inbox_id;
|
||||||
|
if (activeCall.value?.conversationId && activeInboxId) {
|
||||||
|
await endCall({
|
||||||
|
conversationId: activeCall.value.conversationId,
|
||||||
|
inboxId: activeInboxId,
|
||||||
|
callSid: activeCall.value.callSid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await joinCall({
|
||||||
|
conversationId: conversationId.value,
|
||||||
|
inboxId: resolvedInboxId.value,
|
||||||
|
callSid: callSid.value,
|
||||||
|
});
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseBubble class="p-0 border-none" hide-meta>
|
<BaseBubble class="p-0 border-none" hide-meta>
|
||||||
<div class="flex overflow-hidden flex-col w-full max-w-xs">
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="!canJoin || isJoining"
|
||||||
|
data-test-id="voice-call-join"
|
||||||
|
class="flex overflow-hidden flex-col w-full max-w-xs text-left bg-transparent border-none"
|
||||||
|
:class="{
|
||||||
|
'cursor-pointer transition-colors hover:bg-n-alpha-1 active:bg-n-alpha-2':
|
||||||
|
canJoin,
|
||||||
|
'cursor-default': !canJoin,
|
||||||
|
}"
|
||||||
|
@click="handleJoinClick"
|
||||||
|
>
|
||||||
<div class="flex gap-3 items-center p-3 w-full">
|
<div class="flex gap-3 items-center p-3 w-full">
|
||||||
<div
|
<div
|
||||||
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
||||||
@@ -131,8 +215,11 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
|||||||
<span class="text-xs text-n-slate-11">
|
<span class="text-xs text-n-slate-11">
|
||||||
{{ subtext }}
|
{{ subtext }}
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="canJoin" class="mt-1 text-xs font-medium text-n-teal-10">
|
||||||
|
{{ joinLabel }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</BaseBubble>
|
</BaseBubble>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import { useCallsStore } from 'dashboard/stores/calls';
|
|||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import Timer from 'dashboard/helper/Timer';
|
import Timer from 'dashboard/helper/Timer';
|
||||||
|
|
||||||
export function useCallSession() {
|
// `manageSessionState: false` lets sub-consumers (like the timeline VoiceCall
|
||||||
|
// bubble) reuse joinCall/activeCall without registering duplicate Twilio
|
||||||
|
// listeners or running their own duration timer — only the floating widget
|
||||||
|
// owns the session lifecycle.
|
||||||
|
export function useCallSession({ manageSessionState = true } = {}) {
|
||||||
const callsStore = useCallsStore();
|
const callsStore = useCallsStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const isJoining = ref(false);
|
const isJoining = ref(false);
|
||||||
@@ -19,36 +23,38 @@ export function useCallSession() {
|
|||||||
const incomingCalls = computed(() => callsStore.incomingCalls);
|
const incomingCalls = computed(() => callsStore.incomingCalls);
|
||||||
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
||||||
|
|
||||||
watch(
|
if (manageSessionState) {
|
||||||
hasActiveCall,
|
watch(
|
||||||
active => {
|
hasActiveCall,
|
||||||
if (active) {
|
active => {
|
||||||
durationTimer.start();
|
if (active) {
|
||||||
} else {
|
durationTimer.start();
|
||||||
durationTimer.stop();
|
} else {
|
||||||
callDuration.value = 0;
|
durationTimer.stop();
|
||||||
}
|
callDuration.value = 0;
|
||||||
},
|
}
|
||||||
{ immediate: true }
|
},
|
||||||
);
|
{ immediate: true }
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
TwilioVoiceClient.addEventListener('call:disconnected', () =>
|
|
||||||
callsStore.clearActiveCall()
|
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onMounted(() => {
|
||||||
durationTimer.stop();
|
TwilioVoiceClient.addEventListener('call:disconnected', () =>
|
||||||
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
|
callsStore.clearActiveCall()
|
||||||
callsStore.clearActiveCall()
|
);
|
||||||
);
|
});
|
||||||
});
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
durationTimer.stop();
|
||||||
|
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
|
||||||
|
callsStore.clearActiveCall()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const endCall = async ({ conversationId, inboxId, callSid }) => {
|
const endCall = async ({ conversationId, inboxId, callSid }) => {
|
||||||
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
|
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
|
||||||
TwilioVoiceClient.endClientCall();
|
TwilioVoiceClient.endClientCall();
|
||||||
durationTimer.stop();
|
if (manageSessionState) durationTimer.stop();
|
||||||
callsStore.clearActiveCall();
|
callsStore.clearActiveCall();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,7 +79,7 @@ export function useCallSession() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
callsStore.setCallActive(callSid);
|
callsStore.setCallActive(callSid);
|
||||||
durationTimer.start();
|
if (manageSessionState) durationTimer.start();
|
||||||
|
|
||||||
return { conferenceSid: joinResponse?.conference_sid };
|
return { conferenceSid: joinResponse?.conference_sid };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -45,6 +45,24 @@ const shouldShowCall = ({
|
|||||||
return !isAssignedToAnotherAgent(assigneeId, currentUserId);
|
return !isAssignedToAnotherAgent(assigneeId, currentUserId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Whether the current user is allowed to take over / join an existing call.
|
||||||
|
// Mirrors the floating-widget visibility rules so the bubble's join action
|
||||||
|
// stays in sync.
|
||||||
|
export const canCurrentUserJoinCall = ({
|
||||||
|
call,
|
||||||
|
conversation,
|
||||||
|
senderId,
|
||||||
|
currentUserId,
|
||||||
|
}) => {
|
||||||
|
if (!call) return false;
|
||||||
|
return shouldShowCall({
|
||||||
|
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||||
|
senderId,
|
||||||
|
assigneeId: extractAssigneeId(conversation),
|
||||||
|
currentUserId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
function extractCallData(message) {
|
function extractCallData(message) {
|
||||||
const call = message?.call || {};
|
const call = message?.call || {};
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -84,7 +84,9 @@
|
|||||||
"NOT_ANSWERED_YET": "Not answered yet",
|
"NOT_ANSWERED_YET": "Not answered yet",
|
||||||
"THEY_ANSWERED": "They answered",
|
"THEY_ANSWERED": "They answered",
|
||||||
"YOU_ANSWERED": "You answered",
|
"YOU_ANSWERED": "You answered",
|
||||||
"AGENT_ANSWERED": "{agentName} answered"
|
"AGENT_ANSWERED": "{agentName} answered",
|
||||||
|
"JOIN_CALL": "Join call",
|
||||||
|
"REJOIN_CALL": "Rejoin call"
|
||||||
},
|
},
|
||||||
"HEADER": {
|
"HEADER": {
|
||||||
"RESOLVE_ACTION": "Resolve",
|
"RESOLVE_ACTION": "Resolve",
|
||||||
|
|||||||
Reference in New Issue
Block a user