Răsfoiți Sursa

Turn status plugin into folder

Remove the use of an override and add a hook `presenceConstructed` instead
JC Brand 4 ani în urmă
părinte
comite
f40bbbf145

+ 1 - 0
CHANGES.md

@@ -14,6 +14,7 @@
 - Bugfix: `null` inserted by emoji picker and can't switch between skintones
 - New hook: [getMessageActionButtons](https://conversejs.org/docs/html/api/-_converse.html#event:getMessageActionButtons)
 - New hook: [shouldNotifyOfGroupMessage](https://conversejs.org/docs/html/api/-_converse.html#event:shouldNotifyOfGroupMessage)
+- New hook: [presenceConstructed](https://conversejs.org/docs/html/api/-_converse.html#event:presenceConstructed)
 - File structure reordering: All plugins are now in `./plugins` folders.
 - New configuration setting: [show_tab_notifications](https://conversejs.org/docs/html/configuration.html#show-tab-notifications)
 - New configuration setting: [muc_clear_messages_on_leave](https://conversejs.org/docs/html/configuration.html#muc-clear-messages-on-leave)

+ 1 - 1
karma.conf.js

@@ -33,7 +33,6 @@ module.exports = function(config) {
       { pattern: "spec/retractions.js", type: 'module' },
       { pattern: "spec/user-details-modal.js", type: 'module' },
       { pattern: "spec/utils.js", type: 'module' },
-      { pattern: "spec/xmppstatus.js", type: 'module' },
       { pattern: "src/headless/plugins/caps/tests/caps.js", type: 'module' },
       { pattern: "src/headless/plugins/chat/tests/api.js", type: 'module' },
       { pattern: "src/headless/plugins/disco/tests/disco.js", type: 'module' },
@@ -41,6 +40,7 @@ module.exports = function(config) {
       { pattern: "src/headless/plugins/ping/tests/ping.js", type: 'module' },
       { pattern: "src/headless/plugins/roster/tests/presence.js", type: 'module' },
       { pattern: "src/headless/plugins/smacks/tests/smacks.js", type: 'module' },
+      { pattern: "src/headless/plugins/status/tests/status.js", type: 'module' },
       { pattern: "src/headless/tests/converse.js", type: 'module' },
       { pattern: "src/headless/tests/eventemitter.js", type: 'module' },
       { pattern: "src/plugins/bookmark-views/tests/bookmarks.js", type: 'module' },

+ 16 - 16
src/headless/headless.js

@@ -2,23 +2,23 @@
  * --------------------
  * Any of the following components may be removed if they're not needed.
  */
-import "./plugins/adhoc.js";        // XEP-0050 Ad Hoc Commands
-import "./plugins/bookmarks/index.js";   // XEP-0199 XMPP Ping
-import "./plugins/bosh.js";         // XEP-0206 BOSH
-import "./plugins/caps/index.js";         // XEP-0115 Entity Capabilities
-import "./plugins/carbons.js";      // XEP-0280 Message Carbons
-import "./plugins/chat/index.js";   // RFC-6121 Instant messaging
+import "./plugins/adhoc.js";            // XEP-0050 Ad Hoc Commands
+import "./plugins/bookmarks/index.js";  // XEP-0199 XMPP Ping
+import "./plugins/bosh.js";             // XEP-0206 BOSH
+import "./plugins/caps/index.js";       // XEP-0115 Entity Capabilities
+import "./plugins/carbons.js";          // XEP-0280 Message Carbons
+import "./plugins/chat/index.js";       // RFC-6121 Instant messaging
 import "./plugins/chatboxes/index.js";
-import "./plugins/disco/index.js";  // XEP-0030 Service discovery
-import "./plugins/headlines.js";    // Support for headline messages
-import "./plugins/mam/index.js";    // XEP-0313 Message Archive Management
-import "./plugins/muc/index.js";    // XEP-0045 Multi-user chat
-import "./plugins/ping/index.js";         // XEP-0199 XMPP Ping
-import "./plugins/pubsub.js";       // XEP-0060 Pubsub
-import "./plugins/roster/index.js"; // RFC-6121 Contacts Roster
-import "./plugins/smacks/index.js";       // XEP-0198 Stream Management
-import "./plugins/status.js";       // XEP-0199 XMPP Ping
-import "./plugins/vcard.js";        // XEP-0054 VCard-temp
+import "./plugins/disco/index.js";      // XEP-0030 Service discovery
+import "./plugins/headlines.js";        // Support for headline messages
+import "./plugins/mam/index.js";        // XEP-0313 Message Archive Management
+import "./plugins/muc/index.js";        // XEP-0045 Multi-user chat
+import "./plugins/ping/index.js";       // XEP-0199 XMPP Ping
+import "./plugins/pubsub.js";           // XEP-0060 Pubsub
+import "./plugins/roster/index.js";     // RFC-6121 Contacts Roster
+import "./plugins/smacks/index.js";     // XEP-0198 Stream Management
+import "./plugins/status/index.js";
+import "./plugins/vcard.js";            // XEP-0054 VCard-temp
 /* END: Removable components */
 
 import { converse } from "./core.js";

+ 5 - 12
src/headless/plugins/caps/index.js

@@ -2,7 +2,7 @@
  * @copyright 2020, the Converse.js contributors
  * @license Mozilla Public License (MPLv2)
  */
-import { _converse, converse } from '@converse/headless/core';
+import { api, converse } from '@converse/headless/core';
 import { createCapsNode } from './utils.js';
 
 const { Strophe } = converse.env;
@@ -12,16 +12,9 @@ Strophe.addNamespace('CAPS', "http://jabber.org/protocol/caps");
 
 converse.plugins.add('converse-caps', {
 
-    overrides: {
-        // Overrides mentioned here will be picked up by converse.js's
-        // plugin architecture they will replace existing methods on the
-        // relevant objects or classes.
-        XMPPStatus: {
-            constructPresence () {
-                const presence = this.__super__.constructPresence.apply(this, arguments);
-                presence.root().cnode(createCapsNode(_converse)).up();
-                return presence;
-            }
-        }
+    dependencies: ['converse-status'],
+
+    initialize () {
+        api.listen.on('constructedPresence', p => p.root().cnode(createCapsNode()).up() && p);
     }
 });

+ 6 - 5
src/headless/plugins/caps/tests/caps.js

@@ -21,7 +21,7 @@ describe("A sent presence stanza", function () {
         _converse.api.disco.own.features.add("http://jabber.org/protocol/disco#items");
         _converse.api.disco.own.features.add("http://jabber.org/protocol/muc");
 
-        const presence = _converse.xmppstatus.constructPresence();
+        const presence = await _converse.xmppstatus.constructPresence();
         expect(presence.toLocaleString()).toBe(
             `<presence xmlns="jabber:client">`+
                 `<priority>0</priority>`+
@@ -30,9 +30,9 @@ describe("A sent presence stanza", function () {
         done();
     }));
 
-    it("has a given priority", mock.initConverse(['statusInitialized'], {}, (done, _converse) => {
+    it("has a given priority", mock.initConverse(['statusInitialized'], {}, async (done, _converse) => {
         const { api } = _converse;
-        let pres = _converse.xmppstatus.constructPresence('online', null, 'Hello world');
+        let pres = await _converse.xmppstatus.constructPresence('online', null, 'Hello world');
         expect(pres.toLocaleString()).toBe(
             `<presence xmlns="jabber:client">`+
                 `<status>Hello world</status>`+
@@ -40,8 +40,9 @@ 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>`
         );
+
         api.settings.set('priority', 2);
-        pres = _converse.xmppstatus.constructPresence('away', null, 'Going jogging');
+        pres = await _converse.xmppstatus.constructPresence('away', null, 'Going jogging');
         expect(pres.toLocaleString()).toBe(
             `<presence xmlns="jabber:client">`+
                 `<show>away</show>`+
@@ -52,7 +53,7 @@ describe("A sent presence stanza", function () {
         );
 
         api.settings.set('priority', undefined);
-        pres = _converse.xmppstatus.constructPresence('dnd', null, 'Doing taxes');
+        pres = await _converse.xmppstatus.constructPresence('dnd', null, 'Doing taxes');
         expect(pres.toLocaleString()).toBe(
             `<presence xmlns="jabber:client">`+
                 `<show>dnd</show>`+

+ 4 - 4
src/headless/plugins/caps/utils.js

@@ -1,5 +1,5 @@
 import SHA1 from 'strophe.js/src/sha1';
-import { converse } from '@converse/headless/core';
+import { _converse, converse } from '@converse/headless/core';
 
 const { Strophe, $build } = converse.env;
 
@@ -7,7 +7,7 @@ function propertySort (array, property) {
     return array.sort((a, b) => { return a[property] > b[property] ? -1 : 1 });
 }
 
-function generateVerificationString (_converse) {
+function generateVerificationString () {
     const identities = _converse.api.disco.own.identities.get();
     const features = _converse.api.disco.own.features.get();
 
@@ -23,11 +23,11 @@ function generateVerificationString (_converse) {
     return SHA1.b64_sha1(S);
 }
 
-export function createCapsNode (_converse) {
+export function createCapsNode () {
     return $build("c", {
         'xmlns': Strophe.NS.CAPS,
         'hash': "sha-1",
         'node': "https://conversejs.org",
-        'ver': generateVerificationString(_converse)
+        'ver': generateVerificationString()
     }).nodeTree;
 }

+ 2 - 0
src/headless/plugins/smacks/tests/smacks.js

@@ -48,6 +48,8 @@ describe("XEP-0198 Stream Management", function () {
             `<iq from="romeo@montague.lit/orchard" id="${iq.getAttribute('id')}" to="montague.lit" type="get" xmlns="jabber:client">`+
                 `<query xmlns="http://jabber.org/protocol/disco#info"/></iq>`]);
 
+        await u.waitUntil(() => sent_stanzas.filter(s => (s.nodeName === 'presence')).length);
+
         const disco_iq = IQ_stanzas.pop();
         expect(expected_IQs(disco_iq).includes(Strophe.serialize(disco_iq))).toBe(true);
         iq = IQ_stanzas.pop();

+ 0 - 345
src/headless/plugins/status.js

@@ -1,345 +0,0 @@
-/**
- * @module converse-status
- * @copyright The Converse.js contributors
- * @license Mozilla Public License (MPLv2)
- */
-import isNaN from "lodash-es/isNaN";
-import isObject from "lodash-es/isObject";
-import { Model } from '@converse/skeletor/src/model.js';
-import { initStorage } from '@converse/headless/shared/utils.js';
-import { _converse, api, converse } from "@converse/headless/core";
-
-const { Strophe, $build, $pres } = converse.env;
-
-
-converse.plugins.add('converse-status', {
-
-    initialize () {
-
-        api.settings.extend({
-            auto_away: 0, // Seconds after which user status is set to 'away'
-            auto_xa: 0, // Seconds after which user status is set to 'xa'
-            csi_waiting_time: 0, // Support for XEP-0352. Seconds before client is considered idle and CSI is sent out.
-            default_state: 'online',
-            priority: 0,
-        });
-        api.promises.add(['statusInitialized']);
-
-        _converse.XMPPStatus = Model.extend({
-            defaults () {
-                return {"status":  api.settings.get("default_state")}
-            },
-
-            initialize () {
-                this.on('change', item => {
-                    if (!isObject(item.changed)) {
-                        return;
-                    }
-                    if ('status' in item.changed || 'status_message' in item.changed) {
-                        api.user.presence.send(this.get('status'), null, this.get('status_message'));
-                    }
-                });
-            },
-
-            getNickname () {
-                return _converse.nickname;
-            },
-
-            getFullname () {
-                // Gets overridden in converse-vcard
-                return '';
-            },
-
-            constructPresence (type, to=null, status_message) {
-                type = typeof type === 'string' ? type : (this.get('status') || api.settings.get("default_state"));
-                status_message = typeof status_message === 'string' ? status_message : this.get('status_message');
-                let presence;
-                const attrs = {to};
-                if ((type === 'unavailable') ||
-                        (type === 'probe') ||
-                        (type === 'error') ||
-                        (type === 'unsubscribe') ||
-                        (type === 'unsubscribed') ||
-                        (type === 'subscribe') ||
-                        (type === 'subscribed')) {
-                    attrs['type'] = type;
-                    presence = $pres(attrs);
-                } else if (type === 'offline') {
-                    attrs['type'] = 'unavailable';
-                    presence = $pres(attrs);
-                } else if (type === 'online') {
-                    presence = $pres(attrs);
-                } else {
-                    presence = $pres(attrs).c('show').t(type).up();
-                }
-
-                if (status_message) {
-                    presence.c('status').t(status_message).up();
-                }
-
-                const priority = api.settings.get("priority");
-                presence.c('priority').t(isNaN(Number(priority)) ? 0 : priority).up();
-                if (_converse.idle) {
-                    const idle_since = new Date();
-                    idle_since.setSeconds(idle_since.getSeconds() - _converse.idle_seconds);
-                    presence.c('idle', {xmlns: Strophe.NS.IDLE, since: idle_since.toISOString()});
-                }
-                return presence;
-            }
-        });
-
-
-        /**
-         * Send out a Client State Indication (XEP-0352)
-         * @private
-         * @method sendCSI
-         * @memberOf _converse
-         * @param { String } stat - The user's chat status
-         */
-        _converse.sendCSI = function (stat) {
-            api.send($build(stat, {xmlns: Strophe.NS.CSI}));
-            _converse.inactive = (stat === _converse.INACTIVE) ? true : false;
-        };
-
-
-        _converse.onUserActivity = function () {
-            /* Resets counters and flags relating to CSI and auto_away/auto_xa */
-            if (_converse.idle_seconds > 0) {
-                _converse.idle_seconds = 0;
-            }
-            if (!_converse.connection?.authenticated) {
-                // We can't send out any stanzas when there's no authenticated connection.
-                // This can happen when the connection reconnects.
-                return;
-            }
-            if (_converse.inactive) {
-                _converse.sendCSI(_converse.ACTIVE);
-            }
-            if (_converse.idle) {
-                _converse.idle = false;
-                api.user.presence.send();
-            }
-            if (_converse.auto_changed_status === true) {
-                _converse.auto_changed_status = false;
-                // XXX: we should really remember the original state here, and
-                // then set it back to that...
-                _converse.xmppstatus.set('status', api.settings.get("default_state"));
-            }
-        };
-
-        _converse.onEverySecond = function () {
-            /* An interval handler running every second.
-             * Used for CSI and the auto_away and auto_xa features.
-             */
-            if (!_converse.connection?.authenticated) {
-                // We can't send out any stanzas when there's no authenticated connection.
-                // This can happen when the connection reconnects.
-                return;
-            }
-            const stat = _converse.xmppstatus.get('status');
-            _converse.idle_seconds++;
-            if (api.settings.get("csi_waiting_time") > 0 &&
-                    _converse.idle_seconds > api.settings.get("csi_waiting_time") &&
-                    !_converse.inactive) {
-                _converse.sendCSI(_converse.INACTIVE);
-            }
-            if (api.settings.get("idle_presence_timeout") > 0 &&
-                    _converse.idle_seconds > api.settings.get("idle_presence_timeout") &&
-                    !_converse.idle) {
-                _converse.idle = true;
-                api.user.presence.send();
-            }
-            if (api.settings.get("auto_away") > 0 &&
-                    _converse.idle_seconds > api.settings.get("auto_away") &&
-                    stat !== 'away' && stat !== 'xa' && stat !== 'dnd') {
-                _converse.auto_changed_status = true;
-                _converse.xmppstatus.set('status', 'away');
-            } else if (api.settings.get("auto_xa") > 0 &&
-                    _converse.idle_seconds > api.settings.get("auto_xa") &&
-                    stat !== 'xa' && stat !== 'dnd') {
-                _converse.auto_changed_status = true;
-                _converse.xmppstatus.set('status', 'xa');
-            }
-        };
-
-        _converse.registerIntervalHandler = function () {
-            /* Set an interval of one second and register a handler for it.
-             * Required for the auto_away, auto_xa and csi_waiting_time features.
-             */
-            if (
-                api.settings.get("auto_away") < 1 &&
-                api.settings.get("auto_xa") < 1 &&
-                api.settings.get("csi_waiting_time") < 1 &&
-                api.settings.get("idle_presence_timeout") < 1
-            ) {
-                // Waiting time of less then one second means features aren't used.
-                return;
-            }
-            _converse.idle_seconds = 0;
-            _converse.auto_changed_status = false; // Was the user's status changed by Converse?
-
-            const { unloadevent } = _converse;
-            window.addEventListener('click', _converse.onUserActivity);
-            window.addEventListener('focus', _converse.onUserActivity);
-            window.addEventListener('keypress', _converse.onUserActivity);
-            window.addEventListener('mousemove', _converse.onUserActivity);
-            window.addEventListener(unloadevent, _converse.onUserActivity, {'once': true, 'passive': true});
-            window.addEventListener(unloadevent, () => _converse.session?.save('active', false));
-            _converse.everySecondTrigger = window.setInterval(_converse.onEverySecond, 1000);
-        };
-
-
-        api.listen.on('presencesInitialized', (reconnecting) => {
-            if (!reconnecting) {
-                _converse.registerIntervalHandler();
-            }
-        });
-
-
-        function onStatusInitialized (reconnecting) {
-            /**
-             * Triggered when the user's own chat status has been initialized.
-             * @event _converse#statusInitialized
-             * @example _converse.api.listen.on('statusInitialized', status => { ... });
-             * @example _converse.api.waitUntil('statusInitialized').then(() => { ... });
-             */
-            api.trigger('statusInitialized', reconnecting);
-        }
-
-
-        function initStatus (reconnecting) {
-            // If there's no xmppstatus obj, then we were never connected to
-            // begin with, so we set reconnecting to false.
-            reconnecting = _converse.xmppstatus === undefined ? false : reconnecting;
-            if (reconnecting) {
-                onStatusInitialized(reconnecting);
-            } else {
-                const id = `converse.xmppstatus-${_converse.bare_jid}`;
-                _converse.xmppstatus = new _converse.XMPPStatus({ id });
-                initStorage(_converse.xmppstatus, id, 'session');
-                _converse.xmppstatus.fetch({
-                    'success': () => onStatusInitialized(reconnecting),
-                    'error': () => onStatusInitialized(reconnecting),
-                    'silent': true
-                });
-            }
-        }
-
-
-        /************************ BEGIN Event Handlers ************************/
-        api.listen.on('clearSession', () => {
-            if (_converse.shouldClearCache() && _converse.xmppstatus) {
-                _converse.xmppstatus.destroy();
-                delete _converse.xmppstatus;
-                api.promises.add(['statusInitialized']);
-            }
-        });
-
-        api.listen.on('connected', () => initStatus(false));
-        api.listen.on('reconnected', () => initStatus(true));
-        /************************ END Event Handlers ************************/
-
-
-        /************************ BEGIN API ************************/
-        Object.assign(_converse.api.user, {
-            /**
-             * @namespace _converse.api.user.presence
-             * @memberOf _converse.api.user
-             */
-            presence: {
-                /**
-                 * Send out a presence stanza
-                 * @method _converse.api.user.presence.send
-                 * @param { String } type
-                 * @param { String } to
-                 * @param { String } [status] - An optional status message
-                 * @param { Element[]|Strophe.Builder[]|Element|Strophe.Builder } [child_nodes]
-                 *  Nodes(s) to be added as child nodes of the `presence` XML element.
-                 */
-                async send (type, to, status, child_nodes) {
-                    await api.waitUntil('statusInitialized');
-                    const presence = _converse.xmppstatus.constructPresence(type, to, status);
-                    if (child_nodes) {
-                        if (!Array.isArray(child_nodes)) {
-                            child_nodes = [child_nodes];
-                        }
-                        child_nodes.map(c => c?.tree() ?? c).forEach(c => presence.cnode(c).up());
-                    }
-                    api.send(presence);
-                }
-            },
-
-            /**
-             * Set and get the user's chat status, also called their *availability*.
-             * @namespace _converse.api.user.status
-             * @memberOf _converse.api.user
-             */
-            status: {
-                /**
-                 * Return the current user's availability status.
-                 * @async
-                 * @method _converse.api.user.status.get
-                 * @example _converse.api.user.status.get();
-                 */
-                async get () {
-                    await api.waitUntil('statusInitialized');
-                    return _converse.xmppstatus.get('status');
-                },
-
-                /**
-                 * The user's status can be set to one of the following values:
-                 *
-                 * @async
-                 * @method _converse.api.user.status.set
-                 * @param {string} value The user's chat status (e.g. 'away', 'dnd', 'offline', 'online', 'unavailable' or 'xa')
-                 * @param {string} [message] A custom status message
-                 *
-                 * @example _converse.api.user.status.set('dnd');
-                 * @example _converse.api.user.status.set('dnd', 'In a meeting');
-                 */
-                async set (value, message) {
-                    const data = {'status': value};
-                    if (!Object.keys(_converse.STATUS_WEIGHTS).includes(value)) {
-                        throw new Error(
-                            'Invalid availability value. See https://xmpp.org/rfcs/rfc3921.html#rfc.section.2.2.2.1'
-                        );
-                    }
-                    if (typeof message === 'string') {
-                        data.status_message = message;
-                    }
-                    await api.waitUntil('statusInitialized');
-                    _converse.xmppstatus.save(data);
-                },
-
-                /**
-                 * Set and retrieve the user's custom status message.
-                 *
-                 * @namespace _converse.api.user.status.message
-                 * @memberOf _converse.api.user.status
-                 */
-                message: {
-                    /**
-                     * @async
-                     * @method _converse.api.user.status.message.get
-                     * @returns {string} The status message
-                     * @example const message = _converse.api.user.status.message.get()
-                     */
-                    async get () {
-                        await api.waitUntil('statusInitialized');
-                        return _converse.xmppstatus.get('status_message');
-                    },
-                    /**
-                     * @async
-                     * @method _converse.api.user.status.message.set
-                     * @param {string} status The status message
-                     * @example _converse.api.user.status.message.set('In a meeting');
-                     */
-                    async set (status) {
-                        await api.waitUntil('statusInitialized');
-                        _converse.xmppstatus.save({ status_message: status });
-                    }
-                }
-            }
-        });
-    }
-});

+ 103 - 0
src/headless/plugins/status/api.js

@@ -0,0 +1,103 @@
+import { _converse, api } from '@converse/headless/core';
+
+
+export default {
+    /**
+     * @namespace _converse.api.user.presence
+     * @memberOf _converse.api.user
+     */
+    presence: {
+        /**
+         * Send out a presence stanza
+         * @method _converse.api.user.presence.send
+         * @param { String } type
+         * @param { String } to
+         * @param { String } [status] - An optional status message
+         * @param { Element[]|Strophe.Builder[]|Element|Strophe.Builder } [child_nodes]
+         *  Nodes(s) to be added as child nodes of the `presence` XML element.
+         */
+        async send (type, to, status, child_nodes) {
+            await api.waitUntil('statusInitialized');
+            const presence = await _converse.xmppstatus.constructPresence(type, to, status);
+            if (child_nodes) {
+                if (!Array.isArray(child_nodes)) {
+                    child_nodes = [child_nodes];
+                }
+                child_nodes.map(c => c?.tree() ?? c).forEach(c => presence.cnode(c).up());
+            }
+            api.send(presence);
+        }
+    },
+
+    /**
+     * Set and get the user's chat status, also called their *availability*.
+     * @namespace _converse.api.user.status
+     * @memberOf _converse.api.user
+     */
+    status: {
+        /**
+         * Return the current user's availability status.
+         * @async
+         * @method _converse.api.user.status.get
+         * @example _converse.api.user.status.get();
+         */
+        async get () {
+            await api.waitUntil('statusInitialized');
+            return _converse.xmppstatus.get('status');
+        },
+
+        /**
+         * The user's status can be set to one of the following values:
+         *
+         * @async
+         * @method _converse.api.user.status.set
+         * @param {string} value The user's chat status (e.g. 'away', 'dnd', 'offline', 'online', 'unavailable' or 'xa')
+         * @param {string} [message] A custom status message
+         *
+         * @example _converse.api.user.status.set('dnd');
+         * @example _converse.api.user.status.set('dnd', 'In a meeting');
+         */
+        async set (value, message) {
+            const data = {'status': value};
+            if (!Object.keys(_converse.STATUS_WEIGHTS).includes(value)) {
+                throw new Error(
+                    'Invalid availability value. See https://xmpp.org/rfcs/rfc3921.html#rfc.section.2.2.2.1'
+                );
+            }
+            if (typeof message === 'string') {
+                data.status_message = message;
+            }
+            await api.waitUntil('statusInitialized');
+            _converse.xmppstatus.save(data);
+        },
+
+        /**
+         * Set and retrieve the user's custom status message.
+         *
+         * @namespace _converse.api.user.status.message
+         * @memberOf _converse.api.user.status
+         */
+        message: {
+            /**
+             * @async
+             * @method _converse.api.user.status.message.get
+             * @returns {string} The status message
+             * @example const message = _converse.api.user.status.message.get()
+             */
+            async get () {
+                await api.waitUntil('statusInitialized');
+                return _converse.xmppstatus.get('status_message');
+            },
+            /**
+             * @async
+             * @method _converse.api.user.status.message.set
+             * @param {string} status The status message
+             * @example _converse.api.user.status.message.set('In a meeting');
+             */
+            async set (status) {
+                await api.waitUntil('statusInitialized');
+                _converse.xmppstatus.save({ status_message: status });
+            }
+        }
+    }
+}

+ 49 - 0
src/headless/plugins/status/index.js

@@ -0,0 +1,49 @@
+/**
+ * @copyright The Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import XMPPStatus from './status.js';
+import status_api from './api.js';
+import { _converse, api, converse } from '@converse/headless/core';
+import { initStatus, onEverySecond, onUserActivity, registerIntervalHandler, sendCSI } from './utils.js';
+
+
+converse.plugins.add('converse-status', {
+
+    initialize () {
+
+        api.settings.extend({
+            auto_away: 0, // Seconds after which user status is set to 'away'
+            auto_xa: 0, // Seconds after which user status is set to 'xa'
+            csi_waiting_time: 0, // Support for XEP-0352. Seconds before client is considered idle and CSI is sent out.
+            default_state: 'online',
+            priority: 0,
+        });
+        api.promises.add(['statusInitialized']);
+
+        _converse.XMPPStatus = XMPPStatus;
+        _converse.onUserActivity = onUserActivity;
+        _converse.onEverySecond = onEverySecond;
+        _converse.sendCSI = sendCSI;
+        _converse.registerIntervalHandler = registerIntervalHandler;
+
+        Object.assign(_converse.api.user, status_api);
+
+        api.listen.on('presencesInitialized', (reconnecting) => {
+            if (!reconnecting) {
+                _converse.registerIntervalHandler();
+            }
+        });
+
+        api.listen.on('clearSession', () => {
+            if (_converse.shouldClearCache() && _converse.xmppstatus) {
+                _converse.xmppstatus.destroy();
+                delete _converse.xmppstatus;
+                api.promises.add(['statusInitialized']);
+            }
+        });
+
+        api.listen.on('connected', () => initStatus(false));
+        api.listen.on('reconnected', () => initStatus(true));
+    }
+});

+ 72 - 0
src/headless/plugins/status/status.js

@@ -0,0 +1,72 @@
+import isNaN from 'lodash-es/isNaN';
+import isObject from 'lodash-es/isObject';
+import { Model } from '@converse/skeletor/src/model.js';
+import { _converse, api, converse } from '@converse/headless/core';
+
+const { Strophe, $pres } = converse.env;
+
+const XMPPStatus = Model.extend({
+    defaults () {
+        return { "status":  api.settings.get("default_state") }
+    },
+
+    initialize () {
+        this.on('change', item => {
+            if (!isObject(item.changed)) {
+                return;
+            }
+            if ('status' in item.changed || 'status_message' in item.changed) {
+                api.user.presence.send(this.get('status'), null, this.get('status_message'));
+            }
+        });
+    },
+
+    getNickname () {
+        return _converse.nickname;
+    },
+
+    getFullname () {
+        // Gets overridden in converse-vcard
+        return '';
+    },
+
+    async constructPresence (type, to=null, status_message) {
+        type = typeof type === 'string' ? type : (this.get('status') || api.settings.get("default_state"));
+        status_message = typeof status_message === 'string' ? status_message : this.get('status_message');
+        let presence;
+        const attrs = {to};
+        if ((type === 'unavailable') ||
+                (type === 'probe') ||
+                (type === 'error') ||
+                (type === 'unsubscribe') ||
+                (type === 'unsubscribed') ||
+                (type === 'subscribe') ||
+                (type === 'subscribed')) {
+            attrs['type'] = type;
+            presence = $pres(attrs);
+        } else if (type === 'offline') {
+            attrs['type'] = 'unavailable';
+            presence = $pres(attrs);
+        } else if (type === 'online') {
+            presence = $pres(attrs);
+        } else {
+            presence = $pres(attrs).c('show').t(type).up();
+        }
+
+        if (status_message) {
+            presence.c('status').t(status_message).up();
+        }
+
+        const priority = api.settings.get("priority");
+        presence.c('priority').t(isNaN(Number(priority)) ? 0 : priority).up();
+        if (_converse.idle) {
+            const idle_since = new Date();
+            idle_since.setSeconds(idle_since.getSeconds() - _converse.idle_seconds);
+            presence.c('idle', {xmlns: Strophe.NS.IDLE, since: idle_since.toISOString()});
+        }
+        presence = await _converse.api.hook('constructedPresence', presence);
+        return presence;
+    }
+});
+
+export default XMPPStatus;

+ 0 - 0
spec/xmppstatus.js → src/headless/plugins/status/tests/status.js


+ 128 - 0
src/headless/plugins/status/utils.js

@@ -0,0 +1,128 @@
+import { _converse, api, converse } from '@converse/headless/core';
+import { initStorage } from '@converse/headless/shared/utils.js';
+
+const { Strophe, $build } = converse.env;
+
+function onStatusInitialized (reconnecting) {
+    /**
+     * Triggered when the user's own chat status has been initialized.
+     * @event _converse#statusInitialized
+     * @example _converse.api.listen.on('statusInitialized', status => { ... });
+     * @example _converse.api.waitUntil('statusInitialized').then(() => { ... });
+     */
+    api.trigger('statusInitialized', reconnecting);
+}
+
+export function initStatus (reconnecting) {
+    // If there's no xmppstatus obj, then we were never connected to
+    // begin with, so we set reconnecting to false.
+    reconnecting = _converse.xmppstatus === undefined ? false : reconnecting;
+    if (reconnecting) {
+        onStatusInitialized(reconnecting);
+    } else {
+        const id = `converse.xmppstatus-${_converse.bare_jid}`;
+        _converse.xmppstatus = new _converse.XMPPStatus({ id });
+        initStorage(_converse.xmppstatus, id, 'session');
+        _converse.xmppstatus.fetch({
+            'success': () => onStatusInitialized(reconnecting),
+            'error': () => onStatusInitialized(reconnecting),
+            'silent': true
+        });
+    }
+}
+
+export function onUserActivity () {
+    /* Resets counters and flags relating to CSI and auto_away/auto_xa */
+    if (_converse.idle_seconds > 0) {
+        _converse.idle_seconds = 0;
+    }
+    if (!_converse.connection?.authenticated) {
+        // We can't send out any stanzas when there's no authenticated connection.
+        // This can happen when the connection reconnects.
+        return;
+    }
+    if (_converse.inactive) {
+        _converse.sendCSI(_converse.ACTIVE);
+    }
+    if (_converse.idle) {
+        _converse.idle = false;
+        api.user.presence.send();
+    }
+    if (_converse.auto_changed_status === true) {
+        _converse.auto_changed_status = false;
+        // XXX: we should really remember the original state here, and
+        // then set it back to that...
+        _converse.xmppstatus.set('status', api.settings.get("default_state"));
+    }
+}
+
+export function onEverySecond () {
+    /* An interval handler running every second.
+     * Used for CSI and the auto_away and auto_xa features.
+     */
+    if (!_converse.connection?.authenticated) {
+        // We can't send out any stanzas when there's no authenticated connection.
+        // This can happen when the connection reconnects.
+        return;
+    }
+    const stat = _converse.xmppstatus.get('status');
+    _converse.idle_seconds++;
+    if (api.settings.get("csi_waiting_time") > 0 &&
+            _converse.idle_seconds > api.settings.get("csi_waiting_time") &&
+            !_converse.inactive) {
+        _converse.sendCSI(_converse.INACTIVE);
+    }
+    if (api.settings.get("idle_presence_timeout") > 0 &&
+            _converse.idle_seconds > api.settings.get("idle_presence_timeout") &&
+            !_converse.idle) {
+        _converse.idle = true;
+        api.user.presence.send();
+    }
+    if (api.settings.get("auto_away") > 0 &&
+            _converse.idle_seconds > api.settings.get("auto_away") &&
+            stat !== 'away' && stat !== 'xa' && stat !== 'dnd') {
+        _converse.auto_changed_status = true;
+        _converse.xmppstatus.set('status', 'away');
+    } else if (api.settings.get("auto_xa") > 0 &&
+            _converse.idle_seconds > api.settings.get("auto_xa") &&
+            stat !== 'xa' && stat !== 'dnd') {
+        _converse.auto_changed_status = true;
+        _converse.xmppstatus.set('status', 'xa');
+    }
+}
+
+/**
+ * Send out a Client State Indication (XEP-0352)
+ * @function sendCSI
+ * @param { String } stat - The user's chat status
+ */
+export function sendCSI (stat) {
+    api.send($build(stat, {xmlns: Strophe.NS.CSI}));
+    _converse.inactive = (stat === _converse.INACTIVE) ? true : false;
+}
+
+export function registerIntervalHandler () {
+    /* Set an interval of one second and register a handler for it.
+     * Required for the auto_away, auto_xa and csi_waiting_time features.
+     */
+    if (
+        api.settings.get("auto_away") < 1 &&
+        api.settings.get("auto_xa") < 1 &&
+        api.settings.get("csi_waiting_time") < 1 &&
+        api.settings.get("idle_presence_timeout") < 1
+    ) {
+        // Waiting time of less then one second means features aren't used.
+        return;
+    }
+    _converse.idle_seconds = 0;
+    _converse.auto_changed_status = false; // Was the user's status changed by Converse?
+
+    const { unloadevent } = _converse;
+    window.addEventListener('click', _converse.onUserActivity);
+    window.addEventListener('focus', _converse.onUserActivity);
+    window.addEventListener('keypress', _converse.onUserActivity);
+    window.addEventListener('mousemove', _converse.onUserActivity);
+    window.addEventListener(unloadevent, _converse.onUserActivity, {'once': true, 'passive': true});
+    window.addEventListener(unloadevent, () => _converse.session?.save('active', false));
+    _converse.everySecondTrigger = window.setInterval(_converse.onEverySecond, 1000);
+}

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

@@ -6,10 +6,10 @@ import tpl_muc_head from './templates/muc-head.js';
 import { Model } from '@converse/skeletor/src/model.js';
 import { __ } from 'i18n';
 import { _converse, api, converse } from "@converse/headless/core";
+import { showModeratorToolsModal } from './utils.js';
 import {
     getHeadingDropdownItem,
     getHeadingStandaloneButton,
-    showModeratorToolsModal
 } from 'plugins/chatview/utils.js';
 
 import './styles/muc-head.scss';

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

@@ -241,7 +241,7 @@ function verifyAndSetAffiliation (muc, command, args, required_affiliations) {
 }
 
 
-function showModeratorToolsModal (muc, affiliation) {
+export function showModeratorToolsModal (muc, affiliation) {
     if (!muc.verifyRoles(['moderator'])) {
         return;
     }