PluginProbe
VikBooking Hotel Booking Engine & PMS / 1.8.16
VikBooking Hotel Booking Engine & PMS v1.8.16
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.16, at site/resources/chat.js

2,918 lines 98.7 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 \-]{6,}[\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 + '" />').attr('title', file.name).html();
917 }
918
919 // check for playable video files
920 if (url.match(/\.(mp4|mov|ogm|webm)$/i)) {
921 return $('<video controls onloadeddata="' + onload + '">\n' +
922 '<source src="' + url + '" />\n' +
923 '</video>').attr('title', file.name).html();
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 + '"></i>').attr('title', file.name).html();
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 + '">\n' +
934 '<source src="' + url + '" />\n' +
935 '</audio>').attr('title', file.name).html();
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 + '"></i>').attr('title', file.name).html();
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 + '"></i>').attr('title', file.name).html();
946 }
947
948 // check for PDF
949 if (url.match(/\.pdf$/i)) {
950 return $('<i class="fas fa-file-pdf" onclick="' + onclick + '"></i>').attr('title', file.name).html();
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 + '"></i>').attr('title', file.name).html();
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 + '"></i>').attr('title', file.name).html();
961 }
962
963 // check for presentations
964 if (url.match(/\.(ppsx?|odp|keynote)$/i)) {
965 return $('<i class="fas fa-file-powerpoint" onclick="' + onclick + '"></i>').attr('title', file.name).html();
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 + '"></i>').attr('title', file.name).html();
971 }
972
973 // use standard file
974 return $('<i class="fas fa-file" onclick="' + onclick + '"></i>').attr('title', file.name).html();
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 // ENTER shortcut to send a message
2088 target.on('keydown', (event) => {
2089 // check if ENTER was pressed without any other modifiers
2090 if (event.keyCode == 13 && !event.altKey && !event.ctrlKey && !event.shiftKey) {
2091 $('#' + this.data.idManualSend).trigger('click');
2092
2093 return false;
2094 }
2095 });
2096
2097 // init manual send message listener
2098 $('#' + this.data.idManualSend).on('click', () => {
2099 chat.send(target.val());
2100 target.val('');
2101 target.css('height', 'auto');
2102 });
2103
2104 // init attachments upload
2105 $('#' + this.data.idAttachment).on('change', function(event) {
2106 // get selected files
2107 const files = $(this)[0].files;
2108
2109 // upload attachments
2110 chat.uploadAttachments(files);
2111
2112 // unset input file value
2113 $(this).val(null);
2114 });
2115
2116 let actions = [];
2117
2118 chat.data.environment.context.actions.forEach((button) => {
2119 if (button.namespace) {
2120 if (!button.text) {
2121 button.text = (...args) => {
2122 const event = $.Event('chat.' + button.namespace + '.text');
2123 event.args = args.concat([button, chat]);
2124 event.displayText = null;
2125 $(window).trigger(event);
2126 return event.displayText;
2127 }
2128 }
2129
2130 if (!button.icon) {
2131 button.icon = (...args) => {
2132 const event = $.Event('chat.' + button.namespace + '.icon');
2133 event.args = args.concat([button, chat]);
2134 event.displayIcon = null;
2135 $(window).trigger(event);
2136 return event.displayIcon;
2137 }
2138 }
2139
2140 if (!button.action) {
2141 button.action = (...args) => {
2142 const event = $.Event('chat.' + button.namespace + '.action');
2143 event.args = args.concat([button, chat]);
2144 $(window).trigger(event);
2145 };
2146 }
2147
2148 if (!button.disabled) {
2149 button.disabled = (...args) => {
2150 const event = $.Event('chat.' + button.namespace + '.disabled');
2151 event.args = args.concat([button, chat]);
2152 event.shouldDisable = false;
2153 $(window).trigger(event);
2154 return event.shouldDisable;
2155 }
2156 }
2157
2158 if (!button.visible) {
2159 button.visible = (...args) => {
2160 const event = $.Event('chat.' + button.namespace + '.visible');
2161 event.args = args.concat([button, chat]);
2162 event.shouldDisplay = true;
2163 $(window).trigger(event);
2164 return event.shouldDisplay;
2165 }
2166 }
2167 }
2168
2169 actions.push(button);
2170 });
2171
2172 // init context menu
2173 $('#' + this.data.idContextMenu).vboContextMenu({
2174 placement: 'top-left',
2175 buttons: actions.concat([
2176 // File upload
2177 {
2178 text: 'Upload a file',
2179 icon: 'fas fa-paperclip',
2180 separator: true,
2181 action: (root, event) => {
2182 event.preventDefault();
2183
2184 setTimeout(() => {
2185 $('#' + this.data.idAttachment).trigger('click');
2186 }, 16);
2187 },
2188 }
2189 ]),
2190 });
2191 }
2192
2193 /**
2194 * Method used to destroy the field.
2195 * This method is called before removing the field
2196 * from the document.
2197 *
2198 * @param VBOChat chat The chat instance.
2199 *
2200 * @return void
2201 */
2202 onDestroy(chat) {
2203 // invoke parent first
2204 super.onInit(chat);
2205
2206 // turn off textarea events before destroying it
2207 $('#' + this.data.id).off('input').off('keydown');
2208 // turn off manual send event
2209 $('#' + this.data.idManualSend).off('click');
2210 // turn off attachments events
2211 $('#' + this.data.idAttachment).off('change');
2212 // turn off context menu
2213 $('#' + this.data.idContextMenu).vboContextMenu('destroy');
2214 }
2215
2216 }
2217
2218 // Register class within the lookup
2219 VBOChatField.classMap.text = VBOChatFieldText;
2220
2221 /**
2222 * DateHelper class.
2223 * Helper class used to handle date objects.
2224 */
2225 w['DateHelper'] = class DateHelper {
2226
2227 /**
2228 * Checks if the specified date matches the current day.
2229 *
2230 * @param string|Date dt The date to check.
2231 *
2232 * @return boolean True if today, otherwise false.
2233 */
2234 static isToday(dt) {
2235 // compare specified date with current day
2236 return DateHelper.isSameDay(dt, new Date());
2237 }
2238
2239 /**
2240 * Checks if the specified date matches the previous day.
2241 *
2242 * @param string|Date dt The date to check.
2243 *
2244 * @return boolean True if yesterday, otherwise false.
2245 */
2246 static isYesterday(dt) {
2247 // get yesterday date object
2248 var yesterday = new Date();
2249 yesterday.setDate(yesterday.getDate() - 1);
2250
2251 // compare specified date with previous day
2252 return DateHelper.isSameDay(dt, yesterday);
2253 }
2254
2255 /**
2256 * Checks if the specified dates are equals without
2257 * considering the related times.
2258 *
2259 * @param string|Date a The first date to check.
2260 * @param string|Date b The second date to check.
2261 *
2262 * @return boolean True if equals, otherwise false.
2263 */
2264 static isSameDay(a, b) {
2265 // convert string to date
2266 if (typeof a === 'string') {
2267 a = DateHelper.stringToDate(a);
2268 }
2269
2270 // convert string to date
2271 if (typeof b === 'string') {
2272 b = DateHelper.stringToDate(b);
2273 }
2274
2275 // check if the specified days are matching (exclude time)
2276 return (a.getDate() == b.getDate() && a.getMonth() == b.getMonth() && a.getFullYear() == b.getFullYear());
2277 }
2278
2279 /**
2280 * Calculate the difference between the specified dates.
2281 * The difference is always an absolute value.
2282 *
2283 * @param string|Date a The first date to check.
2284 * @param string|Date b The second date to check.
2285 * @param string unit The difference unit [seconds, minutes, hours, days].
2286 *
2287 * @return integer The related difference according to the specified unit.
2288 */
2289 static diff(a, b, unit) {
2290 // convert string to date
2291 if (typeof a === 'string') {
2292 a = DateHelper.stringToDate(a);
2293 } else {
2294 // create new instance in order to avoid manipulating the given object
2295 a = new Date(a);
2296 }
2297
2298 // convert string to date
2299 if (typeof b === 'string') {
2300 b = DateHelper.stringToDate(b);
2301 } else {
2302 // create new instance in order to avoid manipulating the given object
2303 b = new Date(b);
2304 }
2305
2306 // use default unit if not specified
2307 if (typeof unit === 'undefined') {
2308 unit = 'seconds';
2309 }
2310
2311 // always divide by 1000 to convert milliseconds in seconds
2312 var div = 1000;
2313
2314 if (unit.match(/days?/)) {
2315 // in case of "days" or "day", extract days from seconds
2316 div = div * 60 * 60 * 24;
2317
2318 // unset hours, minutes and seconds in order to
2319 // get the exact difference in days
2320 a.setHours(0);
2321 a.setMinutes(0);
2322 a.setSeconds(0);
2323
2324 b.setHours(0);
2325 b.setMinutes(0);
2326 b.setSeconds(0);
2327 } else if (unit.match(/hours?/)) {
2328 // in case of "hours" or "hour", extract hours from seconds
2329 div = div * 60 * 60;
2330 } else if (unit.match(/min|minutes?/)) {
2331 // in case of "min" or "minute" or "minutes", extract minutes from seconds
2332 div = div * 60;
2333 }
2334
2335 // get dates timestamp
2336 a = a.getTime();
2337 b = b.getTime();
2338
2339 // get milliseconds difference between 2 dates
2340 var diff = Math.abs(b - a);
2341
2342 // divide difference by the calculated threshold
2343 return Math.floor(diff / div);
2344 }
2345
2346 /**
2347 * Formats the specified date according to the browser locale.
2348 *
2349 * @param string|Date dt The date to format.
2350 *
2351 * @return string The formatted date.
2352 */
2353 static getFormattedDate(dt) {
2354 // convert string to date
2355 if (typeof dt === 'string') {
2356 dt = DateHelper.stringToDate(dt);
2357 }
2358
2359 // format locale date
2360 return dt.toLocaleDateString();
2361 }
2362
2363 /**
2364 * Formats the specified time according to the browser locale.
2365 *
2366 * @param string|Date dt The date to format.
2367 *
2368 * @return string The formatted time.
2369 */
2370 static getFormattedTime(dt) {
2371 // convert string to date
2372 if (typeof dt === 'string') {
2373 dt = DateHelper.stringToDate(dt);
2374 }
2375
2376 // format locale time (no seconds)
2377 return dt.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
2378 }
2379
2380 /**
2381 * Converts the specified date into a valid SQL (UTC) date format.
2382 *
2383 * @param string|Date dt The date to format.
2384 *
2385 * @return string The resulting date string.
2386 */
2387 static toStringUTC(dt) {
2388 // convert string to date
2389 if (typeof dt === 'string') {
2390 dt = DateHelper.stringToDate(dt);
2391 }
2392
2393 var year = dt.getUTCFullYear();
2394 var month = dt.getUTCMonth() + 1;
2395 var day = dt.getUTCDate();
2396 var hour = dt.getUTCHours();
2397 var min = dt.getUTCMinutes();
2398 var sec = dt.getUTCSeconds();
2399
2400 var date = year + '-' + (month < 10 ? '0' : '') + month + '-' + (day < 10 ? '0' : '') + day;
2401 var time = (hour < 10 ? '0' : '') + hour + ':' + (min < 10 ? '0' : '') + min + ':' + (sec < 10 ? '0' : '') + sec;
2402
2403 return date + ' ' + time;
2404 }
2405
2406 /**
2407 * Converts the specified date string into a Date object.
2408 *
2409 * @param string str The date to format.
2410 *
2411 * @return Date The date object.
2412 */
2413 static stringToDate(str) {
2414 return new Date(str.replace(/\s+/, 'T') + 'Z');
2415 }
2416
2417 }
2418
2419 /**
2420 * VBOChatAjax class.
2421 * Handles asynch server-side connections.
2422 */
2423 w['VBOChatAjax'] = class VBOChatAjax {
2424
2425 /**
2426 * Normalizes the given argument to be sent via AJAX.
2427 *
2428 * @param mixed data An object, an associative array or a serialized string.
2429 *
2430 * @return object The normalized object.
2431 */
2432 static normalizePostData(data) {
2433
2434 if (data === undefined) {
2435 data = {};
2436 } else if (Array.isArray(data)) {
2437 // the form data is serialized @see $.serializeArray()
2438 var form = data;
2439
2440 data = {};
2441
2442 for (var i = 0; i < form.length; i++) {
2443 // if the field ends with [] it should be an array
2444 if (form[i].name.endsWith("[]")) {
2445 // if the field doesn't exist yet, create a new list
2446 if (!data.hasOwnProperty(form[i].name)) {
2447 data[form[i].name] = new Array();
2448 }
2449
2450 // append the value to the array
2451 data[form[i].name].push(form[i].value);
2452 } else {
2453 // otherwise overwrite the value (if any)
2454 data[form[i].name] = form[i].value;
2455 }
2456 }
2457 }
2458
2459 return data;
2460 }
2461
2462 /**
2463 * Makes the connection.
2464 *
2465 * @param mixed url The URL to reach or a configuration object.
2466 * @param mixed data The data to post.
2467 * @param function success The callback to invoke on success.
2468 * @param function failure The callback to invoke on failure.
2469 * @param integer attempt The current attempt (optional).
2470 *
2471 * @return void
2472 */
2473 static do(url, data, success, failure, attempt) {
2474
2475 if (attempt == 1 || attempt === undefined) {
2476 if (!VBOChatAjax.concurrent && VBOChatAjax.isDoing()) {
2477 return false;
2478 }
2479 }
2480
2481 if (attempt === undefined) {
2482 attempt = 1;
2483 }
2484
2485 // return same object if data has been already normalized
2486 data = VBOChatAjax.normalizePostData(data);
2487
2488 var config = {};
2489
2490 if (typeof url === 'object') {
2491 // we have a configuration object, use it
2492 Object.assign(config, url);
2493 } else {
2494 // use the default configuration
2495 config.type = 'post';
2496 config.url = url;
2497 }
2498
2499 // inject data within config
2500 config.data = data;
2501
2502 var xhr = $.ajax(
2503 // use fetched config
2504 config
2505 ).done(function(resp) {
2506
2507 VBOChatAjax.pop(xhr);
2508
2509 if (success !== undefined) {
2510 // check if we should wait for another call
2511 if (VBOChatAjax.isRunningProcess(xhr.idAfter)) {
2512 // register promise
2513 VBOChatAjax.registerPromise(xhr.idAfter, success, resp);
2514 } else {
2515 // execute callback directly
2516 success(resp);
2517 }
2518 }
2519
2520 // process pending promises
2521 VBOChatAjax.processPromises(xhr.identify());
2522
2523 }).fail(function(err) {
2524 // always pop XHR after failure
2525 VBOChatAjax.pop(xhr);
2526
2527 // If the error has been raised by a connection failure,
2528 // retry automatically the same request. Do not retry if the
2529 // number of attempts is higher than the maximum number allowed.
2530 if (attempt < VBOChatAjax.maxAttempts && VBOChatAjax.isConnectionLost(err)) {
2531
2532 // wait 256 milliseconds before launching the request
2533 setTimeout(function() {
2534 // relaunch same action and increase number of attempts by 1
2535 VBOChatAjax.do(url, data, success, failure, attempt + 1);
2536 }, 256);
2537
2538 } else {
2539
2540 // otherwise raise the failure method
2541 if (failure !== undefined) {
2542 failure(err);
2543 }
2544
2545 }
2546
2547 console.error(err);
2548
2549 if (err.status.toString().match(/^5[\d]{2,2}$/)) {
2550 console.error(err.responseText);
2551 }
2552
2553 });
2554
2555 VBOChatAjax.push(xhr);
2556
2557 return xhr;
2558 }
2559 /**
2560 * Makes the connection with the server and start uploading the given data.
2561 *
2562 * @param string url The URL to reach.
2563 * @param mixed data The data to upload.
2564 * @param function done The callback to invoke on success.
2565 * @param function failure The callback to invoke on failure.
2566 * @param function upload The callback to invoke to track the uploading progress.
2567 *
2568 * @return void
2569 */
2570 static upload(url, data, done, failure, upload) {
2571 // define upload config
2572 var config = {
2573 url: url,
2574 type: "post",
2575 contentType: false,
2576 processData: false,
2577 cache: false,
2578 };
2579
2580 // define upload callback to keep track of progress
2581 if (typeof upload === 'function') {
2582 config.xhr = function() {
2583 var xhrobj = $.ajaxSettings.xhr();
2584
2585 if (xhrobj.upload) {
2586 // attach progress event
2587 xhrobj.upload.addEventListener('progress', function(event) {
2588 // calculate percentage
2589 var percent = 0;
2590 var position = event.loaded || event.position;
2591 var total = event.total;
2592 if (event.lengthComputable) {
2593 percent = Math.ceil(position / total * 100);
2594 }
2595
2596 // trigger callback
2597 upload(percent);
2598 }, false);
2599 }
2600
2601 return xhrobj;
2602 };
2603 }
2604
2605 // invoke default do() method by using custom config
2606 return VBOChatAjax.do(config, data, done, failure);
2607 }
2608
2609 /**
2610 * Checks if we own at least an active connection.
2611 *
2612 * @return boolean
2613 */
2614 static isDoing() {
2615 return VBOChatAjax.stack.length > 0 && VBOChatAjax.count > 0;
2616 }
2617
2618 /**
2619 * Checks if the process with the specified ID is running.
2620 *
2621 * @param mixed id The process identifier.
2622 *
2623 * @return boolean True if the process is running, false otherwise.
2624 */
2625 static isRunningProcess(id) {
2626 if (!id) {
2627 return false;
2628 }
2629
2630 // iterate the stack
2631 for (var i = 0; i < VBOChatAjax.stack.length; i++) {
2632 // get XHR instance
2633 var xhr = VBOChatAjax.stack[i];
2634
2635 if (typeof xhr === 'object' && xhr.identifier === id) {
2636 return true;
2637 }
2638 }
2639
2640 return false;
2641 }
2642
2643 /**
2644 * Checks if we are currently running a critical XHR.
2645 * XHRs can be marked in that way by using the prototyped
2646 * critical() function.
2647 *
2648 * @return boolean True if there is at least a critical XHR, otherwise false.
2649 */
2650 static isRunningCritical() {
2651 // iterate the stack
2652 for (var i = 0; i < VBOChatAjax.stack.length; i++) {
2653 // get XHR instance
2654 var xhr = VBOChatAjax.stack[i];
2655
2656 if (typeof xhr === 'object' && typeof xhr.isCritical === 'function' && xhr.isCritical()) {
2657 return true;
2658 }
2659 }
2660
2661 return false;
2662 }
2663
2664 /**
2665 * Registers a new promise for the specified identifier.
2666 *
2667 * @param mixed id The identifier to check.
2668 * @param function callback The callback to trigger.
2669 * @param mixed args The argument of the callback.
2670 *
2671 * @return void
2672 */
2673 static registerPromise(id, callback, args) {
2674 if (!id) {
2675 return;
2676 }
2677
2678 if (!VBOChatAjax.promises.hasOwnProperty(id)) {
2679 // create list
2680 VBOChatAjax.promises[id] = [];
2681 }
2682
2683 // register promise
2684 VBOChatAjax.promises[id].push({
2685 callback: callback,
2686 args: args,
2687 });
2688 }
2689
2690 /**
2691 * Processes all the pending promises for the specified ID.
2692 *
2693 * @param mixed id The id to check.
2694 *
2695 * @return void
2696 */
2697 static processPromises(id) {
2698 if (!VBOChatAjax.promises.hasOwnProperty(id)) {
2699 return
2700 }
2701
2702 // iterate promises lists
2703 while (VBOChatAjax.promises[id].length) {
2704 // get first callback available
2705 var tmp = VBOChatAjax.promises[id].shift();
2706
2707 // trigger callback
2708 tmp.callback(tmp.args);
2709 }
2710 }
2711
2712 /**
2713 * Pushes the opened connection within the stack.
2714 *
2715 * @param mixed xhr The connection resource.
2716 *
2717 * @return void
2718 */
2719 static push(xhr) {
2720 VBOChatAjax.stack.push(xhr);
2721 VBOChatAjax.count++;
2722 }
2723
2724 /**
2725 * Removes the specified connection from the stack.
2726 *
2727 * @param mixed xhr The connection resource.
2728 *
2729 * @return void
2730 */
2731 static pop(xhr) {
2732 var index = VBOChatAjax.stack.indexOf(xhr);
2733
2734 if (index != -1) {
2735 VBOChatAjax.stack.splice(index, 1);
2736 }
2737
2738 VBOChatAjax.count--;
2739 }
2740
2741 /**
2742 * Checks if the given error can be intended as a loss of connection:
2743 * generic error, no status and no response text.
2744 *
2745 * @param object err The error object.
2746 *
2747 * @return boolean
2748 */
2749 static isConnectionLost(err) {
2750 return (
2751 err.statusText == 'error'
2752 && err.status == 0
2753 && err.readyState == 0
2754 && err.responseText == ''
2755 );
2756 }
2757 }
2758
2759 VBOChatAjax.stack = [];
2760 VBOChatAjax.promises = {};
2761 VBOChatAjax.count = 0;
2762 VBOChatAjax.concurrent = true;
2763 VBOChatAjax.maxAttempts = 5;
2764
2765 /**
2766 * Checks if the specified elements are equal.
2767 *
2768 * @param mixed x
2769 * @param mixed y
2770 *
2771 * @return boolean True if identical, false otherwise.
2772 */
2773 Object.equals = function(x, y) {
2774 // if both x and y are null or undefined and exactly the same
2775 if (x === y)
2776 return true;
2777
2778 // if they are not strictly equal, they both need to be Objects
2779 if (!(x instanceof Object) || !(y instanceof Object))
2780 return false;
2781
2782 // they must have the exact same prototype chain, the closest we can do is
2783 // test there constructor
2784 if (x.constructor !== y.constructor)
2785 return false;
2786
2787 for (var p in x) {
2788 // make sure we are testing a valid property
2789 if (!x.hasOwnProperty(p))
2790 continue;
2791
2792 // allows to compare x[p] and y[p] when set to undefined
2793 if (!y.hasOwnProperty(p))
2794 return false;
2795
2796 // if they have the same strict value or identity then they are equal
2797 if (x[p] === y[p])
2798 continue;
2799
2800 // Numbers, Strings, Functions, Booleans must be strictly equal
2801 if (typeof(x[p]) !== "object")
2802 return false;
2803
2804 // Objects and Arrays must be tested recursively
2805 if (!Object.equals(x[p], y[p]))
2806 return false;
2807 }
2808
2809 for (p in y) {
2810 // allows x[p] to be set to undefined
2811 if (y.hasOwnProperty(p) && !x.hasOwnProperty(p))
2812 return false;
2813 }
2814
2815 return true;
2816 }
2817
2818 /**
2819 * Converts the most common special chars in their HTML entities.
2820 *
2821 * @return string The converted string.
2822 */
2823 String.prototype.htmlentities = function() {
2824 return this.toString()
2825 .replace(/&/g, '&amp;')
2826 .replace(/</g, '&lt;')
2827 .replace(/>/g, '&gt;')
2828 .replace(/"/g, '&quot;');
2829 }
2830
2831 /**
2832 * Escapes single quotes and double quotes by converting
2833 * them in the related HTML entity.
2834 *
2835 * @return string The escaped string
2836 */
2837 String.prototype.escape = function() {
2838 return this.toString().replace(/"/g, '&quot;');
2839 }
2840
2841 /**
2842 * Whenever an Ajax request is about to be sent, jQuery triggers the ajaxSend event.
2843 * Any and all handlers that have been registered with the .ajaxSend() method are executed at this time.
2844 * We can implement here all the methods that jqXHR object should support.
2845 */
2846 $(window).ajaxSend(function(event, xhr, settings) {
2847 /**
2848 * Marks the jqXHR object as critical according to the specified argument.
2849 *
2850 * @param mixed is Whether the XHR is critical or not. Undefined
2851 * argument is assumed as TRUE.
2852 *
2853 * @return self
2854 */
2855 xhr.critical = function(is) {
2856 this.criticalFlag = (is === undefined ? true : is);
2857
2858 return this;
2859 };
2860
2861 /**
2862 * Checks whether the jqXHR object is critical or not.
2863 *
2864 * @return boolean
2865 */
2866 xhr.isCritical = function(is) {
2867 return this.criticalFlag ? true : false;
2868 };
2869
2870 /**
2871 * Sets/Gets the ID of the jqXHR object.
2872 *
2873 * @param mixed id The identifier to set.
2874 *
2875 * @return mixed The identifier.
2876 */
2877 xhr.identify = function(id) {
2878 if (id !== undefined) {
2879 this.identifier = id;
2880 }
2881
2882 return this.identifier;
2883 }
2884
2885 /**
2886 * This method is used to push the callback of the request
2887 * in a queue to be executed once [id] request has finished.
2888 *
2889 * @param mixed id The identifier of the process to observe.
2890 *
2891 * @return self
2892 */
2893 xhr.after = function(id) {
2894 this.idAfter = id;
2895
2896 return this;
2897 }
2898 });
2899
2900 /**
2901 * The beforeunload event is fired when the window, the document and its resources are about to be unloaded.
2902 * The document is still visible and the event is still cancelable at this point.
2903 *
2904 * If a string is assigned to the returnValue Event property, a dialog appears asking the user for confirmation
2905 * to leave the page (see example below). Some browsers display the returned string in the dialog box, but others
2906 * display their own message. If no value is provided, the event is processed silently.
2907 */
2908 $(window).on('beforeunload', function(event) {
2909 // check if we are running a XHR in background
2910 // that shouldn't be aborted
2911 if (VBOChatAjax.isRunningCritical()) {
2912 // cancel the event and prompt the confirmation alert
2913 event.preventDefault();
2914 // for some browsers it is needed to setup a return value
2915 event.returnValue = 'Do you want to leave the page?';
2916 }
2917 });
2918 })(jQuery, window);