Bläddra i källkod

Split core.js file into multiple smaller ones

Update storing of app settings. Store settings in a closured
`app_settings` object inside `@converse/headless/shared/settings`

Remove the `_converse.settings` object.
JC Brand 4 år sedan
förälder
incheckning
d3ab68011a

+ 3 - 1
spec/mam.js

@@ -997,9 +997,11 @@ describe("Message Archive Management", function () {
         it("is set once server support for MAM has been confirmed",
                 mock.initConverse([], {}, async function (done, _converse) {
 
+            const { api } = _converse;
+
             const entity = await _converse.api.disco.entities.get(_converse.domain);
             spyOn(_converse, 'onMAMPreferences').and.callThrough();
-            _converse.message_archiving = 'never';
+            api.settings.set('message_archiving', 'never');
 
             const feature = new Model({
                 'var': Strophe.NS.MAM

+ 9 - 6
spec/messages.js

@@ -735,8 +735,9 @@ describe("A Chat Message", function () {
                 ['chatBoxesFetched'], {},
                 async function (done, _converse) {
 
+        const { api } = _converse;
         await mock.waitForRoster(_converse, 'current');
-        _converse.time_format = 'hh:mm';
+        api.settings.set('time_format', 'hh:mm');
         const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
         await mock.openChatBoxFor(_converse, contact_jid)
         const view = _converse.api.chatviews.get(contact_jid);
@@ -751,7 +752,7 @@ describe("A Chat Message", function () {
         expect(msg_author.textContent.trim()).toBe('Romeo Montague');
 
         const msg_time = view.querySelector('.chat-content .chat-msg:last-child .chat-msg__time');
-        const time = dayjs(msg_object.get('time')).format(_converse.time_format);
+        const time = dayjs(msg_object.get('time')).format(api.settings.get('time_format'));
         expect(msg_time.textContent).toBe(time);
         done();
     }));
@@ -1097,7 +1098,8 @@ describe("A Chat Message", function () {
 
                 await mock.waitForRoster(_converse, 'current', 0);
 
-                spyOn(_converse.api, "trigger").and.callThrough();
+                const { api } = _converse;
+                spyOn(api, "trigger").and.callThrough();
                 const message = 'This is a received message from someone not on the roster';
                 const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
                 const msg = $msg({
@@ -1117,7 +1119,7 @@ describe("A Chat Message", function () {
                 let view = _converse.chatboxviews.get(sender_jid);
                 expect(view).not.toBeDefined();
 
-                _converse.allow_non_roster_messaging = true;
+                api.settings.set('allow_non_roster_messaging', true);
                 await _converse.handleMessageStanza(msg);
                 view = _converse.chatboxviews.get(sender_jid);
                 await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
@@ -1344,13 +1346,14 @@ describe("A Chat Message", function () {
         it("is ignored if it's intended for a different resource and filter_by_resource is set to true",
                 mock.initConverse([], {}, async function (done, _converse) {
 
+            const { api } = _converse;
             await mock.waitForRoster(_converse, 'current');
             const rosterview = document.querySelector('converse-roster');
             await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length)
             // Send a message from a different resource
             spyOn(converse.env.log, 'error');
             spyOn(_converse.api.chatboxes, 'create').and.callThrough();
-            _converse.filter_by_resource = true;
+            api.settings.set('filter_by_resource', true);
             const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
             let msg = $msg({
                     from: sender_jid,
@@ -1365,7 +1368,7 @@ describe("A Chat Message", function () {
                 "Ignoring incoming message intended for a different resource: romeo@montague.lit/some-other-resource",
             );
             expect(_converse.api.chatboxes.create).not.toHaveBeenCalled();
-            _converse.filter_by_resource = false;
+            api.settings.set('filter_by_resource', false);
 
             const message = "This message sent to a different resource will be shown";
             msg = $msg({

+ 6 - 4
spec/muc.js

@@ -507,6 +507,7 @@ describe("Groupchats", function () {
             it("will fetch the member list if muc_fetch_members is true",
                     mock.initConverse([], {'muc_fetch_members': true}, async function (done, _converse) {
 
+                const { api } = _converse;
                 let sent_IQs = _converse.connection.IQ_stanzas;
                 const muc_jid = 'lounge@montague.lit';
                 await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
@@ -535,7 +536,7 @@ describe("Groupchats", function () {
 
                 _converse.connection.IQ_stanzas = [];
                 sent_IQs = _converse.connection.IQ_stanzas;
-                _converse.muc_fetch_members = false;
+                api.settings.set('muc_fetch_members', false);
                 await mock.openAndEnterChatRoom(_converse, 'orchard@montague.lit', 'romeo');
                 view = _converse.chatboxviews.get('orchard@montague.lit');
                 expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(0);
@@ -543,7 +544,7 @@ describe("Groupchats", function () {
 
                 _converse.connection.IQ_stanzas = [];
                 sent_IQs = _converse.connection.IQ_stanzas;
-                _converse.muc_fetch_members = ['admin'];
+                api.settings.set('muc_fetch_members', ['admin']);
                 await mock.openAndEnterChatRoom(_converse, 'courtyard@montague.lit', 'romeo');
                 view = _converse.chatboxviews.get('courtyard@montague.lit');
                 expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(1);
@@ -552,7 +553,7 @@ describe("Groupchats", function () {
 
                 _converse.connection.IQ_stanzas = [];
                 sent_IQs = _converse.connection.IQ_stanzas;
-                _converse.muc_fetch_members = ['owner'];
+                api.settings.set('muc_fetch_members', ['owner']);
                 await mock.openAndEnterChatRoom(_converse, 'garden@montague.lit', 'romeo');
                 view = _converse.chatboxviews.get('garden@montague.lit');
                 expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(1);
@@ -4131,6 +4132,7 @@ describe("Groupchats", function () {
         it("will automatically choose a new nickname if a nickname conflict happens and muc_nickname_from_jid=true",
                 mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
 
+            const { api } = _converse;
             const muc_jid = 'conflicting@muc.montague.lit'
             await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo');
             /* <presence
@@ -4144,7 +4146,7 @@ describe("Groupchats", function () {
              *  </error>
              *  </presence>
              */
-            _converse.muc_nickname_from_jid = true;
+            api.settings.set('muc_nickname_from_jid', true);
 
             const attrs = {
                 'from': `${muc_jid}/romeo`,

+ 5 - 4
spec/notification.js

@@ -154,9 +154,11 @@ describe("Notifications", function () {
         describe("A notification sound", function () {
 
             it("is played when the current user is mentioned in a groupchat", mock.initConverse([], {}, async (done, _converse) => {
-                mock.createContacts(_converse, 'current');
+
+                await mock.waitForRoster(_converse, 'current');
                 await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                _converse.play_sounds = true;
+                const { api } = _converse;
+                api.settings.set('play_sounds', true);
 
                 const stub = jasmine.createSpyObj('MyAudio', ['play', 'canPlayType']);
                 spyOn(window, 'Audio').and.returnValue(stub);
@@ -186,7 +188,7 @@ describe("Notifications", function () {
                 }).c('body').t(text);
                 await view.model.handleMessageStanza(message.nodeTree);
                 expect(window.Audio, 1);
-                _converse.play_sounds = false;
+                api.settings.set('play_sounds', false);
 
                 text = "This message won't play a sound because it is sent by romeo";
                 message = $msg({
@@ -197,7 +199,6 @@ describe("Notifications", function () {
                 }).c('body').t(text);
                 await view.model.handleMessageStanza(message.nodeTree);
                 expect(window.Audio, 1);
-                _converse.play_sounds = false;
                 done();
             }));
         });

+ 3 - 2
spec/presence.js

@@ -32,6 +32,7 @@ describe("A sent presence stanza", function () {
     }));
 
     it("has a given priority", mock.initConverse(['statusInitialized'], {}, (done, _converse) => {
+        const { api } = _converse;
         let pres = _converse.xmppstatus.constructPresence('online', null, 'Hello world');
         expect(pres.toLocaleString()).toBe(
             `<presence xmlns="jabber:client">`+
@@ -40,7 +41,7 @@ describe("A sent presence stanza", function () {
                 `<c hash="sha-1" node="https://conversejs.org" ver="PxXfr6uz8ClMWIga0OB/MhKNH/M=" xmlns="http://jabber.org/protocol/caps"/>`+
             `</presence>`
         );
-        _converse.priority = 2;
+        api.settings.set('priority', 2);
         pres = _converse.xmppstatus.constructPresence('away', null, 'Going jogging');
         expect(pres.toLocaleString()).toBe(
             `<presence xmlns="jabber:client">`+
@@ -51,7 +52,7 @@ describe("A sent presence stanza", function () {
             `</presence>`
         );
 
-        delete _converse.priority;
+        api.settings.set('priority', undefined);
         pres = _converse.xmppstatus.constructPresence('dnd', null, 'Doing taxes');
         expect(pres.toLocaleString()).toBe(
             `<presence xmlns="jabber:client">`+

+ 1 - 3
spec/roster.js

@@ -513,7 +513,6 @@ describe("The Contacts Roster", function () {
         it("remembers whether it is closed or opened",
                 mock.initConverse([], {}, async function (done, _converse) {
 
-            _converse.roster_groups = true;
             await mock.waitForRoster(_converse, 'current', 0);
             mock.openControlBox(_converse);
 
@@ -736,10 +735,9 @@ describe("The Contacts Roster", function () {
 
         it("will be hidden when appearing under a collapsed group",
             mock.initConverse(
-                [], {},
+                [], {'roster_groups': false},
                 async function (done, _converse) {
 
-            _converse.roster_groups = false;
             await _addContacts(_converse);
             const rosterview = document.querySelector('converse-roster');
             await u.waitUntil(() => sizzle('li', rosterview).filter(u.isVisible).length, 500);

+ 35 - 274
src/headless/core.js

@@ -1,28 +1,44 @@
 /**
- * @module converse-core
  * @copyright The Converse.js contributors
  * @license Mozilla Public License (MPLv2)
  */
 import './polyfill';
 import Storage from '@converse/skeletor/src/storage.js';
+import _converse from '@converse/headless/shared/_converse';
 import advancedFormat from 'dayjs/plugin/advancedFormat';
 import dayjs from 'dayjs';
+import debounce from 'lodash/debounce';
+import i18n from '@converse/headless/shared/i18n';
+import invoke from 'lodash/invoke';
+import isFunction from 'lodash/isFunction';
+import isObject from 'lodash/isObject';
+import localDriver from 'localforage-webextensionstorage-driver/local';
 import log from '@converse/headless/log';
 import pluggable from 'pluggable.js/src/pluggable';
-import syncDriver from 'localforage-webextensionstorage-driver/sync';
-import localDriver from 'localforage-webextensionstorage-driver/local';
 import sizzle from 'sizzle';
+import syncDriver from 'localforage-webextensionstorage-driver/sync';
 import u from '@converse/headless/utils/core';
 import { Collection } from "@converse/skeletor/src/collection";
 import { Connection, MockConnection } from '@converse/headless/shared/connection.js';
+import {
+    clearUserSettings,
+    extendAppSettings,
+    getAppSetting,
+    getUserSettings,
+    initAppSettings,
+    updateAppSettings,
+    updateUserSettings
+} from '@converse/headless/shared/settings';
 import { Events } from '@converse/skeletor/src/events.js';
 import { Model } from '@converse/skeletor/src/model.js';
-import { Router } from '@converse/skeletor/src/router.js';
 import { Strophe, $build, $iq, $msg, $pres } from 'strophe.js/src/strophe';
-import { assignIn, debounce, invoke, isFunction, isObject, pick } from 'lodash-es';
+import { TimeoutError } from '@converse/headless/shared/errors';
+import { createStore, replacePromise } from '@converse/headless/shared/utils';
 import { html } from 'lit-element';
 import { sprintf } from 'sprintf-js';
 
+export { _converse };
+export { i18n };
 
 dayjs.extend(advancedFormat);
 
@@ -61,12 +77,6 @@ Strophe.addNamespace('VCARDUPDATE', 'vcard-temp:x:update');
 Strophe.addNamespace('XFORM', 'jabber:x:data');
 Strophe.addNamespace('XHTML', 'http://www.w3.org/1999/xhtml');
 
-/**
- * Custom error for indicating timeouts
- * @namespace _converse
- */
-class TimeoutError extends Error {}
-
 
 // Core plugins are whitelisted automatically
 // These are just the @converse/headless plugins, for the full converse,
@@ -93,186 +103,6 @@ const CORE_PLUGINS = [
 ];
 
 
-// Default configuration values
-// ----------------------------
-const DEFAULT_SETTINGS = {
-    allow_non_roster_messaging: false,
-    assets_path: '/dist',
-    authentication: 'login', // Available values are "login", "prebind", "anonymous" and "external".
-    auto_login: false, // Currently only used in connection with anonymous login
-    auto_reconnect: true,
-    blacklisted_plugins: [],
-    clear_cache_on_logout: false,
-    connection_options: {},
-    credentials_url: null, // URL from where login credentials can be fetched
-    discover_connection_methods: true,
-    geouri_regex: /https\:\/\/www.openstreetmap.org\/.*#map=[0-9]+\/([\-0-9.]+)\/([\-0-9.]+)\S*/g,
-    geouri_replacement: 'https://www.openstreetmap.org/?mlat=$1&mlon=$2#map=18/$1/$2',
-    i18n: undefined,
-    idle_presence_timeout: 300, // Seconds after which an idle presence is sent
-    jid: undefined,
-    keepalive: true,
-    loglevel: 'info',
-    locales: [
-        'af', 'ar', 'bg', 'ca', 'cs', 'de', 'eo', 'es', 'eu', 'en', 'fi', 'fr',
-        'gl', 'he', 'hi', 'hu', 'id', 'it', 'ja', 'nb', 'nl', 'mr', 'oc',
-        'pl', 'pt', 'pt_BR', 'ro', 'ru', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW'
-    ],
-    nickname: undefined,
-    password: undefined,
-    persistent_store: 'localStorage',
-    rid: undefined,
-    root: window.document,
-    sid: undefined,
-    singleton: false,
-    strict_plugin_dependencies: false,
-    view_mode: 'overlayed', // Choices are 'overlayed', 'fullscreen', 'mobile'
-    websocket_url: undefined,
-    whitelisted_plugins: []
-};
-
-
-const CONNECTION_STATUS = {};
-CONNECTION_STATUS[Strophe.Status.ATTACHED] = 'ATTACHED';
-CONNECTION_STATUS[Strophe.Status.AUTHENTICATING] = 'AUTHENTICATING';
-CONNECTION_STATUS[Strophe.Status.AUTHFAIL] = 'AUTHFAIL';
-CONNECTION_STATUS[Strophe.Status.CONNECTED] = 'CONNECTED';
-CONNECTION_STATUS[Strophe.Status.CONNECTING] = 'CONNECTING';
-CONNECTION_STATUS[Strophe.Status.CONNFAIL] = 'CONNFAIL';
-CONNECTION_STATUS[Strophe.Status.DISCONNECTED] = 'DISCONNECTED';
-CONNECTION_STATUS[Strophe.Status.DISCONNECTING] = 'DISCONNECTING';
-CONNECTION_STATUS[Strophe.Status.ERROR] = 'ERROR';
-CONNECTION_STATUS[Strophe.Status.RECONNECTING] = 'RECONNECTING';
-CONNECTION_STATUS[Strophe.Status.REDIRECT] = 'REDIRECT';
-
-
-/**
- * @namespace i18n
- */
-export const i18n = {
-    initialize () {},
-
-    /**
-     * Overridable string wrapper method which can be used to provide i18n
-     * support.
-     *
-     * The default implementation in @converse/headless simply calls sprintf
-     * with the passed in arguments.
-     *
-     * If you install the full version of Converse, then this method gets
-     * overwritten in src/i18n/index.js to return a translated string.
-     * @method __
-     * @private
-     * @memberOf i18n
-     * @param { String } str
-     */
-    __ (...args) {
-        return sprintf(...args);
-    }
-};
-
-/**
- * A private, closured object containing the private api (via {@link _converse.api})
- * as well as private methods and internal data-structures.
- * @global
- * @namespace _converse
- */
-export const _converse = {
-    log,
-    CONNECTION_STATUS,
-    templates: {},
-    promises: {
-        'initialized': u.getResolveablePromise()
-    },
-
-    STATUS_WEIGHTS: {
-        'offline':      6,
-        'unavailable':  5,
-        'xa':           4,
-        'away':         3,
-        'dnd':          2,
-        'chat':         1, // We currently don't differentiate between "chat" and "online"
-        'online':       1
-    },
-    ANONYMOUS: 'anonymous',
-    CLOSED: 'closed',
-    EXTERNAL: 'external',
-    LOGIN: 'login',
-    LOGOUT: 'logout',
-    OPENED: 'opened',
-    PREBIND: 'prebind',
-
-    /**
-     * @constant
-     * @type { integer }
-     */
-    STANZA_TIMEOUT: 10000,
-
-    SUCCESS: 'success',
-    FAILURE: 'failure',
-
-    // Generated from css/images/user.svg
-    DEFAULT_IMAGE_TYPE: 'image/svg+xml',
-    DEFAULT_IMAGE: "PD94bWwgdmVyc2lvbj0iMS4wIj8+CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+CiA8cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgZmlsbD0iIzU1NSIvPgogPGNpcmNsZSBjeD0iNjQiIGN5PSI0MSIgcj0iMjQiIGZpbGw9IiNmZmYiLz4KIDxwYXRoIGQ9Im0yOC41IDExMiB2LTEyIGMwLTEyIDEwLTI0IDI0LTI0IGgyMyBjMTQgMCAyNCAxMiAyNCAyNCB2MTIiIGZpbGw9IiNmZmYiLz4KPC9zdmc+Cg==",
-
-    TIMEOUTS: {
-        // Set as module attr so that we can override in tests.
-        PAUSED: 10000,
-        INACTIVE: 90000
-    },
-
-    // XEP-0085 Chat states
-    // https://xmpp.org/extensions/xep-0085.html
-    INACTIVE: 'inactive',
-    ACTIVE: 'active',
-    COMPOSING: 'composing',
-    PAUSED: 'paused',
-    GONE: 'gone',
-
-    // Chat types
-    PRIVATE_CHAT_TYPE: 'chatbox',
-    CHATROOMS_TYPE: 'chatroom',
-    HEADLINES_TYPE: 'headline',
-    CONTROLBOX_TYPE: 'controlbox',
-
-    default_connection_options: {'explicitResourceBinding': true},
-    router: new Router(),
-
-    TimeoutError: TimeoutError,
-
-    isTestEnv: () => {
-        return initialization_settings.bosh_service_url === 'montague.lit/http-bind';
-    },
-
-    /**
-     * Translate the given string based on the current locale.
-     * @method __
-     * @private
-     * @memberOf _converse
-     * @param { String } str
-     */
-    '__': (...args) => i18n.__(...args),
-
-    /**
-     * A no-op method which is used to signal to gettext that the passed in string
-     * should be included in the pot translation file.
-     *
-     * In contrast to the double-underscore method, the triple underscore method
-     * doesn't actually translate the strings.
-     *
-     * One reason for this method might be because we're using strings we cannot
-     * send to the translation function because they require variable interpolation
-     * and we don't yet have the variables at scan time.
-     *
-     * @method ___
-     * @private
-     * @memberOf _converse
-     * @param { String } str
-     */
-    '___': str => str
-}
-
-
 _converse.VERSION_NAME = "v7.0.3dev";
 
 Object.assign(_converse, Events);
@@ -281,36 +111,6 @@ Object.assign(_converse, Events);
 pluggable.enable(_converse, '_converse', 'pluggable');
 
 
-let user_settings; // User settings, populated via api.users.settings
-let initialization_settings = {}; // Container for settings passed in via converse.initialize
-
-
-function initSettings (settings) {
-    _converse.settings = {};
-    initialization_settings = settings;
-    // Allow only whitelisted settings to be overwritten via converse.initialize
-    const allowed_settings = pick(settings, Object.keys(DEFAULT_SETTINGS));
-    assignIn(_converse.settings, DEFAULT_SETTINGS, allowed_settings);
-    assignIn(_converse, DEFAULT_SETTINGS, allowed_settings); // FIXME: remove
-}
-
-
-function initUserSettings () {
-    if (!_converse.bare_jid) {
-        const msg = "No JID to fetch user settings for";
-        log.error(msg);
-        throw Error(msg);
-    }
-    if (!user_settings?.fetched) {
-        const id = `converse.user-settings.${_converse.bare_jid}`;
-        user_settings = new Model({id});
-        user_settings.browserStorage = createStore(id);
-        user_settings.fetched = user_settings.fetch({'promise': true});
-    }
-    return user_settings.fetched;
-}
-
-
 /**
  * ### The private API
  *
@@ -581,13 +381,13 @@ export const api = _converse.api = {
         settings: {
             /**
              * Returns the user settings model. Useful when you want to listen for change events.
+             * @async
              * @method _converse.api.user.settings.getModel
              * @returns {Promise<Model>}
              * @example const settings = await _converse.api.user.settings.getModel();
              */
-            async getModel () {
-                await initUserSettings();
-                return user_settings;
+            getModel () {
+                return getUserSettings();
             },
 
             /**
@@ -599,7 +399,7 @@ export const api = _converse.api = {
              * @example _converse.api.user.settings.get("foo");
              */
             async get (key, fallback) {
-                await initUserSettings();
+                const user_settings = await getUserSettings();
                 return user_settings.get(key) === undefined ? fallback : user_settings.get(key);
             },
 
@@ -617,24 +417,23 @@ export const api = _converse.api = {
              *     "baz": "buz"
              * });
              */
-            async set (key, val) {
-                await initUserSettings();
+            set (key, val) {
                 if (isObject(key)) {
-                    return user_settings.save(key, {'promise': true});
+                    return updateUserSettings(key, {'promise': true});
                 } else {
                     const o = {};
                     o[key] = val;
-                    return user_settings.save(o, {'promise': true});
+                    return updateUserSettings(o, {'promise': true});
                 }
             },
 
             /**
              * Clears all the user settings
+             * @async
              * @method _converse.api.user.settings.clear
              */
-            async clear () {
-                await initUserSettings();
-                user_settings.clear();
+            clear () {
+                return clearUserSettings();
             }
         }
     },
@@ -670,14 +469,7 @@ export const api = _converse.api = {
          * });
          */
         extend (settings) {
-            u.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(pick(settings,Object.keys(DEFAULT_SETTINGS)));
-            const allowed_site_settings = pick(initialization_settings, allowed_keys);
-            const updated_settings = assignIn(pick(settings, allowed_keys), allowed_site_settings);
-            u.merge(_converse.settings, updated_settings);
-            u.merge(_converse, updated_settings); // FIXME: remove
+            return extendAppSettings(settings);
         },
 
         update (settings) {
@@ -692,9 +484,7 @@ export const api = _converse.api = {
          * @example _converse.api.settings.get("play_sounds");
          */
         get (key) {
-            if (Object.keys(DEFAULT_SETTINGS).includes(key)) {
-                return _converse[key];
-            }
+            return getAppSetting(key);
         },
 
         /**
@@ -716,15 +506,7 @@ export const api = _converse.api = {
          * });
          */
         set (key, val) {
-            const o = {};
-            if (isObject(key)) {
-                assignIn(_converse, pick(key, Object.keys(DEFAULT_SETTINGS)));
-                assignIn(_converse.settings, pick(key, Object.keys(DEFAULT_SETTINGS)));
-            } else if (typeof key === 'string') {
-                o[key] = val;
-                assignIn(_converse, pick(o, Object.keys(DEFAULT_SETTINGS)));
-                assignIn(_converse.settings, pick(o, Object.keys(DEFAULT_SETTINGS)));
-            }
+            updateAppSettings(key, val);
         }
     },
 
@@ -938,20 +720,6 @@ export const api = _converse.api = {
 };
 
 
-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 = u.getResolveablePromise();
-        promise.replace = existing_promise.replace;
-        _converse.promises[name] = promise;
-    } else {
-        log.debug(`Not replacing promise "${name}"`);
-    }
-}
-
 _converse.isUniView = function () {
     /* We distinguish between UniView and MultiView instances.
      *
@@ -973,7 +741,6 @@ async function initSessionStorage () {
     };
 }
 
-
 function initPersistentStorage () {
     if (api.settings.get('persistent_store') === 'sessionStorage') {
         return;
@@ -1016,12 +783,6 @@ _converse.getDefaultStore = function () {
     }
 }
 
-
-function createStore (id, storage) {
-    const s = _converse.storage[storage || _converse.getDefaultStore()];
-    return new Storage(id, s);
-}
-
 _converse.createStore = createStore;
 
 
@@ -1531,7 +1292,7 @@ Object.assign(converse, {
         await cleanup();
 
         setUnloadEvent();
-        initSettings(settings);
+        initAppSettings(settings);
         _converse.strict_plugin_dependencies = settings.strict_plugin_dependencies; // Needed by pluggable.js
         log.setLogLevel(api.settings.get("loglevel"));
 

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

@@ -279,7 +279,7 @@ const ChatBox = ModelWithContact.extend({
         if (!attrs.jid) {
             return 'Ignored ChatBox without JID';
         }
-        const room_jids = _converse.auto_join_rooms.map(s => isObject(s) ? s.jid : s);
+        const room_jids = api.settings.get('auto_join_rooms').map(s => isObject(s) ? s.jid : s);
         const auto_join = api.settings.get('auto_join_private_chats').concat(room_jids);
         if (api.settings.get("singleton") && !auto_join.includes(attrs.jid) && !api.settings.get('auto_join_on_invite')) {
             const msg = `${attrs.jid} is not allowed because singleton is true and it's not being auto_joined`;

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

@@ -0,0 +1,111 @@
+import i18n from '@converse/headless/shared/i18n';
+import log from '@converse/headless/log';
+import u from '@converse/headless/utils/core';
+import { CONNECTION_STATUS } from '@converse/headless/shared/constants';
+import { Router } from '@converse/skeletor/src/router.js';
+import { TimeoutError } from '@converse/headless/shared/errors';
+import { getAppSettings } from '@converse/headless/shared/settings';
+
+
+/**
+ * A private, closured object containing the private api (via {@link _converse.api})
+ * as well as private methods and internal data-structures.
+ * @global
+ * @namespace _converse
+ */
+const _converse = {
+    log,
+    CONNECTION_STATUS,
+    templates: {},
+    promises: {
+        'initialized': u.getResolveablePromise()
+    },
+
+    STATUS_WEIGHTS: {
+        'offline':      6,
+        'unavailable':  5,
+        'xa':           4,
+        'away':         3,
+        'dnd':          2,
+        'chat':         1, // We currently don't differentiate between "chat" and "online"
+        'online':       1
+    },
+    ANONYMOUS: 'anonymous',
+    CLOSED: 'closed',
+    EXTERNAL: 'external',
+    LOGIN: 'login',
+    LOGOUT: 'logout',
+    OPENED: 'opened',
+    PREBIND: 'prebind',
+
+    /**
+     * @constant
+     * @type { integer }
+     */
+    STANZA_TIMEOUT: 10000,
+
+    SUCCESS: 'success',
+    FAILURE: 'failure',
+
+    // Generated from css/images/user.svg
+    DEFAULT_IMAGE_TYPE: 'image/svg+xml',
+    DEFAULT_IMAGE: "PD94bWwgdmVyc2lvbj0iMS4wIj8+CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+CiA8cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgZmlsbD0iIzU1NSIvPgogPGNpcmNsZSBjeD0iNjQiIGN5PSI0MSIgcj0iMjQiIGZpbGw9IiNmZmYiLz4KIDxwYXRoIGQ9Im0yOC41IDExMiB2LTEyIGMwLTEyIDEwLTI0IDI0LTI0IGgyMyBjMTQgMCAyNCAxMiAyNCAyNCB2MTIiIGZpbGw9IiNmZmYiLz4KPC9zdmc+Cg==",
+
+    TIMEOUTS: {
+        // Set as module attr so that we can override in tests.
+        PAUSED: 10000,
+        INACTIVE: 90000
+    },
+
+    // XEP-0085 Chat states
+    // https://xmpp.org/extensions/xep-0085.html
+    INACTIVE: 'inactive',
+    ACTIVE: 'active',
+    COMPOSING: 'composing',
+    PAUSED: 'paused',
+    GONE: 'gone',
+
+    // Chat types
+    PRIVATE_CHAT_TYPE: 'chatbox',
+    CHATROOMS_TYPE: 'chatroom',
+    HEADLINES_TYPE: 'headline',
+    CONTROLBOX_TYPE: 'controlbox',
+
+    default_connection_options: {'explicitResourceBinding': true},
+    router: new Router(),
+
+    TimeoutError: TimeoutError,
+
+    isTestEnv: () => {
+        return getAppSettings()['bosh_service_url'] === 'montague.lit/http-bind';
+    },
+
+    /**
+     * Translate the given string based on the current locale.
+     * @method __
+     * @private
+     * @memberOf _converse
+     * @param { String } str
+     */
+    '__': (...args) => i18n.__(...args),
+
+    /**
+     * A no-op method which is used to signal to gettext that the passed in string
+     * should be included in the pot translation file.
+     *
+     * In contrast to the double-underscore method, the triple underscore method
+     * doesn't actually translate the strings.
+     *
+     * One reason for this method might be because we're using strings we cannot
+     * send to the translation function because they require variable interpolation
+     * and we don't yet have the variables at scan time.
+     *
+     * @method ___
+     * @private
+     * @memberOf _converse
+     * @param { String } str
+     */
+    '___': str => str
+}
+
+export default _converse;

+ 14 - 0
src/headless/shared/constants.js

@@ -0,0 +1,14 @@
+import { Strophe } from 'strophe.js/src/strophe';
+
+export const CONNECTION_STATUS = {};
+CONNECTION_STATUS[Strophe.Status.ATTACHED] = 'ATTACHED';
+CONNECTION_STATUS[Strophe.Status.AUTHENTICATING] = 'AUTHENTICATING';
+CONNECTION_STATUS[Strophe.Status.AUTHFAIL] = 'AUTHFAIL';
+CONNECTION_STATUS[Strophe.Status.CONNECTED] = 'CONNECTED';
+CONNECTION_STATUS[Strophe.Status.CONNECTING] = 'CONNECTING';
+CONNECTION_STATUS[Strophe.Status.CONNFAIL] = 'CONNFAIL';
+CONNECTION_STATUS[Strophe.Status.DISCONNECTED] = 'DISCONNECTED';
+CONNECTION_STATUS[Strophe.Status.DISCONNECTING] = 'DISCONNECTING';
+CONNECTION_STATUS[Strophe.Status.ERROR] = 'ERROR';
+CONNECTION_STATUS[Strophe.Status.RECONNECTING] = 'RECONNECTING';
+CONNECTION_STATUS[Strophe.Status.REDIRECT] = 'REDIRECT';

+ 6 - 0
src/headless/shared/errors.js

@@ -0,0 +1,6 @@
+
+/**
+ * Custom error for indicating timeouts
+ * @namespace _converse
+ */
+export class TimeoutError extends Error {}

+ 26 - 0
src/headless/shared/i18n.js

@@ -0,0 +1,26 @@
+import { sprintf } from 'sprintf-js';
+
+/**
+ * @namespace i18n
+ */
+export default {
+    initialize () {},
+
+    /**
+     * Overridable string wrapper method which can be used to provide i18n
+     * support.
+     *
+     * The default implementation in @converse/headless simply calls sprintf
+     * with the passed in arguments.
+     *
+     * If you install the full version of Converse, then this method gets
+     * overwritten in src/i18n/index.js to return a translated string.
+     * @method __
+     * @private
+     * @memberOf i18n
+     * @param { String } str
+     */
+    __ (...args) {
+        return sprintf(...args);
+    }
+};

+ 126 - 0
src/headless/shared/settings.js

@@ -0,0 +1,126 @@
+import _converse from '@converse/headless/shared/_converse';
+import assignIn from 'lodash/assignIn';
+import isObject from 'lodash/isObject';
+import log from '@converse/headless/log';
+import pick from 'lodash/pick';
+import u from '@converse/headless/utils/core';
+import { Model } from '@converse/skeletor/src/model.js';
+import { createStore } from '@converse/headless/shared/utils.js';
+
+let init_settings = {}; // Container for settings passed in via converse.initialize
+let app_settings = {};
+let user_settings; // User settings, populated via api.users.settings
+
+// Default configuration values
+// ----------------------------
+export const DEFAULT_SETTINGS = {
+    allow_non_roster_messaging: false,
+    assets_path: '/dist',
+    authentication: 'login', // Available values are "login", "prebind", "anonymous" and "external".
+    auto_login: false, // Currently only used in connection with anonymous login
+    auto_reconnect: true,
+    blacklisted_plugins: [],
+    clear_cache_on_logout: false,
+    connection_options: {},
+    credentials_url: null, // URL from where login credentials can be fetched
+    discover_connection_methods: true,
+    geouri_regex: /https\:\/\/www.openstreetmap.org\/.*#map=[0-9]+\/([\-0-9.]+)\/([\-0-9.]+)\S*/g,
+    geouri_replacement: 'https://www.openstreetmap.org/?mlat=$1&mlon=$2#map=18/$1/$2',
+    i18n: undefined,
+    idle_presence_timeout: 300, // Seconds after which an idle presence is sent
+    jid: undefined,
+    keepalive: true,
+    loglevel: 'info',
+    locales: [
+        'af', 'ar', 'bg', 'ca', 'cs', 'de', 'eo', 'es', 'eu', 'en', 'fi', 'fr',
+        'gl', 'he', 'hi', 'hu', 'id', 'it', 'ja', 'nb', 'nl', 'mr', 'oc',
+        'pl', 'pt', 'pt_BR', 'ro', 'ru', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW'
+    ],
+    nickname: undefined,
+    password: undefined,
+    persistent_store: 'localStorage',
+    rid: undefined,
+    root: window.document,
+    sid: undefined,
+    singleton: false,
+    strict_plugin_dependencies: false,
+    view_mode: 'overlayed', // Choices are 'overlayed', 'fullscreen', 'mobile'
+    websocket_url: undefined,
+    whitelisted_plugins: []
+};
+
+
+export function getAppSettings () {
+    return app_settings;
+}
+
+export function initAppSettings (settings) {
+    init_settings = settings;
+    app_settings = {};
+    // Allow only whitelisted settings to be overwritten via converse.initialize
+    const allowed_settings = pick(settings, Object.keys(DEFAULT_SETTINGS));
+    assignIn(_converse, DEFAULT_SETTINGS, allowed_settings); // FIXME: remove
+    assignIn(app_settings, DEFAULT_SETTINGS, allowed_settings);
+}
+
+export function getAppSetting (key) {
+    if (Object.keys(DEFAULT_SETTINGS).includes(key)) {
+        return app_settings[key];
+    }
+}
+
+export function extendAppSettings (settings) {
+    u.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(pick(settings,Object.keys(DEFAULT_SETTINGS)));
+    const allowed_site_settings = pick(init_settings, allowed_keys);
+    const updated_settings = assignIn(pick(settings, allowed_keys), allowed_site_settings);
+    u.merge(app_settings, updated_settings);
+    u.merge(_converse, updated_settings); // FIXME: remove
+}
+
+export function updateAppSettings (key, val) {
+    const o = {};
+    if (isObject(key)) {
+        assignIn(_converse, pick(key, Object.keys(DEFAULT_SETTINGS))); // FIXME: remove
+        assignIn(app_settings, pick(key, Object.keys(DEFAULT_SETTINGS)));
+    } else if (typeof key === 'string') {
+        o[key] = val;
+        assignIn(_converse, pick(o, Object.keys(DEFAULT_SETTINGS))); // FIXME: remove
+        assignIn(app_settings, pick(o, Object.keys(DEFAULT_SETTINGS)));
+    }
+}
+
+/**
+ * @async
+ */
+function initUserSettings () {
+    if (!_converse.bare_jid) {
+        const msg = "No JID to fetch user settings for";
+        log.error(msg);
+        throw Error(msg);
+    }
+    if (!user_settings?.fetched) {
+        const id = `converse.user-settings.${_converse.bare_jid}`;
+        user_settings = new Model({id});
+        user_settings.browserStorage = createStore(id);
+        user_settings.fetched = user_settings.fetch({'promise': true});
+    }
+    return user_settings.fetched;
+}
+
+export async function getUserSettings () {
+    await initUserSettings();
+    return user_settings;
+}
+
+export async function updateUserSettings (data, options) {
+    await initUserSettings();
+    return user_settings.save(data, options);
+}
+
+export async function clearUserSettings () {
+    await initUserSettings();
+    return user_settings.clear();
+}

+ 24 - 0
src/headless/shared/utils.js

@@ -0,0 +1,24 @@
+import Storage from '@converse/skeletor/src/storage.js';
+import _converse from '@converse/headless/shared/_converse';
+import log from '@converse/headless/log';
+import u from '@converse/headless/utils/core';
+
+
+export function createStore (id, storage) {
+    const s = _converse.storage[storage || _converse.getDefaultStore()];
+    return new Storage(id, s);
+}
+
+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 = u.getResolveablePromise();
+        promise.replace = existing_promise.replace;
+        _converse.promises[name] = promise;
+    } else {
+        log.debug(`Not replacing promise "${name}"`);
+    }
+}

+ 1 - 1
src/plugins/muc-views/bottom_panel.js

@@ -45,7 +45,7 @@ export default class MUCBottomPanel extends BottomPanel {
         return Object.assign(super.getToolbarOptions(), {
             'is_groupchat': true,
             'label_hide_occupants': __('Hide the list of participants'),
-            'show_occupants_toggle': _converse.visible_toolbar_buttons.toggle_occupants
+            'show_occupants_toggle': api.settings.get('visible_toolbar_buttons').toggle_occupants
         });
     }
 

+ 2 - 2
src/shared/chat/templates/emoji-picker.js

@@ -1,5 +1,5 @@
 import { __ } from 'i18n';
-import { _converse, converse, api } from "@converse/headless/core";
+import { converse, api } from "@converse/headless/core";
 import { html } from "lit-html";
 
 const u = converse.env.utils;
@@ -9,7 +9,7 @@ const emoji_category = (o) => {
     return html`
         <li data-category="${o.category}"
             class="emoji-category ${o.category} ${(o.current_category === o.category) ? 'picked' : ''}"
-            title="${__(_converse.emoji_category_labels[o.category])}">
+            title="${__(api.settings.get('emoji_category_labels')[o.category])}">
 
             <a class="pick-category"
                @click=${o.onCategoryPicked}