html.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675
  1. // Converse.js (A browser based XMPP chat client)
  2. // https://conversejs.org
  3. //
  4. // This is a form utilities module.
  5. //
  6. // Copyright (c) 2013-2019, Jan-Carel Brand <jc@opkode.com>
  7. // Licensed under the Mozilla Public License (MPLv2)
  8. import URI from "urijs";
  9. import _ from "../headless/lodash.noconflict";
  10. import log from '@converse/headless/log';
  11. import sizzle from "sizzle";
  12. import tpl_audio from "../templates/audio.html";
  13. import tpl_file from "../templates/file.html";
  14. import tpl_form_captcha from "../templates/form_captcha.html";
  15. import tpl_form_checkbox from "../templates/form_checkbox.html";
  16. import tpl_form_input from "../templates/form_input.html";
  17. import tpl_form_select from "../templates/form_select.html";
  18. import tpl_form_textarea from "../templates/form_textarea.html";
  19. import tpl_form_url from "../templates/form_url.html";
  20. import tpl_form_username from "../templates/form_username.html";
  21. import tpl_image from "../templates/image.html";
  22. import tpl_select_option from "../templates/select_option.html";
  23. import tpl_video from "../templates/video.html";
  24. import u from "../headless/utils/core";
  25. const URL_REGEX = /\b(https?\:\/\/|www\.|https?:\/\/www\.)[^\s<>]{2,200}\b\/?/g;
  26. function getAutoCompleteProperty (name, options) {
  27. return {
  28. 'muc#roomconfig_lang': 'language',
  29. 'muc#roomconfig_roomsecret': options.new_password ? 'new-password' : 'current-password'
  30. }[name];
  31. }
  32. const XFORM_TYPE_MAP = {
  33. 'text-private': 'password',
  34. 'text-single': 'text',
  35. 'fixed': 'label',
  36. 'boolean': 'checkbox',
  37. 'hidden': 'hidden',
  38. 'jid-multi': 'textarea',
  39. 'list-single': 'dropdown',
  40. 'list-multi': 'dropdown'
  41. };
  42. function slideOutWrapup (el) {
  43. /* Wrapup function for slideOut. */
  44. el.removeAttribute('data-slider-marker');
  45. el.classList.remove('collapsed');
  46. el.style.overflow = "";
  47. el.style.height = "";
  48. }
  49. function isImage (url) {
  50. return new Promise((resolve, reject) => {
  51. const err_msg = `Could not determine whether it's an image: ${url}`;
  52. const img = new Image();
  53. const timer = window.setTimeout(() => reject(new Error(err_msg)), 3000);
  54. img.onerror = img.onabort = function () {
  55. clearTimeout(timer);
  56. reject(new Error(err_msg));
  57. };
  58. img.onload = function () {
  59. clearTimeout(timer);
  60. resolve(img);
  61. };
  62. img.src = url;
  63. });
  64. }
  65. function getURI (url) {
  66. try {
  67. return (url instanceof URI) ? url : (new URI(url));
  68. } catch (error) {
  69. log.debug(error);
  70. return null;
  71. }
  72. }
  73. function checkTLS (uri) {
  74. return window.location.protocol === 'http:' ||
  75. window.location.protocol === 'https:' && uri.protocol().toLowerCase() === "https";
  76. }
  77. function checkFileTypes (types, url) {
  78. const uri = getURI(url);
  79. if (uri === null || !checkTLS(uri)) {
  80. return false;
  81. }
  82. const filename = uri.filename().toLowerCase();
  83. return !!types.filter(ext => filename.endsWith(ext)).length;
  84. }
  85. u.isAudioURL = url => checkFileTypes(['.ogg', '.mp3', '.m4a'], url);
  86. u.isImageURL = url => checkFileTypes(['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.svg'], url);
  87. u.isVideoURL = url => checkFileTypes(['.mp4', '.webm'], url);
  88. function getFileName (uri) {
  89. try {
  90. return decodeURI(uri.filename());
  91. } catch (error) {
  92. log.debug(error);
  93. return uri.filename();
  94. }
  95. }
  96. function renderAudioURL (_converse, uri) {
  97. const { __ } = _converse;
  98. return tpl_audio({
  99. 'url': uri.toString(),
  100. 'label_download': __('Download audio file "%1$s"', getFileName(uri))
  101. })
  102. }
  103. function renderImageURL (_converse, uri) {
  104. if (!_converse.show_images_inline) {
  105. return u.convertToHyperlink(uri);
  106. }
  107. const { __ } = _converse;
  108. return tpl_image({
  109. 'url': uri.toString(),
  110. 'label_download': __('Download image "%1$s"', getFileName(uri))
  111. })
  112. }
  113. function renderFileURL (_converse, uri) {
  114. const { __ } = _converse;
  115. return tpl_file({
  116. 'url': uri.toString(),
  117. 'label_download': __('Download file "%1$s"', getFileName(uri))
  118. })
  119. }
  120. /**
  121. * Returns the markup for a URL that points to a downloadable asset
  122. * (such as a video, image or audio file).
  123. * @method u#getOOBURLMarkup
  124. * @param { String } url
  125. * @returns { String }
  126. */
  127. u.getOOBURLMarkup = function (_converse, url) {
  128. const uri = getURI(url);
  129. if (uri === null) {
  130. return url;
  131. }
  132. if (u.isVideoURL(uri)) {
  133. return tpl_video({url})
  134. } else if (u.isAudioURL(uri)) {
  135. return renderAudioURL(_converse, uri);
  136. } else if (u.isImageURL(uri)) {
  137. return renderImageURL(_converse, uri);
  138. } else {
  139. return renderFileURL(_converse, uri);
  140. }
  141. }
  142. /**
  143. * Applies some resistance to `value` around the `default_value`.
  144. * If value is close enough to `default_value`, then it is returned, otherwise
  145. * `value` is returned.
  146. * @method u#applyDragResistance
  147. * @param { Integer } value
  148. * @param { Integer } default_value
  149. * @returns { Integer }
  150. */
  151. u.applyDragResistance = function (value, default_value) {
  152. if (value === undefined) {
  153. return undefined;
  154. } else if (default_value === undefined) {
  155. return value;
  156. }
  157. const resistance = 10;
  158. if ((value !== default_value) &&
  159. (Math.abs(value- default_value) < resistance)) {
  160. return default_value;
  161. }
  162. return value;
  163. };
  164. async function renderImage (img_url, link_url, el, callback) {
  165. if (u.isImageURL(img_url)) {
  166. let img;
  167. try {
  168. img = await isImage(img_url);
  169. } catch (e) {
  170. log.error(e);
  171. return callback();
  172. }
  173. sizzle(`a[href="${link_url}"]`, el).forEach(a => {
  174. a.innerHTML = "";
  175. u.addClass('chat-image', img);
  176. u.addClass('img-thumbnail', img);
  177. a.insertAdjacentElement('afterBegin', img);
  178. });
  179. }
  180. callback();
  181. }
  182. /**
  183. * Returns a Promise which resolves once all images have been loaded.
  184. * @method u#renderImageURLs
  185. * @param { _converse }
  186. * @param { HTMLElement }
  187. * @returns { Promise }
  188. */
  189. u.renderImageURLs = function (_converse, el) {
  190. if (!_converse.show_images_inline) {
  191. return Promise.resolve();
  192. }
  193. const list = el.textContent.match(URL_REGEX) || [];
  194. return Promise.all(
  195. list.map(url =>
  196. new Promise(resolve => {
  197. if (url.startsWith('https://imgur.com') && !u.isImageURL(url)) {
  198. const imgur_url = url + '.png';
  199. renderImage(imgur_url, url, el, resolve);
  200. } else {
  201. renderImage(url, url, el, resolve);
  202. }
  203. })
  204. )
  205. )
  206. };
  207. u.renderNewLines = function (text) {
  208. return text.replace(/\n\n+/g, '<br/><br/>').replace(/\n/g, '<br/>');
  209. };
  210. u.calculateElementHeight = function (el) {
  211. /* Return the height of the passed in DOM element,
  212. * based on the heights of its children.
  213. */
  214. return _.reduce(
  215. el.children,
  216. (result, child) => result + child.offsetHeight, 0
  217. );
  218. }
  219. u.getNextElement = function (el, selector='*') {
  220. let next_el = el.nextElementSibling;
  221. while (next_el !== null && !sizzle.matchesSelector(next_el, selector)) {
  222. next_el = next_el.nextElementSibling;
  223. }
  224. return next_el;
  225. }
  226. u.getPreviousElement = function (el, selector='*') {
  227. let prev_el = el.previousElementSibling;
  228. while (prev_el !== null && !sizzle.matchesSelector(prev_el, selector)) {
  229. prev_el = prev_el.previousElementSibling
  230. }
  231. return prev_el;
  232. }
  233. u.getFirstChildElement = function (el, selector='*') {
  234. let first_el = el.firstElementChild;
  235. while (first_el !== null && !sizzle.matchesSelector(first_el, selector)) {
  236. first_el = first_el.nextElementSibling
  237. }
  238. return first_el;
  239. }
  240. u.getLastChildElement = function (el, selector='*') {
  241. let last_el = el.lastElementChild;
  242. while (last_el !== null && !sizzle.matchesSelector(last_el, selector)) {
  243. last_el = last_el.previousElementSibling
  244. }
  245. return last_el;
  246. }
  247. u.hasClass = function (className, el) {
  248. return (el instanceof Element) && el.classList.contains(className);
  249. };
  250. /**
  251. * Add a class to an element.
  252. * @method u#addClass
  253. * @param {string} className
  254. * @param {Element} el
  255. */
  256. u.addClass = function (className, el) {
  257. (el instanceof Element) && el.classList.add(className);
  258. return el;
  259. }
  260. /**
  261. * Remove a class from an element.
  262. * @method u#removeClass
  263. * @param {string} className
  264. * @param {Element} el
  265. */
  266. u.removeClass = function (className, el) {
  267. (el instanceof Element) && el.classList.remove(className);
  268. return el;
  269. }
  270. u.removeElement = function (el) {
  271. (el instanceof Element) && el.parentNode && el.parentNode.removeChild(el);
  272. return el;
  273. }
  274. u.showElement = _.flow(
  275. _.partial(u.removeClass, 'collapsed'),
  276. _.partial(u.removeClass, 'hidden')
  277. )
  278. u.hideElement = function (el) {
  279. (el instanceof Element) && el.classList.add('hidden');
  280. return el;
  281. }
  282. u.ancestor = function (el, selector) {
  283. let parent = el;
  284. while (parent !== null && !sizzle.matchesSelector(parent, selector)) {
  285. parent = parent.parentElement;
  286. }
  287. return parent;
  288. }
  289. /**
  290. * Return the element's siblings until one matches the selector.
  291. * @private
  292. * @method u#nextUntil
  293. * @param { HTMLElement } el
  294. * @param { String } selector
  295. */
  296. u.nextUntil = function (el, selector) {
  297. const matches = [];
  298. let sibling_el = el.nextElementSibling;
  299. while (sibling_el !== null && !sibling_el.matches(selector)) {
  300. matches.push(sibling_el);
  301. sibling_el = sibling_el.nextElementSibling;
  302. }
  303. return matches;
  304. }
  305. /**
  306. * Helper method that replace HTML-escaped symbols with equivalent characters
  307. * (e.g. transform occurrences of '&amp;' to '&')
  308. * @private
  309. * @method u#unescapeHTML
  310. * @param { String } string - a String containing the HTML-escaped symbols.
  311. */
  312. u.unescapeHTML = function (string) {
  313. var div = document.createElement('div');
  314. div.innerHTML = string;
  315. return div.innerText;
  316. };
  317. u.escapeHTML = function (string) {
  318. return string
  319. .replace(/&/g, "&amp;")
  320. .replace(/</g, "&lt;")
  321. .replace(/>/g, "&gt;")
  322. .replace(/"/g, "&quot;");
  323. };
  324. u.addMentionsMarkup = function (text, references, chatbox) {
  325. if (chatbox.get('message_type') !== 'groupchat') {
  326. return text;
  327. }
  328. const nick = chatbox.get('nick');
  329. references
  330. .sort((a, b) => b.begin - a.begin)
  331. .forEach(ref => {
  332. const mention = text.slice(ref.begin, ref.end)
  333. chatbox;
  334. if (mention === nick) {
  335. text = text.slice(0, ref.begin) + `<span class="mention mention--self badge badge-info">${mention}</span>` + text.slice(ref.end);
  336. } else {
  337. text = text.slice(0, ref.begin) + `<span class="mention">${mention}</span>` + text.slice(ref.end);
  338. }
  339. });
  340. return text;
  341. };
  342. u.convertToHyperlink = function (url) {
  343. const uri = getURI(url);
  344. if (uri === null) {
  345. return url;
  346. }
  347. url = uri.normalize()._string;
  348. const pretty_url = uri._parts.urn ? url : uri.readable();
  349. if (!uri._parts.protocol && !url.startsWith('http://') && !url.startsWith('https://')) {
  350. url = 'http://' + url;
  351. }
  352. if (uri._parts.protocol === 'xmpp' && uri._parts.query === 'join') {
  353. return `<a target="_blank" rel="noopener" class="open-chatroom" href="${url}">${u.escapeHTML(pretty_url)}</a>`;
  354. }
  355. return `<a target="_blank" rel="noopener" href="${url}">${u.escapeHTML(pretty_url)}</a>`;
  356. }
  357. u.addHyperlinks = function (text) {
  358. const parse_options = {
  359. 'start': /\b(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi
  360. };
  361. return URI.withinString(text, url => u.convertToHyperlink(url), parse_options);
  362. };
  363. u.slideInAllElements = function (elements, duration=300) {
  364. return Promise.all(
  365. _.map(
  366. elements,
  367. _.partial(u.slideIn, _, duration)
  368. ));
  369. };
  370. u.slideToggleElement = function (el, duration) {
  371. if (_.includes(el.classList, 'collapsed') ||
  372. _.includes(el.classList, 'hidden')) {
  373. return u.slideOut(el, duration);
  374. } else {
  375. return u.slideIn(el, duration);
  376. }
  377. };
  378. /**
  379. * Shows/expands an element by sliding it out of itself
  380. * @private
  381. * @method u#slideOut
  382. * @param { HTMLElement } el - The HTML string
  383. * @param { Number } duration - The duration amount in milliseconds
  384. */
  385. u.slideOut = function (el, duration=200) {
  386. return new Promise((resolve, reject) => {
  387. if (!el) {
  388. const err = "An element needs to be passed in to slideOut"
  389. log.warn(err);
  390. reject(new Error(err));
  391. return;
  392. }
  393. const marker = el.getAttribute('data-slider-marker');
  394. if (marker) {
  395. el.removeAttribute('data-slider-marker');
  396. window.cancelAnimationFrame(marker);
  397. }
  398. const end_height = u.calculateElementHeight(el);
  399. if (window.converse_disable_effects) { // Effects are disabled (for tests)
  400. el.style.height = end_height + 'px';
  401. slideOutWrapup(el);
  402. resolve();
  403. return;
  404. }
  405. if (!u.hasClass('collapsed', el) && !u.hasClass('hidden', el)) {
  406. resolve();
  407. return;
  408. }
  409. const steps = duration/17; // We assume 17ms per animation which is ~60FPS
  410. let height = 0;
  411. function draw () {
  412. height += end_height/steps;
  413. if (height < end_height) {
  414. el.style.height = height + 'px';
  415. el.setAttribute(
  416. 'data-slider-marker',
  417. window.requestAnimationFrame(draw)
  418. );
  419. } else {
  420. // We recalculate the height to work around an apparent
  421. // browser bug where browsers don't know the correct
  422. // offsetHeight beforehand.
  423. el.removeAttribute('data-slider-marker');
  424. el.style.height = u.calculateElementHeight(el) + 'px';
  425. el.style.overflow = "";
  426. el.style.height = "";
  427. resolve();
  428. }
  429. }
  430. el.style.height = '0';
  431. el.style.overflow = 'hidden';
  432. el.classList.remove('hidden');
  433. el.classList.remove('collapsed');
  434. el.setAttribute(
  435. 'data-slider-marker',
  436. window.requestAnimationFrame(draw)
  437. );
  438. });
  439. };
  440. u.slideIn = function (el, duration=200) {
  441. /* Hides/collapses an element by sliding it into itself. */
  442. return new Promise((resolve, reject) => {
  443. if (!el) {
  444. const err = "An element needs to be passed in to slideIn";
  445. log.warn(err);
  446. return reject(new Error(err));
  447. } else if (_.includes(el.classList, 'collapsed')) {
  448. return resolve(el);
  449. } else if (window.converse_disable_effects) { // Effects are disabled (for tests)
  450. el.classList.add('collapsed');
  451. el.style.height = "";
  452. return resolve(el);
  453. }
  454. const marker = el.getAttribute('data-slider-marker');
  455. if (marker) {
  456. el.removeAttribute('data-slider-marker');
  457. window.cancelAnimationFrame(marker);
  458. }
  459. const original_height = el.offsetHeight,
  460. steps = duration/17; // We assume 17ms per animation which is ~60FPS
  461. let height = original_height;
  462. el.style.overflow = 'hidden';
  463. function draw () {
  464. height -= original_height/steps;
  465. if (height > 0) {
  466. el.style.height = height + 'px';
  467. el.setAttribute(
  468. 'data-slider-marker',
  469. window.requestAnimationFrame(draw)
  470. );
  471. } else {
  472. el.removeAttribute('data-slider-marker');
  473. el.classList.add('collapsed');
  474. el.style.height = "";
  475. resolve(el);
  476. }
  477. }
  478. el.setAttribute(
  479. 'data-slider-marker',
  480. window.requestAnimationFrame(draw)
  481. );
  482. });
  483. };
  484. function afterAnimationEnds (el, callback) {
  485. el.classList.remove('visible');
  486. if (_.isFunction(callback)) {
  487. callback();
  488. }
  489. }
  490. u.isInDOM = function (el) {
  491. return document.querySelector('body').contains(el);
  492. }
  493. u.isVisible = function (el) {
  494. if (el === null) {
  495. return false;
  496. }
  497. if (u.hasClass('hidden', el)) {
  498. return false;
  499. }
  500. // XXX: Taken from jQuery's "visible" implementation
  501. return el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0;
  502. };
  503. u.fadeIn = function (el, callback) {
  504. if (!el) {
  505. log.warn("An element needs to be passed in to fadeIn");
  506. }
  507. if (window.converse_disable_effects) {
  508. el.classList.remove('hidden');
  509. return afterAnimationEnds(el, callback);
  510. }
  511. if (_.includes(el.classList, 'hidden')) {
  512. el.classList.add('visible');
  513. el.classList.remove('hidden');
  514. el.addEventListener("webkitAnimationEnd", _.partial(afterAnimationEnds, el, callback));
  515. el.addEventListener("animationend", _.partial(afterAnimationEnds, el, callback));
  516. el.addEventListener("oanimationend", _.partial(afterAnimationEnds, el, callback));
  517. } else {
  518. afterAnimationEnds(el, callback);
  519. }
  520. };
  521. /**
  522. * Takes a field in XMPP XForm (XEP-004: Data Forms) format
  523. * and turns it into an HTML field.
  524. * Returns either text or a DOM element (which is not ideal, but fine for now).
  525. * @private
  526. * @method u#xForm2webForm
  527. * @param { XMLElement } field - the field to convert
  528. */
  529. u.xForm2webForm = function (field, stanza, options) {
  530. if (field.getAttribute('type') === 'list-single' ||
  531. field.getAttribute('type') === 'list-multi') {
  532. const values = _.map(
  533. u.queryChildren(field, 'value'),
  534. _.partial(_.get, _, 'textContent')
  535. );
  536. const options = _.map(
  537. u.queryChildren(field, 'option'),
  538. function (option) {
  539. const value = _.get(option.querySelector('value'), 'textContent');
  540. return tpl_select_option({
  541. 'value': value,
  542. 'label': option.getAttribute('label'),
  543. 'selected': _.includes(values, value),
  544. 'required': !!field.querySelector('required')
  545. })
  546. }
  547. );
  548. return tpl_form_select({
  549. 'id': u.getUniqueId(),
  550. 'name': field.getAttribute('var'),
  551. 'label': field.getAttribute('label'),
  552. 'options': options.join(''),
  553. 'multiple': (field.getAttribute('type') === 'list-multi'),
  554. 'required': !!field.querySelector('required')
  555. });
  556. } else if (field.getAttribute('type') === 'fixed') {
  557. const text = _.get(field.querySelector('value'), 'textContent');
  558. return '<p class="form-help">'+text+'</p>';
  559. } else if (field.getAttribute('type') === 'jid-multi') {
  560. return tpl_form_textarea({
  561. 'name': field.getAttribute('var'),
  562. 'label': field.getAttribute('label') || '',
  563. 'value': _.get(field.querySelector('value'), 'textContent'),
  564. 'required': !!field.querySelector('required')
  565. });
  566. } else if (field.getAttribute('type') === 'boolean') {
  567. return tpl_form_checkbox({
  568. 'id': u.getUniqueId(),
  569. 'name': field.getAttribute('var'),
  570. 'label': field.getAttribute('label') || '',
  571. 'checked': _.get(field.querySelector('value'), 'textContent') === "1" && 'checked="1"' || '',
  572. 'required': !!field.querySelector('required')
  573. });
  574. } else if (field.getAttribute('var') === 'url') {
  575. return tpl_form_url({
  576. 'label': field.getAttribute('label') || '',
  577. 'value': _.get(field.querySelector('value'), 'textContent')
  578. });
  579. } else if (field.getAttribute('var') === 'username') {
  580. return tpl_form_username({
  581. 'domain': ' @'+options.domain,
  582. 'name': field.getAttribute('var'),
  583. 'type': XFORM_TYPE_MAP[field.getAttribute('type')],
  584. 'label': field.getAttribute('label') || '',
  585. 'value': _.get(field.querySelector('value'), 'textContent'),
  586. 'required': !!field.querySelector('required')
  587. });
  588. } else if (field.getAttribute('var') === 'ocr') { // Captcha
  589. const uri = field.querySelector('uri');
  590. const el = sizzle('data[cid="'+uri.textContent.replace(/^cid:/, '')+'"]', stanza)[0];
  591. return tpl_form_captcha({
  592. 'label': field.getAttribute('label'),
  593. 'name': field.getAttribute('var'),
  594. 'data': _.get(el, 'textContent'),
  595. 'type': uri.getAttribute('type'),
  596. 'required': !!field.querySelector('required')
  597. });
  598. } else {
  599. const name = field.getAttribute('var');
  600. return tpl_form_input({
  601. 'id': u.getUniqueId(),
  602. 'label': field.getAttribute('label') || '',
  603. 'name': name,
  604. 'fixed_username': options.fixed_username,
  605. 'autocomplete': getAutoCompleteProperty(name, options),
  606. 'placeholder': null,
  607. 'required': !!field.querySelector('required'),
  608. 'type': XFORM_TYPE_MAP[field.getAttribute('type')],
  609. 'value': _.get(field.querySelector('value'), 'textContent')
  610. });
  611. }
  612. }
  613. export default u;