Compare commits

...
Author SHA1 Message Date
Muhsin ab0370fbe1 refactor(voice): make the whole call bubble the join target and switch active calls
- The bubble itself is now the click target with an inline Join/Rejoin
  caption, matching the original design intent — discoverable on hover
  without a separate footer button.
- If the agent already has a different active call, end it first so the
  switch is clean (mirrors FloatingCallWidget.handleJoinCall).
2026-04-30 18:52:48 +04:00
Muhsin e415b0576a 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
2026-04-30 18:46:09 +04:00
4 changed files with 143 additions and 30 deletions
@@ -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,19 @@ 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, hasActiveCall, isJoining, joinCall, endCall } =
useCallSession({
manageSessionState: false,
});
const status = computed(() => call.value?.status);
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 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>
<template>
<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 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">
{{ subtext }}
</span>
<span v-if="canJoin" class="mt-1 text-xs font-medium text-n-teal-10">
{{ joinLabel }}
</span>
</div>
</div>
</div>
</button>
</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) {
+18
View File
@@ -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",