core.js 17 KB

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