1<template>
2 <div v-show="showCallPopup" v-bind="$attrs">
3 <div
4 ref="callPopup"
5 class="fixed z-20 flex w-60 cursor-move select-none flex-col rounded-6 bg-surface-gray-10 p-4 !text-ink-gray-2 shadow-2xl"
6 :style="style"
7 >
8 <div class="flex flex-row-reverse items-center gap-1">
9 <MinimizeIcon
10 class="h-4 w-4 cursor-pointer"
11 @click="toggleCallWindow"
12 />
13 </div>
14 <div class="flex flex-col items-center justify-center gap-3">
15 <Avatar
16 :image="contact?.image"
17 :label="contact?.full_name"
18 class="relative flex !h-24 !w-24 items-center justify-center [&>div]:text-[30px]"
19 :class="onCall || calling ? '' : 'pulse'"
20 />
21 <div class="flex flex-col items-center justify-center gap-1">
22 <div class="text-xl-medium">
23 {{ contact?.full_name ?? "Unknown" }}
24 </div>
25 <div class="text-sm text-ink-gray-5">
26 {{ contact?.mobile_no || contact?.phone }}
27 </div>
28 </div>
29 <CountUpTimer ref="counterUp">
30 <div v-if="onCall" class="my-1 text-base">
31 {{ counterUp?.updatedTime }}
32 </div>
33 </CountUpTimer>
34 <div v-if="!onCall" class="my-1 text-base">
35 {{
36 callStatus == "initiating"
37 ? "Initiating call..."
38 : callStatus == "ringing"
39 ? "Ringing..."
40 : calling
41 ? "Calling..."
42 : "Incoming call..."
43 }}
44 </div>
45 <div v-if="onCall" class="flex gap-2">
46 <Button
47 :icon="muted ? 'lucide-mic-off' : 'lucide-mic'"
48 class="rounded-full"
49 @click="toggleMute"
50 />
51 <Button
52 class="rounded-full bg-surface-red-7 hover:bg-surface-red-8"
53 @click="hangUpCall"
54 >
55 <template #icon>
56 <LucidePhone class="h-4 w-4 rotate-[135deg] text-ink-base" />
57 </template>
58 </Button>
59 </div>
60 <div v-else-if="calling || callStatus == 'initiating'">
61 <Button
62 size="md"
63 variant="solid"
64 theme="red"
65 :label="'Cancel'"
66 @click="cancelCall"
67 class="rounded-6"
68 >
69 <template #prefix>
70 <LucidePhone class="h-4 w-4 rotate-[135deg]" />
71 </template>
72 </Button>
73 </div>
74 <div v-else class="flex gap-2">
75 <Button
76 size="md"
77 variant="solid"
78 theme="green"
79 :label="'Accept'"
80 class="rounded-6"
81 @click="acceptIncomingCall"
82 >
83 <template #prefix>
84 <LucidePhone class="h-4 w-4" />
85 </template>
86 </Button>
87 <Button
88 size="md"
89 variant="solid"
90 theme="red"
91 :label="'Reject'"
92 class="rounded-6"
93 @click="rejectIncomingCall"
94 >
95 <template #prefix>
96 <LucidePhone class="h-4 w-4 rotate-[135deg]" />
97 </template>
98 </Button>
99 </div>
100 </div>
101 </div>
102 </div>
103 <div
104 v-show="showSmallCallWindow"
105 class="ms-2 mt-1 flex cursor-pointer select-none items-center justify-between gap-3 rounded-6 bg-surface-gray-10 px-2 py-1 text-base !text-ink-gray-2"
106 @click="toggleCallWindow"
107 v-bind="$attrs"
108 >
109 <div class="flex items-center gap-2">
110 <Avatar
111 :image="contact?.image"
112 :label="contact?.full_name"
113 class="relative flex !h-5 !w-5 items-center justify-center"
114 />
115 <div class="max-w-[120px] truncate">
116 {{ contact?.full_name ?? "Unknown" }}
117 </div>
118 </div>
119 <div v-if="onCall" class="flex items-center gap-2">
120 <div class="my-1 min-w-[40px] text-center">
121 {{ counterUp?.updatedTime }}
122 </div>
123 <Button
124 variant="solid"
125 theme="red"
126 class="!h-6 !w-6 rounded-full"
127 @click.stop="hangUpCall"
128 >
129 <template #icon>
130 <LucidePhone class="h-4 w-4 rotate-[135deg]" />
131 </template>
132 </Button>
133 </div>
134 <div v-else-if="calling" class="flex items-center gap-3">
135 <div class="my-1">
136 {{ callStatus == "ringing" ? "Ringing..." : "Calling..." }}
137 </div>
138 <Button
139 variant="solid"
140 theme="red"
141 class="!h-6 !w-6 rounded-full"
142 @click.stop="cancelCall"
143 >
144 <template #icon>
145 <LucidePhone class="h-4 w-4 rotate-[135deg]" />
146 </template>
147 </Button>
148 </div>
149 <div v-else class="flex items-center gap-2">
150 <Button
151 variant="solid"
152 theme="green"
153 class="pulse relative !h-6 !w-6 rounded-full"
154 @click.stop="acceptIncomingCall"
155 >
156 <template #icon>
157 <LucidePhone class="h-4 w-4 animate-pulse" />
158 </template>
159 </Button>
160 <Button
161 variant="solid"
162 theme="red"
163 class="!h-6 !w-6 rounded-full"
164 @click.stop="rejectIncomingCall"
165 >
166 <template #icon>
167 <LucidePhone class="h-4 w-4 rotate-[135deg]" />
168 </template>
169 </Button>
170 </div>
171 </div>
172</template>
173
174<script setup lang="ts">
175import { useTelephonyStore } from "@/stores/telephony";
176import { Call, Device } from "@twilio/voice-sdk";
177import { useDraggable, useWindowSize } from "@vueuse/core";
178import { Avatar, call, toast } from "frappe-ui";
179import { inject, ref, watch } from "vue";
180import LucidePhone from "~icons/lucide/phone";
181import CountUpTimer from "./CountUpTimer.vue";
182import MinimizeIcon from "./Icons/MinimizeIcon.vue";
183
184const telephonyStore = useTelephonyStore();
185
186const onCallStarted = inject<(() => void) | undefined>(
187 "onCallStarted",
188 undefined
189);
190const onCallEnded = inject<(() => void) | undefined>("onCallEnded", undefined);
191const onCallFailed = inject<(() => void) | undefined>(
192 "onCallFailed",
193 undefined
194);
195
196let device: Device | null = null;
197let log = ref("");
198let _call = null;
199
200let showCallPopup = ref(false);
201let showSmallCallWindow = ref(false);
202let onCall = ref(false);
203let calling = ref(false);
204let muted = ref(false);
205let callPopup = ref(null);
206let counterUp = ref(null);
207let callStatus = ref("");
208
209const phoneNumber = ref("");
210
211const contact = ref({
212 full_name: "",
213 image: "",
214 mobile_no: "",
215 phone: "",
216});
217
218watch(phoneNumber, (value) => {
219 if (!value) return;
220 call("telephony.api.get_contact_by_phone_number", {
221 phone_number: phoneNumber.value,
222 }).then((data) => {
223 contact.value = data;
224 });
225});
226
227const { width, height } = useWindowSize();
228
229let { x, y, style } = useDraggable(callPopup, {
230 initialValue: { x: width.value - 280, y: height.value - 310 },
231 preventDefault: true,
232});
233
234watch([width, height], () => {
235 x.value = Math.max(0, width.value - 280);
236 y.value = Math.max(0, height.value - 310);
237});
238
239async function startupClient() {
240 log.value = "Requesting Access Token...";
241
242 try {
243 const data = await call("telephony.twilio.api.generate_access_token");
244 log.value = "Got a token.";
245 intitializeDevice(data.token);
246 } catch (err) {
247 log.value = "An error occurred. " + err.message;
248 }
249}
250
251function intitializeDevice(token) {
252 device = new Device(token, {
253 codecPreferences: [Call.Codec.Opus, Call.Codec.PCMU],
254 });
255
256 addDeviceListeners();
257
258 device.register();
259}
260
261function addDeviceListeners() {
262 device.on("registered", () => {
263 log.value = "Ready to make and receive calls!";
264 });
265
266 device.on("unregistered", (device) => {
267 log.value = "Logged out";
268 });
269
270 device.on("error", (error) => {
271 log.value = "Twilio.Device Error: " + error.message;
272 });
273
274 device.on("incoming", handleIncomingCall);
275
276 device.on("tokenWillExpire", async () => {
277 const data = await call("telephony.twilio.api.generate_access_token");
278 device.updateToken(data.token);
279 });
280}
281
282function toggleMute() {
283 if (_call.isMuted()) {
284 _call.mute(false);
285 muted.value = false;
286 } else {
287 _call.mute();
288 muted.value = true;
289 }
290}
291
292function handleIncomingCall(call) {
293 log.value = `Incoming call from ${call.parameters.From}`;
294 phoneNumber.value = call.parameters.From;
295
296 showCallPopup.value = true;
297 _call = call;
298
299 // add event listener to call object
300 call.on("cancel", handleDisconnectedIncomingCall);
301 call.on("disconnect", handleDisconnectedIncomingCall);
302 call.on("reject", handleDisconnectedIncomingCall);
303}
304
305async function acceptIncomingCall() {
306 log.value = "Accepted incoming call.";
307 onCall.value = true;
308 await _call.accept();
309 onCallStarted && onCallStarted();
310 counterUp.value.start();
311}
312
313function rejectIncomingCall() {
314 _call.reject();
315 log.value = "Rejected incoming call";
316 showCallPopup.value = false;
317 if (showSmallCallWindow.value == undefined) {
318 showSmallCallWindow.value = false;
319 } else {
320 showSmallCallWindow.value = false;
321 }
322 callStatus.value = "";
323 muted.value = false;
324 onCallFailed && onCallFailed();
325}
326
327function hangUpCall() {
328 _call.disconnect();
329 log.value = "Hanging up incoming call";
330 onCall.value = false;
331 callStatus.value = "";
332 muted.value = false;
333 counterUp.value.stop();
334 onCallEnded && onCallEnded();
335}
336
337function handleDisconnectedIncomingCall() {
338 log.value = `Call ended from handle disconnected Incoming call.`;
339 showCallPopup.value = false;
340 if (showSmallCallWindow.value == undefined) {
341 showSmallCallWindow.value = false;
342 } else {
343 showSmallCallWindow.value = false;
344 }
345 _call = null;
346 muted.value = false;
347 onCall.value = false;
348 counterUp.value.stop();
349 onCallEnded && onCallEnded();
350}
351
352async function makeOutgoingCall(number) {
353 phoneNumber.value = number;
354
355 if (device) {
356 log.value = `Attempting to call ${number} ...`;
357 onCallStarted && onCallStarted();
358
359 try {
360 _call = await device.connect({
361 params: {
362 To: number,
363 link_doctype: telephonyStore.linkDoc.doctype,
364 link_docname: telephonyStore.linkDoc.docname,
365 },
366 });
367
368 showCallPopup.value = true;
369 callStatus.value = "initiating";
370
371 _call.on("messageReceived", (message) => {
372 let info = message.content;
373 callStatus.value = info.CallStatus;
374
375 log.value = `Call status: ${info.CallStatus}`;
376
377 if (info.CallStatus == "in-progress") {
378 log.value = `Call in progress.`;
379 calling.value = false;
380 onCall.value = true;
381 counterUp.value.start();
382 }
383 });
384
385 _call.on("accept", () => {
386 log.value = `Initiated call!`;
387 showCallPopup.value = true;
388 calling.value = true;
389 onCall.value = false;
390 });
391 _call.on("disconnect", (conn) => {
392 log.value = `Call ended from makeOutgoing call disconnect.`;
393 calling.value = false;
394 onCall.value = false;
395 showCallPopup.value = false;
396 showSmallCallWindow.value = false;
397 _call = null;
398 callStatus.value = "";
399 muted.value = false;
400 counterUp.value.stop();
401 onCallEnded && onCallEnded();
402 });
403 _call.on("cancel", () => {
404 log.value = `Call ended from makeOutgoing call cancel.`;
405 calling.value = false;
406 onCall.value = false;
407 showCallPopup.value = false;
408 showSmallCallWindow.value = false;
409 _call = null;
410 callStatus.value = "";
411 muted.value = false;
412 counterUp.value.stop();
413 onCallEnded && onCallEnded();
414 });
415 } catch (error) {
416 onCallFailed && onCallFailed();
417 log.value = `Could not connect call: ${error.message}`;
418 toast.error(error.message);
419 }
420 } else {
421 onCallFailed && onCallFailed();
422 log.value = "Unable to make call.";
423 toast.error("Unable to make call.");
424 }
425}
426
427function cancelCall() {
428 _call.disconnect();
429 showCallPopup.value = false;
430 if (showSmallCallWindow.value == undefined) {
431 showSmallCallWindow.value = false;
432 } else {
433 showSmallCallWindow.value = false;
434 }
435 calling.value = false;
436 onCall.value = false;
437 callStatus.value = "";
438 muted.value = false;
439}
440
441function toggleCallWindow() {
442 showCallPopup.value = !showCallPopup.value;
443 if (showSmallCallWindow.value == undefined) {
444 showSmallCallWindow.value = !showSmallCallWindow;
445 } else {
446 showSmallCallWindow.value = !showSmallCallWindow.value;
447 }
448}
449
450watch(
451 () => log.value,
452 (value) => {
453 console.log(value);
454 },
455 { immediate: true }
456);
457
458defineExpose({ makeOutgoingCall, setup: startupClient });
459</script>
460
461<style scoped>
462.pulse::before {
463 content: "";
464 position: absolute;
465 border: 1px solid green;
466 width: calc(100% + 20px);
467 height: calc(100% + 20px);
468 border-radius: 50%;
469 animation: pulse 1s linear infinite;
470}
471
472.pulse::after {
473 content: "";
474 position: absolute;
475 border: 1px solid green;
476 width: calc(100% + 20px);
477 height: calc(100% + 20px);
478 border-radius: 50%;
479 animation: pulse 1s linear infinite;
480 animation-delay: 0.3s;
481}
482
483@keyframes pulse {
484 0% {
485 transform: scale(0.5);
486 opacity: 0;
487 }
488
489 50% {
490 transform: scale(1);
491 opacity: 1;
492 }
493
494 100% {
495 transform: scale(1.3);
496 opacity: 0;
497 }
498}
499</style>
500