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

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