xmpp.chapril.org-conversejs/src/utils/html.js

673 lines
21 KiB
JavaScript
Raw Normal View History

2020-01-26 16:21:20 +01:00
/**
* @copyright 2020, the Converse.js contributors
* @license Mozilla Public License (MPLv2)
* @description This is the DOM/HTML utilities module.
*/
2018-10-23 03:41:38 +02:00
import URI from "urijs";
import { isFunction } from "lodash";
import log from '@converse/headless/log';
2018-10-23 03:41:38 +02:00
import sizzle from "sizzle";
2020-03-06 11:16:04 +01:00
import tpl_audio from "../templates/audio.js";
import tpl_file from "../templates/file.js";
2018-10-23 03:41:38 +02:00
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";
2020-03-06 11:16:04 +01:00
import tpl_image from "../templates/image.js";
2018-10-23 03:41:38 +02:00
import tpl_select_option from "../templates/select_option.html";
2020-03-06 11:16:04 +01:00
import tpl_video from "../templates/video.js";
2018-10-23 03:41:38 +02:00
import u from "../headless/utils/core";
const URL_REGEX = /\b(https?\:\/\/|www\.|https?:\/\/www\.)[^\s<>]{2,200}\b\/?/g;
2018-10-23 03:41:38 +02:00
function getAutoCompleteProperty (name, options) {
return {
'muc#roomconfig_lang': 'language',
'muc#roomconfig_roomsecret': options?.new_password ? 'new-password' : 'current-password'
}[name];
}
2018-10-23 03:41:38 +02:00
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 = "";
}
function getURI (url) {
try {
return (url instanceof URI) ? url : (new URI(url));
} catch (error) {
log.debug(error);
return null;
2018-10-23 03:41:38 +02:00
}
}
function checkTLS (uri) {
return window.location.protocol === 'http:' ||
window.location.protocol === 'https:' && uri.protocol().toLowerCase() === "https";
}
function checkFileTypes (types, url) {
const uri = getURI(url);
if (uri === null || !checkTLS(uri)) {
2018-10-23 03:41:38 +02:00
return false;
}
const filename = uri.filename().toLowerCase();
return !!types.filter(ext => filename.endsWith(ext)).length;
2018-10-23 03:41:38 +02:00
}
2020-01-09 14:11:09 +01:00
u.isAudioURL = url => checkFileTypes(['.ogg', '.mp3', '.m4a'], url);
u.isImageURL = url => checkFileTypes(['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.svg'], url);
u.isVideoURL = url => checkFileTypes(['.mp4', '.webm'], url);
function getFileName (uri) {
try {
return decodeURI(uri.filename());
} catch (error) {
log.debug(error);
return uri.filename();
2018-10-23 03:41:38 +02:00
}
}
function renderAudioURL (_converse, uri) {
const { __ } = _converse;
return tpl_audio({
'url': uri.toString(),
'label_download': __('Download audio file "%1$s"', getFileName(uri))
})
}
function renderImageURL (_converse, uri) {
if (!_converse.api.settings.get('show_images_inline')) {
return u.convertToHyperlink(uri);
2018-10-23 03:41:38 +02:00
}
const { __ } = _converse;
return tpl_image({
'url': uri.toString(),
'label_download': __('Download image "%1$s"', getFileName(uri))
})
}
2018-10-23 03:41:38 +02:00
function renderFileURL (_converse, uri) {
const { __ } = _converse;
return tpl_file({
'url': uri.toString(),
'label_download': __('Download file "%1$s"', getFileName(uri))
})
}
/**
* Returns the markup for a URL that points to a downloadable asset
* (such as a video, image or audio file).
* @method u#getOOBURLMarkup
* @param { String } url
* @returns { String }
*/
u.getOOBURLMarkup = function (_converse, url) {
const uri = getURI(url);
if (uri === null) {
return url;
2018-10-23 03:41:38 +02:00
}
2020-01-09 14:11:09 +01:00
if (u.isVideoURL(uri)) {
return tpl_video({url})
2020-01-09 14:11:09 +01:00
} else if (u.isAudioURL(uri)) {
return renderAudioURL(_converse, uri);
2020-01-09 14:11:09 +01:00
} else if (u.isImageURL(uri)) {
return renderImageURL(_converse, uri);
} else {
return renderFileURL(_converse, uri);
2018-10-23 03:41:38 +02:00
}
}
/**
* Applies some resistance to `value` around the `default_value`.
* If value is close enough to `default_value`, then it is returned, otherwise
* `value` is returned.
* @method u#applyDragResistance
* @param { Integer } value
* @param { Integer } default_value
* @returns { Integer }
*/
u.applyDragResistance = function (value, default_value) {
if (value === undefined) {
return undefined;
} else if (default_value === undefined) {
return value;
}
const resistance = 10;
if ((value !== default_value) &&
(Math.abs(value- default_value) < resistance)) {
return default_value;
}
return value;
};
function loadImage (url) {
return new Promise((resolve, reject) => {
const err_msg = `Could not determine whether it's an image: ${url}`;
const img = new Image();
const timer = window.setTimeout(() => reject(new Error(err_msg)), 20000);
img.onerror = img.onabort = function () {
clearTimeout(timer);
reject(new Error(err_msg));
};
img.onload = function () {
clearTimeout(timer);
resolve(img);
};
img.src = url;
});
}
async function renderImage (img_url, link_url, el, callback) {
if (u.isImageURL(img_url)) {
let img;
try {
img = await loadImage(img_url);
} catch (e) {
log.error(e);
return callback();
}
sizzle(`a[href="${link_url}"]`, el).forEach(a => {
a.innerHTML = "";
2020-03-25 10:39:41 +01:00
u.addClass('chat-image__link', a);
u.addClass('chat-image', img);
u.addClass('img-thumbnail', img);
a.insertAdjacentElement('afterBegin', img);
});
}
callback();
}
/**
* Returns a Promise which resolves once all images have been loaded.
* @method u#renderImageURLs
* @param { _converse }
* @param { HTMLElement }
* @returns { Promise }
*/
2018-10-23 03:41:38 +02:00
u.renderImageURLs = function (_converse, el) {
if (!_converse.api.settings.get('show_images_inline')) {
2018-10-23 03:41:38 +02:00
return Promise.resolve();
}
const list = el.textContent.match(URL_REGEX) || [];
return Promise.all(
list.map(url =>
new Promise(resolve => {
let image_url = getURI(url);
if (['imgur.com', 'pbs.twimg.com'].includes(image_url.hostname()) && !u.isImageURL(url)) {
const format = (image_url.hostname() === 'pbs.twimg.com') ? image_url.search(true).format : 'png';
image_url = image_url.removeSearch(/.*/).toString() + `.${format}`;
renderImage(image_url, url, el, resolve);
2018-10-23 03:41:38 +02:00
} else {
renderImage(url, url, el, resolve);
2018-10-23 03:41:38 +02:00
}
})
2018-10-23 03:41:38 +02:00
)
)
};
2018-10-23 03:41:38 +02:00
u.renderNewLines = function (text) {
return text.replace(/\n\n+/g, '<br/><br/>').replace(/\n/g, '<br/>');
};
u.calculateElementHeight = function (el) {
/* Return the height of the passed in DOM element,
* based on the heights of its children.
*/
return Array.from(el.children).reduce((result, child) => result + child.offsetHeight, 0);
2018-10-23 03:41:38 +02:00
}
u.getNextElement = function (el, selector='*') {
let next_el = el.nextElementSibling;
while (next_el !== null && !sizzle.matchesSelector(next_el, selector)) {
2018-10-23 03:41:38 +02:00
next_el = next_el.nextElementSibling;
}
2018-10-23 03:41:38 +02:00
return next_el;
}
2018-10-23 03:41:38 +02:00
u.getPreviousElement = function (el, selector='*') {
let prev_el = el.previousElementSibling;
while (prev_el !== null && !sizzle.matchesSelector(prev_el, selector)) {
prev_el = prev_el.previousElementSibling
}
2018-10-23 03:41:38 +02:00
return prev_el;
}
2018-10-23 03:41:38 +02:00
u.getFirstChildElement = function (el, selector='*') {
let first_el = el.firstElementChild;
while (first_el !== null && !sizzle.matchesSelector(first_el, selector)) {
first_el = first_el.nextElementSibling
}
2018-10-23 03:41:38 +02:00
return first_el;
}
2018-10-23 03:41:38 +02:00
u.getLastChildElement = function (el, selector='*') {
let last_el = el.lastElementChild;
while (last_el !== null && !sizzle.matchesSelector(last_el, selector)) {
last_el = last_el.previousElementSibling
}
2018-10-23 03:41:38 +02:00
return last_el;
}
2018-10-23 03:41:38 +02:00
u.hasClass = function (className, el) {
return (el instanceof Element) && el.classList.contains(className);
2018-10-23 03:41:38 +02:00
};
u.toggleClass = function (className, el) {
u.hasClass(className, el) ? u.removeClass(className, el) : u.addClass(className, el);
}
/**
* Add a class to an element.
* @method u#addClass
* @param {string} className
* @param {Element} el
*/
2018-10-23 03:41:38 +02:00
u.addClass = function (className, el) {
(el instanceof Element) && el.classList.add(className);
return el;
2018-10-23 03:41:38 +02:00
}
/**
* Remove a class from an element.
* @method u#removeClass
* @param {string} className
* @param {Element} el
*/
2018-10-23 03:41:38 +02:00
u.removeClass = function (className, el) {
(el instanceof Element) && el.classList.remove(className);
2018-10-23 03:41:38 +02:00
return el;
}
2018-10-23 03:41:38 +02:00
u.removeElement = function (el) {
(el instanceof Element) && el.parentNode && el.parentNode.removeChild(el);
return el;
2018-10-23 03:41:38 +02:00
}
u.showElement = el => {
u.removeClass('collapsed', el);
u.removeClass('hidden', el);
}
2018-10-23 03:41:38 +02:00
u.hideElement = function (el) {
(el instanceof Element) && el.classList.add('hidden');
2018-10-23 03:41:38 +02:00
return el;
}
2018-10-23 03:41:38 +02:00
u.ancestor = function (el, selector) {
let parent = el;
while (parent !== null && !sizzle.matchesSelector(parent, selector)) {
2018-10-23 03:41:38 +02:00
parent = parent.parentElement;
}
2018-10-23 03:41:38 +02:00
return parent;
}
2019-10-09 16:01:38 +02:00
/**
* Return the element's siblings until one matches the selector.
* @private
* @method u#nextUntil
* @param { HTMLElement } el
* @param { String } selector
2019-10-09 16:01:38 +02:00
*/
u.nextUntil = function (el, selector) {
2018-10-23 03:41:38 +02:00
const matches = [];
let sibling_el = el.nextElementSibling;
while (sibling_el !== null && !sibling_el.matches(selector)) {
2018-10-23 03:41:38 +02:00
matches.push(sibling_el);
sibling_el = sibling_el.nextElementSibling;
}
2018-10-23 03:41:38 +02:00
return matches;
}
/**
* Helper method that replace HTML-escaped symbols with equivalent characters
* (e.g. transform occurrences of '&amp;' to '&')
* @private
* @method u#unescapeHTML
* @param { String } string - a String containing the HTML-escaped symbols.
*/
2018-10-23 03:41:38 +02:00
u.unescapeHTML = function (string) {
var div = document.createElement('div');
div.innerHTML = string;
return div.innerText;
};
u.escapeHTML = function (string) {
return string
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
};
u.addMentionsMarkup = function (text, references, chatbox) {
if (chatbox.get('message_type') !== 'groupchat') {
return text;
2018-10-23 03:41:38 +02:00
}
const nick = chatbox.get('nick');
references
.sort((a, b) => b.begin - a.begin)
.forEach(ref => {
const prefix = text.slice(0, ref.begin);
const offset = ((prefix.match(/&lt;/g) || []).length + (prefix.match(/&gt;/g) || []).length) * 3;
const begin = parseInt(ref.begin, 10) + parseInt(offset, 10);
const end = parseInt(ref.end, 10) + parseInt(offset, 10);
const mention = text.slice(begin, end)
2018-10-23 03:41:38 +02:00
chatbox;
2018-10-23 03:41:38 +02:00
if (mention === nick) {
text = text.slice(0, begin) + `<span class="mention mention--self badge badge-info">${mention}</span>` + text.slice(end);
2018-10-23 03:41:38 +02:00
} else {
text = text.slice(0, begin) + `<span class="mention">${mention}</span>` + text.slice(end);
}
});
2018-10-23 03:41:38 +02:00
return text;
};
u.convertToHyperlink = function (url) {
const uri = getURI(url);
if (uri === null) {
return 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 `<a target="_blank" rel="noopener" class="open-chatroom" href="${url}">${u.escapeHTML(pretty_url)}</a>`;
}
return `<a target="_blank" rel="noopener" href="${url}">${u.escapeHTML(pretty_url)}</a>`;
}
2018-10-23 03:41:38 +02:00
u.addHyperlinks = function (text) {
const parse_options = {
2018-10-23 03:41:38 +02:00
'start': /\b(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi
};
return URI.withinString(text, url => u.convertToHyperlink(url), parse_options);
2018-10-23 03:41:38 +02:00
};
u.slideInAllElements = function (elements, duration=300) {
return Promise.all(Array.from(elements).map(e => u.slideIn(e, duration)));
2018-10-23 03:41:38 +02:00
};
u.slideToggleElement = function (el, duration) {
if (u.hasClass('collapsed', el) || u.hasClass('hidden', el)) {
2018-10-23 03:41:38 +02:00
return u.slideOut(el, duration);
} else {
return u.slideIn(el, duration);
}
};
/**
* Shows/expands an element by sliding it out of itself
* @private
* @method u#slideOut
* @param { HTMLElement } el - The HTML string
* @param { Number } duration - The duration amount in milliseconds
*/
2018-10-23 03:41:38 +02:00
u.slideOut = function (el, duration=200) {
return new Promise((resolve, reject) => {
if (!el) {
const err = "An element needs to be passed in to slideOut"
log.warn(err);
2018-10-23 03:41:38 +02:00
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;
}
2018-10-23 03:41:38 +02:00
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');
2018-10-23 03:41:38 +02:00
el.style.height = u.calculateElementHeight(el) + 'px';
el.style.overflow = "";
el.style.height = "";
resolve();
}
}
2018-10-23 03:41:38 +02:00
el.style.height = '0';
el.style.overflow = 'hidden';
el.classList.remove('hidden');
el.classList.remove('collapsed');
el.setAttribute(
'data-slider-marker',
window.requestAnimationFrame(draw)
);
});
};
2018-10-23 03:41:38 +02:00
u.slideIn = function (el, duration=200) {
/* Hides/collapses an element by sliding it into itself. */
return new Promise((resolve, reject) => {
if (!el) {
const err = "An element needs to be passed in to slideIn";
log.warn(err);
2018-10-23 03:41:38 +02:00
return reject(new Error(err));
} else if (u.hasClass('collapsed', el)) {
2018-10-23 03:41:38 +02:00
return resolve(el);
} else if (window.converse_disable_effects) { // Effects are disabled (for tests)
el.classList.add('collapsed');
el.style.height = "";
return resolve(el);
}
2018-10-23 03:41:38 +02:00
const marker = el.getAttribute('data-slider-marker');
if (marker) {
el.removeAttribute('data-slider-marker');
window.cancelAnimationFrame(marker);
}
2018-10-23 03:41:38 +02:00
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 {
2018-10-23 03:41:38 +02:00
el.removeAttribute('data-slider-marker');
el.classList.add('collapsed');
el.style.height = "";
resolve(el);
}
2018-10-23 03:41:38 +02:00
}
el.setAttribute(
'data-slider-marker',
window.requestAnimationFrame(draw)
);
});
};
function afterAnimationEnds (el, callback) {
el.classList.remove('visible');
if (isFunction(callback)) {
2018-10-23 03:41:38 +02:00
callback();
}
}
u.isInDOM = function (el) {
return document.querySelector('body').contains(el);
}
u.isVisible = function (el) {
if (el === null) {
return false;
}
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;
};
2018-10-23 03:41:38 +02:00
u.fadeIn = function (el, callback) {
if (!el) {
log.warn("An element needs to be passed in to fadeIn");
2018-10-23 03:41:38 +02:00
}
if (window.converse_disable_effects) {
el.classList.remove('hidden');
return afterAnimationEnds(el, callback);
}
if (u.hasClass('hidden', el)) {
2018-10-23 03:41:38 +02:00
el.classList.add('visible');
el.classList.remove('hidden');
el.addEventListener("webkitAnimationEnd", () => afterAnimationEnds(el, callback));
el.addEventListener("animationend", () => afterAnimationEnds(el, callback));
el.addEventListener("oanimationend", () => afterAnimationEnds(el, callback));
2018-10-23 03:41:38 +02:00
} else {
afterAnimationEnds(el, callback);
}
};
/**
* 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).
* @private
* @method u#xForm2webForm
* @param { XMLElement } field - the field to convert
*/
u.xForm2webForm = function (field, stanza, options) {
if (field.getAttribute('type') === 'list-single' ||
field.getAttribute('type') === 'list-multi') {
const values = u.queryChildren(field, 'value').map(el => el?.textContent);
const options = u.queryChildren(field, 'option').map(option => {
const value = option.querySelector('value')?.textContent;
return tpl_select_option({
'value': value,
'label': option.getAttribute('label'),
'selected': values.includes(value),
'required': !!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': !!field.querySelector('required')
});
} else if (field.getAttribute('type') === 'fixed') {
const text = field.querySelector('value')?.textContent;
return '<p class="form-help">'+text+'</p>';
} else if (field.getAttribute('type') === 'jid-multi') {
return tpl_form_textarea({
'name': field.getAttribute('var'),
'label': field.getAttribute('label') || '',
'value': field.querySelector('value')?.textContent,
'required': !!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': field.querySelector('value')?.textContent === "1" && 'checked="1"' || '',
'required': !!field.querySelector('required')
});
} else if (field.getAttribute('var') === 'url') {
return tpl_form_url({
'label': field.getAttribute('label') || '',
'value': field.querySelector('value')?.textContent
});
} else if (field.getAttribute('var') === 'username') {
return tpl_form_username({
'domain': ' @'+options.domain,
'name': field.getAttribute('var'),
'type': XFORM_TYPE_MAP[field.getAttribute('type')],
'label': field.getAttribute('label') || '',
'value': field.querySelector('value')?.textContent,
'required': !!field.querySelector('required')
});
} 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': el?.textContent,
'type': uri.getAttribute('type'),
'required': !!field.querySelector('required')
});
2018-10-23 03:41:38 +02:00
} else {
const name = field.getAttribute('var');
return tpl_form_input({
'id': u.getUniqueId(),
'label': field.getAttribute('label') || '',
'name': name,
'fixed_username': options?.fixed_username,
'autocomplete': getAutoCompleteProperty(name, options),
'placeholder': null,
'required': !!field.querySelector('required'),
'type': XFORM_TYPE_MAP[field.getAttribute('type')],
'value': field.querySelector('value')?.textContent
});
}
2018-10-23 03:41:38 +02:00
}
export default u;