// Converse.js (A browser based XMPP chat client) // https://conversejs.org // // This is a form utilities module. // // Copyright (c) 2013-2019, Jan-Carel Brand // Licensed under the Mozilla Public License (MPLv2) import URI from "urijs"; import _ from "../headless/lodash.noconflict"; import sizzle from "sizzle"; import tpl_audio from "../templates/audio.html"; import tpl_field from "@converse/headless/templates/field.html"; import tpl_file from "../templates/file.html"; import tpl_form_captcha from "../templates/form_captcha.html"; import tpl_form_checkbox from "../templates/form_checkbox.html"; import tpl_form_input from "../templates/form_input.html"; import tpl_form_select from "../templates/form_select.html"; import tpl_form_textarea from "../templates/form_textarea.html"; import tpl_form_url from "../templates/form_url.html"; import tpl_form_username from "../templates/form_username.html"; import tpl_image from "../templates/image.html"; import tpl_select_option from "../templates/select_option.html"; import tpl_video from "../templates/video.html"; import u from "../headless/utils/core"; 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 (url.protocol().toLowerCase() !== "https") { 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 (url.protocol().toLowerCase() !== "https") { 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 (url.protocol().toLowerCase() !== "https") { 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.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.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')) }); } } } export default u;