File "chat.js"
Full Path: /home/romayxjt/public_html/wp-content/plugins/vikbooking/site/resources/chat.js
File size: 93.47 KB
MIME-type: text/html
Charset: utf-8
(function($, w) {
'use strict';
/**
* VBOChat class.
* Singleton used to handle a CHAT client.
*/
w['VBOChat'] = class VBOChat {
/**
* Returns a new chat instance.
*
* @param object data The environment options.
*
* @return VBOChat
*/
static getInstance(data) {
if (data && VBOChat?.instance?.data) {
// the chat was already initialized, preserve the previous data in a static queue
VBOChat.queue.push(VBOChat.instance.data);
}
if (VBOChat.instance === undefined || typeof data !== 'undefined') {
VBOChat.instance = new VBOChat(data);
}
if (!VBOChat.instance.data && data) {
// instantiate a new object with the injected data
VBOChat.instance = new VBOChat(data);
}
return VBOChat.instance;
}
/**
* Class constructor.
*
* @param object data The environment options.
*/
constructor(data) {
this.data = data;
if (this.data) {
if (this.data.environment.users === undefined) {
this.data.environment.users = {};
}
if (this.data.environment.messages === undefined) {
this.data.environment.messages = [];
}
// initialise thread
this.initThread();
this.data.environment.id = this.data.environment.messages.length;
this.data.environment.datetime = new Date();
this.data.environment.attachments = [];
// use default values if not provided
this.data.environment.options = Object.assign({
syncTime: 10,
limit: 20,
autoread: true,
}, this.data.environment.options || {});
if (!this.data.element) {
this.data.element = {
conversation: $(this.data.environment.selector).find('.chat-conversation'),
progressBar: $(this.data.environment.selector).find('.chat-progress-wrap'),
uploadsBar: $(this.data.environment.selector).find('.chat-uploads-tab'),
inputBox: $(this.data.environment.selector).find('.textarea-input'),
};
}
}
this.timers = [];
}
/**
* Initialises the specified thread.
*
* @return self
*/
initThread() {
const thread = {};
// keep thread initial date time
thread.initialDatetime = this.data.environment.messages[0]?.createdon;
// keep thread initial messages length
thread.messagesLength = this.data.environment.messages.length;
this.data.environment.thread = thread;
return this;
}
/**
* Prepares the chat client to be up and running by initializing the last conversation made.
* The synchronization with the server is made here.
*
* @return self
*/
prepare() {
if (this.isPrepared) {
// do not execute again
return this;
}
this.isPrepared = true;
// register e-mail content parser
this.attachContentParser('email', function(content) {
// wrap any e-mail addresses within a "mailto" link
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) {
return '<a href="mailto:' + mail + '">' + mail + '</a>';
});
return content;
});
// register phone content parser
this.attachContentParser('phone', function(content) {
// wrap any potential phone numbers within a "tel" link
content = content.replace(/(^|\s)(?:\+[\d]{1,5})?[\d][\d \-]{3,}[\d](\s|$)/gm, function(phone) {
return '<a href="tel:' + phone + '">' + phone + '</a>';
});
return content;
});
// register URL content parser
this.attachContentParser('url', function(content) {
// wrap any plain URLs within a link
content = content.replace(/https?:\/\/(www\.)?[a-zA-Z0-9@:%._\+~#=\-]{2,256}\.[a-z]{2,6}\b([a-zA-Z0-9@:%_\+.~#?&\/\/=\-;]*)/gi, function(url) {
return '<a href="' + url + '" target="_blank">' + url + '</a>';
});
return content;
});
this.renderInput()
.startConversation()
.buildConversation()
.readNotifications();
// calculate interval duration between each sync (use at least 1000 ms)
const syncDuration = Math.max(1000, this.data.environment.options.syncTime * 1000);
// sync messages when page loads
this.synchronizeMessages();
// try to check if we have new messages to push
this.timers.push(
setInterval(() => {
this.synchronizeMessages();
}, syncDuration)
);
/**
* We should run here an interval that re-build the date separators.
* For example, if we open the chat @ 23:57, the date separators should
* be changed after the midnight ("Today, 23:56" should become "Yesterday, 23:56").
*
* The date of the last message sent/received should be updated too.
*/
this.timers.push(
setInterval(() => {
const now = new Date();
const chat = this;
// check if the date has changed since the last check
if (!DateHelper.isSameDay(now, chat.data.environment.datetime)) {
// update environment datetime
chat.data.environment.datetime = now;
// iterate all the separators
$('.is-a-separator').each(function() {
// get separator UTC date
let dt = $(this).attr('data-datetime');
let utc = DateHelper.stringToDate(dt);
// replace separator with new one
$(this).replaceWith(chat.getDateSeparator(utc));
});
}
}, 10000)
);
return this;
}
/**
* Build the chat conversation.
* The conversation may not contain all messages.
*
* @param mixed start The initial offset of the messages to display.
* If not provided, 0 will be used.
* @param mixed end The ending offset of the messages to display.
* If not provided, all the cached messages will be shown.
*
* @return self
*/
buildConversation(start, end) {
if (start === undefined) {
start = 0;
}
if (end === undefined) {
end = this.data.environment.messages.length;
}
let messages = $('');
// define queue for failed messages
let failedQueue = [];
for (let i = end - 1; i >= start; i--) {
let message = this.data.environment.messages[i];
if (message === undefined) {
// we reached the end of the list before the expected limit
continue;
}
if (message.hasError) {
// push the message within the failed queue, then go to next item
failedQueue.push(message.id);
// unset error to avoid sending it twice
message.hasError = false;
continue;
}
// get message template (message, false: no animation, true: get buffer)
messages = messages.add(this.drawMessage(message, false, true));
}
$(this.data.element.conversation).prepend(messages);
if (start == 0) {
// in case the chat was empty, auto-scroll conversation to the last message
this.scrollToBottom();
}
// we need to iterate all the failed messages and retry to send them
for (let i = 0; i < failedQueue.length; i++) {
// remove message from list
let mess = this.removeMessage(failedQueue[i]);
if (mess) {
// re-send message content
this.send(mess.message);
}
}
return this;
}
/**
* Initializes the conversation.
*
* @return self
*/
startConversation() {
const chat = this;
$(this.data.element.conversation).html('');
// setup environment vars
this.data.environment.isLoadingOlderMessages = false;
// clear scroll event
$(this.data.element.conversation).off('scroll');
// do not register scroll event in case the number of messages is equal or
// higher then the total number of messages under this context
if (this.data.environment.messages.length >= this.data.environment.options.limit) {
// setup scroll event to load older messages
$(this.data.element.conversation).on('scroll', function() {
if (chat.data.environment.isLoadingOlderMessages) {
// ignore if we are currently loading older messages
return;
}
// get scrollable pixel
const scrollHeight = this.scrollHeight - $(this).outerHeight();
// get scroll top
const scrollTop = this.scrollTop;
// start loading older messages only when scrollbar
// hits the first half of the whole scrollable height
if (scrollTop / scrollHeight < 0.5) {
// load older chat messages
chat.loadPreviousMessages();
}
});
}
return this;
}
/**
* Scrolls the chat conversation to the most recent message.
*
* @return self
*/
scrollToBottom() {
const convo = $(this.data.element.conversation);
if (!convo.length) {
return this;
}
convo.scrollTop(convo[0].scrollHeight + 200);
return this;
}
/**
* Checks whether the chat should scroll.
* If we are reading older messages, the chat should not scroll.
* Contrarily, if we are keeping an eye on the latest messages,
* the chat should scroll to the bottom.
*
* @param integer threshold An optional threshold (30px by default).
*
* @return boolean
*/
shouldScroll(threshold) {
const conversation = $(this.data.element.conversation)[0];
// total scrollable amount (we need to exclude the chat height from the scroll height)
let scrollable = conversation.scrollHeight - $(conversation).outerHeight();
// get difference between current scroll top and total scroll top
let diff = Math.abs(scrollable - conversation.scrollTop);
// scroll only in case we are already at the bottom position,
// with a maximum threshold of 30 pixel
return diff <= (threshold || 30);
}
/**
* Returns the index of the message object that matches the specified ID.
*
* @param mixed id The message ID.
*
* @return integer The index of the matching object, otherwise -1.
*/
getMessageIndex(id) {
for (let i = 0; i < this.data.environment.messages.length; i++) {
let message = this.data.environment.messages[i];
if (message.id == id) {
return i;
}
}
return -1;
}
/**
* Returns the message object that matches the specified ID.
*
* @param mixed id The message ID.
*
* @return mixed The matching object, otherwise null.
*/
getMessage(id) {
const index = this.getMessageIndex(id);
if (index != -1) {
return this.data.environment.messages[index];
}
return null;
}
/**
* Removes the message object that matches the specified ID.
*
* @param mixed id The message ID.
* @param boolean strict True to remove the message from the chat too.
*
* @return mixed The removed object on success, otherwise false.
*/
removeMessage(id, strict) {
const index = this.getMessageIndex(id);
if (index == -1) {
return false;
}
// check if the chat message should be removed
if (strict) {
if ($('#'+ id).prev().hasClass('is-a-separator')) {
// remove previous separator too
$('#' + id).prev().remove();
}
// remove chat element
$('#' + id).remove();
}
return this.data.environment.messages.splice(index, 1)[0];
}
/**
* Returns the latest message sent/received.
In this case "latest" means "most recent".
*
* @return mixed The latest message if any, otherwise null.
*/
getLatestMessage() {
return this.data.environment.messages[0] ?? null;
}
/**
* Returns the latest message received that needs to be read.
* In this case "latest" means "most recent".
*
* @return mixed The latest unread message if any, otherwise null.
*/
getLatestUnreadMessage() {
for (let i = 0; i < this.data.environment.messages.length; i++) {
let msg = this.data.environment.messages[i];
if (!msg.read) {
return msg;
}
}
return null;
}
/**
* Counts the total number of unread messages.
* In case there are some unread messages outside the current pagination,
* they won't be counted, obviously.
*
* @return int The total count of unread messages.
*/
getUnreadMessagesCount() {
let count = 0;
for (let i = 0; i < this.data.environment.messages.length; i++) {
let msg = this.data.environment.messages[i];
if (!msg.read) {
count++;
}
}
return count;
}
/**
* Helper function used to calculate the exact position that the
* new message should occupy.
*
* @param object message The message to add.
*
* @return int The correct position.
*/
findMessagePosition(message) {
for (let i = 0; i < this.data.environment.messages.length; i++) {
if (this.data.environment.messages[i].createdon <= message.createdon) {
return i;
}
}
return this.data.environment.messages.length;
}
/**
* Returns the next identifier to use for DOM chat messages.
*
* @string
*/
getNextID() {
return 'msg-' + (++this.data.environment.id);
}
/**
* Checks if the current client is the sender of the message.
*
* @return boolean
*/
isSender(message) {
return message.id_sender == this.data.environment.user.id;
}
/**
* Collects the specified message within the internal state and
* pushes it within the chat conversation.
*
* @param integer id The message ID.
* @param string message The message content.
* @param object sender The sender details.
*
* @return mixed The collected object on success, otherwise false.
*/
collect(id, message, sender) {
// build dummy object
const dummy = {
id: id,
message: message,
id_sender: sender.id || 0,
sender_name: sender.name,
createdon: DateHelper.toStringUTC(new Date()),
};
if (this.data.environment.attachments.length) {
// push attachments within data
dummy.attachments = this.data.environment.attachments;
}
// draw message within the chat
this.drawMessage(dummy, true);
// push dummy data within the messages
this.data.environment.messages.unshift(dummy);
return dummy;
}
/**
* Draws the given message within the chat conversation.
* The method doesn't check whether the message matches
* the current one.
*
* @param object message The message to draw.
* @param boolean animate True to animate the message entrance.
* @param boolean buffer True to return the message template.
*
* @return mixed In case of buffer, the template string will be returned
* instead of being appended within the DOM. Otherwise,
* this object will be returned for chaining.
*/
drawMessage(message, animate, buffer) {
// get index of message
let index = this.getMessageIndex(message.id);
if (index != -1) {
// get next message
index++;
} else {
// use first index available as our message might be not yet in the list
index = 0;
}
let template = $('');
// get last message sent/received
const prev = this.data.environment.messages.length ? this.data.environment.messages[index] : null;
let hasSeparator = false;
if (!prev || DateHelper.diff(message.createdon, prev.createdon, 'minutes') > 10) {
// write date separator because have passed more than 10 minutes since the previous message
const dateSeparator = this.getDateSeparator(message.createdon);
hasSeparator = true;
template = template.add(dateSeparator);
}
// use custom element ID
const elem_id = isFinite(message.id) ? 'delivered-' + message.id : message.id;
// make content HTML-safe
let content = message.message;
if (!isFinite(message.id)) {
// make content HTML-safe only for new messages
content = content.htmlentities();
}
// fetch message content
content = this.renderMessageContent(content);
// determine sender/recipient type
const is_sender = this.isSender(message);
// obtain the details of the user that wrote the message
const user = this.getMessageUser(message);
// create the avatar node
const avatar = this.drawUserAvatar(user);
// create message content
const messageContent = $('<div class="speech-bubble"></div>').addClass('message-content ' + (animate ? 'need-animation ' : '') + (is_sender ? 'sent' : 'received'));
// add message author
messageContent.append(
$('<div class="content-author"></div>')
.text(user.name || message.sender_name)
.attr('data-sender-id', message.id_sender)
.attr('data-sender-name', message.sender_name)
);
// change name to "You" in case the sender is equal to the logged in user
if (this.data.environment.user.id == message.id_sender && this.data.environment.user.name == message.sender_name) {
messageContent.find('.content-author').text(Joomla.JText._('VBO_CHAT_YOU'));
}
// fetch last message author
const lastMessageAuthor = $(this.data.element.conversation).children().last().find('.content-author');
// hide sender name if equal to the previous message (ignore in case the message needs to display a separator above)
if (!hasSeparator && message.id_sender == lastMessageAuthor.attr('data-sender-id') && message.sender_name == lastMessageAuthor.attr('data-sender-name')) {
messageContent.find('.content-author').hide();
}
// add message text
messageContent.append($('<div class="content-text"></div>').html(content.replace(/\n/g, '<br />')));
const messageTemplate = $('<div class="chat-message"></div>').attr('id', elem_id).append(
$('<div class="speech-user-avatar"></div>').addClass(is_sender ? 'speech-sender-avatar' : 'speech-recipient-avatar').html(avatar)
).append(messageContent);
if (!content.length) {
messageTemplate.addClass('message-empty');
}
template = template.add(messageTemplate);
if (typeof message.attachments === 'string') {
try {
// try to decode the JSON attachments
message.attachments = JSON.parse(message.attachments);
} catch (err) {
// malformed string, use empty array
message.attachments = [];
}
}
const hasAttachments = message.attachments && message.attachments.length;
// check if the message has some attachments
if (hasAttachments) {
// iterate attachments
message.attachments.forEach((attachment, i) => {
const attachmentTemplate = messageTemplate.clone();
attachmentTemplate.attr('id', attachmentTemplate.attr('id') + '-attachment-' + (i + 1));
attachmentTemplate.addClass('is-attachment');
attachmentTemplate.removeClass('message-empty');
// get proper media element
const media = this.getMedia(attachment);
// check if we have something to show
if (media) {
attachmentTemplate.find('.message-content').html(media);
template = template.add(attachmentTemplate);
}
});
}
if (buffer) {
// return template in case of no animation
return template;
}
// append HTML to conversation box
$(this.data.element.conversation).append(template);
if (animate) {
// setup timeout to perform entrance animation
setTimeout(() => {
// in case of attachments, we need to find all the messages that
// starts with the message ID
const selector = hasAttachments ? '*[id^="' + elem_id + '"]' : '#' + elem_id;
// ease in message
$(selector).find('*.need-animation').removeClass('need-animation');
this.scrollToBottom();
}, 32);
}
return this;
}
/**
* Returns the details of the user that sent the specified message.
*
* @param object message The details of the message.
*
* @return object The user details.
*/
getMessageUser(message) {
if (!this.data.environment.users.hasOwnProperty(message.id_sender)) {
// user not found, register it right now
this.data.environment.users[message.id_sender] = {
id: message.id_sender,
name: message.sender_name,
avatar: '',
}
}
// obtain a copy of the user instance
const user = Object.assign({}, this.data.environment.users[message.id_sender])
if (!user.name) {
// in case of empty name, use the one specified by the message
user.name = message.sender_name
}
return user;
}
/**
* Creates the avatar for the specified user.
*
* @param object user The user details.
*
* @return mixed The HTML node.
*/
drawUserAvatar(user) {
let avatar = null;
if (user.avatar) {
avatar = $('<img decoding="async" loading="lazy" />')
.attr('alt', user.name)
.attr('title', user.name)
.attr('src', user.avatar);
} else {
let names = user.name.split(/\s+/);
avatar = $('<span></span>')
.attr('title', user.name)
.text(((names.shift() || '').substr(0, 1) + (names.pop() || '').substr(0, 1)).toUpperCase());
}
return avatar;
}
/**
* Renders the message content in order to replace certain tokens
* with a user-friendly representation.
* For example, an e-mail address could be wrapped within a link to
* open the mail app.
*
* @param string content The text to fetch.
*
* @return string The resulting string.
*/
renderMessageContent(content) {
// get parsers list and sort by priority DESC
const pool = Object.values(this.contentParsers || {}).sort((a, b) => {
if (a.priority < b.priority) {
return 1;
}
if (a.priority > b.priority) {
return -1;
}
return 0;
});
pool.forEach((parser) => {
// keep a temporary flag
let tmp = content.toString();
// run parser callback (use tmp in case the callback forgot to the return the value)
content = parser['callback'](tmp) || tmp;
});
return content;
}
/**
* Attaches a callback that will be used to parse the contents.
* In case a function with the same ID already exists, that function
* will be replaced with this one.
*
* @param string id The parser identifier.
* @param function callback The function to run while parsing the contents.
* @param integer priority The callback priority. The higher the value, the
* higher the priority.
*
* @return self
*/
attachContentParser(id, callback, priority) {
if (this.contentParsers === undefined) {
this.contentParsers = {};
}
// register callback
this.contentParsers[id] = {
callback: callback,
priority: priority || 10,
};
return this;
}
/**
* Tries to detach the parser that matches the specified id.
*
* @param string id The parser identifier.
*
* @return boolean True on success, false otherwise.
*/
detachContentParser(id) {
// make sure the pool contains the id
if (this.contentParsers !== undefined && this.contentParsers.hasOwnProperty(id)) {
// detach parser
delete this.contentParsers[id];
return true;
}
return false;
}
/**
* Returns the most appropriate DOM element according to the specified URL.
* In case of a media file, the URL will be wrapped within a <img> tag.
* Otherwise a Font Icon will be used instead.
*
* @param object file The file object (name and url properties required).
*
* @return string The HTML media string.
*/
getMedia(file) {
// always get a string
if (!file?.url?.toString) {
return '';
}
const url = file.url.toString();
if (!file.name) {
// there is something wrong with the attachment, use a broken icon
return '<i class="fas fa-unlink" title="' + url + '"></i>';
}
const onclick = "window.open('" + url + "', '_blank')";
const onload = "VBOChat.getInstance().onMediaLoaded(this)";
// check for images
if (url.match(/\.(a?png|bmp|gif|ico|jpe?g|svg|heic|webp)$/i)) {
return '<img src="' + url + '" onclick="' + onclick + '" onload="' + onload + '" title="' + file.name + '" />';
}
// check for playable video files
if (url.match(/\.(mp4|mov|ogm|webm)$/i)) {
return '<video controls onloadeddata="' + onload + '" title="' + file.name + '">\n' +
'<source src="' + url + '" />\n' +
'</video>';
}
// check for non-playable video files
if (url.match(/\.(3gp|asf|avi|divx|flv|mkv|mp?g|wmv|xvid)$/i)) {
return '<i class="fas fa-file-video" onclick="' + onclick + '" title="' + file.name + '"></i>';
}
// check for playable audio files
if (url.match(/\.(aac|m4a|mp3|opus|wave?)$/i)) {
return '<audio controls onloadeddata="' + onload + '" title="' + file.name + '">\n' +
'<source src="' + url + '" />\n' +
'</audio>';
}
// check for non-playable audio files
if (url.match(/\.(ac3|aiff|flac|midi?|wma)$/i)) {
return '<i class="fas fa-file-audio" onclick="' + onclick + '" title="' + file.name + '"></i>';
}
// check for archives
if (url.match(/\.(zip|tar|rar|gz|bzip2)$/i)) {
return '<i class="fas fa-file-archive" onclick="' + onclick + '" title="' + file.name + '"></i>';
}
// check for PDF
if (url.match(/\.pdf$/i)) {
return '<i class="fas fa-file-pdf" onclick="' + onclick + '" title="' + file.name + '"></i>';
}
// check for documents
if (url.match(/\.(docx?|rtf|odt|pages)$/i)) {
return '<i class="fas fa-file-word" onclick="' + onclick + '" title="' + file.name + '"></i>';
}
// check for excel-like sheets
if (url.match(/\.(xlsx?|csv|ods|numbers)$/i)) {
return '<i class="fas fa-file-excel" onclick="' + onclick + '" title="' + file.name + '"></i>';
}
// check for presentations
if (url.match(/\.(ppsx?|odp|keynote)$/i)) {
return '<i class="fas fa-file-powerpoint" onclick="' + onclick + '" title="' + file.name + '"></i>';
}
// check for plain text documents
if (url.match(/\.(txt|md|markdown)$/i)) {
return '<i class="fas fa-file-alt" onclick="' + onclick + '" title="' + file.name + '"></i>';
}
// use standard file
return '<i class="fas fa-file" onclick="' + onclick + '" title="' + file.name + '"></i>';
}
/**
* Handler invoked every time a media file has been loaded.
*
* @param mixed element The media element.
*
* @return void
*/
onMediaLoaded(element) {
// check if we should scroll after loading a media file,
// because if we are loading previous file, we don't
// need to scroll down
if (this.shouldScroll(element.offsetHeight + 30)) {
// scroll down
this.scrollToBottom();
}
}
/**
* Creates a new progress bar.
*
* @return string The progress bar ID.
*/
createProgressBar() {
if (this.data.environment.idProgress === undefined) {
this.data.environment.idProgress = 0;
}
// increment ID
const id = 'progress-bar-' + (++this.data.environment.idProgress);
// create progress bar
$(this.data.element.progressBar)
.append('<div class="chat-progress-bar" id="' + id + '"><div> </div></div>')
.parent()
.show();
return id;
}
/**
* Removes the specified progress bar.
*
* @param string id The progress bar ID.
*
* @return self
*/
removeProgressBar(id) {
$(this.data.element.progressBar).find('#' + id).remove();
return this;
}
/**
* Updates the progress value of the specified bar.
*
* @param string id The progress bar ID.
* @param integer progress The progress amount.
*
* @return self
*/
updateProgressBar(id, progress) {
progress = Math.max(0, progress);
progress = Math.min(100, progress);
$(this.data.element.progressBar).find('#' + id + ' > div').width(progress + '%').html(progress + '%');
return this;
}
/**
* Reads the pending notifications.
*
* @return self
*/
readNotifications() {
// ignore in case the chat shouldn't auto-read the unread messages
if (!this.data.environment.options.autoread) {
return this;
}
const unread = this.getLatestUnreadMessage();
if (!unread) {
return this;
}
// make AJAX request to read all the messages under this context
VBOChatAjax.do(
// end-point URL
this.data.environment.url,
// POST data
{
task: 'chat.read_messages',
id_context: this.data.environment.context.id,
context: this.data.environment.context.alias,
datetime: unread.createdon,
},
(messages) => {
// flag all the messages as read
messages.forEach((msgId) => {
const msg = this.getMessage(msgId);
if (msg) {
msg.read = true;
}
});
// trigger event
this.triggerEvent('chat.read', {
chat: this,
});
}
);
return this;
}
/**
* Returns the HTML to use for a date separator.
*
* @param string|object datetime The datetime to use.
*
* @return string The HTML separator.
*/
getDateSeparator(datetime) {
let dt_str = '';
if (DateHelper.isToday(datetime)) {
// current day: get formatted time
dt_str = Joomla.JText._('VBTODAY');
} else if (DateHelper.isYesterday(datetime)) {
// previous day: use "yesterday"
dt_str = Joomla.JText._('VBOYESTERDAY');
} else if ((dt_str = DateHelper.diff(datetime, new Date(), 'days')) < 7) {
let tmp = new Date();
tmp.setDate(tmp.getDate() - dt_str);
dt_str = tmp.toLocaleDateString([], {weekday: 'long'});
} else {
// use formatted date
dt_str = DateHelper.getFormattedDate(datetime);
}
return $('<div class="chat-datetime-separator is-a-separator"</div>')
.attr('data-datetime', DateHelper.toStringUTC(datetime))
.text(dt_str + ', ' + DateHelper.getFormattedTime(datetime));
}
/**
* Merges the messages with the ones stored within the internal state.
*
* @param array resp The messages.
*
* @return array Returns a list containing all the new messages.
*/
mergeMessages(resp) {
let newMessages = [], missedMessages = [];
// update messages list
for (let i = 0; i < resp.length; i++) {
// get message that matches the current ID
let message = this.getMessage(resp[i].id);
if (message) {
continue;
}
// detect the correct position of the message
let messageIndex = this.findMessagePosition(resp[i]);
// do not add the message in case it exceeds the static pagination limit, otherwise
// the system might add it twice while loading older messages
if (messageIndex >= this.data.environment.options.limit) {
continue;
}
// insert message within the list
this.data.environment.messages.splice(messageIndex, 0, resp[i]);
if (messageIndex == 0) {
// register message only if new
newMessages.push(resp[i]);
} else {
missedMessages.push(resp[i]);
}
}
return {
newMessages: newMessages,
missedMessages: missedMessages,
};
}
/**
* Uploads the given files.
*
* @param mixed files The files list.
*
* @return self
*/
uploadAttachments(files) {
// create form data for upload
const formData = new FormData();
// inject order data
formData.append('id_context', this.data.environment.context.id);
formData.append('context', this.data.environment.context.alias);
formData.append('task', 'chat.upload_attachments');
// iterate files and append to form data
for (let i = 0; i < files.length; i++) {
formData.append('attachments[]', files[i]);
}
// create progress bar
const id_progress = this.createProgressBar();
VBOChatAjax.upload(
// end-point URL
this.data.environment.url,
// file post data
formData,
// success callback
(resp) => {
// remove progress bar
this.removeProgressBar(id_progress);
for (let i = 0; i < resp.length; i++) {
// register uploaded attachment
this.registerAttachment(resp[i]);
}
},
// failure callback
(error) => {
// remove progress bar
this.removeProgressBar(id_progress);
// raise alert
alert(error.responseText);
},
// progress callback
(progress) => {
// update progress bar
this.updateProgressBar(id_progress, progress);
},
).critical();
return this;
}
/**
* Registers the file within the attachments bar.
*
* @param object file The file to attach.
*
* @return self
*/
registerAttachment(file) {
// push file within the list
this.data.environment.attachments.push(file);
file.id = file.filename.replace(/\.[^.]*$/, '');
$(this.data.element.uploadsBar)
.append('<span class="chat-attachment" id="' + file.id + '">' + file.name + '<i class="fas fa-times"></i></span>')
.parent()
.show();
// register event to remove attachment after clicking the TIMES icon
$('#' + file.id).find('i.fa-times').on('click', (event) => {
// remove attachment
this.removeAttachment(file);
});
return this;
}
/**
* Returns the index of the specified attachment.
*
* @param mixed file The file object or its ID.
*
* @return integer The file index on success, otherwise -1.
*/
getAttachmentIndex(file) {
let id = null
if (typeof file === 'object') {
id = file.id;
} else {
id = file;
}
for (let i = 0; i < this.data.environment.attachments.length; i++) {
if (this.data.environment.attachments[i].id === id) {
return i;
}
}
return -1;
}
/**
* Removes the specified attachment by unlinking it too.
*
* @param object file The file object to remove.
*
* @return self
*/
removeAttachment(file) {
// get attachment index
const index = this.getAttachmentIndex(file);
if (index != -1) {
// remove attachment box
$('#' + file.id).remove();
if (this.data.environment.attachments.length > 1) {
// splice attachments array
this.data.environment.attachments.splice(index, 1);
} else {
// clear all as we are going to have an empty list
this.clearAttachments();
}
// do not attempt deleting the file in case it is marked as "custom"
if (!file.custom) {
// make AJAX request to unlink the specified attachment
VBOChatAjax.do(
// end-point URL
this.data.environment.url,
// POST data
{
task: 'chat.remove_attachment',
id_context: this.data.environment.context.id,
context: this.data.environment.context.alias,
attachment: file,
}
);
}
}
return this;
}
/**
* Clears the attachments list.
*
* @return self
*/
clearAttachments() {
// clear attachments
this.data.environment.attachments = [];
// hide uploads bar
$(this.data.element.uploadsBar)
.html('')
.parent()
.hide();
return this;
}
/**
* Triggers the specified event.
*
* @param string name The event name.
* @param mixed data The data to inject within event.detail property.
*
* @return self
*/
triggerEvent(name, data) {
// create CustomEvent by injecting our own data
const event = new CustomEvent(name, {detail: data});
// dispatch event from window
window.dispatchEvent(event);
return this;
}
/**
* AJAX call used to load older messages of the current context.
* This function is usually invoked when the scroll hits the first half
* of the conversation.
*
* @return self
*/
loadPreviousMessages() {
if (this.data.environment.isLoadingOlderMessages) {
// do not proceed in case we are already loading something
return this;
}
// mark loading flag
this.data.environment.isLoadingOlderMessages = true;
let limit = this.data.environment.options.limit;
// make AJAX request to load older messages
VBOChatAjax.do(
// end-point URL
this.data.environment.url,
// POST data
{
task: 'chat.load_older_messages',
id_context: this.data.environment.context.id,
context: this.data.environment.context.alias,
start: this.data.environment.thread.messagesLength,
limit: limit,
/**
* We need to pass the initial date time in order to exclude
* all the messages that are newer than the latest message we got
* when the page was loaded.
*
* This will avoid errors while retriving older messages
* as new records would shift the current limits.
*/
datetime: this.data.environment.thread.initialDatetime,
},
// success callback
(resp) => {
// make loading available again
this.data.environment.isLoadingOlderMessages = false;
const conversation = $(this.data.element.conversation)[0];
// keep current scroll
let currentScrollTop = conversation.scrollTop;
let currentScrollHeight = conversation.scrollHeight;
// update count of loaded messages
this.data.environment.thread.messagesLength += resp.length;
// get current index
let start = this.data.environment.messages.length;
let end = start + resp.length;
// push messages within the list
for (let i = 0; i < resp.length; i++) {
// add message only if it doesn't exist yet
if (!this.getMessage(resp[i].id)) {
// detect the correct position of the message
let messageIndex = this.findMessagePosition(resp[i]);
// insert message within the list
this.data.environment.messages.splice(messageIndex, 0, resp[i]);
}
}
// turn off scroll event in case we reached the limit
if (resp.length < limit) {
$(this.data.element.conversation).off('scroll');
}
// build conversation messages
this.buildConversation(start, end);
// Recalculate scroll position.
// The new scroll top position will be increased by the difference between
// the old scroll height and the new one.
let newScrollTop = currentScrollTop + (conversation.scrollHeight - currentScrollHeight);
$(conversation).scrollTop(newScrollTop);
},
// failure callback
(error) => {
// make loading available again
this.data.environment.isLoadingOlderMessages = false;
}
);
}
/**
* AJAX call used to synchronize the messages.
* This should be used to load the messages that haven't been
* downloaded by the system.
*
* @return self
*/
synchronizeMessages() {
// get latest message to evaluate a threshold
const latest = this.getLatestMessage();
// make request to synchronize the messages
const xhr = VBOChatAjax.do(
// end-point URL
this.data.environment.url,
// POST data
{
task: 'chat.sync_messages',
id_context: this.data.environment.context.id,
context: this.data.environment.context.alias,
threshold: latest ? latest.id : 0,
},
// success callback
(resp) => {
if (!resp.length) {
// do nothing in case the response is empty
return;
}
// check if the chat should scroll after collecting new messages
let should_scroll = this.shouldScroll();
// update messages
let {newMessages, missedMessages} = this.mergeMessages(resp);
if (!newMessages.length && !missedMessages.length) {
// stop process in case nothing has changed
return;
}
// collect new messages
for (var i = 0; i < newMessages.length; i++) {
// draw message (animation needed)
this.drawMessage(newMessages[i], true);
}
if (newMessages.length) {
// trigger event
this.triggerEvent('chat.sync', {
notifications: newMessages.length,
chat: this,
});
}
if (missedMessages.length) {
// rebuild the conversation to properly display the missed messages too
this.startConversation().buildConversation();
// ignore auto-scroll as it should have been already applied
should_scroll = false;
}
// flush notifications for active chat
this.readNotifications();
/**
* Use bottom scroll only in case the message is visible
* within the scroll. In this way, if we are reading older messages
* we won't pushed back at the page bottom. Contrarily, in case we
* are keeping an eye on the latest messages, the chat will be scrolled
* automatically.
*/
if (should_scroll) {
// scroll conversation to bottom
this.scrollToBottom();
}
/**
* After registering the messages we
* need to fetch the payload in order to build the
* proper input for the response.
*/
this.renderInput();
}
);
// Callback must be invoked after chat.send request.
// In case this process ends while send() request is still
// running, the callback will be pushed within a queue of promises.
// Promises are automatically flushed after the completion of the
// last running send() process.
xhr.after('chat.send');
}
/**
* AJAX call used to send the message to the recipients of the current context.
* The message is always pushed within the chat even if the connection fails.
* In that case, the message will report a button that could be used to re-send
* the message.
*
* @param mixed message The message to send.
*
* @return self
*/
send(message) {
let id = null, data = null;
if (typeof message === 'object') {
// use passed data
data = message;
id = message.id;
} else {
// validate message as string
if (!message.length && this.data.environment.attachments.length == 0) {
return this;
}
// trim message
message = message.trim();
// generate temporary ID
id = this.getNextID();
// build chat bubble
data = this.collect(id, message, this.data.environment.user);
if (data) {
data.attachments = this.data.environment.attachments;
// clear attachments bar
this.clearAttachments();
}
}
if (!data) {
// something went wrong while collecting the message, abort
return this;
}
// Always re-render input after sending a message.
// At this point, a textarea should be always used.
this.renderInput();
this.input.disable();
// make request to reply to an existing message (CRITICAL)
const xhr = VBOChatAjax.do(
// end-point URL
this.data.environment.url,
// POST data
{
task: 'chat.send',
id_context: this.data.environment.context.id,
context: this.data.environment.context.alias,
message: data.message,
createdon: data.createdon,
attachments: data.attachments,
},
// success callback
(message) => {
// get index of dummy message
const dummyIndex = this.getMessageIndex(data.id);
if (dummyIndex != -1) {
// update message with received response
this.data.environment.messages[dummyIndex] = message;
}
// always re-enable the textarea in case of success
this.input.enable();
// trigger event
this.triggerEvent('chat.send', {
message: message,
chat: this,
});
},
// failure callback
(error) => {
const chat = this;
/**
* Something went wrong while trying to send the message.
* Place "re-try" button within the message box so that the user
* will be able to resend the message by clicking it.
*/
$('#' + id).find('.message-content').append('<i class="fas fa-exclamation-circle"></i>')
.append($('<div class="message-error-result"></div>').text(error.responseText));
// register event to re-send the message after clicking the exclamation triangle
$('#' + id).find('.message-content i.fa-exclamation-circle').on('click', function(event) {
// remove any possible explanation of the error
$(this).next('.message-error-result').remove();
// remove icon from message
$(this).off('click').remove();
// re-send the message
chat.send(data);
});
// obtain message
const tmp = this.getMessage(data.id);
if (tmp) {
// mark error
tmp.hasError = true;
}
// always re-enable the textarea in case of success
this.input.enable();
}
);
// mark request as critical
xhr.critical();
// set identifier to request
xhr.identify('chat.send');
return this;
}
/**
* Renders the input according to the specified payload
* of the latest received message.
*
* @return self
*/
renderInput() {
// create default form field data
const data = {
type: 'text',
hint: Joomla.JText._('VBO_CHAT_TEXTAREA_PLACEHOLDER'),
};
// make sure the input is different than the current one
if (this.input && Object.equals(this.input.payload, data)) {
return this;
}
let input = null;
try {
// get input class
input = VBOChatField.getInstance(data);
} catch (err) {
// the given type seems to be not supported, try to use the default one
Object.assign(data, def);
input = VBOChatField.getInstance(data);
}
if (this.input) {
// destroy input set previously
this.input.onDestroy(this);
}
// keep reference to new input
this.input = input;
// render input HTML
const html = this.input.render();
// set rendered HTML into input box
$(this.data.element.inputBox).html(html);
// init new input
this.input.onInit(this);
return this;
}
/**
* Clears all the intervals previously registered.
*
* @return self
*/
destroy() {
if (!this.data) {
// chat never initialized, immediately abort
return this;
}
this.timers.forEach((interval_id, index) => {
clearInterval(interval_id);
});
this.timers = [];
// unregister conversation scroll event to load older messages
$(this.data.element.conversation).off('scroll');
if (this.input) {
// destroy input previously set
this.input.onDestroy(this);
this.input = null;
}
// clear conversation messages
$(this.data.element.conversation).html('');
// the chat requires to be prepared again
this.isPrepared = false;
// delete internal data
delete this.data;
// check whether we previously has another chat
const lastData = VBOChat.queue.pop();
if (lastData) {
// restore the behavior of the previous chat
VBOChat.getInstance(lastData);
}
return this;
}
}
/**
* Holds the environment details of the previously initialized chats.
* A chat environment is pushed here when a new chat is created without
* destroying the previous one first.
*/
VBOChat.queue = [];
/**
* VBOChatField class.
* Abstract representation of a form field.
* This class acts also as a field factory, as the fields
* should be instantiated by using the getInstance() static method:
* var field = VBOChatField.getInstance({type: 'text'});
*/
w['VBOChatField'] = class VBOChatField {
/**
* Returns an instance of the requested field.
* The field will be recognized by checking the
* type property contained within data argument.
*
* @param object data The field attributes.
*
* @return mixed The new field.
*/
static getInstance(data) {
// make sure the type exists
if (!data.hasOwnProperty('type') || !data.type) {
throw 'Missing type property';
}
// fetch field class name
const className = 'VBOChatField' + data.type.charAt(0).toUpperCase() + data.type.substr(1);
// make sure the class exists
if (!VBOChatField.classMap.hasOwnProperty(data.type)) {
throw 'Form field [' + className + '] not found';
}
// find class
const _class = VBOChatField.classMap[data.type];
// instantiate field
return new _class(data);
}
/**
* Class constructor.
*
* @param object data The field attributes.
*/
constructor(data) {
this.data = data;
// keep a copy of the payload which shouldn't be altered
this.payload = Object.assign({}, data);
if (!this.data.id) {
// generate an incremental ID
if (!VBOChatField.incrementalId) {
VBOChatField.incrementalId = 0;
}
this.data.id = 'chat-answer-field-' + (++VBOChatField.incrementalId);
}
}
/**
* Binds the given data.
*
* @param string k The attribute name.
* @param mixed v The attribute value.
*
* @return self
*/
bind(k, v) {
this.data[k] = v;
return this;
}
/**
* Method used to return the field value.
*
* @return mixed The value.
*/
getValue() {
return $('#' + this.data.id).val();
}
/**
* Method used to set the field value.
*
* @param mixed val The value to set.
*
* @return mixed The source element.
*/
setValue(val) {
return $('#' + this.data.id).val(val);
}
/**
* Enables the input.
*
* @return self
*/
enable() {
$('#' + this.data.id).prop('disabled', false).focus();
return this;
}
/**
* Disables the input.
*
* @return self
*/
disable() {
$('#' + this.data.id).prop('disabled', true);
return this;
}
/**
* Method used to return the field selector.
*
* @return mixed The field selector.
*/
getSelector() {
return '#' + this.data.id;
}
/**
* Abstract method used to obtain the input HTML.
*
* @return string The input html.
*/
render() {
// inherit in children classes
}
/**
* Abstract method used to initialise the field.
* This method is called once the field has been
* added within the document.
*
* @param VBOChat chat The chat instance.
*
* @return void
*/
onInit(chat) {
// inherit in children classes
if (this.data.onInit) {
// invoke also custom initialize
this.data.onInit(chat);
}
}
/**
* Abstract method used to destroy the field.
* This method is called before removing the field
* from the document.
*
* @param VBOChat chat The chat instance.
*
* @return void
*/
onDestroy(chat) {
// inherit in children classes
if (this.data.onDestroy) {
// invoke also custom destroy
this.data.onDestroy(chat);
}
}
}
/**
* Form fields classes lookup.
*/
VBOChatField.classMap = {};
/**
* VBOChatFieldText class.
* This field is used to display a HTML input textarea.
*/
w['VBOChatFieldText'] = class VBOChatFieldText extends VBOChatField {
/**
* @override
* Method used to obtain the input HTML.
*
* @return string The input html.
*/
render() {
// fetch attributes
let attrs = '';
if (this.data.name) {
attrs += 'name="' + this.data.name.escape() + '" ';
}
if (this.data.id) {
attrs += 'id="' + this.data.id.escape() + '" ';
}
if (this.data.class) {
attrs += 'class="' + this.data.class.escape() + '" ';
}
if (this.data.hint) {
attrs += 'placeholder="' + this.data.hint.escape() + '" ';
}
if (this.data.value === undefined) {
this.data.value = this.data.default !== undefined ? this.data.default : '';
}
// define default ID for context menu trigger
this.data.idContextMenu = this.data.id + '-ctxmenu';
// define default ID for attachment input
this.data.idAttachment = this.data.id + '-attachment-input';
// define default ID for manual send message button
this.data.idManualSend = this.data.id + '-manual-send';
// return input
return '<div class="send-message-actions">\n'+
'<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'+
'</div>\n'+
'<textarea rows="1" ' + attrs.trim() + '>' + this.data.value + '</textarea>\n' +
'<span id="' + this.data.idManualSend + '" class="manual-send-message"><i class="fas fa-paper-plane"></i></span>\n'+
'<input type="file" id="' + this.data.idAttachment + '" multiple="multiple" style="display:none;" />\n';
}
/**
* Method used to initialise the field.
* This method is called once the field has been
* added within the document.
*
* @param VBOChat chat The chat instance.
*
* @return void
*/
onInit(chat) {
// invoke parent first
super.onInit(chat);
const target = $('#' + this.data.id);
if (!target.length) {
return;
}
let padding = 0;
padding += parseFloat(target.css('padding-top').replace(/[^0-9.]/g, ''));
padding += parseFloat(target.css('padding-bottom').replace(/[^0-9.]/g, ''));
// init textarea events
target.on('input', function () {
this.style.height = 'auto';
this.style.height = (this.scrollHeight - padding) + 'px';
});
// init manual send message listener
$('#' + this.data.idManualSend).on('click', () => {
chat.send(target.val());
target.val('');
target.css('height', 'auto');
});
// init attachments upload
$('#' + this.data.idAttachment).on('change', function(event) {
// get selected files
const files = $(this)[0].files;
// upload attachments
chat.uploadAttachments(files);
// unset input file value
$(this).val(null);
});
let actions = [];
chat.data.environment.context.actions.forEach((button) => {
if (button.namespace) {
if (!button.icon) {
button.icon = (...args) => {
const event = $.Event('chat.' + button.namespace + '.icon');
event.args = args.concat([button, chat]);
event.displayIcon = null;
$(window).trigger(event);
return event.displayIcon;
}
}
if (!button.action) {
button.action = (...args) => {
const event = $.Event('chat.' + button.namespace + '.action');
event.args = args.concat([button, chat]);
$(window).trigger(event);
};
}
if (!button.disabled) {
button.disabled = (...args) => {
const event = $.Event('chat.' + button.namespace + '.disabled');
event.args = args.concat([button, chat]);
event.shouldDisable = false;
$(window).trigger(event);
return event.shouldDisable;
}
}
if (!button.visible) {
button.visible = (...args) => {
const event = $.Event('chat.' + button.namespace + '.visible');
event.args = args.concat([button, chat]);
event.shouldDisplay = true;
$(window).trigger(event);
return event.shouldDisplay;
}
}
}
actions.push(button);
});
// init context menu
$('#' + this.data.idContextMenu).vboContextMenu({
placement: 'top-left',
buttons: actions.concat([
// File upload
{
text: 'Upload a file',
icon: 'fas fa-paperclip',
separator: true,
action: (root, event) => {
event.preventDefault();
setTimeout(() => {
$('#' + this.data.idAttachment).trigger('click');
}, 16);
},
}
]),
});
}
/**
* Method used to destroy the field.
* This method is called before removing the field
* from the document.
*
* @param VBOChat chat The chat instance.
*
* @return void
*/
onDestroy(chat) {
// invoke parent first
super.onInit(chat);
// turn off textarea events before destroying it
$('#' + this.data.id).off('input').off('keydown');
// turn off manual send event
$('#' + this.data.idManualSend).off('click');
// turn off attachments events
$('#' + this.data.idAttachment).off('change');
// turn off context menu
$('#' + this.data.idContextMenu).vboContextMenu('destroy');
}
}
// Register class within the lookup
VBOChatField.classMap.text = VBOChatFieldText;
/**
* DateHelper class.
* Helper class used to handle date objects.
*/
w['DateHelper'] = class DateHelper {
/**
* Checks if the specified date matches the current day.
*
* @param string|Date dt The date to check.
*
* @return boolean True if today, otherwise false.
*/
static isToday(dt) {
// compare specified date with current day
return DateHelper.isSameDay(dt, new Date());
}
/**
* Checks if the specified date matches the previous day.
*
* @param string|Date dt The date to check.
*
* @return boolean True if yesterday, otherwise false.
*/
static isYesterday(dt) {
// get yesterday date object
var yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
// compare specified date with previous day
return DateHelper.isSameDay(dt, yesterday);
}
/**
* Checks if the specified dates are equals without
* considering the related times.
*
* @param string|Date a The first date to check.
* @param string|Date b The second date to check.
*
* @return boolean True if equals, otherwise false.
*/
static isSameDay(a, b) {
// convert string to date
if (typeof a === 'string') {
a = DateHelper.stringToDate(a);
}
// convert string to date
if (typeof b === 'string') {
b = DateHelper.stringToDate(b);
}
// check if the specified days are matching (exclude time)
return (a.getDate() == b.getDate() && a.getMonth() == b.getMonth() && a.getFullYear() == b.getFullYear());
}
/**
* Calculate the difference between the specified dates.
* The difference is always an absolute value.
*
* @param string|Date a The first date to check.
* @param string|Date b The second date to check.
* @param string unit The difference unit [seconds, minutes, hours, days].
*
* @return integer The related difference according to the specified unit.
*/
static diff(a, b, unit) {
// convert string to date
if (typeof a === 'string') {
a = DateHelper.stringToDate(a);
} else {
// create new instance in order to avoid manipulating the given object
a = new Date(a);
}
// convert string to date
if (typeof b === 'string') {
b = DateHelper.stringToDate(b);
} else {
// create new instance in order to avoid manipulating the given object
b = new Date(b);
}
// use default unit if not specified
if (typeof unit === 'undefined') {
unit = 'seconds';
}
// always divide by 1000 to convert milliseconds in seconds
var div = 1000;
if (unit.match(/days?/)) {
// in case of "days" or "day", extract days from seconds
div = div * 60 * 60 * 24;
// unset hours, minutes and seconds in order to
// get the exact difference in days
a.setHours(0);
a.setMinutes(0);
a.setSeconds(0);
b.setHours(0);
b.setMinutes(0);
b.setSeconds(0);
} else if (unit.match(/hours?/)) {
// in case of "hours" or "hour", extract hours from seconds
div = div * 60 * 60;
} else if (unit.match(/min|minutes?/)) {
// in case of "min" or "minute" or "minutes", extract minutes from seconds
div = div * 60;
}
// get dates timestamp
a = a.getTime();
b = b.getTime();
// get milliseconds difference between 2 dates
var diff = Math.abs(b - a);
// divide difference by the calculated threshold
return Math.floor(diff / div);
}
/**
* Formats the specified date according to the browser locale.
*
* @param string|Date dt The date to format.
*
* @return string The formatted date.
*/
static getFormattedDate(dt) {
// convert string to date
if (typeof dt === 'string') {
dt = DateHelper.stringToDate(dt);
}
// format locale date
return dt.toLocaleDateString();
}
/**
* Formats the specified time according to the browser locale.
*
* @param string|Date dt The date to format.
*
* @return string The formatted time.
*/
static getFormattedTime(dt) {
// convert string to date
if (typeof dt === 'string') {
dt = DateHelper.stringToDate(dt);
}
// format locale time (no seconds)
return dt.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
}
/**
* Converts the specified date into a valid SQL (UTC) date format.
*
* @param string|Date dt The date to format.
*
* @return string The resulting date string.
*/
static toStringUTC(dt) {
// convert string to date
if (typeof dt === 'string') {
dt = DateHelper.stringToDate(dt);
}
var year = dt.getUTCFullYear();
var month = dt.getUTCMonth() + 1;
var day = dt.getUTCDate();
var hour = dt.getUTCHours();
var min = dt.getUTCMinutes();
var sec = dt.getUTCSeconds();
var date = year + '-' + (month < 10 ? '0' : '') + month + '-' + (day < 10 ? '0' : '') + day;
var time = (hour < 10 ? '0' : '') + hour + ':' + (min < 10 ? '0' : '') + min + ':' + (sec < 10 ? '0' : '') + sec;
return date + ' ' + time;
}
/**
* Converts the specified date string into a Date object.
*
* @param string str The date to format.
*
* @return Date The date object.
*/
static stringToDate(str) {
return new Date(str.replace(/\s+/, 'T') + 'Z');
}
}
/**
* VBOChatAjax class.
* Handles asynch server-side connections.
*/
w['VBOChatAjax'] = class VBOChatAjax {
/**
* Normalizes the given argument to be sent via AJAX.
*
* @param mixed data An object, an associative array or a serialized string.
*
* @return object The normalized object.
*/
static normalizePostData(data) {
if (data === undefined) {
data = {};
} else if (Array.isArray(data)) {
// the form data is serialized @see $.serializeArray()
var form = data;
data = {};
for (var i = 0; i < form.length; i++) {
// if the field ends with [] it should be an array
if (form[i].name.endsWith("[]")) {
// if the field doesn't exist yet, create a new list
if (!data.hasOwnProperty(form[i].name)) {
data[form[i].name] = new Array();
}
// append the value to the array
data[form[i].name].push(form[i].value);
} else {
// otherwise overwrite the value (if any)
data[form[i].name] = form[i].value;
}
}
}
return data;
}
/**
* Makes the connection.
*
* @param mixed url The URL to reach or a configuration object.
* @param mixed data The data to post.
* @param function success The callback to invoke on success.
* @param function failure The callback to invoke on failure.
* @param integer attempt The current attempt (optional).
*
* @return void
*/
static do(url, data, success, failure, attempt) {
if (attempt == 1 || attempt === undefined) {
if (!VBOChatAjax.concurrent && VBOChatAjax.isDoing()) {
return false;
}
}
if (attempt === undefined) {
attempt = 1;
}
// return same object if data has been already normalized
data = VBOChatAjax.normalizePostData(data);
var config = {};
if (typeof url === 'object') {
// we have a configuration object, use it
Object.assign(config, url);
} else {
// use the default configuration
config.type = 'post';
config.url = url;
}
// inject data within config
config.data = data;
var xhr = $.ajax(
// use fetched config
config
).done(function(resp) {
VBOChatAjax.pop(xhr);
if (success !== undefined) {
// check if we should wait for another call
if (VBOChatAjax.isRunningProcess(xhr.idAfter)) {
// register promise
VBOChatAjax.registerPromise(xhr.idAfter, success, resp);
} else {
// execute callback directly
success(resp);
}
}
// process pending promises
VBOChatAjax.processPromises(xhr.identify());
}).fail(function(err) {
// always pop XHR after failure
VBOChatAjax.pop(xhr);
// If the error has been raised by a connection failure,
// retry automatically the same request. Do not retry if the
// number of attempts is higher than the maximum number allowed.
if (attempt < VBOChatAjax.maxAttempts && VBOChatAjax.isConnectionLost(err)) {
// wait 256 milliseconds before launching the request
setTimeout(function() {
// relaunch same action and increase number of attempts by 1
VBOChatAjax.do(url, data, success, failure, attempt + 1);
}, 256);
} else {
// otherwise raise the failure method
if (failure !== undefined) {
failure(err);
}
}
console.error(err);
if (err.status.toString().match(/^5[\d]{2,2}$/)) {
console.error(err.responseText);
}
});
VBOChatAjax.push(xhr);
return xhr;
}
/**
* Makes the connection with the server and start uploading the given data.
*
* @param string url The URL to reach.
* @param mixed data The data to upload.
* @param function done The callback to invoke on success.
* @param function failure The callback to invoke on failure.
* @param function upload The callback to invoke to track the uploading progress.
*
* @return void
*/
static upload(url, data, done, failure, upload) {
// define upload config
var config = {
url: url,
type: "post",
contentType: false,
processData: false,
cache: false,
};
// define upload callback to keep track of progress
if (typeof upload === 'function') {
config.xhr = function() {
var xhrobj = $.ajaxSettings.xhr();
if (xhrobj.upload) {
// attach progress event
xhrobj.upload.addEventListener('progress', function(event) {
// calculate percentage
var percent = 0;
var position = event.loaded || event.position;
var total = event.total;
if (event.lengthComputable) {
percent = Math.ceil(position / total * 100);
}
// trigger callback
upload(percent);
}, false);
}
return xhrobj;
};
}
// invoke default do() method by using custom config
return VBOChatAjax.do(config, data, done, failure);
}
/**
* Checks if we own at least an active connection.
*
* @return boolean
*/
static isDoing() {
return VBOChatAjax.stack.length > 0 && VBOChatAjax.count > 0;
}
/**
* Checks if the process with the specified ID is running.
*
* @param mixed id The process identifier.
*
* @return boolean True if the process is running, false otherwise.
*/
static isRunningProcess(id) {
if (!id) {
return false;
}
// iterate the stack
for (var i = 0; i < VBOChatAjax.stack.length; i++) {
// get XHR instance
var xhr = VBOChatAjax.stack[i];
if (typeof xhr === 'object' && xhr.identifier === id) {
return true;
}
}
return false;
}
/**
* Checks if we are currently running a critical XHR.
* XHRs can be marked in that way by using the prototyped
* critical() function.
*
* @return boolean True if there is at least a critical XHR, otherwise false.
*/
static isRunningCritical() {
// iterate the stack
for (var i = 0; i < VBOChatAjax.stack.length; i++) {
// get XHR instance
var xhr = VBOChatAjax.stack[i];
if (typeof xhr === 'object' && typeof xhr.isCritical === 'function' && xhr.isCritical()) {
return true;
}
}
return false;
}
/**
* Registers a new promise for the specified identifier.
*
* @param mixed id The identifier to check.
* @param function callback The callback to trigger.
* @param mixed args The argument of the callback.
*
* @return void
*/
static registerPromise(id, callback, args) {
if (!id) {
return;
}
if (!VBOChatAjax.promises.hasOwnProperty(id)) {
// create list
VBOChatAjax.promises[id] = [];
}
// register promise
VBOChatAjax.promises[id].push({
callback: callback,
args: args,
});
}
/**
* Processes all the pending promises for the specified ID.
*
* @param mixed id The id to check.
*
* @return void
*/
static processPromises(id) {
if (!VBOChatAjax.promises.hasOwnProperty(id)) {
return
}
// iterate promises lists
while (VBOChatAjax.promises[id].length) {
// get first callback available
var tmp = VBOChatAjax.promises[id].shift();
// trigger callback
tmp.callback(tmp.args);
}
}
/**
* Pushes the opened connection within the stack.
*
* @param mixed xhr The connection resource.
*
* @return void
*/
static push(xhr) {
VBOChatAjax.stack.push(xhr);
VBOChatAjax.count++;
}
/**
* Removes the specified connection from the stack.
*
* @param mixed xhr The connection resource.
*
* @return void
*/
static pop(xhr) {
var index = VBOChatAjax.stack.indexOf(xhr);
if (index != -1) {
VBOChatAjax.stack.splice(index, 1);
}
VBOChatAjax.count--;
}
/**
* Checks if the given error can be intended as a loss of connection:
* generic error, no status and no response text.
*
* @param object err The error object.
*
* @return boolean
*/
static isConnectionLost(err) {
return (
err.statusText == 'error'
&& err.status == 0
&& err.readyState == 0
&& err.responseText == ''
);
}
}
VBOChatAjax.stack = [];
VBOChatAjax.promises = {};
VBOChatAjax.count = 0;
VBOChatAjax.concurrent = true;
VBOChatAjax.maxAttempts = 5;
/**
* Checks if the specified elements are equal.
*
* @param mixed x
* @param mixed y
*
* @return boolean True if identical, false otherwise.
*/
Object.equals = function(x, y) {
// if both x and y are null or undefined and exactly the same
if (x === y)
return true;
// if they are not strictly equal, they both need to be Objects
if (!(x instanceof Object) || !(y instanceof Object))
return false;
// they must have the exact same prototype chain, the closest we can do is
// test there constructor
if (x.constructor !== y.constructor)
return false;
for (var p in x) {
// make sure we are testing a valid property
if (!x.hasOwnProperty(p))
continue;
// allows to compare x[p] and y[p] when set to undefined
if (!y.hasOwnProperty(p))
return false;
// if they have the same strict value or identity then they are equal
if (x[p] === y[p])
continue;
// Numbers, Strings, Functions, Booleans must be strictly equal
if (typeof(x[p]) !== "object")
return false;
// Objects and Arrays must be tested recursively
if (!Object.equals(x[p], y[p]))
return false;
}
for (p in y) {
// allows x[p] to be set to undefined
if (y.hasOwnProperty(p) && !x.hasOwnProperty(p))
return false;
}
return true;
}
/**
* Converts the most common special chars in their HTML entities.
*
* @return string The converted string.
*/
String.prototype.htmlentities = function() {
return this.toString()
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
/**
* Escapes single quotes and double quotes by converting
* them in the related HTML entity.
*
* @return string The escaped string
*/
String.prototype.escape = function() {
return this.toString().replace(/"/g, '"');
}
/**
* Whenever an Ajax request is about to be sent, jQuery triggers the ajaxSend event.
* Any and all handlers that have been registered with the .ajaxSend() method are executed at this time.
* We can implement here all the methods that jqXHR object should support.
*/
$(window).ajaxSend(function(event, xhr, settings) {
/**
* Marks the jqXHR object as critical according to the specified argument.
*
* @param mixed is Whether the XHR is critical or not. Undefined
* argument is assumed as TRUE.
*
* @return self
*/
xhr.critical = function(is) {
this.criticalFlag = (is === undefined ? true : is);
return this;
};
/**
* Checks whether the jqXHR object is critical or not.
*
* @return boolean
*/
xhr.isCritical = function(is) {
return this.criticalFlag ? true : false;
};
/**
* Sets/Gets the ID of the jqXHR object.
*
* @param mixed id The identifier to set.
*
* @return mixed The identifier.
*/
xhr.identify = function(id) {
if (id !== undefined) {
this.identifier = id;
}
return this.identifier;
}
/**
* This method is used to push the callback of the request
* in a queue to be executed once [id] request has finished.
*
* @param mixed id The identifier of the process to observe.
*
* @return self
*/
xhr.after = function(id) {
this.idAfter = id;
return this;
}
});
/**
* The beforeunload event is fired when the window, the document and its resources are about to be unloaded.
* The document is still visible and the event is still cancelable at this point.
*
* If a string is assigned to the returnValue Event property, a dialog appears asking the user for confirmation
* to leave the page (see example below). Some browsers display the returned string in the dialog box, but others
* display their own message. If no value is provided, the event is processed silently.
*/
$(window).on('beforeunload', function(event) {
// check if we are running a XHR in background
// that shouldn't be aborted
if (VBOChatAjax.isRunningCritical()) {
// cancel the event and prompt the confirmation alert
event.preventDefault();
// for some browsers it is needed to setup a return value
event.returnValue = 'Do you want to leave the page?';
}
});
})(jQuery, window);