/**
* @copyright 2022, the Converse.js contributors
* @license Mozilla Public License (MPLv2)
* @description This is the DOM/HTML utilities module.
*/
import log from '@converse/headless/log';
import tplAudio from 'templates/audio.js';
import tplFile from 'templates/file.js';
import tplFormCaptcha from '../templates/form_captcha.js';
import tplFormCheckbox from '../templates/form_checkbox.js';
import tplFormHelp from '../templates/form_help.js';
import tplFormInput from '../templates/form_input.js';
import tplFormSelect from '../templates/form_select.js';
import tplFormTextarea from '../templates/form_textarea.js';
import tplFormUrl from '../templates/form_url.js';
import tplFormUsername from '../templates/form_username.js';
import tplHyperlink from 'templates/hyperlink.js';
import tplVideo from 'templates/video.js';
import u from '../headless/utils/core';
import { converse } from '@converse/headless/core';
import { getURI, isAudioURL, isImageURL, isVideoURL } from '@converse/headless/utils/url.js';
import { render } from 'lit';
const { sizzle, Strophe } = converse.env;
const APPROVED_URL_PROTOCOLS = ['http', 'https', 'xmpp', 'mailto'];
function getAutoCompleteProperty (name, options) {
return {
'muc#roomconfig_lang': 'language',
'muc#roomconfig_roomsecret': options?.new_password ? 'new-password' : 'current-password'
}[name];
}
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'
};
const XFORM_VALIDATE_TYPE_MAP = {
'xs:anyURI': 'url',
'xs:byte': 'number',
'xs:date': 'date',
'xs:dateTime': 'datetime',
'xs:int': 'number',
'xs:integer': 'number',
'xs:time': 'time',
}
const EMPTY_TEXT_REGEX = /\s*\n\s*/
function stripEmptyTextNodes (el) {
el = el.tree?.() ?? el;
let n;
const text_nodes = [];
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, (node) => {
if (node.parentElement.nodeName.toLowerCase() === 'body') {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
});
while (n = walker.nextNode()) text_nodes.push(n);
text_nodes.forEach((n) => EMPTY_TEXT_REGEX.test(n.data) && n.parentElement.removeChild(n))
return el;
}
const serializer = new XMLSerializer();
/**
* Given two XML or HTML elements, determine if they're equal
* @param { Element } actual
* @param { Element } expected
* @returns { Boolean }
*/
function isEqualNode (actual, expected) {
if (!u.isElement(actual)) throw new Error("Element being compared must be an Element!");
actual = stripEmptyTextNodes(actual);
expected = stripEmptyTextNodes(expected);
let isEqual = actual.isEqualNode(expected);
if (!isEqual) {
// XXX: This is a hack.
// When creating two XML elements, one via DOMParser, and one via
// createElementNS (or createElement), then "isEqualNode" doesn't match.
//
// For example, in the following code `isEqual` is false:
// ------------------------------------------------------
// const a = document.createElementNS('foo', 'div');
// a.setAttribute('xmlns', 'foo');
//
// const b = (new DOMParser()).parseFromString('<div xmlns="foo"></div>', 'text/xml').firstElementChild;
// const isEqual = a.isEqualNode(div); // false
//
// The workaround here is to serialize both elements to string and then use
// DOMParser again for both (via xmlHtmlNode).
//
// This is not efficient, but currently this is only being used in tests.
//
const { xmlHtmlNode } = Strophe;
const actual_string = serializer.serializeToString(actual);
const expected_string = serializer.serializeToString(expected);
isEqual = actual_string === expected_string || xmlHtmlNode(actual_string).isEqualNode(xmlHtmlNode(expected_string));
}
return isEqual;
}
/**
* Given an HTMLElement representing a form field, return it's name and value.
* @param { HTMLElement } field
* @returns { { string, string } | null }
*/
export function getNameAndValue(field) {
const name = field.getAttribute('name');
if (!name) {
return null; // See #1924
}
let value;
if (field.getAttribute('type') === 'checkbox') {
value = field.checked && 1 || 0;
} else if (field.tagName == "TEXTAREA") {
value = field.value.split('\n').filter(s => s.trim());
} else if (field.tagName == "SELECT") {
value = u.getSelectValues(field);
} else {
value = field.value;
}
return { name, value };
}
function getInputType(field) {
const type = XFORM_TYPE_MAP[field.getAttribute('type')]
if (type == 'text') {
const datatypes = field.getElementsByTagNameNS("http://jabber.org/protocol/xdata-validate", "validate");
if (datatypes.length === 1) {
const datatype = datatypes[0].getAttribute("datatype");
return XFORM_VALIDATE_TYPE_MAP[datatype] || type;
}
}
return type;
}
function slideOutWrapup (el) {
/* Wrapup function for slideOut. */
el.removeAttribute('data-slider-marker');
el.classList.remove('collapsed');
el.style.overflow = '';
el.style.height = '';
}
export function getFileName (url) {
const uri = getURI(url);
try {
return decodeURI(uri.filename());
} catch (error) {
log.debug(error);
return uri.filename();
}
}
/**
* 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 { TemplateResult }
*/
export function getOOBURLMarkup (url) {
const uri = getURI(url);
if (uri === null) {
return url;
}
if (isVideoURL(uri)) {
return tplVideo(url);
} else if (isAudioURL(uri)) {
return tplAudio(url);
} else if (isImageURL(uri)) {
return tplFile(uri.toString(), getFileName(uri));
} else {
return tplFile(uri.toString(), getFileName(uri));
}
}
/**
* Return the height of the passed in DOM element,
* based on the heights of its children.
* @method u#calculateElementHeight
* @param { HTMLElement } el
* @returns {number}
*/
u.calculateElementHeight = function (el) {
return Array.from(el.children).reduce((result, child) => result + child.offsetHeight, 0);
};
u.getNextElement = function (el, selector = '*') {
let next_el = el.nextElementSibling;
while (next_el !== null && !sizzle.matchesSelector(next_el, selector)) {
next_el = next_el.nextElementSibling;
}
return next_el;
};
u.getPreviousElement = function (el, selector = '*') {
let prev_el = el.previousElementSibling;
while (prev_el !== null && !sizzle.matchesSelector(prev_el, selector)) {
prev_el = prev_el.previousElementSibling;
}
return prev_el;
};
u.getFirstChildElement = function (el, selector = '*') {
let first_el = el.firstElementChild;
while (first_el !== null && !sizzle.matchesSelector(first_el, selector)) {
first_el = first_el.nextElementSibling;
}
return first_el;
};
u.getLastChildElement = function (el, selector = '*') {
let last_el = el.lastElementChild;
while (last_el !== null && !sizzle.matchesSelector(last_el, selector)) {
last_el = last_el.previousElementSibling;
}
return last_el;
};
u.toggleClass = function (className, el) {
u.hasClass(className, el) ? removeClass(className, el) : addClass(className, el);
};
/**
* Has an element a class?
* @method u#hasClass
* @param { string } className
* @param { Element } el
*/
export function hasClass (className, el) {
return el instanceof Element && el.classList.contains(className);
}
/**
* Add a class to an element.
* @method u#addClass
* @param { string } className
* @param { Element } el
*/
export function addClass (className, el) {
el instanceof Element && el.classList.add(className);
return el;
}
/**
* Remove a class from an element.
* @method u#removeClass
* @param { string } className
* @param { Element } el
*/
export function removeClass (className, el) {
el instanceof Element && el.classList.remove(className);
return el;
}
/**
* Remove an element from its parent
* @method u#removeElement
* @param { Element } el
*/
export function removeElement (el) {
el instanceof Element && el.parentNode && el.parentNode.removeChild(el);
return el;
}
u.getElementFromTemplateResult = function (tr) {
const div = document.createElement('div');
render(tr, div);
return div.firstElementChild;
};
u.showElement = el => {
removeClass('collapsed', el);
removeClass('hidden', el);
};
u.hideElement = function (el) {
el instanceof Element && el.classList.add('hidden');
return el;
};
export function ancestor (el, selector) {
let parent = el;
while (parent !== null && !sizzle.matchesSelector(parent, selector)) {
parent = parent.parentElement;
}
return parent;
}
/**
* Return the element's siblings until one matches the selector.
* @private
* @method u#nextUntil
* @param { HTMLElement } el
* @param { String } selector
*/
u.nextUntil = function (el, selector) {
const matches = [];
let sibling_el = el.nextElementSibling;
while (sibling_el !== null && !sibling_el.matches(selector)) {
matches.push(sibling_el);
sibling_el = sibling_el.nextElementSibling;
}
return matches;
};
/**
* Helper method that replace HTML-escaped symbols with equivalent characters
* (e.g. transform occurrences of '&' to '&')
* @private
* @method u#unescapeHTML
* @param { String } string - a String containing the HTML-escaped symbols.
*/
u.unescapeHTML = function (string) {
var div = document.createElement('div');
div.innerHTML = string;
return div.innerText;
};
u.escapeHTML = function (string) {
return string
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
};
function isProtocolApproved (protocol, safeProtocolsList = APPROVED_URL_PROTOCOLS) {
return !!safeProtocolsList.includes(protocol);
}
// Will return false if URL is malformed or contains disallowed characters
function isUrlValid (urlString) {
try {
const url = new URL(urlString);
return !!url;
} catch (error) {
return false;
}
}
export function getHyperlinkTemplate (url) {
const http_url = RegExp('^w{3}.', 'ig').test(url) ? `http://${url}` : url;
const uri = getURI(url);
if (uri !== null && isUrlValid(http_url) && (isProtocolApproved(uri._parts.protocol) || !uri._parts.protocol)) {
return tplHyperlink(uri, url);
}
return url;
}
u.slideInAllElements = function (elements, duration = 300) {
return Promise.all(Array.from(elements).map(e => u.slideIn(e, duration)));
};
u.slideToggleElement = function (el, duration) {
if (u.hasClass('collapsed', el) || u.hasClass('hidden', el)) {
return u.slideOut(el, duration);
} else {
return u.slideIn(el, duration);
}
};
/**
* Shows/expands an element by sliding it out of itself
* @method slideOut
* @param { HTMLElement } el - The HTML string
* @param { Number } duration - The duration amount in milliseconds
*/
export function slideOut (el, duration = 200) {
return new Promise((resolve, reject) => {
if (!el) {
const err = 'An element needs to be passed in to slideOut';
log.warn(err);
reject(new Error(err));
return;
}
const marker = el.getAttribute('data-slider-marker');
if (marker) {
el.removeAttribute('data-slider-marker');
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', requestAnimationFrame(draw).toString());
} 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', requestAnimationFrame(draw).toString());
});
}
/**
* Hides/contracts an element by sliding it into itself
* @method slideIn
* @param { HTMLElement } el - The HTML string
* @param { Number } duration - The duration amount in milliseconds
*/
export function slideIn (el, duration = 200) {
return new Promise((resolve, reject) => {
if (!el) {
const err = 'An element needs to be passed in to slideIn';
log.warn(err);
return reject(new Error(err));
} else if (u.hasClass('collapsed', el)) {
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');
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', requestAnimationFrame(draw).toString());
} else {
el.removeAttribute('data-slider-marker');
el.classList.add('collapsed');
el.style.height = '';
resolve(el);
}
}
el.setAttribute('data-slider-marker', requestAnimationFrame(draw).toString());
});
}
function afterAnimationEnds (el, callback) {
el.classList.remove('visible');
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;
};
u.fadeIn = function (el, callback) {
if (!el) {
log.warn('An element needs to be passed in to fadeIn');
}
if (window.converse_disable_effects) {
el.classList.remove('hidden');
return afterAnimationEnds(el, callback);
}
if (u.hasClass('hidden', el)) {
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));
} else {
afterAnimationEnds(el, callback);
}
};
/**
* Takes an XML field in XMPP XForm (XEP-004: Data Forms) format returns a
* [TemplateResult](https://lit.polymer-project.org/api/classes/_lit_html_.templateresult.html).
* @method u#xForm2TemplateResult
* @param { Element } field - the field to convert
* @param { Element } stanza - the containing stanza
* @param { Object } options
* @returns { TemplateResult }
*/
u.xForm2TemplateResult = 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 {
'value': value,
'label': option.getAttribute('label'),
'selected': values.includes(value),
'required': !!field.querySelector('required')
};
});
return tplFormSelect({
options,
'id': u.getUniqueId(),
'label': field.getAttribute('label'),
'multiple': field.getAttribute('type') === 'list-multi',
'name': field.getAttribute('var'),
'required': !!field.querySelector('required')
});
} else if (field.getAttribute('type') === 'fixed') {
const text = field.querySelector('value')?.textContent;
return tplFormHelp({ text });
} else if (field.getAttribute('type') === 'jid-multi') {
return tplFormTextarea({
'name': field.getAttribute('var'),
'label': field.getAttribute('label') || '',
'value': field.querySelector('value')?.textContent,
'required': !!field.querySelector('required')
});
} else if (field.getAttribute('type') === 'boolean') {
const value = field.querySelector('value')?.textContent;
return tplFormCheckbox({
'id': u.getUniqueId(),
'name': field.getAttribute('var'),
'label': field.getAttribute('label') || '',
'checked': ((value === '1' || value === 'true') && 'checked="1"') || ''
});
} else if (field.getAttribute('var') === 'url') {
return tplFormUrl({
'label': field.getAttribute('label') || '',
'value': field.querySelector('value')?.textContent
});
} else if (field.getAttribute('var') === 'username') {
return tplFormUsername({
'domain': ' @' + options.domain,
'name': field.getAttribute('var'),
'type': getInputType(field),
'label': field.getAttribute('label') || '',
'value': field.querySelector('value')?.textContent,
'required': !!field.querySelector('required')
});
} else if (field.getAttribute('var') === 'password') {
return tplFormInput({
'name': field.getAttribute('var'),
'type': 'password',
'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 tplFormCaptcha({
'label': field.getAttribute('label'),
'name': field.getAttribute('var'),
'data': el?.textContent,
'type': uri.getAttribute('type'),
'required': !!field.querySelector('required')
});
} else {
const name = field.getAttribute('var');
return tplFormInput({
'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': getInputType(field),
'value': field.querySelector('value')?.textContent
});
}
};
Object.assign(u, {
hasClass,
addClass,
ancestor,
getOOBURLMarkup,
isEqualNode,
removeClass,
removeElement,
slideIn,
slideOut,
});
export default u;