feat(voice): allow joining a call from the conversation timeline bubble
Agents who refresh or dismiss the floating widget can now click a Join / Rejoin action on the voice_call bubble to (re)connect to a ringing or in-progress call. The CTA respects the same visibility rules as the floating widget — outbound calls only surface for the initiator, and inbound assigned calls only surface for the assignee. useCallSession gains a manageSessionState option so the bubble can reuse joinCall/activeCall without registering duplicate Twilio listeners or running its own duration timer. Closes PLA-117
This commit is contained in:
@@ -4,6 +4,8 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
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 BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
@@ -27,9 +29,18 @@ const BG_COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||
};
|
||||
|
||||
const JOINABLE_STATUSES = [
|
||||
VOICE_CALL_STATUS.RINGING,
|
||||
VOICE_CALL_STATUS.IN_PROGRESS,
|
||||
];
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { call, conversationId, currentUserId } = useMessageContext();
|
||||
const { call, conversationId, currentUserId, inboxId, sender } =
|
||||
useMessageContext();
|
||||
const { activeCall, isJoining, joinCall } = useCallSession({
|
||||
manageSessionState: false,
|
||||
});
|
||||
|
||||
const status = computed(() => call.value?.status);
|
||||
const isOutbound = computed(() => call.value?.direction === 'outgoing');
|
||||
@@ -104,6 +115,52 @@ const iconName = computed(() => {
|
||||
});
|
||||
|
||||
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 handleJoinClick = async () => {
|
||||
if (!canJoin.value || isJoining.value) return;
|
||||
await joinCall({
|
||||
conversationId: conversationId.value,
|
||||
inboxId: resolvedInboxId.value,
|
||||
callSid: callSid.value,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -133,6 +190,17 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="canJoin"
|
||||
type="button"
|
||||
:disabled="isJoining"
|
||||
data-test-id="voice-call-join"
|
||||
class="flex gap-2 justify-center items-center px-3 py-2 w-full text-sm font-medium border-t bg-n-alpha-1 hover:bg-n-alpha-2 text-n-teal-11 border-n-strong disabled:opacity-60"
|
||||
@click="handleJoinClick"
|
||||
>
|
||||
<Icon class="size-4" icon="i-ph-phone-call" />
|
||||
{{ joinLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,11 @@ import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
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 { t } = useI18n();
|
||||
const isJoining = ref(false);
|
||||
@@ -19,36 +23,38 @@ export function useCallSession() {
|
||||
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 }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
TwilioVoiceClient.addEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
if (manageSessionState) {
|
||||
watch(
|
||||
hasActiveCall,
|
||||
active => {
|
||||
if (active) {
|
||||
durationTimer.start();
|
||||
} else {
|
||||
durationTimer.stop();
|
||||
callDuration.value = 0;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
durationTimer.stop();
|
||||
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
);
|
||||
});
|
||||
onMounted(() => {
|
||||
TwilioVoiceClient.addEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
durationTimer.stop();
|
||||
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const endCall = async ({ conversationId, inboxId, callSid }) => {
|
||||
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
|
||||
TwilioVoiceClient.endClientCall();
|
||||
durationTimer.stop();
|
||||
if (manageSessionState) durationTimer.stop();
|
||||
callsStore.clearActiveCall();
|
||||
};
|
||||
|
||||
@@ -73,7 +79,7 @@ export function useCallSession() {
|
||||
});
|
||||
|
||||
callsStore.setCallActive(callSid);
|
||||
durationTimer.start();
|
||||
if (manageSessionState) durationTimer.start();
|
||||
|
||||
return { conferenceSid: joinResponse?.conference_sid };
|
||||
} catch (error) {
|
||||
|
||||
@@ -45,6 +45,24 @@ const shouldShowCall = ({
|
||||
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) {
|
||||
const call = message?.call || {};
|
||||
return {
|
||||
|
||||
@@ -84,7 +84,9 @@
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered",
|
||||
"AGENT_ANSWERED": "{agentName} answered"
|
||||
"AGENT_ANSWERED": "{agentName} answered",
|
||||
"JOIN_CALL": "Join call",
|
||||
"REJOIN_CALL": "Rejoin call"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "Resolve",
|
||||
|
||||
Reference in New Issue
Block a user