core.js 19 KB

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