Jelajahi Sumber

Refactor utils

Renamed src/headless/utils/core.js -> src/headless/utils/index.js
Move functions that rely on the `_converse` object to src/headles/utils/session.js
Move object-related functions to src/headless/utils/object.js
Remove `contains` and `contains.not` functions, they're not being used.
JC Brand 1 tahun lalu
induk
melakukan
11934b31fc
48 mengubah file dengan 621 tambahan dan 629 penghapusan
  1. 2 2
      dev.html
  2. 2 2
      src/headless/index.js
  3. 4 2
      src/headless/plugins/bosh.js
  4. 2 1
      src/headless/plugins/chat/model.js
  5. 1 1
      src/headless/plugins/chat/parsers.js
  6. 1 1
      src/headless/plugins/chat/utils.js
  7. 5 4
      src/headless/plugins/disco/entity.js
  8. 3 2
      src/headless/plugins/disco/utils.js
  9. 1 1
      src/headless/plugins/mam/placeholder.js
  10. 5 6
      src/headless/plugins/muc/api.js
  11. 6 5
      src/headless/plugins/muc/muc.js
  12. 1 1
      src/headless/plugins/muc/occupants.js
  13. 1 1
      src/headless/plugins/muc/utils.js
  14. 2 1
      src/headless/plugins/ping/utils.js
  15. 1 1
      src/headless/plugins/roster/utils.js
  16. 3 2
      src/headless/plugins/smacks/utils.js
  17. 1 1
      src/headless/plugins/status/index.js
  18. 1 1
      src/headless/plugins/vcard/utils.js
  19. 0 10
      src/headless/shared/_converse.js
  20. 1 1
      src/headless/shared/api/events.js
  21. 2 1
      src/headless/shared/api/promise.js
  22. 3 2
      src/headless/shared/api/public.js
  23. 3 2
      src/headless/shared/api/user.js
  24. 1 1
      src/headless/shared/connection/index.js
  25. 1 1
      src/headless/shared/parsers.js
  26. 3 3
      src/headless/shared/settings/utils.js
  27. 0 545
      src/headless/utils/core.js
  28. 295 0
      src/headless/utils/index.js
  29. 7 6
      src/headless/utils/init.js
  30. 32 0
      src/headless/utils/jid.js
  31. 25 0
      src/headless/utils/object.js
  32. 69 0
      src/headless/utils/promise.js
  33. 113 0
      src/headless/utils/session.js
  34. 2 1
      src/i18n/index.js
  35. 1 1
      src/plugins/chatview/message-form.js
  36. 5 5
      src/plugins/controlbox/tests/login.js
  37. 1 1
      src/plugins/fullscreen/index.js
  38. 2 1
      src/plugins/minimize/utils.js
  39. 1 1
      src/plugins/muc-views/role-form.js
  40. 4 3
      src/plugins/notifications/utils.js
  41. 1 1
      src/plugins/omemo/device.js
  42. 1 1
      src/plugins/omemo/index.js
  43. 1 1
      src/plugins/omemo/utils.js
  44. 1 1
      src/plugins/roomslist/templates/roomslist.js
  45. 1 2
      src/plugins/rosterview/modals/add-contact.js
  46. 1 1
      src/plugins/rosterview/templates/group.js
  47. 1 1
      src/templates/form_textarea.js
  48. 1 1
      src/utils/html.js

+ 2 - 2
dev.html

@@ -40,8 +40,8 @@
         muc_show_logs_before_join: true,
         notify_all_room_messages: ['discuss@conference.conversejs.org'],
         view_mode: 'fullscreen',
-        // websocket_url: 'wss://conversejs.org/xmpp-websocket',
-        websocket_url: 'ws://chat.example.org:5380/xmpp-websocket',
+        websocket_url: 'wss://conversejs.org/xmpp-websocket',
+        // websocket_url: 'ws://chat.example.org:5380/xmpp-websocket',
         whitelisted_plugins: ['converse-debug'],
         // connection_options: { worker: '/dist/shared-connection-worker.js' }
     });

+ 2 - 2
src/headless/index.js

@@ -2,12 +2,12 @@ import './shared/constants.js';
 import advancedFormat from 'dayjs/plugin/advancedFormat';
 import api from './shared/api/index.js';
 import _converse from './shared/_converse';
+_converse.api = api;
+
 import dayjs from 'dayjs';
 import i18n from './shared/i18n';
 import { converse } from './shared/api/public.js';
 
-_converse.api = api;
-
 dayjs.extend(advancedFormat);
 
 /* START: Removable components

+ 4 - 2
src/headless/plugins/bosh.js

@@ -10,6 +10,8 @@ import log from "../log.js";
 import { BOSH_WAIT } from '../shared/constants.js';
 import { Model } from '@converse/skeletor/src/model.js';
 import { setUserJID, } from '../utils/init.js';
+import { isTestEnv } from '../utils/session.js';
+import { createStore } from '../utils/storage.js';
 
 const { Strophe } = converse.env;
 
@@ -33,7 +35,7 @@ converse.plugins.add('converse-bosh', {
             const id = BOSH_SESSION_ID;
             if (!_converse.bosh_session) {
                 _converse.bosh_session = new Model({id});
-                _converse.bosh_session.browserStorage = _converse.createStore(id, "session");
+                _converse.bosh_session.browserStorage = createStore(id, "session");
                 await new Promise(resolve => _converse.bosh_session.fetch({'success': resolve, 'error': resolve}));
             }
             if (_converse.jid) {
@@ -93,7 +95,7 @@ converse.plugins.add('converse-bosh', {
                     _converse.connection.restore(jid, _converse.connection.onConnectStatusChanged);
                     return true;
                 } catch (e) {
-                    !_converse.isTestEnv() && log.warn("Could not restore session for jid: "+jid+" Error message: "+e.message);
+                    !isTestEnv() && log.warn("Could not restore session for jid: "+jid+" Error message: "+e.message);
                     return false;
                 }
             }

+ 2 - 1
src/headless/plugins/chat/model.js

@@ -11,7 +11,8 @@ import { filesize } from "filesize";
 import { getMediaURLsMetadata } from '../../shared/parsers.js';
 import { getOpenPromise } from '@converse/openpromise';
 import { initStorage } from '../../utils/storage.js';
-import { isUniView, isEmptyMessage } from '../../utils/core.js';
+import { isEmptyMessage } from '../../utils/index.js';
+import { isUniView } from '../../utils/session.js';
 import { parseMessage } from './parsers.js';
 import { sendMarker } from '../../shared/actions.js';
 

+ 1 - 1
src/headless/plugins/chat/parsers.js

@@ -2,7 +2,7 @@ import _converse from '../../shared/_converse.js';
 import api, { converse } from '../../shared/api/index.js';
 import dayjs from 'dayjs';
 import log from '../../log.js';
-import u from '../../utils/core';
+import u from '../../utils/index.js';
 import { rejectMessage } from '../../shared/actions';
 
 import {

+ 1 - 1
src/headless/plugins/chat/utils.js

@@ -3,7 +3,7 @@ import api, { converse } from '../../shared/api/index.js';
 import log from '../../log.js';
 import { isArchived, isHeadline, isServerMessage, } from '../../shared/parsers';
 import { parseMessage } from './parsers.js';
-import { shouldClearCache } from '../../utils/core.js';
+import { shouldClearCache } from '../../utils/session.js';
 
 const { Strophe, u } = converse.env;
 

+ 5 - 4
src/headless/plugins/disco/entity.js

@@ -5,6 +5,7 @@ import sizzle from 'sizzle';
 import { Collection } from '@converse/skeletor/src/collection';
 import { Model } from '@converse/skeletor/src/model.js';
 import { getOpenPromise } from '@converse/openpromise';
+import { createStore } from '../../utils/storage.js';
 
 const { Strophe } = converse.env;
 
@@ -28,21 +29,21 @@ class DiscoEntity extends Model {
 
         this.dataforms = new Collection();
         let id = `converse.dataforms-${this.get('jid')}`;
-        this.dataforms.browserStorage = _converse.createStore(id, 'session');
+        this.dataforms.browserStorage = createStore(id, 'session');
 
         this.features = new Collection();
         id = `converse.features-${this.get('jid')}`;
-        this.features.browserStorage = _converse.createStore(id, 'session');
+        this.features.browserStorage = createStore(id, 'session');
         this.listenTo(this.features, 'add', this.onFeatureAdded);
 
         this.fields = new Collection();
         id = `converse.fields-${this.get('jid')}`;
-        this.fields.browserStorage = _converse.createStore(id, 'session');
+        this.fields.browserStorage = createStore(id, 'session');
         this.listenTo(this.fields, 'add', this.onFieldAdded);
 
         this.identities = new Collection();
         id = `converse.identities-${this.get('jid')}`;
-        this.identities.browserStorage = _converse.createStore(id, 'session');
+        this.identities.browserStorage = createStore(id, 'session');
         this.fetchFeatures(options);
     }
 

+ 3 - 2
src/headless/plugins/disco/utils.js

@@ -1,6 +1,7 @@
 import _converse from '../../shared/_converse.js';
 import api, { converse } from '../../shared/api/index.js';
 import { Collection } from "@converse/skeletor/src/collection";
+import { createStore } from '../../utils/storage.js';
 
 const { Strophe, $iq } = converse.env;
 
@@ -65,7 +66,7 @@ export async function initializeDisco () {
 
     _converse.disco_entities = new _converse.DiscoEntities();
     const id = `converse.disco-entities-${_converse.bare_jid}`;
-    _converse.disco_entities.browserStorage = _converse.createStore(id, 'session');
+    _converse.disco_entities.browserStorage = createStore(id, 'session');
 
     const collection = await _converse.disco_entities.fetchEntities();
     if (collection.length === 0 || !collection.get(_converse.domain)) {
@@ -93,7 +94,7 @@ export function initStreamFeatures () {
         const id = `converse.stream-features-${bare_jid}`;
         api.promises.add('streamFeaturesAdded');
         _converse.stream_features = new Collection();
-        _converse.stream_features.browserStorage = _converse.createStore(id, "session");
+        _converse.stream_features.browserStorage = createStore(id, "session");
     }
 }
 

+ 1 - 1
src/headless/plugins/mam/placeholder.js

@@ -1,5 +1,5 @@
 import { Model } from '@converse/skeletor/src/model.js';
-import { getUniqueId } from '../../utils/core.js';
+import { getUniqueId } from '../../utils/index.js';
 
 export default class MAMPlaceholderMessage extends Model {
 

+ 5 - 6
src/headless/plugins/muc/api.js

@@ -1,9 +1,8 @@
 import _converse from '../../shared/_converse.js';
-import api, { converse } from '../../shared/api/index.js';
+import api from '../../shared/api/index.js';
 import log from '../../log';
 import { Strophe } from 'strophe.js';
-
-const { u } = converse.env;
+import { getJIDFromURI } from '../../utils/jid.js';
 
 
 export default {
@@ -35,9 +34,9 @@ export default {
             if (jids === undefined) {
                 throw new TypeError('rooms.create: You need to provide at least one JID');
             } else if (typeof jids === 'string') {
-                return api.rooms.get(u.getJIDFromURI(jids), attrs, true);
+                return api.rooms.get(getJIDFromURI(jids), attrs, true);
             }
-            return jids.map(jid => api.rooms.get(u.getJIDFromURI(jid), attrs, true));
+            return jids.map(jid => api.rooms.get(getJIDFromURI(jid), attrs, true));
         },
 
         /**
@@ -142,7 +141,7 @@ export default {
             await api.waitUntil('chatBoxesFetched');
 
             async function _get (jid) {
-                jid = u.getJIDFromURI(jid);
+                jid = getJIDFromURI(jid);
                 let model = await api.chatboxes.get(jid);
                 if (!model && create) {
                     model = await api.chatboxes.create(jid, attrs, _converse.ChatRoom);

+ 6 - 5
src/headless/plugins/muc/muc.js

@@ -12,9 +12,10 @@ import { TimeoutError } from '../../shared/errors.js';
 import { computeAffiliationsDelta, setAffiliations, getAffiliationList }  from './affiliations/utils.js';
 import { getOpenPromise } from '@converse/openpromise';
 import { handleCorrection } from '../../shared/chat/utils.js';
-import { initStorage } from '../../utils/storage.js';
+import { initStorage, createStore } from '../../utils/storage.js';
 import { isArchived, getMediaURLsMetadata } from '../../shared/parsers.js';
-import { isUniView, getUniqueId, safeSave } from '../../utils/core.js';
+import { getUniqueId, safeSave } from '../../utils/index.js';
+import { isUniView } from '../../utils/session.js';
 import { parseMUCMessage, parseMUCPresence } from './parsers.js';
 import { sendMarker } from '../../shared/actions.js';
 
@@ -399,19 +400,19 @@ const ChatRoomMixin = {
                 }, {})
             )
         );
-        this.features.browserStorage = _converse.createStore(id, 'session');
+        this.features.browserStorage = createStore(id, 'session');
         this.features.listenTo(_converse, 'beforeLogout', () => this.features.browserStorage.flush());
 
         id = `converse.muc-config-${_converse.bare_jid}-${this.get('jid')}`;
         this.config = new Model({ id });
-        this.config.browserStorage = _converse.createStore(id, 'session');
+        this.config.browserStorage = createStore(id, 'session');
         this.config.listenTo(_converse, 'beforeLogout', () => this.config.browserStorage.flush());
     },
 
     initOccupants () {
         this.occupants = new _converse.ChatRoomOccupants();
         const id = `converse.occupants-${_converse.bare_jid}${this.get('jid')}`;
-        this.occupants.browserStorage = _converse.createStore(id, 'session');
+        this.occupants.browserStorage = createStore(id, 'session');
         this.occupants.chatroom = this;
         this.occupants.listenTo(_converse, 'beforeLogout', () => this.occupants.browserStorage.flush());
     },

+ 1 - 1
src/headless/plugins/muc/occupants.js

@@ -7,7 +7,7 @@ import { Model } from '@converse/skeletor/src/model.js';
 import { Strophe } from 'strophe.js';
 import { getAffiliationList } from './affiliations/utils.js';
 import { getAutoFetchedAffiliationLists } from './utils.js';
-import { getUniqueId } from '../../utils/core.js';
+import { getUniqueId } from '../../utils/index.js';
 
 const { u } = converse.env;
 

+ 1 - 1
src/headless/plugins/muc/utils.js

@@ -2,7 +2,7 @@ import _converse from '../../shared/_converse.js';
 import api, { converse } from '../../shared/api/index.js';
 import log from '../../log.js';
 import { ROLES } from './constants.js';
-import { safeSave } from '../../utils/core.js';
+import { safeSave } from '../../utils/index.js';
 
 const { Strophe, sizzle, u } = converse.env;
 

+ 2 - 1
src/headless/plugins/ping/utils.js

@@ -1,5 +1,6 @@
 import _converse from '../../shared/_converse.js';
 import api, { converse } from '../../shared/api/index.js';
+import { isTestEnv } from '../../utils/session.js';
 
 const { Strophe, $iq } = converse.env;
 
@@ -56,7 +57,7 @@ export function unregisterIntervalHandler () {
 }
 
 export function onEverySecond () {
-    if (_converse.isTestEnv() || !api.connection.authenticated()) {
+    if (isTestEnv() || !api.connection.authenticated()) {
         return;
     }
     const ping_interval = api.settings.get('ping_interval');

+ 1 - 1
src/headless/plugins/roster/utils.js

@@ -5,7 +5,7 @@ import { Model } from '@converse/skeletor/src/model.js';
 import { RosterFilter } from '../../plugins/roster/filter.js';
 import { STATUS_WEIGHTS } from "../../shared/constants";
 import { initStorage } from '../../utils/storage.js';
-import { shouldClearCache } from '../../utils/core.js';
+import { shouldClearCache } from '../../utils/session.js';
 
 const { $pres } = converse.env;
 

+ 3 - 2
src/headless/plugins/smacks/utils.js

@@ -2,12 +2,13 @@ import _converse from '../../shared/_converse.js';
 import api, { converse } from '../../shared/api/index.js';
 import log from '../../log.js';
 import { getOpenPromise } from '@converse/openpromise';
+import { isTestEnv } from '../../utils/session.js';
 
 const { Strophe } = converse.env;
 const u = converse.env.utils;
 
 function isStreamManagementSupported () {
-    if (api.connection.isType('bosh') && !_converse.isTestEnv()) {
+    if (api.connection.isType('bosh') && !isTestEnv()) {
         return false;
     }
     return api.disco.stream.getFeature('sm', Strophe.NS.SM);
@@ -172,7 +173,7 @@ export async function sendEnableStanza () {
         _converse.connection._addSysHandler(el => promise.resolve(saveSessionData(el)), Strophe.NS.SM, 'enabled');
         _converse.connection._addSysHandler(el => promise.resolve(onFailedStanza(el)), Strophe.NS.SM, 'failed');
 
-        const resume = api.connection.isType('websocket') || _converse.isTestEnv();
+        const resume = api.connection.isType('websocket') || isTestEnv();
         const stanza = u.toStanza(`<enable xmlns="${Strophe.NS.SM}" resume="${resume}"/>`);
         api.send(stanza);
         _converse.connection.flush();

+ 1 - 1
src/headless/plugins/status/index.js

@@ -6,7 +6,7 @@ import XMPPStatus from './status.js';
 import _converse from '../../shared/_converse.js';
 import api, { converse } from '../../shared/api/index.js';
 import status_api from './api.js';
-import { shouldClearCache } from '../../utils/core.js';
+import { shouldClearCache } from '../../utils/session.js';
 import {
     addStatusToMUCJoinPresence,
     initStatus,

+ 1 - 1
src/headless/plugins/vcard/utils.js

@@ -2,7 +2,7 @@ import _converse from '../../shared/_converse.js';
 import api, { converse } from '../../shared/api/index.js';
 import log from "../../log.js";
 import { initStorage } from '../../utils/storage.js';
-import { shouldClearCache } from '../../utils/core.js';
+import { shouldClearCache } from '../../utils/session.js';
 
 const { Strophe, $iq, u } = converse.env;
 

+ 0 - 10
src/headless/shared/_converse.js

@@ -3,10 +3,7 @@ import log from '../log.js';
 import pluggable from 'pluggable.js/src/pluggable.js';
 import { Events } from '@converse/skeletor/src/events.js';
 import { Router } from '@converse/skeletor/src/router.js';
-import { createStore, getDefaultStore } from '../utils/storage.js';
-import { getInitSettings } from './settings/utils.js';
 import { getOpenPromise } from '@converse/openpromise';
-import { shouldClearCache } from '../utils/core.js';
 
 import {
     ACTIVE,
@@ -42,7 +39,6 @@ import {
 const _converse = {
     log,
 
-    shouldClearCache, // TODO: Should be moved to utils with next major release
     VERSION_NAME,
 
     templates: {},
@@ -86,12 +82,6 @@ const _converse = {
     default_connection_options: {'explicitResourceBinding': true},
     router: new Router(),
 
-    isTestEnv: () => {
-        return getInitSettings()['bosh_service_url'] === 'montague.lit/http-bind';
-    },
-
-    getDefaultStore,
-    createStore,
 
     /**
      * Translate the given string based on the current locale.

+ 1 - 1
src/headless/shared/api/events.js

@@ -1,5 +1,5 @@
 import _converse from '../_converse.js';
-import isFunction from '../../utils/core.js';
+import { isFunction } from '../../utils/object.js';
 
 
 export default {

+ 2 - 1
src/headless/shared/api/promise.js

@@ -1,6 +1,7 @@
 import _converse from '../_converse.js';
 import { getOpenPromise } from '@converse/openpromise';
-import { waitUntil, isFunction } from '../../utils/core.js';
+import { waitUntil } from '../../utils/promise.js';
+import { isFunction } from '../../utils/object.js';
 
 export default {
     /**

+ 3 - 2
src/headless/shared/api/public.js

@@ -5,8 +5,9 @@ import dayjs from 'dayjs';
 import i18n from '../i18n';
 import log from '../../log.js';
 import sizzle from 'sizzle';
-import u, { setUnloadEvent } from '../../utils/core.js';
+import u from '../../utils/index.js';
 import { ANONYMOUS, CHAT_STATES, KEYCODES, VERSION_NAME } from '../constants.js';
+import { setUnloadEvent, isTestEnv } from '../../utils/session.js';
 import { Collection } from "@converse/skeletor/src/collection";
 import { Model } from '@converse/skeletor/src/model.js';
 import { Strophe, $build, $iq, $msg, $pres } from 'strophe.js';
@@ -124,7 +125,7 @@ export const converse = Object.assign(window.converse || {}, {
          */
         api.trigger('initialized');
 
-        if (_converse.isTestEnv()) {
+        if (isTestEnv()) {
             return _converse;
         }
     },

+ 3 - 2
src/headless/shared/api/user.js

@@ -1,6 +1,7 @@
 import _converse from '../_converse.js';
 import presence_api from './presence.js';
-import u, { replacePromise } from '../../utils/core.js';
+import { isSameDomain } from '../../utils/jid.js';
+import { replacePromise } from '../../utils/session.js';
 import { attemptNonPreboundSession, initConnection, setUserJID } from '../../utils/init.js';
 import { getOpenPromise } from '@converse/openpromise';
 import { user_settings_api } from '../settings/api.js';
@@ -46,7 +47,7 @@ export default {
         async login (jid, password, automatic=false) {
             const { api } = _converse;
             jid = jid || api.settings.get('jid');
-            if (!_converse.connection?.jid || (jid && !u.isSameDomain(_converse.connection.jid, jid))) {
+            if (!_converse.connection?.jid || (jid && !isSameDomain(_converse.connection.jid, jid))) {
                 initConnection();
             }
             if (api.settings.get("connection_options")?.worker && (await _converse.connection.restoreWorkerSession())) {

+ 1 - 1
src/headless/shared/connection/index.js

@@ -6,7 +6,7 @@ import api from '../api/index.js';
 import { ANONYMOUS, BOSH_WAIT, LOGOUT } from '../../shared/constants.js';
 import { CONNECTION_STATUS } from '../constants';
 import { Strophe } from 'strophe.js';
-import { clearSession, tearDown } from "../../utils/core.js";
+import { clearSession, tearDown } from "../../utils/session.js";
 import { getOpenPromise } from '@converse/openpromise';
 import { setUserJID, } from '../../utils/init.js';
 

+ 1 - 1
src/headless/shared/parsers.js

@@ -6,7 +6,7 @@ import log from '../log.js';
 import sizzle from 'sizzle';
 import { Strophe } from 'strophe.js';
 import { URL_PARSE_OPTIONS } from './constants.js';
-import { decodeHTMLEntities } from '..//utils/core.js';
+import { decodeHTMLEntities } from '../utils/index.js';
 import { rejectMessage } from './actions';
 import {
     isAudioURL,

+ 3 - 3
src/headless/shared/settings/utils.js

@@ -2,7 +2,7 @@ import _converse from '../_converse.js';
 import isEqual from "lodash-es/isEqual.js";
 import log from '../../log.js';
 import pick from 'lodash-es/pick';
-import u from '../../utils/core';
+import { merge } from '../../utils/object.js';
 import { DEFAULT_SETTINGS } from './constants.js';
 import { Events } from '@converse/skeletor/src/events.js';
 import { Model } from '@converse/skeletor/src/model.js';
@@ -38,13 +38,13 @@ export function getAppSetting (key) {
 }
 
 export function extendAppSettings (settings) {
-    u.merge(DEFAULT_SETTINGS, settings);
+    merge(DEFAULT_SETTINGS, settings);
     // When updating the settings, we need to avoid overwriting the
     // initialization_settings (i.e. the settings passed in via converse.initialize).
     const allowed_keys = Object.keys(settings).filter(k => k in DEFAULT_SETTINGS);
     const allowed_site_settings = pick(init_settings, allowed_keys);
     const updated_settings = Object.assign(pick(settings, allowed_keys), allowed_site_settings);
-    u.merge(app_settings, updated_settings);
+    merge(app_settings, updated_settings);
 }
 
 /**

+ 0 - 545
src/headless/utils/core.js

@@ -1,545 +0,0 @@
-/**
- * @copyright The Converse.js contributors
- * @license Mozilla Public License (MPLv2)
- * @description This is the core utilities module.
- */
-import DOMPurify from 'dompurify';
-import _converse from '../shared/_converse.js';
-import log from '../log.js';
-import sizzle from "sizzle";
-import { Model } from '@converse/skeletor/src/model.js';
-import { Strophe } from 'strophe.js';
-import { getOpenPromise } from '@converse/openpromise';
-import { settings_api } from '../shared/settings/api.js';
-import { stx , toStanza } from './stanza.js';
-import {
-    getCurrentWord,
-    getSelectValues,
-    isMentionBoundary,
-    placeCaretAtEnd,
-    replaceCurrentWord,
-    webForm2xForm
-} from './form.js';
-import {
-    getOuterWidth,
-    isElement,
-    isTagEqual,
-    queryChildren,
-    stringToElement,
-} from './html.js';
-import {
-    arrayBufferToHex,
-    arrayBufferToString,
-    stringToArrayBuffer,
-    arrayBufferToBase64,
-    base64ToArrayBuffer,
-} from './arraybuffer.js';
-import {
-    isAudioURL,
-    isGIFURL,
-    isVideoURL,
-    isImageURL,
-    isURLWithImageExtension,
-    checkFileTypes,
-    getURI,
-    shouldRenderMediaFromURL,
-    isAllowedProtocolForMedia,
-} from './url.js';
-
-/**
- * The utils object
- * @namespace u
- */
-const u = {
-    arrayBufferToBase64,
-    arrayBufferToHex,
-    arrayBufferToString,
-    base64ToArrayBuffer,
-    checkFileTypes,
-    getSelectValues,
-    getURI,
-    isAllowedProtocolForMedia,
-    isAudioURL,
-    isGIFURL,
-    isImageURL,
-    isURLWithImageExtension,
-    isVideoURL,
-    shouldRenderMediaFromURL,
-    stringToArrayBuffer,
-    webForm2xForm,
-};
-
-export function isError (obj) {
-    return Object.prototype.toString.call(obj) === "[object Error]";
-}
-
-export function isFunction (val) {
-    return typeof val === 'function';
-}
-
-export function isEmptyMessage (attrs) {
-    if (attrs instanceof Model) {
-        attrs = attrs.attributes;
-    }
-    return !attrs['oob_url'] &&
-        !attrs['file'] &&
-        !(attrs['is_encrypted'] && attrs['plaintext']) &&
-        !attrs['message'] &&
-        !attrs['body'];
-}
-
-/**
- * We distinguish between UniView and MultiView instances.
- *
- * UniView means that only one chat is visible, even though there might be multiple ongoing chats.
- * MultiView means that multiple chats may be visible simultaneously.
- */
-export function isUniView () {
-    return ['mobile', 'fullscreen', 'embedded'].includes(settings_api.get("view_mode"));
-}
-
-export function shouldClearCache () {
-    const { api } = _converse;
-    return !_converse.config.get('trusted') ||
-        api.settings.get('clear_cache_on_logout') ||
-        _converse.isTestEnv();
-}
-
-
-export async function tearDown () {
-    const { api } = _converse;
-    await api.trigger('beforeTearDown', {'synchronous': true});
-    window.removeEventListener('click', _converse.onUserActivity);
-    window.removeEventListener('focus', _converse.onUserActivity);
-    window.removeEventListener('keypress', _converse.onUserActivity);
-    window.removeEventListener('mousemove', _converse.onUserActivity);
-    window.removeEventListener(_converse.unloadevent, _converse.onUserActivity);
-    window.clearInterval(_converse.everySecondTrigger);
-    api.trigger('afterTearDown');
-    return _converse;
-}
-
-
-export function clearSession () {
-    _converse.session?.destroy();
-    delete _converse.session;
-    shouldClearCache() && _converse.api.user.settings.clear();
-    /**
-     * Synchronouse event triggered once the user session has been cleared,
-     * for example when the user has logged out or when Converse has
-     * disconnected for some other reason.
-     * @event _converse#clearSession
-     */
-    return _converse.api.trigger('clearSession', {'synchronous': true});
-}
-
-
-/**
- * Given a message object, return its text with @ chars
- * inserted before the mentioned nicknames.
- */
-export function prefixMentions (message) {
-    let text = message.getMessageText();
-    (message.get('references') || [])
-        .sort((a, b) => b.begin - a.begin)
-        .forEach(ref => {
-            text = `${text.slice(0, ref.begin)}@${text.slice(ref.begin)}`
-        });
-    return text;
-}
-
-u.getJIDFromURI = function (jid) {
-    return jid.startsWith('xmpp:') && jid.endsWith('?join')
-        ? jid.replace(/^xmpp:/, '').replace(/\?join$/, '')
-        : jid;
-}
-
-u.getLongestSubstring = function (string, candidates) {
-    function reducer (accumulator, current_value) {
-        if (string.startsWith(current_value)) {
-            if (current_value.length > accumulator.length) {
-                return current_value;
-            } else {
-                return accumulator;
-            }
-        } else {
-            return accumulator;
-        }
-    }
-    return candidates.reduce(reducer, '');
-}
-
-export function isValidJID (jid) {
-    if (typeof jid === 'string') {
-        return jid.split('@').filter(s => !!s).length === 2 && !jid.startsWith('@') && !jid.endsWith('@');
-    }
-    return false;
-}
-
-u.isValidMUCJID = function (jid) {
-    return !jid.startsWith('@') && !jid.endsWith('@');
-};
-
-u.isSameBareJID = function (jid1, jid2) {
-    if (typeof jid1 !== 'string' || typeof jid2 !== 'string') {
-        return false;
-    }
-    return Strophe.getBareJidFromJid(jid1).toLowerCase() ===
-            Strophe.getBareJidFromJid(jid2).toLowerCase();
-};
-
-
-u.isSameDomain = function (jid1, jid2) {
-    if (typeof jid1 !== 'string' || typeof jid2 !== 'string') {
-        return false;
-    }
-    return Strophe.getDomainFromJid(jid1).toLowerCase() ===
-            Strophe.getDomainFromJid(jid2).toLowerCase();
-};
-
-u.isNewMessage = function (message) {
-    /* Given a stanza, determine whether it's a new
-     * message, i.e. not a MAM archived one.
-     */
-    if (message instanceof Element) {
-        return !(
-            sizzle(`result[xmlns="${Strophe.NS.MAM}"]`, message).length &&
-            sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, message).length
-        );
-    } else if (message instanceof Model) {
-        message = message.attributes;
-    }
-    return !(message['is_delayed'] && message['is_archived']);
-};
-
-u.shouldCreateMessage = function (attrs) {
-    return attrs['retracted'] || // Retraction received *before* the message
-        !isEmptyMessage(attrs);
-}
-
-u.shouldCreateGroupchatMessage = function (attrs) {
-    return attrs.nick && (u.shouldCreateMessage(attrs) || attrs.is_tombstone);
-}
-
-u.isChatRoom = function (model) {
-    return model && (model.get('type') === 'chatroom');
-}
-
-export function isErrorObject (o) {
-    return o instanceof Error;
-}
-
-u.isErrorStanza = function (stanza) {
-    if (!isElement(stanza)) {
-        return false;
-    }
-    return stanza.getAttribute('type') === 'error';
-}
-
-u.isForbiddenError = function (stanza) {
-    if (!isElement(stanza)) {
-        return false;
-    }
-    return sizzle(`error[type="auth"] forbidden[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length > 0;
-}
-
-u.isServiceUnavailableError = function (stanza) {
-    if (!isElement(stanza)) {
-        return false;
-    }
-    return sizzle(`error[type="cancel"] service-unavailable[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length > 0;
-}
-
-/**
- * Merge the second object into the first one.
- * @method u#merge
- * @param {Object} dst
- * @param {Object} src
- */
-export function merge (dst, src) {
-    for (const k in src) {
-        if (!Object.prototype.hasOwnProperty.call(src, k)) continue;
-        if (k === "__proto__" || k === "constructor") continue;
-
-        if (dst[k] instanceof Object) {
-            merge(dst[k], src[k]);
-        } else {
-            dst[k] = src[k];
-        }
-    }
-}
-
-
-u.contains = function (attr, query) {
-    const checker = (item, key) => item.get(key).toLowerCase().includes(query.toLowerCase());
-    return function (item) {
-        if (typeof attr === 'object') {
-            return Object.keys(attr).reduce((acc, k) => acc || checker(item, k), false);
-        } else if (typeof attr === 'string') {
-            return checker(item, attr);
-        } else {
-            throw new TypeError('contains: wrong attribute type. Must be string or array.');
-        }
-    };
-};
-
-u.getAttribute = function (key, item) {
-    return item.get(key);
-};
-
-u.contains.not = function (attr, query) {
-    return function (item) {
-        return !(u.contains(attr, query)(item));
-    };
-};
-
-u.isPersistableModel = function (model) {
-    return model.collection && model.collection.browserStorage;
-};
-
-u.getResolveablePromise = getOpenPromise;
-u.getOpenPromise = getOpenPromise;
-
-/**
- * Call the callback once all the events have been triggered
- * @private
- * @method u#onMultipleEvents
- * @param { Array } events: An array of objects, with keys `object` and
- *   `event`, representing the event name and the object it's triggered upon.
- * @param { Function } callback: The function to call once all events have
- *    been triggered.
- */
-u.onMultipleEvents = function (events=[], callback) {
-    let triggered = [];
-
-    function handler (result) {
-        triggered.push(result)
-        if (events.length === triggered.length) {
-            callback(triggered);
-            triggered = [];
-        }
-    }
-    events.forEach(e => e.object.on(e.event, handler));
-};
-
-
-export function safeSave (model, attributes, options) {
-    if (u.isPersistableModel(model)) {
-        model.save(attributes, options);
-    } else {
-        model.set(attributes, options);
-    }
-}
-
-
-u.siblingIndex = function (el) {
-    /* eslint-disable no-cond-assign */
-    for (var i = 0; el = el.previousElementSibling; i++);
-    return i;
-};
-
-/**
- * @param {Element} el
- * @param {string} name
- * @param {string} [type]
- * @param {boolean} [bubbles]
- * @param {boolean} [cancelable]
- */
-function triggerEvent (el, name, type="Event", bubbles=true, cancelable=true) {
-    const evt = document.createEvent(type);
-    evt.initEvent(name, bubbles, cancelable);
-    el.dispatchEvent(evt);
-}
-
-export function getRandomInt (max) {
-    return (Math.random() * max) | 0;
-}
-
-/**
- * @param {string} [suffix]
- * @return {string}
- */
-export function getUniqueId (suffix) {
-    const uuid = crypto.randomUUID?.() ??
-        'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
-            const r = getRandomInt(16);
-            const v = c === 'x' ? r : r & 0x3 | 0x8;
-            return v.toString(16);
-        });
-    if (typeof(suffix) === "string" || typeof(suffix) === "number") {
-        return uuid + ":" + suffix;
-    } else {
-        return uuid;
-    }
-}
-
-
-/**
- * Clears the specified timeout and interval.
- * @method u#clearTimers
- * @param {ReturnType<typeof setTimeout>} timeout - Id if the timeout to clear.
- * @param {ReturnType<typeof setInterval>} interval - Id of the interval to clear.
- * @copyright Simen Bekkhus 2016
- * @license MIT
- */
-function clearTimers(timeout, interval) {
-    clearTimeout(timeout);
-    clearInterval(interval);
-}
-
-
-/**
- * Creates a {@link Promise} that resolves if the passed in function returns a truthy value.
- * Rejects if it throws or does not return truthy within the given max_wait.
- * @method u#waitUntil
- * @param { Function } func - The function called every check_delay,
- *  and the result of which is the resolved value of the promise.
- * @param { number } [max_wait=300] - The time to wait before rejecting the promise.
- * @param { number } [check_delay=3] - The time to wait before each invocation of {func}.
- * @returns {Promise} A promise resolved with the value of func,
- *  or rejected with the exception thrown by it or it times out.
- * @copyright Simen Bekkhus 2016
- * @license MIT
- */
-export function waitUntil (func, max_wait=300, check_delay=3) {
-    // Run the function once without setting up any listeners in case it's already true
-    try {
-        const result = func();
-        if (result) {
-            return Promise.resolve(result);
-        }
-    } catch (e) {
-        return Promise.reject(e);
-    }
-
-    const promise = getOpenPromise();
-    const timeout_err = new Error();
-
-    function checker () {
-        try {
-            const result = func();
-            if (result) {
-                clearTimers(max_wait_timeout, interval);
-                promise.resolve(result);
-            }
-        } catch (e) {
-            clearTimers(max_wait_timeout, interval);
-            promise.reject(e);
-        }
-    }
-
-    const interval = setInterval(checker, check_delay);
-
-    function handler () {
-        clearTimers(max_wait_timeout, interval);
-        const err_msg = `Wait until promise timed out: \n\n${timeout_err.stack}`;
-        console.trace();
-        log.error(err_msg);
-        promise.reject(new Error(err_msg));
-    }
-
-    const max_wait_timeout = setTimeout(handler, max_wait);
-
-    return promise;
-}
-
-
-export function setUnloadEvent () {
-    if ('onpagehide' in window) {
-        // Pagehide gets thrown in more cases than unload. Specifically it
-        // gets thrown when the page is cached and not just
-        // closed/destroyed. It's the only viable event on mobile Safari.
-        // https://www.webkit.org/blog/516/webkit-page-cache-ii-the-unload-event/
-        _converse.unloadevent = 'pagehide';
-    } else if ('onbeforeunload' in window) {
-        _converse.unloadevent = 'beforeunload';
-    } else if ('onunload' in window) {
-        _converse.unloadevent = 'unload';
-    }
-}
-
-
-export function replacePromise (name) {
-    const existing_promise = _converse.promises[name];
-    if (!existing_promise) {
-        throw new Error(`Tried to replace non-existing promise: ${name}`);
-    }
-    if (existing_promise.replace) {
-        const promise = getOpenPromise();
-        promise.replace = existing_promise.replace;
-        _converse.promises[name] = promise;
-    } else {
-        log.debug(`Not replacing promise "${name}"`);
-    }
-}
-
-
-const element = document.createElement('div');
-
-export function decodeHTMLEntities (str) {
-    if (str && typeof str === 'string') {
-        element.innerHTML = DOMPurify.sanitize(str);
-        str = element.textContent;
-        element.textContent = '';
-    }
-    return str;
-}
-
-
-export function saveWindowState (ev) {
-    // XXX: eventually we should be able to just use
-    // document.visibilityState (when we drop support for older
-    // browsers).
-    let state;
-    const event_map = {
-        'focus': "visible",
-        'focusin': "visible",
-        'pageshow': "visible",
-        'blur': "hidden",
-        'focusout': "hidden",
-        'pagehide': "hidden"
-    };
-    ev = ev || document.createEvent('Events');
-    if (ev.type in event_map) {
-        state = event_map[ev.type];
-    } else {
-        state = document.hidden ? "hidden" : "visible";
-    }
-    _converse.windowState = state;
-    /**
-     * Triggered when window state has changed.
-     * Used to determine when a user left the page and when came back.
-     * @event _converse#windowStateChanged
-     * @type { object }
-     * @property{ string } state - Either "hidden" or "visible"
-     * @example _converse.api.listen.on('windowStateChanged', obj => { ... });
-     */
-    _converse.api.trigger('windowStateChanged', {state});
-}
-
-
-export default Object.assign({
-    getCurrentWord,
-    getOuterWidth,
-    getRandomInt,
-    isMentionBoundary,
-    getUniqueId,
-    isElement,
-    isEmptyMessage,
-    isErrorObject,
-    isTagEqual,
-    isValidJID,
-    merge,
-    placeCaretAtEnd,
-    prefixMentions,
-    queryChildren,
-    replaceCurrentWord,
-    safeSave,
-    saveWindowState,
-    shouldClearCache,
-    stringToElement,
-    stx,
-    toStanza,
-    triggerEvent,
-    waitUntil, // TODO: remove. Only the API should be used
-}, u);

+ 295 - 0
src/headless/utils/index.js

@@ -0,0 +1,295 @@
+/**
+ * @copyright The Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ * @description This is the core utilities module.
+ */
+import DOMPurify from 'dompurify';
+import sizzle from "sizzle";
+import { Model } from '@converse/skeletor/src/model.js';
+import { Strophe } from 'strophe.js';
+import { getOpenPromise } from '@converse/openpromise';
+import { stx , toStanza } from './stanza.js';
+import { saveWindowState, shouldClearCache } from './session.js';
+import { merge, isError, isFunction } from './object.js';
+import { createStore, getDefaultStore } from './storage.js';
+import { waitUntil } from './promise.js';
+import { isValidJID, isValidMUCJID, isSameBareJID } from './jid.js';
+import {
+    getCurrentWord,
+    getSelectValues,
+    isMentionBoundary,
+    placeCaretAtEnd,
+    replaceCurrentWord,
+    webForm2xForm
+} from './form.js';
+import {
+    getOuterWidth,
+    isElement,
+    isTagEqual,
+    queryChildren,
+    stringToElement,
+} from './html.js';
+import {
+    arrayBufferToHex,
+    arrayBufferToString,
+    stringToArrayBuffer,
+    arrayBufferToBase64,
+    base64ToArrayBuffer,
+} from './arraybuffer.js';
+import {
+    isAudioURL,
+    isGIFURL,
+    isVideoURL,
+    isImageURL,
+    isURLWithImageExtension,
+    checkFileTypes,
+    getURI,
+    shouldRenderMediaFromURL,
+    isAllowedProtocolForMedia,
+} from './url.js';
+
+
+/**
+ * The utils object
+ * @namespace u
+ */
+const u = {
+    arrayBufferToBase64,
+    arrayBufferToHex,
+    arrayBufferToString,
+    base64ToArrayBuffer,
+    checkFileTypes,
+    getSelectValues,
+    getURI,
+    isAllowedProtocolForMedia,
+    isAudioURL,
+    isError,
+    isFunction,
+    isGIFURL,
+    isImageURL,
+    isURLWithImageExtension,
+    isVideoURL,
+    shouldRenderMediaFromURL,
+    stringToArrayBuffer,
+    webForm2xForm,
+};
+
+
+export function isEmptyMessage (attrs) {
+    if (attrs instanceof Model) {
+        attrs = attrs.attributes;
+    }
+    return !attrs['oob_url'] &&
+        !attrs['file'] &&
+        !(attrs['is_encrypted'] && attrs['plaintext']) &&
+        !attrs['message'] &&
+        !attrs['body'];
+}
+
+/**
+ * Given a message object, return its text with @ chars
+ * inserted before the mentioned nicknames.
+ */
+export function prefixMentions (message) {
+    let text = message.getMessageText();
+    (message.get('references') || [])
+        .sort((a, b) => b.begin - a.begin)
+        .forEach(ref => {
+            text = `${text.slice(0, ref.begin)}@${text.slice(ref.begin)}`
+        });
+    return text;
+}
+
+u.getLongestSubstring = function (string, candidates) {
+    function reducer (accumulator, current_value) {
+        if (string.startsWith(current_value)) {
+            if (current_value.length > accumulator.length) {
+                return current_value;
+            } else {
+                return accumulator;
+            }
+        } else {
+            return accumulator;
+        }
+    }
+    return candidates.reduce(reducer, '');
+}
+
+u.isNewMessage = function (message) {
+    /* Given a stanza, determine whether it's a new
+     * message, i.e. not a MAM archived one.
+     */
+    if (message instanceof Element) {
+        return !(
+            sizzle(`result[xmlns="${Strophe.NS.MAM}"]`, message).length &&
+            sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, message).length
+        );
+    } else if (message instanceof Model) {
+        message = message.attributes;
+    }
+    return !(message['is_delayed'] && message['is_archived']);
+};
+
+u.shouldCreateMessage = function (attrs) {
+    return attrs['retracted'] || // Retraction received *before* the message
+        !isEmptyMessage(attrs);
+}
+
+u.shouldCreateGroupchatMessage = function (attrs) {
+    return attrs.nick && (u.shouldCreateMessage(attrs) || attrs.is_tombstone);
+}
+
+u.isChatRoom = function (model) {
+    return model && (model.get('type') === 'chatroom');
+}
+
+export function isErrorObject (o) {
+    return o instanceof Error;
+}
+
+u.isErrorStanza = function (stanza) {
+    if (!isElement(stanza)) {
+        return false;
+    }
+    return stanza.getAttribute('type') === 'error';
+}
+
+u.isForbiddenError = function (stanza) {
+    if (!isElement(stanza)) {
+        return false;
+    }
+    return sizzle(`error[type="auth"] forbidden[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length > 0;
+}
+
+u.isServiceUnavailableError = function (stanza) {
+    if (!isElement(stanza)) {
+        return false;
+    }
+    return sizzle(`error[type="cancel"] service-unavailable[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length > 0;
+}
+
+u.getAttribute = function (key, item) {
+    return item.get(key);
+};
+
+u.isPersistableModel = function (model) {
+    return model.collection && model.collection.browserStorage;
+};
+
+u.getResolveablePromise = getOpenPromise;
+u.getOpenPromise = getOpenPromise;
+
+/**
+ * Call the callback once all the events have been triggered
+ * @private
+ * @method u#onMultipleEvents
+ * @param { Array } events: An array of objects, with keys `object` and
+ *   `event`, representing the event name and the object it's triggered upon.
+ * @param { Function } callback: The function to call once all events have
+ *    been triggered.
+ */
+u.onMultipleEvents = function (events=[], callback) {
+    let triggered = [];
+
+    function handler (result) {
+        triggered.push(result)
+        if (events.length === triggered.length) {
+            callback(triggered);
+            triggered = [];
+        }
+    }
+    events.forEach(e => e.object.on(e.event, handler));
+};
+
+
+export function safeSave (model, attributes, options) {
+    if (u.isPersistableModel(model)) {
+        model.save(attributes, options);
+    } else {
+        model.set(attributes, options);
+    }
+}
+
+
+u.siblingIndex = function (el) {
+    /* eslint-disable no-cond-assign */
+    for (var i = 0; el = el.previousElementSibling; i++);
+    return i;
+};
+
+/**
+ * @param {Element} el
+ * @param {string} name
+ * @param {string} [type]
+ * @param {boolean} [bubbles]
+ * @param {boolean} [cancelable]
+ */
+function triggerEvent (el, name, type="Event", bubbles=true, cancelable=true) {
+    const evt = document.createEvent(type);
+    evt.initEvent(name, bubbles, cancelable);
+    el.dispatchEvent(evt);
+}
+
+export function getRandomInt (max) {
+    return (Math.random() * max) | 0;
+}
+
+/**
+ * @param {string} [suffix]
+ * @return {string}
+ */
+export function getUniqueId (suffix) {
+    const uuid = crypto.randomUUID?.() ??
+        'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+            const r = getRandomInt(16);
+            const v = c === 'x' ? r : r & 0x3 | 0x8;
+            return v.toString(16);
+        });
+    if (typeof(suffix) === "string" || typeof(suffix) === "number") {
+        return uuid + ":" + suffix;
+    } else {
+        return uuid;
+    }
+}
+
+
+const element = document.createElement('div');
+
+export function decodeHTMLEntities (str) {
+    if (str && typeof str === 'string') {
+        element.innerHTML = DOMPurify.sanitize(str);
+        str = element.textContent;
+        element.textContent = '';
+    }
+    return str;
+}
+
+export default Object.assign({
+    createStore,
+    getCurrentWord,
+    getDefaultStore,
+    getOuterWidth,
+    getRandomInt,
+    getUniqueId,
+    isElement,
+    isEmptyMessage,
+    isErrorObject,
+    isMentionBoundary,
+    isSameBareJID,
+    isTagEqual,
+    isValidJID,
+    isValidMUCJID,
+    merge,
+    placeCaretAtEnd,
+    prefixMentions,
+    queryChildren,
+    replaceCurrentWord,
+    safeSave,
+    saveWindowState,
+    shouldClearCache,
+    stringToElement,
+    stx,
+    toStanza,
+    triggerEvent,
+    waitUntil, // TODO: remove. Only the API should be used
+}, u);

+ 7 - 6
src/headless/utils/init.js

@@ -9,7 +9,8 @@ import { Connection, MockConnection } from '../shared/connection/index.js';
 import { Model } from '@converse/skeletor/src/model.js';
 import { Strophe } from 'strophe.js';
 import { createStore, initStorage } from './storage.js';
-import { saveWindowState, isValidJID } from './core.js';
+import { isValidJID } from './jid.js';
+import { saveWindowState, isTestEnv } from './session.js';
 
 
 function setUpXMLLogging () {
@@ -48,7 +49,7 @@ export function initConnection () {
         }
     }
 
-    const XMPPConnection = _converse.isTestEnv() ? MockConnection : Connection;
+    const XMPPConnection = isTestEnv() ? MockConnection : Connection;
     _converse.connection = new XMPPConnection(
         getConnectionServiceURL(),
         Object.assign(
@@ -139,7 +140,7 @@ export async function initSessionStorage (_converse) {
     await Storage.sessionStorageInitialized;
     _converse.storage = {
         'session': Storage.localForage.createInstance({
-            'name': _converse.isTestEnv() ? 'converse-test-session' : 'converse-session',
+            'name': isTestEnv() ? 'converse-test-session' : 'converse-session',
             'description': 'sessionStorage instance',
             'driver': ['sessionStorageWrapper']
         })
@@ -166,7 +167,7 @@ function initPersistentStorage (_converse, store_name) {
     }
 
     const config = {
-        'name': _converse.isTestEnv() ? 'converse-test-persistent' : 'converse-persistent',
+        'name': isTestEnv() ? 'converse-test-persistent' : 'converse-persistent',
         'storeName': store_name
     }
     if (_converse.api.settings.get("persistent_store") === 'localStorage') {
@@ -409,12 +410,12 @@ export async function attemptNonPreboundSession (credentials, automatic) {
             if (credentials) return connect(credentials);
         }
 
-        if (!_converse.isTestEnv() && 'credentials' in navigator) {
+        if (!isTestEnv() && 'credentials' in navigator) {
             const credentials = await getLoginCredentialsFromBrowser();
             if (credentials) return connect(credentials);
         }
 
-        if (!_converse.isTestEnv()) log.warn("attemptNonPreboundSession: Couldn't find credentials to log in with");
+        if (!isTestEnv()) log.warn("attemptNonPreboundSession: Couldn't find credentials to log in with");
 
     } else if (
         [ANONYMOUS, EXTERNAL].includes(api.settings.get("authentication")) &&

+ 32 - 0
src/headless/utils/jid.js

@@ -0,0 +1,32 @@
+import { Strophe } from 'strophe.js';
+
+export function isValidJID (jid) {
+    if (typeof jid === 'string') {
+        return jid.split('@').filter((s) => !!s).length === 2 && !jid.startsWith('@') && !jid.endsWith('@');
+    }
+    return false;
+}
+
+export function isValidMUCJID (jid) {
+    return !jid.startsWith('@') && !jid.endsWith('@');
+}
+
+export function isSameBareJID (jid1, jid2) {
+    if (typeof jid1 !== 'string' || typeof jid2 !== 'string') {
+        return false;
+    }
+    return Strophe.getBareJidFromJid(jid1).toLowerCase() === Strophe.getBareJidFromJid(jid2).toLowerCase();
+}
+
+export function isSameDomain (jid1, jid2) {
+    if (typeof jid1 !== 'string' || typeof jid2 !== 'string') {
+        return false;
+    }
+    return Strophe.getDomainFromJid(jid1).toLowerCase() === Strophe.getDomainFromJid(jid2).toLowerCase();
+}
+
+export function getJIDFromURI (jid) {
+    return jid.startsWith('xmpp:') && jid.endsWith('?join')
+        ? jid.replace(/^xmpp:/, '').replace(/\?join$/, '')
+        : jid;
+}

+ 25 - 0
src/headless/utils/object.js

@@ -0,0 +1,25 @@
+/**
+ * Merge the second object into the first one.
+ * @param {Object} dst
+ * @param {Object} src
+ */
+export function merge (dst, src) {
+    for (const k in src) {
+        if (!Object.prototype.hasOwnProperty.call(src, k)) continue;
+        if (k === '__proto__' || k === 'constructor') continue;
+
+        if (dst[k] instanceof Object) {
+            merge(dst[k], src[k]);
+        } else {
+            dst[k] = src[k];
+        }
+    }
+}
+
+export function isError (obj) {
+    return Object.prototype.toString.call(obj) === '[object Error]';
+}
+
+export function isFunction (val) {
+    return typeof val === 'function';
+}

+ 69 - 0
src/headless/utils/promise.js

@@ -0,0 +1,69 @@
+import log from '../log.js';
+import { getOpenPromise } from '@converse/openpromise';
+
+/**
+ * Clears the specified timeout and interval.
+ * @method u#clearTimers
+ * @param {ReturnType<typeof setTimeout>} timeout - Id if the timeout to clear.
+ * @param {ReturnType<typeof setInterval>} interval - Id of the interval to clear.
+ * @copyright Simen Bekkhus 2016
+ * @license MIT
+ */
+function clearTimers(timeout, interval) {
+    clearTimeout(timeout);
+    clearInterval(interval);
+}
+
+/**
+ * Creates a {@link Promise} that resolves if the passed in function returns a truthy value.
+ * Rejects if it throws or does not return truthy within the given max_wait.
+ * @param { Function } func - The function called every check_delay,
+ *  and the result of which is the resolved value of the promise.
+ * @param { number } [max_wait=300] - The time to wait before rejecting the promise.
+ * @param { number } [check_delay=3] - The time to wait before each invocation of {func}.
+ * @returns {Promise} A promise resolved with the value of func,
+ *  or rejected with the exception thrown by it or it times out.
+ * @copyright Simen Bekkhus 2016
+ * @license MIT
+ */
+export function waitUntil (func, max_wait=300, check_delay=3) {
+    // Run the function once without setting up any listeners in case it's already true
+    try {
+        const result = func();
+        if (result) {
+            return Promise.resolve(result);
+        }
+    } catch (e) {
+        return Promise.reject(e);
+    }
+
+    const promise = getOpenPromise();
+    const timeout_err = new Error();
+
+    function checker () {
+        try {
+            const result = func();
+            if (result) {
+                clearTimers(max_wait_timeout, interval);
+                promise.resolve(result);
+            }
+        } catch (e) {
+            clearTimers(max_wait_timeout, interval);
+            promise.reject(e);
+        }
+    }
+
+    const interval = setInterval(checker, check_delay);
+
+    function handler () {
+        clearTimers(max_wait_timeout, interval);
+        const err_msg = `Wait until promise timed out: \n\n${timeout_err.stack}`;
+        console.trace();
+        log.error(err_msg);
+        promise.reject(new Error(err_msg));
+    }
+
+    const max_wait_timeout = setTimeout(handler, max_wait);
+
+    return promise;
+}

+ 113 - 0
src/headless/utils/session.js

@@ -0,0 +1,113 @@
+import _converse from '../shared/_converse.js';
+import log from '../log.js';
+import { getOpenPromise } from '@converse/openpromise';
+import { settings_api } from '../shared/settings/api.js';
+import { getInitSettings } from '../shared/settings/utils.js';
+
+/**
+ * We distinguish between UniView and MultiView instances.
+ *
+ * UniView means that only one chat is visible, even though there might be multiple ongoing chats.
+ * MultiView means that multiple chats may be visible simultaneously.
+ */
+export function isUniView () {
+    return ['mobile', 'fullscreen', 'embedded'].includes(settings_api.get("view_mode"));
+}
+
+export function isTestEnv () {
+    return getInitSettings()['bosh_service_url'] === 'montague.lit/http-bind';
+}
+
+export function saveWindowState (ev) {
+    // XXX: eventually we should be able to just use
+    // document.visibilityState (when we drop support for older
+    // browsers).
+    let state;
+    const event_map = {
+        'focus': "visible",
+        'focusin': "visible",
+        'pageshow': "visible",
+        'blur': "hidden",
+        'focusout': "hidden",
+        'pagehide': "hidden"
+    };
+    ev = ev || document.createEvent('Events');
+    if (ev.type in event_map) {
+        state = event_map[ev.type];
+    } else {
+        state = document.hidden ? "hidden" : "visible";
+    }
+    _converse.windowState = state;
+    /**
+     * Triggered when window state has changed.
+     * Used to determine when a user left the page and when came back.
+     * @event _converse#windowStateChanged
+     * @type { object }
+     * @property{ string } state - Either "hidden" or "visible"
+     * @example _converse.api.listen.on('windowStateChanged', obj => { ... });
+     */
+    _converse.api.trigger('windowStateChanged', {state});
+}
+
+export function setUnloadEvent () {
+    if ('onpagehide' in window) {
+        // Pagehide gets thrown in more cases than unload. Specifically it
+        // gets thrown when the page is cached and not just
+        // closed/destroyed. It's the only viable event on mobile Safari.
+        // https://www.webkit.org/blog/516/webkit-page-cache-ii-the-unload-event/
+        _converse.unloadevent = 'pagehide';
+    } else if ('onbeforeunload' in window) {
+        _converse.unloadevent = 'beforeunload';
+    } else if ('onunload' in window) {
+        _converse.unloadevent = 'unload';
+    }
+}
+
+export function replacePromise (name) {
+    const existing_promise = _converse.promises[name];
+    if (!existing_promise) {
+        throw new Error(`Tried to replace non-existing promise: ${name}`);
+    }
+    if (existing_promise.replace) {
+        const promise = getOpenPromise();
+        promise.replace = existing_promise.replace;
+        _converse.promises[name] = promise;
+    } else {
+        log.debug(`Not replacing promise "${name}"`);
+    }
+}
+
+export function shouldClearCache () {
+    const { api } = _converse;
+    return !_converse.config.get('trusted') ||
+        api.settings.get('clear_cache_on_logout') ||
+        isTestEnv();
+}
+
+
+export async function tearDown () {
+    const { api } = _converse;
+    await api.trigger('beforeTearDown', {'synchronous': true});
+    window.removeEventListener('click', _converse.onUserActivity);
+    window.removeEventListener('focus', _converse.onUserActivity);
+    window.removeEventListener('keypress', _converse.onUserActivity);
+    window.removeEventListener('mousemove', _converse.onUserActivity);
+    window.removeEventListener(_converse.unloadevent, _converse.onUserActivity);
+    window.clearInterval(_converse.everySecondTrigger);
+    api.trigger('afterTearDown');
+    return _converse;
+}
+
+
+export function clearSession () {
+    _converse.session?.destroy();
+    delete _converse.session;
+    shouldClearCache() && _converse.api.user.settings.clear();
+    /**
+     * Synchronouse event triggered once the user session has been cleared,
+     * for example when the user has logged out or when Converse has
+     * disconnected for some other reason.
+     * @event _converse#clearSession
+     */
+    return _converse.api.trigger('clearSession', {'synchronous': true});
+}

+ 2 - 1
src/i18n/index.js

@@ -6,6 +6,7 @@
  */
 import Jed from 'jed';
 import { _converse, api, converse, log, i18n } from '@converse/headless';
+import { isTestEnv } from '@converse/headless/utils/session';
 
 const { dayjs } = converse.env;
 
@@ -120,7 +121,7 @@ Object.assign(i18n, {
     },
 
     async initialize () {
-        if (_converse.isTestEnv()) {
+        if (isTestEnv()) {
             _converse.locale = 'en';
         } else {
             try {

+ 1 - 1
src/plugins/chatview/message-form.js

@@ -3,7 +3,7 @@ import { ElementView } from '@converse/skeletor/src/element.js';
 import { __ } from 'i18n';
 import { _converse, api, converse } from "@converse/headless";
 import { parseMessageForCommands } from './utils.js';
-import { prefixMentions } from '@converse/headless/utils/core.js';
+import { prefixMentions } from '@converse/headless/utils/index.js';
 
 const { u } = converse.env;
 

+ 5 - 5
src/plugins/controlbox/tests/login.js

@@ -25,15 +25,15 @@ describe("The Login Form", function () {
         cbview.querySelector('input[name="password"]').value = 'secret';
 
         expect(_converse.config.get('trusted')).toBe(true);
-        expect(_converse.getDefaultStore()).toBe('persistent');
+        expect(u.getDefaultStore()).toBe('persistent');
         cbview.querySelector('input[type="submit"]').click();
         expect(_converse.config.get('trusted')).toBe(true);
-        expect(_converse.getDefaultStore()).toBe('persistent');
+        expect(u.getDefaultStore()).toBe('persistent');
 
         checkbox.click();
         cbview.querySelector('input[type="submit"]').click();
         expect(_converse.config.get('trusted')).toBe(false);
-        expect(_converse.getDefaultStore()).toBe('session');
+        expect(u.getDefaultStore()).toBe('session');
     }));
 
     it("checkbox can be set to false by default",
@@ -60,11 +60,11 @@ describe("The Login Form", function () {
 
         cbview.querySelector('input[type="submit"]').click();
         expect(_converse.config.get('trusted')).toBe(false);
-        expect(_converse.getDefaultStore()).toBe('session');
+        expect(u.getDefaultStore()).toBe('session');
 
         checkbox.click();
         cbview.querySelector('input[type="submit"]').click();
         expect(_converse.config.get('trusted')).toBe(true);
-        expect(_converse.getDefaultStore()).toBe('persistent');
+        expect(u.getDefaultStore()).toBe('persistent');
     }));
 });

+ 1 - 1
src/plugins/fullscreen/index.js

@@ -4,7 +4,7 @@
  * @copyright 2022, the Converse.js contributors
  */
 import { api, converse } from "@converse/headless";
-import { isUniView } from '@converse/headless/utils/core.js';
+import { isUniView } from '@converse/headless/utils/session.js';
 
 import './styles/fullscreen.scss';
 

+ 2 - 1
src/plugins/minimize/utils.js

@@ -1,5 +1,6 @@
 import { _converse, api, converse } from '@converse/headless';
 import { __ } from 'i18n';
+import { isTestEnv } from '@converse/headless/utils/session.js';
 
 const { dayjs, u } = converse.env;
 
@@ -61,7 +62,7 @@ function getBoxesWidth (newchat) {
  * @param { _converse.ChatBoxView|_converse.ChatRoomView|_converse.ControlBoxView|_converse.HeadlinesFeedView } [newchat]
  */
 export function trimChats (newchat) {
-    if (_converse.isTestEnv() || api.settings.get('no_trimming') || api.settings.get("view_mode") !== 'overlayed') {
+    if (isTestEnv() || api.settings.get('no_trimming') || api.settings.get("view_mode") !== 'overlayed') {
         return;
     }
     const shown_chats = getShownChats();

+ 1 - 1
src/plugins/muc-views/role-form.js

@@ -2,7 +2,7 @@ import tplRoleForm from './templates/role-form.js';
 import { CustomElement } from 'shared/components/element.js';
 import { __ } from 'i18n';
 import { api, converse, log } from '@converse/headless';
-import { isErrorObject } from '@converse/headless/utils/core.js';
+import { isErrorObject } from '@converse/headless/utils/index.js';
 
 const { Strophe, sizzle } = converse.env;
 

+ 4 - 3
src/plugins/notifications/utils.js

@@ -1,7 +1,8 @@
 import Favico from 'favico.js-slevomat';
 import { __ } from 'i18n';
 import { _converse, api, converse, log } from '@converse/headless';
-import { isEmptyMessage } from '@converse/headless/utils/core.js';
+import { isEmptyMessage } from '@converse/headless/utils/index.js';
+import { isTestEnv } from '@converse/headless/utils/session.js';
 
 const { Strophe } = converse.env;
 const supports_html5_notification = 'Notification' in window;
@@ -12,11 +13,11 @@ let favicon;
 
 
 export function isMessageToHiddenChat (attrs) {
-    return _converse.isTestEnv() || (_converse.chatboxes.get(attrs.from)?.isHidden() ?? false);
+    return isTestEnv() || (_converse.chatboxes.get(attrs.from)?.isHidden() ?? false);
 }
 
 export function areDesktopNotificationsEnabled () {
-    return _converse.isTestEnv() || (
+    return isTestEnv() || (
         supports_html5_notification &&
         api.settings.get('show_desktop_notifications') &&
         Notification.permission === 'granted'

+ 1 - 1
src/plugins/omemo/device.js

@@ -2,7 +2,7 @@ import { IQError } from './errors.js';
 import { Model } from '@converse/skeletor/src/model.js';
 import { UNDECIDED } from './consts.js';
 import { _converse, api, converse, log } from '@converse/headless';
-import { getRandomInt } from '@converse/headless/utils/core.js';
+import { getRandomInt } from '@converse/headless/utils/index.js';
 import { parseBundle } from './utils.js';
 
 const { Strophe, sizzle, $iq } = converse.env;

+ 1 - 1
src/plugins/omemo/index.js

@@ -13,7 +13,7 @@ import Devices from './devices.js';
 import OMEMOStore from './store.js';
 import omemo_api from './api.js';
 import { _converse, api, converse, log } from '@converse/headless';
-import { shouldClearCache } from '@converse/headless/utils/core.js';
+import { shouldClearCache } from '@converse/headless/utils/session.js';
 import {
     createOMEMOMessageStanza,
     encryptFile,

+ 1 - 1
src/plugins/omemo/utils.js

@@ -9,7 +9,7 @@ import { __ } from 'i18n';
 import { _converse, converse, api, log } from '@converse/headless';
 import { html } from 'lit';
 import { initStorage } from '@converse/headless/utils/storage.js';
-import { isError } from '@converse/headless/utils/core.js';
+import { isError } from '@converse/headless/utils/object.js';
 import { isAudioURL, isImageURL, isVideoURL, getURI } from '@converse/headless/utils/url.js';
 import { until } from 'lit/directives/until.js';
 import {

+ 1 - 1
src/plugins/roomslist/templates/roomslist.js

@@ -3,7 +3,7 @@ import 'plugins/muc-views/modals/muc-list.js';
 import { __ } from 'i18n';
 import { _converse, api } from "@converse/headless";
 import { html } from "lit";
-import { isUniView } from '@converse/headless/utils/core.js';
+import { isUniView } from '@converse/headless/utils/session.js';
 import { addBookmarkViaEvent } from 'plugins/bookmark-views/utils.js';
 
 

+ 1 - 2
src/plugins/rosterview/modals/add-contact.js

@@ -1,11 +1,10 @@
 import 'shared/autocomplete/index.js';
 import BaseModal from "plugins/modal/modal.js";
-import api from '@converse/headless/shared/api';
 import debounce from 'lodash-es/debounce';
 import tplAddContactModal from "./templates/add-contact.js";
 import { Strophe } from 'strophe.js';
 import { __ } from 'i18n';
-import { _converse } from "@converse/headless";
+import { _converse, api } from "@converse/headless";
 import { addClass, removeClass } from 'utils/html.js';
 
 export default class AddContactModal extends BaseModal {

+ 1 - 1
src/plugins/rosterview/templates/group.js

@@ -2,7 +2,7 @@ import 'shared/components/icons.js';
 import { __ } from 'i18n';
 import { _converse, converse } from "@converse/headless";
 import { html } from "lit";
-import { isUniView } from '@converse/headless/utils/core.js';
+import { isUniView } from '@converse/headless/utils/session.js';
 import { repeat } from 'lit/directives/repeat.js';
 import { toggleGroup } from '../utils.js';
 

+ 1 - 1
src/templates/form_textarea.js

@@ -1,5 +1,5 @@
 import { html } from "lit";
-import u from '@converse/headless/utils/core.js';
+import u from '@converse/headless/utils/index.js';
 
 export default  (o) => {
     const id = u.getUniqueId();

+ 1 - 1
src/utils/html.js

@@ -15,7 +15,7 @@ import tplFormUrl from '../templates/form_url.js';
 import tplFormUsername from '../templates/form_username.js';
 import tplHyperlink from 'templates/hyperlink.js';
 import tplVideo from 'templates/video.js';
-import u from '../headless/utils/core';
+import u from '../headless/utils/index.js';
 import { converse, log } from '@converse/headless';
 import { getURI, isAudioURL, isImageURL, isVideoURL } from '@converse/headless/utils/url.js';
 import { render } from 'lit';