diff --git a/src/converse-message-view.js b/src/converse-message-view.js index 2371443c3..9bdf2b0f6 100644 --- a/src/converse-message-view.js +++ b/src/converse-message-view.js @@ -6,6 +6,7 @@ (function (root, factory) { define([ + "./utils/html", "utils/emoji", "@converse/headless/converse-core", "xss", @@ -17,6 +18,7 @@ "templates/message_versions_modal.html", ], factory); }(this, function ( + html, u, converse, xss, diff --git a/src/converse-register.js b/src/converse-register.js index d9339576f..3de209ab9 100644 --- a/src/converse-register.js +++ b/src/converse-register.js @@ -12,12 +12,12 @@ (function (root, factory) { define(["utils/form", "@converse/headless/converse-core", - "@converse/headless/templates/form_username.html", + "templates/form_username.html", "templates/register_link.html", "templates/register_panel.html", "templates/registration_form.html", "templates/registration_request.html", - "@converse/headless/templates/form_input.html", + "templates/form_input.html", "templates/spinner.html", "converse-controlbox" ], factory); diff --git a/src/headless/utils/core.js b/src/headless/utils/core.js index 13e956825..e08ad47c3 100644 --- a/src/headless/utils/core.js +++ b/src/headless/utils/core.js @@ -12,15 +12,9 @@ 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 @@ -49,57 +43,14 @@ }(this, function ( sizzle, Promise, - FastTextEncoding, _, Backbone, - Strophe, - URI, - tpl_audio, - tpl_file, - tpl_image, - tpl_video + Strophe ) { "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 = {}; + const u = {}; u.getLongestSubstring = function (string, candidates) { function reducer (accumulator, current_value) { @@ -116,118 +67,6 @@ 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. @@ -241,328 +80,6 @@ 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('@'); }; diff --git a/src/headless/utils/form.js b/src/headless/utils/form.js index 42acc3494..fcd6e43dc 100644 --- a/src/headless/utils/form.js +++ b/src/headless/utils/form.js @@ -6,49 +6,16 @@ // Copyright (c) 2013-2018, Jan-Carel Brand // Licensed under the Mozilla Public License (MPLv2) // -/*global define, escape, Jed */ +/*global define */ (function (root, factory) { define([ - "sizzle", "../lodash.noconflict", "./core", - "../templates/field.html", - "../templates/select_option.html", - "../templates/form_select.html", - "../templates/form_textarea.html", - "../templates/form_checkbox.html", - "../templates/form_username.html", - "../templates/form_input.html", - "../templates/form_captcha.html", - "../templates/form_url.html", + "../templates/field.html" ], factory); -}(this, function ( - sizzle, - _, - u, - tpl_field, - tpl_select_option, - tpl_form_select, - tpl_form_textarea, - tpl_form_checkbox, - tpl_form_username, - tpl_form_input, - tpl_form_captcha, - tpl_form_url - ) { +}(this, function (_, u, tpl_field) { "use strict"; - var XFORM_TYPE_MAP = { - 'text-private': 'password', - 'text-single': 'text', - 'fixed': 'label', - 'boolean': 'checkbox', - 'hidden': 'hidden', - 'jid-multi': 'textarea', - 'list-single': 'dropdown', - 'list-multi': 'dropdown' - }; - u.webForm2xForm = function (field) { /* Takes an HTML DOM and turns it into an XForm field. * @@ -72,101 +39,5 @@ }) ); }; - - u.xForm2webForm = function (field, stanza, domain) { - /* Takes a field in XMPP XForm (XEP-004: Data Forms) format - * and turns it into an HTML field. - * - * Returns either text or a DOM element (which is not ideal, but fine - * for now). - * - * Parameters: - * (XMLElement) field - the field to convert - */ - if (field.getAttribute('type')) { - if (field.getAttribute('type') === 'list-single' || - field.getAttribute('type') === 'list-multi') { - - const values = _.map( - u.queryChildren(field, 'value'), - _.partial(_.get, _, 'textContent') - ); - const options = _.map( - u.queryChildren(field, 'option'), - function (option) { - const value = _.get(option.querySelector('value'), 'textContent'); - return tpl_select_option({ - 'value': value, - 'label': option.getAttribute('label'), - 'selected': _.includes(values, value), - 'required': !_.isNil(field.querySelector('required')) - }) - } - ); - return tpl_form_select({ - 'id': u.getUniqueId(), - 'name': field.getAttribute('var'), - 'label': field.getAttribute('label'), - 'options': options.join(''), - 'multiple': (field.getAttribute('type') === 'list-multi'), - 'required': !_.isNil(field.querySelector('required')) - }); - } else if (field.getAttribute('type') === 'fixed') { - const text = _.get(field.querySelector('value'), 'textContent'); - return '

'+text+'

'; - } else if (field.getAttribute('type') === 'jid-multi') { - return tpl_form_textarea({ - 'name': field.getAttribute('var'), - 'label': field.getAttribute('label') || '', - 'value': _.get(field.querySelector('value'), 'textContent'), - 'required': !_.isNil(field.querySelector('required')) - }); - } else if (field.getAttribute('type') === 'boolean') { - return tpl_form_checkbox({ - 'id': u.getUniqueId(), - 'name': field.getAttribute('var'), - 'label': field.getAttribute('label') || '', - 'checked': _.get(field.querySelector('value'), 'textContent') === "1" && 'checked="1"' || '', - 'required': !_.isNil(field.querySelector('required')) - }); - } else if (field.getAttribute('var') === 'url') { - return tpl_form_url({ - 'label': field.getAttribute('label') || '', - 'value': _.get(field.querySelector('value'), 'textContent') - }); - } else if (field.getAttribute('var') === 'username') { - return tpl_form_username({ - 'domain': ' @'+domain, - 'name': field.getAttribute('var'), - 'type': XFORM_TYPE_MAP[field.getAttribute('type')], - 'label': field.getAttribute('label') || '', - 'value': _.get(field.querySelector('value'), 'textContent'), - 'required': !_.isNil(field.querySelector('required')) - }); - } else { - return tpl_form_input({ - 'id': u.getUniqueId(), - 'label': field.getAttribute('label') || '', - 'name': field.getAttribute('var'), - 'placeholder': null, - 'required': !_.isNil(field.querySelector('required')), - 'type': XFORM_TYPE_MAP[field.getAttribute('type')], - 'value': _.get(field.querySelector('value'), 'textContent') - }); - } - } else { - if (field.getAttribute('var') === 'ocr') { // Captcha - const uri = field.querySelector('uri'); - const el = sizzle('data[cid="'+uri.textContent.replace(/^cid:/, '')+'"]', stanza)[0]; - return tpl_form_captcha({ - 'label': field.getAttribute('label'), - 'name': field.getAttribute('var'), - 'data': _.get(el, 'textContent'), - 'type': uri.getAttribute('type'), - 'required': !_.isNil(field.querySelector('required')) - }); - } - } - } return u; })); diff --git a/src/headless/templates/audio.html b/src/templates/audio.html similarity index 100% rename from src/headless/templates/audio.html rename to src/templates/audio.html diff --git a/src/headless/templates/file.html b/src/templates/file.html similarity index 100% rename from src/headless/templates/file.html rename to src/templates/file.html diff --git a/src/headless/templates/form_captcha.html b/src/templates/form_captcha.html similarity index 100% rename from src/headless/templates/form_captcha.html rename to src/templates/form_captcha.html diff --git a/src/headless/templates/form_checkbox.html b/src/templates/form_checkbox.html similarity index 100% rename from src/headless/templates/form_checkbox.html rename to src/templates/form_checkbox.html diff --git a/src/headless/templates/form_input.html b/src/templates/form_input.html similarity index 100% rename from src/headless/templates/form_input.html rename to src/templates/form_input.html diff --git a/src/headless/templates/form_select.html b/src/templates/form_select.html similarity index 100% rename from src/headless/templates/form_select.html rename to src/templates/form_select.html diff --git a/src/headless/templates/form_textarea.html b/src/templates/form_textarea.html similarity index 100% rename from src/headless/templates/form_textarea.html rename to src/templates/form_textarea.html diff --git a/src/headless/templates/form_url.html b/src/templates/form_url.html similarity index 100% rename from src/headless/templates/form_url.html rename to src/templates/form_url.html diff --git a/src/headless/templates/form_username.html b/src/templates/form_username.html similarity index 100% rename from src/headless/templates/form_username.html rename to src/templates/form_username.html diff --git a/src/headless/templates/image.html b/src/templates/image.html similarity index 100% rename from src/headless/templates/image.html rename to src/templates/image.html diff --git a/src/headless/templates/select_option.html b/src/templates/select_option.html similarity index 100% rename from src/headless/templates/select_option.html rename to src/templates/select_option.html diff --git a/src/headless/templates/video.html b/src/templates/video.html similarity index 100% rename from src/headless/templates/video.html rename to src/templates/video.html diff --git a/src/utils/html.js b/src/utils/html.js new file mode 100644 index 000000000..27f42b7eb --- /dev/null +++ b/src/utils/html.js @@ -0,0 +1,643 @@ +// Converse.js (A browser based XMPP chat client) +// http://conversejs.org +// +// This is a form utilities module. +// +// Copyright (c) 2013-2018, Jan-Carel Brand +// Licensed under the Mozilla Public License (MPLv2) +// +/*global define */ +(function (root, factory) { + define([ + "sizzle", + "../headless/lodash.noconflict", + "../headless/utils/core", + "urijs", + "../templates/audio.html", + "../headless/templates/field.html", + "../templates/file.html", + "../templates/form_captcha.html", + "../templates/form_checkbox.html", + "../templates/form_input.html", + "../templates/form_select.html", + "../templates/form_textarea.html", + "../templates/form_url.html", + "../templates/form_username.html", + "../templates/image.html", + "../templates/select_option.html", + "../templates/video.html" + ], factory); +}(this, function ( + sizzle, + _, + u, + URI, + tpl_audio, + tpl_field, + tpl_file, + tpl_form_captcha, + tpl_form_checkbox, + tpl_form_input, + tpl_form_select, + tpl_form_textarea, + tpl_form_url, + tpl_form_username, + tpl_image, + tpl_select_option, + tpl_video + ) { + "use strict"; + + 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 XFORM_TYPE_MAP = { + 'text-private': 'password', + 'text-single': 'text', + 'fixed': 'label', + 'boolean': 'checkbox', + 'hidden': 'hidden', + 'jid-multi': 'textarea', + 'list-single': 'dropdown', + 'list-multi': 'dropdown' + }; + + function slideOutWrapup (el) { + /* Wrapup function for slideOut. */ + el.removeAttribute('data-slider-marker'); + el.classList.remove('collapsed'); + el.style.overflow = ""; + el.style.height = ""; + } + + + 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; + }); + }; + + + 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.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.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.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.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.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.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.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.renderNewLines = function (text) { + return text.replace(/\n\n+/g, '

').replace(/\n/g, '
'); + }; + + 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.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.hasClass = function (className, el) { + return _.includes(el.classList, className); + }; + + 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.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.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.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.xForm2webForm = function (field, stanza, domain) { + /* Takes a field in XMPP XForm (XEP-004: Data Forms) format + * and turns it into an HTML field. + * + * Returns either text or a DOM element (which is not ideal, but fine + * for now). + * + * Parameters: + * (XMLElement) field - the field to convert + */ + if (field.getAttribute('type')) { + if (field.getAttribute('type') === 'list-single' || + field.getAttribute('type') === 'list-multi') { + + const values = _.map( + u.queryChildren(field, 'value'), + _.partial(_.get, _, 'textContent') + ); + const options = _.map( + u.queryChildren(field, 'option'), + function (option) { + const value = _.get(option.querySelector('value'), 'textContent'); + return tpl_select_option({ + 'value': value, + 'label': option.getAttribute('label'), + 'selected': _.includes(values, value), + 'required': !_.isNil(field.querySelector('required')) + }) + } + ); + return tpl_form_select({ + 'id': u.getUniqueId(), + 'name': field.getAttribute('var'), + 'label': field.getAttribute('label'), + 'options': options.join(''), + 'multiple': (field.getAttribute('type') === 'list-multi'), + 'required': !_.isNil(field.querySelector('required')) + }); + } else if (field.getAttribute('type') === 'fixed') { + const text = _.get(field.querySelector('value'), 'textContent'); + return '

'+text+'

'; + } else if (field.getAttribute('type') === 'jid-multi') { + return tpl_form_textarea({ + 'name': field.getAttribute('var'), + 'label': field.getAttribute('label') || '', + 'value': _.get(field.querySelector('value'), 'textContent'), + 'required': !_.isNil(field.querySelector('required')) + }); + } else if (field.getAttribute('type') === 'boolean') { + return tpl_form_checkbox({ + 'id': u.getUniqueId(), + 'name': field.getAttribute('var'), + 'label': field.getAttribute('label') || '', + 'checked': _.get(field.querySelector('value'), 'textContent') === "1" && 'checked="1"' || '', + 'required': !_.isNil(field.querySelector('required')) + }); + } else if (field.getAttribute('var') === 'url') { + return tpl_form_url({ + 'label': field.getAttribute('label') || '', + 'value': _.get(field.querySelector('value'), 'textContent') + }); + } else if (field.getAttribute('var') === 'username') { + return tpl_form_username({ + 'domain': ' @'+domain, + 'name': field.getAttribute('var'), + 'type': XFORM_TYPE_MAP[field.getAttribute('type')], + 'label': field.getAttribute('label') || '', + 'value': _.get(field.querySelector('value'), 'textContent'), + 'required': !_.isNil(field.querySelector('required')) + }); + } else { + return tpl_form_input({ + 'id': u.getUniqueId(), + 'label': field.getAttribute('label') || '', + 'name': field.getAttribute('var'), + 'placeholder': null, + 'required': !_.isNil(field.querySelector('required')), + 'type': XFORM_TYPE_MAP[field.getAttribute('type')], + 'value': _.get(field.querySelector('value'), 'textContent') + }); + } + } else { + if (field.getAttribute('var') === 'ocr') { // Captcha + const uri = field.querySelector('uri'); + const el = sizzle('data[cid="'+uri.textContent.replace(/^cid:/, '')+'"]', stanza)[0]; + return tpl_form_captcha({ + 'label': field.getAttribute('label'), + 'name': field.getAttribute('var'), + 'data': _.get(el, 'textContent'), + 'type': uri.getAttribute('type'), + 'required': !_.isNil(field.querySelector('required')) + }); + } + } + } + return u; +}));