core.js 18 KB

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