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:
Muhsin
2026-04-30 18:46:09 +04:00
parent 353089473e
commit e415b0576a
4 changed files with 122 additions and 28 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,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>