core.js 19 KB

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