PluginProbe
VikBooking Hotel Booking Engine & PMS / 1.8.8
VikBooking Hotel Booking Engine & PMS v1.8.8
1.8.16 1.8.15 1.8.14 1.8.13 1.8.12 1.8.11 1.8.10 1.8.9 1.8.6 1.8.7 1.8.8 trunk 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 All 37 releases
vikbooking / site / resources / chat.js

chat.js in VikBooking Hotel Booking Engine & PMS 1.8.8, at site/resources/chat.js

2,908 lines 98.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 (function($, w) {
2 'use strict';
3
4 /**
5 * VBOChat class.
6 * Singleton used to handle a CHAT client.
7 */
8 w['VBOChat'] = class VBOChat {
9
10 /**
11 * Returns a new chat instance.
12 *
13 * @param object data The environment options.
14 *
15 * @return VBOChat
16 */
17 static getInstance(data) {
18 if (data && VBOChat?.instance?.data) {
19 // the chat was already initialized, preserve the previous data in a static queue
20 VBOChat.queue.push(VBOChat.instance.data);
21 }
22
23 if (VBOChat.instance === undefined || typeof data !== 'undefined') {
24 VBOChat.instance = new VBOChat(data);
25 }
26
27 if (!VBOChat.instance.data && data) {
28 // instantiate a new object with the injected data
29 VBOChat.instance = new VBOChat(data);
30 }
31
32 return VBOChat.instance;
33 }
34
35 /**
36 * Class constructor.
37 *
38 * @param object data The environment options.
39 */
40 constructor(data) {
41 this.data = data;
42
43 if (this.data) {
44 if (this.data.environment.users === undefined) {
45 this.data.environment.users = {};
46 }
47
48 if (this.data.environment.messages === undefined) {
49 this.data.environment.messages = [];
50 }
51
52 // initialise thread
53 this.initThread();
54
55 this.data.environment.id = this.data.environment.messages.length;
56 this.data.environment.datetime = new Date();
57 this.data.environment.attachments = [];
58
59 // use default values if not provided
60 this.data.environment.options = Object.assign({
61 syncTime: 10,
62 limit: 20,
63 autoread: true,
64 }, this.data.environment.options || {});
65
66 if (!this.data.element) {
67 this.data.element = {
68 conversation: $(this.data.environment.selector).find('.chat-conversation'),
69 progressBar: $(this.data.environment.selector).find('.chat-progress-wrap'),
70 uploadsBar: $(this.data.environment.selector).find('.chat-uploads-tab'),
71 inputBox: $(this.data.environment.selector).find('.textarea-input'),
72 };
73 }
74 }
75
76 this.timers = [];
77 }
78
79 /**
80 * Initialises the specified thread.
81 *
82 * @return self
83 */
84 initThread() {
85 const thread = {};
86
87 // keep thread initial date time
88 thread.initialDatetime = this.data.environment.messages[0]?.createdon;
89 // keep thread initial messages length
90 thread.messagesLength = this.data.environment.messages.length;
91
92 this.data.environment.thread = thread;
93
94 return this;
95 }
96
97 /**
98 * Prepares the chat client to be up and running by initializing the last conversation made.
99 * The synchronization with the server is made here.
100 *
101 * @return self
102 */
103 prepare() {
104 if (this.isPrepared) {
105 // do not execute again
106 return this;
107 }
108
109 this.isPrepared = true;
110
111 // register e-mail content parser
112 this.attachContentParser('email', function(content) {
113 // wrap any e-mail addresses within a "mailto" link
114 content = content.replace(/[a-z0-9][a-z0-9._\-]{1,63}@(?:[a-z][a-z0-9\-]{1,62}\.?){1,3}\.[a-z][a-z0-9]{1,62}/gi, function(mail) {
115 return '<a href="mailto:' + mail + '">' + mail + '</a>';
116 });
117
118 return content;
119 });
120
121 // register phone content parser
122 this.attachContentParser('phone', function(content) {
123 // wrap any potential phone numbers within a "tel" link
124 content = content.replace(/(^|\s)(?:\+[\d]{1,5})?[\d][\d \-]{3,}[\d](\s|$)/gm, function(phone) {
125 return '<a href="tel:' + phone + '">' + phone + '</a>';
126 });
127
128 return content;
129 });
130
131 // register URL content parser
132 this.attachContentParser('url', function(content) {
133 // do not prepare URLs in case the content already contains images or tags
134 if (content.match(/<(img|a)\b/)) {
135 return content;
136 }
137
138 // wrap any plain URLs within a link
139 content = content.replace(/https?:\/\/(www\.)?[a-zA-Z0-9@:%._\+~#=\-]{2,256}\.[a-z]{2,6}\b([a-zA-Z0-9@:%_\+.~#?&\/\/=\-;]*)/gi, function(url) {
140 return '<a href="' + url + '" target="_blank">' + url + '</a>';
141 });
142
143 return content;
144 });
145
146 this.renderInput()
147 .startConversation()
148 .buildConversation()
149 .readNotifications();
150
151 // calculate interval duration between each sync (use at least 1000 ms)
152 const syncDuration = Math.max(1000, this.data.environment.options.syncTime * 1000);
153
154 // sync messages when page loads
155 this.synchronizeMessages();
156
157 // try to check if we have new messages to push
158 this.timers.push(
159 setInterval(() => {
160 this.synchronizeMessages();
161 }, syncDuration)
162 );
163
164 /**
165 * We should run here an interval that re-build the date separators.
166 * For example, if we open the chat @ 23:57, the date separators should
167 * be changed after the midnight ("Today, 23:56" should become "Yesterday, 23:56").
168 *
169 * The date of the last message sent/received should be updated too.
170 */
171 this.timers.push(
172 setInterval(() => {
173 const now = new Date();
174
175 const chat = this;
176
177 // check if the date has changed since the last check
178 if (!DateHelper.isSameDay(now, chat.data.environment.datetime)) {
179 // update environment datetime
180 chat.data.environment.datetime = now;
181
182 // iterate all the separators
183 $('.is-a-separator').each(function() {
184 // get separator UTC date
185 let dt = $(this).attr('data-datetime');
186 let utc = DateHelper.stringToDate(dt);
187
188 // replace separator with new one
189 $(this).replaceWith(chat.getDateSeparator(utc));
190 });
191 }
192 }, 10000)
193 );
194
195 this.triggerEvent('chat.prepare', {
196 chat: this,
197 });
198
199 return this;
200 }
201
202 /**
203 * Build the chat conversation.
204 * The conversation may not contain all messages.
205 *
206 * @param mixed start The initial offset of the messages to display.
207 * If not provided, 0 will be used.
208 * @param mixed end The ending offset of the messages to display.
209 * If not provided, all the cached messages will be shown.
210 *
211 * @return self
212 */
213 buildConversation(start, end) {
214 if (start === undefined) {
215 start = 0;
216 }
217
218 if (end === undefined) {
219 end = this.data.environment.messages.length;
220 }
221
222 let messages = $('');
223
224 // define queue for failed messages
225 let failedQueue = [];
226
227 for (let i = end - 1; i >= start; i--) {
228 let message = this.data.environment.messages[i];
229
230 if (message === undefined) {
231 // we reached the end of the list before the expected limit
232 continue;
233 }
234
235 if (message.hasError) {
236 // push the message within the failed queue, then go to next item
237 failedQueue.push(message.id);
238
239 // unset error to avoid sending it twice
240 message.hasError = false;
241 continue;
242 }
243
244 // get message template (message, false: no animation, true: get buffer)
245 messages = messages.add(this.drawMessage(message, false, true));
246 }
247
248 $(this.data.element.conversation).prepend(messages);
249
250 if (start == 0) {
251 // in case the chat was empty, auto-scroll conversation to the last message
252 this.scrollToBottom();
253 }
254
255 // we need to iterate all the failed messages and retry to send them
256 for (let i = 0; i < failedQueue.length; i++) {
257 // remove message from list
258 let mess = this.removeMessage(failedQueue[i]);
259
260 if (mess) {
261 // re-send message content
262 this.send(mess.message);
263 }
264 }
265
266 return this;
267 }
268
269 /**
270 * Initializes the conversation.
271 *
272 * @return self
273 */
274 startConversation() {
275 const chat = this;
276
277 $(this.data.element.conversation).html('');
278
279 // setup environment vars
280 this.data.environment.isLoadingOlderMessages = false;
281
282 // clear scroll event
283 $(this.data.element.conversation).off('scroll');
284
285 // do not register scroll event in case the number of messages is equal or
286 // higher then the total number of messages under this context
287 if (this.data.environment.messages.length >= this.data.environment.options.limit) {
288 // setup scroll event to load older messages
289 $(this.data.element.conversation).on('scroll', function() {
290 if (chat.data.environment.isLoadingOlderMessages) {
291 // ignore if we are currently loading older messages
292 return;
293 }
294
295 // get scrollable pixel
296 const scrollHeight = this.scrollHeight - $(this).outerHeight();
297 // get scroll top
298 const scrollTop = this.scrollTop;
299
300 // start loading older messages only when scrollbar
301 // hits the first half of the whole scrollable height
302 if (scrollTop / scrollHeight < 0.5) {
303 // load older chat messages
304 chat.loadPreviousMessages();
305 }
306
307 });
308 }
309
310 return this;
311 }
312
313 /**
314 * Scrolls the chat conversation to the most recent message.
315 *
316 * @return self
317 */
318 scrollToBottom() {
319 const convo = $(this.data.element.conversation);
320
321 if (!convo.length) {
322 return this;
323 }
324
325 convo.scrollTop(convo[0].scrollHeight + 200);
326
327 return this;
328 }
329
330 /**
331 * Scroll the chat conversation to the beginning of specified message.
332 *
333 * @param object msg The message object.
334 *
335 * @return self
336 */
337 scrollToMessage(msg) {
338 const conversation = $(this.data.element.conversation);
339
340 // get unread message DOM element
341 const messageEl = conversation.find('#delivered-' + msg.id);
342
343 if (messageEl.length) {
344 // scroll conversation to the beginning of the first unread message
345 conversation.scrollTop(messageEl[0].offsetTop);
346 }
347
348 return this;
349 }
350
351 /**
352 * Checks whether the chat should scroll.
353 * If we are reading older messages, the chat should not scroll.
354 * Contrarily, if we are keeping an eye on the latest messages,
355 * the chat should scroll to the bottom.
356 *
357 * @param integer threshold An optional threshold (30px by default).
358 *
359 * @return boolean
360 */
361 shouldScroll(threshold) {
362 const conversation = $(this.data.element.conversation)[0];
363
364 // total scrollable amount (we need to exclude the chat height from the scroll height)
365 let scrollable = conversation.scrollHeight - $(conversation).outerHeight();
366 // get difference between current scroll top and total scroll top
367 let diff = Math.abs(scrollable - conversation.scrollTop);
368
369 // scroll only in case we are already at the bottom position,
370 // with a maximum threshold of 30 pixel
371 return diff <= (threshold || 30);
372 }
373
374 /**
375 * Returns the index of the message object that matches the specified ID.
376 *
377 * @param mixed id The message ID.
378 *
379 * @return integer The index of the matching object, otherwise -1.
380 */
381 getMessageIndex(id) {
382 for (let i = 0; i < this.data.environment.messages.length; i++) {
383 let message = this.data.environment.messages[i];
384
385 if (message.id == id) {
386 return i;
387 }
388 }
389
390 return -1;
391 }
392
393 /**
394 * Returns the message object that matches the specified ID.
395 *
396 * @param mixed id The message ID.
397 *
398 * @return mixed The matching object, otherwise null.
399 */
400 getMessage(id) {
401 const index = this.getMessageIndex(id);
402
403 if (index != -1) {
404 return this.data.environment.messages[index];
405 }
406
407 return null;
408 }
409
410 /**
411 * Removes the message object that matches the specified ID.
412 *
413 * @param mixed id The message ID.
414 * @param boolean strict True to remove the message from the chat too.
415 *
416 * @return mixed The removed object on success, otherwise false.
417 */
418 removeMessage(id, strict) {
419 const index = this.getMessageIndex(id);
420
421 if (index == -1) {
422 return false;
423 }
424
425 // check if the chat message should be removed
426 if (strict) {
427 if ($('#'+ id).prev().hasClass('is-a-separator')) {
428 // remove previous separator too
429 $('#' + id).prev().remove();
430 }
431
432 // remove chat element
433 $('#' + id).remove();
434 }
435
436 return this.data.environment.messages.splice(index, 1)[0];
437 }
438
439 /**
440 * Returns the latest message sent/received.
441 In this case "latest" means "most recent".
442 *
443 * @return mixed The latest message if any, otherwise null.
444 */
445 getLatestMessage() {
446 return this.data.environment.messages[0] ?? null;
447 }
448
449 /**
450 * Returns a list of messages received that needs to be read.
451 *
452 * @return object[] The unread messages.
453 */
454 getUnreadMessages() {
455 let unread = [];
456
457 for (let i = 0; i < this.data.environment.messages.length; i++) {
458 let msg = this.data.environment.messages[i];
459
460 if (!msg.read) {
461 unread.push(msg);
462 }
463 }
464
465 return unread;
466 }
467
468 /**
469 * Returns the latest message received that needs to be read.
470 * In this case "latest" means "most recent".
471 *
472 * @return mixed The latest unread message if any, otherwise null.
473 */
474 getLatestUnreadMessage() {
475 for (let i = 0; i < this.data.environment.messages.length; i++) {
476 let msg = this.data.environment.messages[i];
477
478 if (!msg.read) {
479 return msg;
480 }
481 }
482
483 return null;
484 }
485
486 /**
487 * Counts the total number of unread messages.
488 * In case there are some unread messages outside the current pagination,
489 * they won't be counted, obviously.
490 *
491 * @return int The total count of unread messages.
492 */
493 getUnreadMessagesCount() {
494 let count = 0;
495
496 for (let i = 0; i < this.data.environment.messages.length; i++) {
497 let msg = this.data.environment.messages[i];
498
499 if (!msg.read) {
500 count++;
501 }
502 }
503
504 return count;
505 }
506
507 /**
508 * Helper function used to calculate the exact position that the
509 * new message should occupy.
510 *
511 * @param object message The message to add.
512 *
513 * @return int The correct position.
514 */
515 findMessagePosition(message) {
516 for (let i = 0; i < this.data.environment.messages.length; i++) {
517 if (this.data.environment.messages[i].createdon <= message.createdon) {
518 return i;
519 }
520 }
521
522 return this.data.environment.messages.length;
523 }
524
525 /**
526 * Returns the next identifier to use for DOM chat messages.
527 *
528 * @string
529 */
530 getNextID() {
531 return 'msg-' + (++this.data.environment.id);
532 }
533
534 /**
535 * Checks if the current client is the sender of the message.
536 *
537 * @return boolean
538 */
539 isSender(message) {
540 // in case the message sender is equal to the current user
541 // or in case the message sender is AI (-2) and the current user is an admin (0)
542 return message.id_sender == this.data.environment.user.id
543 || (message.id_sender == -2 && this.data.environment.user.id == 0);
544 }
545
546 /**
547 * Collects the specified message within the internal state and
548 * pushes it within the chat conversation.
549 *
550 * @param integer id The message ID.
551 * @param string message The message content.
552 * @param object sender The sender details.
553 *
554 * @return mixed The collected object on success, otherwise false.
555 */
556 collect(id, message, sender) {
557 // build dummy object
558 const dummy = {
559 id: id,
560 message: message,
561 id_sender: sender.id || 0,
562 sender_name: sender.name,
563 createdon: DateHelper.toStringUTC(new Date()),
564 };
565
566 if (this.data.environment.attachments.length) {
567 // push attachments within data
568 dummy.attachments = this.data.environment.attachments;
569 }
570
571 // draw message within the chat
572 this.drawMessage(dummy, true);
573
574 // push dummy data within the messages
575 this.data.environment.messages.unshift(dummy);
576
577 return dummy;
578 }
579
580 /**
581 * Draws the given message within the chat conversation.
582 * The method doesn't check whether the message matches
583 * the current one.
584 *
585 * @param object message The message to draw.
586 * @param boolean animate True to animate the message entrance.
587 * @param boolean buffer True to return the message template.
588 *
589 * @return mixed In case of buffer, the template string will be returned
590 * instead of being appended within the DOM. Otherwise,
591 * this object will be returned for chaining.
592 */
593 drawMessage(message, animate, buffer) {
594 // get index of message
595 let index = this.getMessageIndex(message.id);
596
597 if (index != -1) {
598 // get next message
599 index++;
600 } else {
601 // use first index available as our message might be not yet in the list
602 index = 0;
603 }
604
605 let template = $('');
606
607 // get last message sent/received
608 const prev = this.data.environment.messages.length ? this.data.environment.messages[index] : null;
609
610 let hasSeparator = false;
611
612 if (!prev || DateHelper.diff(message.createdon, prev.createdon, 'minutes') > 10) {
613 // write date separator because have passed more than 10 minutes since the previous message
614 const dateSeparator = this.getDateSeparator(message.createdon);
615 hasSeparator = true;
616
617 template = template.add(dateSeparator);
618 }
619
620 // use custom element ID
621 const elem_id = isFinite(message.id) ? 'delivered-' + message.id : message.id;
622
623 // make content HTML-safe
624 let content = message.message;
625
626 if (!isFinite(message.id) && message.id !== 'msg-ai-typing') {
627 // make content HTML-safe only for new messages
628 content = content.htmlentities();
629 }
630
631 // fetch message content
632 content = this.renderMessageContent(content);
633
634 // determine sender/recipient type
635 const is_sender = this.isSender(message);
636
637 // obtain the details of the user that wrote the message
638 const user = this.getMessageUser(message);
639
640 // create the avatar node
641 const avatar = this.drawUserAvatar(user);
642
643 // create message content
644 const messageContent = $('<div class="speech-bubble"></div>').addClass('message-content ' + (animate ? 'need-animation ' : '') + (is_sender ? 'sent' : 'received'));
645
646 // add message author
647 messageContent.append(
648 $('<div class="content-author"></div>')
649 .text(user.name || message.sender_name)
650 .attr('data-sender-id', message.id_sender)
651 .attr('data-sender-name', message.sender_name)
652 );
653
654 // change name to "You" in case the sender is equal to the logged in user
655 if (this.data.environment.user.id == message.id_sender && this.data.environment.user.name == message.sender_name) {
656 messageContent.find('.content-author').text(Joomla.JText._('VBO_CHAT_YOU'));
657 }
658
659 // fetch last message author
660 const lastMessageAuthor = $(this.data.element.conversation).children().last().find('.content-author');
661
662 // hide sender name if equal to the previous message (ignore in case the message needs to display a separator above)
663 if (!hasSeparator && message.id_sender == lastMessageAuthor.attr('data-sender-id') && message.sender_name == lastMessageAuthor.attr('data-sender-name')) {
664 messageContent.find('.content-author').hide();
665 }
666
667 if (!content.match(/<br\s*\/?>/)) {
668 // replace new lines with BR tags in case of plain text
669 content = content.replace(/\n/g, '<br />');
670 }
671
672 // add message text
673 messageContent.append($('<div class="content-text"></div>').html(content));
674
675 const messageTemplate = $('<div class="chat-message"></div>').attr('id', elem_id).append(
676 $('<div class="speech-user-avatar"></div>').addClass(is_sender ? 'speech-sender-avatar' : 'speech-recipient-avatar').html(avatar)
677 ).append(messageContent);
678
679 if (!content.length) {
680 messageTemplate.addClass('message-empty');
681 }
682
683 template = template.add(messageTemplate);
684
685 if (typeof message.attachments === 'string') {
686 try {
687 // try to decode the JSON attachments
688 message.attachments = JSON.parse(message.attachments);
689 } catch (err) {
690 // malformed string, use empty array
691 message.attachments = [];
692 }
693 }
694
695 const hasAttachments = message.attachments && message.attachments.length;
696
697 // check if the message has some attachments
698 if (hasAttachments) {
699 // iterate attachments
700 message.attachments.forEach((attachment, i) => {
701 const attachmentTemplate = messageTemplate.clone();
702
703 attachmentTemplate.attr('id', attachmentTemplate.attr('id') + '-attachment-' + (i + 1));
704 attachmentTemplate.addClass('is-attachment');
705 attachmentTemplate.removeClass('message-empty');
706
707 // get proper media element
708 const media = this.getMedia(attachment);
709
710 // check if we have something to show
711 if (media) {
712 attachmentTemplate.find('.message-content').html(media);
713 template = template.add(attachmentTemplate);
714 }
715 });
716 }
717
718 if (buffer) {
719 // return template in case of no animation
720 return template;
721 }
722
723 // append HTML to conversation box
724 $(this.data.element.conversation).append(template);
725
726 if (animate) {
727 // setup timeout to perform entrance animation
728 setTimeout(() => {
729 // in case of attachments, we need to find all the messages that
730 // starts with the message ID
731 const selector = hasAttachments ? '*[id^="' + elem_id + '"]' : '#' + elem_id;
732 // ease in message
733 $(selector).find('*.need-animation').removeClass('need-animation');
734
735 if (is_sender) {
736 // Auto-scroll to bottom only in case the message has been sent by this user.
737 // Otherwise the scroll will be delegated to the messages synchronization.
738 this.scrollToBottom();
739 }
740 }, 32);
741 }
742
743 return this;
744 }
745
746 /**
747 * Returns the details of the user that sent the specified message.
748 *
749 * @param object message The details of the message.
750 *
751 * @return object The user details.
752 */
753 getMessageUser(message) {
754 if (!this.data.environment.users.hasOwnProperty(message.id_sender)) {
755 // user not found, register it right now
756 this.data.environment.users[message.id_sender] = {
757 id: message.id_sender,
758 name: message.sender_name,
759 avatar: '',
760 }
761 }
762
763 // obtain a copy of the user instance
764 const user = Object.assign({}, this.data.environment.users[message.id_sender])
765
766 if (!user.name) {
767 // in case of empty name, use the one specified by the message
768 user.name = message.sender_name
769 }
770
771 return user;
772 }
773
774 /**
775 * Creates the avatar for the specified user.
776 *
777 * @param object user The user details.
778 *
779 * @return mixed The HTML node.
780 */
781 drawUserAvatar(user) {
782 let avatar = null;
783
784 if (user.avatar) {
785 avatar = $('<img decoding="async" loading="lazy" />')
786 .attr('alt', user.name)
787 .attr('title', user.name)
788 .attr('src', user.avatar);
789 } else {
790 // find first non-unicode letter in string
791 const firstLetter = (str) => {
792 return Array.from(str || '').find(c => /\p{L}/u.test(c)) || '';
793 }
794
795 // split name in chunks
796 let names = user.name.trim().split(/\s+/);
797
798 // build initials
799 let initials = firstLetter(names.shift()) + firstLetter(names.pop());
800
801 avatar = $('<span></span>')
802 .attr('title', user.name)
803 .text(initials.toUpperCase());
804 }
805
806 return avatar;
807 }
808
809 /**
810 * Renders the message content in order to replace certain tokens
811 * with a user-friendly representation.
812 * For example, an e-mail address could be wrapped within a link to
813 * open the mail app.
814 *
815 * @param string content The text to fetch.
816 *
817 * @return string The resulting string.
818 */
819 renderMessageContent(content) {
820 // get parsers list and sort by priority DESC
821 const pool = Object.values(this.contentParsers || {}).sort((a, b) => {
822 if (a.priority < b.priority) {
823 return 1;
824 }
825
826 if (a.priority > b.priority) {
827 return -1;
828 }
829
830 return 0;
831 });
832
833 pool.forEach((parser) => {
834 // keep a temporary flag
835 let tmp = content.toString();
836
837 // run parser callback (use tmp in case the callback forgot to the return the value)
838 content = parser['callback'](tmp) || tmp;
839 });
840
841 return content;
842 }
843
844 /**
845 * Attaches a callback that will be used to parse the contents.
846 * In case a function with the same ID already exists, that function
847 * will be replaced with this one.
848 *
849 * @param string id The parser identifier.
850 * @param function callback The function to run while parsing the contents.
851 * @param integer priority The callback priority. The higher the value, the
852 * higher the priority.
853 *
854 * @return self
855 */
856 attachContentParser(id, callback, priority) {
857 if (this.contentParsers === undefined) {
858 this.contentParsers = {};
859 }
860
861 // register callback
862 this.contentParsers[id] = {
863 callback: callback,
864 priority: priority || 10,
865 };
866
867 return this;
868 }
869
870 /**
871 * Tries to detach the parser that matches the specified id.
872 *
873 * @param string id The parser identifier.
874 *
875 * @return boolean True on success, false otherwise.
876 */
877 detachContentParser(id) {
878 // make sure the pool contains the id
879 if (this.contentParsers !== undefined && this.contentParsers.hasOwnProperty(id)) {
880 // detach parser
881 delete this.contentParsers[id];
882
883 return true;
884 }
885
886 return false;
887 }
888
889 /**
890 * Returns the most appropriate DOM element according to the specified URL.
891 * In case of a media file, the URL will be wrapped within a <img> tag.
892 * Otherwise a Font Icon will be used instead.
893 *
894 * @param object file The file object (name and url properties required).
895 *
896 * @return string The HTML media string.
897 */
898 getMedia(file) {
899 // always get a string
900 if (!file?.url?.toString) {
901 return '';
902 }
903
904 const url = file.url.toString();
905
906 if (!file.name) {
907 // there is something wrong with the attachment, use a broken icon
908 return '<i class="fas fa-unlink" title="' + url + '"></i>';
909 }
910
911 const onclick = "window.open('" + url + "', '_blank')";
912 const onload = "VBOChat.getInstance().onMediaLoaded(this)";
913
914 // check for images
915 if (url.match(/\.(a?png|bmp|gif|ico|jpe?g|svg|heic|webp)$/i)) {
916 return '<img src="' + url + '" onclick="' + onclick + '" onload="' + onload + '" title="' + file.name + '" />';
917 }
918
919 // check for playable video files
920 if (url.match(/\.(mp4|mov|ogm|webm)$/i)) {
921 return '<video controls onloadeddata="' + onload + '" title="' + file.name + '">\n' +
922 '<source src="' + url + '" />\n' +
923 '</video>';
924 }
925
926 // check for non-playable video files
927 if (url.match(/\.(3gp|asf|avi|divx|flv|mkv|mp?g|wmv|xvid)$/i)) {
928 return '<i class="fas fa-file-video" onclick="' + onclick + '" title="' + file.name + '"></i>';
929 }
930
931 // check for playable audio files
932 if (url.match(/\.(aac|m4a|mp3|ogg|opus|wave?)$/i)) {
933 return '<audio controls onloadeddata="' + onload + '" title="' + file.name + '">\n' +
934 '<source src="' + url + '" />\n' +
935 '</audio>';
936 }
937
938 // check for non-playable audio files
939 if (url.match(/\.(ac3|aiff|flac|midi?|wma)$/i)) {
940 return '<i class="fas fa-file-audio" onclick="' + onclick + '" title="' + file.name + '"></i>';
941 }
942
943 // check for archives
944 if (url.match(/\.(zip|tar|rar|gz|bzip2)$/i)) {
945 return '<i class="fas fa-file-archive" onclick="' + onclick + '" title="' + file.name + '"></i>';
946 }
947
948 // check for PDF
949 if (url.match(/\.pdf$/i)) {
950 return '<i class="fas fa-file-pdf" onclick="' + onclick + '" title="' + file.name + '"></i>';
951 }
952
953 // check for documents
954 if (url.match(/\.(docx?|rtf|odt|pages)$/i)) {
955 return '<i class="fas fa-file-word" onclick="' + onclick + '" title="' + file.name + '"></i>';
956 }
957
958 // check for excel-like sheets
959 if (url.match(/\.(xlsx?|csv|ods|numbers)$/i)) {
960 return '<i class="fas fa-file-excel" onclick="' + onclick + '" title="' + file.name + '"></i>';
961 }
962
963 // check for presentations
964 if (url.match(/\.(ppsx?|odp|keynote)$/i)) {
965 return '<i class="fas fa-file-powerpoint" onclick="' + onclick + '" title="' + file.name + '"></i>';
966 }
967
968 // check for plain text documents
969 if (url.match(/\.(txt|md|markdown)$/i)) {
970 return '<i class="fas fa-file-alt" onclick="' + onclick + '" title="' + file.name + '"></i>';
971 }
972
973 // use standard file
974 return '<i class="fas fa-file" onclick="' + onclick + '" title="' + file.name + '"></i>';
975 }
976
977 /**
978 * Handler invoked every time a media file has been loaded.
979 *
980 * @param mixed element The media element.
981 *
982 * @return void
983 */
984 onMediaLoaded(element) {
985 // check if we should scroll after loading a media file,
986 // because if we are loading previous file, we don't
987 // need to scroll down
988 if (this.shouldScroll(element.offsetHeight + 30)) {
989 // scroll down
990 this.scrollToBottom();
991 }
992 }
993
994 /**
995 * Creates a new progress bar.
996 *
997 * @return string The progress bar ID.
998 */
999 createProgressBar() {
1000 if (this.data.environment.idProgress === undefined) {
1001 this.data.environment.idProgress = 0;
1002 }
1003
1004 // increment ID
1005 const id = 'progress-bar-' + (++this.data.environment.idProgress);
1006
1007 // create progress bar
1008 $(this.data.element.progressBar)
1009 .append('<div class="chat-progress-bar" id="' + id + '"><div>&nbsp;</div></div>')
1010 .parent()
1011 .show();
1012
1013 return id;
1014 }
1015
1016 /**
1017 * Removes the specified progress bar.
1018 *
1019 * @param string id The progress bar ID.
1020 *
1021 * @return self
1022 */
1023 removeProgressBar(id) {
1024 $(this.data.element.progressBar).find('#' + id).remove();
1025
1026 return this;
1027 }
1028
1029 /**
1030 * Updates the progress value of the specified bar.
1031 *
1032 * @param string id The progress bar ID.
1033 * @param integer progress The progress amount.
1034 *
1035 * @return self
1036 */
1037 updateProgressBar(id, progress) {
1038 progress = Math.max(0, progress);
1039 progress = Math.min(100, progress);
1040
1041 $(this.data.element.progressBar).find('#' + id + ' > div').width(progress + '%').html(progress + '%');
1042
1043 return this;
1044 }
1045
1046 /**
1047 * Reads the pending notifications.
1048 *
1049 * @return self
1050 */
1051 readNotifications() {
1052 // ignore in case the chat shouldn't auto-read the unread messages
1053 if (!this.data.environment.options.autoread) {
1054 return this;
1055 }
1056
1057 const unread = this.getLatestUnreadMessage();
1058
1059 if (!unread) {
1060 return this;
1061 }
1062
1063 // make AJAX request to read all the messages under this context
1064 VBOChatAjax.do(
1065 // end-point URL
1066 this.data.environment.url,
1067 // POST data
1068 {
1069 task: 'chat.read_messages',
1070 id_context: this.data.environment.context.id,
1071 context: this.data.environment.context.alias,
1072 datetime: unread.createdon,
1073 },
1074 (messages) => {
1075 // flag all the messages as read
1076 messages.forEach((msgId) => {
1077 const msg = this.getMessage(msgId);
1078
1079 if (msg) {
1080 msg.read = true;
1081 }
1082 });
1083
1084 // trigger event
1085 this.triggerEvent('chat.read', {
1086 chat: this,
1087 });
1088 }
1089 );
1090
1091 return this;
1092 }
1093
1094 /**
1095 * Returns the HTML to use for a date separator.
1096 *
1097 * @param string|object datetime The datetime to use.
1098 *
1099 * @return string The HTML separator.
1100 */
1101 getDateSeparator(datetime) {
1102 let dt_str = '';
1103
1104 if (DateHelper.isToday(datetime)) {
1105 // current day: get formatted time
1106 dt_str = Joomla.JText._('VBTODAY');
1107 } else if (DateHelper.isYesterday(datetime)) {
1108 // previous day: use "yesterday"
1109 dt_str = Joomla.JText._('VBOYESTERDAY');
1110 } else if ((dt_str = DateHelper.diff(datetime, new Date(), 'days')) < 7) {
1111 let tmp = new Date();
1112 tmp.setDate(tmp.getDate() - dt_str);
1113 dt_str = tmp.toLocaleDateString([], {weekday: 'long'});
1114 } else {
1115 // use formatted date
1116 dt_str = DateHelper.getFormattedDate(datetime);
1117 }
1118
1119 return $('<div class="chat-datetime-separator is-a-separator"</div>')
1120 .attr('data-datetime', DateHelper.toStringUTC(datetime))
1121 .text(dt_str + ', ' + DateHelper.getFormattedTime(datetime));
1122 }
1123
1124 /**
1125 * Merges the messages with the ones stored within the internal state.
1126 *
1127 * @param array resp The messages.
1128 *
1129 * @return array Returns a list containing all the new messages.
1130 */
1131 mergeMessages(resp) {
1132 let newMessages = [], missedMessages = [];
1133
1134 // update messages list
1135 for (let i = 0; i < resp.length; i++) {
1136 // get message that matches the current ID
1137 let message = this.getMessage(resp[i].id);
1138
1139 if (message) {
1140 continue;
1141 }
1142
1143 // detect the correct position of the message
1144 let messageIndex = this.findMessagePosition(resp[i]);
1145
1146 // do not add the message in case it exceeds the static pagination limit, otherwise
1147 // the system might add it twice while loading older messages
1148 if (messageIndex >= this.data.environment.options.limit) {
1149 continue;
1150 }
1151
1152 // insert message within the list
1153 this.data.environment.messages.splice(messageIndex, 0, resp[i]);
1154
1155 if (messageIndex == 0) {
1156 // register message only if new
1157 newMessages.push(resp[i]);
1158 } else {
1159 missedMessages.push(resp[i]);
1160 }
1161 }
1162
1163 return {
1164 newMessages: newMessages,
1165 missedMessages: missedMessages,
1166 };
1167 }
1168
1169 /**
1170 * Uploads the given files.
1171 *
1172 * @param mixed files The files list.
1173 *
1174 * @return self
1175 */
1176 uploadAttachments(files) {
1177 // create form data for upload
1178 const formData = new FormData();
1179
1180 // inject order data
1181 formData.append('id_context', this.data.environment.context.id);
1182 formData.append('context', this.data.environment.context.alias);
1183 formData.append('task', 'chat.upload_attachments');
1184
1185 // iterate files and append to form data
1186 for (let i = 0; i < files.length; i++) {
1187 formData.append('attachments[]', files[i]);
1188 }
1189
1190 // create progress bar
1191 const id_progress = this.createProgressBar();
1192
1193 VBOChatAjax.upload(
1194 // end-point URL
1195 this.data.environment.url,
1196 // file post data
1197 formData,
1198 // success callback
1199 (resp) => {
1200 // remove progress bar
1201 this.removeProgressBar(id_progress);
1202
1203 for (let i = 0; i < resp.length; i++) {
1204 // register uploaded attachment
1205 this.registerAttachment(resp[i]);
1206 }
1207 },
1208 // failure callback
1209 (error) => {
1210 // remove progress bar
1211 this.removeProgressBar(id_progress);
1212
1213 // remove attachments bar as well in case there are no files
1214 if (this.data.environment.attachments.length == 0) {
1215 this.clearAttachments();
1216 }
1217
1218 // raise alert
1219 this.alert(error.responseText || error.statusText || 'Connection lost!');
1220 },
1221 // progress callback
1222 (progress) => {
1223 // update progress bar
1224 this.updateProgressBar(id_progress, progress);
1225 },
1226 ).critical();
1227
1228 return this;
1229 }
1230
1231 /**
1232 * Registers the file within the attachments bar.
1233 *
1234 * @param object file The file to attach.
1235 *
1236 * @return self
1237 */
1238 registerAttachment(file) {
1239 // push file within the list
1240 this.data.environment.attachments.push(file);
1241
1242 file.id = file.filename.replace(/\.[^.]*$/, '');
1243
1244 $(this.data.element.uploadsBar)
1245 .append('<span class="chat-attachment" id="' + file.id + '">' + file.name + '<i class="fas fa-times"></i></span>')
1246 .parent()
1247 .show();
1248
1249 // register event to remove attachment after clicking the TIMES icon
1250 $('#' + file.id).find('i.fa-times').on('click', (event) => {
1251 // remove attachment
1252 this.removeAttachment(file);
1253 });
1254
1255 return this;
1256 }
1257
1258 /**
1259 * Returns the index of the specified attachment.
1260 *
1261 * @param mixed file The file object or its ID.
1262 *
1263 * @return integer The file index on success, otherwise -1.
1264 */
1265 getAttachmentIndex(file) {
1266 let id = null
1267
1268 if (typeof file === 'object') {
1269 id = file.id;
1270 } else {
1271 id = file;
1272 }
1273
1274 for (let i = 0; i < this.data.environment.attachments.length; i++) {
1275 if (this.data.environment.attachments[i].id === id) {
1276 return i;
1277 }
1278 }
1279
1280 return -1;
1281 }
1282
1283 /**
1284 * Removes the specified attachment by unlinking it too.
1285 *
1286 * @param object file The file object to remove.
1287 *
1288 * @return self
1289 */
1290 removeAttachment(file) {
1291 // get attachment index
1292 const index = this.getAttachmentIndex(file);
1293
1294 if (index != -1) {
1295 // remove attachment box
1296 $('#' + file.id).remove();
1297
1298 if (this.data.environment.attachments.length > 1) {
1299 // splice attachments array
1300 this.data.environment.attachments.splice(index, 1);
1301 } else {
1302 // clear all as we are going to have an empty list
1303 this.clearAttachments();
1304 }
1305
1306 // do not attempt deleting the file in case it is marked as "custom"
1307 if (!file.custom) {
1308 // make AJAX request to unlink the specified attachment
1309 VBOChatAjax.do(
1310 // end-point URL
1311 this.data.environment.url,
1312 // POST data
1313 {
1314 task: 'chat.remove_attachment',
1315 id_context: this.data.environment.context.id,
1316 context: this.data.environment.context.alias,
1317 attachment: file,
1318 }
1319 );
1320 }
1321 }
1322
1323 return this;
1324 }
1325
1326 /**
1327 * Clears the attachments list.
1328 *
1329 * @return self
1330 */
1331 clearAttachments() {
1332 // clear attachments
1333 this.data.environment.attachments = [];
1334
1335 // hide uploads bar
1336 $(this.data.element.uploadsBar)
1337 .html('')
1338 .parent()
1339 .hide();
1340
1341 return this;
1342 }
1343
1344 /**
1345 * Triggers the specified event.
1346 *
1347 * @param string name The event name.
1348 * @param mixed data The data to inject within event.detail property.
1349 *
1350 * @return self
1351 */
1352 triggerEvent(name, data) {
1353 // create CustomEvent by injecting our own data
1354 const event = new CustomEvent(name, {detail: data});
1355
1356 // dispatch event from window
1357 window.dispatchEvent(event);
1358
1359 return this;
1360 }
1361
1362 /**
1363 * AJAX call used to load older messages of the current context.
1364 * This function is usually invoked when the scroll hits the first half
1365 * of the conversation.
1366 *
1367 * @return self
1368 */
1369 loadPreviousMessages() {
1370 if (this.data.environment.isLoadingOlderMessages) {
1371 // do not proceed in case we are already loading something
1372 return this;
1373 }
1374
1375 // mark loading flag
1376 this.data.environment.isLoadingOlderMessages = true;
1377
1378 let limit = this.data.environment.options.limit;
1379
1380 // make AJAX request to load older messages
1381 VBOChatAjax.do(
1382 // end-point URL
1383 this.data.environment.url,
1384 // POST data
1385 {
1386 task: 'chat.load_older_messages',
1387 id_context: this.data.environment.context.id,
1388 context: this.data.environment.context.alias,
1389 start: this.data.environment.thread.messagesLength,
1390 limit: limit,
1391 /**
1392 * We need to pass the initial date time in order to exclude
1393 * all the messages that are newer than the latest message we got
1394 * when the page was loaded.
1395 *
1396 * This will avoid errors while retriving older messages
1397 * as new records would shift the current limits.
1398 */
1399 datetime: this.data.environment.thread.initialDatetime,
1400 },
1401 // success callback
1402 (resp) => {
1403 // make loading available again
1404 this.data.environment.isLoadingOlderMessages = false;
1405
1406 const conversation = $(this.data.element.conversation)[0];
1407
1408 // keep current scroll
1409 let currentScrollTop = conversation.scrollTop;
1410 let currentScrollHeight = conversation.scrollHeight;
1411
1412 // update count of loaded messages
1413 this.data.environment.thread.messagesLength += resp.length;
1414
1415 // get current index
1416 let start = this.data.environment.messages.length;
1417 let end = start + resp.length;
1418
1419 // push messages within the list
1420 for (let i = 0; i < resp.length; i++) {
1421 // add message only if it doesn't exist yet
1422 if (!this.getMessage(resp[i].id)) {
1423 // detect the correct position of the message
1424 let messageIndex = this.findMessagePosition(resp[i]);
1425
1426 // insert message within the list
1427 this.data.environment.messages.splice(messageIndex, 0, resp[i]);
1428 }
1429 }
1430
1431 // turn off scroll event in case we reached the limit
1432 if (resp.length < limit) {
1433 $(this.data.element.conversation).off('scroll');
1434 }
1435
1436 // build conversation messages
1437 this.buildConversation(start, end);
1438
1439 // Recalculate scroll position.
1440 // The new scroll top position will be increased by the difference between
1441 // the old scroll height and the new one.
1442 let newScrollTop = currentScrollTop + (conversation.scrollHeight - currentScrollHeight);
1443 $(conversation).scrollTop(newScrollTop);
1444 },
1445 // failure callback
1446 (error) => {
1447 // make loading available again
1448 this.data.environment.isLoadingOlderMessages = false;
1449 }
1450 );
1451 }
1452
1453 /**
1454 * AJAX call used to synchronize the messages.
1455 * This should be used to load the messages that haven't been
1456 * downloaded by the system.
1457 *
1458 * @return self
1459 */
1460 synchronizeMessages() {
1461 // get latest message to evaluate a threshold
1462 const latest = this.getLatestMessage();
1463
1464 // make request to synchronize the messages
1465 const xhr = VBOChatAjax.do(
1466 // end-point URL
1467 this.data.environment.url,
1468 // POST data
1469 {
1470 task: 'chat.sync_messages',
1471 id_context: this.data.environment.context.id,
1472 context: this.data.environment.context.alias,
1473 threshold: latest ? latest.id : 0,
1474 },
1475 // success callback
1476 (resp) => {
1477 if (typeof resp?.metadata === 'object') {
1478 // refresh context metadata
1479 this.data.environment.context.metadata = resp.metadata;
1480 }
1481
1482 // trigger event
1483 this.triggerEvent('chat.sync.before', {
1484 chat: this,
1485 messages: resp?.messages || [],
1486 });
1487
1488 if (!resp.messages.length) {
1489 // do nothing in case the response is empty
1490 return;
1491 }
1492
1493 // check if the chat should scroll after collecting new messages
1494 let should_scroll = this.shouldScroll();
1495
1496 // update messages
1497 let {newMessages, missedMessages} = this.mergeMessages(resp.messages.reverse());
1498
1499 if (!newMessages.length && !missedMessages.length) {
1500 // stop process in case nothing has changed
1501 return;
1502 }
1503
1504 // collect new messages
1505 for (var i = 0; i < newMessages.length; i++) {
1506 // draw message (animation needed)
1507 this.drawMessage(newMessages[i], true);
1508 }
1509
1510 if (newMessages.length) {
1511 // trigger event
1512 this.triggerEvent('chat.sync', {
1513 notifications: newMessages.length,
1514 chat: this,
1515 });
1516 }
1517
1518 if (missedMessages.length) {
1519 // rebuild the conversation to properly display the missed messages too
1520 this.startConversation().buildConversation();
1521 // ignore auto-scroll as it should have been already applied
1522 should_scroll = false;
1523 }
1524
1525 /**
1526 * Use bottom scroll only in case the message is visible
1527 * within the scroll. In this way, if we are reading older messages
1528 * we won't pushed back at the page bottom. Contrarily, in case we
1529 * are keeping an eye on the latest messages, the chat will be scrolled
1530 * automatically.
1531 */
1532 if (should_scroll && newMessages.length) {
1533 // scroll conversation to the next message
1534 this.scrollToMessage(newMessages[0]);
1535 }
1536
1537 // flush notifications for active chat
1538 this.readNotifications();
1539
1540 /**
1541 * After registering the messages we
1542 * need to fetch the payload in order to build the
1543 * proper input for the response.
1544 */
1545 this.renderInput();
1546 }
1547 );
1548
1549 // Callback must be invoked after chat.send request.
1550 // In case this process ends while send() request is still
1551 // running, the callback will be pushed within a queue of promises.
1552 // Promises are automatically flushed after the completion of the
1553 // last running send() process.
1554 xhr.after('chat.send');
1555 }
1556
1557 /**
1558 * AJAX call used to send the message to the recipients of the current context.
1559 * The message is always pushed within the chat even if the connection fails.
1560 * In that case, the message will report a button that could be used to re-send
1561 * the message.
1562 *
1563 * @param mixed message The message to send.
1564 *
1565 * @return self
1566 */
1567 send(message) {
1568 let id = null, data = null;
1569
1570 if (typeof message === 'object') {
1571 // use passed data
1572 data = message;
1573 id = message.id;
1574 } else {
1575 // validate message as string
1576 if (!message.length && this.data.environment.attachments.length == 0) {
1577 return this;
1578 }
1579
1580 // trim message
1581 message = message.trim();
1582
1583 // generate temporary ID
1584 id = this.getNextID();
1585
1586 // build chat bubble
1587 data = this.collect(id, message, this.data.environment.user);
1588
1589 if (data) {
1590 data.attachments = this.data.environment.attachments;
1591
1592 // clear attachments bar
1593 this.clearAttachments();
1594 }
1595 }
1596
1597 if (!data) {
1598 // something went wrong while collecting the message, abort
1599 return this;
1600 }
1601
1602 // Always re-render input after sending a message.
1603 // At this point, a textarea should be always used.
1604 this.renderInput();
1605
1606 this.input.disable();
1607
1608 // trigger event
1609 this.triggerEvent('chat.send.before', {
1610 message: message,
1611 chat: this,
1612 });
1613
1614 // make request to reply to an existing message (CRITICAL)
1615 const xhr = VBOChatAjax.do(
1616 // end-point URL
1617 this.data.environment.url,
1618 // POST data
1619 {
1620 task: 'chat.send',
1621 id_context: this.data.environment.context.id,
1622 context: this.data.environment.context.alias,
1623 message: data.message,
1624 createdon: data.createdon,
1625 attachments: data.attachments,
1626 },
1627 // success callback
1628 (message) => {
1629 // get index of dummy message
1630 const dummyIndex = this.getMessageIndex(data.id);
1631
1632 if (dummyIndex != -1) {
1633 // update message with received response
1634 this.data.environment.messages[dummyIndex] = message;
1635 }
1636
1637 // always re-enable the textarea in case of success
1638 this.input.enable();
1639
1640 // trigger event
1641 this.triggerEvent('chat.send', {
1642 message: message,
1643 chat: this,
1644 });
1645 },
1646 // failure callback
1647 (error) => {
1648 const chat = this;
1649
1650 /**
1651 * Something went wrong while trying to send the message.
1652 * Place "re-try" button within the message box so that the user
1653 * will be able to resend the message by clicking it.
1654 */
1655 $('#' + id).find('.message-content').append($('<div class="message-error-result"></div>').text(error.responseText))
1656 .find('.content-text').append('<i class="fas fa-exclamation-circle"></i>');
1657
1658 // register event to re-send the message after clicking the exclamation triangle
1659 $('#' + id).find('.message-content i.fa-exclamation-circle').on('click', function(event) {
1660 // remove any possible explanation of the error
1661 $(this).closest('.message-content').find('.message-error-result').remove();
1662
1663 // remove icon from message
1664 $(this).off('click').remove();
1665
1666 // re-send the message
1667 chat.send(data);
1668 });
1669
1670 // scroll down to make the whole error immediately visible
1671 chat.scrollToBottom();
1672
1673 // obtain message
1674 const tmp = this.getMessage(data.id);
1675
1676 if (tmp) {
1677 // mark error
1678 tmp.hasError = true;
1679 }
1680
1681 // always re-enable the textarea in case of success
1682 this.input.enable();
1683
1684 // trigger event
1685 this.triggerEvent('chat.send.failed', {
1686 message: tmp,
1687 chat: this,
1688 error: error,
1689 });
1690 }
1691 );
1692
1693 // mark request as critical
1694 xhr.critical();
1695 // set identifier to request
1696 xhr.identify('chat.send');
1697
1698 return this;
1699 }
1700
1701 /**
1702 * Renders the input according to the specified payload
1703 * of the latest received message.
1704 *
1705 * @return self
1706 */
1707 renderInput() {
1708 // create default form field data
1709 const data = {
1710 type: 'text',
1711 hint: Joomla.JText._('VBO_CHAT_TEXTAREA_PLACEHOLDER'),
1712 };
1713
1714 // make sure the input is different than the current one
1715 if (this.input && Object.equals(this.input.payload, data)) {
1716 return this;
1717 }
1718
1719 let input = null;
1720
1721 try {
1722 // get input class
1723 input = VBOChatField.getInstance(data);
1724 } catch (err) {
1725 // the given type seems to be not supported, try to use the default one
1726 Object.assign(data, def);
1727 input = VBOChatField.getInstance(data);
1728 }
1729
1730 if (this.input) {
1731 // destroy input set previously
1732 this.input.onDestroy(this);
1733 }
1734
1735 // keep reference to new input
1736 this.input = input;
1737
1738 // render input HTML
1739 const html = this.input.render();
1740
1741 // set rendered HTML into input box
1742 $(this.data.element.inputBox).html(html);
1743
1744 // init new input
1745 this.input.onInit(this);
1746
1747 return this;
1748 }
1749
1750 /**
1751 * Clears all the intervals previously registered.
1752 *
1753 * @return self
1754 */
1755 destroy() {
1756 if (!this.data) {
1757 // chat never initialized, immediately abort
1758 return this;
1759 }
1760
1761 this.timers.forEach((interval_id, index) => {
1762 clearInterval(interval_id);
1763 });
1764
1765 this.timers = [];
1766
1767 // unregister conversation scroll event to load older messages
1768 $(this.data.element.conversation).off('scroll');
1769
1770 if (this.input) {
1771 // destroy input previously set
1772 this.input.onDestroy(this);
1773 this.input = null;
1774 }
1775
1776 // clear conversation messages
1777 $(this.data.element.conversation).html('');
1778
1779 // the chat requires to be prepared again
1780 this.isPrepared = false;
1781
1782 // delete internal data
1783 delete this.data;
1784
1785 // check whether we previously has another chat
1786 const lastData = VBOChat.queue.pop();
1787
1788 if (lastData) {
1789 // restore the behavior of the previous chat
1790 VBOChat.getInstance(lastData);
1791 }
1792
1793 return this;
1794 }
1795
1796 /**
1797 * Displays the error message to the user.
1798 * A system alert will be used by default.
1799 *
1800 * @param string error The error message to display.
1801 *
1802 * @return void
1803 */
1804 alert(error) {
1805 setTimeout(() => {
1806 alert(error);
1807 }, 32);
1808 }
1809 }
1810
1811 /**
1812 * Holds the environment details of the previously initialized chats.
1813 * A chat environment is pushed here when a new chat is created without
1814 * destroying the previous one first.
1815 */
1816 VBOChat.queue = [];
1817
1818 /**
1819 * VBOChatField class.
1820 * Abstract representation of a form field.
1821 * This class acts also as a field factory, as the fields
1822 * should be instantiated by using the getInstance() static method:
1823 * var field = VBOChatField.getInstance({type: 'text'});
1824 */
1825 w['VBOChatField'] = class VBOChatField {
1826
1827 /**
1828 * Returns an instance of the requested field.
1829 * The field will be recognized by checking the
1830 * type property contained within data argument.
1831 *
1832 * @param object data The field attributes.
1833 *
1834 * @return mixed The new field.
1835 */
1836 static getInstance(data) {
1837 // make sure the type exists
1838 if (!data.hasOwnProperty('type') || !data.type) {
1839 throw 'Missing type property';
1840 }
1841
1842 // fetch field class name
1843 const className = 'VBOChatField' + data.type.charAt(0).toUpperCase() + data.type.substr(1);
1844
1845 // make sure the class exists
1846 if (!VBOChatField.classMap.hasOwnProperty(data.type)) {
1847 throw 'Form field [' + className + '] not found';
1848 }
1849
1850 // find class
1851 const _class = VBOChatField.classMap[data.type];
1852
1853 // instantiate field
1854 return new _class(data);
1855 }
1856
1857 /**
1858 * Class constructor.
1859 *
1860 * @param object data The field attributes.
1861 */
1862 constructor(data) {
1863 this.data = data;
1864 // keep a copy of the payload which shouldn't be altered
1865 this.payload = Object.assign({}, data);
1866
1867 if (!this.data.id) {
1868 // generate an incremental ID
1869 if (!VBOChatField.incrementalId) {
1870 VBOChatField.incrementalId = 0;
1871 }
1872
1873 this.data.id = 'chat-answer-field-' + (++VBOChatField.incrementalId);
1874 }
1875 }
1876
1877 /**
1878 * Binds the given data.
1879 *
1880 * @param string k The attribute name.
1881 * @param mixed v The attribute value.
1882 *
1883 * @return self
1884 */
1885 bind(k, v) {
1886 this.data[k] = v;
1887
1888 return this;
1889 }
1890
1891 /**
1892 * Method used to return the field value.
1893 *
1894 * @return mixed The value.
1895 */
1896 getValue() {
1897 return $('#' + this.data.id).val();
1898 }
1899
1900 /**
1901 * Method used to set the field value.
1902 *
1903 * @param mixed val The value to set.
1904 *
1905 * @return mixed The source element.
1906 */
1907 setValue(val) {
1908 return $('#' + this.data.id).val(val).trigger('input');
1909 }
1910
1911 /**
1912 * Focuses the input.
1913 *
1914 * @return self
1915 */
1916 focus() {
1917 $('#' + this.data.id).focus();
1918
1919 return this;
1920 }
1921
1922 /**
1923 * Enables the input.
1924 *
1925 * @return self
1926 */
1927 enable() {
1928 $('#' + this.data.id).prop('disabled', false).focus();
1929
1930 return this;
1931 }
1932
1933 /**
1934 * Disables the input.
1935 *
1936 * @return self
1937 */
1938 disable() {
1939 $('#' + this.data.id).prop('disabled', true);
1940
1941 return this;
1942 }
1943
1944 /**
1945 * Method used to return the field selector.
1946 *
1947 * @return mixed The field selector.
1948 */
1949 getSelector() {
1950 return '#' + this.data.id;
1951 }
1952
1953 /**
1954 * Abstract method used to obtain the input HTML.
1955 *
1956 * @return string The input html.
1957 */
1958 render() {
1959 // inherit in children classes
1960 }
1961
1962 /**
1963 * Abstract method used to initialise the field.
1964 * This method is called once the field has been
1965 * added within the document.
1966 *
1967 * @param VBOChat chat The chat instance.
1968 *
1969 * @return void
1970 */
1971 onInit(chat) {
1972 // inherit in children classes
1973
1974 if (this.data.onInit) {
1975 // invoke also custom initialize
1976 this.data.onInit(chat);
1977 }
1978 }
1979
1980 /**
1981 * Abstract method used to destroy the field.
1982 * This method is called before removing the field
1983 * from the document.
1984 *
1985 * @param VBOChat chat The chat instance.
1986 *
1987 * @return void
1988 */
1989 onDestroy(chat) {
1990 // inherit in children classes
1991
1992 if (this.data.onDestroy) {
1993 // invoke also custom destroy
1994 this.data.onDestroy(chat);
1995 }
1996 }
1997
1998 }
1999
2000 /**
2001 * Form fields classes lookup.
2002 */
2003 VBOChatField.classMap = {};
2004
2005 /**
2006 * VBOChatFieldText class.
2007 * This field is used to display a HTML input textarea.
2008 */
2009 w['VBOChatFieldText'] = class VBOChatFieldText extends VBOChatField {
2010
2011 /**
2012 * @override
2013 * Method used to obtain the input HTML.
2014 *
2015 * @return string The input html.
2016 */
2017 render() {
2018 // fetch attributes
2019 let attrs = '';
2020
2021 if (this.data.name) {
2022 attrs += 'name="' + this.data.name.escape() + '" ';
2023 }
2024
2025 if (this.data.id) {
2026 attrs += 'id="' + this.data.id.escape() + '" ';
2027 }
2028
2029 if (this.data.class) {
2030 attrs += 'class="' + this.data.class.escape() + '" ';
2031 }
2032
2033 if (this.data.hint) {
2034 attrs += 'placeholder="' + this.data.hint.escape() + '" ';
2035 }
2036
2037 if (this.data.value === undefined) {
2038 this.data.value = this.data.default !== undefined ? this.data.default : '';
2039 }
2040
2041 // define default ID for context menu trigger
2042 this.data.idContextMenu = this.data.id + '-ctxmenu';
2043
2044 // define default ID for attachment input
2045 this.data.idAttachment = this.data.id + '-attachment-input';
2046
2047 // define default ID for manual send message button
2048 this.data.idManualSend = this.data.id + '-manual-send';
2049
2050 // return input
2051 return '<div class="send-message-actions">\n'+
2052 '<a href="javascript:void(0)" class="chat-action-btn ctx-actions" id="' + this.data.idContextMenu + '" aria-label="more actions"><i class="fas fa-ellipsis-v"></i></a>\n'+
2053 '</div>\n'+
2054 '<textarea rows="1" ' + attrs.trim() + '>' + this.data.value + '</textarea>\n' +
2055 '<span id="' + this.data.idManualSend + '" class="manual-send-message"><i class="fas fa-paper-plane"></i></span>\n'+
2056 '<input type="file" id="' + this.data.idAttachment + '" multiple="multiple" style="display:none;" />\n';
2057 }
2058
2059 /**
2060 * Method used to initialise the field.
2061 * This method is called once the field has been
2062 * added within the document.
2063 *
2064 * @param VBOChat chat The chat instance.
2065 *
2066 * @return void
2067 */
2068 onInit(chat) {
2069 // invoke parent first
2070 super.onInit(chat);
2071
2072 const target = $('#' + this.data.id);
2073 if (!target.length) {
2074 return;
2075 }
2076
2077 let padding = 0;
2078 padding += parseFloat(target.css('padding-top').replace(/[^0-9.]/g, ''));
2079 padding += parseFloat(target.css('padding-bottom').replace(/[^0-9.]/g, ''));
2080
2081 // init textarea events
2082 target.on('input', function () {
2083 this.style.height = 'auto';
2084 this.style.height = (this.scrollHeight - padding) + 'px';
2085 });
2086
2087 // init manual send message listener
2088 $('#' + this.data.idManualSend).on('click', () => {
2089 chat.send(target.val());
2090 target.val('');
2091 target.css('height', 'auto');
2092 });
2093
2094 // init attachments upload
2095 $('#' + this.data.idAttachment).on('change', function(event) {
2096 // get selected files
2097 const files = $(this)[0].files;
2098
2099 // upload attachments
2100 chat.uploadAttachments(files);
2101
2102 // unset input file value
2103 $(this).val(null);
2104 });
2105
2106 let actions = [];
2107
2108 chat.data.environment.context.actions.forEach((button) => {
2109 if (button.namespace) {
2110 if (!button.text) {
2111 button.text = (...args) => {
2112 const event = $.Event('chat.' + button.namespace + '.text');
2113 event.args = args.concat([button, chat]);
2114 event.displayText = null;
2115 $(window).trigger(event);
2116 return event.displayText;
2117 }
2118 }
2119
2120 if (!button.icon) {
2121 button.icon = (...args) => {
2122 const event = $.Event('chat.' + button.namespace + '.icon');
2123 event.args = args.concat([button, chat]);
2124 event.displayIcon = null;
2125 $(window).trigger(event);
2126 return event.displayIcon;
2127 }
2128 }
2129
2130 if (!button.action) {
2131 button.action = (...args) => {
2132 const event = $.Event('chat.' + button.namespace + '.action');
2133 event.args = args.concat([button, chat]);
2134 $(window).trigger(event);
2135 };
2136 }
2137
2138 if (!button.disabled) {
2139 button.disabled = (...args) => {
2140 const event = $.Event('chat.' + button.namespace + '.disabled');
2141 event.args = args.concat([button, chat]);
2142 event.shouldDisable = false;
2143 $(window).trigger(event);
2144 return event.shouldDisable;
2145 }
2146 }
2147
2148 if (!button.visible) {
2149 button.visible = (...args) => {
2150 const event = $.Event('chat.' + button.namespace + '.visible');
2151 event.args = args.concat([button, chat]);
2152 event.shouldDisplay = true;
2153 $(window).trigger(event);
2154 return event.shouldDisplay;
2155 }
2156 }
2157 }
2158
2159 actions.push(button);
2160 });
2161
2162 // init context menu
2163 $('#' + this.data.idContextMenu).vboContextMenu({
2164 placement: 'top-left',
2165 buttons: actions.concat([
2166 // File upload
2167 {
2168 text: 'Upload a file',
2169 icon: 'fas fa-paperclip',
2170 separator: true,
2171 action: (root, event) => {
2172 event.preventDefault();
2173
2174 setTimeout(() => {
2175 $('#' + this.data.idAttachment).trigger('click');
2176 }, 16);
2177 },
2178 }
2179 ]),
2180 });
2181 }
2182
2183 /**
2184 * Method used to destroy the field.
2185 * This method is called before removing the field
2186 * from the document.
2187 *
2188 * @param VBOChat chat The chat instance.
2189 *
2190 * @return void
2191 */
2192 onDestroy(chat) {
2193 // invoke parent first
2194 super.onInit(chat);
2195
2196 // turn off textarea events before destroying it
2197 $('#' + this.data.id).off('input').off('keydown');
2198 // turn off manual send event
2199 $('#' + this.data.idManualSend).off('click');
2200 // turn off attachments events
2201 $('#' + this.data.idAttachment).off('change');
2202 // turn off context menu
2203 $('#' + this.data.idContextMenu).vboContextMenu('destroy');
2204 }
2205
2206 }
2207
2208 // Register class within the lookup
2209 VBOChatField.classMap.text = VBOChatFieldText;
2210
2211 /**
2212 * DateHelper class.
2213 * Helper class used to handle date objects.
2214 */
2215 w['DateHelper'] = class DateHelper {
2216
2217 /**
2218 * Checks if the specified date matches the current day.
2219 *
2220 * @param string|Date dt The date to check.
2221 *
2222 * @return boolean True if today, otherwise false.
2223 */
2224 static isToday(dt) {
2225 // compare specified date with current day
2226 return DateHelper.isSameDay(dt, new Date());
2227 }
2228
2229 /**
2230 * Checks if the specified date matches the previous day.
2231 *
2232 * @param string|Date dt The date to check.
2233 *
2234 * @return boolean True if yesterday, otherwise false.
2235 */
2236 static isYesterday(dt) {
2237 // get yesterday date object
2238 var yesterday = new Date();
2239 yesterday.setDate(yesterday.getDate() - 1);
2240
2241 // compare specified date with previous day
2242 return DateHelper.isSameDay(dt, yesterday);
2243 }
2244
2245 /**
2246 * Checks if the specified dates are equals without
2247 * considering the related times.
2248 *
2249 * @param string|Date a The first date to check.
2250 * @param string|Date b The second date to check.
2251 *
2252 * @return boolean True if equals, otherwise false.
2253 */
2254 static isSameDay(a, b) {
2255 // convert string to date
2256 if (typeof a === 'string') {
2257 a = DateHelper.stringToDate(a);
2258 }
2259
2260 // convert string to date
2261 if (typeof b === 'string') {
2262 b = DateHelper.stringToDate(b);
2263 }
2264
2265 // check if the specified days are matching (exclude time)
2266 return (a.getDate() == b.getDate() && a.getMonth() == b.getMonth() && a.getFullYear() == b.getFullYear());
2267 }
2268
2269 /**
2270 * Calculate the difference between the specified dates.
2271 * The difference is always an absolute value.
2272 *
2273 * @param string|Date a The first date to check.
2274 * @param string|Date b The second date to check.
2275 * @param string unit The difference unit [seconds, minutes, hours, days].
2276 *
2277 * @return integer The related difference according to the specified unit.
2278 */
2279 static diff(a, b, unit) {
2280 // convert string to date
2281 if (typeof a === 'string') {
2282 a = DateHelper.stringToDate(a);
2283 } else {
2284 // create new instance in order to avoid manipulating the given object
2285 a = new Date(a);
2286 }
2287
2288 // convert string to date
2289 if (typeof b === 'string') {
2290 b = DateHelper.stringToDate(b);
2291 } else {
2292 // create new instance in order to avoid manipulating the given object
2293 b = new Date(b);
2294 }
2295
2296 // use default unit if not specified
2297 if (typeof unit === 'undefined') {
2298 unit = 'seconds';
2299 }
2300
2301 // always divide by 1000 to convert milliseconds in seconds
2302 var div = 1000;
2303
2304 if (unit.match(/days?/)) {
2305 // in case of "days" or "day", extract days from seconds
2306 div = div * 60 * 60 * 24;
2307
2308 // unset hours, minutes and seconds in order to
2309 // get the exact difference in days
2310 a.setHours(0);
2311 a.setMinutes(0);
2312 a.setSeconds(0);
2313
2314 b.setHours(0);
2315 b.setMinutes(0);
2316 b.setSeconds(0);
2317 } else if (unit.match(/hours?/)) {
2318 // in case of "hours" or "hour", extract hours from seconds
2319 div = div * 60 * 60;
2320 } else if (unit.match(/min|minutes?/)) {
2321 // in case of "min" or "minute" or "minutes", extract minutes from seconds
2322 div = div * 60;
2323 }
2324
2325 // get dates timestamp
2326 a = a.getTime();
2327 b = b.getTime();
2328
2329 // get milliseconds difference between 2 dates
2330 var diff = Math.abs(b - a);
2331
2332 // divide difference by the calculated threshold
2333 return Math.floor(diff / div);
2334 }
2335
2336 /**
2337 * Formats the specified date according to the browser locale.
2338 *
2339 * @param string|Date dt The date to format.
2340 *
2341 * @return string The formatted date.
2342 */
2343 static getFormattedDate(dt) {
2344 // convert string to date
2345 if (typeof dt === 'string') {
2346 dt = DateHelper.stringToDate(dt);
2347 }
2348
2349 // format locale date
2350 return dt.toLocaleDateString();
2351 }
2352
2353 /**
2354 * Formats the specified time according to the browser locale.
2355 *
2356 * @param string|Date dt The date to format.
2357 *
2358 * @return string The formatted time.
2359 */
2360 static getFormattedTime(dt) {
2361 // convert string to date
2362 if (typeof dt === 'string') {
2363 dt = DateHelper.stringToDate(dt);
2364 }
2365
2366 // format locale time (no seconds)
2367 return dt.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
2368 }
2369
2370 /**
2371 * Converts the specified date into a valid SQL (UTC) date format.
2372 *
2373 * @param string|Date dt The date to format.
2374 *
2375 * @return string The resulting date string.
2376 */
2377 static toStringUTC(dt) {
2378 // convert string to date
2379 if (typeof dt === 'string') {
2380 dt = DateHelper.stringToDate(dt);
2381 }
2382
2383 var year = dt.getUTCFullYear();
2384 var month = dt.getUTCMonth() + 1;
2385 var day = dt.getUTCDate();
2386 var hour = dt.getUTCHours();
2387 var min = dt.getUTCMinutes();
2388 var sec = dt.getUTCSeconds();
2389
2390 var date = year + '-' + (month < 10 ? '0' : '') + month + '-' + (day < 10 ? '0' : '') + day;
2391 var time = (hour < 10 ? '0' : '') + hour + ':' + (min < 10 ? '0' : '') + min + ':' + (sec < 10 ? '0' : '') + sec;
2392
2393 return date + ' ' + time;
2394 }
2395
2396 /**
2397 * Converts the specified date string into a Date object.
2398 *
2399 * @param string str The date to format.
2400 *
2401 * @return Date The date object.
2402 */
2403 static stringToDate(str) {
2404 return new Date(str.replace(/\s+/, 'T') + 'Z');
2405 }
2406
2407 }
2408
2409 /**
2410 * VBOChatAjax class.
2411 * Handles asynch server-side connections.
2412 */
2413 w['VBOChatAjax'] = class VBOChatAjax {
2414
2415 /**
2416 * Normalizes the given argument to be sent via AJAX.
2417 *
2418 * @param mixed data An object, an associative array or a serialized string.
2419 *
2420 * @return object The normalized object.
2421 */
2422 static normalizePostData(data) {
2423
2424 if (data === undefined) {
2425 data = {};
2426 } else if (Array.isArray(data)) {
2427 // the form data is serialized @see $.serializeArray()
2428 var form = data;
2429
2430 data = {};
2431
2432 for (var i = 0; i < form.length; i++) {
2433 // if the field ends with [] it should be an array
2434 if (form[i].name.endsWith("[]")) {
2435 // if the field doesn't exist yet, create a new list
2436 if (!data.hasOwnProperty(form[i].name)) {
2437 data[form[i].name] = new Array();
2438 }
2439
2440 // append the value to the array
2441 data[form[i].name].push(form[i].value);
2442 } else {
2443 // otherwise overwrite the value (if any)
2444 data[form[i].name] = form[i].value;
2445 }
2446 }
2447 }
2448
2449 return data;
2450 }
2451
2452 /**
2453 * Makes the connection.
2454 *
2455 * @param mixed url The URL to reach or a configuration object.
2456 * @param mixed data The data to post.
2457 * @param function success The callback to invoke on success.
2458 * @param function failure The callback to invoke on failure.
2459 * @param integer attempt The current attempt (optional).
2460 *
2461 * @return void
2462 */
2463 static do(url, data, success, failure, attempt) {
2464
2465 if (attempt == 1 || attempt === undefined) {
2466 if (!VBOChatAjax.concurrent && VBOChatAjax.isDoing()) {
2467 return false;
2468 }
2469 }
2470
2471 if (attempt === undefined) {
2472 attempt = 1;
2473 }
2474
2475 // return same object if data has been already normalized
2476 data = VBOChatAjax.normalizePostData(data);
2477
2478 var config = {};
2479
2480 if (typeof url === 'object') {
2481 // we have a configuration object, use it
2482 Object.assign(config, url);
2483 } else {
2484 // use the default configuration
2485 config.type = 'post';
2486 config.url = url;
2487 }
2488
2489 // inject data within config
2490 config.data = data;
2491
2492 var xhr = $.ajax(
2493 // use fetched config
2494 config
2495 ).done(function(resp) {
2496
2497 VBOChatAjax.pop(xhr);
2498
2499 if (success !== undefined) {
2500 // check if we should wait for another call
2501 if (VBOChatAjax.isRunningProcess(xhr.idAfter)) {
2502 // register promise
2503 VBOChatAjax.registerPromise(xhr.idAfter, success, resp);
2504 } else {
2505 // execute callback directly
2506 success(resp);
2507 }
2508 }
2509
2510 // process pending promises
2511 VBOChatAjax.processPromises(xhr.identify());
2512
2513 }).fail(function(err) {
2514 // always pop XHR after failure
2515 VBOChatAjax.pop(xhr);
2516
2517 // If the error has been raised by a connection failure,
2518 // retry automatically the same request. Do not retry if the
2519 // number of attempts is higher than the maximum number allowed.
2520 if (attempt < VBOChatAjax.maxAttempts && VBOChatAjax.isConnectionLost(err)) {
2521
2522 // wait 256 milliseconds before launching the request
2523 setTimeout(function() {
2524 // relaunch same action and increase number of attempts by 1
2525 VBOChatAjax.do(url, data, success, failure, attempt + 1);
2526 }, 256);
2527
2528 } else {
2529
2530 // otherwise raise the failure method
2531 if (failure !== undefined) {
2532 failure(err);
2533 }
2534
2535 }
2536
2537 console.error(err);
2538
2539 if (err.status.toString().match(/^5[\d]{2,2}$/)) {
2540 console.error(err.responseText);
2541 }
2542
2543 });
2544
2545 VBOChatAjax.push(xhr);
2546
2547 return xhr;
2548 }
2549 /**
2550 * Makes the connection with the server and start uploading the given data.
2551 *
2552 * @param string url The URL to reach.
2553 * @param mixed data The data to upload.
2554 * @param function done The callback to invoke on success.
2555 * @param function failure The callback to invoke on failure.
2556 * @param function upload The callback to invoke to track the uploading progress.
2557 *
2558 * @return void
2559 */
2560 static upload(url, data, done, failure, upload) {
2561 // define upload config
2562 var config = {
2563 url: url,
2564 type: "post",
2565 contentType: false,
2566 processData: false,
2567 cache: false,
2568 };
2569
2570 // define upload callback to keep track of progress
2571 if (typeof upload === 'function') {
2572 config.xhr = function() {
2573 var xhrobj = $.ajaxSettings.xhr();
2574
2575 if (xhrobj.upload) {
2576 // attach progress event
2577 xhrobj.upload.addEventListener('progress', function(event) {
2578 // calculate percentage
2579 var percent = 0;
2580 var position = event.loaded || event.position;
2581 var total = event.total;
2582 if (event.lengthComputable) {
2583 percent = Math.ceil(position / total * 100);
2584 }
2585
2586 // trigger callback
2587 upload(percent);
2588 }, false);
2589 }
2590
2591 return xhrobj;
2592 };
2593 }
2594
2595 // invoke default do() method by using custom config
2596 return VBOChatAjax.do(config, data, done, failure);
2597 }
2598
2599 /**
2600 * Checks if we own at least an active connection.
2601 *
2602 * @return boolean
2603 */
2604 static isDoing() {
2605 return VBOChatAjax.stack.length > 0 && VBOChatAjax.count > 0;
2606 }
2607
2608 /**
2609 * Checks if the process with the specified ID is running.
2610 *
2611 * @param mixed id The process identifier.
2612 *
2613 * @return boolean True if the process is running, false otherwise.
2614 */
2615 static isRunningProcess(id) {
2616 if (!id) {
2617 return false;
2618 }
2619
2620 // iterate the stack
2621 for (var i = 0; i < VBOChatAjax.stack.length; i++) {
2622 // get XHR instance
2623 var xhr = VBOChatAjax.stack[i];
2624
2625 if (typeof xhr === 'object' && xhr.identifier === id) {
2626 return true;
2627 }
2628 }
2629
2630 return false;
2631 }
2632
2633 /**
2634 * Checks if we are currently running a critical XHR.
2635 * XHRs can be marked in that way by using the prototyped
2636 * critical() function.
2637 *
2638 * @return boolean True if there is at least a critical XHR, otherwise false.
2639 */
2640 static isRunningCritical() {
2641 // iterate the stack
2642 for (var i = 0; i < VBOChatAjax.stack.length; i++) {
2643 // get XHR instance
2644 var xhr = VBOChatAjax.stack[i];
2645
2646 if (typeof xhr === 'object' && typeof xhr.isCritical === 'function' && xhr.isCritical()) {
2647 return true;
2648 }
2649 }
2650
2651 return false;
2652 }
2653
2654 /**
2655 * Registers a new promise for the specified identifier.
2656 *
2657 * @param mixed id The identifier to check.
2658 * @param function callback The callback to trigger.
2659 * @param mixed args The argument of the callback.
2660 *
2661 * @return void
2662 */
2663 static registerPromise(id, callback, args) {
2664 if (!id) {
2665 return;
2666 }
2667
2668 if (!VBOChatAjax.promises.hasOwnProperty(id)) {
2669 // create list
2670 VBOChatAjax.promises[id] = [];
2671 }
2672
2673 // register promise
2674 VBOChatAjax.promises[id].push({
2675 callback: callback,
2676 args: args,
2677 });
2678 }
2679
2680 /**
2681 * Processes all the pending promises for the specified ID.
2682 *
2683 * @param mixed id The id to check.
2684 *
2685 * @return void
2686 */
2687 static processPromises(id) {
2688 if (!VBOChatAjax.promises.hasOwnProperty(id)) {
2689 return
2690 }
2691
2692 // iterate promises lists
2693 while (VBOChatAjax.promises[id].length) {
2694 // get first callback available
2695 var tmp = VBOChatAjax.promises[id].shift();
2696
2697 // trigger callback
2698 tmp.callback(tmp.args);
2699 }
2700 }
2701
2702 /**
2703 * Pushes the opened connection within the stack.
2704 *
2705 * @param mixed xhr The connection resource.
2706 *
2707 * @return void
2708 */
2709 static push(xhr) {
2710 VBOChatAjax.stack.push(xhr);
2711 VBOChatAjax.count++;
2712 }
2713
2714 /**
2715 * Removes the specified connection from the stack.
2716 *
2717 * @param mixed xhr The connection resource.
2718 *
2719 * @return void
2720 */
2721 static pop(xhr) {
2722 var index = VBOChatAjax.stack.indexOf(xhr);
2723
2724 if (index != -1) {
2725 VBOChatAjax.stack.splice(index, 1);
2726 }
2727
2728 VBOChatAjax.count--;
2729 }
2730
2731 /**
2732 * Checks if the given error can be intended as a loss of connection:
2733 * generic error, no status and no response text.
2734 *
2735 * @param object err The error object.
2736 *
2737 * @return boolean
2738 */
2739 static isConnectionLost(err) {
2740 return (
2741 err.statusText == 'error'
2742 && err.status == 0
2743 && err.readyState == 0
2744 && err.responseText == ''
2745 );
2746 }
2747 }
2748
2749 VBOChatAjax.stack = [];
2750 VBOChatAjax.promises = {};
2751 VBOChatAjax.count = 0;
2752 VBOChatAjax.concurrent = true;
2753 VBOChatAjax.maxAttempts = 5;
2754
2755 /**
2756 * Checks if the specified elements are equal.
2757 *
2758 * @param mixed x
2759 * @param mixed y
2760 *
2761 * @return boolean True if identical, false otherwise.
2762 */
2763 Object.equals = function(x, y) {
2764 // if both x and y are null or undefined and exactly the same
2765 if (x === y)
2766 return true;
2767
2768 // if they are not strictly equal, they both need to be Objects
2769 if (!(x instanceof Object) || !(y instanceof Object))
2770 return false;
2771
2772 // they must have the exact same prototype chain, the closest we can do is
2773 // test there constructor
2774 if (x.constructor !== y.constructor)
2775 return false;
2776
2777 for (var p in x) {
2778 // make sure we are testing a valid property
2779 if (!x.hasOwnProperty(p))
2780 continue;
2781
2782 // allows to compare x[p] and y[p] when set to undefined
2783 if (!y.hasOwnProperty(p))
2784 return false;
2785
2786 // if they have the same strict value or identity then they are equal
2787 if (x[p] === y[p])
2788 continue;
2789
2790 // Numbers, Strings, Functions, Booleans must be strictly equal
2791 if (typeof(x[p]) !== "object")
2792 return false;
2793
2794 // Objects and Arrays must be tested recursively
2795 if (!Object.equals(x[p], y[p]))
2796 return false;
2797 }
2798
2799 for (p in y) {
2800 // allows x[p] to be set to undefined
2801 if (y.hasOwnProperty(p) && !x.hasOwnProperty(p))
2802 return false;
2803 }
2804
2805 return true;
2806 }
2807
2808 /**
2809 * Converts the most common special chars in their HTML entities.
2810 *
2811 * @return string The converted string.
2812 */
2813 String.prototype.htmlentities = function() {
2814 return this.toString()
2815 .replace(/&/g, '&amp;')
2816 .replace(/</g, '&lt;')
2817 .replace(/>/g, '&gt;')
2818 .replace(/"/g, '&quot;');
2819 }
2820
2821 /**
2822 * Escapes single quotes and double quotes by converting
2823 * them in the related HTML entity.
2824 *
2825 * @return string The escaped string
2826 */
2827 String.prototype.escape = function() {
2828 return this.toString().replace(/"/g, '&quot;');
2829 }
2830
2831 /**
2832 * Whenever an Ajax request is about to be sent, jQuery triggers the ajaxSend event.
2833 * Any and all handlers that have been registered with the .ajaxSend() method are executed at this time.
2834 * We can implement here all the methods that jqXHR object should support.
2835 */
2836 $(window).ajaxSend(function(event, xhr, settings) {
2837 /**
2838 * Marks the jqXHR object as critical according to the specified argument.
2839 *
2840 * @param mixed is Whether the XHR is critical or not. Undefined
2841 * argument is assumed as TRUE.
2842 *
2843 * @return self
2844 */
2845 xhr.critical = function(is) {
2846 this.criticalFlag = (is === undefined ? true : is);
2847
2848 return this;
2849 };
2850
2851 /**
2852 * Checks whether the jqXHR object is critical or not.
2853 *
2854 * @return boolean
2855 */
2856 xhr.isCritical = function(is) {
2857 return this.criticalFlag ? true : false;
2858 };
2859
2860 /**
2861 * Sets/Gets the ID of the jqXHR object.
2862 *
2863 * @param mixed id The identifier to set.
2864 *
2865 * @return mixed The identifier.
2866 */
2867 xhr.identify = function(id) {
2868 if (id !== undefined) {
2869 this.identifier = id;
2870 }
2871
2872 return this.identifier;
2873 }
2874
2875 /**
2876 * This method is used to push the callback of the request
2877 * in a queue to be executed once [id] request has finished.
2878 *
2879 * @param mixed id The identifier of the process to observe.
2880 *
2881 * @return self
2882 */
2883 xhr.after = function(id) {
2884 this.idAfter = id;
2885
2886 return this;
2887 }
2888 });
2889
2890 /**
2891 * The beforeunload event is fired when the window, the document and its resources are about to be unloaded.
2892 * The document is still visible and the event is still cancelable at this point.
2893 *
2894 * If a string is assigned to the returnValue Event property, a dialog appears asking the user for confirmation
2895 * to leave the page (see example below). Some browsers display the returned string in the dialog box, but others
2896 * display their own message. If no value is provided, the event is processed silently.
2897 */
2898 $(window).on('beforeunload', function(event) {
2899 // check if we are running a XHR in background
2900 // that shouldn't be aborted
2901 if (VBOChatAjax.isRunningCritical()) {
2902 // cancel the event and prompt the confirmation alert
2903 event.preventDefault();
2904 // for some browsers it is needed to setup a return value
2905 event.returnValue = 'Do you want to leave the page?';
2906 }
2907 });
2908 })(jQuery, window);