// Converse.js (A browser based XMPP chat client) // http://conversejs.org // // This is the utilities module. // // Copyright (c) 2012-2017, Jan-Carel Brand // Licensed under the Mozilla Public License (MPLv2) // /*global define, escape, window, Uint8Array */ (function (root, factory) { if (typeof define === 'function' && define.amd) { define([ "sizzle", "es6-promise/dist/es6-promise.auto", "fast-text-encoding/text", "../lodash.noconflict", "backbone", "strophe.js", "urijs", "../templates/audio.html", "../templates/file.html", "../templates/image.html", "../templates/video.html" ], factory); } else { // Used by the mockups const Strophe = { 'Strophe': root.Strophe, '$build': root.$build, '$iq': root.$iq, '$msg': root.$msg, '$pres': root.$pres, 'SHA1': root.SHA1, 'MD5': root.MD5, 'b64_hmac_sha1': root.b64_hmac_sha1, 'b64_sha1': root.b64_sha1, 'str_hmac_sha1': root.str_hmac_sha1, 'str_sha1': root.str_sha1 }; root.converse_utils = factory( root.sizzle, root.Promise, null, root._, root.Backbone, Strophe ); } }(this, function ( sizzle, Promise, FastTextEncoding, _, Backbone, Strophe, URI, tpl_audio, tpl_file, tpl_image, tpl_video ) { "use strict"; Strophe = Strophe.Strophe; const URL_REGEX = /\b(https?:\/\/|www\.|https?:\/\/www\.)[^\s<>]{2,200}\b\/?/g; const logger = _.assign({ 'debug': _.get(console, 'log') ? console.log.bind(console) : _.noop, 'error': _.get(console, 'log') ? console.log.bind(console) : _.noop, 'info': _.get(console, 'log') ? console.log.bind(console) : _.noop, 'warn': _.get(console, 'log') ? console.log.bind(console) : _.noop }, console); const isImage = function (url) { return new Promise((resolve, reject) => { var img = new Image(); var timer = window.setTimeout(function () { reject(new Error("Could not determine whether it's an image")); img = null; }, 3000); img.onerror = img.onabort = function () { clearTimeout(timer); reject(new Error("Could not determine whether it's an image")); }; img.onload = function () { clearTimeout(timer); resolve(img); }; img.src = url; }); }; function slideOutWrapup (el) { /* Wrapup function for slideOut. */ el.removeAttribute('data-slider-marker'); el.classList.remove('collapsed'); el.style.overflow = ""; el.style.height = ""; } var u = {}; u.getLongestSubstring = function (string, candidates) { function reducer (accumulator, current_value) { if (string.startsWith(current_value)) { if (current_value.length > accumulator.length) { return current_value; } else { return accumulator; } } else { return accumulator; } } return candidates.reduce(reducer, ''); } u.getNextElement = function (el, selector='*') { let next_el = el.nextElementSibling; while (!_.isNull(next_el) && !sizzle.matchesSelector(next_el, selector)) { next_el = next_el.nextElementSibling; } return next_el; } u.getPreviousElement = function (el, selector='*') { let prev_el = el.previousSibling; while (!_.isNull(prev_el) && !sizzle.matchesSelector(prev_el, selector)) { prev_el = prev_el.previousSibling } return prev_el; } u.getFirstChildElement = function (el, selector='*') { let first_el = el.firstElementChild; while (!_.isNull(first_el) && !sizzle.matchesSelector(first_el, selector)) { first_el = first_el.nextSibling } return first_el; } u.getLastChildElement = function (el, selector='*') { let last_el = el.lastElementChild; while (!_.isNull(last_el) && !sizzle.matchesSelector(last_el, selector)) { last_el = last_el.previousSibling } return last_el; } u.calculateElementHeight = function (el) { /* Return the height of the passed in DOM element, * based on the heights of its children. */ return _.reduce( el.children, (result, child) => result + child.offsetHeight, 0 ); } u.addClass = function (className, el) { if (el instanceof Element) { el.classList.add(className); } } u.removeClass = function (className, el) { if (el instanceof Element) { el.classList.remove(className); } return el; } u.removeElement = function (el) { if (!_.isNil(el) && !_.isNil(el.parentNode)) { el.parentNode.removeChild(el); } } u.showElement = _.flow( _.partial(u.removeClass, 'collapsed'), _.partial(u.removeClass, 'hidden') ) u.hideElement = function (el) { if (!_.isNil(el)) { el.classList.add('hidden'); } return el; } u.ancestor = function (el, selector) { let parent = el; while (!_.isNil(parent) && !sizzle.matchesSelector(parent, selector)) { parent = parent.parentElement; } return parent; } u.nextUntil = function (el, selector, include_self=false) { /* Return the element's siblings until one matches the selector. */ const matches = []; let sibling_el = el.nextElementSibling; while (!_.isNil(sibling_el) && !sibling_el.matches(selector)) { matches.push(sibling_el); sibling_el = sibling_el.nextElementSibling; } return matches; } u.unescapeHTML = function (string) { /* Helper method that replace HTML-escaped symbols with equivalent characters * (e.g. transform occurrences of '&' to '&') * * Parameters: * (String) string: a String containing the HTML-escaped symbols. */ var div = document.createElement('div'); div.innerHTML = string; return div.innerText; }; u.escapeHTML = function (string) { return string .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); }; u.prefixMentions = function (message) { /* Given a message object, return its text with @ chars * inserted before the mentioned nicknames. */ let text = message.get('message'); (message.get('references') || []) .sort((a, b) => b.begin - a.begin) .forEach(ref => { text = `${text.slice(0, ref.begin)}@${text.slice(ref.begin)}` }); return text; }; u.addMentionsMarkup = function (text, references, chatbox) { if (chatbox.get('message_type') !== 'groupchat') { return text; } const nick = chatbox.get('nick'); references .sort((a, b) => b.begin - a.begin) .forEach(ref => { const mention = text.slice(ref.begin, ref.end) chatbox; if (mention === nick) { text = text.slice(0, ref.begin) + `${mention}` + text.slice(ref.end); } else { text = text.slice(0, ref.begin) + `${mention}` + text.slice(ref.end); } }); return text; }; u.addHyperlinks = function (text) { return URI.withinString(text, url => { const uri = new URI(url); url = uri.normalize()._string; const pretty_url = uri._parts.urn ? url : uri.readable(); if (!uri._parts.protocol && !url.startsWith('http://') && !url.startsWith('https://')) { url = 'http://' + url; } if (uri._parts.protocol === 'xmpp' && uri._parts.query === 'join') { return `${u.escapeHTML(pretty_url)}`; } return `${u.escapeHTML(pretty_url)}`; }, { 'start': /\b(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi }); }; u.renderNewLines = function (text) { return text.replace(/\n\n+/g, '

').replace(/\n/g, '
'); }; u.renderImageURLs = function (_converse, el) { /* Returns a Promise which resolves once all images have been loaded. */ if (!_converse.show_images_inline) { return Promise.resolve(); } const { __ } = _converse; const list = el.textContent.match(URL_REGEX) || []; return Promise.all( _.map(list, url => new Promise((resolve, reject) => { if (u.isImageURL(url)) { return isImage(url).then(img => { const i = new Image(); i.src = img.src; i.addEventListener('load', resolve); // We also resolve for non-images, otherwise the // Promise.all resolves prematurely. i.addEventListener('error', resolve); const { __ } = _converse; _.each(sizzle(`a[href="${url}"]`, el), (a) => { a.outerHTML= tpl_image({ 'url': url, 'label_download': __('Download') }) }); }).catch(resolve) } else { return resolve(); } }) ) ) }; u.renderFileURL = function (_converse, url) { const uri = new URI(url); if (u.isImageURL(uri) || u.isVideoURL(uri) || u.isAudioURL(uri)) { return url; } const { __ } = _converse, filename = uri.filename(); return tpl_file({ 'url': url, 'label_download': __('Download file "%1$s"', decodeURI(filename)) }) }; u.isAudioURL = function (url) { if (!(url instanceof URI)) { url = new URI(url); } const filename = url.filename().toLowerCase(); if (!_.includes(["https", "http"], url.protocol().toLowerCase())) { return false; } return filename.endsWith('.ogg') || filename.endsWith('.mp3') || filename.endsWith('.m4a'); } u.isVideoURL = function (url) { if (!(url instanceof URI)) { url = new URI(url); } const filename = url.filename().toLowerCase(); if (!_.includes(["https", "http"], url.protocol().toLowerCase())) { return false; } return filename.endsWith('.mp4') || filename.endsWith('.webm'); } u.isImageURL = function (url) { if (!(url instanceof URI)) { url = new URI(url); } const filename = url.filename().toLowerCase(); if (!_.includes(["https", "http"], url.protocol().toLowerCase())) { return false; } return filename.endsWith('.jpg') || filename.endsWith('.jpeg') || filename.endsWith('.png') || filename.endsWith('.gif') || filename.endsWith('.bmp') || filename.endsWith('.tiff') || filename.endsWith('.svg'); }; u.renderImageURL = function (_converse, url) { if (!_converse.show_images_inline) { return u.addHyperlinks(url); } const uri = new URI(url); if (u.isImageURL(uri)) { const { __ } = _converse; return tpl_image({ 'url': url, 'label_download': __('Download image "%1$s"', decodeURI(uri.filename())) }) } return url; }; u.renderMovieURL = function (_converse, url) { const uri = new URI(url); if (u.isVideoURL(uri)) { const { __ } = _converse; return tpl_video({ 'url': url, 'label_download': __('Download video file "%1$s"', decodeURI(uri.filename())) }) } return url; }; u.renderAudioURL = function (_converse, url) { const uri = new URI(url); if (u.isAudioURL(uri)) { const { __ } = _converse; return tpl_audio({ 'url': url, 'label_download': __('Download audio file "%1$s"', decodeURI(uri.filename())) }) } return url; }; u.slideInAllElements = function (elements, duration=300) { return Promise.all( _.map( elements, _.partial(u.slideIn, _, duration) )); }; u.slideToggleElement = function (el, duration) { if (_.includes(el.classList, 'collapsed') || _.includes(el.classList, 'hidden')) { return u.slideOut(el, duration); } else { return u.slideIn(el, duration); } }; u.hasClass = function (className, el) { return _.includes(el.classList, className); }; u.slideOut = function (el, duration=200) { /* Shows/expands an element by sliding it out of itself * * Parameters: * (HTMLElement) el - The HTML string * (Number) duration - The duration amount in milliseconds */ return new Promise((resolve, reject) => { if (_.isNil(el)) { const err = "Undefined or null element passed into slideOut" logger.warn(err); reject(new Error(err)); return; } const marker = el.getAttribute('data-slider-marker'); if (marker) { el.removeAttribute('data-slider-marker'); window.cancelAnimationFrame(marker); } const end_height = u.calculateElementHeight(el); if (window.converse_disable_effects) { // Effects are disabled (for tests) el.style.height = end_height + 'px'; slideOutWrapup(el); resolve(); return; } if (!u.hasClass('collapsed', el) && !u.hasClass('hidden', el)) { resolve(); return; } const steps = duration/17; // We assume 17ms per animation which is ~60FPS let height = 0; function draw () { height += end_height/steps; if (height < end_height) { el.style.height = height + 'px'; el.setAttribute( 'data-slider-marker', window.requestAnimationFrame(draw) ); } else { // We recalculate the height to work around an apparent // browser bug where browsers don't know the correct // offsetHeight beforehand. el.removeAttribute('data-slider-marker'); el.style.height = u.calculateElementHeight(el) + 'px'; el.style.overflow = ""; el.style.height = ""; resolve(); } } el.style.height = '0'; el.style.overflow = 'hidden'; el.classList.remove('hidden'); el.classList.remove('collapsed'); el.setAttribute( 'data-slider-marker', window.requestAnimationFrame(draw) ); }); }; u.slideIn = function (el, duration=200) { /* Hides/collapses an element by sliding it into itself. */ return new Promise((resolve, reject) => { if (_.isNil(el)) { const err = "Undefined or null element passed into slideIn"; logger.warn(err); return reject(new Error(err)); } else if (_.includes(el.classList, 'collapsed')) { return resolve(el); } else if (window.converse_disable_effects) { // Effects are disabled (for tests) el.classList.add('collapsed'); el.style.height = ""; return resolve(el); } const marker = el.getAttribute('data-slider-marker'); if (marker) { el.removeAttribute('data-slider-marker'); window.cancelAnimationFrame(marker); } const original_height = el.offsetHeight, steps = duration/17; // We assume 17ms per animation which is ~60FPS let height = original_height; el.style.overflow = 'hidden'; function draw () { height -= original_height/steps; if (height > 0) { el.style.height = height + 'px'; el.setAttribute( 'data-slider-marker', window.requestAnimationFrame(draw) ); } else { el.removeAttribute('data-slider-marker'); el.classList.add('collapsed'); el.style.height = ""; resolve(el); } } el.setAttribute( 'data-slider-marker', window.requestAnimationFrame(draw) ); }); }; function afterAnimationEnds (el, callback) { el.classList.remove('visible'); if (_.isFunction(callback)) { callback(); } } u.fadeIn = function (el, callback) { if (_.isNil(el)) { logger.warn("Undefined or null element passed into fadeIn"); } if (window.converse_disable_effects) { el.classList.remove('hidden'); return afterAnimationEnds(el, callback); } if (_.includes(el.classList, 'hidden')) { el.classList.add('visible'); el.classList.remove('hidden'); el.addEventListener("webkitAnimationEnd", _.partial(afterAnimationEnds, el, callback)); el.addEventListener("animationend", _.partial(afterAnimationEnds, el, callback)); el.addEventListener("oanimationend", _.partial(afterAnimationEnds, el, callback)); } else { afterAnimationEnds(el, callback); } }; u.isValidJID = function (jid) { return _.compact(jid.split('@')).length === 2 && !jid.startsWith('@') && !jid.endsWith('@'); }; u.isValidMUCJID = function (jid) { return !jid.startsWith('@') && !jid.endsWith('@'); }; u.isSameBareJID = function (jid1, jid2) { return Strophe.getBareJidFromJid(jid1).toLowerCase() === Strophe.getBareJidFromJid(jid2).toLowerCase(); }; u.getMostRecentMessage = function (model) { const messages = model.messages.filter('message'); return messages[messages.length-1]; } u.isNewMessage = function (message) { /* Given a stanza, determine whether it's a new * message, i.e. not a MAM archived one. */ if (message instanceof Element) { return !( sizzle(`result[xmlns="${Strophe.NS.MAM}"]`, message).length && sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, message).length ); } else { return !(message.get('is_delayed') && message.get('is_archived')); } }; u.isOnlyChatStateNotification = function (attrs) { if (attrs instanceof Backbone.Model) { attrs = attrs.attributes; } return attrs['chat_state'] && !attrs['oob_url'] && !attrs['file'] && !(attrs['is_encrypted'] && attrs['plaintext']) && !attrs['message']; }; u.isHeadlineMessage = function (_converse, message) { var from_jid = message.getAttribute('from'); if (message.getAttribute('type') === 'headline') { return true; } const chatbox = _converse.chatboxes.get(Strophe.getBareJidFromJid(from_jid)); if (chatbox && chatbox.get('type') === 'chatroom') { return false; } if (message.getAttribute('type') !== 'error' && !_.isNil(from_jid) && !_.includes(from_jid, '@')) { // Some servers (I'm looking at you Prosody) don't set the message // type to "headline" when sending server messages. For now we // check if an @ signal is included, and if not, we assume it's // a headline message. return true; } return false; }; u.merge = function merge (first, second) { /* Merge the second object into the first one. */ for (var k in second) { if (_.isObject(first[k])) { merge(first[k], second[k]); } else { first[k] = second[k]; } } }; u.applyUserSettings = function applyUserSettings (context, settings, user_settings) { /* Configuration settings might be nested objects. We only want to * add settings which are whitelisted. */ for (var k in settings) { if (_.isUndefined(user_settings[k])) { continue; } if (_.isObject(settings[k]) && !_.isArray(settings[k])) { applyUserSettings(context[k], settings[k], user_settings[k]); } else { context[k] = user_settings[k]; } } }; u.stringToNode = function (s) { /* Converts an HTML string into a DOM Node. * Expects that the HTML string has only one top-level element, * i.e. not multiple ones. * * Parameters: * (String) s - The HTML string */ var div = document.createElement('div'); div.innerHTML = s; return div.firstElementChild; }; u.getOuterWidth = function (el, include_margin=false) { var width = el.offsetWidth; if (!include_margin) { return width; } var style = window.getComputedStyle(el); width += parseInt(style.marginLeft, 10) + parseInt(style.marginRight, 10); return width; }; u.stringToElement = function (s) { /* Converts an HTML string into a DOM element. * Expects that the HTML string has only one top-level element, * i.e. not multiple ones. * * Parameters: * (String) s - The HTML string */ var div = document.createElement('div'); div.innerHTML = s; return div.firstElementChild; }; u.matchesSelector = function (el, selector) { /* Checks whether the DOM element matches the given selector. * * Parameters: * (DOMElement) el - The DOM element * (String) selector - The selector */ return ( el.matches || el.matchesSelector || el.msMatchesSelector || el.mozMatchesSelector || el.webkitMatchesSelector || el.oMatchesSelector ).call(el, selector); }; u.queryChildren = function (el, selector) { /* Returns a list of children of the DOM element that match the * selector. * * Parameters: * (DOMElement) el - the DOM element * (String) selector - the selector they should be matched * against. */ return _.filter(el.childNodes, _.partial(u.matchesSelector, _, selector)); }; u.contains = function (attr, query) { return function (item) { if (typeof attr === 'object') { var value = false; _.forEach(attr, function (a) { value = value || _.includes(item.get(a).toLowerCase(), query.toLowerCase()); }); return value; } else if (typeof attr === 'string') { return _.includes(item.get(attr).toLowerCase(), query.toLowerCase()); } else { throw new TypeError('contains: wrong attribute type. Must be string or array.'); } }; }; u.isOfType = function (type, item) { return item.get('type') == type; }; u.isInstance = function (type, item) { return item instanceof type; }; u.getAttribute = function (key, item) { return item.get(key); }; u.contains.not = function (attr, query) { return function (item) { return !(u.contains(attr, query)(item)); }; }; u.rootContains = function (root, el) { // The document element does not have the contains method in IE. if (root === document && !root.contains) { return document.head.contains(el) || document.body.contains(el); } return root.contains ? root.contains(el) : window.HTMLElement.prototype.contains.call(root, el); }; u.createFragmentFromText = function (markup) { /* Returns a DocumentFragment containing DOM nodes based on the * passed-in markup text. */ // http://stackoverflow.com/questions/9334645/create-node-from-markup-string var frag = document.createDocumentFragment(), tmp = document.createElement('body'), child; tmp.innerHTML = markup; // Append elements in a loop to a DocumentFragment, so that the // browser does not re-render the document for each node. while (child = tmp.firstChild) { // eslint-disable-line no-cond-assign frag.appendChild(child); } return frag }; u.isPersistableModel = function (model) { return model.collection && model.collection.browserStorage; }; u.getResolveablePromise = function () { /* Returns a promise object on which `resolve` or `reject` can be * called. */ const wrapper = {}; const promise = new Promise((resolve, reject) => { wrapper.resolve = resolve; wrapper.reject = reject; }) _.assign(promise, wrapper); return promise; }; u.interpolate = function (string, o) { return string.replace(/{{{([^{}]*)}}}/g, (a, b) => { var r = o[b]; return typeof r === 'string' || typeof r === 'number' ? r : a; }); }; u.onMultipleEvents = function (events=[], callback) { /* Call the callback once all the events have been triggered * * Parameters: * (Array) events: An array of objects, with keys `object` and * `event`, representing the event name and the object it's * triggered upon. * (Function) callback: The function to call once all events have * been triggered. */ let triggered = []; function handler (result) { triggered.push(result) if (events.length === triggered.length) { callback(triggered); triggered = []; } } _.each(events, (map) => map.object.on(map.event, handler)); }; u.safeSave = function (model, attributes) { if (u.isPersistableModel(model)) { model.save(attributes); } else { model.set(attributes); } }; u.siblingIndex = function (el) { /* eslint-disable no-cond-assign */ for (var i = 0; el = el.previousElementSibling; i++); return i; }; u.getCurrentWord = function (input) { const cursor = input.selectionEnd || undefined; return _.last(input.value.slice(0, cursor).split(' ')); }; u.replaceCurrentWord = function (input, new_value) { const cursor = input.selectionEnd || undefined, current_word = _.last(input.value.slice(0, cursor).split(' ')), value = input.value; input.value = value.slice(0, cursor - current_word.length) + `${new_value} ` + value.slice(cursor); input.selectionEnd = cursor - current_word.length + new_value.length + 1; }; u.isVisible = function (el) { if (u.hasClass('hidden', el)) { return false; } // XXX: Taken from jQuery's "visible" implementation return el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0; }; u.triggerEvent = function (el, name, type="Event", bubbles=true, cancelable=true) { const evt = document.createEvent(type); evt.initEvent(name, bubbles, cancelable); el.dispatchEvent(evt); }; u.geoUriToHttp = function(text, geouri_replacement) { const regex = /geo:([\-0-9.]+),([\-0-9.]+)(?:,([\-0-9.]+))?(?:\?(.*))?/g; return text.replace(regex, geouri_replacement); }; u.httpToGeoUri = function(text, _converse) { const replacement = 'geo:$1,$2'; return text.replace(_converse.geouri_regex, replacement); }; u.getSelectValues = function (select) { const result = []; const options = select && select.options; for (var i=0, iLen=options.length; i ('00' + x.toString(16)).slice(-2)).join(''); }; u.arrayBufferToString = function (ab) { return new TextDecoder("utf-8").decode(ab); }; u.stringToArrayBuffer = function (string) { const bytes = new TextEncoder("utf-8").encode(string); return bytes.buffer; }; u.arrayBufferToBase64 = function (ab) { return btoa((new Uint8Array(ab)).reduce((data, byte) => data + String.fromCharCode(byte), '')); }; u.base64ToArrayBuffer = function (b64) { const binary_string = window.atob(b64), len = binary_string.length, bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = binary_string.charCodeAt(i) } return bytes.buffer }; u.getRandomInt = function (max) { return Math.floor(Math.random() * Math.floor(max)); }; u.putCurserAtEnd = function (textarea) { if (textarea !== document.activeElement) { textarea.focus(); } // Double the length because Opera is inconsistent about whether a carriage return is one character or two. const len = textarea.value.length * 2; // Timeout seems to be required for Blink setTimeout(() => textarea.setSelectionRange(len, len), 1); // Scroll to the bottom, in case we're in a tall textarea // (Necessary for Firefox and Chrome) this.scrollTop = 999999; }; u.getUniqueId = function () { return 'xxxxxxxx-xxxx'.replace(/[x]/g, function(c) { var r = Math.random() * 16 | 0, v = c === 'x' ? r : r & 0x3 | 0x8; return v.toString(16); }); }; return u; }));