core.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  1. /**
  2. * @copyright 2020, the Converse.js contributors
  3. * @license Mozilla Public License (MPLv2)
  4. * @description This is the core utilities module.
  5. */
  6. import DOMPurify from 'dompurify';
  7. import _converse from '@converse/headless/shared/_converse.js';
  8. import compact from "lodash-es/compact";
  9. import isElement from "lodash-es/isElement";
  10. import isObject from "lodash-es/isObject";
  11. import last from "lodash-es/last";
  12. import log from '@converse/headless/log.js';
  13. import sizzle from "sizzle";
  14. import { Model } from '@converse/skeletor/src/model.js';
  15. import { Strophe } from 'strophe.js/src/strophe.js';
  16. import { getOpenPromise } from '@converse/openpromise';
  17. /**
  18. * The utils object
  19. * @namespace u
  20. */
  21. const u = {};
  22. u.isTagEqual = function (stanza, name) {
  23. if (stanza.nodeTree) {
  24. return u.isTagEqual(stanza.nodeTree, name);
  25. } else if (!(stanza instanceof Element)) {
  26. throw Error(
  27. "isTagEqual called with value which isn't "+
  28. "an element or Strophe.Builder instance");
  29. } else {
  30. return Strophe.isTagEqual(stanza, name);
  31. }
  32. }
  33. const parser = new DOMParser();
  34. const parserErrorNS = parser.parseFromString('invalid', 'text/xml')
  35. .getElementsByTagName("parsererror")[0].namespaceURI;
  36. u.getJIDFromURI = function (jid) {
  37. return jid.startsWith('xmpp:') && jid.endsWith('?join')
  38. ? jid.replace(/^xmpp:/, '').replace(/\?join$/, '')
  39. : jid;
  40. }
  41. u.toStanza = function (string) {
  42. const node = parser.parseFromString(string, "text/xml");
  43. if (node.getElementsByTagNameNS(parserErrorNS, 'parsererror').length) {
  44. throw new Error(`Parser Error: ${string}`);
  45. }
  46. return node.firstElementChild;
  47. }
  48. u.getLongestSubstring = function (string, candidates) {
  49. function reducer (accumulator, current_value) {
  50. if (string.startsWith(current_value)) {
  51. if (current_value.length > accumulator.length) {
  52. return current_value;
  53. } else {
  54. return accumulator;
  55. }
  56. } else {
  57. return accumulator;
  58. }
  59. }
  60. return candidates.reduce(reducer, '');
  61. }
  62. u.prefixMentions = function (message) {
  63. /* Given a message object, return its text with @ chars
  64. * inserted before the mentioned nicknames.
  65. */
  66. let text = message.get('message');
  67. (message.get('references') || [])
  68. .sort((a, b) => b.begin - a.begin)
  69. .forEach(ref => {
  70. text = `${text.slice(0, ref.begin)}@${text.slice(ref.begin)}`
  71. });
  72. return text;
  73. };
  74. u.isValidJID = function (jid) {
  75. if (typeof jid === 'string') {
  76. return compact(jid.split('@')).length === 2 && !jid.startsWith('@') && !jid.endsWith('@');
  77. }
  78. return false;
  79. };
  80. u.isValidMUCJID = function (jid) {
  81. return !jid.startsWith('@') && !jid.endsWith('@');
  82. };
  83. u.isSameBareJID = function (jid1, jid2) {
  84. if (typeof jid1 !== 'string' || typeof jid2 !== 'string') {
  85. return false;
  86. }
  87. return Strophe.getBareJidFromJid(jid1).toLowerCase() ===
  88. Strophe.getBareJidFromJid(jid2).toLowerCase();
  89. };
  90. u.isSameDomain = function (jid1, jid2) {
  91. if (typeof jid1 !== 'string' || typeof jid2 !== 'string') {
  92. return false;
  93. }
  94. return Strophe.getDomainFromJid(jid1).toLowerCase() ===
  95. Strophe.getDomainFromJid(jid2).toLowerCase();
  96. };
  97. u.isNewMessage = function (message) {
  98. /* Given a stanza, determine whether it's a new
  99. * message, i.e. not a MAM archived one.
  100. */
  101. if (message instanceof Element) {
  102. return !(
  103. sizzle(`result[xmlns="${Strophe.NS.MAM}"]`, message).length &&
  104. sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, message).length
  105. );
  106. } else if (message instanceof Model) {
  107. message = message.attributes;
  108. }
  109. return !(message['is_delayed'] && message['is_archived']);
  110. };
  111. u.shouldCreateMessage = function (attrs) {
  112. return attrs['retracted'] || // Retraction received *before* the message
  113. !u.isEmptyMessage(attrs);
  114. }
  115. u.shouldCreateGroupchatMessage = function (attrs) {
  116. return attrs.nick && (u.shouldCreateMessage(attrs) || attrs.is_tombstone);
  117. }
  118. u.isEmptyMessage = function (attrs) {
  119. if (attrs instanceof Model) {
  120. attrs = attrs.attributes;
  121. }
  122. return !attrs['oob_url'] &&
  123. !attrs['file'] &&
  124. !(attrs['is_encrypted'] && attrs['plaintext']) &&
  125. !attrs['message'];
  126. };
  127. //TODO: Remove
  128. u.isOnlyChatStateNotification = function (attrs) {
  129. return attrs['chat_state'] && u.isEmptyMessage(attrs);
  130. };
  131. u.isOnlyMessageDeliveryReceipt = function (attrs) {
  132. return attrs['received'] && u.isEmptyMessage(attrs);
  133. };
  134. u.isChatRoom = function (model) {
  135. return model && (model.get('type') === 'chatroom');
  136. }
  137. u.isErrorObject = function (o) {
  138. return o instanceof Error;
  139. }
  140. u.isErrorStanza = function (stanza) {
  141. if (!isElement(stanza)) {
  142. return false;
  143. }
  144. return stanza.getAttribute('type') === 'error';
  145. }
  146. u.isForbiddenError = function (stanza) {
  147. if (!isElement(stanza)) {
  148. return false;
  149. }
  150. return sizzle(`error[type="auth"] forbidden[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length > 0;
  151. }
  152. u.isServiceUnavailableError = function (stanza) {
  153. if (!isElement(stanza)) {
  154. return false;
  155. }
  156. return sizzle(`error[type="cancel"] service-unavailable[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length > 0;
  157. }
  158. /**
  159. * Merge the second object into the first one.
  160. * @private
  161. * @method u#merge
  162. * @param { Object } first
  163. * @param { Object } second
  164. */
  165. u.merge = function merge (first, second) {
  166. for (const k in second) {
  167. if (isObject(first[k])) {
  168. merge(first[k], second[k]);
  169. } else {
  170. first[k] = second[k];
  171. }
  172. }
  173. };
  174. u.getOuterWidth = function (el, include_margin=false) {
  175. let width = el.offsetWidth;
  176. if (!include_margin) {
  177. return width;
  178. }
  179. const style = window.getComputedStyle(el);
  180. width += parseInt(style.marginLeft ? style.marginLeft : 0, 10) +
  181. parseInt(style.marginRight ? style.marginRight : 0, 10);
  182. return width;
  183. };
  184. /**
  185. * Converts an HTML string into a DOM element.
  186. * Expects that the HTML string has only one top-level element,
  187. * i.e. not multiple ones.
  188. * @private
  189. * @method u#stringToElement
  190. * @param { String } s - The HTML string
  191. */
  192. u.stringToElement = function (s) {
  193. var div = document.createElement('div');
  194. div.innerHTML = s;
  195. return div.firstElementChild;
  196. };
  197. /**
  198. * Checks whether the DOM element matches the given selector.
  199. * @private
  200. * @method u#matchesSelector
  201. * @param { DOMElement } el - The DOM element
  202. * @param { String } selector - The selector
  203. */
  204. u.matchesSelector = function (el, selector) {
  205. const match = (
  206. el.matches ||
  207. el.matchesSelector ||
  208. el.msMatchesSelector ||
  209. el.mozMatchesSelector ||
  210. el.webkitMatchesSelector ||
  211. el.oMatchesSelector
  212. );
  213. return match ? match.call(el, selector) : false;
  214. };
  215. /**
  216. * Returns a list of children of the DOM element that match the selector.
  217. * @private
  218. * @method u#queryChildren
  219. * @param { DOMElement } el - the DOM element
  220. * @param { String } selector - the selector they should be matched against
  221. */
  222. u.queryChildren = function (el, selector) {
  223. return Array.from(el.childNodes).filter(el => u.matchesSelector(el, selector));
  224. };
  225. u.contains = function (attr, query) {
  226. const checker = (item, key) => item.get(key).toLowerCase().includes(query.toLowerCase());
  227. return function (item) {
  228. if (typeof attr === 'object') {
  229. return Object.keys(attr).reduce((acc, k) => acc || checker(item, k), false);
  230. } else if (typeof attr === 'string') {
  231. return checker(item, attr);
  232. } else {
  233. throw new TypeError('contains: wrong attribute type. Must be string or array.');
  234. }
  235. };
  236. };
  237. u.isOfType = function (type, item) {
  238. return item.get('type') == type;
  239. };
  240. u.isInstance = function (type, item) {
  241. return item instanceof type;
  242. };
  243. u.getAttribute = function (key, item) {
  244. return item.get(key);
  245. };
  246. u.contains.not = function (attr, query) {
  247. return function (item) {
  248. return !(u.contains(attr, query)(item));
  249. };
  250. };
  251. u.rootContains = function (root, el) {
  252. // The document element does not have the contains method in IE.
  253. if (root === document && !root.contains) {
  254. return document.head.contains(el) || document.body.contains(el);
  255. }
  256. return root.contains ? root.contains(el) : window.HTMLElement.prototype.contains.call(root, el);
  257. };
  258. u.createFragmentFromText = function (markup) {
  259. /* Returns a DocumentFragment containing DOM nodes based on the
  260. * passed-in markup text.
  261. */
  262. // http://stackoverflow.com/questions/9334645/create-node-from-markup-string
  263. var frag = document.createDocumentFragment(),
  264. tmp = document.createElement('body'), child;
  265. tmp.innerHTML = markup;
  266. // Append elements in a loop to a DocumentFragment, so that the
  267. // browser does not re-render the document for each node.
  268. while (child = tmp.firstChild) { // eslint-disable-line no-cond-assign
  269. frag.appendChild(child);
  270. }
  271. return frag
  272. };
  273. u.isPersistableModel = function (model) {
  274. return model.collection && model.collection.browserStorage;
  275. };
  276. u.getResolveablePromise = getOpenPromise;
  277. u.getOpenPromise = getOpenPromise;
  278. u.interpolate = function (string, o) {
  279. return string.replace(/{{{([^{}]*)}}}/g,
  280. (a, b) => {
  281. var r = o[b];
  282. return typeof r === 'string' || typeof r === 'number' ? r : a;
  283. });
  284. };
  285. /**
  286. * Call the callback once all the events have been triggered
  287. * @private
  288. * @method u#onMultipleEvents
  289. * @param { Array } events: An array of objects, with keys `object` and
  290. * `event`, representing the event name and the object it's triggered upon.
  291. * @param { Function } callback: The function to call once all events have
  292. * been triggered.
  293. */
  294. u.onMultipleEvents = function (events=[], callback) {
  295. let triggered = [];
  296. function handler (result) {
  297. triggered.push(result)
  298. if (events.length === triggered.length) {
  299. callback(triggered);
  300. triggered = [];
  301. }
  302. }
  303. events.forEach(e => e.object.on(e.event, handler));
  304. };
  305. export function safeSave (model, attributes, options) {
  306. if (u.isPersistableModel(model)) {
  307. model.save(attributes, options);
  308. } else {
  309. model.set(attributes, options);
  310. }
  311. }
  312. u.safeSave = safeSave;
  313. u.siblingIndex = function (el) {
  314. /* eslint-disable no-cond-assign */
  315. for (var i = 0; el = el.previousElementSibling; i++);
  316. return i;
  317. };
  318. /**
  319. * Returns the current word being written in the input element
  320. * @method u#getCurrentWord
  321. * @param {HTMLElement} input - The HTMLElement in which text is being entered
  322. * @param {integer} [index] - An optional rightmost boundary index. If given, the text
  323. * value of the input element will only be considered up until this index.
  324. * @param {string} [delineator] - An optional string delineator to
  325. * differentiate between words.
  326. * @private
  327. */
  328. u.getCurrentWord = function (input, index, delineator) {
  329. if (!index) {
  330. index = input.selectionEnd || undefined;
  331. }
  332. let [word] = input.value.slice(0, index).split(/\s/).slice(-1);
  333. if (delineator) {
  334. [word] = word.split(delineator).slice(-1);
  335. }
  336. return word;
  337. };
  338. u.isMentionBoundary = (s) => s !== '@' && RegExp(`(\\p{Z}|\\p{P})`, 'u').test(s);
  339. u.replaceCurrentWord = function (input, new_value) {
  340. const caret = input.selectionEnd || undefined;
  341. const current_word = last(input.value.slice(0, caret).split(/\s/));
  342. const value = input.value;
  343. const mention_boundary = u.isMentionBoundary(current_word[0]) ? current_word[0] : '';
  344. input.value = value.slice(0, caret - current_word.length) + mention_boundary + `${new_value} ` + value.slice(caret);
  345. const selection_end = caret - current_word.length + new_value.length + 1;
  346. input.selectionEnd = mention_boundary ? selection_end + 1 : selection_end;
  347. };
  348. u.triggerEvent = function (el, name, type="Event", bubbles=true, cancelable=true) {
  349. const evt = document.createEvent(type);
  350. evt.initEvent(name, bubbles, cancelable);
  351. el.dispatchEvent(evt);
  352. };
  353. u.getSelectValues = function (select) {
  354. const result = [];
  355. const options = select && select.options;
  356. for (var i=0, iLen=options.length; i<iLen; i++) {
  357. const opt = options[i];
  358. if (opt.selected) {
  359. result.push(opt.value || opt.text);
  360. }
  361. }
  362. return result;
  363. };
  364. u.formatFingerprint = function (fp) {
  365. fp = fp.replace(/^05/, '');
  366. for (let i=1; i<8; i++) {
  367. const idx = i*8+i-1;
  368. fp = fp.slice(0, idx) + ' ' + fp.slice(idx);
  369. }
  370. return fp;
  371. };
  372. u.getRandomInt = function (max) {
  373. return Math.floor(Math.random() * Math.floor(max));
  374. };
  375. u.placeCaretAtEnd = function (textarea) {
  376. if (textarea !== document.activeElement) {
  377. textarea.focus();
  378. }
  379. // Double the length because Opera is inconsistent about whether a carriage return is one character or two.
  380. const len = textarea.value.length * 2;
  381. // Timeout seems to be required for Blink
  382. setTimeout(() => textarea.setSelectionRange(len, len), 1);
  383. // Scroll to the bottom, in case we're in a tall textarea
  384. // (Necessary for Firefox and Chrome)
  385. this.scrollTop = 999999;
  386. };
  387. u.getUniqueId = function (suffix) {
  388. const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
  389. const r = Math.random() * 16 | 0;
  390. const v = c === 'x' ? r : r & 0x3 | 0x8;
  391. return v.toString(16);
  392. });
  393. if (typeof(suffix) === "string" || typeof(suffix) === "number") {
  394. return uuid + ":" + suffix;
  395. } else {
  396. return uuid;
  397. }
  398. }
  399. u.httpToGeoUri = function(text, _converse) {
  400. const replacement = 'geo:$1,$2';
  401. return text.replace(_converse.api.settings.get("geouri_regex"), replacement);
  402. };
  403. /**
  404. * Clears the specified timeout and interval.
  405. * @method u#clearTimers
  406. * @param {number} timeout - Id if the timeout to clear.
  407. * @param {number} interval - Id of the interval to clear.
  408. * @private
  409. * @copyright Simen Bekkhus 2016
  410. * @license MIT
  411. */
  412. function clearTimers(timeout, interval) {
  413. clearTimeout(timeout);
  414. clearInterval(interval);
  415. }
  416. /**
  417. * Creates a {@link Promise} that resolves if the passed in function returns a truthy value.
  418. * Rejects if it throws or does not return truthy within the given max_wait.
  419. * @method u#waitUntil
  420. * @param {Function} func - The function called every check_delay,
  421. * and the result of which is the resolved value of the promise.
  422. * @param {number} [max_wait=300] - The time to wait before rejecting the promise.
  423. * @param {number} [check_delay=3] - The time to wait before each invocation of {func}.
  424. * @returns {Promise} A promise resolved with the value of func,
  425. * or rejected with the exception thrown by it or it times out.
  426. * @copyright Simen Bekkhus 2016
  427. * @license MIT
  428. */
  429. u.waitUntil = function (func, max_wait=300, check_delay=3) {
  430. // Run the function once without setting up any listeners in case it's already true
  431. try {
  432. const result = func();
  433. if (result) {
  434. return Promise.resolve(result);
  435. }
  436. } catch (e) {
  437. return Promise.reject(e);
  438. }
  439. const promise = getOpenPromise();
  440. const timeout_err = new Error();
  441. function checker () {
  442. try {
  443. const result = func();
  444. if (result) {
  445. clearTimers(max_wait_timeout, interval);
  446. promise.resolve(result);
  447. }
  448. } catch (e) {
  449. clearTimers(max_wait_timeout, interval);
  450. promise.reject(e);
  451. }
  452. }
  453. const interval = setInterval(checker, check_delay);
  454. function handler () {
  455. clearTimers(max_wait_timeout, interval);
  456. const err_msg = `Wait until promise timed out: \n\n${timeout_err.stack}`;
  457. console.trace();
  458. log.error(err_msg);
  459. promise.reject(new Error(err_msg));
  460. }
  461. const max_wait_timeout = setTimeout(handler, max_wait);
  462. return promise;
  463. };
  464. export function setUnloadEvent () {
  465. if ('onpagehide' in window) {
  466. // Pagehide gets thrown in more cases than unload. Specifically it
  467. // gets thrown when the page is cached and not just
  468. // closed/destroyed. It's the only viable event on mobile Safari.
  469. // https://www.webkit.org/blog/516/webkit-page-cache-ii-the-unload-event/
  470. _converse.unloadevent = 'pagehide';
  471. } else if ('onbeforeunload' in window) {
  472. _converse.unloadevent = 'beforeunload';
  473. } else if ('onunload' in window) {
  474. _converse.unloadevent = 'unload';
  475. }
  476. }
  477. export async function getLoginCredentialsFromBrowser () {
  478. try {
  479. const creds = await navigator.credentials.get({'password': true});
  480. if (creds && creds.type == 'password' && u.isValidJID(creds.id)) {
  481. await _converse.setUserJID(creds.id);
  482. return {'jid': creds.id, 'password': creds.password};
  483. }
  484. } catch (e) {
  485. log.error(e);
  486. }
  487. }
  488. export function replacePromise (name) {
  489. const existing_promise = _converse.promises[name];
  490. if (!existing_promise) {
  491. throw new Error(`Tried to replace non-existing promise: ${name}`);
  492. }
  493. if (existing_promise.replace) {
  494. const promise = getOpenPromise();
  495. promise.replace = existing_promise.replace;
  496. _converse.promises[name] = promise;
  497. } else {
  498. log.debug(`Not replacing promise "${name}"`);
  499. }
  500. }
  501. const element = document.createElement('div');
  502. export function decodeHTMLEntities (str) {
  503. if (str && typeof str === 'string') {
  504. element.innerHTML = DOMPurify.sanitize(str);
  505. str = element.textContent;
  506. element.textContent = '';
  507. }
  508. return str;
  509. }
  510. export default u;