diff --git a/app/drops/user_drop.rb b/app/drops/user_drop.rb index 7cafea1bb..cf6f1b6a1 100644 --- a/app/drops/user_drop.rb +++ b/app/drops/user_drop.rb @@ -7,11 +7,15 @@ class UserDrop < BaseDrop @obj.try(:available_name) end + def email + @obj.try(:email) + end + def first_name - @obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1 + @obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1 end def last_name - @obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1 + @obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1 end end diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index f2383551c..229fbec03 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -4,6 +4,7 @@ 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 Icon from 'dashboard/components-next/icon/Icon.vue'; import BaseBubble from 'next/message/bubbles/Base.vue'; @@ -29,14 +30,16 @@ const BG_COLOR_MAP = { const { t } = useI18n(); const store = useStore(); -const { call, conversationId, currentUserId } = useMessageContext(); +const { call, conversationId, currentUserId, inboxId } = useMessageContext(); +const { joinCall, endCall, activeCall, hasActiveCall, isJoining } = + useCallSession(); const status = computed(() => call.value?.status); const isOutbound = computed(() => call.value?.direction === 'outgoing'); const isFailed = computed(() => [VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value) ); -const acceptedByAgentId = computed(() => call.value?.accepted_by_agent_id); +const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId); const didCurrentUserAnswer = computed( () => !!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value @@ -52,8 +55,7 @@ const conversationAssignee = computed(() => { return conversation?.meta?.assignee || null; }); const displayAgentName = computed(() => { - if (call.value?.accepted_by_agent_name) - return call.value.accepted_by_agent_name; + if (call.value?.acceptedByAgentName) return call.value.acceptedByAgentName; if (acceptedByAgentId.value) { const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value); if (agent?.available_name) return agent.available_name; @@ -104,6 +106,45 @@ const iconName = computed(() => { }); const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9'); + +const callSid = computed(() => call.value?.providerCallId); + +// Show "Join call" when the call is still ringing, no agent has claimed it, +// and the conversation is unassigned or assigned to the current user. Mirrors +// the eligibility used by FloatingCallWidget so the bubble can act as a +// recovery affordance after a refresh or missed widget. +const canJoinCall = computed(() => { + if (status.value !== VOICE_CALL_STATUS.RINGING) return false; + if (isOutbound.value) return false; + if (acceptedByAgentId.value) return false; + if (!callSid.value || !inboxId.value || !conversationId.value) return false; + // Suppress the button once this call is the local active session — the + // message status webhook may lag behind, so we can't rely on `status` alone + // to hide it after a successful join from this client. + if (hasActiveCall.value && activeCall.value?.callSid === callSid.value) + return false; + const assignee = conversationAssignee.value; + if (assignee?.id && assignee.id !== currentUserId.value) return false; + return true; +}); + +const handleJoinCall = async () => { + if (!canJoinCall.value || isJoining.value) return; + + if (hasActiveCall.value && activeCall.value?.callSid !== callSid.value) { + await endCall({ + conversationId: activeCall.value.conversationId, + inboxId: activeCall.value.inboxId, + callSid: activeCall.value.callSid, + }); + } + + await joinCall({ + conversationId: conversationId.value, + inboxId: inboxId.value, + callSid: callSid.value, + }); +};